Skip to content
Open
Show file tree
Hide file tree
Changes from 19 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
118 changes: 118 additions & 0 deletions examples/jwt-bearer-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# JWT Bearer Token Validation

## Overview

When using the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type, OpenAuth validates the JWT signature and then calls your success callback where you can validate the issuer and map claims to your user system.

## Validation Process

1. **JWT signature verification**: OpenAuth fetches the issuer's JWKS and verifies the JWT signature
2. **Success callback**: Your success callback receives the JWT claims where you can validate the issuer
3. **Token generation**: If you approve the JWT, return `ctx.subject()` to generate final access/refresh tokens

## Configuration

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

const app = issuer({
providers: {
gitlab: OidcProvider({
clientID: "your-gitlab-app-id",
issuer: "https://gitlab.com"
})
},
subjects: { /* your subjects */ },
storage: /* your storage */,
success: async (ctx, value) => {
if (value.provider === "gitlab") {
// Handle GitLab OAuth login
const userID = /* map GitLab user to your system */
return ctx.subject("user", { userID })
}

if (value.provider === "jwt-bearer") {
console.log("JWT Bearer token from:", value.issuer)
console.log("Full claims:", value.claims)

// Validate the issuer - this is where YOU decide who to trust
const trustedIssuers = [
"https://gitlab.com", // Your main GitLab instance
"https://accounts.google.com", // Google service accounts
"https://login.microsoftonline.com" // Azure AD
]

if (!trustedIssuers.includes(value.issuer)) {
throw new Error(`Untrusted issuer: ${value.issuer}`)
}

// Handle different issuers differently
if (value.issuer === "https://gitlab.com") {
// JWT from GitLab (maybe from CI/CD pipeline)
const userID = /* lookup user from GitLab subject */
return ctx.subject("user", { userID })
}

if (value.issuer === "https://accounts.google.com") {
// JWT from Google service account
const serviceID = /* extract service info */
return ctx.subject("service", { serviceID })
}

// Add validation for additional custom claims
if (value.claims.custom_role !== "api_access") {
throw new Error("JWT missing required role")
}

return ctx.subject("api_user", {
userID: value.subject,
issuer: value.issuer
})
}
}
})
```

## 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. **Signature verification**: OpenAuth fetches the issuer's JWKS from `${issuer}/.well-known/jwks.json` and verifies the JWT signature

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

```typescript
{
provider: "jwt-bearer",
claims: JWTPayload, // Full JWT claims object
issuer: string, // The JWT issuer
subject: string, // The JWT subject (sub claim)
audience: string // The JWT audience (aud claim)
}
```

4. **Issuer validation**: In your success callback, you decide which issuers to trust

5. **Token generation**: If you approve the JWT, return `ctx.subject()` to generate final access/refresh tokens

## Security Considerations

**Why validate in the success callback?**

- **Flexible validation**: You can implement custom logic for different issuers
- **Context-aware**: Access to full JWT claims for additional validation
- **Granular control**: Different handling per issuer (users vs services vs APIs)
- **Dynamic trust**: Trust decisions can be based on database lookups or external APIs
- **Consistent pattern**: Same validation approach as other OAuth providers

**Best practices:**

- **Use allowlists**: Explicitly list trusted issuers rather than trying to block bad ones
- **Validate additional claims**: Check roles, audiences, or custom claims as needed
- **Log JWT usage**: Monitor bearer token usage for security auditing
- **Handle errors gracefully**: Throw clear errors for untrusted issuers or invalid claims
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
"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",
"@types/node": "22"
},
"dependencies": {
"@changesets/cli": "2.27.10",
Expand Down
81 changes: 81 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,58 @@ export interface Client {
redirectURI: string,
verifier?: string,
): Promise<ExchangeSuccess | ExchangeError>
/**
* Exchange the jwt for access and refresh tokens.
*
* ```ts
* const exchanged = await client.exchange(<code>, <redirect_uri>)
* ```
*
* You call this after the user has been redirected back to your app after the OAuth flow.
*
* :::tip
* For SSR sites, the code is returned in the query parameter.
* :::
*
* So the code comes from the query parameter in the redirect URI. The redirect URI here is
* the one that you passed in to the `authorize` call when starting the flow.
*
* :::tip
* For SPA sites, the code is returned through the URL hash.
* :::
*
* If you used the PKCE flow for an SPA app, the code is returned as a part of the redirect URL
* hash.
*
* ```ts {4}
* const exchanged = await client.exchange(
* <code>,
* <redirect_uri>,
* <challenge.verifier>
* )
* ```
*
* You also need to pass in the previously stored challenge verifier.
*
* 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 { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error"
*
* if (exchanged.err) {
* if (exchanged.err instanceof InvalidAuthorizationCodeError) {
* // handle invalid code error
* }
* 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 +719,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