diff --git a/packages/oauth-provider/src/introspect.ts b/packages/oauth-provider/src/introspect.ts index c4b41b2e44..7b29234527 100644 --- a/packages/oauth-provider/src/introspect.ts +++ b/packages/oauth-provider/src/introspect.ts @@ -17,6 +17,7 @@ import { getClient, getJwtPlugin, getStoredToken, + parseClientMetadata, validateClientCredentials, } from "./utils"; @@ -218,7 +219,7 @@ async function validateOpaqueAccessToken( user, scopes: accessToken.scopes, referenceId: accessToken?.referenceId, - metadata: client?.metadata ? JSON.parse(client.metadata) : undefined, + metadata: parseClientMetadata(client?.metadata), }) : {}; diff --git a/packages/oauth-provider/src/register.test.ts b/packages/oauth-provider/src/register.test.ts index fc1c800d54..b6516d2947 100644 --- a/packages/oauth-provider/src/register.test.ts +++ b/packages/oauth-provider/src/register.test.ts @@ -210,6 +210,41 @@ describe("oauth register", async () => { expect(response.data?.disabled).toBeFalsy(); }); + + it("should register client with metadata field", async () => { + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + metadata: { + foo: "bar", + nested: { key: "value" }, + }, + }, + }); + expect(response?.client_id).toBeDefined(); + expect(response?.client_secret).toBeDefined(); + // Metadata should be spread at the top level of the response + expect(response?.foo).toBe("bar"); + expect(response?.nested).toEqual({ key: "value" }); + }); + + it("should register client with metadata and other extra fields", async () => { + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + metadata: { + fromMetadata: "value1", + }, + customField: "value2", + } as any, + }); + expect(response?.client_id).toBeDefined(); + // Both metadata and extra fields should be spread at the top level + expect(response?.fromMetadata).toBe("value1"); + expect(response?.customField).toBe("value2"); + }); }); describe("oauth register - unauthenticated", async () => { diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index 324df23839..5c49811301 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -4,7 +4,7 @@ import { generateRandomString } from "better-auth/crypto"; import { toExpJWT } from "better-auth/plugins"; import type { OAuthOptions, SchemaClient, Scope } from "./types"; import type { OAuthClient } from "./types/oauth"; -import { storeClientSecret } from "./utils"; +import { parseClientMetadata, storeClientSecret } from "./utils"; export async function registerEndpoint( ctx: GenericEndpointContext, @@ -252,6 +252,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient { skip_consent: skipConsent, enable_end_session: enableEndSession, reference_id: referenceId, + metadata: inputMetadata, // All other metadata ...rest } = input; @@ -260,8 +261,15 @@ export function oauthToSchema(input: OAuthClient): SchemaClient { const expiresAt = _expiresAt ? new Date(_expiresAt * 1000) : undefined; const createdAt = _createdAt ? new Date(_createdAt * 1000) : undefined; const scopes = _scope?.split(" "); - const metadata = - rest && Object.keys(rest).length ? JSON.stringify(rest) : undefined; + const metadataObj = { + ...(rest && Object.keys(rest).length ? rest : {}), + ...(inputMetadata && typeof inputMetadata === "object" + ? inputMetadata + : {}), + }; + const metadata = Object.keys(metadataObj).length + ? JSON.stringify(metadataObj) + : undefined; return { // Important Fields @@ -354,7 +362,7 @@ export function schemaToOAuth(input: SchemaClient): OAuthClient { ? Math.round(createdAt.getTime() / 1000) : undefined; const _scopes = scopes?.join(" "); - const _metadata = metadata ? JSON.parse(metadata) : undefined; + const _metadata = parseClientMetadata(metadata); return { // All other metadata diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index e8a64fc6f8..d1b99493d1 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -20,6 +20,7 @@ import { decryptStoredClientSecret, getJwtPlugin, getStoredToken, + parseClientMetadata, storeToken, validateClientCredentials, } from "./utils"; @@ -85,7 +86,7 @@ async function createJwtAccessToken( scopes, resource: ctx.body.resource, referenceId, - metadata: client.metadata ? JSON.parse(client.metadata) : undefined, + metadata: parseClientMetadata(client.metadata), }) : {}; @@ -142,7 +143,7 @@ async function createIdToken( ? await opts.customIdTokenClaims({ user, scopes, - metadata: client.metadata ? JSON.parse(client.metadata) : undefined, + metadata: parseClientMetadata(client.metadata), }) : {}; @@ -817,7 +818,7 @@ async function handleClientCredentialsGrant( ? await opts.customAccessTokenClaims({ scopes: requestedScopes, resource: ctx.body.resource, - metadata: client.metadata ? JSON.parse(client.metadata) : undefined, + metadata: parseClientMetadata(client.metadata), }) : {}; diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts index 9f5e75ea26..a2e0f167a7 100644 --- a/packages/oauth-provider/src/utils/index.ts +++ b/packages/oauth-provider/src/utils/index.ts @@ -374,6 +374,19 @@ export async function validateClientCredentials( return client; } +/** + * Parse client metadata that may be stored as JSON string or already parsed object. + * Handles database adapters that auto-parse JSON columns. + * + * @internal + */ +export function parseClientMetadata( + metadata: string | object | undefined, +): object | undefined { + if (!metadata) return undefined; + return typeof metadata === "string" ? JSON.parse(metadata) : metadata; +} + /** * Parse space-separated prompt string into a set of prompts *