Skip to content

OIDC Support ​

@saurbit/oauth2 provides OpenID Connect (OIDC) extensions for each OAuth 2.0 flow. Each OIDC variant wraps the corresponding base flow with OIDC-specific behaviour: discovery document generation, ID token enforcement, openIdConnect security scheme, and optional UserInfo support.

What OIDC Adds ​

Every OIDC flow builder and flow instance adds the following on top of the base OAuth 2.0 versions:

FeatureDescription
Discovery URLA /.well-known/openid-configuration endpoint URL is configured and exposed.
JWKS EndpointA JWKS endpoint URL for publishing the provider's public signing keys.
getDiscoveryConfiguration()Generates a full OpenID Connect discovery document from the flow's configuration.
toOpenAPISecurityScheme()Returns an openIdConnect scheme type (instead of oauth2) referencing the discovery URL.
openid scope enforcementThe openid scope is automatically included in getScopes() and required at the authorization/token endpoints.
ID token requirementgenerateAccessToken must return an idToken field. The flow rejects token responses without one.
Static config overridessetOpenIdConfiguration() lets you merge custom fields into the discovery document.
UserInfo supportOptional getUserInfo() model callback for the UserInfo endpoint (authorization code & device flows).

Shared OIDC Builder Methods ​

These methods are available on OIDCAuthorizationCodeFlowBuilder, OIDCDeviceAuthorizationFlowBuilder, and OIDCClientCredentialsFlowBuilder, in addition to all common builder methods.

setDiscoveryUrl(url) ​

ts
.setDiscoveryUrl(url: string): this

Sets the OpenID Connect discovery document URL. Defaults to "/.well-known/openid-configuration".

setJwksEndpoint(url) ​

ts
.setJwksEndpoint(url: string): this

Sets the JWKS endpoint URL for publishing public signing keys. Defaults to "/.well-known/jwks.json". Can be an absolute URL or a relative path resolved against the discovery URL's origin.

setOpenIdConfiguration(config) ​

ts
.setOpenIdConfiguration(config: Record<string, string | string[] | undefined>): this

Sets static overrides merged into the discovery document. These take precedence over values derived from the flow's configuration.

ts
.setOpenIdConfiguration({
  claims_supported: ["sub", "email", "name"],
  subject_types_supported: ["public", "pairwise"],
})

Shared OIDC Flow Methods ​

These methods are available on every OIDC flow instance, in addition to all common flow methods.

getDiscoveryUrl() ​

ts
getDiscoveryUrl(): string

Returns the configured discovery document URL.

getJwksEndpoint() ​

ts
getJwksEndpoint(): string | undefined

Returns the configured JWKS endpoint URL.

getDiscoveryConfiguration(req?) ​

ts
getDiscoveryConfiguration(req?: Request): Record<string, string | string[] | undefined>

Generates the full OpenID Connect discovery document. Relative endpoint URLs are resolved against the request's origin (or the discovery URL's origin if no request is provided).

The returned document includes standard fields such as issuer, token_endpoint, jwks_uri, grant_types_supported, scopes_supported, response_types_supported, token_endpoint_auth_methods_supported, and more.

ts
app.get("/.well-known/openid-configuration", (req) => {
  return new Response(
    JSON.stringify(flow.getDiscoveryConfiguration(req)),
    { headers: { "Content-Type": "application/json" } },
  );
});

toOpenAPISecurityScheme() ​

All OIDC flows return an openIdConnect scheme type referencing the discovery URL, instead of the oauth2 scheme type used by base flows:

ts
// Returns e.g. { "myOIDC": { type: "openIdConnect", openIdConnectUrl: "/.well-known/openid-configuration" } }
flow.toOpenAPISecurityScheme();

OIDC Authorization Code ​

OIDCAuthorizationCodeFlowBuilder ​

Extends the base AuthorizationCodeFlowBuilder with OIDC-specific configuration.

Additional builder methods ​

In addition to the shared OIDC builder methods and all base authorization code builder methods:

setUserInfoEndpoint(url) ​
ts
.setUserInfoEndpoint(url: string): this

