Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
11b1227
add gitlab and github actions oidc
nullfunc Sep 3, 2025
57af85e
add grant-type for jwt assertion
nullfunc Sep 3, 2025
af53876
type enforcement updates
nullfunc Sep 4, 2025
c58ec05
error update
nullfunc Sep 4, 2025
e132d52
add gitlab and github actions oidc
nullfunc Sep 3, 2025
4329d2b
add grant-type for jwt assertion
nullfunc Sep 3, 2025
3e51d8d
type enforcement updates
nullfunc Sep 4, 2025
b8d187d
error update
nullfunc Sep 4, 2025
f196406
update gitlabs with oidc provider
nullfunc Sep 4, 2025
a210b19
Merge remote-tracking branch 'refs/remotes/origin/add-jwt-assertion-g…
nullfunc Sep 4, 2025
ec7834c
assert jwt-bearer
nullfunc Sep 4, 2025
4fe5d3c
update tests
nullfunc Sep 4, 2025
01fbc60
add trusted issuer check for jwt assertions
nullfunc Sep 4, 2025
d8b30bd
oidc provider and jwt assertion grant-types
nullfunc Sep 5, 2025
966e6f5
remove trailing '/' from github issuer
nullfunc Sep 5, 2025
15576b5
jwt grant updates to pass provider name
nullfunc Sep 8, 2025
a09fba3
fix missing param in test data
nullfunc Sep 8, 2025
5e2f4f5
revert bun.lockb to original
nullfunc Sep 8, 2025
977dd9d
auto: format code
github-actions[bot] Sep 8, 2025
27477fe
update hono and exports/imports
nullfunc Sep 9, 2025
c6b227d
update to dependencies
nullfunc Sep 9, 2025
3f6812e
review updates
nullfunc Sep 9, 2025
44f971b
auto: format code
github-actions[bot] Sep 9, 2025
7fe77c5
ignore audience if not defined. for github CI JWT the aud varies by r…
nullfunc Sep 10, 2025
6cf959d
auto: format code
github-actions[bot] Sep 10, 2025
dcdb969
handle decode failure for better error
nullfunc Sep 10, 2025
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