diff --git a/docs/app/global.css b/docs/app/global.css index e01da85633..60ddb4263f 100644 --- a/docs/app/global.css +++ b/docs/app/global.css @@ -242,3 +242,7 @@ @apply bg-background text-foreground; } } + +html { + scroll-behavior: smooth; +} diff --git a/docs/content/docs/plugins/oidc-provider.mdx b/docs/content/docs/plugins/oidc-provider.mdx index 17dec99f85..d974f51815 100644 --- a/docs/content/docs/plugins/oidc-provider.mdx +++ b/docs/content/docs/plugins/oidc-provider.mdx @@ -85,14 +85,42 @@ Once installed, you can utilize the OIDC Provider to manage authentication flows To register a new OIDC client, use the `oauth2.register` method. + + ```ts title="client.ts" const application = await client.oauth2.register({ name: "My Client", - redirectURLs: ["https://client.example.com/callback"], + redirect_uris: ["https://client.example.com/callback"], }); ``` -Once the application is created, you will receive a `clientId` and `clientSecret` that you can display to the user. +Once the application is created, you will receive a `client_id` and `client_secret` that you can display to the user. + +This Endpoint support [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591) compliant client registration. + +### UserInfo Endpoint + +The OIDC Provider includes a UserInfo endpoint that allows clients to retrieve information about the authenticated user. This endpoint is available at `/oauth2/userinfo` and requires a valid access token. + + + +```ts title="client-app.ts" +// Example of how a client would use the UserInfo endpoint +const response = await fetch('https://your-domain.com/api/auth/oauth2/userinfo', { + headers: { + 'Authorization': 'Bearer ACCESS_TOKEN' + } +}); + +const userInfo = await response.json(); +// userInfo contains user details based on the scopes granted +``` + +The UserInfo endpoint returns different claims based on the scopes that were granted during authorization: + +- With `openid` scope: Returns the user's ID (`sub` claim) +- With `profile` scope: Returns name, picture, given_name, family_name +- With `email` scope: Returns email and email_verified ### Consent Screen @@ -110,6 +138,8 @@ export const auth = betterAuth({ The plugin will redirect the user to the specified path with a `client_id` and `scope` query parameter. You can use this information to display a custom consent screen. Once the user consents, you can call `oauth2.consent` to complete the authorization. + + ```ts title="server.ts" const res = await client.oauth2.consent({ accept: true, // or false to deny diff --git a/packages/better-auth/src/plugins/oidc-provider/index.ts b/packages/better-auth/src/plugins/oidc-provider/index.ts index f341ebd585..e9fe78ebc3 100644 --- a/packages/better-auth/src/plugins/oidc-provider/index.ts +++ b/packages/better-auth/src/plugins/oidc-provider/index.ts @@ -695,48 +695,156 @@ export const oidcProvider = (options: OIDCOptions) => { { method: "POST", body: z.object({ - name: z.string(), - icon: z.string().optional(), + redirect_uris: z.array(z.string()), + token_endpoint_auth_method: z + .enum(["none", "client_secret_basic", "client_secret_post"]) + .default("client_secret_basic") + .optional(), + grant_types: z + .array( + z.enum([ + "authorization_code", + "implicit", + "password", + "client_credentials", + "refresh_token", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "urn:ietf:params:oauth:grant-type:saml2-bearer", + ]), + ) + .default(["authorization_code"]) + .optional(), + response_types: z + .array(z.enum(["code", "token"])) + .default(["code"]) + .optional(), + client_name: z.string().optional(), + client_uri: z.string().optional(), + logo_uri: z.string().optional(), + scope: z.string().optional(), + contacts: z.array(z.string()).optional(), + tos_uri: z.string().optional(), + policy_uri: z.string().optional(), + jwks_uri: z.string().optional(), + jwks: z.record(z.any()).optional(), metadata: z.record(z.any()).optional(), - redirectURLs: z.array(z.string()), + software_id: z.string().optional(), + software_version: z.string().optional(), + software_statement: z.string().optional(), }), }, async (ctx) => { const body = ctx.body; const session = await getSessionFromCtx(ctx); + + // Check authorization if (!session && !options.allowDynamicClientRegistration) { throw new APIError("UNAUTHORIZED", { - message: "Unauthorized", + error: "invalid_token", + error_description: + "Authentication required for client registration", }); } + + // Validate redirect URIs for redirect-based flows + if ( + (!body.grant_types || + body.grant_types.includes("authorization_code") || + body.grant_types.includes("implicit")) && + (!body.redirect_uris || body.redirect_uris.length === 0) + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_redirect_uri", + error_description: + "Redirect URIs are required for authorization_code and implicit grant types", + }); + } + + // Validate correlation between grant_types and response_types + if (body.grant_types && body.response_types) { + if ( + body.grant_types.includes("authorization_code") && + !body.response_types.includes("code") + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "When 'authorization_code' grant type is used, 'code' response type must be included", + }); + } + if ( + body.grant_types.includes("implicit") && + !body.response_types.includes("token") + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "When 'implicit' grant type is used, 'token' response type must be included", + }); + } + } + const clientId = options.generateClientId?.() || generateRandomString(32, "a-z", "A-Z"); const clientSecret = options.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z"); - const client = await ctx.context.adapter.create>({ + + // Create the client with the existing schema + const client: Client = await ctx.context.adapter.create({ model: modelName.oauthClient, data: { - name: body.name, - icon: body.icon, + name: body.client_name, + icon: body.logo_uri, metadata: body.metadata ? JSON.stringify(body.metadata) : null, clientId: clientId, clientSecret: clientSecret, - redirectURLs: body.redirectURLs.join(","), + redirectURLs: body.redirect_uris.join(","), type: "web", - authenticationScheme: "client_secret", + authenticationScheme: + body.token_endpoint_auth_method || "client_secret_basic", disabled: false, userId: session?.session.userId, createdAt: new Date(), updatedAt: new Date(), }, }); - return ctx.json({ - ...client, - redirectURLs: client.redirectURLs.split(","), - metadata: client.metadata ? JSON.parse(client.metadata) : null, - } as Client); + + // Format the response according to RFC7591 + return ctx.json( + { + client_id: clientId, + client_secret: clientSecret, + client_id_issued_at: Math.floor(Date.now() / 1000), + client_secret_expires_at: 0, // 0 means it doesn't expire + redirect_uris: body.redirect_uris, + token_endpoint_auth_method: + body.token_endpoint_auth_method || "client_secret_basic", + grant_types: body.grant_types || ["authorization_code"], + response_types: body.response_types || ["code"], + client_name: body.client_name, + client_uri: body.client_uri, + logo_uri: body.logo_uri, + scope: body.scope, + contacts: body.contacts, + tos_uri: body.tos_uri, + policy_uri: body.policy_uri, + jwks_uri: body.jwks_uri, + jwks: body.jwks, + software_id: body.software_id, + software_version: body.software_version, + software_statement: body.software_statement, + metadata: body.metadata, + }, + { + status: 201, + headers: { + "Cache-Control": "no-store", + Pragma: "no-cache", + }, + }, + ); }, ), getOAuthClient: createAuthEndpoint( diff --git a/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts b/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts index 7dad0a7600..36336c8bb0 100644 --- a/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts +++ b/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts @@ -62,28 +62,33 @@ describe("oidc", async () => { it("should create oidc client", async ({ expect }) => { const createdClient = await serverClient.oauth2.register({ - name: application.name, - redirectURLs: application.redirectURLs, - icon: application.icon, - metadata: { - custom: "data", - }, + client_name: application.name, + redirect_uris: application.redirectURLs, + logo_uri: application.icon, }); expect(createdClient.data).toMatchObject({ - id: expect.any(String), - name: "test", - icon: "", - metadata: { - custom: "data", - }, - clientId: expect.any(String), - clientSecret: expect.any(String), - redirectURLs: ["http://localhost:3000/api/auth/oauth2/callback/test"], - type: "web", - disabled: false, + client_id: expect.any(String), + client_secret: expect.any(String), + client_name: "test", + logo_uri: "", + redirect_uris: ["http://localhost:3000/api/auth/oauth2/callback/test"], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_basic", + client_id_issued_at: expect.any(Number), + client_secret_expires_at: 0, }); if (createdClient.data) { - application = createdClient.data; + application = { + clientId: createdClient.data.client_id, + clientSecret: createdClient.data.client_secret, + redirectURLs: createdClient.data.redirect_uris, + metadata: {}, + icon: createdClient.data.logo_uri || "", + type: "web", + disabled: false, + name: createdClient.data.client_name || "", + }; } });