Sets the UserInfo endpoint URL (e.g. /userinfo). Included in the discovery document.

setRegistrationEndpoint(url) ​
ts
.setRegistrationEndpoint(url: string): this

Sets the dynamic client registration endpoint URL. Included in the discovery document.

Model callback differences ​

generateAccessToken(handler) ​

The result must include an idToken field:

ts
.generateAccessToken(async (context) => ({
  accessToken: await signJwt({ sub: userId, ... }),
  idToken: await signIdToken({ sub: userId, nonce, ... }),  // required
  refreshToken: "rt_abc123",
  scope: ["openid", "profile"],
}))
getClientForAuthentication(handler) ​

Receives an OIDCAuthorizationCodeEndpointRequest which extends the base request with OIDC-specific parameters:

ParameterTypeDescription
noncestring | undefinedValue for ID token replay protection.
display"page" | "popup" | "touch" | "wap" | undefinedHow to display the auth/consent UI.
prompt("none" | "login" | "consent" | "select_account")[]UI behaviour directives.
maxAgenumber | undefinedMax age of user authentication in seconds.
uiLocalesstring[] | undefinedPreferred UI languages (BCP47 tags).
idTokenHintstring | undefinedExisting ID token as a hint.
loginHintstring | undefinedHint for the user's identifier.
acrValuesstring[] | undefinedDesired authentication class references.

These same OIDC parameters are also available in the context passed to getUserForAuthentication and generateAuthorizationCode.

getUserInfo (model callback) ​

Optional. Implement to support the UserInfo endpoint:

ts
// On the model (set via the builder's model option or constructor)
getUserInfo: async (accessToken) => {
  const payload = await verifyJwt(accessToken);
  if (!payload) return undefined;
  const user = await db.findUserById(payload.sub);
  return { sub: user.id, email: user.email, name: user.name };
}

OIDCAuthorizationCodeFlow ​

Extends the base AuthorizationCodeFlow with:

  • openid scope enforcement — The openid scope is required in the authorization request. Requests without it are rejected.
  • ID token requirement — The token() method verifies that the token response includes an id_token. For refresh token grants, the ID token is optional per the OIDC spec.
  • OIDC request parameter parsing — The authorization endpoint parses nonce, prompt, display, max_age, ui_locales, id_token_hint, login_hint, and acr_values from the query string and passes them through the context.

Additional flow methods ​

getUserInfo(accessToken) ​
ts
async getUserInfo(accessToken: string): Promise<OIDCUserInfo | undefined>

Retrieves UserInfo claims by delegating to the model's getUserInfo callback. Returns undefined if not implemented.

ts
app.get("/userinfo", async (req) => {
  const verify = await flow.verifyToken(req);
  if (!verify.success) return new Response("Unauthorized", { status: 401 });

  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
  const userInfo = await flow.getUserInfo(token);
  return new Response(JSON.stringify(userInfo), {
    headers: { "Content-Type": "application/json" },
  });
});
getDiscoveryConfiguration(req?) ​

The discovery document includes authorization_endpoint, token_endpoint, jwks_uri, userinfo_endpoint, registration_endpoint, and grant_types_supported: ["authorization_code"].

Quick Start ​

ts
import { OIDCAuthorizationCodeFlowBuilder } from "@saurbit/oauth2";

