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