Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions examples/jwt-bearer-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# JWT Bearer Token Validation

## Overview

When using the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type, OpenAuth automatically validates JWT signatures using JWKS and calls your success callback to handle the validated JWT claims.

## Validation Process

1. **JWT decoding**: OpenAuth decodes the JWT assertion to extract claims
2. **OIDC provider matching**: Finds a matching OIDC provider based on the JWT issuer
3. **JWT signature verification**: Automatically fetches the issuer's JWKS and verifies the JWT signature using the provider's `verifyIdToken()` method
4. **Success callback**: Your success callback receives the validated JWT claims
5. **Token generation**: Return `ctx.subject()` to generate final access/refresh tokens

## Configuration

Configure `oidcProviders` for each JWT issuer you want to accept:

```typescript
import { issuer } from "@openauthjs/openauth"
import { OidcProvider } from "@openauthjs/openauth/provider/oidc"
import { GitHubProvider } from "@openauthjs/openauth/provider/github"

const app = issuer({
// OIDC providers for JWT bearer validation
oidcProviders: {
gitlab: OidcProvider({
clientID: "https://gitlab.com", // Must match JWT 'aud' claim
issuer: "https://gitlab.com", // Must match JWT 'iss' claim
provider: "gitlab" // Provider type identifier
}),
github: OidcProvider({
clientID: "github-actions",
issuer: "https://token.actions.githubusercontent.com",
provider: "github"
})
},

// Regular OAuth providers for interactive login
providers: {
github: GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!
})
},

subjects: { /* your subjects */ },
storage: /* your storage */,

success: async (ctx, value) => {
// Handle regular OAuth providers
if (value.provider === "github") {
const providerData = await getGithubData(value.tokenset.access)
const { user } = await upsertUser(providerData)
return ctx.subject("user", {
id: user.id,
tenant: user.defaultTenant,
hasura: {
"x-hasura-allowed-roles": ["user"],
"x-hasura-default-role": "user",
"x-hasura-user-id": user.id,
},
externalTenants: user.tenants.map(t => t.id),
githubOrgs: providerData.orgs?.map(org => org.name)
}, {
subject: user.id
})
}

// Handle JWT bearer tokens
if (!value.tokenset) {
console.log("JWT Bearer token from:", value.issuer)
console.log("JWT claims:", value.claims)

// The JWT signature is already validated by OpenAuth using JWKS
// Map different issuers to appropriate subjects

if (value.issuer === "https://gitlab.com") {
// JWT from GitLab CI/CD pipeline
return ctx.subject("service", {
id: value.subject,
issuer: value.issuer,
})
}

if (value.issuer === "https://token.actions.githubusercontent.com") {
// JWT from GitHub CI Action
return ctx.subject("service", {
id: value.subject,
issuer: value.issuer,
})
}

// Default: map to API user if no specific handling
return ctx.subject("api_user", {
id: value.subject,
issuer: value.issuer,
audience: value.audience
})
}

throw new Error(`Unsupported provider: ${value.provider}`)
}
})
```

## Token Exchange Flow

1. **Client sends JWT assertion**: A client makes a POST request to `/token` with:

```http
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<jwt_token>
```

2. **OIDC provider matching**: OpenAuth finds the matching OIDC provider by comparing the JWT `iss` claim with configured provider issuers

3. **Signature verification**: OpenAuth uses the matched OIDC provider to verify the JWT signature (automatically fetches JWKS)

4. **Success callback**: OpenAuth calls your success callback with:

```typescript
{
provider: string, // OIDC provider type (from config.type)
claims: JWTPayload, // Full JWT claims object
issuer: string, // The JWT issuer (iss claim)
subject: string, // The JWT subject (sub claim)
audience: string // The JWT audience (aud claim)
}
```

5. **Token generation**: Return `ctx.subject()` to generate final access/refresh tokens

## Security Considerations

**OIDC provider configuration acts as allowlist:**

- **Explicit trust**: Only JWTs from configured `oidcProviders` are accepted
- **Automatic validation**: JWT signature verification is handled automatically
- **No additional issuer validation needed**: The OIDC provider matching already ensures trusted issuers
- **JWKS fetching**: OpenAuth automatically fetches and caches JWKS for signature verification

**Best practices:**