const flow = new OIDCAuthorizationCodeFlowBuilder<LoginFormData>({
  tokenEndpoint: "/token",
})
  .setDiscoveryUrl("/.well-known/openid-configuration")
  .setJwksEndpoint("/.well-known/jwks.json")
  .setAuthorizationEndpoint("/authorize")
  .setUserInfoEndpoint("/userinfo")
  .setSecuritySchemeName("oidcAuth")
  .setScopes({
    "openid": "OpenID Connect",
    "profile": "User profile",
    "email": "Email address",
  })
  .addClientAuthenticationMethod("client_secret_basic")

  .getClientForAuthentication(async ({ clientId, redirectUri, nonce, prompt }) => {
    // nonce, prompt, and other OIDC params are available here
    return db.findClient(clientId, redirectUri);
  })
  .getUserForAuthentication(async (context, { username, password }) => {
    // context includes nonce, prompt, maxAge, etc.
    const user = await db.authenticateUser(username, password);
    if (!user) return { type: "unauthenticated" };
    return { type: "authenticated", user };
  })
  .generateAuthorizationCode(async (context, user) => {
    const code = crypto.randomUUID();
    await db.saveAuthorizationCode(code, {
      clientId: context.client.id,
      userId: user.id,
      nonce: context.nonce,  // persist for ID token
      scope: context.scope,
    });
    return { type: "code", code };
  })
  .getClient(async (request) => {
    // same as base authorization code flow
    return db.findAndValidateClient(request);
  })
  .generateAccessToken(async (context) => ({
    accessToken: await signJwt({ sub: userId, scope }),
    idToken: await signIdToken({ sub: userId, nonce: storedNonce }),  // required
    refreshToken: crypto.randomUUID(),
    scope: ["openid", "profile"],
  }))
  .verifyToken(async (token) => {
    const payload = await verifyJwt(token);
    if (!payload) return { isValid: false };
    return { isValid: true, credentials: { user: payload.sub, scope: payload.scope } };
  })
  .build();

OIDC Client Credentials ​

OIDCClientCredentialsFlowBuilder ​

Extends the base ClientCredentialsFlowBuilder with OIDC-specific configuration.

Note

Because Client Credentials is a machine-to-machine flow with no end-user involvement, this OIDC variant does not issue ID tokens, does not expose a UserInfo endpoint, and the none authentication method is disabled. The discovery document is provided for interoperability with OIDC-aware clients.

Additional builder methods ​

The shared OIDC builder methods (setDiscoveryUrl, setJwksEndpoint, setOpenIdConfiguration) are available. There are no additional OIDC-specific builder methods beyond those.

Model callback differences ​

generateAccessToken returns the same result shape as the base Client Credentials flow — no idToken is required.

OIDCClientCredentialsFlow ​

Extends the base ClientCredentialsFlow with:

  • Discovery document generation — getDiscoveryConfiguration() returns a standard OIDC discovery document with grant_types_supported: ["client_credentials"].
  • openIdConnect security scheme — toOpenAPISecurityScheme() returns an openIdConnect type instead of oauth2.

The discovery document fields userinfo_endpoint, registration_endpoint, and claims_supported are undefined by default since they are not relevant for this flow.

Quick Start ​

ts
import { OIDCClientCredentialsFlowBuilder } from "@saurbit/oauth2";

const flow = new OIDCClientCredentialsFlowBuilder({ tokenEndpoint: "/token" })
  .setDiscoveryUrl("/.well-known/openid-configuration")
  .setJwksEndpoint("/.well-known/jwks.json")
  .setSecuritySchemeName("oidcService")
  .setScopes({ "data:read": "Read data", "data:write": "Write data" })
  .addClientAuthenticationMethod("client_secret_basic")
  .getClient(async ({ clientId, clientSecret }) => {
    return db.findAndValidateClient(clientId, clientSecret);
  })
  .generateAccessToken(async (context) => ({
    accessToken: await signJwt({ sub: context.client.id, scope: context.scope }),
  }))
  .verifyToken(async (token) => {
    const payload = await verifyJwt(token);
    if (!payload) return { isValid: false };
    return { isValid: true, credentials: { app: { id: payload.sub }, scope: payload.scope } };
  })
  .build();

OIDC Device Authorization ​

OIDCDeviceAuthorizationFlowBuilder ​

Extends the base DeviceAuthorizationFlowBuilder with OIDC-specific configuration.

Additional builder methods ​

In addition to the shared OIDC builder methods and all base device authorization builder methods:

setUserInfoEndpoint(url) ​
ts
.setUserInfoEndpoint(url: string): this

Sets the UserInfo endpoint URL. Included in the discovery document.

setRegistrationEndpoint(url) ​
ts
.setRegistrationEndpoint(url: string): this

Sets the dynamic client registration endpoint URL. Included in the discovery document.

Model callback differences ​

generateAccessToken(handler) ​

