diff --git a/.cspell/auth-terms.txt b/.cspell/auth-terms.txt index 7f84870e61..d1c7fd6381 100644 --- a/.cspell/auth-terms.txt +++ b/.cspell/auth-terms.txt @@ -33,3 +33,4 @@ Vercel rgba idtoken eidas +ciba diff --git a/docs/content/docs/plugins/ciba.mdx b/docs/content/docs/plugins/ciba.mdx new file mode 100644 index 0000000000..bc048cecb6 --- /dev/null +++ b/docs/content/docs/plugins/ciba.mdx @@ -0,0 +1,667 @@ +--- +title: CIBA +description: Client Initiated Backchannel Authentication for AI agents, CLI tools, and headless services +--- + +`CIBA` `AI Agents` `CLI` `Headless` + +The CIBA plugin implements [Client Initiated Backchannel Authentication (CIBA)](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html), enabling authentication flows where an agent or service authenticates on behalf of a user who approves out-of-band — via email, SMS, push notification, or any custom channel. + +This is ideal for: +- **AI agents** that need to act on behalf of a user +- **CLI tools** that authenticate without a browser redirect +- **Headless services** that request user authorization asynchronously +- **IoT devices** with limited or no UI + +**Key Features**: + +- **Poll, ping, and push** token delivery modes +- **`authorization_details`** (Rich Authorization Requests, RFC 9396) for structured consent +- **`id_token_hint`** with full JWT signature validation (JWKS asymmetric + HS256 fallback) +- **`requested_expiry`** for client-controlled request lifetimes +- **Resource indicators** forwarded to the token pipeline +- **HTTPS enforcement** on push/ping notification endpoints (CIBA §10.3) +- **Replay protection** — requests are consumed on token issuance +- **Rate limiting** with `slow_down` and progressive interval increase + +Requires the [`@better-auth/oauth-provider`](/docs/plugins/oauth-provider) and [`jwt`](/docs/plugins/jwt) plugins. + +## How It Works + +1. **Agent requests auth**: The agent calls `/oauth2/bc-authorize` with client credentials and a `login_hint` (user's email) or `id_token_hint` +2. **User is notified**: Your `sendNotification` callback delivers an approval link to the user +3. **User approves/denies**: The user visits the approval page and approves or rejects +4. **Agent gets tokens**: The agent polls `/oauth2/token` (poll mode), receives a ping notification then polls (ping mode), or receives tokens directly at a callback URL (push mode) + +## Installation + + + + ### Install the package + + ```package-install + npm i @better-auth/ciba + ``` + + + + ### Add the plugins to your auth config + + CIBA requires the `oauthProvider` and `jwt` plugins for OAuth client management and token signing. + + ```ts title="auth.ts" + import { betterAuth } from "better-auth"; + import { jwt } from "better-auth/plugins"; + import { oauthProvider } from "@better-auth/oauth-provider"; // [!code highlight] + import { ciba } from "@better-auth/ciba"; // [!code highlight] + + export const auth = betterAuth({ + // ... other config + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/sign-in", + consentPage: "/consent", + }), + ciba({ // [!code highlight] + sendNotification: async (data, request) => { // [!code highlight] + await sendEmail({ // [!code highlight] + to: lookupEmail(data.userId), // [!code highlight] + subject: "Approve sign-in request", // [!code highlight] + body: `Click to approve: ${data.approvalUrl}`, // [!code highlight] + }); // [!code highlight] + }, // [!code highlight] + }), // [!code highlight] + ], + }); + ``` + + + + ### Migrate the database + + Run the migration or generate the schema to add the necessary 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 + + ```ts title="auth-client.ts" + import { createAuthClient } from "better-auth/client"; + import { cibaClient } from "@better-auth/ciba/client"; // [!code highlight] + + export const authClient = createAuthClient({ + plugins: [ + cibaClient(), // [!code highlight] + ], + }); + ``` + + + +## Basic Usage + +### Agent: Initiate Authentication + +The agent sends a backchannel authorize request with client credentials and a user hint: + +```ts +const response = await fetch("https://your-app.com/api/auth/oauth2/bc-authorize", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Basic ${btoa(`${clientId}:${clientSecret}`)}`, + }, + body: JSON.stringify({ + scope: "openid profile", + login_hint: "user@example.com", + binding_message: "Sign in to My CLI Tool", + }), +}); + +const { auth_req_id, expires_in, interval } = await response.json(); +``` + +The response contains: +- `auth_req_id` — unique request identifier (~190-bit entropy) +- `expires_in` — request lifetime in seconds +- `interval` — minimum polling interval in seconds (poll/ping mode only) + +### User: Approve the Request + +When the user clicks the approval link, your app shows an approval page. Use `auth_req_id` from the URL to fetch request details: + +```tsx title="app/ciba/approve/page.tsx" +export default function ApprovalPage() { + const searchParams = useSearchParams(); + const authReqId = searchParams.get("auth_req_id"); + const [request, setRequest] = useState(null); + + useEffect(() => { + authClient.ciba.verify({ + query: { auth_req_id: authReqId }, + }).then(({ data }) => setRequest(data)); + }, [authReqId]); + + const handleApprove = () => + authClient.ciba.authorize({ auth_req_id: authReqId }); + + const handleReject = () => + authClient.ciba.reject({ auth_req_id: authReqId }); + + if (!request) return

Loading...