- **Configure specific issuers**: Only add OIDC providers for issuers you trust
- **Match audience claims**: Ensure JWT `aud` claim matches your `clientID` configuration
- **Validate additional claims**: Check roles, scopes, or custom claims in the success callback
- **Use specific types**: Create different subject types for different use cases (users vs services)
- **Log JWT usage**: Monitor bearer token usage for security auditing
- **Handle claim validation**: Throw clear errors for missing or invalid claims
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"release": "bun run --filter=\"@openauthjs/openauth\" build && changeset publish"
},
"devDependencies": {
"@tsconfig/node22": "22.0.0",
"@types/bun": "latest"
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.2.21"
},
"dependencies": {
"@changesets/cli": "2.27.10",
Expand Down
26 changes: 15 additions & 11 deletions packages/openauth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,38 @@
"@cloudflare/workers-types": "4.20241205.0",
"@tsconfig/node22": "22.0.0",
"@types/node": "22.10.1",
"arctic": "2.2.2",
"hono": "4.6.9",
"ioredis": "5.4.1",
"arctic": "2.3.4",
"hono": "4.9.6",
"typescript": "5.6.3",
"valibot": "1.0.0-beta.15"
},
"exports": {
".": {
"import": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts"
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./*": {
"import": "./dist/esm/*.js",
"types": "./dist/types/*.d.ts"
"import": "./src/*.ts",
"types": "./src/*.ts"
},
"./ui": {
"import": "./dist/esm/ui/index.js",
"types": "./dist/types/ui/index.d.ts"
"import": "./src/ui/index.ts",
"types": "./src/ui/index.ts"
},
"./ui/*": {
"import": "./src/ui/*.tsx",
"types": "./src/ui/*.tsx"
}
},
"peerDependencies": {
"arctic": "^2.2.2",
"arctic": "^2.3.4",
"hono": "^4.0.0"
},
"dependencies": {
"@standard-schema/spec": "1.0.0-beta.3",
"aws4fetch": "1.0.20",
"jose": "5.9.6"
"jose": "5.9.6",
"ioredis": "5.4.1"
},
"files": [
"src",
Expand Down
72 changes: 72 additions & 0 deletions packages/openauth/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
import {
InvalidAccessTokenError,
InvalidAuthorizationCodeError,
InvalidJWTError,
InvalidRefreshTokenError,
InvalidSubjectError,
} from "./error.js"
Expand Down Expand Up @@ -451,6 +452,49 @@ export interface Client {
redirectURI: string,
verifier?: string,
): Promise<ExchangeSuccess | ExchangeError>
/**
* Exchange a JWT assertion for access and refresh tokens using the JWT Bearer grant type.
*
* ```ts
* const exchanged = await client.exchangeJWT(<jwt_assertion>)
* ```
*
* This implements the JWT Bearer grant type (RFC 7523) where you exchange a signed JWT
* for OpenAuth access and refresh tokens.
*
* :::tip
* The JWT must be signed by a trusted issuer configured in your OpenAuth server.
* :::
*
* The JWT assertion should contain standard claims like `iss` (issuer), `sub` (subject),
* `aud` (audience), and `exp` (expiration). The issuer must match one of your configured
* OIDC providers.
*
* ```ts
* // Example: exchanging a GitLab CI JWT
* const gitlabJWT = process.env.OIDC_TOKEN
* const exchanged = await client.exchangeJWT(gitlabJWT)
* ```
*
* This method returns the access and refresh tokens. Or if it fails, it returns an error that
* you can handle depending on the error.
*
* ```ts
* import { InvalidJWTError } from "@openauthjs/openauth/error"
*
* if (exchanged.err) {
* if (exchanged.err instanceof InvalidJWTError) {
* // handle invalid JWT error (signature verification failed, untrusted issuer, etc.)
* }
* else {
* // handle other errors
* }
* }
*
* const { access, refresh } = exchanged.tokens
* ```
*/
exchangeJWT(assertion: string): Promise<ExchangeSuccess | ExchangeError>
/**
* Refreshes the tokens if they have expired. This is used in an SPA app to maintain the
* session, without logging the user out.
Expand Down Expand Up @@ -666,6 +710,34 @@ export function createClient(input: ClientInput): Client {
},
}
},
async exchangeJWT(
assertion: string,
): Promise<ExchangeSuccess | ExchangeError> {
const tokens = await f(issuer + "/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
assertion,
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
}).toString(),
})
const json = (await tokens.json()) as any
if (!tokens.ok) {
return {
err: new InvalidJWTError(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InvalidJWTError is a bit too specific. Lots of other reasons why this could fail.

}
}
return {
err: false,
tokens: {
access: json.access_token as string,
refresh: json.refresh_token as string,
expiresIn: json.expires_in as number,
},
}
},
async refresh(
refresh: string,
opts?: RefreshOptions,
Expand Down
9 changes: 9 additions & 0 deletions packages/openauth/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,12 @@ export class InvalidAuthorizationCodeError extends Error {
super("Invalid authorization code")
}
}

/**
* The JWT is invalid.
*/
export class InvalidJWTError extends Error {
constructor() {
super("Invalid JWT")
}
}
Loading
Loading