refactor!: remove deprecated oidc-provider plugin (#10031)

This commit is contained in:
Gustavo Valverde
2026-06-12 21:45:19 +00:00
committed by GitHub
parent 5e8b2e5180
commit 6fe9faab65
35 changed files with 14 additions and 6766 deletions
+5
View File
@@ -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`.
@@ -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" },
-18
View File
@@ -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: () => <LucideAArrowDown className="w-4 h-4" />,
},
{
title: "OIDC Provider",
href: "/docs/plugins/oidc-provider",
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 32 32"
>
<path
fill="currentColor"
d="M16 2a8 8 0 1 0 8 8a8.01 8.01 0 0 0-8-8m5.91 7h-2.438a15.3 15.3 0 0 0-.791-4.36A6.01 6.01 0 0 1 21.91 9m-5.888 6.999h-.008c-.38-.12-1.309-1.821-1.479-4.999h2.93c-.17 3.176-1.094 4.877-1.443 4.999M14.535 9c.17-3.176 1.094-4.877 1.443-4.999h.008c.38.12 1.309 1.821 1.479 4.999zM13.32 4.64A15.3 15.3 0 0 0 12.528 9H10.09a6.01 6.01 0 0 1 3.23-4.36M10.09 11h2.437a15.3 15.3 0 0 0 .792 4.36A6.01 6.01 0 0 1 10.09 11m8.59 4.36a15.3 15.3 0 0 0 .792-4.36h2.438a6.01 6.01 0 0 1-3.23 4.36M28 30H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h24a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2M4 22v6h24v-6z"
></path>
<circle cx="7" cy="25" r="1" fill="currentColor"></circle>
</svg>
),
},
{
title: "OAuth Provider",
href: "/docs/plugins/oauth-provider",
+1 -1
View File
@@ -68,7 +68,7 @@ Both OIDC and MCP plugins are productionready.
* 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"
+1 -1
View File
@@ -125,7 +125,7 @@ Key features:
* **Per-endpoint rate limiting**: Configurable rate limits for each OAuth endpoint.
<Callout type="info">
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.
</Callout>
***
@@ -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 |
| --------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------- | ------------------------------------------------ |
-1
View File
@@ -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 |
-1
View File
@@ -26,7 +26,6 @@
"oauth-proxy",
"oauth-provider",
"cimd",
"oidc-provider",
"mcp",
"device-authorization",
"stripe",
+3 -124
View File
@@ -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
<Step>
### 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.
-655
View File
@@ -1,655 +0,0 @@
---
title: OIDC Provider
description: Open ID Connect plugin for Better Auth that allows you to have your own OIDC provider.
---
<Callout type="warn">
This plugin will soon be deprecated in favor of the [OAuth Provider Plugin](/docs/plugins/oauth-provider).
</Callout>
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.
<Callout type="warn">
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).
</Callout>
## Installation
<Steps>
<Step>
### 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]
]
})
```
</Step>
<Step>
### Migrate the Database
Run the migration or generate the schema to add the necessary fields and tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```package-install
npx auth migrate
```
</Tab>
<Tab value="generate">
```package-install
npx auth generate
```
</Tab>
</Tabs>
See the [Schema](#schema) section to add the fields manually.
</Step>
<Step>
### 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]
]
})
```
</Step>
</Steps>
## 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.
<APIMethod path="/oauth2/register" method="POST" note="By default, client registration requires authentication. Set `allowDynamicClientRegistration: true` to allow public registration. Make sure to add the `oidcClient()` plugin to your auth client configuration.">
```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<string, any> = {"keys": [{"kty": "RSA", "alg": "RS256", "use": "sig", "n": "...", "e": "..."}]}
/**
* The metadata of the application.
*/
metadata?: Record<string, any> = {"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
}
```
</APIMethod>
<Callout>
This endpoint supports [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591) compliant client registration.
</Callout>
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.
<Endpoint path="/oauth2/userinfo" method="GET" />
#### 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<string, any> = {};
// 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.
<Endpoint path="/oauth2/consent" method="POST" />
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
})
]
})
```
<Callout type="info">
When `useJWTPlugin: false` (default), ID tokens are signed with the application secret.
</Callout>
### 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",
},
];
<DatabaseTable name="oauthApplication" fields={oauthApplicationTableFields} />
### 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",
},
];
<DatabaseTable name="oauthAccessToken" fields={oauthAccessTokenTableFields} />
### 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",
},
];
<DatabaseTable name="oauthConsent" fields={oauthConsentTableFields} />
## 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<string, any>` - 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.
-5
View File
@@ -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",
@@ -1,10 +0,0 @@
import { betterAuth } from "better-auth";
import { oidcProvider } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
oidcProvider({
loginPage: "/login",
}),
],
});
-9
View File
@@ -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",
@@ -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(),
@@ -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";
@@ -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";
@@ -50,7 +50,7 @@ export const jwt = <O extends JwtOptions>(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",
);
}
@@ -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",
);
});
@@ -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",
},
},
);
}
@@ -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<typeof oidcProvider>,
} satisfies BetterAuthClientPlugin;
};
export type OidcClientPlugin = ReturnType<typeof oidcClient>;
export type * from "./types";
@@ -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,
});
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,<script>alert(1)</script>",
"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();
});
});
@@ -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<typeof oAuthApplicationSchema>;
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;
@@ -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<OIDCMetadata> | 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<string, any> | 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<string, any>,
scopes: string[],
client: Client,
) => Record<string, any> | Promise<Record<string, any>>)
| 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<string> } - A function that hashes the client secret.
* - { encrypt: (clientSecret: string) => Promise<string>, decrypt: (clientSecret: string) => Promise<string> } - A function that encrypts and decrypts the client secret.
*
* @default "plain"
*/
storeClientSecret?:
| (
| "hashed"
| "plain"
| "encrypted"
| { hash: (clientSecret: string) => Promise<string> }
| {
encrypt: (clientSecret: string) => Promise<string>;
decrypt: (clientSecret: string) => Promise<string>;
}
)
| 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<typeof schema> | 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<string, any> | 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;
}
@@ -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;
};
@@ -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);
});
});
@@ -1,30 +0,0 @@
import { InvalidRequest } from "../error";
export type AuthorizePrompt = "login" | "consent" | "select_account" | "none";
export type AuthorizePromptSet = ReadonlySet<AuthorizePrompt>;
/**
* 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<AuthorizePrompt>();
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;
}
-1
View File
@@ -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",
@@ -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: {
-19
View File
@@ -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
-1
View File
@@ -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"
}
-149
View File
@@ -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<ReturnType<typeof authConfig>>;
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<void>((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",
);
}
});
});
-215
View File
@@ -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<ReturnType<typeof authConfig>>;
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<void>((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<typeof auth.api.registerOAuthApplication>
>;
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=");
}
});
});