> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ownid.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Actions Authentication

> Verify an OwnID access-token (JWT) and authorize custom actions on your server

A custom authentication flow can return an OwnID access-token (JWT) to your application in the `accessToken` field. Before performing a sensitive action, verify the OwnID access-token on your server, confirm that it was issued for your OwnID application, and check that it contains the authentication claim your action requires.

The following TypeScript example uses [`jose`](https://github.com/panva/jose) to verify the RS256 signature on the OwnID access-token with OwnID's JSON Web Key Set (JWKS). It also validates standard token claims, enforces one-time use, checks the completed authentication methods, and extracts the user's login identifier.

For a single copyable implementation, jump to the [complete example](#complete-example).

Install `jose` before using the example:

```bash theme={null}
npm install jose
```

## Configure the issuer and signing keys

Set your OwnID application ID and environment. The issuer identifies the OwnID application that is allowed to mint an OwnID access-token for your server. Use the same value as the expected audience so that a valid token intended for another application is rejected.

```typescript theme={null}
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose";

const OWNID_APPID = "<< YOUR APP ID >>";
const OWNID_ENV = "<< YOUR ENV >>" as "prod" | "uat";
const OWNID_ISSUER = `${OWNID_APPID}.server${OWNID_ENV === "uat" ? ".uat" : ""}.ownid.com`;
const OWNID_JWKS_URL = new URL(`https://${OWNID_ISSUER}/oidc/jwks`);

const ownIdJwks = createRemoteJWKSet(OWNID_JWKS_URL);
```

`createRemoteJWKSet` selects the public key whose `kid` matches the OwnID access-token header. The library caches fetched keys and refreshes the JWKS when it encounters an unknown key ID, allowing normal key rotation without hard-coding public keys.

## Model the authentication requirements

The `authorization_details` claim records which authentication or verification methods the user completed. `RequireAuth` lets each custom action require one method, at least one method from a set, or every method in a set.

```typescript theme={null}
type AuthType =
  | "PasskeyAuth"
  | "EmailVerification"
  | "PhoneNumberVerification"
  | "IdDocVerification"
  | "SessionCreation";

type RequireAuth =
  | AuthType
  | { oneOf: AuthType[] }
  | { allOf: AuthType[] };

interface AuthClaim {
  type: AuthType;
  [key: string]: unknown;
}

interface OwnIdAccessTokenPayload extends JWTPayload {
  authorization_details?: AuthClaim[];
}
```

Treat these values as authorization evidence only after verifying the OwnID access-token (JWT). Reading claims from an unverified token does not prove that OwnID produced them.

## Prevent OwnID access-token replay

The JWT ID (`jti`) uniquely identifies an OwnID access-token. Store consumed IDs until their tokens expire so that a captured token cannot authorize the same action again.

```typescript theme={null}
async function consumeJti(jti: string, expiresAt: number): Promise<boolean> {
  // Atomically store the ID only if it does not exist, with a TTL ending at
  // expiresAt (epoch seconds). Return true only when this request stores it.
  throw new Error("consumeJti: not implemented");
}
```

<Warning>
  The placeholder above must be backed by shared, durable storage in production. Claiming a `jti` should be atomic—for example, with Redis `SET NX` and an expiry or a database uniqueness constraint—so two concurrent requests cannot both consume the same token. Never use process-local memory when your service runs more than one instance.
</Warning>

## Verify and consume the OwnID access-token

`jwtVerify` verifies the RS256 signature and validates `exp`, `iss`, and `aud`. The function rejects malformed, expired, incorrectly issued, or incorrectly targeted tokens. It then requires both `jti` and `exp`, evaluates the action's authentication requirement, and atomically records the OwnID access-token as consumed. A token whose ID was already stored is rejected.

```typescript theme={null}
async function tryConsumeAccessToken(accessToken: string, requiredAuth?: RequireAuth) {
  let payload: OwnIdAccessTokenPayload;

  try {
    const result = await jwtVerify(accessToken, ownIdJwks, {
      issuer: OWNID_ISSUER,
      audience: OWNID_ISSUER,
    });
    payload = result.payload as OwnIdAccessTokenPayload;
  } catch {
    return null;
  }

  const jti = payload.jti;
  const exp = payload.exp;
  if (!jti || !exp) return null;

  if (!hasAuthClaims(payload, requiredAuth)) return null;
  if (!(await consumeJti(jti, exp))) return null;
  return { loginId: getLoginId(payload.sub), ...payload };
}
```

A `null` result is intentionally nonspecific. Return a generic unauthorized response to the client rather than exposing whether a signature, claim, or replay check failed. Log suitable diagnostic details only in your protected server logs.

## Check the authorization details

Convert the claim array to a set and evaluate the requested policy:

```typescript theme={null}
function hasAuthClaims(
  { authorization_details }: OwnIdAccessTokenPayload,
  requiredAuth?: RequireAuth,
) {
  if (!requiredAuth) return true;

  const authz = new Set<AuthType>(authorization_details?.map((claim) => claim.type));

  if (typeof requiredAuth === "string") {
    return authz.has(requiredAuth);
  }
  if ("oneOf" in requiredAuth) {
    return requiredAuth.oneOf.some((claim) => authz.has(claim));
  }
  if ("allOf" in requiredAuth) {
    return requiredAuth.allOf.every((claim) => authz.has(claim));
  }

  throw new Error(`Invalid requiredAuth: ${JSON.stringify(requiredAuth)}`);
}
```

* A string requires that exact method.
* `oneOf` succeeds when the OwnID access-token contains at least one listed method.
* `allOf` succeeds only when the OwnID access-token contains every listed method.
* Omitting `requiredAuth` accepts any otherwise valid token. For sensitive actions, pass an explicit requirement.

## Extract the login identifier

OwnID formats the subject (`sub`) as `<IdentifierType>:<value>`. Split it only once conceptually: a missing type, missing value, or extra colon makes the value invalid.

```typescript theme={null}
type LoginIdType = "Email" | "PhoneNumber" | "UserName";

function getLoginId(sub?: string | `${LoginIdType}:${string}`) {
  const [type, id, ...rest] = sub?.split(":") ?? [];
  if (!type || !id || rest.length) return null;

  return { type, id } as { type: LoginIdType; id: string };
}
```

Check the returned identifier type before using its value. For example, an action that updates an email-based account should reject a phone number or username subject.

## Authorize a custom action

Call the verifier in the server endpoint that performs the action. Read the `accessToken` value from the custom-action request, then verify and consume that OwnID access-token before applying the action. JWKS resolution can require a network request, so always await it.

```typescript theme={null}
const payload = await tryConsumeAccessToken(accessToken, {
  oneOf: ["PasskeyAuth", "PhoneNumberVerification"],
});

if (!payload) {
  // Return an unauthorized response.
  return;
}

if (payload.loginId?.type !== "Email") {
  // Reject identifier types that this action does not support.
  return;
}

await updateUser(payload.loginId.id).withCustomAction();
```

Only execute the custom action after every check succeeds. Adapt the required authentication methods and accepted identifier types to the risk and data model of each action.

## Complete example

The full implementation below combines the sections above into one file:

```typescript theme={null}
/**
 * OwnID access-token (JWT) verification and claim extraction.
 *
 * Verifies an OwnID access-token issued by a single OwnID app and extracts the
 * identity claim. Replace the storage placeholders and example application
 * function before using this in production.
 */

import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";

const OWNID_APPID = "<< YOUR APP ID >>";
const OWNID_ENV = "<< YOUR ENV >>" as "prod" | "uat";
const OWNID_ISSUER = `${OWNID_APPID}.server${OWNID_ENV === "uat" ? ".uat" : ""}.ownid.com`;
const OWNID_JWKS_URL = new URL(`https://${OWNID_ISSUER}/oidc/jwks`);

// jose caches keys internally and fetches the JWKS again for an unknown kid.
const ownIdJwks = createRemoteJWKSet(OWNID_JWKS_URL);

type AuthType =
  | "PasskeyAuth"
  | "EmailVerification"
  | "PhoneNumberVerification"
  | "IdDocVerification"
  | "SessionCreation";

type RequireAuth =
  | AuthType
  | { oneOf: AuthType[] }
  | { allOf: AuthType[] };

interface AuthClaim {
  type: AuthType;
  [key: string]: unknown;
}

interface OwnIdAccessTokenPayload extends JWTPayload {
  authorization_details?: AuthClaim[];
}

async function consumeJti(jti: string, expiresAt: number): Promise<boolean> {
  // TODO: Atomically store the ID only if it does not exist, with an expiry.
  // Return true only when this request stores it. For example, use Redis SET
  // with NX and EXAT, or insert into a database column with a unique constraint.
  throw new Error("consumeJti: not implemented");
}

async function tryConsumeAccessToken(accessToken: string, requiredAuth?: RequireAuth) {
  let payload: OwnIdAccessTokenPayload;

  try {
    const result = await jwtVerify(accessToken, ownIdJwks, {
      issuer: OWNID_ISSUER,
      audience: OWNID_ISSUER,
    });
    payload = result.payload as OwnIdAccessTokenPayload;
  } catch {
    return null;
  }

  const jti = payload.jti;
  const exp = payload.exp;
  if (!jti || !exp) return null;

  if (!hasAuthClaims(payload, requiredAuth)) return null;
  if (!(await consumeJti(jti, exp))) return null;
  return { loginId: getLoginId(payload.sub), ...payload };
}

function hasAuthClaims(
  { authorization_details }: OwnIdAccessTokenPayload,
  requiredAuth?: RequireAuth,
) {
  if (!requiredAuth) return true;

  const authz = new Set<AuthType>(authorization_details?.map((claim) => claim.type));

  if (typeof requiredAuth === "string") {
    return authz.has(requiredAuth);
  }
  if ("oneOf" in requiredAuth) {
    return requiredAuth.oneOf.some((claim) => authz.has(claim));
  }
  if ("allOf" in requiredAuth) {
    return requiredAuth.allOf.every((claim) => authz.has(claim));
  }

  throw new Error(`Invalid requiredAuth: ${JSON.stringify(requiredAuth)}`);
}

type LoginIdType = "Email" | "PhoneNumber" | "UserName";

function getLoginId(sub?: string | `${LoginIdType}:${string}`) {
  const [type, id, ...rest] = sub?.split(":") ?? [];
  if (!type || !id || rest.length) return null;

  return { type, id } as { type: LoginIdType; id: string };
}

// Replace this declaration with your application's user update function.
declare function updateUser(loginId: string): {
  withCustomAction(): Promise<void>;
};

async function usageExample(accessToken: string) {
  const payload = await tryConsumeAccessToken(accessToken, {
    oneOf: ["PasskeyAuth", "PhoneNumberVerification"],
  });

  if (!payload) return;
  if (payload.loginId?.type !== "Email") {
    throw new Error("Unsupported loginId type");
  }

  // Replace this with your application's custom action.
  await updateUser(payload.loginId.id).withCustomAction();
}
```