The result must include an idToken field. May also return a DeviceAuthorizationAccessTokenError for polling states:

ts
.generateAccessToken(async (context) => {
  const status = await db.getDeviceCodeStatus(context.deviceCode);
  if (status === "pending") return { type: "error", error: "authorization_pending" };

  return {
    accessToken: await signJwt({ sub: userId }),
    idToken: await signIdToken({ sub: userId }),  // required
    refreshToken: crypto.randomUUID(),
    scope: ["openid", "profile"],
  };
})
getUserInfo (model callback) ​

Optional. Same interface as the authorization code variant — implement to support the UserInfo endpoint.

OIDCDeviceAuthorizationFlow ​

Extends the base DeviceAuthorizationFlow with:

  • openid scope enforcement — The openid scope is automatically included in getScopes().
  • ID token requirement — The token() method verifies that the token response includes an id_token. For refresh token grants, the ID token is optional per the OIDC spec.
  • UserInfo support — getUserInfo(accessToken) delegates to the model callback.

Additional flow methods ​

getUserInfo(accessToken) ​
ts
async getUserInfo(accessToken: string): Promise<OIDCUserInfo | undefined>

Same as the OIDC Authorization Code variant.

getDiscoveryConfiguration(req?) ​

The discovery document includes authorization_endpoint (the device authorization endpoint), token_endpoint, jwks_uri, userinfo_endpoint, registration_endpoint, and grant_types_supported: ["urn:ietf:params:oauth:grant-type:device_code"].

Quick Start ​

ts
import { OIDCDeviceAuthorizationFlowBuilder } from "@saurbit/oauth2";

const flow = new OIDCDeviceAuthorizationFlowBuilder({ tokenEndpoint: "/token" })
  .setDiscoveryUrl("/.well-known/openid-configuration")
  .setJwksEndpoint("/.well-known/jwks.json")
  .setAuthorizationEndpoint("/device/authorize")
  .setVerificationEndpoint("/device/verify")
  .setUserInfoEndpoint("/userinfo")
  .setSecuritySchemeName("oidcDevice")
  .setScopes({ "openid": "OpenID Connect", "profile": "User profile" })
  .addClientAuthenticationMethod("client_secret_basic")
  .addClientAuthenticationMethod("none")

  .getClientForAuthentication(async ({ clientId }) => {
    return db.findClient(clientId);
  })
  .generateDeviceCode(async (context) => ({
    deviceCode: crypto.randomUUID(),
    userCode: generateUserFriendlyCode(),
  }))
  .verifyUserCode(async (userCode) => {
    return db.findDeviceCodeByUserCode(userCode);
  })
  .getClient(async (request) => {
    return db.findAndValidateClient(request);
  })
  .generateAccessToken(async (context) => {
    const record = await db.findDeviceCode(context.deviceCode);
    if (!record?.authorizedBy) return { type: "error", error: "authorization_pending" };
    return {
      accessToken: await signJwt({ sub: record.authorizedBy }),
      idToken: await signIdToken({ sub: record.authorizedBy }),  // required
      scope: ["openid", "profile"],
    };
  })
  .verifyToken(async (token) => {
    const payload = await verifyJwt(token);
    if (!payload) return { isValid: false };
    return { isValid: true, credentials: { user: payload.sub, scope: payload.scope } };
  })
  .build();

OIDCMultipleFlows ​

OIDCMultipleFlows aggregates multiple OIDC flow instances into a single handler with a unified token endpoint, token verification, and discovery document.

Constructor ​

ts
new OIDCMultipleFlows({
  flows: OIDCFlow[],
  discoveryUrl: string,
  securitySchemeName: string,
  tokenEndpoint?: string,      // defaults to "/token"
  jwksEndpoint?: string,       // defaults to "/jwks"
  description?: string,
  openidConfiguration?: Record<string, string | string[] | undefined>,
})
ParameterTypeDescription
flowsOIDCFlow[]Ordered list of OIDC flows to delegate to.
discoveryUrlstringURL of the combined discovery document.
securitySchemeNamestringName of the OpenAPI security scheme entry.
tokenEndpointstringURL of the unified token endpoint. Defaults to /token.
jwksEndpointstringURL of the JWKS endpoint. Defaults to /jwks.
descriptionstringOptional description for the OpenAPI security scheme.
openidConfigurationRecord<string, string | string[] | undefined>Optional static overrides for the merged discovery document.

