From 6fe9faab65eb640dbe9bb762954a068586e8661c Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Fri, 12 Jun 2026 14:45:19 -0700 Subject: [PATCH] refactor!: remove deprecated oidc-provider plugin (#10031) --- .changeset/remove-oidc-provider.md | 5 + .../components/landing/framework-sections.tsx | 1 - docs/components/sidebar-content.tsx | 18 - docs/content/blogs/1-3.mdx | 2 +- docs/content/blogs/1-5.mdx | 2 +- .../blogs/security-update-june-2026.mdx | 4 +- docs/content/docs/plugins/index.mdx | 1 - docs/content/docs/plugins/meta.json | 1 - docs/content/docs/plugins/oauth-provider.mdx | 127 +- docs/content/docs/plugins/oidc-provider.mdx | 655 ----- docs/lib/plugins.ts | 5 - .../src/oidc-provider.ts | 10 - packages/better-auth/package.json | 9 - .../better-auth/src/client/client.test.ts | 2 - .../better-auth/src/client/plugins/index.ts | 1 - packages/better-auth/src/plugins/index.ts | 1 - packages/better-auth/src/plugins/jwt/index.ts | 2 +- .../better-auth/src/plugins/jwt/jwt.test.ts | 2 +- .../src/plugins/oidc-provider/authorize.ts | 435 ---- .../src/plugins/oidc-provider/client.ts | 19 - .../src/plugins/oidc-provider/error.ts | 24 - .../src/plugins/oidc-provider/index.ts | 1879 -------------- .../src/plugins/oidc-provider/oidc.test.ts | 2257 ----------------- .../oidc-provider/redirect-uri.test.ts | 57 - .../src/plugins/oidc-provider/schema.ts | 193 -- .../src/plugins/oidc-provider/types.ts | 546 ---- .../src/plugins/oidc-provider/utils.ts | 15 - .../oidc-provider/utils/prompt.test.ts | 69 - .../src/plugins/oidc-provider/utils/prompt.ts | 30 - packages/better-auth/tsdown.config.ts | 1 - .../init/configs/temp-plugins.config.ts | 23 - pnpm-lock.yaml | 19 - test/package.json | 1 - test/unit/oidc/index.spec.ts | 149 -- test/unit/oidc/scope-consent.spec.ts | 215 -- 35 files changed, 14 insertions(+), 6766 deletions(-) create mode 100644 .changeset/remove-oidc-provider.md delete mode 100644 docs/content/docs/plugins/oidc-provider.mdx delete mode 100644 e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/src/oidc-provider.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/authorize.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/client.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/error.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/index.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/oidc.test.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/redirect-uri.test.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/schema.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/types.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/utils.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/utils/prompt.test.ts delete mode 100644 packages/better-auth/src/plugins/oidc-provider/utils/prompt.ts delete mode 100644 test/unit/oidc/index.spec.ts delete mode 100644 test/unit/oidc/scope-consent.spec.ts diff --git a/.changeset/remove-oidc-provider.md b/.changeset/remove-oidc-provider.md new file mode 100644 index 0000000000..6cc2bafbdd --- /dev/null +++ b/.changeset/remove-oidc-provider.md @@ -0,0 +1,5 @@ +--- +"better-auth": minor +--- + +Remove the deprecated `oidcProvider` plugin from `better-auth/plugins`. Migrate OIDC authorization-server integrations to `@better-auth/oauth-provider`. diff --git a/docs/components/landing/framework-sections.tsx b/docs/components/landing/framework-sections.tsx index 4a2f09b83c..1e6d098bcc 100644 --- a/docs/components/landing/framework-sections.tsx +++ b/docs/components/landing/framework-sections.tsx @@ -612,7 +612,6 @@ export const plugins = [ { name: "Multi Session", category: "org" }, { name: "API Key", category: "org" }, { name: "SSO", category: "enterprise" }, - { name: "OIDC Provider", category: "enterprise" }, { name: "SCIM", category: "enterprise" }, { name: "OAuth Proxy", category: "enterprise" }, { name: "JWT", category: "security" }, diff --git a/docs/components/sidebar-content.tsx b/docs/components/sidebar-content.tsx index 2e72203e28..6714d5d5a0 100644 --- a/docs/components/sidebar-content.tsx +++ b/docs/components/sidebar-content.tsx @@ -1879,24 +1879,6 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2, href: "", icon: () => , }, - { - title: "OIDC Provider", - href: "/docs/plugins/oidc-provider", - icon: () => ( - - - - - ), - }, { title: "OAuth Provider", href: "/docs/plugins/oauth-provider", diff --git a/docs/content/blogs/1-3.mdx b/docs/content/blogs/1-3.mdx index f4d93f5616..a4332fb03d 100644 --- a/docs/content/blogs/1-3.mdx +++ b/docs/content/blogs/1-3.mdx @@ -68,7 +68,7 @@ Both OIDC and MCP plugins are production‑ready. * Trusted clients * Encrypted & hashed client secrets -👉 [Read OIDC docs](/docs/plugins/oidc-provider) +👉 [Read OAuth Provider docs](/docs/plugins/oauth-provider) 👉 [Read MCP docs](/docs/plugins/mcp) ```ts title="auth.ts" diff --git a/docs/content/blogs/1-5.mdx b/docs/content/blogs/1-5.mdx index 50f9fa6f25..46ca7460c6 100644 --- a/docs/content/blogs/1-5.mdx +++ b/docs/content/blogs/1-5.mdx @@ -125,7 +125,7 @@ Key features: * **Per-endpoint rate limiting**: Configurable rate limits for each OAuth endpoint. - The OAuth 2.1 Provider replaces the previous OIDC Provider plugin, which will be deprecated in a future release. The MCP plugin will also transition to use the OAuth 2.1 Provider as its foundation. See the [migration guide](https://www.better-auth.com/docs/plugins/oauth-provider#from-oidc-provider-plugin) for upgrading from the OIDC Provider plugin. + Use the OAuth 2.1 Provider for OAuth and OpenID Connect authorization-server integrations. MCP integrations use the same provider foundation. *** diff --git a/docs/content/blogs/security-update-june-2026.mdx b/docs/content/blogs/security-update-june-2026.mdx index 7c8188cc18..b2004c150d 100644 --- a/docs/content/blogs/security-update-june-2026.mdx +++ b/docs/content/blogs/security-update-june-2026.mdx @@ -68,9 +68,9 @@ The tables below cover the advisories published in this security cycle, grouped | [GHSA-5rr4-8452-hf4v](https://github.com/better-auth/better-auth/security/advisories/GHSA-5rr4-8452-hf4v) | SSO provider registration URL validation | Critical | `@better-auth/sso@1.6.11` | | [GHSA-gv74-j8m3-fg5f](https://github.com/better-auth/better-auth/security/advisories/GHSA-gv74-j8m3-fg5f) | SSO provider registration authorization | High | `@better-auth/sso@1.6.11` | -### Deprecated plugins (OIDC and MCP) +### OAuth Provider for authorization-server integrations -The `oidcProvider` and `mcp` plugins have been deprecated for roughly half a year and will be removed from the library in **1.7**. For the advisories below we strongly recommend migrating to `@better-auth/oauth-provider` rather than relying on continued patches to these plugins. +Use `@better-auth/oauth-provider` for OAuth and OpenID Connect provider integrations. It is the current authorization-server package for new integrations. | Advisory | Area | Severity | Fixed path | | --------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------- | ------------------------------------------------ | diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index 114d62864a..a507535207 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -47,7 +47,6 @@ Better Auth ships with 50+ plugins that extend the framework with additional aut | Plugin | Description | | ---------------------------------------------------------- | -------------------------------------------------------------- | | [OAuth 2.1 Provider](/docs/plugins/oauth-provider) | Turn your auth server into an OAuth 2.1 provider | -| [OIDC Provider](/docs/plugins/oidc-provider) | OpenID Connect provider support | | [MCP](/docs/plugins/mcp) | MCP provider authentication | | [Device Authorization](/docs/plugins/device-authorization) | OAuth 2.0 Device Authorization Grant for limited-input devices | diff --git a/docs/content/docs/plugins/meta.json b/docs/content/docs/plugins/meta.json index 30031ac244..ccc700add2 100644 --- a/docs/content/docs/plugins/meta.json +++ b/docs/content/docs/plugins/meta.json @@ -26,7 +26,6 @@ "oauth-proxy", "oauth-provider", "cimd", - "oidc-provider", "mcp", "device-authorization", "stripe", diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 03f5a24c5f..96d9f366a3 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -19,7 +19,7 @@ The plugin has a secured configuration by default providing ease to users unfami * **Dynamic Client Registration**: Allow clients to register clients dynamically. * **Public Clients**: Support public clients for native mobile clients and user-agent clients (like AI) * **Confidential Clients**: Supports confidential clients for web clients - * **Trusted Clients**: Configure hard-coded trusted clients with optional consent bypass. + * **Trusted Clients**: Store first-party clients with optional consent bypass and cache them by `client_id`. * **JWT Plugin compatibility**: required by default with an option to disable * **JWT Signing**: sign JWT tokens when requesting a `resource` * **JWKS Verifiable**: verify tokens remotely at the [`/jwks`](/docs/plugins/jwt#verifying-the-token) endpoint @@ -42,7 +42,7 @@ The plugin has a secured configuration by default providing ease to users unfami ### Mount the Plugin - Add the OIDC plugin to your auth config. See [Configuration Section](#configuration) on how to configure the plugin. + Add the OAuth Provider plugin to your auth config. See [Configuration Section](#configuration) on how to configure the plugin. ```ts title="auth.ts" import { betterAuth } from "better-auth"; @@ -939,7 +939,7 @@ You don't need to handle anything from your side; when a new session is created, When a user is redirected to the OIDC provider for authentication, they may be prompted to authorize the application to access their data. -**Note**: Trusted clients with `skipConsent: true` will bypass the consent screen entirely, providing a seamless experience for first-party applications. +**Note**: Trusted clients with `skip_consent: true` will bypass the consent screen entirely, providing a seamless experience for first-party applications. ```ts title="auth.ts" oauthProvider({ @@ -1303,22 +1303,6 @@ The `require_pkce` field: **Recommendation:** Keep PKCE enabled (default) whenever possible. PKCE provides defense-in-depth even for confidential clients. -#### Migrating from oidc-provider - -If you're migrating from the deprecated `oidc-provider` plugin and have confidential clients that don't support PKCE: - -1. **For legacy clients, opt-out per-client:** - Set `require_pkce: false` when registering clients that cannot be updated to support PKCE. - -2. **For new clients, use PKCE:** - New client registrations should always use PKCE (the default) for better security. - -3. **Phase out non-PKCE clients:** - Plan to upgrade or replace clients that don't support PKCE over time. - -4. **Monitor usage:** - Track which clients have `require_pkce: false` for migration planning. - #### Security Considerations PKCE prevents authorization code interception attacks. Even for confidential clients with client\_secret authentication, PKCE provides additional security: @@ -2221,108 +2205,3 @@ The following are available under the `prefix` configuration setting: ## Optimizations To improve lookup performance, database adapters may map the field `client_id` on the table `oauthClient` to `id`. Note that `id` should support strings formatted like UUIDs and urls. - -## Migrations - -### From OIDC Provider Plugin - -See [OIDC Provider Plugin](/docs/plugins/oidc-provider) for the previous implementation. - -#### Configuration - -* **`idTokenExpiresIn`** now defaults to `10 hours` (previously `1 hour` through `accessTokenExpiresIn`) -* **`refreshTokenExpiresIn`** now defaults to `30 days` (previously `7 days`) -* **`advertisedMetadata`** (previously `metadata`) no longer supports changing metadata fields to prevent accidental misconfiguration. -* **`clientRegistrationDefaultScopes`** (previously `defaultScope`) is now in array format instead of a space-separated string -* **`consentPage`** is now required -* **`getConsentHTML`** is removed in favor of the `consentPage` as raw html is not a response type supported by the authorize endpoint in OAuth -* **`requirePKCE`** (global option) is removed. PKCE is now required by default per OAuth 2.1. Individual clients can opt-out using `require_pkce: false` during registration if needed for legacy compatibility. -* **`allowPlainCodeChallengeMethod`** is removed as the `plain` code challenge is considered less secure than the default `S256` method -* **`customUserInfoClaims`** (previously `getAdditionalUserInfoClaim`) passes the jwt payload instead of the client of the access token used in the request. -* **`storeClientSecret`** now defaults to `hashed`, or `encrypted` if `disableJwtPlugin: true` (previously `plain`). -* JWT plugin now is enabled by default. To disable the plugin, set `disableJwtPlugin: true`. -* Authorization query `code_challenge_method` "S256" must be in caps as described by OAuth 2.1 - -#### Database - -##### Table: `oauthClient` - -Previously `oauthApplication` - -* If `storeClientSecret` was unset or `plain`, you must hash all the stored `clientSecret` values into its "SHA-256" representation then convert it into base64Url format or use another storage method specified by `storeClientSecret`. - The following function will convert a `plain` representation into the default hash: - -```ts -import { createHash } from "@better-auth/utils/hash"; -import { base64Url } from "@better-auth/utils/base64"; - -const defaultHasher = async (value: string) => { - const hash = await createHash("SHA-256").digest( - new TextEncoder().encode(value), - ); - const hashed = base64Url.encode(new Uint8Array(hash), { - padding: false, - }); - return hashed; -}; -``` - -* `type` field is no longer a required field. Instead, the schema requires `public` of type `boolean`. Migrate with the following rules: - * Clients with `type: "public"`: set `type: undefined`, `public: true`, and `clientSecret: undefined` - * Clients with `type: "native"`: set `public: true` and `clientSecret: undefined` - * Clients with `type: "user-agent-based"`: set `public: true` and `clientSecret: undefined` - * Clients with `clientSecret: undefined`: set `public: true` -* `redirectURLs` renamed to `redirectUris` -* `requirePkce` field added (optional, defaults to `true`). For existing confidential clients that don't support PKCE, set `requirePkce: false`. -* `metadata` is now stored in database as individual fields instead of a JSON object. Parse the metadata into their respective fields. The OIDC plugin did not utilize this field but this OAuth plugin may utilize them in the future. - -##### Table: `oauthAccessToken` - -Option 1 (simple): - -You may choose to opt-out of this table conversion with minimal impact. By doing so, users of the existing application will simply need to login again. Simply delete the existing table `oauthAccessToken`. - -Option 2 (more complex): - -Migrate all tables (you may need to create a clone of `oauthAccessToken` into `oauthRefreshToken` before a migration). - -* Convert `oauthAccessToken` with `refreshToken` field into a new `oauthRefreshToken` entry. - -```ts -{ - token: defaultHasher(refreshToken), - expiresAt: refreshTokenExpiresAt, - clientId: clientId, - scopes: scopes, - userId: userId, - createdAt: createdAt, - updatedAt: updatedAt, -} -``` - -* Keep `oauthAccessToken` but reference new `oauthRefreshToken`. - -```ts -{ - token: defaultHasher(accessToken), - expiresAt: accessTokenExpiresAt, - clientId: clientId, - scopes: scopes, - refreshId: oauthRefreshToken.id, // `undefined` if no refreshToken - createdAt: createdAt, - updatedAt: updatedAt, -} -``` - -### From MCP Plugin - -See [MCP Plugin](/docs/plugins/mcp) for prior MCP-specific endpoints. - -The MCP endpoints moved from `/mcp` to the `/oauth2` equivalent. - -* `/oauth2/authorize` (previously `/mcp/authorize`) -* `/oauth2/token` (previously `/mcp/token`) -* `/oauth2/register` (previously `/mcp/register`) -* `/mcp/get-session` removed as not OAuth 2 compliant, use `/oauth2/introspect` instead -* `/.well-known/oauth-protected-resource` removed, use the helper `mcpHandler` (or manually with the server `api.oAuth2introspectVerify` or the resource client `verifyAccessToken`) -* Database changes are equivalent to the [From OIDC Provider Plugin](#from-oidc-provider-plugin) section. diff --git a/docs/content/docs/plugins/oidc-provider.mdx b/docs/content/docs/plugins/oidc-provider.mdx deleted file mode 100644 index 656b3f3d98..0000000000 --- a/docs/content/docs/plugins/oidc-provider.mdx +++ /dev/null @@ -1,655 +0,0 @@ ---- -title: OIDC Provider -description: Open ID Connect plugin for Better Auth that allows you to have your own OIDC provider. ---- - - - This plugin will soon be deprecated in favor of the [OAuth Provider Plugin](/docs/plugins/oauth-provider). - - -The **OIDC Provider Plugin** enables you to build and manage your own OpenID Connect (OIDC) provider, granting full control over user authentication without relying on third-party services like Okta or Azure AD. It also allows other services to authenticate users through your OIDC provider. - -**Key Features**: - -* **Client Registration**: Register clients to authenticate with your OIDC provider. -* **Dynamic Client Registration**: Allow clients to register dynamically. -* **Trusted Clients**: Configure hard-coded trusted clients with optional consent bypass. -* **Authorization Code Flow**: Support the Authorization Code Flow. -* **Public Clients**: Support public clients for SPA, mobile apps, CLI tools, etc. -* **JWKS Endpoint**: Publish a JWKS endpoint to allow clients to verify tokens. (Not fully implemented) -* **Refresh Tokens**: Issue refresh tokens and handle access token renewal using the `refresh_token` grant. -* **OAuth Consent**: Implement OAuth consent screens for user authorization, with an option to bypass consent for trusted applications. -* **UserInfo Endpoint**: Provide a UserInfo endpoint for clients to retrieve user details. - - - This plugin is in active development and may not be suitable for production use. Please report any issues or bugs on [GitHub](https://github.com/better-auth/better-auth). - - -## Installation - - - - ### Mount the Plugin - - Add the OIDC plugin to your auth config. See [Configuration Section](#configuration) on how to configure the plugin. - - ```ts title="auth.ts" - import { betterAuth } from "better-auth"; - import { oidcProvider } from "better-auth/plugins"; // [!code highlight] - - const auth = betterAuth({ - plugins: [ - oidcProvider({ // [!code highlight] - loginPage: "/sign-in", // path to the login page // [!code highlight] - // ...other options // [!code highlight] - }) // [!code highlight] - ] - }) - ``` - - - - ### Migrate the Database - - Run the migration or generate the schema to add the necessary fields and tables to the database. - - - - ```package-install - npx auth migrate - ``` - - - - ```package-install - npx auth generate - ``` - - - - See the [Schema](#schema) section to add the fields manually. - - - - ### Add the Client Plugin - - Add the OIDC client plugin to your auth client config. - - ```ts title="auth-client.ts" - import { createAuthClient } from "better-auth/client"; - import { oidcClient } from "better-auth/client/plugins" // [!code highlight] - - const authClient = createAuthClient({ - plugins: [ - oidcClient({ // [!code highlight] - // Your OIDC configuration // [!code highlight] - }) // [!code highlight] - ] - }) - ``` - - - -## Usage - -Once installed, you can utilize the OIDC Provider to manage authentication flows within your application. - -### Register a New Client - -To register a new OIDC client, use the `oauth2.register` method on the client or `auth.api.registerOAuthApplication` on the server. - - - ```ts - type registerOAuthApplication = { - /** - * A list of redirect URIs. - */ - redirect_uris: string[] = ["https://client.example.com/callback"] - /** - * The authentication method for the token endpoint. - */ - token_endpoint_auth_method?: "none" | "client_secret_basic" | "client_secret_post" = "client_secret_basic" - /** - * The grant types supported by the application. - */ - grant_types?: ("authorization_code" | "implicit" | "password" | "client_credentials" | "refresh_token" | "urn:ietf:params:oauth:grant-type:jwt-bearer" | "urn:ietf:params:oauth:grant-type:saml2-bearer")[] = ["authorization_code"] - /** - * The response types supported by the application. - */ - response_types?: ("code" | "token")[] = ["code"] - /** - * The name of the application. - */ - client_name?: string = "My App" - /** - * The URI of the application. - */ - client_uri?: string = "https://client.example.com" - /** - * The URI of the application logo. - */ - logo_uri?: string = "https://client.example.com/logo.png" - /** - * The scopes supported by the application. Separated by spaces. - */ - scope?: string = "profile email" - /** - * The contact information for the application. - */ - contacts?: string[] = ["admin@example.com"] - /** - * The URI of the application terms of service. - */ - tos_uri?: string = "https://client.example.com/tos" - /** - * The URI of the application privacy policy. - */ - policy_uri?: string = "https://client.example.com/policy" - /** - * The URI of the application JWKS. - */ - jwks_uri?: string = "https://client.example.com/jwks" - /** - * The JWKS of the application. - */ - jwks?: Record = {"keys": [{"kty": "RSA", "alg": "RS256", "use": "sig", "n": "...", "e": "..."}]} - /** - * The metadata of the application. - */ - metadata?: Record = {"key": "value"} - /** - * The software ID of the application. - */ - software_id?: string = "my-software" - /** - * The software version of the application. - */ - software_version?: string = "1.0.0" - /** - * The software statement of the application. - */ - software_statement?: string - } - ``` - - - - This endpoint supports [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591) compliant client registration. - - -Once the application is created, you will receive a `client_id` and `client_secret` that you can display to the user. - -### Trusted Clients - -For first-party applications and internal services, you can configure trusted clients directly in your OIDC provider configuration. Trusted clients bypass database lookups for better performance and can optionally skip consent screens for improved user experience. - -```ts title="auth.ts" -import { betterAuth } from "better-auth"; -import { oidcProvider } from "better-auth/plugins"; - -const auth = betterAuth({ - plugins: [ - oidcProvider({ - loginPage: "/sign-in", - trustedClients: [ - { - clientId: "internal-dashboard", - clientSecret: "secure-secret-here", - name: "Internal Dashboard", - type: "web", - redirectUrls: ["https://dashboard.company.com/auth/callback"], - disabled: false, - skipConsent: true, // Skip consent for this trusted client - metadata: { internal: true } - }, - { - clientId: "mobile-app", - clientSecret: "mobile-secret", - name: "Company Mobile App", - type: "native", - redirectUrls: ["com.company.app://auth"], - disabled: false, - skipConsent: false, // Still require consent if needed - metadata: {} - } - ] - })] -}) -``` - -### 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. - - - -#### Server-Side Usage - -```ts title="server.ts" -import { auth } from "@/lib/auth"; - -const userInfo = await auth.api.oAuth2userInfo({ - headers: { - authorization: "Bearer ACCESS_TOKEN" - } -}); -// userInfo contains user details based on the scopes granted -``` - -#### Client-Side Usage (For Third-Party OAuth Clients) - -Third-party OAuth clients can call the UserInfo endpoint using standard HTTP requests: - -```ts title="external-client.ts" -const response = await fetch('https://your-domain.com/api/auth/oauth2/userinfo', { - headers: { - 'Authorization': 'Bearer ACCESS_TOKEN' - } -}); - -const userInfo = await response.json(); -``` - -**Returned claims based on scopes:** - -* 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` - -#### Custom Claims - -The `getAdditionalUserInfoClaim` function receives the user object, requested scopes array, and the client, allowing you to conditionally include claims based on the scopes granted during authorization. These additional claims will be included in both the UserInfo endpoint response and the ID token. - -```ts title="auth.ts" -import { betterAuth } from "better-auth"; -import { oidcProvider } from "better-auth/plugins"; - -export const auth = betterAuth({ - plugins: [ - oidcProvider({ - loginPage: "/sign-in", - getAdditionalUserInfoClaim: async (user, scopes, client) => { - const claims: Record = {}; - - // Add custom claims based on scopes - if (scopes.includes("profile")) { - claims.department = user.department; - claims.job_title = user.jobTitle; - } - - // Add claims based on client metadata - if (client.metadata?.includeRoles) { - claims.roles = user.roles; - } - - return claims; - } - }) - ] -}); -``` - -### Consent Screen - -When a user is redirected to the OIDC provider for authentication, they may be prompted to authorize the application to access their data. This is known as the consent screen. By default, Better Auth will display a sample consent screen. You can customize the consent screen by providing a `consentPage` option during initialization. - -**Note**: Trusted clients with `skipConsent: true` will bypass the consent screen entirely, providing a seamless experience for first-party applications. - -```ts title="auth.ts" -import { betterAuth } from "better-auth"; -import { oidcProvider } from "better-auth/plugins"; - -export const auth = betterAuth({ - plugins: [ - oidcProvider({ - consentPage: "/path/to/consent/page" - }) - ] -}) -``` - -The plugin will redirect the user to the specified path with `consent_code`, `client_id` and `scope` query parameters. You can use this information to display a custom consent screen. Once the user consents, you can call `oauth2.consent` to complete the authorization. - - - -The consent endpoint supports two methods for passing the consent code: - -**Method 1: URL Parameter** - -```ts title="consent-page.ts" -import { authClient } from "@/lib/auth-client" - -// Get the consent code from the URL -const params = new URLSearchParams(window.location.search); - -// Submit consent with the code in the request body -const consentCode = params.get('consent_code'); -if (!consentCode) { - throw new Error('Consent code not found in URL parameters'); -} - -const res = await authClient.oauth2.consent({ - accept: true, // or false to deny - consent_code: consentCode, -}); -``` - -**Method 2: Cookie-Based** - -```ts title="consent-page.ts" -import { authClient } from "@/lib/auth-client" - -// The consent code is automatically stored in a signed cookie -// Just submit the consent decision -const res = await authClient.oauth2.consent({ - accept: true, // or false to deny - // consent_code not needed when using cookie-based flow -}); -``` - -Both methods are fully supported. The URL parameter method works well with mobile apps and third-party contexts, while the cookie-based method provides a simpler implementation for web applications. - -### Handling Login - -When a user is redirected to the OIDC provider for authentication, if they are not already logged in, they will be redirected to the login page. You can customize the login page by providing a `loginPage` option during initialization. - -```ts title="auth.ts" -import { betterAuth } from "better-auth"; - -export const auth = betterAuth({ - plugins: [ - oidcProvider({ - loginPage: "/sign-in" - }) - ] -}) -``` - -You don't need to handle anything from your side; when a new session is created, the plugin will handle continuing the authorization flow. - -## Configuration - -### OIDC Metadata - -Customize the OIDC metadata by providing a configuration object during initialization. - -```ts title="auth.ts" -import { betterAuth } from "better-auth"; -import { oidcProvider } from "better-auth/plugins"; - -export const auth = betterAuth({ - plugins: [ - oidcProvider({ - metadata: { - issuer: "https://your-domain.com", - authorization_endpoint: "/custom/oauth2/authorize", - token_endpoint: "/custom/oauth2/token", - // ...other custom metadata - } - }) - ] -}) -``` - -### JWKS Endpoint - -The OIDC Provider plugin can integrate with the JWT plugin to provide asymmetric key signing for ID tokens verifiable at a JWKS endpoint. - -To make your plugin OIDC compliant, you **MUST** disable the `/token` endpoint, the OAuth equivalent is located at `/oauth2/token` instead. - -```ts title="auth.ts" -import { betterAuth } from "better-auth"; -import { oidcProvider } from "better-auth/plugins"; -import { jwt } from "better-auth/plugins"; - -export const auth = betterAuth({ - disabledPaths: [ - "/token", - ], - plugins: [ - jwt(), // Make sure to add the JWT plugin - oidcProvider({ - useJWTPlugin: true, // Enable JWT plugin integration - loginPage: "/sign-in", - // ... other options - }) - ] -}) -``` - - - When `useJWTPlugin: false` (default), ID tokens are signed with the application secret. - - -### Dynamic Client Registration - -If you want to allow clients to register dynamically, you can enable this feature by setting the `allowDynamicClientRegistration` option to `true`. - -```ts title="auth.ts" -const auth = betterAuth({ - plugins: [ - oidcProvider({ - allowDynamicClientRegistration: true, - }) - ] -}) -``` - -This will allow clients to register using the `/register` endpoint to be publicly available. - -## Schema - -The OIDC Provider plugin adds the following tables to the database: - -### OAuth Application - -Table Name: `oauthApplication` - -export const oauthApplicationTableFields = [ - { - name: "id", - type: "string", - description: "Database ID of the OAuth client", - isPrimaryKey: true, - }, - { - name: "clientId", - type: "string", - description: "Unique identifier for each OAuth client", - isUnique: true, - }, - { - name: "clientSecret", - type: "string", - description: - "Secret key for the OAuth client. Optional for public clients using PKCE.", - isOptional: true, - }, - { - name: "icon", - type: "string", - description: "Icon of the OAuth client", - isOptional: true, - }, - { - name: "name", - type: "string", - description: "Name of the OAuth client", - }, - { - name: "redirectUrls", - type: "string", - description: "Comma-separated list of redirect URLs", - }, - { - name: "metadata", - type: "string", - description: "Additional metadata for the OAuth client", - isOptional: true, - }, - { - name: "type", - type: "string", - description: "Type of OAuth client (e.g., web, mobile)", - }, - { - name: "disabled", - type: "boolean", - description: "Indicates if the client is disabled", - isOptional: true, - }, - { - name: "userId", - type: "string", - description: "ID of the user who owns the client. (optional)", - isOptional: true, - isForeignKey: true, - references: { model: "user", field: "id", onDelete: "cascade" }, - }, - { - name: "createdAt", - type: "Date", - description: "Timestamp of when the OAuth client was created", - }, - { - name: "updatedAt", - type: "Date", - description: "Timestamp of when the OAuth client was last updated", - }, -]; - - - -### OAuth Access Token - -Table Name: `oauthAccessToken` - -export const oauthAccessTokenTableFields = [ - { - name: "id", - type: "string", - description: "Database ID of the access token", - isPrimaryKey: true, - }, - { - name: "accessToken", - type: "string", - description: "Access token issued to the client", - isUnique: true, - }, - { - name: "refreshToken", - type: "string", - description: "Refresh token issued to the client", - isUnique: true, - }, - { - name: "accessTokenExpiresAt", - type: "Date", - description: "Expiration date of the access token", - }, - { - name: "refreshTokenExpiresAt", - type: "Date", - description: "Expiration date of the refresh token", - }, - { - name: "clientId", - type: "string", - description: "ID of the OAuth client", - isForeignKey: true, - references: { model: "oauthApplication", field: "clientId", onDelete: "cascade" }, - }, - { - name: "userId", - type: "string", - description: "ID of the user associated with the token", - isOptional: true, - isForeignKey: true, - references: { model: "user", field: "id", onDelete: "cascade" }, - }, - { - name: "scopes", - type: "string", - description: "Comma-separated list of scopes granted", - }, - { - name: "createdAt", - type: "Date", - description: "Timestamp of when the access token was created", - }, - { - name: "updatedAt", - type: "Date", - description: "Timestamp of when the access token was last updated", - }, -]; - - - -### OAuth Consent - -Table Name: `oauthConsent` - -export const oauthConsentTableFields = [ - { - name: "id", - type: "string", - description: "Database ID of the consent", - isPrimaryKey: true, - }, - { - name: "userId", - type: "string", - description: "ID of the user who gave consent", - isForeignKey: true, - references: { model: "user", field: "id", onDelete: "cascade" }, - }, - { - name: "clientId", - type: "string", - description: "ID of the OAuth client", - isForeignKey: true, - references: { model: "oauthApplication", field: "clientId", onDelete: "cascade" }, - }, - { - name: "scopes", - type: "string", - description: "Comma-separated list of scopes consented to", - }, - { - name: "consentGiven", - type: "boolean", - description: "Indicates if consent was given", - }, - { - name: "createdAt", - type: "Date", - description: "Timestamp of when the consent was given", - }, - { - name: "updatedAt", - type: "Date", - description: "Timestamp of when the consent was last updated", - }, -]; - - - -## Options - -**allowDynamicClientRegistration**: `boolean` - Enable or disable dynamic client registration. - -**metadata**: `OIDCMetadata` - Customize the OIDC provider metadata. - -**loginPage**: `string` - Path to the custom login page. - -**consentPage**: `string` - Path to the custom consent page. - -**trustedClients**: `(Client & { skipConsent?: boolean })[]` - Array of trusted clients that are configured directly in the provider options. These clients bypass database lookups and can optionally skip consent screens. - -**getAdditionalUserInfoClaim**: `(user: User, scopes: string[], client: Client) => Record` - Function to get additional user info claims. - -**useJWTPlugin**: `boolean` - When `true`, ID tokens are signed using the JWT plugin's asymmetric keys. When `false` (default), ID tokens are signed with HMAC-SHA256 using the application secret. - -**schema**: `AuthPluginSchema` - Customize the OIDC provider schema. diff --git a/docs/lib/plugins.ts b/docs/lib/plugins.ts index 87bd13af1a..1357f7bbf4 100644 --- a/docs/lib/plugins.ts +++ b/docs/lib/plugins.ts @@ -90,11 +90,6 @@ const pluginMeta: Record< tagline: "Multi-tenant organizations with roles and invitations", }, // Enterprise - "oidc-provider": { - category: "Enterprise", - icon: "Globe", - tagline: "OpenID Connect provider for federated identity", - }, "oauth-provider": { category: "Enterprise", icon: "Server", diff --git a/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/src/oidc-provider.ts b/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/src/oidc-provider.ts deleted file mode 100644 index fb57ceb638..0000000000 --- a/e2e/smoke/test/fixtures/tsconfig-exact-optional-property-types/src/oidc-provider.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { betterAuth } from "better-auth"; -import { oidcProvider } from "better-auth/plugins"; - -export const auth = betterAuth({ - plugins: [ - oidcProvider({ - loginPage: "/login", - }), - ], -}); diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index f2953f52cd..d7b72cc90b 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -251,11 +251,6 @@ "types": "./dist/plugins/haveibeenpwned/index.d.mts", "default": "./dist/plugins/haveibeenpwned/index.mjs" }, - "./plugins/oidc-provider": { - "dev-source": "./src/plugins/oidc-provider/index.ts", - "types": "./dist/plugins/oidc-provider/index.d.mts", - "default": "./dist/plugins/oidc-provider/index.mjs" - }, "./plugins/magic-link": { "dev-source": "./src/plugins/magic-link/index.ts", "types": "./dist/plugins/magic-link/index.d.mts", @@ -440,9 +435,6 @@ "plugins/one-time-token": [ "./dist/plugins/one-time-token/index.d.mts" ], - "plugins/oidc-provider": [ - "./dist/plugins/oidc-provider/index.d.mts" - ], "plugins/jwt": [ "./dist/plugins/jwt/index.d.mts" ], @@ -504,7 +496,6 @@ "@opentelemetry/sdk-trace-base": "^1.30.0", "@opentelemetry/sdk-trace-node": "^1.30.0", "happy-dom": "^20.8.9", - "listhen": "^1.9.0", "msw": "^2.12.10", "next": "^16.2.6", "oauth2-mock-server": "^8.2.2", diff --git a/packages/better-auth/src/client/client.test.ts b/packages/better-auth/src/client/client.test.ts index 3ca5cf84c2..17650723ad 100644 --- a/packages/better-auth/src/client/client.test.ts +++ b/packages/better-auth/src/client/client.test.ts @@ -13,7 +13,6 @@ import { emailOTPClient, magicLinkClient, multiSessionClient, - oidcClient, organizationClient, twoFactorClient, } from "./plugins"; @@ -857,7 +856,6 @@ describe("type", () => { emailOTPClient(), adminClient(), multiSessionClient(), - oidcClient(), deviceAuthorizationClient(), testClientPlugin(), testClientPlugin2(), diff --git a/packages/better-auth/src/client/plugins/index.ts b/packages/better-auth/src/client/plugins/index.ts index 2502f2877f..e585f5d707 100644 --- a/packages/better-auth/src/client/plugins/index.ts +++ b/packages/better-auth/src/client/plugins/index.ts @@ -18,7 +18,6 @@ export * from "../../plugins/last-login-method/client"; export * from "../../plugins/magic-link/client"; export * from "../../plugins/multi-session/client"; export * from "../../plugins/oauth-popup/client"; -export * from "../../plugins/oidc-provider/client"; export * from "../../plugins/one-tap/client"; export * from "../../plugins/one-time-token/client"; export * from "../../plugins/organization/client"; diff --git a/packages/better-auth/src/plugins/index.ts b/packages/better-auth/src/plugins/index.ts index 4b8c689bbc..10593cb877 100644 --- a/packages/better-auth/src/plugins/index.ts +++ b/packages/better-auth/src/plugins/index.ts @@ -16,7 +16,6 @@ export * from "./magic-link"; export * from "./multi-session"; export * from "./oauth-popup"; export * from "./oauth-proxy"; -export * from "./oidc-provider"; export * from "./one-tap"; export * from "./one-time-token"; export * from "./open-api"; diff --git a/packages/better-auth/src/plugins/jwt/index.ts b/packages/better-auth/src/plugins/jwt/index.ts index 7375efddff..e0bbf943ba 100644 --- a/packages/better-auth/src/plugins/jwt/index.ts +++ b/packages/better-auth/src/plugins/jwt/index.ts @@ -50,7 +50,7 @@ export const jwt = (options?: O) => { // Alg is required to be specified when using remote url (needed in openid metadata) if (options?.jwks?.remoteUrl && !options.jwks?.keyPairConfig?.alg) { throw new BetterAuthError( - "options.jwks.keyPairConfig.alg must be specified when using the oidc plugin with options.jwks.remoteUrl", + "options.jwks.keyPairConfig.alg must be specified when options.jwks.remoteUrl is used for OpenID metadata", ); } diff --git a/packages/better-auth/src/plugins/jwt/jwt.test.ts b/packages/better-auth/src/plugins/jwt/jwt.test.ts index 993fe876d1..93dda57972 100644 --- a/packages/better-auth/src/plugins/jwt/jwt.test.ts +++ b/packages/better-auth/src/plugins/jwt/jwt.test.ts @@ -438,7 +438,7 @@ describe("jwt - remote url", async () => { ], }), ).toThrowError( - "options.jwks.keyPairConfig.alg must be specified when using the oidc plugin with options.jwks.remoteUrl", + "options.jwks.keyPairConfig.alg must be specified when options.jwks.remoteUrl is used for OpenID metadata", ); }); diff --git a/packages/better-auth/src/plugins/oidc-provider/authorize.ts b/packages/better-auth/src/plugins/oidc-provider/authorize.ts deleted file mode 100644 index 39b054927e..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/authorize.ts +++ /dev/null @@ -1,435 +0,0 @@ -import type { GenericEndpointContext } from "@better-auth/core"; -import { APIError } from "@better-auth/core/error"; -import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata"; -import { getSessionFromCtx } from "../../api"; -import { generateRandomString } from "../../crypto"; -import { InvalidClient, InvalidRequest } from "./error"; -import { getClient } from "./index"; -import type { AuthorizationQuery, OIDCOptions } from "./types"; -import { parsePrompt } from "./utils/prompt"; - -function formatErrorURL(url: string, error: string, description: string) { - return `${url}${ - url.includes("?") ? "&" : "?" - }error=${error}&error_description=${description}`; -} - -function getErrorURL( - ctx: GenericEndpointContext, - error: string, - description: string, -) { - const baseURL = - ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`; - const formattedURL = formatErrorURL(baseURL, error, description); - return formattedURL; -} - -export async function authorize( - ctx: GenericEndpointContext, - options: OIDCOptions, -) { - const handleRedirect = (url: string) => { - const fromFetch = isBrowserFetchRequest(ctx.request?.headers); - if (fromFetch) { - return ctx.json({ - redirect: true, - url, - }); - } else { - throw ctx.redirect(url); - } - }; - - const opts = { - codeExpiresIn: 600, - defaultScope: "openid", - ...options, - scopes: [ - "openid", - "profile", - "email", - "offline_access", - ...(options?.scopes || []), - ], - }; - if (!ctx.request) { - throw new APIError("UNAUTHORIZED", { - error_description: "request not found", - error: "invalid_request", - }); - } - const query = ctx.query as AuthorizationQuery; - const session = await getSessionFromCtx(ctx); - if (!session) { - // Handle prompt=none per OIDC spec - must return error instead of redirecting - const promptSet = parsePrompt(query.prompt ?? ""); - if (promptSet.has("none")) { - if (!query.redirect_uri) { - throw new InvalidRequest( - "redirect_uri is required when prompt=none and must be usable to return errors without displaying UI", - ); - } - if (!query.client_id) { - throw new InvalidClient("client_id is required"); - } - const client = await getClient( - query.client_id, - options.trustedClients || [], - ); - if (!client) { - throw new InvalidClient("client_id is required"); - } - const validRedirectURI = client.redirectUrls.find( - (url) => url === query.redirect_uri, - ); - if (!validRedirectURI) { - throw new InvalidRequest( - "redirect_uri is invalid or not registered for this client", - ); - } - return handleRedirect( - formatErrorURL( - validRedirectURI, - "login_required", - "Authentication required but prompt is none", - ), - ); - } - - /** - * If the user is not logged in, we need to redirect them to the - * login page. - */ - await ctx.setSignedCookie( - "oidc_login_prompt", - JSON.stringify(ctx.query), - ctx.context.secret, - { - maxAge: 600, - path: "/", - sameSite: "lax", - }, - ); - const queryFromURL = ctx.request.url?.split("?")[1]!; - return handleRedirect(`${options.loginPage}?${queryFromURL}`); - } - - if (!query.client_id) { - const errorURL = getErrorURL( - ctx, - "invalid_client", - "client_id is required", - ); - throw ctx.redirect(errorURL); - } - - if (!query.response_type) { - const errorURL = getErrorURL( - ctx, - "invalid_request", - "response_type is required", - ); - throw ctx.redirect(errorURL); - } - - const client = await getClient( - ctx.query.client_id, - options.trustedClients || [], - ); - if (!client) { - const errorURL = getErrorURL( - ctx, - "invalid_client", - "client_id is required", - ); - throw ctx.redirect(errorURL); - } - const redirectURI = client.redirectUrls.find( - (url) => url === ctx.query.redirect_uri, - ); - - if (!redirectURI || !query.redirect_uri) { - /** - * show UI error here warning the user that the redirect URI is invalid - */ - throw new APIError("BAD_REQUEST", { - message: "Invalid redirect URI", - }); - } - if (client.disabled) { - const errorURL = getErrorURL(ctx, "client_disabled", "client is disabled"); - throw ctx.redirect(errorURL); - } - - if (query.response_type !== "code") { - const errorURL = getErrorURL( - ctx, - "unsupported_response_type", - "unsupported response type", - ); - throw ctx.redirect(errorURL); - } - - const requestScope = - query.scope?.split(" ").filter((s) => s) || - opts.defaultScope?.split(" ") || - []; - const invalidScopes = requestScope.filter((scope) => { - return !opts.scopes.includes(scope); - }); - if (invalidScopes.length) { - return handleRedirect( - formatErrorURL( - query.redirect_uri, - "invalid_scope", - `The following scopes are invalid: ${invalidScopes.join(", ")}`, - ), - ); - } - - if ( - (!query.code_challenge || !query.code_challenge_method) && - options.requirePKCE - ) { - return handleRedirect( - formatErrorURL(query.redirect_uri, "invalid_request", "pkce is required"), - ); - } - - if (query.code_challenge_method && !query.code_challenge) { - return handleRedirect( - formatErrorURL( - query.redirect_uri, - "invalid_request", - "code_challenge_method requires code_challenge", - ), - ); - } - - if (query.code_challenge) { - const allowedCodeChallengeMethods = options.allowPlainCodeChallengeMethod - ? ["s256", "plain"] - : ["s256"]; - let codeChallengeMethod: AuthorizationQuery["code_challenge_method"] = - query.code_challenge_method?.toLowerCase() as AuthorizationQuery["code_challenge_method"]; - // Backward-compat: callers who explicitly opt into plain PKCE retain the - // legacy "default missing method to `plain`" behavior. The secure default - // (`allowPlainCodeChallengeMethod: false`) still rejects a missing method - // as `invalid_request`. The whole branch should be removed once the next - // minor drops the `plain` PKCE allowlist entry (see FIXME below). - // FIXME(legacy-plain-pkce-removal): remove this fallback on next; require - // callers to send `code_challenge_method` explicitly. - if (!codeChallengeMethod && options.allowPlainCodeChallengeMethod) { - codeChallengeMethod = "plain"; - } - if ( - !codeChallengeMethod || - !allowedCodeChallengeMethods.includes(codeChallengeMethod) - ) { - return handleRedirect( - formatErrorURL( - query.redirect_uri, - "invalid_request", - "invalid code_challenge method", - ), - ); - } - // Persist the normalized value back so the verification record stores the - // lowercased and (optionally) fallback-resolved method. The token endpoint - // compares against `"plain"` exactly, so casing variations or a missing - // method on the opt-in path would otherwise break PKCE verification. - query.code_challenge_method = codeChallengeMethod; - } - - const code = generateRandomString(32, "a-z", "A-Z", "0-9"); - const codeExpiresInMs = opts.codeExpiresIn! * 1000; - const expiresAt = new Date(Date.now() + codeExpiresInMs); - - // Determine if consent is required - // Consent is ALWAYS required unless: - // 1. The client is trusted (skipConsent = true) - // 2. The user has already consented and prompt is not "consent" - const skipConsentForTrustedClient = client.skipConsent; - const hasAlreadyConsented = await ctx.context.adapter - .findOne<{ - consentGiven: boolean; - scopes: string; - }>({ - model: "oauthConsent", - where: [ - { - field: "clientId", - value: client.clientId, - }, - { - field: "userId", - value: session.user.id, - }, - ], - }) - .then((res) => { - if (!res?.consentGiven) { - return false; - } - const consentedScopes = res.scopes ? res.scopes.split(" ") : []; - const hasConsented = requestScope.every((scope) => - consentedScopes.includes(scope), - ); - return hasConsented; - }); - - const promptSet = parsePrompt(query.prompt ?? ""); - - // Handle prompt=none per OIDC spec 3.1.2.1 - // The Authorization Server MUST NOT display any authentication or consent UI - if (promptSet.has("none")) { - // If consent is required, return consent_required error - if (!skipConsentForTrustedClient && !hasAlreadyConsented) { - return handleRedirect( - formatErrorURL( - query.redirect_uri, - "consent_required", - "Consent required but prompt is none", - ), - ); - } - // If we reach here, user is authenticated and consent is satisfied - // Continue without any UI interaction - } - - // Handle max_age parameter per OIDC spec 3.1.2.1 - // max_age=0 is equivalent to prompt=login - let requireLogin = promptSet.has("login"); - if (query.max_age !== undefined) { - const maxAge = Number(query.max_age); - if (Number.isInteger(maxAge) && maxAge >= 0) { - const sessionAge = - (Date.now() - new Date(session.session.createdAt).getTime()) / 1000; - if (sessionAge > maxAge) { - // Session is older than max_age, force re-authentication - requireLogin = true; - } - } - // If max_age is invalid (not a non-negative integer), ignore it per OIDC spec - } - - const requireConsent = - !skipConsentForTrustedClient && - (!hasAlreadyConsented || promptSet.has("consent")); - - try { - /** - * Save the code in the database - */ - await ctx.context.internalAdapter.createVerificationValue({ - value: JSON.stringify({ - clientId: client.clientId, - redirectURI: query.redirect_uri, - scope: requestScope, - userId: session.user.id, - authTime: new Date(session.session.createdAt).getTime(), - /** - * Consent is required per OIDC spec unless: - * 1. Client is trusted (skipConsent = true) - * 2. User has already consented (and prompt is not "consent") - * - * When consent is required, the code needs to be treated as a - * consent request. Once the user consents, the code will be - * updated with the actual authorization code. - */ - requireConsent, - state: requireConsent ? query.state : null, - codeChallenge: query.code_challenge, - codeChallengeMethod: query.code_challenge_method, - nonce: query.nonce, - }), - identifier: code, - expiresAt, - }); - } catch { - return handleRedirect( - formatErrorURL( - query.redirect_uri, - "server_error", - "An error occurred while processing the request", - ), - ); - } - - if (requireLogin) { - await ctx.setSignedCookie( - "oidc_login_prompt", - JSON.stringify(ctx.query), - ctx.context.secret, - { - maxAge: 600, - path: "/", - sameSite: "lax", - }, - ); - await ctx.setSignedCookie("oidc_consent_prompt", code, ctx.context.secret, { - maxAge: 600, - path: "/", - sameSite: "lax", - }); - - const loginURI = `${options.loginPage}?${new URLSearchParams({ - client_id: client.clientId, - code, - state: query.state, - }).toString()}`; - return handleRedirect(loginURI); - } - - // If consent is not required, redirect with the code immediately - if (!requireConsent) { - const redirectURIWithCode = new URL(redirectURI); - redirectURIWithCode.searchParams.set("code", code); - redirectURIWithCode.searchParams.set("state", ctx.query.state); - return handleRedirect(redirectURIWithCode.toString()); - } - - // Consent is required - redirect to consent page or show consent HTML - - if (options?.consentPage) { - // Set cookie to support cookie-based consent flows - await ctx.setSignedCookie("oidc_consent_prompt", code, ctx.context.secret, { - maxAge: 600, - path: "/", - sameSite: "lax", - }); - - // Pass the consent code as a URL parameter to support URL-based consent flows - const urlParams = new URLSearchParams(); - urlParams.set("consent_code", code); - urlParams.set("client_id", client.clientId); - urlParams.set("scope", requestScope.join(" ")); - const consentURI = `${options.consentPage}?${urlParams.toString()}`; - - return handleRedirect(consentURI); - } - const htmlFn = options?.getConsentHTML; - - if (!htmlFn) { - throw new APIError("INTERNAL_SERVER_ERROR", { - message: "No consent page provided", - }); - } - - return new Response( - htmlFn({ - scopes: requestScope, - clientMetadata: client.metadata, - clientIcon: client?.icon, - clientId: client.clientId, - clientName: client.name, - code, - }), - { - headers: { - "content-type": "text/html", - }, - }, - ); -} diff --git a/packages/better-auth/src/plugins/oidc-provider/client.ts b/packages/better-auth/src/plugins/oidc-provider/client.ts deleted file mode 100644 index 884242154f..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/client.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { BetterAuthClientPlugin } from "@better-auth/core"; -import { PACKAGE_VERSION } from "../../version"; -import type { oidcProvider } from "."; - -/** - * @deprecated Use `@better-auth/oauth-provider` instead. This plugin will be removed in the next major version. - * @see https://www.better-auth.com/docs/plugins/oauth-provider - */ -export const oidcClient = () => { - return { - id: "oidc-client", - version: PACKAGE_VERSION, - $InferServerPlugin: {} as ReturnType, - } satisfies BetterAuthClientPlugin; -}; - -export type OidcClientPlugin = ReturnType; - -export type * from "./types"; diff --git a/packages/better-auth/src/plugins/oidc-provider/error.ts b/packages/better-auth/src/plugins/oidc-provider/error.ts deleted file mode 100644 index 5d122e39c6..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/error.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { APIError } from "@better-auth/core/error"; - -class OIDCProviderError extends APIError {} - -export class InvalidRequest extends OIDCProviderError { - constructor(error_description: string, error_detail?: string) { - super("BAD_REQUEST", { - message: error_description, - error: "invalid_request", - error_description, - error_detail, - }); - } -} - -export class InvalidClient extends OIDCProviderError { - constructor(error_description: string) { - super("BAD_REQUEST", { - message: error_description, - error: "invalid_client", - error_description, - }); - } -} diff --git a/packages/better-auth/src/plugins/oidc-provider/index.ts b/packages/better-auth/src/plugins/oidc-provider/index.ts deleted file mode 100644 index e3c73a8956..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/index.ts +++ /dev/null @@ -1,1879 +0,0 @@ -import type { - BetterAuthPlugin, - GenericEndpointContext, -} from "@better-auth/core"; -import { - createAuthEndpoint, - createAuthMiddleware, -} from "@better-auth/core/api"; -import { getCurrentAuthContext } from "@better-auth/core/context"; -import { deprecate } from "@better-auth/core/utils/deprecate"; -import { isSafeUrlScheme } from "@better-auth/core/utils/url"; -import { base64 } from "@better-auth/utils/base64"; -import { createHash } from "@better-auth/utils/hash"; -import type { OpenAPIParameter } from "better-call"; -import { jwtVerify, SignJWT } from "jose"; -import * as z from "zod"; -import { APIError, getSessionFromCtx, sessionMiddleware } from "../../api"; -import { expireCookie, parseSetCookieHeader } from "../../cookies"; -import { - constantTimeEqual, - generateRandomString, - symmetricDecrypt, - symmetricEncrypt, -} from "../../crypto"; -import { mergeSchema } from "../../db"; -import { HIDE_METADATA } from "../../utils"; -import { PACKAGE_VERSION } from "../../version"; -import { getJwtToken, verifyJWT } from "../jwt"; -import { authorize } from "./authorize"; -import type { OAuthApplication } from "./schema"; -import { schema } from "./schema"; -import type { - Client, - CodeVerificationValue, - OAuthAccessToken, - OIDCMetadata, - OIDCOptions, -} from "./types"; -import { defaultClientSecretHasher } from "./utils"; -import { parsePrompt } from "./utils/prompt"; - -declare module "@better-auth/core" { - interface BetterAuthPluginRegistry { - "oidc-provider": { - creator: typeof oidcProvider; - }; - } -} - -/** - * Get a client by ID, checking trusted clients first, then database - */ -export async function getClient( - clientId: string, - trustedClients: (Client & { skipConsent?: boolean | undefined })[] = [], -): Promise<(Client & { skipConsent?: boolean | undefined }) | null> { - const { - context: { adapter }, - } = await getCurrentAuthContext(); - const trustedClient = trustedClients.find( - (client) => client.clientId === clientId, - ); - if (trustedClient) { - return trustedClient; - } - return adapter - .findOne({ - model: "oauthApplication", - where: [{ field: "clientId", value: clientId }], - }) - .then((res) => { - if (!res) { - return null; - } - // omit sensitive fields - return { - clientId: res.clientId, - clientSecret: res.clientSecret, - type: res.type, - name: res.name, - icon: res.icon, - disabled: res.disabled, - redirectUrls: (res.redirectUrls ?? "").split(","), - metadata: res.metadata ? JSON.parse(res.metadata) : {}, - } satisfies Client; - }); -} - -export const getMetadata = ( - ctx: GenericEndpointContext, - options?: OIDCOptions | undefined, -): OIDCMetadata => { - const jwtPlugin = ctx.context.getPlugin("jwt"); - const issuer = - jwtPlugin && jwtPlugin.options?.jwt && jwtPlugin.options.jwt.issuer - ? jwtPlugin.options.jwt.issuer - : (ctx.context.options.baseURL as string); - const baseURL = ctx.context.baseURL; - const supportedAlgs = options?.useJWTPlugin ? ["RS256", "EdDSA"] : ["HS256"]; - return { - issuer, - authorization_endpoint: `${baseURL}/oauth2/authorize`, - token_endpoint: `${baseURL}/oauth2/token`, - userinfo_endpoint: `${baseURL}/oauth2/userinfo`, - jwks_uri: `${baseURL}/jwks`, - registration_endpoint: `${baseURL}/oauth2/register`, - end_session_endpoint: `${baseURL}/oauth2/endsession`, - scopes_supported: ["openid", "profile", "email", "offline_access"], - response_types_supported: ["code"], - response_modes_supported: ["query"], - grant_types_supported: ["authorization_code", "refresh_token"], - acr_values_supported: [ - "urn:mace:incommon:iap:silver", - "urn:mace:incommon:iap:bronze", - ], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: supportedAlgs, - token_endpoint_auth_methods_supported: [ - "client_secret_basic", - "client_secret_post", - "none", - ], - code_challenge_methods_supported: ["S256"], - claims_supported: [ - "sub", - "iss", - "aud", - "exp", - "nbf", - "iat", - "jti", - "email", - "email_verified", - "name", - ], - ...options?.metadata, - }; -}; - -const oAuthConsentBodySchema = z.object({ - accept: z.boolean(), - consent_code: z.string().optional().nullish(), -}); - -const oAuth2TokenBodySchema = z.record(z.any(), z.any()); - -const registerOAuthApplicationBodySchema = z.object({ - // This plugin is deprecated and removed in the next major. It gets only the - // non-breaking guard that rejects code-execution schemes (javascript:, data:, - // vbscript:). The stricter https-or-loopback policy lives in - // @better-auth/oauth-provider via SafeUrlSchema; migrating there is the path to - // full parity, so we do not tighten this plugin further. - redirect_uris: z - .array( - z.string().refine(isSafeUrlScheme, { - message: - "redirect_uri cannot use a javascript:, data:, or vbscript: scheme", - }), - ) - .meta({ - description: - 'A list of redirect URIs. Eg: ["https://client.example.com/callback"]', - }), - token_endpoint_auth_method: z - .enum(["none", "client_secret_basic", "client_secret_post"]) - .meta({ - description: - 'The authentication method for the token endpoint. Eg: "client_secret_basic"', - }) - .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", - ]), - ) - .meta({ - description: - 'The grant types supported by the application. Eg: ["authorization_code"]', - }) - .default(["authorization_code"]) - .optional(), - response_types: z - .array(z.enum(["code", "token"])) - .meta({ - description: - 'The response types supported by the application. Eg: ["code"]', - }) - .default(["code"]) - .optional(), - client_name: z - .string() - .meta({ - description: 'The name of the application. Eg: "My App"', - }) - .optional(), - client_uri: z - .string() - .meta({ - description: - 'The URI of the application. Eg: "https://client.example.com"', - }) - .optional(), - logo_uri: z - .string() - .meta({ - description: - 'The URI of the application logo. Eg: "https://client.example.com/logo.png"', - }) - .optional(), - scope: z - .string() - .meta({ - description: - 'The scopes supported by the application. Separated by spaces. Eg: "profile email"', - }) - .optional(), - contacts: z - .array(z.string()) - .meta({ - description: - 'The contact information for the application. Eg: ["admin@example.com"]', - }) - .optional(), - tos_uri: z - .string() - .meta({ - description: - 'The URI of the application terms of service. Eg: "https://client.example.com/tos"', - }) - .optional(), - policy_uri: z - .string() - .meta({ - description: - 'The URI of the application privacy policy. Eg: "https://client.example.com/policy"', - }) - .optional(), - jwks_uri: z - .string() - .meta({ - description: - 'The URI of the application JWKS. Eg: "https://client.example.com/jwks"', - }) - .optional(), - jwks: z - .record(z.any(), z.any()) - .meta({ - description: - 'The JWKS of the application. Eg: {"keys": [{"kty": "RSA", "alg": "RS256", "use": "sig", "n": "...", "e": "..."}]}', - }) - .optional(), - metadata: z - .record(z.any(), z.any()) - .meta({ - description: 'The metadata of the application. Eg: {"key": "value"}', - }) - .optional(), - software_id: z - .string() - .meta({ - description: 'The software ID of the application. Eg: "my-software"', - }) - .optional(), - software_version: z - .string() - .meta({ - description: 'The software version of the application. Eg: "1.0.0"', - }) - .optional(), - software_statement: z - .string() - .meta({ - description: "The software statement of the application.", - }) - .optional(), -}); - -const DEFAULT_CODE_EXPIRES_IN = 600; -const DEFAULT_ACCESS_TOKEN_EXPIRES_IN = 3600; -const DEFAULT_REFRESH_TOKEN_EXPIRES_IN = 604800; - -const warnOidcDeprecation = deprecate( - () => {}, - 'The "oidc-provider" plugin is deprecated and will be removed in the next major version. ' + - "Migrate to @better-auth/oauth-provider. " + - "See: https://www.better-auth.com/docs/plugins/oauth-provider", -); - -/** - * OpenID Connect (OIDC) plugin for Better Auth. This plugin implements the - * authorization code flow and the token exchange flow. It also implements the - * userinfo endpoint. - * - * @deprecated Use `@better-auth/oauth-provider` instead. This plugin will be removed in the next major version. - * @see https://www.better-auth.com/docs/plugins/oauth-provider - * - * @param options - The options for the OIDC plugin. - * @returns A Better Auth plugin. - */ -export const oidcProvider = (options: OIDCOptions) => { - if (!options.__skipDeprecationWarning) { - warnOidcDeprecation(); - } - const modelName = { - oauthClient: "oauthApplication", - oauthAccessToken: "oauthAccessToken", - oauthConsent: "oauthConsent", - }; - - const opts = { - codeExpiresIn: DEFAULT_CODE_EXPIRES_IN, - defaultScope: "openid", - accessTokenExpiresIn: DEFAULT_ACCESS_TOKEN_EXPIRES_IN, - refreshTokenExpiresIn: DEFAULT_REFRESH_TOKEN_EXPIRES_IN, - allowPlainCodeChallengeMethod: false, - storeClientSecret: "plain" as const, - ...options, - scopes: [ - "openid", - "profile", - "email", - "offline_access", - ...(options?.scopes || []), - ], - }; - - const trustedClients = options.trustedClients || []; - - /** - * Store client secret according to the configured storage method - */ - async function storeClientSecret( - ctx: GenericEndpointContext, - clientSecret: string, - ) { - if (opts.storeClientSecret === "encrypted") { - return await symmetricEncrypt({ - key: ctx.context.secretConfig, - data: clientSecret, - }); - } - if (opts.storeClientSecret === "hashed") { - return await defaultClientSecretHasher(clientSecret); - } - if ( - typeof opts.storeClientSecret === "object" && - "hash" in opts.storeClientSecret - ) { - return await opts.storeClientSecret.hash(clientSecret); - } - if ( - typeof opts.storeClientSecret === "object" && - "encrypt" in opts.storeClientSecret - ) { - return await opts.storeClientSecret.encrypt(clientSecret); - } - - return clientSecret; - } - - /** - * Verify stored client secret against provided client secret - */ - async function verifyStoredClientSecret( - ctx: GenericEndpointContext, - storedClientSecret: string, - clientSecret: string, - ): Promise { - if (opts.storeClientSecret === "encrypted") { - const decrypted = await symmetricDecrypt({ - key: ctx.context.secretConfig, - data: storedClientSecret, - }); - return constantTimeEqual(decrypted, clientSecret); - } - if (opts.storeClientSecret === "hashed") { - const hashedClientSecret = await defaultClientSecretHasher(clientSecret); - return constantTimeEqual(hashedClientSecret, storedClientSecret); - } - if ( - typeof opts.storeClientSecret === "object" && - "hash" in opts.storeClientSecret - ) { - const hashedClientSecret = - await opts.storeClientSecret.hash(clientSecret); - return constantTimeEqual(hashedClientSecret, storedClientSecret); - } - if ( - typeof opts.storeClientSecret === "object" && - "decrypt" in opts.storeClientSecret - ) { - const decryptedClientSecret = - await opts.storeClientSecret.decrypt(storedClientSecret); - return constantTimeEqual(decryptedClientSecret, clientSecret); - } - - return constantTimeEqual(clientSecret, storedClientSecret); - } - - return { - id: "oidc-provider", - version: PACKAGE_VERSION, - hooks: { - after: [ - { - matcher() { - return true; - }, - handler: createAuthMiddleware(async (ctx) => { - const loginPromptCookie = await ctx.getSignedCookie( - "oidc_login_prompt", - ctx.context.secret, - ); - const cookieName = ctx.context.authCookies.sessionToken.name; - const parsedSetCookieHeader = parseSetCookieHeader( - ctx.context.responseHeaders?.get("set-cookie") || "", - ); - const hasSessionToken = parsedSetCookieHeader.has(cookieName); - if (!loginPromptCookie || !hasSessionToken) { - return; - } - expireCookie(ctx, { - name: "oidc_login_prompt", - attributes: { path: "/" }, - }); - const sessionCookie = parsedSetCookieHeader.get(cookieName)?.value; - const sessionToken = sessionCookie?.split(".")[0]!; - if (!sessionToken) { - return; - } - const session = - (await ctx.context.internalAdapter.findSession(sessionToken)) || - ctx.context.newSession; - if (!session) { - return; - } - ctx.query = JSON.parse(loginPromptCookie); - - // Remove "login" from prompt since user just logged in - const promptSet = parsePrompt(String(ctx.query?.prompt)); - if (promptSet.has("login")) { - const newPromptSet = new Set(promptSet); - newPromptSet.delete("login"); - ctx.query = { - ...ctx.query, - prompt: Array.from(newPromptSet).join(" "), - }; - } - - ctx.context.session = session; - const response = await authorize(ctx, opts); - return response; - }), - }, - ], - }, - endpoints: { - getOpenIdConfig: createAuthEndpoint( - "/.well-known/openid-configuration", - { - method: "GET", - operationId: "getOpenIdConfig", - metadata: HIDE_METADATA, - }, - async (ctx) => { - const metadata = getMetadata(ctx, options); - return ctx.json(metadata); - }, - ), - oAuth2authorize: createAuthEndpoint( - "/oauth2/authorize", - { - method: "GET", - operationId: "oauth2Authorize", - query: z.record(z.string(), z.any()), - metadata: { - openapi: { - description: "Authorize an OAuth2 request", - responses: { - "200": { - description: "Authorization response generated successfully", - content: { - "application/json": { - schema: { - type: "object", - additionalProperties: true, - description: - "Authorization response, contents depend on the authorize function implementation", - }, - }, - }, - }, - }, - }, - }, - }, - async (ctx) => { - return authorize(ctx, opts); - }, - ), - oAuthConsent: createAuthEndpoint( - "/oauth2/consent", - { - method: "POST", - operationId: "oauth2Consent", - body: oAuthConsentBodySchema, - use: [sessionMiddleware], - metadata: { - openapi: { - description: - "Handle OAuth2 consent. Supports both URL parameter-based flows (consent_code in body) and cookie-based flows (signed cookie).", - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - properties: { - accept: { - type: "boolean", - description: - "Whether the user accepts or denies the consent request", - }, - consent_code: { - type: "string", - description: - "The consent code from the authorization request. Optional if using cookie-based flow.", - }, - }, - required: ["accept"], - }, - }, - }, - }, - responses: { - "200": { - description: "Consent processed successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - redirectURI: { - type: "string", - format: "uri", - description: - "The URI to redirect to, either with an authorization code or an error", - }, - }, - required: ["redirectURI"], - }, - }, - }, - }, - }, - }, - }, - }, - async (ctx) => { - // Support both consent flow methods: - // 1. URL parameter-based: consent_code in request body (standard OAuth2 pattern) - // 2. Cookie-based: using signed cookie for stateful consent flows - let consentCode: string | null = ctx.body.consent_code || null; - - if (!consentCode) { - // Check for cookie-based consent flow - const cookieValue = await ctx.getSignedCookie( - "oidc_consent_prompt", - ctx.context.secret, - ); - if (cookieValue) { - consentCode = cookieValue; - } - } - - if (!consentCode) { - throw new APIError("UNAUTHORIZED", { - error_description: - "consent_code is required (either in body or cookie)", - error: "invalid_request", - }); - } - - const verification = - await ctx.context.internalAdapter.findVerificationValue( - consentCode, - ); - if (!verification) { - throw new APIError("UNAUTHORIZED", { - error_description: "Invalid code", - error: "invalid_request", - }); - } - if (verification.expiresAt < new Date()) { - throw new APIError("UNAUTHORIZED", { - error_description: "Code expired", - error: "invalid_request", - }); - } - - // Clear the cookie - expireCookie(ctx, { - name: "oidc_consent_prompt", - attributes: { path: "/" }, - }); - - const value = JSON.parse(verification.value) as CodeVerificationValue; - if (!value.requireConsent) { - throw new APIError("UNAUTHORIZED", { - error_description: "Consent not required", - error: "invalid_request", - }); - } - - if (!ctx.body.accept) { - await ctx.context.internalAdapter.deleteVerificationByIdentifier( - consentCode, - ); - return ctx.json({ - redirectURI: `${value.redirectURI}?error=access_denied&error_description=User denied access`, - }); - } - const code = generateRandomString(32, "a-z", "A-Z", "0-9"); - const codeExpiresInMs = - (opts?.codeExpiresIn ?? DEFAULT_CODE_EXPIRES_IN) * 1000; - const expiresAt = new Date(Date.now() + codeExpiresInMs); - await ctx.context.internalAdapter.updateVerificationByIdentifier( - consentCode, - { - value: JSON.stringify({ - ...value, - requireConsent: false, - }), - identifier: code, - expiresAt, - }, - ); - await ctx.context.adapter.create({ - model: modelName.oauthConsent, - data: { - clientId: value.clientId, - userId: value.userId, - scopes: value.scope.join(" "), - consentGiven: true, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - const redirectURI = new URL(value.redirectURI); - redirectURI.searchParams.set("code", code); - if (value.state) redirectURI.searchParams.set("state", value.state); - return ctx.json({ - redirectURI: redirectURI.toString(), - }); - }, - ), - oAuth2token: createAuthEndpoint( - "/oauth2/token", - { - method: "POST", - operationId: "oauth2Token", - body: oAuth2TokenBodySchema, - metadata: { - ...HIDE_METADATA, - allowedMediaTypes: [ - "application/x-www-form-urlencoded", - "application/json", - ], - }, - }, - async (ctx) => { - let { body } = ctx; - if (!body) { - throw new APIError("BAD_REQUEST", { - error_description: "request body not found", - error: "invalid_request", - }); - } - if (body instanceof FormData) { - body = Object.fromEntries(body.entries()); - } - if (!(body instanceof Object)) { - throw new APIError("BAD_REQUEST", { - error_description: "request body is not an object", - error: "invalid_request", - }); - } - let { client_id, client_secret } = body; - const authorization = - ctx.request?.headers.get("authorization") || null; - if ( - authorization && - !client_secret && - authorization.startsWith("Basic ") - ) { - let decoded: string; - try { - const encoded = authorization.replace("Basic ", ""); - decoded = new TextDecoder().decode(base64.decode(encoded)); - } catch { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid authorization header format", - error: "invalid_client", - }); - } - // RFC 6749 §2.3.1: split on the first `:` (the secret may contain - // further colons), then percent-decode each half before comparing - // against stored credentials (the client encodes reserved - // characters per RFC 3986 before base64). - const colonIndex = decoded.indexOf(":"); - if (colonIndex === -1) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid authorization header format", - error: "invalid_client", - }); - } - let id: string; - let secret: string; - try { - id = decodeURIComponent(decoded.slice(0, colonIndex)); - secret = decodeURIComponent(decoded.slice(colonIndex + 1)); - } catch { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid authorization header format", - error: "invalid_client", - }); - } - if (!id || !secret) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid authorization header format", - error: "invalid_client", - }); - } - if (client_id && client_id.toString() !== id) { - throw new APIError("UNAUTHORIZED", { - error_description: - "client_id in body does not match Authorization header", - error: "invalid_client", - }); - } - client_id = id; - client_secret = secret; - } - - const now = Date.now(); - const iat = Math.floor(now / 1000); - const exp = iat + (opts.accessTokenExpiresIn ?? 3600); - - const accessTokenExpiresAt = new Date(exp * 1000); - const refreshTokenExpiresAt = new Date( - (iat + (opts.refreshTokenExpiresIn ?? 604800)) * 1000, - ); - - const { - grant_type, - code, - redirect_uri, - refresh_token, - code_verifier, - } = body; - if (grant_type === "refresh_token") { - if (!refresh_token) { - throw new APIError("BAD_REQUEST", { - error_description: "refresh_token is required", - error: "invalid_request", - }); - } - const token = await ctx.context.adapter.findOne({ - model: modelName.oauthAccessToken, - where: [ - { - field: "refreshToken", - value: refresh_token.toString(), - }, - ], - }); - if (!token) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid refresh token", - error: "invalid_grant", - }); - } - if (token.clientId !== client_id?.toString()) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_id", - error: "invalid_client", - }); - } - if (token.refreshTokenExpiresAt < new Date()) { - throw new APIError("UNAUTHORIZED", { - error_description: "refresh token expired", - error: "invalid_grant", - }); - } - const refreshClient = await getClient( - client_id.toString(), - trustedClients, - ); - if (!refreshClient) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_id", - error: "invalid_client", - }); - } - if (refreshClient.disabled) { - throw new APIError("UNAUTHORIZED", { - error_description: "client is disabled", - error: "invalid_client", - }); - } - if (refreshClient.type !== "public") { - if (!refreshClient.clientSecret || !client_secret) { - throw new APIError("UNAUTHORIZED", { - error_description: - "client_secret is required for confidential clients", - error: "invalid_client", - }); - } - const isValidSecret = await verifyStoredClientSecret( - ctx, - refreshClient.clientSecret, - client_secret.toString(), - ); - if (!isValidSecret) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_secret", - error: "invalid_client", - }); - } - } - const accessToken = generateRandomString(32, "a-z", "A-Z"); - const newRefreshToken = generateRandomString(32, "a-z", "A-Z"); - - await ctx.context.adapter.create({ - model: modelName.oauthAccessToken, - data: { - accessToken, - refreshToken: newRefreshToken, - accessTokenExpiresAt, - refreshTokenExpiresAt, - clientId: client_id.toString(), - userId: token.userId, - scopes: token.scopes, - createdAt: new Date(iat * 1000), - updatedAt: new Date(iat * 1000), - }, - }); - return ctx.json({ - access_token: accessToken, - token_type: "Bearer", - expires_in: opts.accessTokenExpiresIn, - refresh_token: newRefreshToken, - scope: token.scopes, - }); - } - - if (!code) { - throw new APIError("BAD_REQUEST", { - error_description: "code is required", - error: "invalid_request", - }); - } - - if (options.requirePKCE && !code_verifier) { - throw new APIError("BAD_REQUEST", { - error_description: "code verifier is missing", - error: "invalid_request", - }); - } - - // Atomic single-use redemption per RFC 6749 §4.1.2. The first - // caller receives the row; concurrent racers receive `null` - // and fall through to the `invalid_grant` error path. - // - // TODO(legacy-hardening-coordinate): in-flight follow-ups at - // https://github.com/better-auth/better-auth/security/advisories/GHSA-9h47-pqcx-hjr4 - // and https://github.com/better-auth/better-auth/security/advisories/GHSA-pw9m-5jxm-xr6h - // touch this same surface. Whoever lands second must rebase - // to keep the atomic consume + `invalid_grant` semantics in - // place; do not regress to a `findVerificationValue` + - // delete pair. - const verificationValue = - await ctx.context.internalAdapter.consumeVerificationValue( - code.toString(), - ); - if (!verificationValue) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid code", - error: "invalid_grant", - }); - } - if (!client_id) { - throw new APIError("UNAUTHORIZED", { - error_description: "client_id is required", - error: "invalid_client", - }); - } - if (!grant_type) { - throw new APIError("BAD_REQUEST", { - error_description: "grant_type is required", - error: "invalid_request", - }); - } - if (grant_type !== "authorization_code") { - throw new APIError("BAD_REQUEST", { - error_description: "grant_type must be 'authorization_code'", - error: "unsupported_grant_type", - }); - } - - if (!redirect_uri) { - throw new APIError("BAD_REQUEST", { - error_description: "redirect_uri is required", - error: "invalid_request", - }); - } - - const client = await getClient(client_id.toString(), trustedClients); - if (!client) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_id", - error: "invalid_client", - }); - } - if (client.disabled) { - throw new APIError("UNAUTHORIZED", { - error_description: "client is disabled", - error: "invalid_client", - }); - } - - const value = JSON.parse( - verificationValue.value, - ) as CodeVerificationValue; - if (value.clientId !== client_id.toString()) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_id", - error: "invalid_client", - }); - } - if (value.redirectURI !== redirect_uri.toString()) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid redirect_uri", - error: "invalid_client", - }); - } - if (value.codeChallenge && !code_verifier) { - throw new APIError("BAD_REQUEST", { - error_description: "code verifier is missing", - error: "invalid_request", - }); - } - if (client.type === "public") { - // For public clients (type: 'public'), validate PKCE instead of client_secret - if (!code_verifier) { - throw new APIError("BAD_REQUEST", { - error_description: - "code verifier is required for public clients", - error: "invalid_request", - }); - } - // PKCE validation happens later in the flow, so we skip client_secret validation - } else { - if (!client.clientSecret || !client_secret) { - throw new APIError("UNAUTHORIZED", { - error_description: - "client_secret is required for confidential clients", - error: "invalid_client", - }); - } - const isValidSecret = await verifyStoredClientSecret( - ctx, - client.clientSecret, - client_secret.toString(), - ); - if (!isValidSecret) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_secret", - error: "invalid_client", - }); - } - } - if (value.codeChallenge) { - const challenge = - value.codeChallengeMethod === "plain" - ? code_verifier - : await createHash("SHA-256", "base64urlnopad").digest( - code_verifier, - ); - - if (challenge !== value.codeChallenge) { - throw new APIError("UNAUTHORIZED", { - error_description: "code verification failed", - error: "invalid_request", - }); - } - } - - const requestedScopes = value.scope; - const accessToken = generateRandomString(32, "a-z", "A-Z"); - const refreshToken = generateRandomString(32, "A-Z", "a-z"); - await ctx.context.adapter.create({ - model: modelName.oauthAccessToken, - data: { - accessToken, - refreshToken, - accessTokenExpiresAt, - refreshTokenExpiresAt, - clientId: client_id.toString(), - userId: value.userId, - scopes: requestedScopes.join(" "), - createdAt: new Date(iat * 1000), - updatedAt: new Date(iat * 1000), - }, - }); - const user = await ctx.context.internalAdapter.findUserById( - value.userId, - ); - if (!user) { - throw new APIError("UNAUTHORIZED", { - error_description: "user not found", - error: "invalid_grant", - }); - } - - const profile = { - given_name: user.name.split(" ")[0]!, - family_name: user.name.split(" ")[1]!, - name: user.name, - profile: user.image, - updated_at: new Date(user.updatedAt).toISOString(), - }; - const email = { - email: user.email, - email_verified: user.emailVerified, - }; - const userClaims = { - ...(requestedScopes.includes("profile") ? profile : {}), - ...(requestedScopes.includes("email") ? email : {}), - }; - - const additionalUserClaims = options.getAdditionalUserInfoClaim - ? await options.getAdditionalUserInfoClaim( - user, - requestedScopes, - client, - ) - : {}; - - const payload = { - sub: user.id, - aud: client_id.toString(), - iat: iat, - auth_time: ctx.context.session - ? new Date(ctx.context.session.session.createdAt).getTime() - : undefined, - nonce: value.nonce, - acr: "urn:mace:incommon:iap:silver", // default to silver - ⚠︎ this should be configurable and should be validated against the client's metadata - ...userClaims, - ...additionalUserClaims, - }; - const expirationTime = - Math.floor(Date.now() / 1000) + - (opts?.accessTokenExpiresIn ?? DEFAULT_ACCESS_TOKEN_EXPIRES_IN); - - let idToken: string; - - // The JWT plugin is enabled, so we use the JWKS keys to sign - if (options.useJWTPlugin) { - const jwtPlugin = ctx.context.getPlugin("jwt"); - if (!jwtPlugin) { - ctx.context.logger.error( - "OIDC: `useJWTPlugin` is enabled but the JWT plugin is not available. Make sure you have the JWT Plugin in your plugins array or set `useJWTPlugin` to false.", - ); - throw new APIError("INTERNAL_SERVER_ERROR", { - error_description: "JWT plugin is not enabled", - error: "internal_server_error", - }); - } - idToken = await getJwtToken( - { - ...ctx, - context: { - ...ctx.context, - session: { - session: { - id: generateRandomString(32, "a-z", "A-Z"), - createdAt: new Date(iat * 1000), - updatedAt: new Date(iat * 1000), - userId: user.id, - expiresAt: accessTokenExpiresAt, - token: accessToken, - ipAddress: ctx.request?.headers.get("x-forwarded-for"), - }, - user, - }, - }, - }, - { - ...jwtPlugin.options, - jwt: { - ...jwtPlugin.options?.jwt, - getSubject: () => user.id, - audience: client_id.toString(), - issuer: - jwtPlugin.options?.jwt?.issuer ?? - (typeof ctx.context.options.baseURL === "string" - ? ctx.context.options.baseURL - : undefined), - expirationTime, - definePayload: () => payload, - }, - }, - ); - - // If the JWT token is not enabled, create a key and use it to sign - } else { - idToken = await new SignJWT(payload) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt(iat) - .setExpirationTime(accessTokenExpiresAt) - .sign(new TextEncoder().encode(client.clientSecret)); - } - - return ctx.json( - { - access_token: accessToken, - token_type: "Bearer", - expires_in: opts.accessTokenExpiresIn, - refresh_token: requestedScopes.includes("offline_access") - ? refreshToken - : undefined, - scope: requestedScopes.join(" "), - id_token: requestedScopes.includes("openid") - ? idToken - : undefined, - }, - { - headers: { - "Cache-Control": "no-store", - Pragma: "no-cache", - }, - }, - ); - }, - ), - oAuth2userInfo: createAuthEndpoint( - "/oauth2/userinfo", - { - method: "GET", - operationId: "oauth2Userinfo", - metadata: { - ...HIDE_METADATA, - openapi: { - description: "Get OAuth2 user information", - responses: { - "200": { - description: "User information retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - sub: { - type: "string", - description: "Subject identifier (user ID)", - }, - email: { - type: "string", - format: "email", - nullable: true, - description: - "User's email address, included if 'email' scope is granted", - }, - name: { - type: "string", - nullable: true, - description: - "User's full name, included if 'profile' scope is granted", - }, - picture: { - type: "string", - format: "uri", - nullable: true, - description: - "User's profile picture URL, included if 'profile' scope is granted", - }, - given_name: { - type: "string", - nullable: true, - description: - "User's given name, included if 'profile' scope is granted", - }, - family_name: { - type: "string", - nullable: true, - description: - "User's family name, included if 'profile' scope is granted", - }, - email_verified: { - type: "boolean", - nullable: true, - description: - "Whether the email is verified, included if 'email' scope is granted", - }, - }, - required: ["sub"], - }, - }, - }, - }, - }, - }, - }, - }, - async (ctx) => { - if (!ctx.request) { - throw new APIError("UNAUTHORIZED", { - error_description: "request not found", - error: "invalid_request", - }); - } - const authorization = ctx.request.headers.get("authorization"); - if (!authorization) { - throw new APIError("UNAUTHORIZED", { - error_description: "authorization header not found", - error: "invalid_request", - }); - } - const token = authorization.replace("Bearer ", ""); - const accessToken = - await ctx.context.adapter.findOne({ - model: modelName.oauthAccessToken, - where: [ - { - field: "accessToken", - value: token, - }, - ], - }); - if (!accessToken) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid access token", - error: "invalid_token", - }); - } - if (accessToken.accessTokenExpiresAt < new Date()) { - throw new APIError("UNAUTHORIZED", { - error_description: "The Access Token expired", - error: "invalid_token", - }); - } - - const client = await getClient(accessToken.clientId, trustedClients); - if (!client) { - throw new APIError("UNAUTHORIZED", { - error_description: "client not found", - error: "invalid_token", - }); - } - - const user = await ctx.context.internalAdapter.findUserById( - accessToken.userId, - ); - if (!user) { - throw new APIError("UNAUTHORIZED", { - error_description: "user not found", - error: "invalid_token", - }); - } - const requestedScopes = accessToken.scopes.split(" "); - const baseUserClaims = { - sub: user.id, - email: requestedScopes.includes("email") ? user.email : undefined, - name: requestedScopes.includes("profile") ? user.name : undefined, - picture: requestedScopes.includes("profile") - ? user.image - : undefined, - given_name: requestedScopes.includes("profile") - ? user.name.split(" ")[0]! - : undefined, - family_name: requestedScopes.includes("profile") - ? user.name.split(" ")[1]! - : undefined, - email_verified: requestedScopes.includes("email") - ? user.emailVerified - : undefined, - }; - const userClaims = options.getAdditionalUserInfoClaim - ? await options.getAdditionalUserInfoClaim( - user, - requestedScopes, - client, - ) - : baseUserClaims; - return ctx.json({ - ...baseUserClaims, - ...userClaims, - }); - }, - ), - /** - * ### Endpoint - * - * POST `/oauth2/register` - * - * ### API Methods - * - * **server:** - * `auth.api.registerOAuthApplication` - * - * **client:** - * `authClient.oauth2.register` - * - * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/oidc-provider#api-method-oauth2-register) - */ - registerOAuthApplication: createAuthEndpoint( - "/oauth2/register", - { - method: "POST", - body: registerOAuthApplicationBodySchema, - metadata: { - openapi: { - description: "Register an OAuth2 application", - responses: { - "200": { - description: "OAuth2 application registered successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - name: { - type: "string", - description: "Name of the OAuth2 application", - }, - icon: { - type: "string", - nullable: true, - description: "Icon URL for the application", - }, - metadata: { - type: "object", - additionalProperties: true, - nullable: true, - description: - "Additional metadata for the application", - }, - clientId: { - type: "string", - description: "Unique identifier for the client", - }, - clientSecret: { - type: "string", - description: "Secret key for the client", - }, - redirectURLs: { - type: "array", - items: { type: "string", format: "uri" }, - description: "List of allowed redirect URLs", - }, - type: { - type: "string", - description: "Type of the client", - enum: ["web"], - }, - authenticationScheme: { - type: "string", - description: - "Authentication scheme used by the client", - enum: ["client_secret"], - }, - disabled: { - type: "boolean", - description: "Whether the client is disabled", - enum: [false], - }, - userId: { - type: "string", - nullable: true, - description: - "ID of the user who registered the client, null if registered anonymously", - }, - createdAt: { - type: "string", - format: "date-time", - description: "Creation timestamp", - }, - updatedAt: { - type: "string", - format: "date-time", - description: "Last update timestamp", - }, - }, - required: [ - "name", - "clientId", - "clientSecret", - "redirectURLs", - "type", - "authenticationScheme", - "disabled", - "createdAt", - "updatedAt", - ], - }, - }, - }, - }, - }, - }, - }, - }, - async (ctx) => { - const body = ctx.body; - const session = await getSessionFromCtx(ctx); - - // Check authorization - if (!session && !options.allowDynamicClientRegistration) { - throw new APIError("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 storedClientSecret = await storeClientSecret(ctx, clientSecret); - - // Create the client with the existing schema - const client: Client = await ctx.context.adapter.create({ - model: modelName.oauthClient, - data: { - name: body.client_name, - icon: body.logo_uri, - metadata: body.metadata ? JSON.stringify(body.metadata) : null, - clientId: clientId, - clientSecret: storedClientSecret, - redirectUrls: body.redirect_uris.join(","), - type: "web", - authenticationScheme: - body.token_endpoint_auth_method || "client_secret_basic", - disabled: false, - userId: session?.session.userId, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - // Format the response according to RFC7591 - return ctx.json( - { - client_id: clientId, - ...(client.type !== "public" - ? { - client_secret: clientSecret, - client_secret_expires_at: 0, // 0 means it doesn't expire - } - : {}), - 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( - "/oauth2/client/:id", - { - method: "GET", - use: [sessionMiddleware], - metadata: { - openapi: { - description: "Get OAuth2 client details", - responses: { - "200": { - description: "OAuth2 client retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - clientId: { - type: "string", - description: "Unique identifier for the client", - }, - name: { - type: "string", - description: "Name of the OAuth2 application", - }, - icon: { - type: "string", - nullable: true, - description: "Icon URL for the application", - }, - }, - required: ["clientId", "name"], - }, - }, - }, - }, - }, - }, - }, - }, - async ( - ctx, - ): Promise<{ - clientId: string; - name: string; - icon: string | null; - }> => { - const client = await getClient(ctx.params.id, trustedClients); - if (!client) { - throw new APIError("NOT_FOUND", { - error_description: "client not found", - error: "not_found", - }); - } - return ctx.json({ - clientId: client.clientId, - name: client.name, - icon: client.icon || null, - }); - }, - ), - /** - * ### Endpoint - * - * GET/POST `/oauth2/endsession` - * - * Implements RP-Initiated Logout as per OpenID Connect RP-Initiated Logout 1.0. - * Allows relying parties to request that an OpenID Provider log out the end-user. - * - * @see [OpenID Connect RP-Initiated Logout Spec](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) - */ - endSession: createAuthEndpoint( - "/oauth2/endsession", - { - method: ["GET", "POST"], - query: z - .object({ - id_token_hint: z.string().optional(), - logout_hint: z.string().optional(), - client_id: z.string().optional(), - post_logout_redirect_uri: z.string().optional(), - state: z.string().optional(), - ui_locales: z.string().optional(), - }) - .optional(), - metadata: { - ...HIDE_METADATA, - openapi: { - description: - "RP-Initiated Logout endpoint. Logs out the end-user and optionally redirects to a post-logout URI.", - parameters: [ - { - name: "id_token_hint", - in: "query", - description: - "Previously issued ID Token passed as a hint about the End-User's current authenticated session", - required: false, - schema: { type: "string" }, - }, - { - name: "logout_hint", - in: "query", - description: - "Hint to the Authorization Server about the End-User that is logging out", - required: false, - schema: { type: "string" }, - }, - { - name: "client_id", - in: "query", - description: - "OAuth 2.0 Client Identifier. Required if post_logout_redirect_uri is used without id_token_hint", - required: false, - schema: { type: "string" }, - }, - { - name: "post_logout_redirect_uri", - in: "query", - description: - "URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed", - required: false, - schema: { type: "string", format: "uri" }, - }, - { - name: "state", - in: "query", - description: - "Opaque value used by the RP to maintain state between the logout request and the callback", - required: false, - schema: { type: "string" }, - }, - { - name: "ui_locales", - in: "query", - description: - "End-User's preferred languages and scripts for the user interface", - required: false, - schema: { type: "string" }, - }, - ] as OpenAPIParameter[], - responses: { - "302": { - description: - "Redirect to post_logout_redirect_uri or logout confirmation page", - }, - "200": { - description: "Logout completed successfully", - }, - }, - }, - }, - }, - async (ctx) => { - const { id_token_hint, client_id, post_logout_redirect_uri, state } = - ctx.query || {}; - - let validatedClientId: string | null = null; - let validatedUserId: string | null = null; - - // Validate id_token_hint if provided - if (id_token_hint) { - try { - const jwtPlugin = ctx.context.getPlugin("jwt"); - if (jwtPlugin && jwtPlugin.options && options?.useJWTPlugin) { - // For JWT plugin tokens, verify using JWKS - const verified = await verifyJWT( - id_token_hint, - jwtPlugin.options, - ); - if (verified) { - validatedUserId = verified.sub; - validatedClientId = verified.aud - ? typeof verified.aud === "string" - ? verified.aud - : verified.aud[0]! - : null; - } - } else { - // For HS256 tokens, we need the client_id to verify - if (client_id) { - const client = await getClient(client_id, trustedClients); - if (client && client.clientSecret) { - try { - const { payload } = await jwtVerify( - id_token_hint, - new TextEncoder().encode(client.clientSecret), - ); - validatedUserId = payload.sub as string; - validatedClientId = payload.aud as string; - } catch { - // Invalid token, continue with logout but no validation - } - } - } - } - } catch { - // Invalid id_token_hint, but we continue with logout anyway - ctx.context.logger.debug( - "Invalid id_token_hint provided to end_session endpoint", - ); - } - } - - // Validate client_id if provided - if (client_id) { - const client = await getClient(client_id, trustedClients); - if (!client) { - throw new APIError("BAD_REQUEST", { - error: "invalid_client", - error_description: "Invalid client_id", - }); - } - // If we have a validated client from the token, ensure they match - if (validatedClientId && validatedClientId !== client_id) { - throw new APIError("BAD_REQUEST", { - error: "invalid_request", - error_description: - "client_id does not match the ID Token's audience", - }); - } - validatedClientId = client_id; - } - - // Validate post_logout_redirect_uri if provided - if (post_logout_redirect_uri) { - if (!validatedClientId) { - throw new APIError("BAD_REQUEST", { - error: "invalid_request", - error_description: - "client_id is required when using post_logout_redirect_uri without a valid id_token_hint", - }); - } - - const client = await getClient(validatedClientId, trustedClients); - if (!client) { - throw new APIError("BAD_REQUEST", { - error: "invalid_client", - error_description: "Invalid client", - }); - } - - const isValidRedirectUri = client.redirectUrls.some( - (registeredUri) => post_logout_redirect_uri === registeredUri, - ); - - if (!isValidRedirectUri) { - throw new APIError("BAD_REQUEST", { - error: "invalid_request", - error_description: - "post_logout_redirect_uri is not registered for this client", - }); - } - } - - const session = await getSessionFromCtx(ctx); - - // Logout deletes the user's session and OAuth tokens, so on an HTTP - // request it must be proven intentional: the request is same-site (or - // from a trusted origin), or it carries an id_token_hint for the - // session being ended. Otherwise a cross-site GET could log the user - // out and revoke their tokens, which the global origin check does not - // prevent because it skips GET. A request whose site cannot be - // established, or a valid id_token_hint for a different user, does not - // authorize ending this session. - if (ctx.request && (validatedUserId || session)) { - const fetchSite = ctx.request.headers.get("Sec-Fetch-Site"); - const originHeader = - ctx.request.headers.get("origin") || - ctx.request.headers.get("referer"); - const isSameSiteRequest = - fetchSite === "same-origin" || - fetchSite === "same-site" || - fetchSite === "none" || - (!!originHeader && - ctx.context.isTrustedOrigin(originHeader, { - allowRelativePaths: false, - })); - const hintMatchesSession = - !!validatedUserId && validatedUserId === session?.user.id; - if (!isSameSiteRequest && !hintMatchesSession) { - throw new APIError("FORBIDDEN", { - error: "invalid_request", - error_description: - "Logout must be same-site or carry an id_token_hint for the current session", - }); - } - } - - if (validatedUserId || session) { - const userId = validatedUserId || session?.user.id; - if (userId) { - // FIXME(next): scope deletion to the session/token family - // represented by the validated id_token_hint (sid) instead of - // every access token for the user. The successor - // @better-auth/oauth-provider logout already deletes by sid. - await ctx.context.adapter.deleteMany({ - model: modelName.oauthAccessToken, - where: [{ field: "userId", value: userId }], - }); - } - } - - if (session) { - await ctx.context.internalAdapter.deleteSession( - session.session.token, - ); - expireCookie(ctx, ctx.context.authCookies.sessionToken); - } - - if (post_logout_redirect_uri) { - try { - const redirectUrl = new URL(post_logout_redirect_uri); - if (state) { - redirectUrl.searchParams.set("state", state); - } - return ctx.redirect(redirectUrl.toString()); - } catch { - throw new APIError("BAD_REQUEST", { - error: "invalid_request", - error_description: "Invalid post_logout_redirect_uri format", - }); - } - } - - return ctx.json({ - success: true, - message: "Logout successful", - }); - }, - ), - }, - schema: mergeSchema(schema, options?.schema), - get options() { - return opts; - }, - } satisfies BetterAuthPlugin; -}; -export type * from "./types"; diff --git a/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts b/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts deleted file mode 100644 index 7b63f19272..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts +++ /dev/null @@ -1,2257 +0,0 @@ -import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose"; -import type { Listener } from "listhen"; -import { listen } from "listhen"; -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - it, - test, -} from "vitest"; -import type { AuthClient } from "../../client"; -import { createAuthClient } from "../../client"; -import { toNodeHandler } from "../../integrations/node"; -import { getTestInstance } from "../../test-utils/test-instance"; -import { genericOAuth } from "../generic-oauth"; -import { jwt } from "../jwt"; -import { oidcProvider } from "."; -import type { OidcClientPlugin } from "./client"; -import { oidcClient } from "./client"; -import type { Client } from "./types"; - -// Pre-verifies any user the RP creates via OAuth signup so the existing-user -// path on the RP side does not trip the local-emailVerified gate. -const autoVerifyUserHook = { - user: { - create: { - before: async (user: Record) => ({ - data: { ...user, emailVerified: true }, - }), - }, - }, -} as const; - -// Type for the server client with OIDC plugin -type ServerClient = AuthClient<{ - plugins: [OidcClientPlugin]; -}>; - -/** - * Helper to handle OIDC consent flow when required per OIDC spec - */ -async function handleConsentFlow( - redirectURI: string, - serverClient: ServerClient, - sessionHeaders: Headers, - consentHeaders: Headers, -): Promise { - if (!redirectURI.includes("consent_code=")) { - return redirectURI; - } - - // Extract consent code from redirect URL - const url = new URL(redirectURI, "http://localhost:3000"); - const consentCode = url.searchParams.get("consent_code"); - - if (!consentCode) { - throw new Error("Consent code not found in redirect URL"); - } - - // Merge session headers with consent cookies - const authHeaders = new Headers(sessionHeaders); - consentHeaders.forEach((value, key) => { - if (key.toLowerCase() === "cookie") { - const existing = authHeaders.get("Cookie") || ""; - authHeaders.set("Cookie", existing ? `${existing}; ${value}` : value); - } else { - authHeaders.set(key, value); - } - }); - - // Accept consent - const response = await serverClient.oauth2.consent( - { accept: true, consent_code: consentCode }, - { headers: authHeaders, throw: true }, - ); - - return response.redirectURI; -} - -describe("oidc init", () => { - it("default options", () => { - const provider = oidcProvider({ - loginPage: "/login", - }); - const options = provider.options; - expect(options).toMatchInlineSnapshot(` - { - "accessTokenExpiresIn": 3600, - "allowPlainCodeChallengeMethod": false, - "codeExpiresIn": 600, - "defaultScope": "openid", - "loginPage": "/login", - "refreshTokenExpiresIn": 604800, - "scopes": [ - "openid", - "profile", - "email", - "offline_access", - ], - "storeClientSecret": "plain", - } - `); - }); -}); - -describe("oidc", async () => { - const { - auth: authorizationServer, - signInWithTestUser, - customFetchImpl, - testUser, - } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: true, - getAdditionalUserInfoClaim(user) { - return { - custom: "custom value", - userId: user.id, - }; - }, - }), - jwt(), - ], - }); - const { headers } = await signInWithTestUser(); - const serverClient = createAuthClient({ - plugins: [oidcClient()], - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers, - }, - }); - - let server: Listener; - - beforeAll(async () => { - server = await listen(toNodeHandler(authorizationServer.handler), { - port: 3000, - }); - }); - - afterAll(async () => { - await server.close(); - }); - - let application: Client = { - clientId: "test-client-id", - clientSecret: "test-client-secret-oidc", - redirectUrls: ["http://localhost:3000/api/auth/callback/test"], - metadata: {}, - type: "web", - disabled: false, - name: "test", - }; - - it("should create oidc client", async ({ expect }) => { - const createdClient = await serverClient.oauth2.register({ - client_name: application.name, - redirect_uris: application.redirectUrls, - logo_uri: application.icon, - }); - expect(createdClient.data).toMatchObject({ - client_id: expect.any(String), - client_secret: expect.any(String), - client_name: "test", - redirect_uris: ["http://localhost:3000/api/auth/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 = { - 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!, - }; - } - const client = await authorizationServer.api.getOAuthClient({ - params: { - id: application.clientId, - }, - headers, - }); - expect(client).toEqual({ - clientId: application.clientId, - name: application.name, - icon: null, - }); - }); - - it("should sign in the user with the provider", async ({ expect }) => { - // The RP (Relying Party) - the client application - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test"], - }, - }, - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test", - clientId: application.clientId, - clientSecret: application.clientSecret || "", - authorizationUrl: - "http://localhost:3000/api/auth/oauth2/authorize", - tokenUrl: "http://localhost:3000/api/auth/oauth2/token", - scopes: ["openid", "profile", "email"], - pkce: true, - }, - ], - }), - ], - }); - - const client = createAuthClient({ - baseURL: "http://localhost:5000", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - const oAuthHeaders = new Headers(); - const data = await client.signIn.social( - { - provider: "test", - callbackURL: "/dashboard", - }, - { - throw: true, - onSuccess: cookieSetter(oAuthHeaders), - }, - ); - expect(data.url).toContain( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - expect(data.url).toContain(`client_id=${application.clientId}`); - - // Make the authorization request - let redirectURI = ""; - const consentHeaders = new Headers(); - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - // Capture any consent cookies - cookieSetter(consentHeaders)(context); - }, - }); - - // Handle consent flow if required (per OIDC spec for non-trusted clients) - redirectURI = await handleConsentFlow( - redirectURI, - serverClient, - headers, - consentHeaders, - ); - - // Verify we got an authorization code - expect(redirectURI).toContain( - "http://localhost:3000/api/auth/callback/test?code=", - ); - - // Complete the OAuth flow - let callbackURL = ""; - await client.$fetch(redirectURI, { - headers: oAuthHeaders, - onError(context) { - callbackURL = context.response.headers.get("Location") || ""; - }, - }); - expect(callbackURL).toContain("/dashboard"); - }); - - it("should sign in after a consent flow", async ({ expect }) => { - // The RP (Relying Party) - the client application - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test"], - }, - }, - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test", - clientId: application.clientId, - clientSecret: application.clientSecret || "", - authorizationUrl: - "http://localhost:3000/api/auth/oauth2/authorize", - tokenUrl: "http://localhost:3000/api/auth/oauth2/token", - scopes: ["openid", "profile", "email"], - prompt: "consent", - pkce: true, - }, - ], - }), - ], - }); - - const client = createAuthClient({ - baseURL: "http://localhost:5000", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - const oAuthHeaders = new Headers(); - const data = await client.signIn.social( - { - provider: "test", - callbackURL: "/dashboard", - }, - { - throw: true, - onSuccess: cookieSetter(oAuthHeaders), - }, - ); - expect(data.url).toContain( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - expect(data.url).toContain(`client_id=${application.clientId}`); - - let redirectURI = ""; - const newHeaders = new Headers(); - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - cookieSetter(newHeaders)(context); - newHeaders.append("Cookie", headers.get("Cookie") || ""); - }, - }); - expect(redirectURI).toContain("/oauth2/authorize?"); - expect(redirectURI).toContain("consent_code="); - expect(redirectURI).toContain("client_id="); - - // No need to extract consent_code - it's in the signed cookie - const res = await serverClient.oauth2.consent( - { - accept: true, - }, - { - headers: newHeaders, - throw: true, - }, - ); - expect(res.redirectURI).toContain( - "http://localhost:3000/api/auth/callback/test?code=", - ); - - let callbackURL = ""; - await client.$fetch(res.redirectURI, { - headers: oAuthHeaders, - onError(context) { - callbackURL = context.response.headers.get("Location") || ""; - }, - }); - expect(callbackURL).toContain("/dashboard"); - }); - - it("should sign in after a login flow", async ({ expect }) => { - // The RP (Relying Party) - the client application - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test"], - }, - }, - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test", - clientId: application.clientId, - clientSecret: application.clientSecret || "", - authorizationUrl: - "http://localhost:3000/api/auth/oauth2/authorize", - tokenUrl: "http://localhost:3000/api/auth/oauth2/token", - scopes: ["openid", "profile", "email"], - prompt: "login", - pkce: true, - }, - ], - }), - ], - }); - - const client = createAuthClient({ - baseURL: "http://localhost:5000", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - const oAuthHeaders = new Headers(); - const data = await client.signIn.social( - { - provider: "test", - callbackURL: "/dashboard", - }, - { - throw: true, - onSuccess: cookieSetter(oAuthHeaders), - }, - ); - expect(data.url).toContain( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - expect(data.url).toContain(`client_id=${application.clientId}`); - - let redirectURI = ""; - const newHeaders = new Headers(); - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - cookieSetter(newHeaders)(context); - }, - headers: newHeaders, - }); - expect(redirectURI).toContain("/login"); - - await serverClient.signIn.email( - { - email: testUser.email, - password: testUser.password, - }, - { - headers: newHeaders, - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - cookieSetter(newHeaders)(context); - }, - }, - ); - - expect(redirectURI).toContain( - "http://localhost:3000/api/auth/callback/test?code=", - ); - let callbackURL = ""; - await client.$fetch(redirectURI, { - headers: oAuthHeaders, - onError(context) { - callbackURL = context.response.headers.get("Location") || ""; - }, - }); - expect(callbackURL).toContain("/dashboard"); - }); - - describe("prompt parameter handling", () => { - it("should return login_required error when prompt=none and user not authenticated", async ({ - expect, - }) => { - // Create an OAuth client - const testClient = await serverClient.oauth2.register({ - client_name: "test-login-required-prompt-none", - redirect_uris: [ - "http://localhost:3000/api/auth/callback/login-required", - ], - }); - const clientId = testClient.data?.client_id ?? ""; - const redirectUri = testClient.data?.redirect_uris?.[0] ?? ""; - - // Try to authorize with prompt=none - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set("client_id", clientId); - authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid profile email"); - authUrl.searchParams.set("state", "test-state"); - authUrl.searchParams.set("prompt", "none"); - authUrl.searchParams.set("code_challenge", "test-challenge"); - authUrl.searchParams.set("code_challenge_method", "S256"); - - const response = await customFetchImpl(authUrl.toString(), { - method: "GET", - redirect: "manual", - }); - const redirectURI = response.headers.get("Location") || ""; - - expect(redirectURI).toContain("error=login_required"); - expect(redirectURI).toContain("error_description=Authentication"); - expect(redirectURI).toContain("prompt"); - expect(redirectURI).toContain("none"); - }); - - it("should not redirect to invalid redirect_uri when prompt=none", async ({ - expect, - }) => { - const attackerRedirect = "https://malicious.com/callback"; - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set("client_id", application.clientId); - authUrl.searchParams.set("redirect_uri", attackerRedirect); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid"); - authUrl.searchParams.set("state", "x"); - authUrl.searchParams.set("prompt", "none"); - - const response = await customFetchImpl(authUrl.toString(), { - method: "GET", - redirect: "manual", - }); - - const location = response.headers.get("Location") || ""; - expect(location === null || location === "").not.toContain( - "malicious.com", - ); - expect([400, 302]).toContain(response.status); - }); - - it("should return 400 invalid_request when prompt=none without redirect_uri", async ({ - expect, - }) => { - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set("client_id", application.clientId); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid"); - authUrl.searchParams.set("state", "x"); - authUrl.searchParams.set("prompt", "none"); - // No redirect_uri - must not fall through to login page - - const response = await customFetchImpl(authUrl.toString(), { - method: "GET", - redirect: "manual", - }); - - expect(response.status).toBe(400); - const location = response.headers.get("Location") || ""; - expect(location).not.toContain("/login"); - const body = await response.json().catch(() => ({})); - expect(body.error ?? body.code).toBe("invalid_request"); - }); - - it("should return consent_required error when prompt=none and consent needed", async ({ - expect, - }) => { - // Create a new OAuth application that requires consent - const newClient = await serverClient.oauth2.register({ - client_name: "test-consent-required", - redirect_uris: ["http://localhost:3000/api/auth/callback/test2"], - }); - - // Create a fresh user session that hasn't consented to this new client yet - const { headers: freshHeaders } = await signInWithTestUser(); - - // Try to authorize with prompt=none on the new client - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set("client_id", newClient.data?.client_id || ""); - authUrl.searchParams.set( - "redirect_uri", - newClient.data?.redirect_uris[0] || "", - ); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid profile email"); - authUrl.searchParams.set("state", "test-state"); - authUrl.searchParams.set("prompt", "none"); - authUrl.searchParams.set("code_challenge", "test-challenge"); - authUrl.searchParams.set("code_challenge_method", "S256"); - - let redirectURI = ""; - await customFetchImpl(authUrl.toString(), { - method: "GET", - headers: freshHeaders, - redirect: "manual", - }).then((res) => { - redirectURI = res.headers.get("Location") || ""; - }); - - expect(redirectURI).toContain("error=consent_required"); - expect(redirectURI).toContain("error_description=Consent"); - expect(redirectURI).toContain("prompt"); - expect(redirectURI).toContain("none"); - }); - - it("should succeed with prompt=none when user authenticated and consented", async ({ - expect, - }) => { - // Create a new client for this test - const testClient = await serverClient.oauth2.register({ - client_name: "test-prompt-none-success", - redirect_uris: ["http://localhost:3000/api/auth/callback/test-none"], - }); - - // First, establish consent by doing a normal authorization flow - const authUrl1 = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl1.searchParams.set("client_id", testClient.data?.client_id || ""); - authUrl1.searchParams.set( - "redirect_uri", - testClient.data?.redirect_uris[0] || "", - ); - authUrl1.searchParams.set("response_type", "code"); - authUrl1.searchParams.set("scope", "openid profile email"); - authUrl1.searchParams.set("state", "initial-state"); - authUrl1.searchParams.set("code_challenge", "test-challenge"); - authUrl1.searchParams.set("code_challenge_method", "S256"); - - let consentRedirect = ""; - await serverClient.$fetch(authUrl1.toString(), { - method: "GET", - onError(context) { - consentRedirect = context.response.headers.get("Location") || ""; - }, - }); - - // If consent is required, accept it - if (consentRedirect.includes("consent_code=")) { - const consentUrl = new URL(consentRedirect, "http://localhost:3000"); - const consentCode = consentUrl.searchParams.get("consent_code"); - - await serverClient.oauth2.consent( - { accept: true, consent_code: consentCode }, - { throw: true }, - ); - } - - // Now test prompt=none - should succeed since consent is established - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set("client_id", testClient.data?.client_id || ""); - authUrl.searchParams.set( - "redirect_uri", - testClient.data?.redirect_uris[0] || "", - ); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid profile email"); - authUrl.searchParams.set("state", "test-state"); - authUrl.searchParams.set("prompt", "none"); - authUrl.searchParams.set("code_challenge", "test-challenge"); - authUrl.searchParams.set("code_challenge_method", "S256"); - - let redirectURI = ""; - await serverClient.$fetch(authUrl.toString(), { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - }, - }); - - // Should succeed with authorization code - expect(redirectURI).toContain("code="); - expect(redirectURI).toContain("state=test-state"); - expect(redirectURI).not.toContain("error="); - }); - - it("should handle prompt=login with consent requirement", async ({ - expect, - }) => { - // Create a new client that will require consent - const loginConsentClient = await serverClient.oauth2.register({ - client_name: "test-login-consent", - redirect_uris: [ - "http://localhost:3000/api/auth/callback/login-consent", - ], - }); - - // User is already authenticated, but we force login with prompt=login - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set( - "client_id", - loginConsentClient.data?.client_id || "", - ); - authUrl.searchParams.set( - "redirect_uri", - loginConsentClient.data?.redirect_uris[0] || "", - ); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid profile email"); - authUrl.searchParams.set("state", "test-state"); - authUrl.searchParams.set("prompt", "login"); // Force login even though already authenticated - authUrl.searchParams.set("code_challenge", "test-challenge"); - authUrl.searchParams.set("code_challenge_method", "S256"); - - let redirectURI = ""; - const loginHeaders = new Headers(); - await serverClient.$fetch(authUrl.toString(), { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - // Capture any cookies from the redirect - const setCookie = context.response.headers.get("set-cookie"); - if (setCookie) { - loginHeaders.set("Cookie", setCookie); - } - }, - }); - - // Should redirect to login page - expect(redirectURI).toContain("/login"); - expect(redirectURI).toContain("client_id"); - - await serverClient.signIn.email( - { - email: testUser.email, - password: testUser.password, - }, - { - headers: loginHeaders, - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - // Capture session cookies - const setCookie = context.response.headers.get("set-cookie"); - if (setCookie) { - const existing = loginHeaders.get("Cookie") || ""; - loginHeaders.set( - "Cookie", - existing ? `${existing}; ${setCookie}` : setCookie, - ); - } - }, - }, - ); - - // After login with prompt=login removed, should proceed to consent - // Should NOT still be redirecting to login - expect(redirectURI).not.toContain("/login"); - // Should have consent_code (since this is a new client requiring consent) - expect(redirectURI).toContain("consent_code="); - - // Extract consent code and accept consent - const consentUrl = new URL(redirectURI, "http://localhost:3000"); - const consentCode = consentUrl.searchParams.get("consent_code"); - expect(consentCode).toBeTruthy(); - - const consentResponse = await serverClient.oauth2.consent( - { accept: true, consent_code: consentCode }, - { headers: loginHeaders, throw: true }, - ); - - // After consent, should redirect to the client's redirect_uri with code - expect(consentResponse.redirectURI).toContain( - loginConsentClient.data?.redirect_uris[0] || "", - ); - expect(consentResponse.redirectURI).toContain("code="); - expect(consentResponse.redirectURI).toContain("state=test-state"); - }); - }); - - describe("max_age parameter handling", () => { - it("should force re-authentication when session age exceeds max_age", async ({ - expect, - }) => { - // This test verifies that max_age triggers re-authentication - // In a real scenario, we'd need to manipulate session creation time - // For now, we test with max_age=0 which should always trigger re-authentication - - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set("client_id", application.clientId); - authUrl.searchParams.set( - "redirect_uri", - application.redirectUrls[0] || "", - ); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid profile email"); - authUrl.searchParams.set("state", "test-state"); - authUrl.searchParams.set("max_age", "0"); // Force immediate re-authentication - authUrl.searchParams.set("code_challenge", "test-challenge"); - authUrl.searchParams.set("code_challenge_method", "S256"); - - let redirectURI = ""; - await serverClient.$fetch(authUrl.toString(), { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - }, - }); - - // Should redirect to login page (same behavior as prompt=login) - expect(redirectURI).toContain("/login"); - expect(redirectURI).toContain("client_id=" + application.clientId); - }); - - it("should not force re-authentication when session age is within max_age", async ({ - expect, - }) => { - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set("client_id", application.clientId); - authUrl.searchParams.set( - "redirect_uri", - application.redirectUrls[0] || "", - ); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid profile email"); - authUrl.searchParams.set("state", "test-state"); - authUrl.searchParams.set("max_age", "3600"); // 1 hour - should be valid - authUrl.searchParams.set("code_challenge", "test-challenge"); - authUrl.searchParams.set("code_challenge_method", "S256"); - - let redirectURI = ""; - await serverClient.$fetch(authUrl.toString(), { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - }, - }); - - // Should either succeed with code or redirect to consent (but NOT to login) - expect(redirectURI).not.toContain("/login"); - // It should either have a code or consent_code - expect( - redirectURI.includes("code=") || redirectURI.includes("consent_code="), - ).toBe(true); - }); - }); - - /** - * @see https://github.com/better-auth/better-auth/issues/4594 - */ - describe("cookie persistence bug (issue #4594)", () => { - // Reproduce issue #4594: oidc_login_prompt cookie persists after OIDC flow - // and causes subsequent normal logins to redirect to OIDC client - it("should not redirect to OIDC client on subsequent normal logins", async ({ - expect, - }) => { - // Step 1: Create a new OAuth client for this test - const testClient = await serverClient.oauth2.register({ - client_name: "test-cookie-persistence", - redirect_uris: ["http://localhost:3000/api/auth/callback/test-persist"], - }); - - // Step 2: Logout to start fresh - await serverClient.signOut({ - fetchOptions: { - throw: false, - }, - }); - - // Step 3: Initiate OIDC authorization flow (which will set oidc_login_prompt cookie) - const authUrl = new URL( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - authUrl.searchParams.set("client_id", testClient.data?.client_id || ""); - authUrl.searchParams.set( - "redirect_uri", - testClient.data?.redirect_uris[0] || "", - ); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid profile email"); - authUrl.searchParams.set("state", "test-state"); - authUrl.searchParams.set("code_challenge", "test-challenge"); - authUrl.searchParams.set("code_challenge_method", "S256"); - - const oidcHeaders = new Headers(); - let redirectURI = ""; - - await customFetchImpl(authUrl.toString(), { - method: "GET", - redirect: "manual", - }).then((res) => { - redirectURI = res.headers.get("Location") || ""; - // Capture the oidc_login_prompt cookie - const setCookie = res.headers.get("set-cookie"); - if (setCookie) { - oidcHeaders.set("Cookie", setCookie); - } - }); - - // Should redirect to login and set oidc_login_prompt cookie - expect(redirectURI).toContain("/login"); - expect(oidcHeaders.get("Cookie")).toContain("oidc_login_prompt"); - - // Step 4: Complete the OIDC login flow - await customFetchImpl("http://localhost:3000/api/auth/sign-in/email", { - method: "POST", - headers: { - "Content-Type": "application/json", - Cookie: oidcHeaders.get("Cookie") || "", - }, - body: JSON.stringify({ - email: testUser.email, - password: testUser.password, - }), - redirect: "manual", - }).then((res) => { - redirectURI = res.headers.get("Location") || ""; - // Update cookies with session - const setCookie = res.headers.get("set-cookie"); - if (setCookie) { - const existing = oidcHeaders.get("Cookie") || ""; - oidcHeaders.set( - "Cookie", - existing ? `${existing}; ${setCookie}` : setCookie, - ); - } - }); - - // Should redirect to consent or client callback (OIDC flow continues) - expect(redirectURI).not.toContain("/login"); - - // Step 5: Now do a NORMAL login to the main app (NOT OIDC flow) - // This simulates a user later logging into the main app directly - // The bug was that oidc_login_prompt cookie would still be present - // and cause a redirect to the OIDC client - const normalLoginHeaders = new Headers(); - let normalLoginRedirect = ""; - - await customFetchImpl("http://localhost:3000/api/auth/sign-in/email", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email: testUser.email, - password: testUser.password, - }), - redirect: "manual", - }).then((res) => { - normalLoginRedirect = res.headers.get("Location") || ""; - const setCookie = res.headers.get("set-cookie"); - if (setCookie) { - normalLoginHeaders.set("Cookie", setCookie); - } - }); - - expect(normalLoginRedirect).not.toContain("/callback/"); - expect(normalLoginRedirect).not.toContain( - testClient.data?.redirect_uris[0] || "", - ); - }); - }); - - describe("end session endpoint", () => { - it("should return end_session_endpoint in metadata", async ({ expect }) => { - const response: any = await serverClient.$fetch( - "/.well-known/openid-configuration", - { - method: "GET", - }, - ); - const metadata = response.data || response; - expect(metadata.end_session_endpoint).toBe( - "http://localhost:3000/api/auth/oauth2/endsession", - ); - }); - - it("should logout successfully without parameters", async ({ expect }) => { - const response = await serverClient.$fetch("/oauth2/endsession", { - method: "GET", - headers: { "Sec-Fetch-Site": "same-origin" }, - }); - expect(response.data).toMatchObject({ - success: true, - message: "Logout successful", - }); - }); - - it("should logout with post_logout_redirect_uri and state", async ({ - expect, - }) => { - let redirectLocation = ""; - await serverClient.$fetch( - `/oauth2/endsession?client_id=${application.clientId}&post_logout_redirect_uri=${encodeURIComponent(application.redirectUrls[0]!)}&state=test-state`, - { - method: "GET", - headers: { "Sec-Fetch-Site": "same-origin" }, - onError(context) { - redirectLocation = context.response.headers.get("Location") || ""; - }, - }, - ); - expect(redirectLocation).toContain(application.redirectUrls[0]); - expect(redirectLocation).toContain("state=test-state"); - }); - - it("should fail with invalid client_id", async ({ expect }) => { - const response = await serverClient.$fetch( - "/oauth2/endsession?client_id=invalid-client", - { - method: "GET", - }, - ); - expect(response.error).toMatchObject({ - error: "invalid_client", - error_description: "Invalid client_id", - }); - }); - - it("should fail with post_logout_redirect_uri without client_id", async ({ - expect, - }) => { - const response = await serverClient.$fetch( - `/oauth2/endsession?post_logout_redirect_uri=${encodeURIComponent("http://localhost:3000/callback")}`, - { - method: "GET", - }, - ); - expect(response.error).toMatchObject({ - error: "invalid_request", - error_description: expect.stringContaining("client_id is required"), - }); - }); - - it("should fail with unregistered post_logout_redirect_uri", async ({ - expect, - }) => { - const response = await serverClient.$fetch( - `/oauth2/endsession?client_id=${application.clientId}&post_logout_redirect_uri=${encodeURIComponent("http://evil.com/callback")}`, - { - method: "GET", - }, - ); - expect(response.error).toMatchObject({ - error: "invalid_request", - error_description: expect.stringContaining("not registered"), - }); - }); - - it("should support POST method", async ({ expect }) => { - const response = await serverClient.$fetch("/oauth2/endsession", { - method: "POST", - headers: { "Sec-Fetch-Site": "same-origin" }, - }); - expect(response.data).toMatchObject({ - success: true, - message: "Logout successful", - }); - }); - }); -}); - -describe("oidc storage", async () => { - let server: Listener; - - afterEach(async () => { - if (server) { - await server.close(); - } - }); - - test.for([ - { - storeClientSecret: undefined, - }, - { - storeClientSecret: "hashed", - }, - { - storeClientSecret: "encrypted", - }, - ] as const)("OIDC base test", async ({ storeClientSecret }) => { - const { - auth: authorizationServer, - signInWithTestUser, - customFetchImpl, - } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: true, - getAdditionalUserInfoClaim(user) { - return { - custom: "custom value", - userId: user.id, - }; - }, - storeClientSecret, - }), - jwt(), - ], - }); - const { headers } = await signInWithTestUser(); - const serverClient = createAuthClient({ - plugins: [oidcClient()], - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers, - }, - }); - - server = await listen(toNodeHandler(authorizationServer.handler), { - port: 3000, - }); - - let application: Client = { - clientId: "test-client-id", - clientSecret: "test-client-secret-oidc", - redirectUrls: ["http://localhost:3000/api/auth/callback/test"], - metadata: {}, - icon: "", - type: "web", - disabled: false, - name: "test", - }; - const createdClient = await serverClient.oauth2.register({ - client_name: application.name, - redirect_uris: application.redirectUrls, - logo_uri: application.icon, - }); - expect(createdClient.data).toMatchObject({ - client_id: expect.any(String), - client_secret: expect.any(String), - client_name: "test", - logo_uri: "", - redirect_uris: ["http://localhost:3000/api/auth/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 = { - 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 || "", - }; - } - // The RP (Relying Party) - the client application - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test"], - }, - }, - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test", - clientId: application.clientId, - clientSecret: application.clientSecret || "", - authorizationUrl: - "http://localhost:3000/api/auth/oauth2/authorize", - tokenUrl: "http://localhost:3000/api/auth/oauth2/token", - scopes: ["openid", "profile", "email"], - pkce: true, - }, - ], - }), - ], - }); - - const client = createAuthClient({ - baseURL: "http://localhost:5000", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - const oAuthHeaders = new Headers(); - const data = await client.signIn.social( - { - provider: "test", - callbackURL: "/dashboard", - }, - { - throw: true, - onSuccess: cookieSetter(oAuthHeaders), - }, - ); - expect(data.url).toContain( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - expect(data.url).toContain(`client_id=${application.clientId}`); - - let redirectURI = ""; - const newHeaders = new Headers(); - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - cookieSetter(newHeaders)(context); - // Note: headers might be available from parent scope (serverClient auth) - // newHeaders already has the consent cookies - }, - }); - - // Handle consent flow if required (per OIDC spec for non-trusted clients) - redirectURI = await handleConsentFlow( - redirectURI, - serverClient, - headers, - newHeaders, - ); - - // Verify we got an authorization code - expect(redirectURI).toContain( - "http://localhost:3000/api/auth/callback/test?code=", - ); - - let callbackURL = ""; - await client.$fetch(redirectURI, { - headers: oAuthHeaders, - onError(context) { - callbackURL = context.response.headers.get("Location") || ""; - }, - }); - expect(callbackURL).toContain("/dashboard"); - }); -}); - -describe("oidc token response format", async () => { - async function setupOAuthFlowAndGetCode(scopes: string[]) { - const { - auth: authorizationServer, - signInWithTestUser, - customFetchImpl, - } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: false, - }), - jwt(), - ], - }); - const { headers } = await signInWithTestUser(); - const serverClient = createAuthClient({ - plugins: [oidcClient()], - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers, - }, - }); - - const server = await listen(toNodeHandler(authorizationServer.handler), { - port: 3000, - }); - - const createdClient = await serverClient.oauth2.register({ - client_name: "test-app", - redirect_uris: ["http://localhost:3000/api/auth/callback/test"], - logo_uri: "", - }); - - const application = { - clientId: createdClient.data!.client_id, - clientSecret: createdClient.data!.client_secret, - }; - - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test", - clientId: application.clientId, - clientSecret: application.clientSecret, - authorizationUrl: - "http://localhost:3000/api/auth/oauth2/authorize", - tokenUrl: "http://localhost:3000/api/auth/oauth2/token", - scopes, - pkce: false, - }, - ], - }), - ], - }); - - const client = createAuthClient({ - baseURL: "http://localhost:5000", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - const oAuthHeaders = new Headers(); - const data = await client.signIn.social( - { - provider: "test", - callbackURL: "/dashboard", - }, - { - throw: true, - onSuccess: cookieSetter(oAuthHeaders), - }, - ); - - let redirectURI = ""; - const consentHeaders = new Headers(); - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - cookieSetter(consentHeaders)(context); - }, - }); - - redirectURI = await handleConsentFlow( - redirectURI, - serverClient, - headers, - consentHeaders, - ); - - const url = new URL(redirectURI); - const code = url.searchParams.get("code")!; - - return { - server, - customFetchImpl, - application, - code, - }; - } - - it("should return Bearer token_type in authorization_code token response", async ({ - expect, - }) => { - const { server, customFetchImpl, application, code } = - await setupOAuthFlowAndGetCode(["openid", "profile", "email"]); - - const tokenResponse = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "authorization_code", - code, - redirect_uri: "http://localhost:3000/api/auth/callback/test", - client_id: application.clientId, - client_secret: application.clientSecret, - }), - }, - ); - - const tokenData = await tokenResponse.json(); - - expect(tokenData.token_type).toBe("Bearer"); - expect(tokenData.access_token).toBeDefined(); - expect(tokenData.expires_in).toBeDefined(); - expect(tokenData.id_token).toBeDefined(); - expect(tokenData.scope).toBeDefined(); - - await server.close(); - }); - - it("should return Bearer token_type in refresh_token grant response", async ({ - expect, - }) => { - const { server, customFetchImpl, application, code } = - await setupOAuthFlowAndGetCode([ - "openid", - "profile", - "email", - "offline_access", - ]); - - const initialTokenResponse = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "authorization_code", - code, - redirect_uri: "http://localhost:3000/api/auth/callback/test", - client_id: application.clientId, - client_secret: application.clientSecret, - }), - }, - ); - - const initialTokenData = await initialTokenResponse.json(); - expect(initialTokenData.refresh_token).toBeDefined(); - expect(initialTokenData.token_type).toBe("Bearer"); - - const refreshTokenResponse = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: initialTokenData.refresh_token, - client_id: application.clientId, - client_secret: application.clientSecret, - }), - }, - ); - - const refreshTokenData = await refreshTokenResponse.json(); - - expect(refreshTokenData.token_type).toBe("Bearer"); - expect(refreshTokenData.access_token).toBeDefined(); - expect(refreshTokenData.expires_in).toBeDefined(); - expect(refreshTokenData.refresh_token).toBeDefined(); - expect(refreshTokenData.scope).toBeDefined(); - - await server.close(); - }); - - /** - * Concurrent redemption of the same authorization code must mint tokens - * for exactly one caller. Reverting `consumeVerificationValue` back to a - * `findVerificationValue` + `deleteVerificationByIdentifier` pair makes - * this test fail with two successes. - * - * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-7w99-5wm4-3g79 - */ - it("rejects concurrent redemption of the same authorization code", async () => { - const { server, customFetchImpl, application, code } = - await setupOAuthFlowAndGetCode(["openid", "profile", "email"]); - - const exchange = () => - customFetchImpl("http://localhost:3000/api/auth/oauth2/token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "authorization_code", - code, - redirect_uri: "http://localhost:3000/api/auth/callback/test", - client_id: application.clientId, - client_secret: application.clientSecret, - }), - }); - - const [first, second] = await Promise.all([exchange(), exchange()]); - const firstBody = (await first.json()) as { - access_token?: string; - error?: string; - }; - const secondBody = (await second.json()) as { - access_token?: string; - error?: string; - }; - - const successes = [firstBody, secondBody].filter( - (b) => b.access_token != null, - ); - const failures = [firstBody, secondBody].filter((b) => b.error != null); - expect(successes).toHaveLength(1); - expect(failures).toHaveLength(1); - expect(failures[0]?.error).toBe("invalid_grant"); - - await server.close(); - }); -}); - -describe("oidc-jwt", async () => { - let server: Listener | null = null; - - afterEach(async () => { - if (server) { - await server.close(); - server = null; - } - }); - - test.for([ - { useJwt: true, description: "with jwt plugin", expected: "EdDSA" }, - { useJwt: false, description: "without jwt plugin", expected: "HS256" }, - ])("testing oidc-provider $description to return token signed with $expected", async ({ - useJwt, - expected, - }) => { - const { - auth: authorizationServer, - signInWithTestUser, - customFetchImpl, - testUser, - } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: true, - getAdditionalUserInfoClaim(user) { - return { - custom: "custom value", - userId: user.id, - }; - }, - useJWTPlugin: useJwt, - }), - ...(useJwt ? [jwt()] : []), - ], - }); - const { headers } = await signInWithTestUser(); - const serverClient = createAuthClient({ - plugins: [oidcClient()], - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - headers, - }, - }); - server = await listen(toNodeHandler(authorizationServer.handler), { - port: 3000, - }); - let application: Client = { - clientId: "test-client-id", - clientSecret: "test-client-secret-oidc", - redirectUrls: ["http://localhost:3000/api/auth/callback/test"], - metadata: {}, - icon: "", - type: "web", - disabled: false, - name: "test", - }; - const createdClient = await serverClient.oauth2.register({ - client_name: application.name, - redirect_uris: application.redirectUrls, - logo_uri: application.icon, - }); - expect(createdClient.data).toMatchObject({ - client_id: expect.any(String), - client_secret: expect.any(String), - client_name: "test", - logo_uri: "", - redirect_uris: ["http://localhost:3000/api/auth/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 = { - 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 || "", - }; - } - - // The RP (Relying Party) - the client application - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test"], - }, - }, - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test", - clientId: application.clientId, - clientSecret: application.clientSecret || "", - authorizationUrl: - "http://localhost:3000/api/auth/oauth2/authorize", - tokenUrl: "http://localhost:3000/api/auth/oauth2/token", - scopes: ["openid", "profile", "email"], - pkce: true, - }, - ], - }), - ], - }); - - const client = createAuthClient({ - baseURL: "http://localhost:5000", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - const oAuthHeaders = new Headers(); - const data = await client.signIn.social( - { - provider: "test", - callbackURL: "/dashboard", - }, - { - throw: true, - onSuccess: cookieSetter(oAuthHeaders), - }, - ); - expect(data.url).toContain( - "http://localhost:3000/api/auth/oauth2/authorize", - ); - expect(data.url).toContain(`client_id=${application.clientId}`); - - let redirectURI = ""; - const newHeaders = new Headers(); - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - cookieSetter(newHeaders)(context); - if (headers.get("Cookie")) { - newHeaders.append("Cookie", headers.get("Cookie") || ""); - } - }, - }); - - // Check if consent is needed (per OIDC spec) - if (redirectURI.includes("consent_code=")) { - // Handle consent flow - this is expected per OIDC spec for non-trusted clients - expect(redirectURI).toContain("/oauth2/authorize?"); - expect(redirectURI).toContain("consent_code="); - expect(redirectURI).toContain("client_id="); - - // Extract consent_code from URL - const url = new URL(redirectURI, "http://localhost:3000"); - const consentCode = url.searchParams.get("consent_code"); - - const res = await serverClient.oauth2.consent( - { - accept: true, - consent_code: consentCode, - }, - { - headers: newHeaders, - throw: true, - }, - ); - expect(res.redirectURI).toContain( - "http://localhost:3000/api/auth/callback/test?code=", - ); - redirectURI = res.redirectURI; - } else { - // Direct code response (trusted client) - expect(redirectURI).toContain( - "http://localhost:3000/api/auth/callback/test?code=", - ); - } - let authToken = undefined; - let callbackURL = ""; - await client.$fetch(redirectURI, { - headers: oAuthHeaders, - onError(context) { - callbackURL = context.response.headers.get("Location") || ""; - authToken = context.response.headers.get("set-auth-token")!; - }, - }); - expect(callbackURL).toContain("/dashboard"); - const accessToken = await client.getAccessToken( - { providerId: "test", userId: testUser.id }, - { - auth: { - type: "Bearer", - token: authToken, - }, - }, - ); - const decoded = decodeProtectedHeader(accessToken.data?.idToken!); - if (useJwt) { - const jwks = await authorizationServer.api.getJwks(); - const jwkSet = createLocalJWKSet(jwks); - const checkSignature = await jwtVerify( - accessToken.data?.idToken!, - jwkSet, - ); - expect(checkSignature).toBeDefined(); - expect(Number.isInteger(checkSignature.payload.iat)).toBeTruthy(); - expect(Number.isInteger(checkSignature.payload.exp)).toBeTruthy(); - } else { - const clientSecret = application.clientSecret; - const checkSignature = await jwtVerify( - accessToken.data?.idToken!, - new TextEncoder().encode(clientSecret), - ); - expect(checkSignature).toBeDefined(); - } - - // expect(checkSignature.payload).toBeDefined(); - expect(decoded.alg).toBe(expected); - }); -}); - -/** - * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-pw9m-5jxm-xr6h - */ -describe("oidc-provider refresh_token grant client authentication", () => { - const REFRESH_TOKEN = "pw9m-test-refresh-token"; - const CLIENT_ID = "pw9m-confidential-test-client"; - const CLIENT_SECRET = "pw9m-secret-only-the-client-knows"; - - async function seedConfidentialClientAndToken( - db: Awaited>["db"], - userId: string, - ) { - await db.create({ - model: "oauthApplication", - data: { - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - type: "web", - name: "Confidential Test Client", - redirectUrls: "http://localhost/callback", - disabled: false, - metadata: null, - icon: null, - userId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - await db.create({ - model: "oauthAccessToken", - data: { - accessToken: "stale-access-token-not-used", - refreshToken: REFRESH_TOKEN, - accessTokenExpiresAt: new Date(Date.now() - 60 * 1000), - refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000), - clientId: CLIENT_ID, - userId, - scopes: "openid profile email offline_access", - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - } - - it("should reject refresh_token grant on confidential client without client_secret", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: false, - }), - ], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: CLIENT_ID, - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_client"); - expect(body?.access_token).toBeUndefined(); - }); - - it("should reject refresh_token grant on confidential client with wrong client_secret", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: false, - }), - ], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: CLIENT_ID, - client_secret: "wrong-secret", - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_client"); - expect(body?.access_token).toBeUndefined(); - }); - - it("should accept refresh_token grant when client_secret comes via Authorization: Basic", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: false, - }), - ], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const basic = `Basic ${Buffer.from( - `${CLIENT_ID}:${CLIENT_SECRET}`, - ).toString("base64")}`; - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - authorization: basic, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(200); - expect(body?.access_token).toBeDefined(); - expect(body?.refresh_token).toBeDefined(); - }); - - it("should accept refresh_token grant when Authorization: Basic and matching client_id is in body", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: false, - }), - ], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const basic = `Basic ${Buffer.from( - `${CLIENT_ID}:${CLIENT_SECRET}`, - ).toString("base64")}`; - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - authorization: basic, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: CLIENT_ID, - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(200); - expect(body?.access_token).toBeDefined(); - }); - - it("should reject refresh_token grant when body client_id does not match Authorization: Basic", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: false, - }), - ], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const basic = `Basic ${Buffer.from( - `${CLIENT_ID}:${CLIENT_SECRET}`, - ).toString("base64")}`; - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - authorization: basic, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: "different-client-id", - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_client"); - }); - - it("should reject refresh_token grant when the confidential client is disabled", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - requirePKCE: false, - }), - ], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - await db.update<{ disabled: boolean }>({ - model: "oauthApplication", - where: [{ field: "clientId", value: CLIENT_ID }], - update: { disabled: true }, - }); - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/oauth2/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_client"); - expect(body?.access_token).toBeUndefined(); - }); -}); - -/** - * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-9h47-pqcx-hjr4 - */ -describe("oidc-provider discovery metadata and PKCE gate (security)", () => { - const buildInstance = async ( - options?: Partial[0]>, - ) => { - const { auth, customFetchImpl, signInWithTestUser } = await getTestInstance( - { - baseURL: "http://localhost:3000", - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/oauth2/authorize", - ...options, - }), - ], - }, - ); - return { auth, customFetchImpl, signInWithTestUser }; - }; - - it("/.well-known/openid-configuration must not advertise alg=none", async () => { - const { auth } = await buildInstance(); - const res = await auth.handler( - new Request( - "http://localhost:3000/api/auth/.well-known/openid-configuration", - { method: "GET" }, - ), - ); - const body = (await res.json()) as { - id_token_signing_alg_values_supported: string[]; - code_challenge_methods_supported: string[]; - }; - expect(body.id_token_signing_alg_values_supported).not.toContain("none"); - expect(body.code_challenge_methods_supported).toEqual(["S256"]); - }); - - it("authorize must reject code_challenge_method=plain when allowPlainCodeChallengeMethod is false (default)", async () => { - const trustedClient: Client = { - clientId: "pkce-plain-rejection-client", - clientSecret: "test-client-secret", - redirectUrls: ["http://localhost:3000/cb"], - metadata: {}, - type: "web", - disabled: false, - name: "test", - icon: undefined, - skipConsent: true, - }; - const { auth, signInWithTestUser } = await buildInstance({ - trustedClients: [trustedClient], - }); - const { headers: sessionHeaders } = await signInWithTestUser(); - - const url = new URL("http://localhost:3000/api/auth/oauth2/authorize"); - url.searchParams.set("client_id", trustedClient.clientId); - url.searchParams.set("redirect_uri", trustedClient.redirectUrls[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", "openid profile email"); - url.searchParams.set("state", "xyz"); - url.searchParams.set( - "code_challenge", - "plainPkceVerifier_at_least_43_chars_long_for_validity", - ); - url.searchParams.set("code_challenge_method", "plain"); - - const res = await auth.handler( - new Request(url, { method: "GET", headers: sessionHeaders }), - ); - const location = res.headers.get("location") ?? ""; - expect(location).toContain("error=invalid_request"); - expect(location).toMatch(/invalid.*code.*challenge.*method/i); - }); - - it("authorize must reject missing code_challenge_method when code_challenge is provided", async () => { - const trustedClient: Client = { - clientId: "pkce-missing-method-client", - clientSecret: "test-client-secret", - redirectUrls: ["http://localhost:3000/cb"], - metadata: {}, - type: "web", - disabled: false, - name: "test", - icon: undefined, - skipConsent: true, - }; - const { auth, signInWithTestUser } = await buildInstance({ - trustedClients: [trustedClient], - }); - const { headers: sessionHeaders } = await signInWithTestUser(); - - const url = new URL("http://localhost:3000/api/auth/oauth2/authorize"); - url.searchParams.set("client_id", trustedClient.clientId); - url.searchParams.set("redirect_uri", trustedClient.redirectUrls[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", "openid profile email"); - url.searchParams.set("state", "xyz"); - url.searchParams.set( - "code_challenge", - "someChallengeValue_at_least_43_chars_long_for_validity", - ); - // code_challenge_method intentionally omitted - - const res = await auth.handler( - new Request(url, { method: "GET", headers: sessionHeaders }), - ); - const location = res.headers.get("location") ?? ""; - const callback = trustedClient.redirectUrls[0]!; - const issuedCode = - location.startsWith(callback) && /[?&]code=/.test(location); - expect(issuedCode).toBe(false); - expect(location).toContain("error=invalid_request"); - }); - - it("authorize must reject code_challenge_method without code_challenge", async () => { - const trustedClient: Client = { - clientId: "pkce-method-without-challenge-client", - clientSecret: "test-client-secret", - redirectUrls: ["http://localhost:3000/cb"], - metadata: {}, - type: "web", - disabled: false, - name: "test", - icon: undefined, - skipConsent: true, - }; - const { auth, signInWithTestUser } = await buildInstance({ - trustedClients: [trustedClient], - }); - const { headers: sessionHeaders } = await signInWithTestUser(); - - const url = new URL("http://localhost:3000/api/auth/oauth2/authorize"); - url.searchParams.set("client_id", trustedClient.clientId); - url.searchParams.set("redirect_uri", trustedClient.redirectUrls[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", "openid profile email"); - url.searchParams.set("state", "xyz"); - url.searchParams.set("code_challenge_method", "S256"); - // code_challenge intentionally omitted - - const res = await auth.handler( - new Request(url, { method: "GET", headers: sessionHeaders }), - ); - const location = res.headers.get("location") ?? ""; - const callback = trustedClient.redirectUrls[0]!; - const issuedCode = - location.startsWith(callback) && /[?&]code=/.test(location); - expect(issuedCode).toBe(false); - expect(location).toContain("error=invalid_request"); - }); - - it("authorize accepts missing code_challenge_method when allowPlainCodeChallengeMethod is opted in", async () => { - const trustedClient: Client = { - clientId: "pkce-plain-opt-in-client", - clientSecret: "test-client-secret", - redirectUrls: ["http://localhost:3000/cb"], - metadata: {}, - type: "web", - disabled: false, - name: "test", - icon: undefined, - skipConsent: true, - }; - const { auth, signInWithTestUser } = await buildInstance({ - trustedClients: [trustedClient], - allowPlainCodeChallengeMethod: true, - }); - const { headers: sessionHeaders } = await signInWithTestUser(); - - const codeChallenge = - "someChallengeValue_at_least_43_chars_long_for_validity"; - const url = new URL("http://localhost:3000/api/auth/oauth2/authorize"); - url.searchParams.set("client_id", trustedClient.clientId); - url.searchParams.set("redirect_uri", trustedClient.redirectUrls[0]!); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", "openid profile email"); - url.searchParams.set("state", "xyz"); - url.searchParams.set("code_challenge", codeChallenge); - // code_challenge_method intentionally omitted; the opt-in retains the - // legacy "default to plain" behavior so this should issue a code. - - const res = await auth.handler( - new Request(url, { method: "GET", headers: sessionHeaders }), - ); - const location = res.headers.get("location") ?? ""; - const callback = trustedClient.redirectUrls[0]!; - const issuedCode = - location.startsWith(callback) && /[?&]code=/.test(location); - expect(issuedCode).toBe(true); - expect(location).not.toContain("error="); - - // Inspect the persisted verification value to prove the fallback-resolved - // `plain` method was written to storage. Regression: a previous shape only - // touched a local and never wrote it back to `query.code_challenge_method`, - // so the token endpoint compared against `undefined` at exchange time and - // broke PKCE verification. - const code = new URL(location).searchParams.get("code")!; - const { internalAdapter } = await auth.$context; - const stored = await internalAdapter.findVerificationValue(code); - expect(stored).toBeDefined(); - const storedValue = JSON.parse(stored!.value) as { - codeChallengeMethod?: string; - }; - expect(storedValue.codeChallengeMethod).toBe("plain"); - }); -}); - -describe("oidc end session cross-site protection (security)", async () => { - const { auth, signInWithTestUser, db } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [oidcProvider({ loginPage: "/login" })], - }); - const { user, headers } = await signInWithTestUser(); - const cookie = headers.get("cookie") ?? ""; - - async function seedAccessToken() { - await db.create({ - model: "oauthApplication", - data: { - clientId: "endsession-csrf-client", - clientSecret: "endsession-csrf-secret", - type: "web", - name: "Endsession CSRF Client", - redirectUrls: "http://localhost/callback", - disabled: false, - metadata: null, - icon: null, - userId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - await db.create({ - model: "oauthAccessToken", - data: { - accessToken: "endsession-csrf-access", - refreshToken: "endsession-csrf-refresh", - accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), - refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000), - clientId: "endsession-csrf-client", - userId: user.id, - scopes: "openid", - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - } - - it("rejects a cross-site GET logout carrying only a session cookie", async () => { - await seedAccessToken(); - - const response = await auth.handler( - new Request("http://localhost:3000/api/auth/oauth2/endsession", { - method: "GET", - headers: { cookie, "Sec-Fetch-Site": "cross-site" }, - }), - ); - expect(response.status).toBe(403); - - // A blocked logout must leave the session and OAuth tokens intact. - const session = await auth.api.getSession({ - headers: new Headers({ cookie }), - }); - expect(session?.user.id).toBe(user.id); - const tokenRow = await db.findOne({ - model: "oauthAccessToken", - where: [{ field: "accessToken", value: "endsession-csrf-access" }], - }); - expect(tokenRow).not.toBeNull(); - }); - - it("allows a same-site cookie-only logout", async () => { - const response = await auth.handler( - new Request("http://localhost:3000/api/auth/oauth2/endsession", { - method: "GET", - headers: { cookie, "Sec-Fetch-Site": "same-origin" }, - }), - ); - expect(response.status).toBe(200); - }); -}); diff --git a/packages/better-auth/src/plugins/oidc-provider/redirect-uri.test.ts b/packages/better-auth/src/plugins/oidc-provider/redirect-uri.test.ts deleted file mode 100644 index 8ed6272e65..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/redirect-uri.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-86j7-9j95-vpqj - */ -import { describe, expect, it } from "vitest"; -import { createAuthClient } from "../../client"; -import { getTestInstance } from "../../test-utils/test-instance"; -import { oidcProvider } from "."; -import { oidcClient } from "./client"; - -async function getClient() { - const { customFetchImpl, signInWithTestUser } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [ - oidcProvider({ - loginPage: "/login", - consentPage: "/consent", - }), - ], - }); - const { headers } = await signInWithTestUser(); - return createAuthClient({ - plugins: [oidcClient()], - baseURL: "http://localhost:3000", - fetchOptions: { customFetchImpl, headers }, - }); -} - -describe("oidc-provider redirect_uri scheme validation", () => { - it("rejects javascript:, data:, and vbscript: redirect URIs at registration", async () => { - const client = await getClient(); - for (const uri of [ - "javascript:fetch('/api/auth/get-session')//", - "data:text/html,", - "vbscript:msgbox(1)", - ]) { - const reg = await client.oauth2.register({ - client_name: "Evil App", - redirect_uris: [uri], - }); - expect(reg.error?.status).toBe(400); - expect(reg.data?.client_id).toBeUndefined(); - } - }); - - it("still accepts https and loopback http redirect URIs", async () => { - const client = await getClient(); - const reg = await client.oauth2.register({ - client_name: "Good App", - redirect_uris: [ - "https://client.example.com/callback", - "http://localhost:3000/callback", - ], - }); - expect(reg.error).toBeNull(); - expect(reg.data?.client_id).toBeDefined(); - }); -}); diff --git a/packages/better-auth/src/plugins/oidc-provider/schema.ts b/packages/better-auth/src/plugins/oidc-provider/schema.ts deleted file mode 100644 index a6107c52f7..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/schema.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { BetterAuthPluginDBSchema } from "@better-auth/core/db"; -import * as z from "zod"; - -const oAuthApplicationSchema = z.object({ - /** - * Client ID - * - * size 32 - * - * as described on https://www.rfc-editor.org/rfc/rfc6749.html#section-2.2 - */ - clientId: z.string(), - /** - * Client Secret - * - * A secret for the client, if required by the authorization server. - * Optional for public clients using PKCE. - * - * size 32 - */ - clientSecret: z.string().optional(), - /** - * The client type - * - * as described on https://www.rfc-editor.org/rfc/rfc6749.html#section-2.1 - * - * - web - A web application - * - native - A mobile application - * - user-agent-based - A user-agent-based application - * - public - A public client (PKCE-enabled, no client_secret) - */ - type: z.enum(["web", "native", "user-agent-based", "public"]), - /** - * The name of the client. - */ - name: z.string(), - /** - * The icon of the client. - */ - icon: z.string().optional(), - /** - * Additional metadata about the client. - */ - metadata: z.string().optional(), - /** - * Whether the client is disabled or not. - */ - disabled: z.boolean().optional().default(false), - - // Database fields - redirectUrls: z.string(), - userId: z.string().optional(), - createdAt: z.date(), - updatedAt: z.date(), -}); - -export type OAuthApplication = z.infer; - -export const schema = { - oauthApplication: { - modelName: "oauthApplication", - fields: { - name: { - type: "string", - }, - icon: { - type: "string", - required: false, - }, - metadata: { - type: "string", - required: false, - }, - clientId: { - type: "string", - unique: true, - }, - clientSecret: { - type: "string", - required: false, - }, - redirectUrls: { - type: "string", - }, - type: { - type: "string", - }, - disabled: { - type: "boolean", - required: false, - defaultValue: false, - }, - userId: { - type: "string", - required: false, - references: { - model: "user", - field: "id", - onDelete: "cascade", - }, - index: true, - }, - createdAt: { - type: "date", - }, - updatedAt: { - type: "date", - }, - }, - }, - oauthAccessToken: { - modelName: "oauthAccessToken", - fields: { - accessToken: { - type: "string", - unique: true, - }, - refreshToken: { - type: "string", - unique: true, - }, - accessTokenExpiresAt: { - type: "date", - }, - refreshTokenExpiresAt: { - type: "date", - }, - clientId: { - type: "string", - references: { - model: "oauthApplication", - field: "clientId", - onDelete: "cascade", - }, - index: true, - }, - userId: { - type: "string", - required: false, - references: { - model: "user", - field: "id", - onDelete: "cascade", - }, - index: true, - }, - scopes: { - type: "string", - }, - createdAt: { - type: "date", - }, - updatedAt: { - type: "date", - }, - }, - }, - oauthConsent: { - modelName: "oauthConsent", - fields: { - clientId: { - type: "string", - references: { - model: "oauthApplication", - field: "clientId", - onDelete: "cascade", - }, - index: true, - }, - userId: { - type: "string", - references: { - model: "user", - field: "id", - onDelete: "cascade", - }, - index: true, - }, - scopes: { - type: "string", - }, - createdAt: { - type: "date", - }, - updatedAt: { - type: "date", - }, - consentGiven: { - type: "boolean", - }, - }, - }, -} satisfies BetterAuthPluginDBSchema; diff --git a/packages/better-auth/src/plugins/oidc-provider/types.ts b/packages/better-auth/src/plugins/oidc-provider/types.ts deleted file mode 100644 index 0cab32e374..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/types.ts +++ /dev/null @@ -1,546 +0,0 @@ -import type { InferOptionSchema, User } from "../../types"; -import type { OAuthApplication, schema } from "./schema"; - -export interface OIDCOptions { - /** @internal */ - __skipDeprecationWarning?: boolean | undefined; - /** - * The amount of time in seconds that the access token is valid for. - * - * @default 3600 (1 hour) - Recommended by the OIDC spec - */ - accessTokenExpiresIn?: number | undefined; - /** - * Allow dynamic client registration. - */ - allowDynamicClientRegistration?: boolean | undefined; - /** - * The metadata for the OpenID Connect provider. - */ - metadata?: Partial | undefined; - /** - * The amount of time in seconds that the refresh token is valid for. - * - * @default 604800 (7 days) - Recommended by the OIDC spec - */ - refreshTokenExpiresIn?: number | undefined; - /** - * The amount of time in seconds that the authorization code is valid for. - * - * @default 600 (10 minutes) - Recommended by the OIDC spec - */ - codeExpiresIn?: number | undefined; - /** - * The scopes that the client is allowed to request. - * - * @see https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims - * @default - * ```ts - * ["openid", "profile", "email", "offline_access"] - * ``` - */ - scopes?: string[] | undefined; - /** - * The default scope to use if the client does not provide one. - * - * @default "openid" - */ - defaultScope?: string | undefined; - /** - * A URL to the consent page where the user will be redirected if the client - * requests consent. - * - * After the user consents, they should be redirected by the client to the - * `redirect_uri` with the authorization code. - * - * When the server redirects the user to the consent page, it will include the - * following query parameters: - * - `consent_code` - The consent code to identify the authorization request. - * - `client_id` - The ID of the client. - * - `scope` - The requested scopes. - * - * Once the user consents, you need to call the `/oauth2/consent` endpoint - * with `accept: true` and optionally the `consent_code` (if using URL parameter flow) - * to complete the authorization. This will return the client to the `redirect_uri` - * with the authorization code. - * - * @example - * ```ts - * consentPage: "/oauth/authorize" - * ``` - */ - consentPage?: string | undefined; - /** - * The HTML for the consent page. This is used if `consentPage` is not - * provided. This should be a function that returns an HTML string. - * The function will be called with the following props: - */ - getConsentHTML?: - | ((props: { - clientId: string; - clientName: string; - clientIcon?: string | undefined; - clientMetadata: Record | null; - code: string; - scopes: string[]; - }) => string) - | undefined; - /** - * The URL to the login page. This is used if the client requests the `login` - * prompt. - */ - loginPage: string; - /** - * Whether to require PKCE (proof key code exchange) or not - * - * According to OAuth2.1 spec this should be required. But in any - * case if you want to disable this you can use this options. - * - * @default true - */ - requirePKCE?: boolean | undefined; - /** - * Allow plain to be used as a code challenge method. - * - * @default true - */ - allowPlainCodeChallengeMethod?: boolean | undefined; - /** - * Custom function to generate a client ID. - */ - generateClientId?: (() => string) | undefined; - /** - * Custom function to generate a client secret. - */ - generateClientSecret?: (() => string) | undefined; - /** - * Get the additional user info claims - * - * This applies to the `userinfo` endpoint and the `id_token`. - * - * @param user - The user object. - * @param scopes - The scopes that the client requested. - * @param client - The client object. - * @returns The user info claim. - */ - getAdditionalUserInfoClaim?: - | (( - user: User & Record, - scopes: string[], - client: Client, - ) => Record | Promise>) - | undefined; - /** - * Trusted clients that are configured directly in the provider options. - * These clients bypass database lookups and can optionally skip consent screens. - */ - trustedClients?: Client[] | undefined; - /** - * Store the client secret in your database in a secure way - * Note: This will not affect the client secret sent to the user, it will only affect the client secret stored in your database - * - * - "hashed" - The client secret is hashed using the `hash` function. - * - "plain" - The client secret is stored in the database in plain text. - * - "encrypted" - The client secret is encrypted using the `encrypt` function. - * - { hash: (clientSecret: string) => Promise } - A function that hashes the client secret. - * - { encrypt: (clientSecret: string) => Promise, decrypt: (clientSecret: string) => Promise } - A function that encrypts and decrypts the client secret. - * - * @default "plain" - */ - storeClientSecret?: - | ( - | "hashed" - | "plain" - | "encrypted" - | { hash: (clientSecret: string) => Promise } - | { - encrypt: (clientSecret: string) => Promise; - decrypt: (clientSecret: string) => Promise; - } - ) - | undefined; - /** - * Whether to use the JWT plugin to sign the ID token. - * - * @default false - */ - useJWTPlugin?: boolean | undefined; - /** - * Custom schema for the OIDC plugin - */ - schema?: InferOptionSchema | undefined; -} - -export interface AuthorizationQuery { - /** - * The response type. Must be 'code' or 'token'. Code is for authorization code flow, token is - * for implicit flow. - */ - response_type: "code" | "token"; - /** - * The redirect URI for the client. Must be one of the registered redirect URLs for the client. - */ - redirect_uri?: string | undefined; - /** - * The scope of the request. Must be a space-separated list of case sensitive strings. - * - * - "openid" is required for all requests - * - "profile" is required for requests that require user profile information. - * - "email" is required for requests that require user email information. - * - "offline_access" is required for requests that require a refresh token. - */ - scope?: string | undefined; - /** - * Opaque value used to maintain state between the request and the callback. Typically, - * Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the - * value of this parameter with a browser cookie. - * - * Note: Better Auth stores the state in a database instead of a cookie. - This is to minimize - * the complication with native apps and other clients that may not have access to cookies. - */ - state: string; - /** - * The client ID. Must be the ID of a registered client. - */ - client_id: string; - /** - * The prompt parameter is used to specify the type of user interaction that is required. - */ - prompt?: - | (string & {}) - | ("none" | "consent" | "login" | "select_account") - | undefined; - /** - * The display parameter is used to specify how the authorization server displays the - * authentication and consent user interface pages to the end user. - */ - display?: ("page" | "popup" | "touch" | "wap") | undefined; - /** - * End-User's preferred languages and scripts for the user interface, represented as a - * space-separated list of BCP47 [RFC5646] language tag values, ordered by preference. For - * instance, the value "fr-CA fr en" represents a preference for French as spoken in Canada, - * then French (without a region designation), followed by English (without a region - * designation). - * - * Better Auth does not support this parameter yet. It'll not throw an error if it's provided, - * - * 🏗️ currently not implemented - */ - ui_locales?: string | undefined; - /** - * The maximum authentication age. - * - * Specifies the allowable elapsed time in seconds since the last time the End-User was - * actively authenticated by the provider. If the elapsed time is greater than this value, the - * provider MUST attempt to actively re-authenticate the End-User. - * - * Note that max_age=0 is equivalent to prompt=login. - */ - max_age?: number | undefined; - /** - * Requested Authentication Context Class Reference values. - * - * Space-separated string that - * specifies the acr values that the Authorization Server is being requested to use for - * processing this Authentication Request, with the values appearing in order of preference. - * The Authentication Context Class satisfied by the authentication performed is returned as - * the acr Claim Value, as specified in Section 2. The acr Claim is requested as a Voluntary - * Claim by this parameter. - */ - acr_values?: string | undefined; - /** - * Hint to the Authorization Server about the login identifier the End-User might use to log in - * (if necessary). This hint can be used by an RP if it first asks the End-User for their - * e-mail address (or other identifier) and then wants to pass that value as a hint to the - * discovered authorization service. It is RECOMMENDED that the hint value match the value used - * for discovery. This value MAY also be a phone number in the format specified for the - * phone_number Claim. The use of this parameter is left to the OP's discretion. - */ - login_hint?: string | undefined; - /** - * ID Token previously issued by the Authorization Server being passed as a hint about the - * End-User's current or past authenticated session with the Client. - * - * 🏗️ currently not implemented - */ - id_token_hint?: string | undefined; - /** - * Code challenge - */ - code_challenge?: string | undefined; - /** - * Code challenge method used - */ - code_challenge_method?: ("plain" | "s256") | undefined; - /** - * String value used to associate a Client session with an ID Token, and to mitigate replay - * attacks. The value is passed through unmodified from the Authentication Request to the ID Token. - * If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the - * value of the nonce parameter sent in the Authentication Request. If present in the - * Authentication Request, Authorization Servers MUST include a nonce Claim in the ID Token - * with the Claim Value being the nonce value sent in the Authentication Request. - */ - nonce?: string | undefined; -} - -export type Client = Omit< - OAuthApplication, - "metadata" | "updatedAt" | "createdAt" | "redirectUrls" | "userId" -> & { - metadata: Record | null; - /** - * List of registered redirect URLs. Must include the whole URL, including the protocol, port, - * and path. - * - * For example, `https://example.com/auth/callback` - */ - redirectUrls: string[]; - /** - * Whether to skip the consent screen for this client. - * Only applies to trusted clients. - */ - skipConsent?: boolean | undefined; -}; - -export interface TokenBody { - /** - * The grant type. Must be 'authorization_code' or 'refresh_token'. - */ - grant_type: "authorization_code" | "refresh_token"; - /** - * The authorization code received from the authorization server. - */ - code?: string | undefined; - /** - * The redirect URI of the client. - */ - redirect_uri?: string | undefined; - /** - * The client ID. - */ - client_id?: string | undefined; - /** - * The client secret. - */ - client_secret?: string | undefined; - /** - * The refresh token received from the authorization server. - */ - refresh_token?: string | undefined; -} - -export interface CodeVerificationValue { - /** - * The client ID - */ - clientId: string; - /** - * The redirect URI for the client - */ - redirectURI: string; - /** - * The scopes that the client requested - */ - scope: string[]; - /** - * The user ID - */ - userId: string; - /** - * The time that the user authenticated - */ - authTime: number; - /** - * Whether the user needs to consent to the scopes - * before the code can be exchanged for an access token. - * - * If this is true, then the code is treated as a consent - * request. Once the user consents, the code will be updated - * with the actual code. - */ - requireConsent: boolean; - /** - * The state parameter from the request - * - * If the prompt is set to `consent`, then the state - * parameter is saved here. This is to prevent the client - * from using the code before the user consents. - */ - state: string | null; - /** - * Code challenge - */ - codeChallenge?: string | undefined; - /** - * Code Challenge Method - */ - codeChallengeMethod?: ("sha256" | "plain") | undefined; - /** - * Nonce - */ - nonce?: string | undefined; -} - -export interface OAuthAccessToken { - /** - * The access token - */ - accessToken: string; - /** - * The refresh token - */ - refreshToken: string; - /** - * The time that the access token expires - */ - accessTokenExpiresAt: Date; - /** - * The time that the refresh token expires - */ - refreshTokenExpiresAt: Date; - /** - * The client ID - */ - clientId: string; - /** - * The user ID - */ - userId: string; - /** - * The scopes that the access token has access to - */ - scopes: string; -} - -export interface OIDCMetadata { - /** - * The issuer identifier, this is the URL of the provider and can be used to verify - * the `iss` claim in the ID token. - * - * default: the base URL of the server (e.g. `https://example.com`) - */ - issuer: string; - /** - * The URL of the authorization endpoint. - * - * @default `/oauth2/authorize` - */ - authorization_endpoint: string; - /** - * The URL of the token endpoint. - * - * @default `/oauth2/token` - */ - token_endpoint: string; - /** - * The URL of the userinfo endpoint. - * - * @default `/oauth2/userinfo` - */ - userinfo_endpoint: string; - /** - * The URL of the jwks_uri endpoint. - * - * For JWKS to work, you must install the `jwt` plugin. - * - * This value is automatically set to `/jwks` if the `jwt` plugin is installed. - * - * @default `/jwks` - */ - jwks_uri: string; - /** - * The URL of the dynamic client registration endpoint. - * - * @default `/oauth2/register` - */ - registration_endpoint: string; - /** - * Supported scopes. - */ - scopes_supported: string[]; - /** - * Supported response types. - * - * only `code` is supported. - */ - response_types_supported: ["code"]; - /** - * Supported response modes. - * - * `query`: the authorization code is returned in the query string - * - * only `query` is supported. - */ - response_modes_supported: ["query"]; - /** - * Supported grant types. - * - * The first element MUST be "authorization_code"; additional grant types like - * "refresh_token" can follow. Guarantees a non-empty array at the type level. - */ - grant_types_supported: [ - "authorization_code", - ...("authorization_code" | "refresh_token")[], - ]; - /** - * acr_values supported. - * - * - `urn:mace:incommon:iap:silver`: Silver level of assurance - * - `urn:mace:incommon:iap:bronze`: Bronze level of assurance - * - * only `urn:mace:incommon:iap:silver` and `urn:mace:incommon:iap:bronze` are supported. - * - * - * @default - * ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"] - * @see https://incommon.org/federation/attributes.html - */ - acr_values_supported: string[]; - /** - * Supported subject types. - * - * pairwise: the subject identifier is unique to the client - * public: the subject identifier is unique to the server - * - * only `public` is supported. - */ - subject_types_supported: ["public"]; - /** - * Supported ID token signing algorithms. - */ - id_token_signing_alg_values_supported: string[]; - /** - * Supported token endpoint authentication methods. - * - * only `client_secret_basic` and `client_secret_post` are supported. - * - * @default - * ["client_secret_basic", "client_secret_post"] - */ - token_endpoint_auth_methods_supported: [ - "client_secret_basic", - "client_secret_post", - "none", - ]; - /** - * Supported claims. - * - * @default - * ["sub", "iss", "aud", "exp", "nbf", "iat", "jti", "email", "email_verified", "name"] - */ - claims_supported: string[]; - /** - * Supported code challenge methods. - * - * only `S256` is supported. - * - * @default ["S256"] - */ - code_challenge_methods_supported: ["S256"]; - /** - * The URL of the RP-initiated logout endpoint. - * - * @default `/oauth2/endsession` - */ - end_session_endpoint?: string; -} diff --git a/packages/better-auth/src/plugins/oidc-provider/utils.ts b/packages/better-auth/src/plugins/oidc-provider/utils.ts deleted file mode 100644 index 4d918b3ac7..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { base64Url } from "@better-auth/utils/base64"; -import { createHash } from "@better-auth/utils/hash"; - -/** - * Default client secret hasher using SHA-256 - */ -export const defaultClientSecretHasher = async (clientSecret: string) => { - const hash = await createHash("SHA-256").digest( - new TextEncoder().encode(clientSecret), - ); - const hashed = base64Url.encode(new Uint8Array(hash), { - padding: false, - }); - return hashed; -}; diff --git a/packages/better-auth/src/plugins/oidc-provider/utils/prompt.test.ts b/packages/better-auth/src/plugins/oidc-provider/utils/prompt.test.ts deleted file mode 100644 index 316a3144da..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/utils/prompt.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InvalidRequest } from "../error"; -import { parsePrompt } from "./prompt"; - -describe("parsePrompt", () => { - it("should parse single prompt value", () => { - const result = parsePrompt("login"); - expect(result.has("login")).toBe(true); - expect(result.size).toBe(1); - }); - - it("should parse multiple prompt values", () => { - const result = parsePrompt("login consent"); - expect(result.has("login")).toBe(true); - expect(result.has("consent")).toBe(true); - expect(result.size).toBe(2); - }); - - it("should parse space-separated prompts with extra spaces", () => { - const result = parsePrompt("login consent select_account"); - expect(result.has("login")).toBe(true); - expect(result.has("consent")).toBe(true); - expect(result.has("select_account")).toBe(true); - expect(result.size).toBe(3); - }); - - it("should ignore invalid prompt values", () => { - const result = parsePrompt("login invalid_prompt consent"); - expect(result.has("login")).toBe(true); - expect(result.has("consent")).toBe(true); - expect(result.size).toBe(2); - }); - - it("should handle none prompt alone", () => { - const result = parsePrompt("none"); - expect(result.has("none")).toBe(true); - expect(result.size).toBe(1); - }); - - it("should throw error when none is combined with other prompts", () => { - expect(() => parsePrompt("none login")).toThrow(InvalidRequest); - try { - parsePrompt("none login"); - expect.fail("Should have thrown InvalidRequest"); - } catch (error) { - expect(error).toBeInstanceOf(InvalidRequest); - expect((error as any).body.error_description).toBe( - "prompt none must only be used alone", - ); - } - }); - - it("should throw error when none is combined with consent", () => { - expect(() => parsePrompt("none consent")).toThrow(InvalidRequest); - }); - - it("should return empty set for empty string", () => { - const result = parsePrompt(""); - expect(result.size).toBe(0); - }); - - it("should handle all valid prompt types", () => { - const result = parsePrompt("login consent select_account"); - expect(result.has("login")).toBe(true); - expect(result.has("consent")).toBe(true); - expect(result.has("select_account")).toBe(true); - expect(result.size).toBe(3); - }); -}); diff --git a/packages/better-auth/src/plugins/oidc-provider/utils/prompt.ts b/packages/better-auth/src/plugins/oidc-provider/utils/prompt.ts deleted file mode 100644 index 67ce96631e..0000000000 --- a/packages/better-auth/src/plugins/oidc-provider/utils/prompt.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { InvalidRequest } from "../error"; - -export type AuthorizePrompt = "login" | "consent" | "select_account" | "none"; -export type AuthorizePromptSet = ReadonlySet; - -/** - * Parse space-separated prompt string into a set of prompts - * - * @param prompt - */ -export function parsePrompt(prompt: string) { - const prompts = prompt.split(" ").map((p) => p.trim()); - const set = new Set(); - for (const p of prompts) { - if ( - p === "login" || - p === "consent" || - p === "select_account" || - p === "none" - ) { - set.add(p); - } - } - - if (set.has("none") && set.size > 1) { - throw new InvalidRequest("prompt none must only be used alone"); - } - - return new Set(set) as AuthorizePromptSet; -} diff --git a/packages/better-auth/tsdown.config.ts b/packages/better-auth/tsdown.config.ts index c7940938f3..448050d1c1 100644 --- a/packages/better-auth/tsdown.config.ts +++ b/packages/better-auth/tsdown.config.ts @@ -51,7 +51,6 @@ export default defineConfig({ "./src/plugins/multi-session/index.ts", "./src/plugins/one-tap/index.ts", "./src/plugins/open-api/index.ts", - "./src/plugins/oidc-provider/index.ts", "./src/plugins/oauth-proxy/index.ts", "./src/plugins/organization/index.ts", "./src/plugins/organization/access/index.ts", diff --git a/packages/cli/src/commands/init/configs/temp-plugins.config.ts b/packages/cli/src/commands/init/configs/temp-plugins.config.ts index 46fac618b1..a22f5ab367 100644 --- a/packages/cli/src/commands/init/configs/temp-plugins.config.ts +++ b/packages/cli/src/commands/init/configs/temp-plugins.config.ts @@ -636,29 +636,6 @@ export const tempPluginsConfig = { ], }, }, - oidc: { - displayName: "OIDC", - auth: { - function: "oidc", - imports: [ - { - path: "better-auth/plugins", - imports: [createImport({ name: "oidc" })], - isNamedImport: false, - }, - ], - }, - authClient: { - function: "oidcClient", - imports: [ - { - path: "better-auth/client/plugins", - imports: [createImport({ name: "oidcClient" })], - isNamedImport: false, - }, - ], - }, - }, admin: { displayName: "Admin", auth: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f68f0d72dd..af4c716a9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -987,9 +987,6 @@ importers: happy-dom: specifier: '>=20.8.9 <21' version: 20.8.9 - listhen: - specifier: ^1.9.0 - version: 1.9.0 msw: specifier: ^2.12.10 version: 2.12.10(@types/node@25.6.0)(typescript@5.9.3) @@ -1636,9 +1633,6 @@ importers: better-auth: specifier: workspace:* version: link:../packages/better-auth - openid-client: - specifier: ^6.8.2 - version: 6.8.2 undici: specifier: ^7.24.0 version: 7.24.1 @@ -11503,9 +11497,6 @@ packages: engines: {node: ^20.19 || ^22.12 || ^24} hasBin: true - oauth4webapi@3.8.5: - resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} - ob1@0.83.3: resolution: {integrity: sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==} engines: {node: '>=20.19.4'} @@ -11583,9 +11574,6 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} - openid-client@6.8.2: - resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} - ora@3.4.0: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} @@ -25661,8 +25649,6 @@ snapshots: transitivePeerDependencies: - supports-color - oauth4webapi@3.8.5: {} - ob1@0.83.3: dependencies: flow-enums-runtime: 0.0.6 @@ -25751,11 +25737,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openid-client@6.8.2: - dependencies: - jose: 6.1.3 - oauth4webapi: 3.8.5 - ora@3.4.0: dependencies: chalk: 2.4.2 diff --git a/test/package.json b/test/package.json index 5044d95696..87ca4fa2d1 100644 --- a/test/package.json +++ b/test/package.json @@ -12,7 +12,6 @@ "@better-auth/core": "workspace:*", "@better-fetch/fetch": "catalog:", "better-auth": "workspace:*", - "openid-client": "^6.8.2", "undici": "^7.24.0", "vitest": "catalog:vitest" } diff --git a/test/unit/oidc/index.spec.ts b/test/unit/oidc/index.spec.ts deleted file mode 100644 index 44ca60d1a9..0000000000 --- a/test/unit/oidc/index.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import fs from "node:fs/promises"; -import type { IncomingMessage } from "node:http"; -import https from "node:https"; -import path from "node:path"; -import { betterFetch } from "@better-fetch/fetch"; -import type { Auth } from "better-auth"; -import { betterAuth } from "better-auth"; -import { toNodeHandler } from "better-auth/node"; -import { jwt, oidcProvider } from "better-auth/plugins"; -import * as client from "openid-client"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, -} from "vitest"; - -const options = { - key: await fs.readFile( - path.resolve(import.meta.dirname, "../../fixtures/key.pem"), - ), - cert: await fs.readFile( - path.resolve(import.meta.dirname, "../../fixtures/cert.pem"), - ), -}; - -describe("oidc provider", async () => { - const authConfig = () => ({ - plugins: [ - oidcProvider({ - loginPage: "/auth/login", - }), - jwt(), - ], - }); - let requests: IncomingMessage[] = []; - let server: https.Server; - let url: string; - let auth: Auth>; - - beforeEach(async () => { - auth = betterAuth(authConfig()); - const handler = toNodeHandler(auth); - server = https.createServer(options, (req, res) => { - requests.push(req); - return handler(req, res); - }); - await new Promise((resolve) => { - server.listen(0, () => { - resolve(); - }); - }); - url = `https://localhost:${(server.address() as any).port}`; - // @ts-expect-error - auth.options.baseURL = url; - }); - - afterEach(() => { - requests = []; - server.close(); - }); - - beforeAll(() => { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - }); - afterAll(() => { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = "1"; - }); - - it("should get server metadata correctly", async () => { - const configuration = await client.discovery( - new URL(`${url}/api/auth/.well-known/openid-configuration`), - "mock", - ); - const serverMetadata = configuration.serverMetadata(); - expect(serverMetadata).toMatchObject({ - acr_values_supported: [ - "urn:mace:incommon:iap:silver", - "urn:mace:incommon:iap:bronze", - ], - authorization_endpoint: expect.stringContaining( - "/api/auth/oauth2/authorize", - ), - claims_supported: [ - "sub", - "iss", - "aud", - "exp", - "nbf", - "iat", - "jti", - "email", - "email_verified", - "name", - ], - code_challenge_methods_supported: ["S256"], - grant_types_supported: ["authorization_code", "refresh_token"], - id_token_signing_alg_values_supported: ["HS256", "none"], - issuer: expect.stringContaining("localhost"), - jwks_uri: expect.stringContaining("/api/auth/jwks"), - registration_endpoint: expect.stringContaining( - "/api/auth/oauth2/register", - ), - response_modes_supported: ["query"], - response_types_supported: ["code"], - scopes_supported: ["openid", "profile", "email", "offline_access"], - subject_types_supported: ["public"], - token_endpoint: expect.stringContaining("/api/auth/oauth2/token"), - token_endpoint_auth_methods_supported: [ - "client_secret_basic", - "client_secret_post", - "none", - ], - userinfo_endpoint: expect.stringContaining("/api/auth/oauth2/userinfo"), - }); - }); - - it("should perform authorization code flow", async () => { - const config = await client.discovery( - new URL(`${url}/api/auth/.well-known/openid-configuration`), - "mock", - ); - const authorizationUrl = client.buildAuthorizationUrl(config, {}); - - expect(authorizationUrl.href).toContain( - "/api/auth/oauth2/authorize?client_id=mock&response_type=code", - ); - - const result = (await betterFetch(authorizationUrl.href, { - throw: true, - }).catch((e) => { - console.log(e); - throw e; - })) as { - redirect: boolean; - url: string; - }; - - { - expect(result!.redirect).toBe(true); - expect(result!.url).toEqual( - "/auth/login?client_id=mock&response_type=code", - ); - } - }); -}); diff --git a/test/unit/oidc/scope-consent.spec.ts b/test/unit/oidc/scope-consent.spec.ts deleted file mode 100644 index ed14772860..0000000000 --- a/test/unit/oidc/scope-consent.spec.ts +++ /dev/null @@ -1,215 +0,0 @@ -import fs from "node:fs/promises"; -import https from "node:https"; -import path from "node:path"; -import { betterFetch } from "@better-fetch/fetch"; -import type { Auth } from "better-auth"; -import { betterAuth } from "better-auth"; -import { toNodeHandler } from "better-auth/node"; -import { jwt, oidcProvider } from "better-auth/plugins"; -import * as client from "openid-client"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, -} from "vitest"; - -const options = { - key: await fs.readFile( - path.resolve(import.meta.dirname, "../../fixtures/key.pem"), - ), - cert: await fs.readFile( - path.resolve(import.meta.dirname, "../../fixtures/cert.pem"), - ), -}; - -describe("oidc scope consent", async () => { - const authConfig = () => ({ - emailAndPassword: { - enabled: true, - }, - plugins: [ - oidcProvider({ - loginPage: "/auth/login", - consentPage: "/auth/consent", - }), - jwt(), - ], - }); - let requests: any[] = []; - let server: https.Server; - let url: string; - let auth: Auth>; - - beforeEach(async () => { - auth = betterAuth(authConfig()); - const handler = toNodeHandler(auth); - server = https.createServer(options, (req, res) => { - requests.push(req); - return handler(req, res); - }); - await new Promise((resolve) => { - server.listen(0, () => { - resolve(); - }); - }); - url = `https://localhost:${(server.address() as any).port}`; - // @ts-expect-error - auth.options.baseURL = url; - }); - - afterEach(() => { - requests = []; - server.close(); - }); - - beforeAll(() => { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - }); - afterAll(() => { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = "1"; - }); - - it("should require new consent when scopes are expanded", async () => { - // 1. Create a user and login - await auth.api.signUpEmail({ - body: { - email: "test@example.com", - password: "password123", - name: "Test User", - }, - }); - - const signInRes = await auth.api.signInEmail({ - body: { - email: "test@example.com", - password: "password123", - }, - asResponse: true, - }); - const cookie = signInRes.headers.get("set-cookie"); - - // 2. Register a client - let clientReg: Awaited< - ReturnType - >; - try { - clientReg = await auth.api.registerOAuthApplication({ - body: { - client_name: "scope-test-client", - redirect_uris: ["https://rp.example.com/callback"], - }, - headers: { - cookie: cookie || "", - }, - }); - } catch (e) { - console.error(e); - throw e; - } - - // 3. Discover configuration - const config = await client.discovery( - new URL(`${url}/api/auth/.well-known/openid-configuration`), - clientReg.client_id, - clientReg.client_secret, - ); - - // 4. Initial request with base scopes - const authorizationUrl1 = client.buildAuthorizationUrl(config, { - redirect_uri: "https://rp.example.com/callback", - scope: "openid profile", - code_challenge: "challenge", - code_challenge_method: "S256", - }); - - const res1 = await betterFetch<{ redirect: boolean; url: string }>( - authorizationUrl1.href, - { - headers: { - cookie: cookie || "", - }, - }, - ); - - let redirectURI1 = ""; - if (res1.data?.redirect) { - redirectURI1 = res1.data.url; - } - - // Should require consent initially - expect(redirectURI1).toContain("consent_code="); - - // Extract consent code and accept - const consentUrl1 = new URL(redirectURI1, "http://localhost"); - const consentCode1 = consentUrl1.searchParams.get("consent_code"); - - await auth.api.oAuthConsent({ - body: { - accept: true, - consent_code: consentCode1!, - }, - headers: { - cookie: cookie || "", - }, - }); - - // 5. Second request with base scopes (no change) - { - const authorizationUrl2 = client.buildAuthorizationUrl(config, { - redirect_uri: "https://rp.example.com/callback", - scope: "openid profile", - code_challenge: "challenge", - code_challenge_method: "S256", - }); - - const res2 = await betterFetch<{ redirect: boolean; url: string }>( - authorizationUrl2.href, - { - headers: { - cookie: cookie || "", - }, - }, - ); - - let redirectURI2 = ""; - if (res2.data?.redirect) { - redirectURI2 = res2.data.url; - } - - // Should NOT require consent because scopes are unchanged - expect(redirectURI2).not.toContain("consent_code="); - expect(redirectURI2).toContain("code="); - } - - { - // 6. Third request with EXPANDED scopes (adding email) - const authorizationUrl2 = client.buildAuthorizationUrl(config, { - redirect_uri: "https://rp.example.com/callback", - scope: "openid profile email", - code_challenge: "challenge", - code_challenge_method: "S256", - }); - - const res2 = await betterFetch<{ redirect: boolean; url: string }>( - authorizationUrl2.href, - { - headers: { - cookie: cookie || "", - }, - }, - ); - - let redirectURI2 = ""; - if (res2.data?.redirect) { - redirectURI2 = res2.data.url; - } - - // Should require consent again because scope expanded - expect(redirectURI2).toContain("consent_code="); - } - }); -});