diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index b81fdf76fe..ca152c0e34 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -1368,6 +1368,56 @@ oauthProvider({ ``` +### Pairwise Subject Identifiers + +By default, the `sub` (subject) claim in tokens uses the user's internal ID, which is the same across all clients. This is the **public** subject type per [OIDC Core Section 8](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes). + +You can enable **pairwise** subject identifiers so each client receives a unique, unlinkable `sub` for the same user. This prevents relying parties from correlating users across services. + +```ts title="auth.ts" +oauthProvider({ + pairwiseSecret: "your-256-bit-secret", // [!code highlight] +}) +``` + +When `pairwiseSecret` is configured, the server advertises both `"public"` and `"pairwise"` in the discovery endpoint's `subject_types_supported`. Clients opt in by setting `subject_type: "pairwise"` at registration. + +#### Per-Client Configuration + +```ts title="register-client.ts" +const response = await auth.api.createOAuthClient({ + headers, + body: { + client_name: 'Privacy-Sensitive App', + redirect_uris: ['https://app.example.com/callback'], + token_endpoint_auth_method: 'client_secret_post', + subject_type: 'pairwise', // Enable pairwise sub for this client + } +}); +``` + +#### How It Works + +Pairwise identifiers are computed using HMAC-SHA256 over the **sector identifier** (the host of the client's first redirect URI) and the user ID, keyed with `pairwiseSecret`. This means: + +- Two clients with different redirect URI hosts always receive different `sub` values for the same user +- Two clients sharing the same redirect URI host receive the **same** pairwise `sub` (per OIDC Core Section 8.1) +- The same client always receives the same `sub` for the same user (deterministic) + +Pairwise `sub` appears in: +- `id_token` +- `/oauth2/userinfo` response +- Token introspection (`/oauth2/introspect`) + +JWT access tokens always use the real user ID as `sub`, since resource servers may need to look up users directly. + + +**Limitations:** +- `sector_identifier_uri` is not yet supported. All `redirect_uris` for a pairwise client must share the same host. Clients with redirect URIs on different hosts will be rejected at registration. +- `pairwiseSecret` must be at least 32 characters long. +- Rotating `pairwiseSecret` will change all pairwise `sub` values, breaking existing RP sessions. Treat this secret as permanent once set. + + ### MCP You can easily make your APIs [MCP-compatible](https://modelcontextprotocol.io/specification/draft/basic/authorization) simply by adding a resource server which directs users to this OAuth 2.1 authorization server. @@ -1577,6 +1627,12 @@ Table Name: `oauthClient` description: "Field that indicates if the application can logout via an id_token. You may choose to enable this for trusted applications.", isOptional: true, }, + { + name: "subjectType", + type: "string", + description: "Subject identifier type for this client. Set to \"pairwise\" to receive unique, unlinkable sub claims per user. Requires pairwiseSecret to be configured on the server.", + isOptional: true, + }, { name: "scopes", type: "string[]", diff --git a/packages/oauth-provider/src/introspect.ts b/packages/oauth-provider/src/introspect.ts index 491904ff56..5683d33a40 100644 --- a/packages/oauth-provider/src/introspect.ts +++ b/packages/oauth-provider/src/introspect.ts @@ -18,6 +18,7 @@ import { getJwtPlugin, getStoredToken, parseClientMetadata, + resolveSubjectIdentifier, validateClientCredentials, } from "./utils"; @@ -371,6 +372,27 @@ export async function validateAccessToken( }); } +/** + * Resolves pairwise sub on an introspection payload. + * Applied at the presentation layer so internal validation functions + * keep real user.id (needed for user lookup in /userinfo). + */ +async function resolveIntrospectionSub( + opts: OAuthOptions, + payload: JWTPayload, + client: SchemaClient, +): Promise { + if (payload.active && payload.sub) { + const resolvedSub = await resolveSubjectIdentifier( + payload.sub as string, + client, + opts, + ); + return { ...payload, sub: resolvedSub }; + } + return payload; +} + export async function introspectEndpoint( ctx: GenericEndpointContext, opts: OAuthOptions, @@ -429,7 +451,7 @@ export async function introspectEndpoint( token, client.clientId, ); - return payload; + return resolveIntrospectionSub(opts, payload, client); } catch (error) { if (error instanceof APIError) { if (token_type_hint === "access_token") { @@ -452,7 +474,7 @@ export async function introspectEndpoint( refreshToken.token, client.clientId, ); - return payload; + return resolveIntrospectionSub(opts, payload, client); } catch (error) { if (error instanceof APIError) { if (token_type_hint === "refresh_token") { diff --git a/packages/oauth-provider/src/metadata.ts b/packages/oauth-provider/src/metadata.ts index 50d7ab4886..213dec4385 100644 --- a/packages/oauth-provider/src/metadata.ts +++ b/packages/oauth-provider/src/metadata.ts @@ -89,7 +89,9 @@ export function oidcServerMetadata( claims_supported: opts?.advertisedMetadata?.claims_supported ?? opts?.claims ?? [], userinfo_endpoint: `${baseURL}/oauth2/userinfo`, - subject_types_supported: ["public"], + subject_types_supported: opts.pairwiseSecret + ? ["public", "pairwise"] + : ["public"], id_token_signing_alg_values_supported: jwtPluginOptions?.jwks?.keyPairConfig ?.alg ? [jwtPluginOptions?.jwks?.keyPairConfig?.alg] diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index 92cb0f2bf5..005b2f9ff6 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -118,6 +118,13 @@ export const oauthProvider = >(options: O) => { clientRegistrationAllowedScopes, }; + // Validate pairwiseSecret minimum length + if (opts.pairwiseSecret && opts.pairwiseSecret.length < 32) { + throw new BetterAuthError( + "pairwiseSecret must be at least 32 characters long for adequate HMAC-SHA256 security", + ); + } + // TODO: device_code grant also allows for refresh tokens if ( opts.grantTypes && @@ -1131,6 +1138,7 @@ export const oauthProvider = >(options: O) => { .default(["code"]) .optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), + subject_type: z.enum(["public", "pairwise"]).optional(), }), metadata: { openapi: { diff --git a/packages/oauth-provider/src/oauthClient/index.ts b/packages/oauth-provider/src/oauthClient/index.ts index 1229c201fd..8cbcfb6332 100644 --- a/packages/oauth-provider/src/oauthClient/index.ts +++ b/packages/oauth-provider/src/oauthClient/index.ts @@ -57,6 +57,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions) => skip_consent: z.boolean().optional(), enable_end_session: z.boolean().optional(), require_pkce: z.boolean().optional(), + subject_type: z.enum(["public", "pairwise"]).optional(), metadata: z.record(z.string(), z.unknown()).optional(), }), metadata: { diff --git a/packages/oauth-provider/src/pairwise.test.ts b/packages/oauth-provider/src/pairwise.test.ts new file mode 100644 index 0000000000..fedae75724 --- /dev/null +++ b/packages/oauth-provider/src/pairwise.test.ts @@ -0,0 +1,569 @@ +import { createAuthClient } from "better-auth/client"; +import { generateRandomString } from "better-auth/crypto"; +import { + createAuthorizationCodeRequest, + createAuthorizationURL, +} from "better-auth/oauth2"; +import { jwt } from "better-auth/plugins/jwt"; +import { getTestInstance } from "better-auth/test"; +import { APIError } from "better-call"; +import { decodeJwt } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; +import { oauthProviderClient } from "./client"; +import { oauthProvider } from "./oauth"; +import type { OAuthClient } from "./types/oauth"; + +describe("pairwise subject identifiers", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const rpBaseUrl = "http://localhost:5000"; + const rpBaseUrl2 = "http://localhost:6000"; + const validAudience = "https://myapi.example.com"; + + const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ + jwt: { + issuer: authServerBaseUrl, + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-pairwise-secret-key-32chars!!", + validAudiences: [validAudience], + allowDynamicClientRegistration: true, + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const client = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { + customFetchImpl, + headers, + }, + }); + + let pairwiseClientA: OAuthClient | null; + let pairwiseClientB: OAuthClient | null; + let publicClient: OAuthClient | null; + let sameHostClientA: OAuthClient | null; + + const redirectUriA = `${rpBaseUrl}/api/auth/oauth2/callback/test-a`; + const redirectUriB = `${rpBaseUrl2}/api/auth/oauth2/callback/test-b`; + const redirectUriSameHost = `${rpBaseUrl}/api/auth/oauth2/callback/test-same`; + const redirectUriPublic = `${rpBaseUrl}/api/auth/oauth2/callback/test-public`; + + beforeAll(async () => { + pairwiseClientA = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUriA], + scope: "openid profile email offline_access", + skip_consent: true, + subject_type: "pairwise", + }, + }); + expect(pairwiseClientA?.client_id).toBeDefined(); + + pairwiseClientB = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUriB], + scope: "openid profile email offline_access", + skip_consent: true, + subject_type: "pairwise", + }, + }); + expect(pairwiseClientB?.client_id).toBeDefined(); + + publicClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUriPublic], + scope: "openid profile email offline_access", + skip_consent: true, + }, + }); + expect(publicClient?.client_id).toBeDefined(); + + sameHostClientA = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUriSameHost], + scope: "openid profile email offline_access", + skip_consent: true, + subject_type: "pairwise", + }, + }); + expect(sameHostClientA?.client_id).toBeDefined(); + }); + + async function getTokensForClient( + oauthClient: OAuthClient, + redirectUri: string, + overrides?: { + resource?: string; + }, + ) { + const codeVerifier = generateRandomString(32); + const url = await createAuthorizationURL({ + id: "test", + options: { + clientId: oauthClient.client_id, + clientSecret: oauthClient.client_secret!, + redirectURI: redirectUri, + }, + redirectURI: "", + authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`, + state: "test-state", + scopes: ["openid", "profile", "email", "offline_access"], + codeVerifier, + }); + + let callbackRedirectUrl = ""; + await client.$fetch(url.toString(), { + headers, + onError(context) { + callbackRedirectUrl = context.response.headers.get("Location") || ""; + }, + }); + const callbackUrl = new URL(callbackRedirectUrl); + const code = callbackUrl.searchParams.get("code")!; + + const { body, headers: reqHeaders } = createAuthorizationCodeRequest({ + code, + codeVerifier, + redirectURI: redirectUri, + options: { + clientId: oauthClient.client_id, + clientSecret: oauthClient.client_secret!, + redirectURI: redirectUri, + }, + resource: overrides?.resource, + }); + + const tokens = await client.$fetch<{ + access_token?: string; + id_token?: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; + }>("/oauth2/token", { + method: "POST", + body, + headers: reqHeaders, + }); + + return tokens; + } + + it("should produce different sub across pairwise clients (cross-RP unlinkability)", async () => { + const tokensA = await getTokensForClient(pairwiseClientA!, redirectUriA); + const tokensB = await getTokensForClient(pairwiseClientB!, redirectUriB); + + const idTokenA = decodeJwt(tokensA.data!.id_token!); + const idTokenB = decodeJwt(tokensB.data!.id_token!); + + expect(idTokenA.sub).toBeDefined(); + expect(idTokenB.sub).toBeDefined(); + // Different sectors โ†’ different pairwise sub + expect(idTokenA.sub).not.toBe(idTokenB.sub); + }); + + it("should produce same sub for same pairwise client (determinism)", async () => { + const tokens1 = await getTokensForClient(pairwiseClientA!, redirectUriA); + const tokens2 = await getTokensForClient(pairwiseClientA!, redirectUriA); + + const idToken1 = decodeJwt(tokens1.data!.id_token!); + const idToken2 = decodeJwt(tokens2.data!.id_token!); + + expect(idToken1.sub).toBe(idToken2.sub); + }); + + it("should return user.id as sub for public client (fallback)", async () => { + const publicTokens = await getTokensForClient( + publicClient!, + redirectUriPublic, + ); + const pairwiseTokens = await getTokensForClient( + pairwiseClientA!, + redirectUriA, + ); + + const publicIdToken = decodeJwt(publicTokens.data!.id_token!); + const pairwiseIdToken = decodeJwt(pairwiseTokens.data!.id_token!); + + expect(publicIdToken.sub).toBeDefined(); + // Public sub differs from pairwise sub for same user + expect(publicIdToken.sub).not.toBe(pairwiseIdToken.sub); + }); + + it("should produce same pairwise sub for clients on same host (sector isolation)", async () => { + const tokensA = await getTokensForClient(pairwiseClientA!, redirectUriA); + const tokensSameHost = await getTokensForClient( + sameHostClientA!, + redirectUriSameHost, + ); + + const idTokenA = decodeJwt(tokensA.data!.id_token!); + const idTokenSameHost = decodeJwt(tokensSameHost.data!.id_token!); + + // Same host (localhost) โ†’ same sector โ†’ same pairwise sub + expect(idTokenA.sub).toBe(idTokenSameHost.sub); + }); + + it("should have consistent sub between id_token and userinfo", async () => { + const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA); + const idToken = decodeJwt(tokens.data!.id_token!); + + const userinfo = await client.$fetch<{ sub?: string }>("/oauth2/userinfo", { + method: "GET", + headers: { + authorization: `Bearer ${tokens.data!.access_token}`, + }, + }); + + expect(userinfo.data?.sub).toBe(idToken.sub); + }); + + it("should return pairwise sub in opaque access token introspection", async () => { + const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA); + + const introspection = await client.oauth2.introspect( + { + client_id: pairwiseClientA!.client_id, + client_secret: pairwiseClientA!.client_secret, + token: tokens.data!.access_token!, + token_type_hint: "access_token", + }, + { + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + }, + ); + + const idToken = decodeJwt(tokens.data!.id_token!); + expect(introspection.data?.active).toBe(true); + expect(introspection.data?.sub).toBe(idToken.sub); + }); + + it("should preserve pairwise sub after token refresh", async () => { + const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA); + const originalIdToken = decodeJwt(tokens.data!.id_token!); + + const refreshBody = new URLSearchParams({ + grant_type: "refresh_token", + client_id: pairwiseClientA!.client_id, + client_secret: pairwiseClientA!.client_secret!, + refresh_token: tokens.data!.refresh_token!, + }); + + const refreshResponse = await client.$fetch<{ + access_token?: string; + id_token?: string; + refresh_token?: string; + }>("/oauth2/token", { + method: "POST", + body: refreshBody, + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + }); + + expect(refreshResponse.data?.id_token).toBeDefined(); + const refreshedIdToken = decodeJwt(refreshResponse.data!.id_token!); + expect(refreshedIdToken.sub).toBe(originalIdToken.sub); + }); + + it("should keep user.id in JWT access token sub (not pairwise)", async () => { + const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA, { + resource: validAudience, + }); + + const accessToken = decodeJwt(tokens.data!.access_token!); + const idToken = decodeJwt(tokens.data!.id_token!); + + // JWT access token uses real user.id for user lookup + expect(accessToken.sub).toBeDefined(); + expect(accessToken.sub).not.toBe(idToken.sub); + }); +}); + +describe("pairwise DCR validation", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const rpBaseUrl = "http://localhost:5000"; + const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/test`; + + it("should reject pairwise subject_type when pairwiseSecret not configured", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + await expect( + auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + subject_type: "pairwise", + }, + }), + ).rejects.toThrow(APIError); + }); + + it("should accept pairwise subject_type when pairwiseSecret is configured", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + subject_type: "pairwise", + skip_consent: true, + }, + }); + + expect(response?.client_id).toBeDefined(); + expect(response?.subject_type).toBe("pairwise"); + }); + + it("should default to public when no subject_type specified", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + skip_consent: true, + }, + }); + + expect(response?.client_id).toBeDefined(); + expect(response?.subject_type).toBeUndefined(); + }); + + it("should reject pairwise client with redirect_uris on different hosts", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + await expect( + auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [ + "https://app-a.example.com/callback", + "https://app-b.example.com/callback", + ], + subject_type: "pairwise", + }, + }), + ).rejects.toThrow(APIError); + }); + + it("should accept pairwise client with redirect_uris on the same host", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [ + "https://app.example.com/callback-a", + "https://app.example.com/callback-b", + ], + subject_type: "pairwise", + skip_consent: true, + }, + }); + + expect(response?.client_id).toBeDefined(); + expect(response?.subject_type).toBe("pairwise"); + }); + + it("should round-trip subject_type through DCR", async () => { + const { signInWithTestUser, customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + allowDynamicClientRegistration: true, + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const dcrClient = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { + customFetchImpl, + headers, + }, + }); + + const response = await dcrClient.$fetch("/oauth2/register", { + method: "POST", + body: { + redirect_uris: [redirectUri], + subject_type: "pairwise", + token_endpoint_auth_method: "none", + }, + }); + + expect(response.data?.subject_type).toBe("pairwise"); + }); +}); + +describe("pairwise configuration validation", () => { + it("should reject pairwiseSecret shorter than 32 characters", () => { + expect(() => + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "too-short", + }), + ).toThrow("pairwiseSecret must be at least 32 characters"); + }); + + it("should accept pairwiseSecret of 32+ characters", () => { + expect(() => + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "a-valid-secret-that-is-32-chars!", + }), + ).not.toThrow(); + }); +}); + +describe("pairwise metadata", async () => { + const authServerBaseUrl = "http://localhost:3000"; + + it("should include pairwise in subject_types_supported when secret configured", async () => { + const { auth } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-pairwise-metadata-secret!!!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const metadata = await auth.api.getOpenIdConfig(); + expect(metadata.subject_types_supported).toEqual(["public", "pairwise"]); + }); + + it("should only include public when no pairwise secret", async () => { + const { auth } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const metadata = await auth.api.getOpenIdConfig(); + expect(metadata.subject_types_supported).toEqual(["public"]); + }); +}); diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index 94ae1113de..05a830c4a2 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -110,6 +110,45 @@ export async function checkOAuthClient( }); } + // Validate subject_type + if (client.subject_type !== undefined) { + if ( + client.subject_type !== "public" && + client.subject_type !== "pairwise" + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: `subject_type must be "public" or "pairwise"`, + }); + } + if (client.subject_type === "pairwise" && !opts.pairwiseSecret) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "pairwise subject_type requires server pairwiseSecret configuration", + }); + } + // Per OIDC Core ยง8.1, when multiple redirect_uris have different hosts, + // a sector_identifier_uri is required (not yet supported). Reject registration + // until sector_identifier_uri support is added. + if ( + client.subject_type === "pairwise" && + client.redirect_uris && + client.redirect_uris.length > 1 + ) { + const hosts = new Set( + client.redirect_uris.map((uri: string) => new URL(uri).host), + ); + if (hosts.size > 1) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "pairwise clients with redirect_uris on different hosts require a sector_identifier_uri, which is not yet supported. All redirect_uris must share the same host.", + }); + } + } + } + // Check requested application scopes const requestedScopes = (client?.scope as string | undefined) ?.split(" ") @@ -263,6 +302,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient { skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, + subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, // All other metadata @@ -317,6 +357,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient { skipConsent, enableEndSession, requirePKCE, + subjectType, referenceId, metadata, }; @@ -364,6 +405,7 @@ export function schemaToOAuth(input: SchemaClient): OAuthClient { skipConsent, enableEndSession, requirePKCE, + subjectType, referenceId, metadata, // in JSON format } = input; @@ -417,6 +459,7 @@ export function schemaToOAuth(input: SchemaClient): OAuthClient { skip_consent: skipConsent ?? undefined, enable_end_session: enableEndSession ?? undefined, require_pkce: requirePKCE ?? undefined, + subject_type: subjectType ?? undefined, reference_id: referenceId ?? undefined, }; } diff --git a/packages/oauth-provider/src/schema.ts b/packages/oauth-provider/src/schema.ts index ddf1b063cc..5d116af493 100644 --- a/packages/oauth-provider/src/schema.ts +++ b/packages/oauth-provider/src/schema.ts @@ -27,6 +27,10 @@ export const schema = { type: "boolean", required: false, }, + subjectType: { + type: "string", + required: false, + }, scopes: { type: "string[]", required: false, diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index f632f11eee..7d3d493edc 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -22,6 +22,7 @@ import { getStoredToken, isPKCERequired, parseClientMetadata, + resolveSubjectIdentifier, storeToken, validateClientCredentials, } from "./utils"; @@ -132,6 +133,7 @@ async function createIdToken( const iat = Math.floor(Date.now() / 1000); const exp = iat + (opts.idTokenExpiresIn ?? 36000); const userClaims = userNormalClaims(user, scopes); + const resolvedSub = await resolveSubjectIdentifier(user.id, client, opts); const authTimeSec = authTime != null ? Math.floor(authTime.getTime() / 1000) : undefined; // TODO: this should be validated against the login process @@ -157,7 +159,7 @@ async function createIdToken( auth_time: authTimeSec, acr, iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL, - sub: user.id, + sub: resolvedSub, aud: client.clientId, nonce, iat, diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index facdfc4a7a..528cd4b669 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -664,6 +664,14 @@ export interface OAuthOptions< */ userinfo?: { window: number; max: number } | false; }; + /** + * Secret used to compute pairwise subject identifiers (HMAC-SHA256). + * When set, clients with `subject_type: "pairwise"` receive unique, + * unlinkable `sub` values per sector identifier. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg + */ + pairwiseSecret?: string; } export interface OAuthAuthorizationQuery { @@ -907,6 +915,8 @@ export interface SchemaClient< skipConsent?: boolean; /** Used to enable client to logout via the `/oauth2/end-session` endpoint */ enableEndSession?: boolean; + /** Subject identifier type: "public" (default) or "pairwise" */ + subjectType?: "public" | "pairwise"; /** Reference to the owner of this client. Eg. Organization, Team, Profile */ referenceId?: string; /** diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts index 760d25d77d..7de56189db 100644 --- a/packages/oauth-provider/src/types/oauth.ts +++ b/packages/oauth-provider/src/types/oauth.ts @@ -216,9 +216,9 @@ export interface OIDCMetadata extends AuthServerMetadata { * pairwise: the subject identifier is unique to the client * public: the subject identifier is unique to the server * - * only `public` is supported. + * @see https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes */ - subject_types_supported: "public"[]; + subject_types_supported: ("public" | "pairwise")[]; /** * Supported ID token signing algorithms. * @@ -307,6 +307,12 @@ export interface OAuthClient { * requesting offline_access scope, regardless of this setting. */ require_pkce?: boolean; + /** + * Subject identifier type for this client. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes + */ + subject_type?: "public" | "pairwise"; //---- All other metadata ----// reference_id?: string; [key: string]: unknown; diff --git a/packages/oauth-provider/src/userinfo.ts b/packages/oauth-provider/src/userinfo.ts index 109651f408..f204b96261 100644 --- a/packages/oauth-provider/src/userinfo.ts +++ b/packages/oauth-provider/src/userinfo.ts @@ -3,6 +3,7 @@ import { APIError } from "better-auth/api"; import type { User } from "better-auth/types"; import { validateAccessToken } from "./introspect"; import type { OAuthOptions, Scope } from "./types"; +import { getClient, resolveSubjectIdentifier } from "./utils"; /** * Provides shared /userinfo and id_token claims functionality @@ -80,6 +81,21 @@ export async function userInfoEndpoint( } const baseUserClaims = userNormalClaims(user, scopes ?? []); + + // Resolve pairwise sub if server has pairwise enabled and client is configured for it + if (opts.pairwiseSecret) { + const clientId = (jwt.client_id ?? jwt.azp) as string | undefined; + if (clientId) { + const client = await getClient(ctx, opts, clientId); + if (client) { + baseUserClaims.sub = await resolveSubjectIdentifier( + user.id, + client, + opts, + ); + } + } + } const additionalInfoUserClaims = opts.customUserInfoClaims && scopes?.length ? await opts.customUserInfoClaims({ user, scopes, jwt }) diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts index 4739a598d3..0dc5a61e0a 100644 --- a/packages/oauth-provider/src/utils/index.ts +++ b/packages/oauth-provider/src/utils/index.ts @@ -4,6 +4,7 @@ import { base64, base64Url } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; import { constantTimeEqual, + makeSignature, symmetricDecrypt, symmetricEncrypt, } from "better-auth/crypto"; @@ -418,6 +419,54 @@ export function parsePrompt(prompt: string) { return new Set(set); } +/** + * Extracts the sector identifier (hostname) from a client's first redirect URI. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg + * @internal + */ +export function getSectorIdentifier(client: SchemaClient): string { + const uri = client.redirectUris?.[0]; + if (!uri) { + throw new BetterAuthError( + "Client has no redirect URIs for sector identifier", + ); + } + return new URL(uri).host; +} + +/** + * Computes a pairwise subject identifier using HMAC-SHA256. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg + * @internal + */ +export async function computePairwiseSub( + userId: string, + client: SchemaClient, + secret: string, +): Promise { + const sectorId = getSectorIdentifier(client); + return makeSignature(`${sectorId}.${userId}`, secret); +} + +/** + * Returns the appropriate subject identifier for a user+client pair. + * Uses pairwise when the client opts in and the server has a secret configured. + * + * @internal + */ +export async function resolveSubjectIdentifier( + userId: string, + client: SchemaClient, + opts: OAuthOptions, +): Promise { + if (client.subjectType === "pairwise" && opts.pairwiseSecret) { + return computePairwiseSub(userId, client, opts.pairwiseSecret); + } + return userId; +} + /** * Deletes a prompt value *