feat(oauth-provider): pairwise subject identifiers (OIDC Core §8) (#8292)

Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
Gustavo Valverde
2026-03-03 17:41:28 +00:00
committed by GitHub
parent eb202d21b7
commit ab7ec8a70b
13 changed files with 794 additions and 6 deletions

View File

@@ -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.
<Callout type="warn">
**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.
</Callout>
### 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[]",

View File

@@ -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<Scope[]>,
payload: JWTPayload,
client: SchemaClient<Scope[]>,
): Promise<JWTPayload> {
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<Scope[]>,
@@ -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") {

View File

@@ -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]

View File

@@ -118,6 +118,13 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(options: O) => {
.default(["code"])
.optional(),
type: z.enum(["web", "native", "user-agent-based"]).optional(),
subject_type: z.enum(["public", "pairwise"]).optional(),
}),
metadata: {
openapi: {

View File

@@ -57,6 +57,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
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: {

View File

@@ -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<OAuthClient>("/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"]);
});
});

View File

@@ -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<Scope[]> {
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<Scope[]> {
skipConsent,
enableEndSession,
requirePKCE,
subjectType,
referenceId,
metadata,
};
@@ -364,6 +405,7 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
skipConsent,
enableEndSession,
requirePKCE,
subjectType,
referenceId,
metadata, // in JSON format
} = input;
@@ -417,6 +459,7 @@ export function schemaToOAuth(input: SchemaClient<Scope[]>): OAuthClient {
skip_consent: skipConsent ?? undefined,
enable_end_session: enableEndSession ?? undefined,
require_pkce: requirePKCE ?? undefined,
subject_type: subjectType ?? undefined,
reference_id: referenceId ?? undefined,
};
}

View File

@@ -27,6 +27,10 @@ export const schema = {
type: "boolean",
required: false,
},
subjectType: {
type: "string",
required: false,
},
scopes: {
type: "string[]",
required: false,

View File

@@ -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,

View File

@@ -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;
/**

View File

@@ -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;

View File

@@ -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 })

View File

@@ -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<Scope[]>): 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<Scope[]>,
secret: string,
): Promise<string> {
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<Scope[]>,
opts: OAuthOptions<Scope[]>,
): Promise<string> {
if (client.subjectType === "pairwise" && opts.pairwiseSecret) {
return computePairwiseSub(userId, client, opts.pairwiseSecret);
}
return userId;
}
/**
* Deletes a prompt value
*