; + + return ( +
+

Sign-in Request

+

Client: {request.client_name}

+

Message: {request.binding_message}

+

Scopes: {request.scope}

+

Expires: {new Date(request.expires_at).toLocaleString()}

+ + +
+ ); +} +``` + + + Users must be authenticated before they can approve or deny requests. Redirect unauthenticated users to the login page with a return URL back to the approval page. + + +### Agent: Poll for Tokens + +After initiating the request, the agent polls until the user responds: + +```ts +async function pollForToken(authReqId: string, interval: number) { + let pollingInterval = interval; + + while (true) { + await new Promise((r) => setTimeout(r, pollingInterval * 1000)); + + const response = await fetch("https://your-app.com/api/auth/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Basic ${btoa(`${clientId}:${clientSecret}`)}`, + }, + body: new URLSearchParams({ + grant_type: "urn:openid:params:grant-type:ciba", + auth_req_id: authReqId, + }), + }); + + const data = await response.json(); + + if (data.access_token) { + return data; // { access_token, token_type, id_token, scope, ... } + } + + switch (data.error) { + case "authorization_pending": + break; // Continue polling + case "slow_down": + pollingInterval += 5; + break; + case "access_denied": + throw new Error("User denied the request"); + case "expired_token": + throw new Error("Request expired"); + } + } +} +``` + + + The `/oauth2/token` endpoint is the existing OAuth provider token endpoint. The CIBA plugin hooks into it when the grant type is `urn:openid:params:grant-type:ciba`. The `/oauth2/` prefix comes from the OAuth provider's route naming. + + +## Authorization Details (RAR) + +For structured consent beyond free-text `binding_message`, pass `authorization_details` per [RFC 9396](https://datatracker.ietf.org/doc/html/rfc9396). This lets the user see exactly what they're approving: + +```ts +const response = await fetch("/api/auth/oauth2/bc-authorize", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Basic ${btoa(`${clientId}:${clientSecret}`)}`, + }, + body: JSON.stringify({ + scope: "openid", + login_hint: "user@example.com", + binding_message: "Purchase: Sony WH-1000XM5", + authorization_details: JSON.stringify([{ + type: "purchase", + merchant: "Aether AI", + item: "Sony WH-1000XM5", + amount: { currency: "USD", value: "378.45" }, + }]), + }), +}); +``` + +The `authorization_details` are: +- Validated as JSON at request time (malformed JSON returns `400`) +- Stored with the CIBA request +- Returned from the `/ciba/verify` endpoint (parsed) for the approval UI +- Available in the token response + +## Delivery Modes + +### Poll (default) + +The agent polls `/oauth2/token` at the interval specified in the bc-authorize response. The server enforces rate limiting — polling too fast returns `slow_down` and increases the required interval by 5 seconds. + +### Ping + +The server sends a notification to the client's endpoint when the user responds, signaling the agent to poll for tokens. This reduces unnecessary polling. + +```ts +ciba({ + deliveryModes: ["ping", "poll"], + sendNotification: async (data) => { /* ... */ }, + resolveClientNotificationEndpoint: async (clientId, ctx) => { + return "https://my-agent.example.com/ciba-callback"; + }, +}) +``` + +The agent must include `client_notification_token` in the bc-authorize request. + +### Push + +The server delivers tokens directly to the client's notification endpoint. No polling needed. + +```ts +ciba({ + deliveryModes: ["push", "poll"], + sendNotification: async (data) => { /* ... */ }, + resolveClientNotificationEndpoint: async (clientId, ctx) => { + return "https://my-agent.example.com/ciba-callback"; + }, +}) +``` + +The agent must include `client_notification_token` in the bc-authorize request. Push mode requires both a notification token and endpoint. The response omits `interval` since polling is not used. + + + Push and ping notification endpoints must use HTTPS per CIBA §10.3. Loopback addresses (`localhost`, `127.0.0.1`, `[::1]`) are exempt for local development. + + +## ID Token Hint + +Instead of `login_hint`, agents can pass a previously issued `id_token_hint` to identify the user. The plugin validates: +- JWT signature (JWKS asymmetric keys first, HS256 fallback) +- `aud` claim matches the requesting `client_id` +- `iss` claim matches the server's issuer +- Token is not expired + +```ts +const response = await fetch("/api/auth/oauth2/bc-authorize", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Basic ${btoa(`${clientId}:${clientSecret}`)}`, + }, + body: JSON.stringify({ + scope: "openid", + id_token_hint: previousIdToken, + }), +}); +``` + + + Only one hint may be provided per request. Sending both `login_hint` and `id_token_hint` (or `login_hint_token`) returns `400 invalid_request`. + + +## Notification Callback + +The `sendNotification` callback is how you deliver the approval link to the user. You decide the channel: + +```ts +ciba({ + sendNotification: async (data, request) => { + // data.userId — user ID to notify + // data.authReqId — unique request identifier + // data.approvalUrl — URL the user visits to approve/deny + // data.clientName — name of the requesting OAuth client + // data.scope — requested scopes + // data.bindingMessage — optional context message + // data.authorizationDetails — parsed RAR object (if provided) + + await sendEmail({ + to: await lookupEmail(data.userId), + subject: data.bindingMessage || "Sign-in request", + body: ` + An application "${data.clientName}" is requesting access. + + ${data.bindingMessage ? `Message: ${data.bindingMessage}` : ""} + Scopes: ${data.scope} + + Approve: ${data.approvalUrl} + `, + }); + }, +}) +``` + +The callback is fire-and-forget — failures are silently ignored so they don't block the bc-authorize response. + +## User Resolution + +By default, the plugin resolves users from `login_hint` by email lookup via the internal adapter. You can customize this: + +```ts +ciba({ + sendNotification: async (data) => { /* ... */ }, + resolveUser: async (loginHint, ctx) => { + // Custom lookup — by phone, username, external ID, etc. + const user = await db.users.findOne({ phone: loginHint }); + return user; // Must return { id: string } or null + }, +}) +``` + +## Endpoints + +### Backchannel Authorize + +Initiate a CIBA backchannel authentication request. Accepts `application/json` or `application/x-www-form-urlencoded`. Client credentials can be provided via HTTP Basic `Authorization` header instead of body parameters. + + +```ts +type bcAuthorize = { + /** + * Client identifier (or via HTTP Basic) + */ + client_id?: string; + /** + * Client secret (or via HTTP Basic) + */ + client_secret?: string; + /** + * Space-separated scopes (must include "openid") + */ + scope: string; + /** + * User email or identifier (exactly one hint required) + */ + login_hint?: string; + /** + * Previously issued ID token (exactly one hint required) + */ + id_token_hint?: string; + /** + * Token containing user hint (exactly one hint required) + */ + login_hint_token?: string; + /** + * Message shown in approval UI (max 256 chars) + */ + binding_message?: string; + /** + * Bearer token for push/ping callbacks + */ + client_notification_token?: string; + /** + * Override notification endpoint URL + */ + client_notification_uri?: string; + /** + * JSON-encoded RAR array (RFC 9396) + */ + authorization_details?: string; + /** + * Resource indicator for audience restriction + */ + resource?: string; + /** + * Requested lifetime in seconds (capped by server config) + */ + requested_expiry?: number; +} +``` + + +### Verify Request + +Get CIBA request details for the approval UI. Unauthenticated — the `auth_req_id` acts as the bearer secret. + + +```ts +type cibaVerify = { + /** + * The request identifier + */ + auth_req_id: string; +} +``` + + +Returns: `auth_req_id`, `client_name`, `scope`, `binding_message`, `authorization_details`, `status`, `expires_at`. + +### Approve Request + + +```ts +type cibaAuthorize = { + /** + * The request identifier + */ + auth_req_id: string; +} +``` + + +### Reject Request + + +```ts +type cibaReject = { + /** + * The request identifier + */ + auth_req_id: string; +} +``` + + +## Error Handling + +The CIBA flow defines specific error codes: + +| Error Code | Description | +|------------|-------------| +| `authorization_pending` | User hasn't responded yet (continue polling) | +| `slow_down` | Polling too frequently (increase interval by 5s) | +| `expired_token` | The auth request has expired | +| `access_denied` | User denied the request | +| `invalid_grant` | Invalid, consumed, or push-mode `auth_req_id` | +| `invalid_scope` | Scope missing `openid` | +| `invalid_request` | Malformed request, multiple hints, or missing fields | +| `unknown_user_id` | Could not find user from hint | +| `invalid_client` | Invalid client credentials | + +## Options + +### Server + +**sendNotification** (required): Callback to notify the user about the pending request. Receives `(data, request?)` where `data` contains `userId`, `authReqId`, `approvalUrl`, `clientName`, `scope`, `bindingMessage`, and `authorizationDetails`. Returns `Promise`. + +**deliveryModes**: Supported token delivery modes. Default: `["poll"]`. Can include `"poll"`, `"ping"`, and `"push"`. + +**requestLifetime**: Auth request lifetime in seconds. Default: `300` (5 minutes). + +**pollingInterval**: Minimum polling interval in seconds. Default: `5`. + +**approvalPage**: URL path for the approval page. Default: `"/ciba/approve"`. + +**resolveUser**: Custom user resolution from `login_hint`. Takes `(hint, ctx)` and returns `Promise<{ id: string } | null>`. Default: email lookup via the internal adapter. + +**resolveClientNotificationEndpoint**: Resolve the notification endpoint for push/ping delivery. Takes `(clientId, ctx)` and returns `Promise`. + +**pushRetryAttempts**: Push delivery retry attempts with exponential backoff. Default: `3`. + +### Client + +No client-specific configuration options. The plugin adds the following methods: + +- **ciba.verify()**: Get request details for the approval UI +- **ciba.authorize()**: Approve a pending request (requires authentication) +- **ciba.reject()**: Reject a pending request (requires authentication) + +## Schema + +The plugin requires a new table to store CIBA request data. + +Table Name: `cibaRequest` + + + +## Discovery Metadata + +The plugin enriches the OpenID Connect discovery document (`/.well-known/openid-configuration`) with: + +```json +{ + "backchannel_authentication_endpoint": "https://your-app.com/api/auth/oauth2/bc-authorize", + "backchannel_token_delivery_modes_supported": ["poll"], + "backchannel_user_code_parameter_supported": false, + "grant_types_supported": ["...", "urn:openid:params:grant-type:ciba"] +} +``` + +## Security Considerations + +1. **TLS Required**: Push/ping notification endpoints must use HTTPS (loopback exempt for development) +2. **Client Authentication**: Agents authenticate via client credentials — HTTP Basic or body parameters. Client secrets are validated using the oauth-provider's configured storage method (hashed, encrypted, or plain) +3. **Unguessable Request IDs**: `auth_req_id` is 32 cryptographically random alphanumeric characters (~190 bits of entropy) +4. **Session Required**: Only authenticated users can approve or reject requests, and the session user must match the target user +5. **ID Token Validation**: `id_token_hint` is verified with full JWT signature checking — not trusted at face value +6. **Request Expiry**: Requests automatically expire (default 5 minutes). Clients can request shorter lifetimes via `requested_expiry` but not longer +7. **Polling Rate Limiting**: `slow_down` responses increase the required interval by 5 seconds per violation +8. **Replay Protection**: Approved requests are deleted after token issuance. A second poll returns `invalid_grant` +9. **Client Re-verification**: For push delivery, the client is re-verified as non-disabled before tokens are generated diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index e392d18307..ae7f256283 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -50,6 +50,7 @@ Better Auth ships with 50+ plugins that extend the framework with additional aut | [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 | +| [CIBA](/docs/plugins/ciba) | Backchannel authentication (CIBA) for AI agents, CLI tools, and headless services | ## Payments & Billing diff --git a/packages/ciba/package.json b/packages/ciba/package.json new file mode 100644 index 0000000000..3cb323d858 --- /dev/null +++ b/packages/ciba/package.json @@ -0,0 +1,82 @@ +{ + "name": "@better-auth/ciba", + "version": "1.5.1-beta.3", + "description": "Client Initiated Backchannel Authentication (CIBA) plugin for Better Auth", + "type": "module", + "license": "MIT", + "homepage": "https://www.better-auth.com/docs/plugins/ciba", + "repository": { + "type": "git", + "url": "git+https://github.com/better-auth/better-auth.git", + "directory": "packages/ciba" + }, + "author": "Gustavo Valverde", + "keywords": [ + "auth", + "ciba", + "backchannel", + "oauth", + "oidc", + "ai-agents", + "typescript", + "better-auth" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "lint:package": "publint run --strict", + "lint:types": "attw --profile esm-only --pack .", + "typecheck": "tsc --project tsconfig.json", + "test": "vitest", + "coverage": "vitest run --coverage --coverage.provider=istanbul" + }, + "files": [ + "dist" + ], + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "dev-source": "./src/index.ts", + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./client": { + "dev-source": "./src/client.ts", + "types": "./dist/client.d.mts", + "default": "./dist/client.mjs" + } + }, + "typesVersions": { + "*": { + "*": [ + "./dist/index.d.mts" + ], + "client": [ + "./dist/client.d.mts" + ] + } + }, + "dependencies": { + "jose": "^6.1.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@better-auth/core": "workspace:*", + "@better-auth/oauth-provider": "workspace:*", + "better-auth": "workspace:*", + "tsdown": "catalog:" + }, + "peerDependencies": { + "@better-auth/core": "workspace:*", + "@better-auth/oauth-provider": "workspace:*", + "@better-auth/utils": "catalog:", + "@better-fetch/fetch": "catalog:", + "better-auth": "workspace:*", + "better-call": "catalog:" + } +} diff --git a/packages/ciba/src/approval.ts b/packages/ciba/src/approval.ts new file mode 100644 index 0000000000..8beb99c50d --- /dev/null +++ b/packages/ciba/src/approval.ts @@ -0,0 +1,294 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import { createUserTokens, getClient } from "@better-auth/oauth-provider"; +import { + APIError, + createAuthEndpoint, + sessionMiddleware, +} from "better-auth/api"; +import * as z from "zod"; +import { deliverError, deliverPing, deliverPush } from "./push"; +import type { CibaOptions } from "./types"; +import { + deleteCibaRequest, + findCibaRequest, + getOAuthOpts, + updateCibaRequest, +} from "./utils"; + +/** + * POST /ciba/authorize — Approve a pending CIBA request. + * Requires an authenticated session; the user must match cibaRequest.userId. + */ +export function createCibaAuthorize(options: CibaOptions) { + return createAuthEndpoint( + "/ciba/authorize", + { + method: "POST", + use: [sessionMiddleware], + body: z.object({ + auth_req_id: z.string().min(1), + }), + metadata: { + openapi: { + description: "Approve a CIBA authentication request", + }, + }, + }, + async (ctx) => { + const session = ctx.context.session; + const { auth_req_id: authReqId } = ctx.body; + + const cibaRequest = await findCibaRequest(ctx, authReqId); + if (!cibaRequest) { + throw new APIError("NOT_FOUND", { + error_description: "CIBA request not found", + error: "invalid_request", + }); + } + + if (cibaRequest.userId !== session.user.id) { + throw new APIError("FORBIDDEN", { + error_description: "You are not authorized to approve this request", + error: "access_denied", + }); + } + + if (cibaRequest.status !== "pending") { + throw new APIError("BAD_REQUEST", { + error_description: `Request is already ${cibaRequest.status}`, + error: "invalid_request", + }); + } + + if (cibaRequest.expiresAt < new Date()) { + await deleteCibaRequest(ctx, authReqId); + throw new APIError("BAD_REQUEST", { + error_description: "The auth_req_id has expired", + error: "expired_token", + }); + } + + await updateCibaRequest(ctx, cibaRequest.id, { + status: "approved", + }); + + // Defensive re-read: if a concurrent approval already processed + // this request, the record may be gone or no longer "approved". + const updated = await findCibaRequest(ctx, authReqId); + if (!updated || updated.status !== "approved") { + throw new APIError("BAD_REQUEST", { + error_description: `Request is already ${updated?.status ?? "consumed"}`, + error: "invalid_request", + }); + } + + const oauthOpts = getOAuthOpts(ctx); + + if (cibaRequest.deliveryMode === "push") { + if ( + !cibaRequest.clientNotificationEndpoint || + !cibaRequest.clientNotificationToken + ) { + throw new APIError("INTERNAL_SERVER_ERROR", { + error_description: + "Push-mode request missing notification endpoint or token", + error: "server_error", + }); + } + + const client = await getClient( + ctx as unknown as GenericEndpointContext, + oauthOpts, + cibaRequest.clientId, + ); + if (!client) { + throw new APIError("INTERNAL_SERVER_ERROR", { + error_description: "OAuth client not found", + error: "server_error", + }); + } + if (client.disabled) { + await deleteCibaRequest(ctx, authReqId); + throw new APIError("BAD_REQUEST", { + error_description: + "OAuth client has been disabled since the request was created", + error: "invalid_request", + }); + } + + const scopes = cibaRequest.scope.split(" "); + const tokenCtx = ctx as unknown as GenericEndpointContext; + if (cibaRequest.resource) { + (tokenCtx.body as Record).resource = + cibaRequest.resource; + } + + // Build extra claims: act (agent identity) + authorization_details (RAR) + const pushExtra: { + accessTokenClaims: Record; + tokenResponse?: Record; + } = { accessTokenClaims: { act: { sub: client.clientId } } }; + + if (cibaRequest.authorizationDetails) { + try { + const ad = JSON.parse(cibaRequest.authorizationDetails); + pushExtra.accessTokenClaims.authorization_details = ad; + pushExtra.tokenResponse = { authorization_details: ad }; + } catch { + // Ignore malformed JSON — issue tokens without authorization_details + } + } + + if (cibaRequest.agentClaims) { + try { + const ac = JSON.parse(cibaRequest.agentClaims); + if (ac.agent) { + pushExtra.accessTokenClaims.agent = ac.agent; + } + } catch { + // Ignore malformed agent claims + } + } + + // Generate tokens, then delete request to prevent replay via polling + const tokenResult = await createUserTokens( + tokenCtx, + oauthOpts, + client, + scopes, + session.user, + authReqId, + undefined, + undefined, + undefined, + new Date(), + pushExtra, + ); + + // Consume the request before push — prevents double-mint + await deleteCibaRequest(ctx, authReqId); + + // Extract the raw JSON body from createUserTokens result. + // Handles both internal calls (_flag wrapper) and Response objects. + let tokenBody: Record; + if ( + typeof Response !== "undefined" && + tokenResult instanceof Response + ) { + tokenBody = (await tokenResult.clone().json()) as Record< + string, + unknown + >; + } else if ( + typeof tokenResult === "object" && + tokenResult !== null && + "_flag" in tokenResult + ) { + tokenBody = ( + tokenResult as unknown as { + body: Record; + } + ).body; + } else { + tokenBody = tokenResult as Record; + } + + deliverPush( + cibaRequest.clientNotificationEndpoint, + cibaRequest.clientNotificationToken, + { auth_req_id: authReqId, ...tokenBody }, + options.pushRetryAttempts ?? 3, + ).catch(() => { + // Push delivery failed — tokens are minted, request consumed. + // Client must use refresh_token or re-initiate. + }); + } else if (cibaRequest.deliveryMode === "ping") { + if ( + cibaRequest.clientNotificationEndpoint && + cibaRequest.clientNotificationToken + ) { + deliverPing( + cibaRequest.clientNotificationEndpoint, + cibaRequest.clientNotificationToken, + authReqId, + ).catch(() => { + // Ping delivery failure: client can still poll + }); + } + } + + return ctx.json({ success: true }); + }, + ); +} + +/** + * POST /ciba/reject — Reject a pending CIBA request. + * Requires an authenticated session; the user must match cibaRequest.userId. + */ +export function createCibaReject() { + return createAuthEndpoint( + "/ciba/reject", + { + method: "POST", + use: [sessionMiddleware], + body: z.object({ + auth_req_id: z.string().min(1), + }), + metadata: { + openapi: { + description: "Reject a CIBA authentication request", + }, + }, + }, + async (ctx) => { + const session = ctx.context.session; + const { auth_req_id: authReqId } = ctx.body; + + const cibaRequest = await findCibaRequest(ctx, authReqId); + if (!cibaRequest) { + throw new APIError("NOT_FOUND", { + error_description: "CIBA request not found", + error: "invalid_request", + }); + } + + if (cibaRequest.userId !== session.user.id) { + throw new APIError("FORBIDDEN", { + error_description: "You are not authorized to reject this request", + error: "access_denied", + }); + } + + if (cibaRequest.status !== "pending") { + throw new APIError("BAD_REQUEST", { + error_description: `Request is already ${cibaRequest.status}`, + error: "invalid_request", + }); + } + + await updateCibaRequest(ctx, cibaRequest.id, { + status: "rejected", + }); + + // Push mode: deliver error to notification endpoint + if ( + cibaRequest.deliveryMode === "push" && + cibaRequest.clientNotificationEndpoint && + cibaRequest.clientNotificationToken + ) { + deliverError( + cibaRequest.clientNotificationEndpoint, + cibaRequest.clientNotificationToken, + authReqId, + "access_denied", + "The user denied the authorization request", + ).catch(() => { + // Best-effort delivery + }); + } + + return ctx.json({ success: true }); + }, + ); +} diff --git a/packages/ciba/src/bc-authorize.ts b/packages/ciba/src/bc-authorize.ts new file mode 100644 index 0000000000..bfe25bd649 --- /dev/null +++ b/packages/ciba/src/bc-authorize.ts @@ -0,0 +1,379 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import { + basicToClientCredentials, + validateClientCredentials, +} from "@better-auth/oauth-provider"; +import { APIError, createAuthEndpoint } from "better-auth/api"; +import { generateRandomString } from "better-auth/crypto"; +import { importJWK, jwtVerify } from "jose"; +import * as z from "zod"; +import type { CibaOptions } from "./types"; +import { getOAuthOpts, isSecureEndpoint } from "./utils"; + +export function createBcAuthorize(options: CibaOptions) { + const deliveryModes = options.deliveryModes ?? ["poll"]; + const requestLifetime = options.requestLifetime ?? 300; + const pollingInterval = options.pollingInterval ?? 5; + const approvalPage = options.approvalPage ?? "/ciba/approve"; + + return createAuthEndpoint( + "/oauth2/bc-authorize", + { + method: "POST", + body: z.object({ + client_id: z.string().optional(), + client_secret: z.string().optional(), + scope: z.string().min(1), + login_hint: z.string().optional(), + id_token_hint: z.string().optional(), + login_hint_token: z.string().optional(), + binding_message: z.string().max(256).optional(), + client_notification_token: z.string().optional(), + client_notification_uri: z.string().url().optional(), + authorization_details: z.string().optional(), + resource: z.string().optional(), + agent_claims: z.string().optional(), + requested_expiry: z.coerce.number().int().positive().optional(), + }), + metadata: { + allowedMediaTypes: [ + "application/x-www-form-urlencoded", + "application/json", + ], + openapi: { + description: "Initiate a CIBA backchannel authentication request", + responses: { + 200: { + description: "Authentication request accepted", + content: { + "application/json": { + schema: { + type: "object", + properties: { + auth_req_id: { type: "string" }, + expires_in: { type: "number" }, + interval: { type: "number" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + async (ctx) => { + const oauthOpts = getOAuthOpts(ctx); + + let clientId = ctx.body.client_id; + let clientSecret = ctx.body.client_secret; + + const authorization = ctx.request?.headers.get("authorization") || null; + if (authorization?.startsWith("Basic ")) { + const res = basicToClientCredentials(authorization); + clientId = res?.client_id; + clientSecret = res?.client_secret; + } + + if (!clientId) { + throw new APIError("BAD_REQUEST", { + error_description: "client_id is required", + error: "invalid_request", + }); + } + + const scopes = ctx.body.scope.split(" "); + if (!scopes.includes("openid")) { + throw new APIError("BAD_REQUEST", { + error_description: 'scope must include "openid" for CIBA requests', + error: "invalid_scope", + }); + } + + const client = await validateClientCredentials( + ctx as unknown as GenericEndpointContext, + oauthOpts, + clientId, + clientSecret, + scopes, + ); + + // Validate exactly one hint (CIBA §7.1) + const hints = [ + ctx.body.login_hint, + ctx.body.id_token_hint, + ctx.body.login_hint_token, + ].filter(Boolean); + if (hints.length !== 1) { + throw new APIError("BAD_REQUEST", { + error_description: + "Exactly one of login_hint, id_token_hint, or login_hint_token is required", + error: "invalid_request", + }); + } + + let user: { id: string } | null = null; + + if (ctx.body.id_token_hint) { + user = await resolveUserFromIdTokenHint( + ctx, + ctx.body.id_token_hint, + clientId, + ); + } else { + const loginHint = ctx.body.login_hint || ctx.body.login_hint_token!; + + if (options.resolveUser) { + user = await options.resolveUser( + loginHint, + ctx as unknown as GenericEndpointContext, + ); + } else { + const found = + await ctx.context.internalAdapter.findUserByEmail(loginHint); + user = found?.user ?? null; + } + } + + if (!user) { + throw new APIError("BAD_REQUEST", { + error_description: + "The user identified by the hint could not be found", + error: "unknown_user_id", + }); + } + + const notificationEndpoint = + ctx.body.client_notification_uri ?? + (options.resolveClientNotificationEndpoint + ? await options.resolveClientNotificationEndpoint( + clientId, + ctx as unknown as GenericEndpointContext, + ) + : undefined); + + let mode: "poll" | "ping" | "push" = "poll"; + if (ctx.body.client_notification_token && notificationEndpoint) { + if (deliveryModes.includes("push")) mode = "push"; + else if (deliveryModes.includes("ping")) mode = "ping"; + } + + if (mode === "push" || mode === "ping") { + if (!ctx.body.client_notification_token) { + throw new APIError("BAD_REQUEST", { + error_description: + "client_notification_token is required for push/ping delivery modes", + error: "invalid_request", + }); + } + if (!notificationEndpoint) { + throw new APIError("BAD_REQUEST", { + error_description: + "A client notification endpoint is required for push/ping delivery (provide client_notification_uri or configure resolveClientNotificationEndpoint)", + error: "invalid_request", + }); + } + // CIBA §10.3: notification endpoint MUST use TLS + if (!isSecureEndpoint(notificationEndpoint)) { + throw new APIError("BAD_REQUEST", { + error_description: + "client_notification_uri must use HTTPS (CIBA §10.3)", + error: "invalid_request", + }); + } + } + + let parsedAuthorizationDetails: unknown; + if (ctx.body.authorization_details) { + try { + parsedAuthorizationDetails = JSON.parse( + ctx.body.authorization_details, + ); + } catch { + throw new APIError("BAD_REQUEST", { + error_description: "authorization_details must be valid JSON", + error: "invalid_request", + }); + } + } + + // Generate auth_req_id (~190-bit entropy) + const authReqId = generateRandomString(32, "A-Z", "a-z", "0-9"); + + const expiresIn = ctx.body.requested_expiry + ? Math.min(ctx.body.requested_expiry, requestLifetime) + : requestLifetime; + + await ctx.context.adapter.create({ + model: "cibaRequest", + data: { + authReqId, + clientId, + userId: user.id, + scope: ctx.body.scope, + bindingMessage: ctx.body.binding_message, + authorizationDetails: ctx.body.authorization_details, + resource: ctx.body.resource, + agentClaims: ctx.body.agent_claims, + status: "pending", + deliveryMode: mode, + clientNotificationToken: ctx.body.client_notification_token, + clientNotificationEndpoint: notificationEndpoint, + pollingInterval, + expiresAt: new Date(Date.now() + expiresIn * 1000), + createdAt: new Date(), + }, + }); + + const baseURL = ctx.context.baseURL; + const approvalUrl = `${baseURL}${approvalPage}?auth_req_id=${encodeURIComponent(authReqId)}`; + + const attestationJwt = + ctx.request?.headers.get("OAuth-Client-Attestation") || undefined; + const attestationPopJwt = + ctx.request?.headers.get("OAuth-Client-Attestation-PoP") || undefined; + + options + .sendNotification( + { + userId: user.id, + authReqId, + clientName: client.name, + scope: ctx.body.scope, + bindingMessage: ctx.body.binding_message, + authorizationDetails: parsedAuthorizationDetails, + agentClaims: ctx.body.agent_claims, + approvalUrl, + attestationJwt, + attestationPopJwt, + }, + ctx.request, + ) + .catch(() => { + // Notification delivery is best-effort + }); + + // Return response (interval omitted for push mode per spec) + const response: Record = { + auth_req_id: authReqId, + expires_in: expiresIn, + }; + if (mode !== "push") { + response.interval = pollingInterval; + } + + return ctx.json(response, { + headers: { + "Cache-Control": "no-store", + Pragma: "no-cache", + }, + }); + }, + ); +} + +/** + * Validate id_token_hint JWT and extract the user. + * + * Tries asymmetric verification via JWKS (jwt plugin) first, + * falls back to HS256 symmetric verification using the server secret. + * Validates that `aud` matches the requesting client_id (CIBA §7.1). + */ +async function resolveUserFromIdTokenHint( + ctx: { + context: { + secret: string; + baseURL: string; + adapter: any; + internalAdapter: any; + getPlugin: (id: string) => any; + }; + }, + idTokenHint: string, + clientId: string, +): Promise<{ id: string } | null> { + let sub: string | undefined; + + const jwtPlugin = ctx.context.getPlugin("jwt"); + const jwtOpts = jwtPlugin?.options; + const expectedIssuer = jwtOpts?.jwt?.issuer ?? ctx.context.baseURL; + + // Try asymmetric verification via JWKS (jwt plugin) + if (jwtOpts) { + try { + const header = decodeJwtHeader(idTokenHint); + if (header?.kid) { + let keys: Array<{ + id: string; + publicKey: string; + alg?: string; + }>; + + if (jwtOpts.adapter?.getJwks) { + keys = await jwtOpts.adapter.getJwks(ctx); + } else { + keys = await ctx.context.adapter.findMany({ + model: "jwks", + }); + } + + const key = keys?.find((k: { id: string }) => k.id === header.kid); + if (key?.publicKey) { + const publicKey = JSON.parse(key.publicKey); + const alg = key.alg ?? jwtOpts.jwks?.keyPairConfig?.alg ?? "EdDSA"; + const cryptoKey = await importJWK(publicKey, alg); + const { payload } = await jwtVerify(idTokenHint, cryptoKey, { + issuer: expectedIssuer, + audience: clientId, + }); + sub = payload.sub as string | undefined; + } + } + } catch { + // Asymmetric verification failed — fall through to HS256 + } + } + + // Fall back to HS256 symmetric verification + if (!sub) { + try { + const { payload } = await jwtVerify( + idTokenHint, + new TextEncoder().encode(ctx.context.secret), + { + issuer: expectedIssuer, + audience: clientId, + }, + ); + sub = payload.sub as string | undefined; + } catch { + throw new APIError("BAD_REQUEST", { + error_description: + "id_token_hint is invalid, expired, or audience does not match client_id", + error: "invalid_request", + }); + } + } + + if (!sub) { + throw new APIError("BAD_REQUEST", { + error_description: "id_token_hint does not contain a valid subject", + error: "invalid_request", + }); + } + + return ctx.context.internalAdapter.findUserById(sub); +} + +/** Decode the JOSE header from a compact JWT without verifying. */ +function decodeJwtHeader(token: string): { alg: string; kid?: string } | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const padded = parts[0]! + "=".repeat((4 - (parts[0]!.length % 4)) % 4); + const json = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(json); + } catch { + return null; + } +} diff --git a/packages/ciba/src/ciba.test.ts b/packages/ciba/src/ciba.test.ts new file mode 100644 index 0000000000..7b5233e51d --- /dev/null +++ b/packages/ciba/src/ciba.test.ts @@ -0,0 +1,656 @@ +import type { OAuthClient } from "@better-auth/oauth-provider"; +import { oauthProvider } from "@better-auth/oauth-provider"; +import { jwt } from "better-auth/plugins/jwt"; +import { getTestInstance } from "better-auth/test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { ciba } from "./index"; +import { isSecureEndpoint } from "./utils"; + +describe("isSecureEndpoint", () => { + it("should accept HTTPS URLs", () => { + expect(isSecureEndpoint("https://example.com/callback")).toBe(true); + }); + it("should reject plain HTTP URLs", () => { + expect(isSecureEndpoint("http://example.com/callback")).toBe(false); + }); + it("should allow localhost HTTP (dev exemption)", () => { + expect(isSecureEndpoint("http://localhost:4000/callback")).toBe(true); + }); + it("should allow 127.0.0.1 HTTP", () => { + expect(isSecureEndpoint("http://127.0.0.1:4000/callback")).toBe(true); + }); + it("should allow [::1] HTTP", () => { + expect(isSecureEndpoint("http://[::1]:4000/callback")).toBe(true); + }); + it("should reject invalid URLs", () => { + expect(isSecureEndpoint("not-a-url")).toBe(false); + }); +}); + +describe("ciba", async () => { + const baseURL = "http://localhost:3000"; + const notifications: Array<{ + userId: string; + authReqId: string; + clientName?: string; + scope: string; + bindingMessage?: string; + approvalUrl: string; + }> = []; + + const { auth, signInWithTestUser, customFetchImpl, testUser } = + await getTestInstance({ + baseURL, + plugins: [ + jwt({ jwt: { issuer: baseURL } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ciba({ + sendNotification: async (data) => { + notifications.push(data); + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + let oauthClient: OAuthClient; + + beforeAll(async () => { + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: ["http://localhost:5000/callback"], + skip_consent: true, + }, + }); + expect(response?.client_id).toBeDefined(); + expect(response?.client_secret).toBeDefined(); + oauthClient = response!; + }); + + describe("bc-authorize", () => { + it("should accept a valid CIBA request", async () => { + const res = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid profile", + login_hint: testUser.email, + }, + }); + expect(res.auth_req_id).toBeDefined(); + expect(res.expires_in).toBeDefined(); + expect(res.interval).toBe(5); + expect(notifications.length).toBeGreaterThan(0); + }); + + it("should reject request without openid scope", async () => { + await expect( + auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "profile", + login_hint: testUser.email, + }, + }), + ).rejects.toThrow(); + }); + + it("should reject request with no hints", async () => { + await expect( + auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + } as any, + }), + ).rejects.toThrow(); + }); + + it("should reject request with multiple hints", async () => { + await expect( + auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + id_token_hint: "some-token", + }, + }), + ).rejects.toThrow(); + }); + + it("should reject request for unknown user", async () => { + await expect( + auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: "nonexistent@example.com", + }, + }), + ).rejects.toThrow(); + }); + }); + + describe("token polling", () => { + function pollToken(params: Record) { + return customFetchImpl(`${baseURL}/api/auth/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params), + }); + } + + function cibaTokenParams(authReqId: string) { + return { + grant_type: "urn:openid:params:grant-type:ciba", + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + auth_req_id: authReqId, + }; + } + + it("should return authorization_pending for pending request", async () => { + const bcRes = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + }, + }); + + const tokenRes = await pollToken( + cibaTokenParams(bcRes.auth_req_id as string), + ); + + expect(tokenRes.status).toBe(400); + const body = await tokenRes.json(); + expect(body.error).toBe("authorization_pending"); + }); + + it("should issue tokens after approval", async () => { + const bcRes = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid profile", + login_hint: testUser.email, + }, + }); + + await auth.api.cibaAuthorize({ + headers, + body: { auth_req_id: bcRes.auth_req_id as string }, + }); + + const tokenRes = await pollToken( + cibaTokenParams(bcRes.auth_req_id as string), + ); + + expect(tokenRes.status).toBe(200); + const tokenBody = await tokenRes.json(); + expect(tokenBody.access_token).toBeDefined(); + expect(tokenBody.token_type).toBe("Bearer"); + expect(tokenBody.scope).toContain("openid"); + }); + + it("should reject replay after token issuance", async () => { + const bcRes = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + }, + }); + + await auth.api.cibaAuthorize({ + headers, + body: { auth_req_id: bcRes.auth_req_id as string }, + }); + + // First poll: success + await pollToken(cibaTokenParams(bcRes.auth_req_id as string)); + + // Second poll: should fail (request consumed) + const replayRes = await pollToken( + cibaTokenParams(bcRes.auth_req_id as string), + ); + + expect(replayRes.status).toBe(400); + const body = await replayRes.json(); + expect(body.error).toBe("invalid_grant"); + }); + + it("should return access_denied after rejection", async () => { + const bcRes = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + }, + }); + + await auth.api.cibaReject({ + headers, + body: { auth_req_id: bcRes.auth_req_id as string }, + }); + + const tokenRes = await pollToken( + cibaTokenParams(bcRes.auth_req_id as string), + ); + + expect(tokenRes.status).toBe(400); + const body = await tokenRes.json(); + expect(body.error).toBe("access_denied"); + }); + }); + + describe("approval flow", () => { + it("should allow the request owner to approve", async () => { + const bcRes = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + }, + }); + + // The signed-in user IS the test user, so this should succeed + const result = await auth.api.cibaAuthorize({ + headers, + body: { auth_req_id: bcRes.auth_req_id as string }, + }); + expect(result.success).toBe(true); + }); + + it("should prevent double-approve", async () => { + const bcRes = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + }, + }); + + // First approve + await auth.api.cibaAuthorize({ + headers, + body: { auth_req_id: bcRes.auth_req_id as string }, + }); + + // Second approve should fail + await expect( + auth.api.cibaAuthorize({ + headers, + body: { auth_req_id: bcRes.auth_req_id as string }, + }), + ).rejects.toThrow(); + }); + }); + + describe("verify endpoint", () => { + it("should return request details", async () => { + const bcRes = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid profile", + login_hint: testUser.email, + binding_message: "Approve login to TestApp", + }, + }); + + const verifyRes = await auth.api.cibaVerify({ + query: { auth_req_id: bcRes.auth_req_id as string }, + }); + + expect(verifyRes.auth_req_id).toBe(bcRes.auth_req_id); + expect(verifyRes.scope).toBe("openid profile"); + expect(verifyRes.binding_message).toBe("Approve login to TestApp"); + expect(verifyRes.status).toBe("pending"); + expect(verifyRes.expires_at).toBeDefined(); + }); + + it("should return 404 for unknown auth_req_id", async () => { + await expect( + auth.api.cibaVerify({ + query: { auth_req_id: "nonexistent" }, + }), + ).rejects.toThrow(); + }); + }); + + describe("malformed authorization_details", () => { + it("should reject invalid JSON in authorization_details", async () => { + await expect( + auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + authorization_details: "not valid json {{{", + }, + }), + ).rejects.toThrow(); + }); + + it("should accept valid JSON in authorization_details", async () => { + const res = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + authorization_details: JSON.stringify([ + { type: "payment", amount: 100 }, + ]), + }, + }); + expect(res.auth_req_id).toBeDefined(); + }); + }); + + describe("push mode polling blocked", () => { + it("should reject token polling for push-mode requests", async () => { + // Create a separate instance with push delivery enabled + const pushNotifications: unknown[] = []; + const { + auth: pushAuth, + signInWithTestUser: pushSignIn, + customFetchImpl: pushFetch, + } = await getTestInstance({ + baseURL, + plugins: [ + jwt({ jwt: { issuer: baseURL } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ciba({ + deliveryModes: ["push", "poll"], + sendNotification: async (data) => { + pushNotifications.push(data); + }, + resolveClientNotificationEndpoint: async () => + "https://client.example.com/ciba-callback", + }), + ], + }); + + const { headers: pushHeaders } = await pushSignIn(); + const pushClient = await pushAuth.api.adminCreateOAuthClient({ + headers: pushHeaders, + body: { + redirect_uris: ["http://localhost:5000/callback"], + skip_consent: true, + }, + }); + + // Create a push-mode CIBA request (token + endpoint → push) + const bcRes = await pushAuth.api.bcAuthorize({ + body: { + client_id: pushClient!.client_id, + client_secret: pushClient!.client_secret!, + scope: "openid", + login_hint: "test@test.com", + client_notification_token: "push-bearer-token", + }, + }); + expect(bcRes.auth_req_id).toBeDefined(); + // Push mode: no interval in response + expect(bcRes.interval).toBeUndefined(); + + // Attempt to poll — should be rejected + const tokenRes = await pushFetch(`${baseURL}/api/auth/oauth2/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:openid:params:grant-type:ciba", + client_id: pushClient!.client_id, + client_secret: pushClient!.client_secret!, + auth_req_id: bcRes.auth_req_id as string, + }), + }); + + expect(tokenRes.status).toBe(400); + const body = await tokenRes.json(); + expect(body.error).toBe("invalid_grant"); + }); + }); + + describe("id_token_hint validation", () => { + it("should reject an invalid id_token_hint", async () => { + await expect( + auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + id_token_hint: "not-a-valid-jwt", + }, + }), + ).rejects.toThrow(); + }); + + it("should reject a well-formed JWT with wrong signature", async () => { + // Craft a JWT signed with the wrong key + const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + const payload = btoa( + JSON.stringify({ + sub: "fake-user-id", + aud: oauthClient.client_id, + iss: baseURL, + exp: Math.floor(Date.now() / 1000) + 3600, + }), + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + const fakeJwt = `${header}.${payload}.invalid-signature`; + + await expect( + auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + id_token_hint: fakeJwt, + }, + }), + ).rejects.toThrow(); + }); + + it("should accept a valid id_token from a previous flow", async () => { + // Complete a full CIBA flow to get a valid id_token + const bcRes = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + login_hint: testUser.email, + }, + }); + + await auth.api.cibaAuthorize({ + headers, + body: { auth_req_id: bcRes.auth_req_id as string }, + }); + + const tokenRes = await customFetchImpl( + `${baseURL}/api/auth/oauth2/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:openid:params:grant-type:ciba", + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + auth_req_id: bcRes.auth_req_id as string, + }), + }, + ); + + expect(tokenRes.status).toBe(200); + const tokenBody = await tokenRes.json(); + expect(tokenBody.id_token).toBeDefined(); + + // Now use that id_token as a hint in a new CIBA request + const bcRes2 = await auth.api.bcAuthorize({ + body: { + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret!, + scope: "openid", + id_token_hint: tokenBody.id_token, + }, + }); + + expect(bcRes2.auth_req_id).toBeDefined(); + expect(bcRes2.expires_in).toBeDefined(); + }); + }); + + describe("HTTPS enforcement", () => { + it("should reject HTTP push notification endpoints", async () => { + const pushNotifications: unknown[] = []; + const { auth: httpAuth, signInWithTestUser: httpSignIn } = + await getTestInstance({ + baseURL, + plugins: [ + jwt({ jwt: { issuer: baseURL } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ciba({ + deliveryModes: ["push", "poll"], + sendNotification: async (data) => { + pushNotifications.push(data); + }, + // HTTP endpoint (not HTTPS, not localhost) + resolveClientNotificationEndpoint: async () => + "http://external.example.com/ciba-callback", + }), + ], + }); + + const { headers: httpHeaders } = await httpSignIn(); + const httpClient = await httpAuth.api.adminCreateOAuthClient({ + headers: httpHeaders, + body: { + redirect_uris: ["http://localhost:5000/callback"], + skip_consent: true, + }, + }); + + await expect( + httpAuth.api.bcAuthorize({ + body: { + client_id: httpClient!.client_id, + client_secret: httpClient!.client_secret!, + scope: "openid", + login_hint: "test@test.com", + client_notification_token: "push-bearer-token", + }, + }), + ).rejects.toThrow(); + }); + + it("should allow localhost push endpoints for dev", async () => { + const devNotifications: unknown[] = []; + const { auth: devAuth, signInWithTestUser: devSignIn } = + await getTestInstance({ + baseURL, + plugins: [ + jwt({ jwt: { issuer: baseURL } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ciba({ + deliveryModes: ["push", "poll"], + sendNotification: async (data) => { + devNotifications.push(data); + }, + resolveClientNotificationEndpoint: async () => + "http://localhost:4000/ciba-callback", + }), + ], + }); + + const { headers: devHeaders } = await devSignIn(); + const devClient = await devAuth.api.adminCreateOAuthClient({ + headers: devHeaders, + body: { + redirect_uris: ["http://localhost:5000/callback"], + skip_consent: true, + }, + }); + + const bcRes = await devAuth.api.bcAuthorize({ + body: { + client_id: devClient!.client_id, + client_secret: devClient!.client_secret!, + scope: "openid", + login_hint: "test@test.com", + client_notification_token: "push-bearer-token", + }, + }); + expect(bcRes.auth_req_id).toBeDefined(); + // Push mode: no interval + expect(bcRes.interval).toBeUndefined(); + }); + }); + + describe("discovery metadata", () => { + it("should include CIBA fields in openid-configuration", async () => { + const config = await auth.api.getOpenIdConfig(); + expect(config.backchannel_authentication_endpoint).toBe( + `${baseURL}/api/auth/oauth2/bc-authorize`, + ); + expect(config.backchannel_token_delivery_modes_supported).toEqual([ + "poll", + ]); + expect(config.backchannel_user_code_parameter_supported).toBe(false); + expect(config.grant_types_supported).toContain( + "urn:openid:params:grant-type:ciba", + ); + }); + }); +}); diff --git a/packages/ciba/src/client.ts b/packages/ciba/src/client.ts new file mode 100644 index 0000000000..ed573ffb51 --- /dev/null +++ b/packages/ciba/src/client.ts @@ -0,0 +1,14 @@ +import type { BetterAuthClientPlugin } from "better-auth/client"; +import type { ciba } from "./index"; + +export const cibaClient = () => + ({ + id: "ciba", + $InferServerPlugin: {} as ReturnType, + pathMethods: { + "/oauth2/bc-authorize": "POST", + "/ciba/verify": "GET", + "/ciba/authorize": "POST", + "/ciba/reject": "POST", + }, + }) satisfies BetterAuthClientPlugin; diff --git a/packages/ciba/src/error-codes.ts b/packages/ciba/src/error-codes.ts new file mode 100644 index 0000000000..0f1751d51a --- /dev/null +++ b/packages/ciba/src/error-codes.ts @@ -0,0 +1,15 @@ +import { defineErrorCodes } from "@better-auth/core/utils/error-codes"; + +export const CIBA_ERROR_CODES = defineErrorCodes({ + AUTHORIZATION_PENDING: "The authorization request is still pending", + SLOW_DOWN: "Polling too frequently, increase interval", + EXPIRED_TOKEN: "The auth_req_id has expired", + ACCESS_DENIED: "The user denied the authorization request", + INVALID_GRANT: "The auth_req_id is invalid or has already been consumed", + INVALID_BINDING_MESSAGE: "The binding_message is invalid", + UNKNOWN_USER: "The user identified by the hint could not be found", + MISSING_HINT: + "Exactly one of login_hint, id_token_hint, or login_hint_token is required", + MISSING_NOTIFICATION_TOKEN: + "client_notification_token is required for push/ping delivery modes", +}); diff --git a/packages/ciba/src/grant-handler.ts b/packages/ciba/src/grant-handler.ts new file mode 100644 index 0000000000..c61a78a4a8 --- /dev/null +++ b/packages/ciba/src/grant-handler.ts @@ -0,0 +1,191 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import type { OAuthOptions, Scope } from "@better-auth/oauth-provider"; +import { + basicToClientCredentials, + createUserTokens, + validateClientCredentials, +} from "@better-auth/oauth-provider"; +import { APIError } from "better-auth/api"; +import type { CibaOptions } from "./types"; +import { deleteCibaRequest, findCibaRequest, updateCibaRequest } from "./utils"; + +const SLOW_DOWN_INCREMENT = 5; + +/** + * Creates the CIBA grant handler that runs inside the oauth-provider's + * token endpoint switch-default case. + * + * Called as: handler(ctx, opts) where opts = OAuthOptions from oauth-provider. + * Returns a full ctx.json() Response (same shape as createUserTokens). + */ +export function createCibaGrantHandler(_options: CibaOptions) { + return async (ctx: GenericEndpointContext, opts: OAuthOptions) => { + let clientId: string | undefined = ctx.body?.client_id; + let clientSecret: string | undefined = ctx.body?.client_secret; + const authReqId: string | undefined = ctx.body?.auth_req_id; + + const authorization = ctx.request?.headers.get("authorization") || null; + if (authorization?.startsWith("Basic ")) { + const res = basicToClientCredentials(authorization); + clientId = res?.client_id; + clientSecret = res?.client_secret; + } + + if (!clientId) { + throw new APIError("BAD_REQUEST", { + error_description: "client_id is required", + error: "invalid_request", + }); + } + if (!authReqId) { + throw new APIError("BAD_REQUEST", { + error_description: "auth_req_id is required for CIBA grant", + error: "invalid_request", + }); + } + + const cibaRequest = await findCibaRequest(ctx, authReqId); + if (!cibaRequest) { + throw new APIError("BAD_REQUEST", { + error_description: + "The auth_req_id is invalid or has already been consumed", + error: "invalid_grant", + }); + } + + // Validate client credentials with the stored scopes + const scopes = cibaRequest.scope.split(" "); + const client = await validateClientCredentials( + ctx, + opts, + clientId, + clientSecret, + scopes, + ); + + if (cibaRequest.clientId !== clientId) { + throw new APIError("BAD_REQUEST", { + error_description: "auth_req_id does not belong to this client", + error: "invalid_grant", + }); + } + + // Check expiry first to avoid wasting rate-limit state on expired requests + if (cibaRequest.expiresAt < new Date()) { + await deleteCibaRequest(ctx, authReqId); + throw new APIError("BAD_REQUEST", { + error_description: "The auth_req_id has expired", + error: "expired_token", + }); + } + + // Push-mode requests are not redeemable via polling (CIBA §5) + if (cibaRequest.deliveryMode === "push") { + throw new APIError("BAD_REQUEST", { + error_description: + "Push-mode requests cannot be polled; tokens are delivered to the client notification endpoint", + error: "invalid_grant", + }); + } + + // Rate limiting (slow_down) + const now = Date.now(); + if (cibaRequest.lastPolledAt) { + const elapsed = now - cibaRequest.lastPolledAt; + if (elapsed < cibaRequest.pollingInterval * 1000) { + await updateCibaRequest(ctx, cibaRequest.id, { + lastPolledAt: now, + pollingInterval: cibaRequest.pollingInterval + SLOW_DOWN_INCREMENT, + }); + throw new APIError("BAD_REQUEST", { + error_description: "Polling too frequently, increase interval", + error: "slow_down", + }); + } + } + await updateCibaRequest(ctx, cibaRequest.id, { lastPolledAt: now }); + + switch (cibaRequest.status) { + case "pending": + throw new APIError("BAD_REQUEST", { + error_description: "The authorization request is still pending", + error: "authorization_pending", + }); + + case "rejected": + await deleteCibaRequest(ctx, authReqId); + throw new APIError("BAD_REQUEST", { + error_description: "The user denied the authorization request", + error: "access_denied", + }); + + case "approved": { + const user = await ctx.context.internalAdapter.findUserById( + cibaRequest.userId, + ); + if (!user) { + await deleteCibaRequest(ctx, authReqId); + throw new APIError("BAD_REQUEST", { + error_description: "User not found, may have been deleted", + error: "invalid_request", + }); + } + + // Inject resource into ctx.body for checkResource() + if (cibaRequest.resource) { + ctx.body.resource = cibaRequest.resource; + } + + // Delete before issuing to prevent replay + await deleteCibaRequest(ctx, authReqId); + + // Build extra claims: act (agent identity) + authorization_details (RAR) + const extra: { + accessTokenClaims: Record; + tokenResponse?: Record; + } = { accessTokenClaims: { act: { sub: client.clientId } } }; + + if (cibaRequest.authorizationDetails) { + try { + const ad = JSON.parse(cibaRequest.authorizationDetails); + extra.accessTokenClaims.authorization_details = ad; + extra.tokenResponse = { authorization_details: ad }; + } catch { + // Ignore malformed JSON — issue tokens without authorization_details + } + } + + if (cibaRequest.agentClaims) { + try { + const ac = JSON.parse(cibaRequest.agentClaims); + if (ac.agent) { + extra.accessTokenClaims.agent = ac.agent; + } + } catch { + // Ignore malformed agent claims + } + } + + return createUserTokens( + ctx, + opts, + client, + scopes, + user, + authReqId, + undefined, + undefined, + undefined, + new Date(), + extra, + ); + } + + default: + throw new APIError("INTERNAL_SERVER_ERROR", { + error_description: "Unexpected CIBA request status", + error: "server_error", + }); + } + }; +} diff --git a/packages/ciba/src/index.ts b/packages/ciba/src/index.ts new file mode 100644 index 0000000000..37a82b341a --- /dev/null +++ b/packages/ciba/src/index.ts @@ -0,0 +1,67 @@ +import { BetterAuthError } from "@better-auth/core/error"; +import type { OAuthProviderExtension } from "@better-auth/oauth-provider"; +import type { BetterAuthPlugin } from "better-auth/types"; +import { createCibaAuthorize, createCibaReject } from "./approval"; +import { createBcAuthorize } from "./bc-authorize"; +import { CIBA_ERROR_CODES } from "./error-codes"; +import { createCibaGrantHandler } from "./grant-handler"; +import { schema } from "./schema"; +import type { CibaOptions } from "./types"; +import { createCibaVerify } from "./verify"; + +export { deliverPing } from "./push"; +export type { CibaOptions, SendNotificationData } from "./types"; + +const CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba"; + +/** + * Client Initiated Backchannel Authentication (CIBA) plugin for better-auth. + * + * Enables AI agents and other clients to request user authorization + * out-of-band — the agent initiates a request, the user approves on + * a separate device/channel. + * + * Requires the `oauth-provider` and `jwt` plugins. + * + * @see https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html + */ +export const ciba = (options: CibaOptions) => { + const deliveryModes = options.deliveryModes ?? ["poll"]; + + return { + id: "ciba", + schema, + + init(ctx) { + const oauthPlugin = ctx.getPlugin("oauth-provider"); + if (!oauthPlugin) { + throw new BetterAuthError( + "The CIBA plugin requires the oauth-provider plugin", + ); + } + }, + + extensions: { + "oauth-provider": { + grantTypes: { + [CIBA_GRANT_TYPE]: createCibaGrantHandler(options), + }, + grantTypeURIs: [CIBA_GRANT_TYPE], + metadata: ({ baseURL }) => ({ + backchannel_authentication_endpoint: `${baseURL}/oauth2/bc-authorize`, + backchannel_token_delivery_modes_supported: deliveryModes, + backchannel_user_code_parameter_supported: false, + }), + } satisfies OAuthProviderExtension, + }, + + endpoints: { + bcAuthorize: createBcAuthorize(options), + cibaVerify: createCibaVerify(), + cibaAuthorize: createCibaAuthorize(options), + cibaReject: createCibaReject(), + }, + + $ERROR_CODES: CIBA_ERROR_CODES, + } satisfies BetterAuthPlugin; +}; diff --git a/packages/ciba/src/push.ts b/packages/ciba/src/push.ts new file mode 100644 index 0000000000..a0eb8f3c9c --- /dev/null +++ b/packages/ciba/src/push.ts @@ -0,0 +1,105 @@ +/** + * Push/ping delivery with exponential backoff retry. + * + * On exhaustion: request stays approved, allowing polling fallback. + */ + +const FETCH_TIMEOUT_MS = 10_000; + +async function fetchWithRetry( + url: string, + init: RequestInit, + maxRetries: number, +): Promise { + let lastError: Error | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (res.ok) return res; + lastError = new Error(`HTTP ${res.status}`); + } catch (err) { + lastError = err as Error; + } + if (attempt < maxRetries) { + // Exponential backoff: 1s, 2s, 4s + await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); + } + } + throw lastError; +} + +/** + * Push mode: deliver full token response to the client's notification endpoint. + */ +export async function deliverPush( + endpoint: string, + notificationToken: string, + payload: Record, + maxRetries: number, +): Promise { + await fetchWithRetry( + endpoint, + { + method: "POST", + headers: { + Authorization: `Bearer ${notificationToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }, + maxRetries, + ); +} + +/** + * Ping mode: notify client that the request is ready for polling. + */ +export async function deliverPing( + endpoint: string, + notificationToken: string, + authReqId: string, +): Promise { + await fetchWithRetry( + endpoint, + { + method: "POST", + headers: { + Authorization: `Bearer ${notificationToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ auth_req_id: authReqId }), + }, + 2, // Fewer retries for ping since client can still poll + ); +} + +/** + * Push mode error delivery: notify client of rejection. + */ +export async function deliverError( + endpoint: string, + notificationToken: string, + authReqId: string, + error: string, + errorDescription: string, +): Promise { + await fetchWithRetry( + endpoint, + { + method: "POST", + headers: { + Authorization: `Bearer ${notificationToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + auth_req_id: authReqId, + error, + error_description: errorDescription, + }), + }, + 1, // Minimal retries for error delivery + ); +} diff --git a/packages/ciba/src/schema.ts b/packages/ciba/src/schema.ts new file mode 100644 index 0000000000..82d6d771fe --- /dev/null +++ b/packages/ciba/src/schema.ts @@ -0,0 +1,82 @@ +import type { BetterAuthPluginDBSchema } from "@better-auth/core/db"; + +export const schema = { + cibaRequest: { + modelName: "cibaRequest", + fields: { + authReqId: { + type: "string", + unique: true, + required: true, + }, + clientId: { + type: "string", + required: true, + references: { + model: "oauthClient", + field: "clientId", + }, + }, + userId: { + type: "string", + required: true, + references: { + model: "user", + field: "id", + }, + }, + scope: { + type: "string", + required: true, + }, + bindingMessage: { + type: "string", + required: false, + }, + authorizationDetails: { + type: "string", + required: false, + }, + resource: { + type: "string", + required: false, + }, + agentClaims: { + type: "string", + required: false, + }, + status: { + type: "string", + required: true, + }, + deliveryMode: { + type: "string", + required: true, + }, + clientNotificationToken: { + type: "string", + required: false, + }, + clientNotificationEndpoint: { + type: "string", + required: false, + }, + pollingInterval: { + type: "number", + required: true, + }, + lastPolledAt: { + type: "number", + required: false, + }, + expiresAt: { + type: "date", + required: true, + }, + createdAt: { + type: "date", + required: true, + }, + }, + }, +} satisfies BetterAuthPluginDBSchema; diff --git a/packages/ciba/src/types.ts b/packages/ciba/src/types.ts new file mode 100644 index 0000000000..63a6f6456c --- /dev/null +++ b/packages/ciba/src/types.ts @@ -0,0 +1,86 @@ +import type { GenericEndpointContext } from "@better-auth/core"; + +/** + * Data passed to the {@link CibaOptions.sendNotification} callback. + */ +export interface SendNotificationData { + userId: string; + authReqId: string; + clientName?: string; + scope: string; + bindingMessage?: string; + authorizationDetails?: unknown; + agentClaims?: string; + approvalUrl: string; + /** + * Client Attestation JWT from the `OAuth-Client-Attestation` header. + * @see https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-08.html + */ + attestationJwt?: string; + /** + * Client Attestation PoP JWT from the `OAuth-Client-Attestation-PoP` header. + * @see https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-08.html + */ + attestationPopJwt?: string; +} + +export interface CibaOptions { + /** + * Notify user about a pending auth request (email, SMS, push, etc.). + * Called as fire-and-forget; failures are silently ignored. + */ + sendNotification: ( + data: SendNotificationData, + request?: Request, + ) => Promise; + + /** + * Supported token delivery modes. + * @default ["poll"] + */ + deliveryModes?: ("poll" | "ping" | "push")[]; + + /** + * Auth request lifetime in seconds. + * @default 300 + */ + requestLifetime?: number; + + /** + * Minimum polling interval in seconds. + * @default 5 + */ + pollingInterval?: number; + + /** + * Custom user resolution from login_hint. + * Default: lookup by email via internalAdapter. + */ + resolveUser?: ( + loginHint: string, + ctx: GenericEndpointContext, + ) => Promise<{ id: string } | null>; + + /** + * Resolve the notification endpoint for a given client. + * Used for push/ping delivery when the client doesn't provide + * client_notification_uri in the backchannel auth request. + * Typically reads from client metadata stored during registration. + */ + resolveClientNotificationEndpoint?: ( + clientId: string, + ctx: GenericEndpointContext, + ) => Promise; + + /** + * URL path for the approval page. auth_req_id is added as a query param. + * @default "/ciba/approve" + */ + approvalPage?: string; + + /** + * Push delivery retry attempts. + * @default 3 + */ + pushRetryAttempts?: number; +} diff --git a/packages/ciba/src/utils.ts b/packages/ciba/src/utils.ts new file mode 100644 index 0000000000..766fe22f33 --- /dev/null +++ b/packages/ciba/src/utils.ts @@ -0,0 +1,84 @@ +import type { OAuthOptions, Scope } from "@better-auth/oauth-provider"; + +/** + * Get the oauth-provider's resolved options from endpoint context. + */ +export function getOAuthOpts(ctx: { + context: { getPlugin: (id: string) => any }; +}): OAuthOptions { + const plugin = ctx.context.getPlugin("oauth-provider"); + if (!plugin?.options) { + throw new Error("oauth-provider plugin is required for CIBA"); + } + return plugin.options as OAuthOptions; +} + +/** + * Validate that a URL uses HTTPS (CIBA spec §10.3). + * Loopback addresses (localhost, 127.0.0.1, [::1]) are exempt + * per standard practice for local development (RFC 8252 §8.3). + */ +export function isSecureEndpoint(endpoint: string): boolean { + try { + const url = new URL(endpoint); + const isLoopback = + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]"; + return url.protocol === "https:" || isLoopback; + } catch { + return false; + } +} + +export interface CibaRequest { + id: string; + authReqId: string; + clientId: string; + userId: string; + scope: string; + bindingMessage?: string; + authorizationDetails?: string; + resource?: string; + agentClaims?: string; + status: string; + deliveryMode: string; + clientNotificationToken?: string; + clientNotificationEndpoint?: string; + pollingInterval: number; + lastPolledAt?: number; + expiresAt: Date; + createdAt: Date; +} + +export async function findCibaRequest( + ctx: { context: { adapter: any } }, + authReqId: string, +): Promise { + return ctx.context.adapter.findOne({ + model: "cibaRequest", + where: [{ field: "authReqId", value: authReqId }], + }) as Promise; +} + +export async function updateCibaRequest( + ctx: { context: { adapter: any } }, + id: string, + update: Partial, +): Promise { + await ctx.context.adapter.update({ + model: "cibaRequest", + where: [{ field: "id", value: id }], + update, + }); +} + +export async function deleteCibaRequest( + ctx: { context: { adapter: any } }, + authReqId: string, +): Promise { + await ctx.context.adapter.deleteMany({ + model: "cibaRequest", + where: [{ field: "authReqId", value: authReqId }], + }); +} diff --git a/packages/ciba/src/verify.ts b/packages/ciba/src/verify.ts new file mode 100644 index 0000000000..157b6437ed --- /dev/null +++ b/packages/ciba/src/verify.ts @@ -0,0 +1,99 @@ +import { getClient } from "@better-auth/oauth-provider"; +import { APIError, createAuthEndpoint } from "better-auth/api"; +import * as z from "zod"; +import { deleteCibaRequest, findCibaRequest, getOAuthOpts } from "./utils"; + +/** + * GET /ciba/verify — Returns non-sensitive metadata for the approval UI. + * The auth_req_id (~190-bit entropy) serves as the secret. + */ +export function createCibaVerify() { + return createAuthEndpoint( + "/ciba/verify", + { + method: "GET", + query: z.object({ + auth_req_id: z.string().min(1), + }), + metadata: { + openapi: { + description: "Get CIBA request details for the approval UI", + responses: { + 200: { + description: "CIBA request details", + }, + }, + }, + }, + }, + async (ctx) => { + const { auth_req_id: authReqId } = ctx.query; + + const cibaRequest = await findCibaRequest(ctx, authReqId); + if (!cibaRequest) { + throw new APIError("NOT_FOUND", { + error_description: "CIBA request not found or expired", + error: "invalid_request", + }); + } + + if (cibaRequest.expiresAt < new Date()) { + throw new APIError("BAD_REQUEST", { + error_description: "The auth_req_id has expired", + error: "expired_token", + }); + } + + const oauthOpts = getOAuthOpts(ctx); + const client = await getClient( + ctx as any, + oauthOpts, + cibaRequest.clientId, + ); + if (!client) { + throw new APIError("BAD_REQUEST", { + error_description: "OAuth client not found", + error: "invalid_request", + }); + } + if (client.disabled) { + await deleteCibaRequest(ctx, authReqId); + throw new APIError("BAD_REQUEST", { + error_description: "OAuth client has been disabled", + error: "invalid_request", + }); + } + + let parsedAuthorizationDetails: unknown; + if (cibaRequest.authorizationDetails) { + try { + parsedAuthorizationDetails = JSON.parse( + cibaRequest.authorizationDetails, + ); + } catch { + parsedAuthorizationDetails = null; + } + } + + let parsedAgentClaims: unknown; + if (cibaRequest.agentClaims) { + try { + parsedAgentClaims = JSON.parse(cibaRequest.agentClaims); + } catch { + parsedAgentClaims = null; + } + } + + return ctx.json({ + auth_req_id: cibaRequest.authReqId, + client_name: client.name, + scope: cibaRequest.scope, + binding_message: cibaRequest.bindingMessage, + authorization_details: parsedAuthorizationDetails, + agent_claims: parsedAgentClaims, + status: cibaRequest.status, + expires_at: cibaRequest.expiresAt.toISOString(), + }); + }, + ); +} diff --git a/packages/ciba/tsconfig.json b/packages/ciba/tsconfig.json new file mode 100644 index 0000000000..71af791623 --- /dev/null +++ b/packages/ciba/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable"] + }, + "include": ["./src"], + "references": [ + { + "path": "../better-auth/tsconfig.json" + }, + { + "path": "../core/tsconfig.json" + }, + { + "path": "../oauth-provider/tsconfig.json" + } + ] +} diff --git a/packages/ciba/tsdown.config.ts b/packages/ciba/tsdown.config.ts new file mode 100644 index 0000000000..88ba203781 --- /dev/null +++ b/packages/ciba/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + dts: { build: true, incremental: true }, + format: ["esm"], + entry: ["./src/index.ts", "./src/client.ts"], + external: [ + "@better-auth/core", + "@better-auth/oauth-provider", + "@better-auth/utils", + "@better-fetch/fetch", + "better-auth", + "better-call", + ], + sourcemap: true, + treeshake: true, + clean: true, +}); diff --git a/packages/ciba/vitest.config.ts b/packages/ciba/vitest.config.ts new file mode 100644 index 0000000000..bbb5405668 --- /dev/null +++ b/packages/ciba/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + clearMocks: true, + restoreMocks: true, + testTimeout: 10_000, + }, +}); diff --git a/packages/oauth-provider/src/metadata.ts b/packages/oauth-provider/src/metadata.ts index 37eab3dc3d..51de25c31d 100644 --- a/packages/oauth-provider/src/metadata.ts +++ b/packages/oauth-provider/src/metadata.ts @@ -18,6 +18,9 @@ export function authServerMetadata( public_client_supported?: boolean; grant_types_supported?: GrantType[]; jwt_disabled?: boolean; + backchannel_authentication_endpoint?: string; + backchannel_token_delivery_modes_supported?: ("poll" | "ping" | "push")[]; + backchannel_user_code_parameter_supported?: boolean; }, ) { const baseURL = ctx.context.baseURL; @@ -61,6 +64,12 @@ export function authServerMetadata( ], code_challenge_methods_supported: ["S256"], authorization_response_iss_parameter_supported: true, + backchannel_authentication_endpoint: + overrides?.backchannel_authentication_endpoint, + backchannel_token_delivery_modes_supported: + overrides?.backchannel_token_delivery_modes_supported, + backchannel_user_code_parameter_supported: + overrides?.backchannel_user_code_parameter_supported, }; return metadata; } diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index d4be8959e0..86b96ac4f2 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -673,6 +673,7 @@ export const oauthProvider = >(options: O) => { refresh_token: z.string().optional(), resource: z.string().optional(), scope: z.string().optional(), + auth_req_id: z.string().optional(), }) .passthrough(), metadata: { @@ -1210,13 +1211,7 @@ export const oauthProvider = >(options: O) => { .default("client_secret_basic") .optional(), grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) + .array(z.string().trim().min(1)) .default(["authorization_code"]) .optional(), response_types: z diff --git a/packages/oauth-provider/src/oauthClient/index.ts b/packages/oauth-provider/src/oauthClient/index.ts index fcfd2c0ea0..e0dbd2c18d 100644 --- a/packages/oauth-provider/src/oauthClient/index.ts +++ b/packages/oauth-provider/src/oauthClient/index.ts @@ -36,13 +36,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions) => .default("client_secret_basic") .optional(), grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) + .array(z.string().trim().min(1)) .default(["authorization_code"]) .optional(), response_types: z @@ -249,13 +243,7 @@ export const createOAuthClient = (opts: OAuthOptions) => .default("client_secret_basic") .optional(), grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) + .array(z.string().trim().min(1)) .default(["authorization_code"]) .optional(), response_types: z @@ -523,15 +511,7 @@ export const adminUpdateOAuthClient = (opts: OAuthOptions) => software_statement: z.string().optional(), post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(), // NOTE: token_endpoint_auth_method is currently immutable since it changes isPublic definition - grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) - .optional(), + grant_types: z.array(z.string().trim().min(1)).optional(), response_types: z.array(z.enum(["code"])).optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), // SERVER_ONLY applicable fields @@ -577,15 +557,7 @@ export const updateOAuthClient = (opts: OAuthOptions) => software_statement: z.string().optional(), post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(), // NOTE: token_endpoint_auth_method is currently immutable since it changes isPublic definition - grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) - .optional(), + grant_types: z.array(z.string().trim().min(1)).optional(), response_types: z.array(z.enum(["code"])).optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), }), diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index d240f81b8a..2c8028c281 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -35,9 +35,13 @@ export async function tokenEndpoint( ctx: GenericEndpointContext, opts: OAuthOptions, ) { - const grantType: GrantType | undefined = ctx.body?.grant_type; + const grantType: string | undefined = ctx.body?.grant_type; - if (opts.grantTypes && grantType && !opts.grantTypes.includes(grantType)) { + if ( + opts.grantTypes && + grantType && + !opts.grantTypes.includes(grantType as GrantType) + ) { throw new APIError("BAD_REQUEST", { error_description: `unsupported grant_type ${grantType}`, error: "unsupported_grant_type", @@ -555,6 +559,7 @@ export async function createUserTokens( refresh_token: refreshToken?.token, scope: scopes.join(" "), id_token: idToken, + ...extra?.tokenResponse, }, { headers: { @@ -679,13 +684,6 @@ async function handleAuthorizationCodeGrant( error: "invalid_request", }); } - if (!redirect_uri) { - throw new APIError("BAD_REQUEST", { - error_description: "redirect_uri is required", - error: "invalid_request", - }); - } - const isAuthCodeWithSecret = client_id && client_secret; const isAuthCodeWithPkce = client_id && code && code_verifier; diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts index ed686e3a65..8f8f8540ac 100644 --- a/packages/oauth-provider/src/types/oauth.ts +++ b/packages/oauth-provider/src/types/oauth.ts @@ -8,6 +8,7 @@ export type GrantType = | "authorization_code" | "client_credentials" | "refresh_token" + | "urn:openid:params:grant-type:ciba" | (string & {}); export type AuthMethod = @@ -174,6 +175,23 @@ export interface AuthServerMetadata { * @default true */ authorization_response_iss_parameter_supported?: boolean; + /** + * URL of the backchannel authentication endpoint (CIBA). + * @see https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html + */ + backchannel_authentication_endpoint?: string; + /** + * Supported CIBA token delivery modes. + */ + backchannel_token_delivery_modes_supported?: ("poll" | "ping" | "push")[]; + /** + * Signing algorithms supported for backchannel authentication requests. + */ + backchannel_authentication_request_signing_alg_values_supported?: string[]; + /** + * Whether the server supports the user_code parameter in CIBA requests. + */ + backchannel_user_code_parameter_supported?: boolean; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32e080f673..4162e6e8c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1172,6 +1172,37 @@ importers: specifier: ^3.5.29 version: 3.5.29(typescript@5.9.3) + packages/ciba: + dependencies: + '@better-auth/utils': + specifier: 'catalog:' + version: 0.3.1 + '@better-fetch/fetch': + specifier: 'catalog:' + version: 1.1.21 + better-call: + specifier: 'catalog:' + version: 2.0.2(zod@4.3.6) + jose: + specifier: ^6.1.3 + version: 6.1.3 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@better-auth/core': + specifier: workspace:* + version: link:../core + '@better-auth/oauth-provider': + specifier: workspace:* + version: link:../oauth-provider + better-auth: + specifier: workspace:* + version: link:../better-auth + tsdown: + specifier: 'catalog:' + version: 0.21.1(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.0)(publint@0.3.17)(synckit@0.11.11)(typescript@5.9.3) + packages/cli: dependencies: '@babel/core': @@ -31708,7 +31739,7 @@ snapshots: rolldown: 1.0.0-rc.8 rolldown-plugin-dts: 0.22.4(oxc-resolver@11.19.0)(rolldown@1.0.0-rc.8)(typescript@5.9.3) semver: 7.7.4 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.5.0