token(request) ​

ts
async token(request: Request): Promise<OAuth2FlowTokenResponse>

Tries each registered flow in order. The first flow that returns a successful result is used. If no flow succeeds, returns a combined error from all flows.

verifyToken(request) ​

ts
async verifyToken(request: Request): Promise<StrategyResult>

Tries each registered flow's verifyToken() in order. The first flow that successfully verifies the token is used.

getDiscoveryConfiguration(req?) ​

ts
getDiscoveryConfiguration(req?: Request): Record<string, string | string[] | undefined>

Merges the discovery configurations of all registered flows. Array-valued fields (e.g. grant_types_supported, scopes_supported) are merged and deduplicated. Static overrides from the constructor's openidConfiguration take precedence.

The merged document uses the OIDCMultipleFlows instance's tokenEndpoint and jwksEndpoint (not the individual flows'), ensuring a single unified token endpoint.

toOpenAPIPathItem(scopes?) ​

ts
toOpenAPIPathItem(scopes?: string[]): Record<string, string[]>

Returns an OpenAPI security requirement object using the aggregated security scheme name.

toOpenAPISecurityScheme() ​

ts
toOpenAPISecurityScheme(): Record<string, { type: "openIdConnect"; description?: string; openIdConnectUrl: string }>

Returns an openIdConnect security scheme definition pointing to the discovery URL.

getDiscoveryUrl() ​

ts
getDiscoveryUrl(): string

Returns the configured discovery document URL.

getSecuritySchemeName() ​

ts
getSecuritySchemeName(): string

Returns the configured security scheme name.

getTokenEndpoint() ​

ts
getTokenEndpoint(): string

Returns the configured token endpoint URL. Useful when you need to register endpoint routes dynamically.

getJwksEndpoint() ​

ts
getJwksEndpoint(): string

Returns the configured JWKS endpoint URL.

getDescription() ​

ts
getDescription(): string | undefined

Returns the optional description provided in the constructor.

Quick Start ​

ts
import {
  OIDCAuthorizationCodeFlowBuilder,
  OIDCClientCredentialsFlowBuilder,
  OIDCMultipleFlows,
} from "@saurbit/oauth2";

// Build individual OIDC flows
const authCodeFlow = new OIDCAuthorizationCodeFlowBuilder({ ... }).build();
const clientCredsFlow = new OIDCClientCredentialsFlowBuilder({ ... }).build();

// Combine them into a single handler
const oidc = new OIDCMultipleFlows({
  flows: [authCodeFlow, clientCredsFlow],
  discoveryUrl: "/.well-known/openid-configuration",
  securitySchemeName: "oidc",
  tokenEndpoint: "/oauth2/token",
  jwksEndpoint: "/.well-known/jwks.json",
  description: "OpenID Connect with Authorization Code and Client Credentials",
});

// Unified token endpoint — delegates to the matching flow
app.post(oidc.getTokenEndpoint(), async (req) => {
  const result = await oidc.token(req);
  if (result.success) return new Response(JSON.stringify(result.tokenResponse));
  return new Response(JSON.stringify({ error: result.error.errorCode }), { status: 400 });
});

// Unified discovery document — merges all flows
app.get("/.well-known/openid-configuration", (req) => {
  return new Response(JSON.stringify(oidc.getDiscoveryConfiguration(req)), {
    headers: { "Content-Type": "application/json" },
  });
});

// Unified token verification on protected routes
app.get("/api/protected", async (req) => {
  const result = await oidc.verifyToken(req);
  if (!result.success) return new Response("Unauthorized", { status: 401 });
  return new Response(JSON.stringify({ data: "secret" }));
});

// OpenAPI security scheme
const securitySchemes = oidc.toOpenAPISecurityScheme();
// { "oidc": { type: "openIdConnect", openIdConnectUrl: "/.well-known/openid-configuration" } }