feat(ciba): add backchannel authentication plugin

Add @better-auth/ciba implementing OpenID Connect Client-Initiated
Backchannel Authentication (CIBA) with poll and ping delivery modes.

Uses the oauth-provider extension protocol to register the
urn:openid:params:grant-type:ciba grant type and contribute
backchannel_authentication_endpoint to AS metadata.

Includes agent_claims forwarding, authorization_details propagation,
and OAuth Client Attestation header passthrough to sendNotification.
This commit is contained in:
Gustavo Valverde
2026-03-20 21:06:34 +00:00
parent 9ceb4c0246
commit c0d5aaf107
25 changed files with 2940 additions and 49 deletions
+1
View File
@@ -33,3 +33,4 @@ Vercel
rgba
idtoken
eidas
ciba
+667
View File
@@ -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
<Steps>
<Step>
### Install the package
```package-install
npm i @better-auth/ciba
```
</Step>
<Step>
### 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]
],
});
```
</Step>
<Step>
### Migrate the database
Run the migration or generate the schema to add the necessary tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```package-install
npx auth migrate
```
</Tab>
<Tab value="generate">
```package-install
npx auth generate
```
</Tab>
</Tabs>
See the [Schema](#schema) section to add the fields manually.
</Step>
<Step>
### Add the client plugin
```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]
],
});
```
</Step>
</Steps>
## 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 <p>Loading...</p>;
return (
<div>
<h2>Sign-in Request</h2>
<p>Client: {request.client_name}</p>
<p>Message: {request.binding_message}</p>
<p>Scopes: {request.scope}</p>
<p>Expires: {new Date(request.expires_at).toLocaleString()}</p>
<button onClick={handleApprove}>Approve</button>
<button onClick={handleReject}>Deny</button>
</div>
);
}
```
<Callout type="warn">
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.
</Callout>
### 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");
}
}
}
```
<Callout type="info">
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.
</Callout>
## 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.
<Callout type="warn">
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.
</Callout>
## 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,
}),
});
```
<Callout type="info">
Only one hint may be provided per request. Sending both `login_hint` and `id_token_hint` (or `login_hint_token`) returns `400 invalid_request`.
</Callout>
## 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.
<APIMethod
path="/oauth2/bc-authorize"
method="POST"
>
```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;
}
```
</APIMethod>
### Verify Request
Get CIBA request details for the approval UI. Unauthenticated — the `auth_req_id` acts as the bearer secret.
<APIMethod
path="/ciba/verify"
method="GET"
>
```ts
type cibaVerify = {
/**
* The request identifier
*/
auth_req_id: string;
}
```
</APIMethod>
Returns: `auth_req_id`, `client_name`, `scope`, `binding_message`, `authorization_details`, `status`, `expires_at`.
### Approve Request
<APIMethod
path="/ciba/authorize"
method="POST"
requireSession
>
```ts
type cibaAuthorize = {
/**
* The request identifier
*/
auth_req_id: string;
}
```
</APIMethod>
### Reject Request
<APIMethod
path="/ciba/reject"
method="POST"
requireSession
>
```ts
type cibaReject = {
/**
* The request identifier
*/
auth_req_id: string;
}
```
</APIMethod>
## 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<void>`.
**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<string | undefined>`.
**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`
<DatabaseTable
fields={[
{
name: "id",
type: "string",
description: "Unique identifier for the CIBA request",
isPrimaryKey: true
},
{
name: "authReqId",
type: "string",
description: "The auth request identifier (unique, ~190-bit entropy)",
},
{
name: "clientId",
type: "string",
description: "The requesting OAuth client identifier",
isForeignKey: true
},
{
name: "userId",
type: "string",
description: "The target user to be authenticated",
isForeignKey: true
},
{
name: "scope",
type: "string",
description: "Requested OAuth scopes",
},
{
name: "bindingMessage",
type: "string",
description: "Optional context message shown in approval UI",
isOptional: true
},
{
name: "authorizationDetails",
type: "string",
description: "JSON-encoded Rich Authorization Requests (RFC 9396)",
isOptional: true
},
{
name: "resource",
type: "string",
description: "Resource indicator for audience restriction",
isOptional: true
},
{
name: "status",
type: "string",
description: "Current status: pending, approved, or rejected",
},
{
name: "deliveryMode",
type: "string",
description: "Token delivery mode: poll, ping, or push",
},
{
name: "clientNotificationToken",
type: "string",
description: "Bearer token for push/ping callbacks",
isOptional: true
},
{
name: "clientNotificationEndpoint",
type: "string",
description: "Callback URL for push/ping delivery",
isOptional: true
},
{
name: "pollingInterval",
type: "number",
description: "Minimum seconds between polls",
},
{
name: "lastPolledAt",
type: "number",
description: "Timestamp of last poll (milliseconds)",
isOptional: true
},
{
name: "expiresAt",
type: "Date",
description: "When the request expires",
},
{
name: "createdAt",
type: "Date",
description: "When the request was created",
}
]}
/>
## 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
+1
View File
@@ -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
+82
View File
@@ -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:"
}
}
+294
View File
@@ -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<string, unknown>).resource =
cibaRequest.resource;
}
// Build extra claims: act (agent identity) + authorization_details (RAR)
const pushExtra: {
accessTokenClaims: Record<string, unknown>;
tokenResponse?: Record<string, unknown>;
} = { 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<string, unknown>;
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<string, unknown>;
}
).body;
} else {
tokenBody = tokenResult as Record<string, unknown>;
}
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 });
},
);
}
+379
View File
@@ -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<string, unknown> = {
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;
}
}
+656
View File
@@ -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<string, string>) {
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",
);
});
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { BetterAuthClientPlugin } from "better-auth/client";
import type { ciba } from "./index";
export const cibaClient = () =>
({
id: "ciba",
$InferServerPlugin: {} as ReturnType<typeof ciba>,
pathMethods: {
"/oauth2/bc-authorize": "POST",
"/ciba/verify": "GET",
"/ciba/authorize": "POST",
"/ciba/reject": "POST",
},
}) satisfies BetterAuthClientPlugin;
+15
View File
@@ -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",
});
+191
View File
@@ -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<Scope[]>) => {
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<string, unknown>;
tokenResponse?: Record<string, unknown>;
} = { 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",
});
}
};
}
+67
View File
@@ -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;
};
+105
View File
@@ -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<Response> {
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<string, unknown>,
maxRetries: number,
): Promise<void> {
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<void> {
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<void> {
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
);
}
+82
View File
@@ -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;
+86
View File
@@ -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<void>;
/**
* 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<string | undefined>;
/**
* 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;
}
+84
View File
@@ -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<Scope[]> {
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<Scope[]>;
}
/**
* 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<CibaRequest | null> {
return ctx.context.adapter.findOne({
model: "cibaRequest",
where: [{ field: "authReqId", value: authReqId }],
}) as Promise<CibaRequest | null>;
}
export async function updateCibaRequest(
ctx: { context: { adapter: any } },
id: string,
update: Partial<CibaRequest>,
): Promise<void> {
await ctx.context.adapter.update({
model: "cibaRequest",
where: [{ field: "id", value: id }],
update,
});
}
export async function deleteCibaRequest(
ctx: { context: { adapter: any } },
authReqId: string,
): Promise<void> {
await ctx.context.adapter.deleteMany({
model: "cibaRequest",
where: [{ field: "authReqId", value: authReqId }],
});
}
+99
View File
@@ -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(),
});
},
);
}
+18
View File
@@ -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"
}
]
}
+18
View File
@@ -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,
});
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from "vitest/config";
export default defineProject({
test: {
clearMocks: true,
restoreMocks: true,
testTimeout: 10_000,
},
});
+9
View File
@@ -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;
}
+2 -7
View File
@@ -673,6 +673,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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
@@ -36,13 +36,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
.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<Scope[]>) =>
.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<Scope[]>) =>
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<Scope[]>) =>
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(),
}),
+7 -9
View File
@@ -35,9 +35,13 @@ export async function tokenEndpoint(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
) {
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;
@@ -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;
}
/**
+32 -1
View File
@@ -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