mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
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:
@@ -33,3 +33,4 @@ Vercel
|
||||
rgba
|
||||
idtoken
|
||||
eidas
|
||||
ciba
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Generated
+32
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user