mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-23 07:18:56 -05:00
feat(oauth-provider): pairwise subject identifiers (OIDC Core §8) (#8292)
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
@@ -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[]",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
569
packages/oauth-provider/src/pairwise.test.ts
Normal file
569
packages/oauth-provider/src/pairwise.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ export const schema = {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
},
|
||||
subjectType: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
scopes: {
|
||||
type: "string[]",
|
||||
required: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user