diff --git a/examples/jwt-bearer-validation.md b/examples/jwt-bearer-validation.md new file mode 100644 index 00000000..9dc16957 --- /dev/null +++ b/examples/jwt-bearer-validation.md @@ -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= + ``` + +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 diff --git a/package.json b/package.json index 5c5606eb..fd43e07e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/openauth/package.json b/packages/openauth/package.json index d71c4c4d..4a39029f 100644 --- a/packages/openauth/package.json +++ b/packages/openauth/package.json @@ -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", diff --git a/packages/openauth/src/client.ts b/packages/openauth/src/client.ts index 83013232..24f0718f 100644 --- a/packages/openauth/src/client.ts +++ b/packages/openauth/src/client.ts @@ -49,6 +49,7 @@ import { import { InvalidAccessTokenError, InvalidAuthorizationCodeError, + InvalidJWTError, InvalidRefreshTokenError, InvalidSubjectError, } from "./error.js" @@ -451,6 +452,49 @@ export interface Client { redirectURI: string, verifier?: string, ): Promise + /** + * Exchange a JWT assertion for access and refresh tokens using the JWT Bearer grant type. + * + * ```ts + * const exchanged = await client.exchangeJWT() + * ``` + * + * 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 /** * Refreshes the tokens if they have expired. This is used in an SPA app to maintain the * session, without logging the user out. @@ -666,6 +710,34 @@ export function createClient(input: ClientInput): Client { }, } }, + async exchangeJWT( + assertion: string, + ): Promise { + 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(), + } + } + 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, diff --git a/packages/openauth/src/error.ts b/packages/openauth/src/error.ts index b35de0b5..e5986cfb 100644 --- a/packages/openauth/src/error.ts +++ b/packages/openauth/src/error.ts @@ -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") + } +} diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index 2092ad2f..69936e1c 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -192,7 +192,13 @@ import { UnauthorizedClientError, UnknownStateError, } from "./error.js" -import { compactDecrypt, CompactEncrypt, jwtVerify, SignJWT } from "jose" +import { + compactDecrypt, + CompactEncrypt, + decodeJwt, + jwtVerify, + SignJWT, +} from "jose" import { Storage, StorageAdapter } from "./storage/storage.js" import { encryptionKeys, legacySigningKeys, signingKeys } from "./keys.js" import { validatePKCE } from "./pkce.js" @@ -204,6 +210,7 @@ import { setTheme, Theme } from "./ui/theme.js" import { getRelativeUrl, isDomainMatch, lazy } from "./util.js" import { cors } from "hono/cors" import { logger } from "hono/logger" +import { OidcProvider } from "./provider/oidc.js" /** @internal */ export const aws = awsHandle @@ -211,6 +218,7 @@ export const aws = awsHandle export interface IssuerInput< Providers extends Record>, Subjects extends SubjectSchema, + OidcProviders extends Record>, Result = { [key in keyof Providers]: Prettify< { @@ -283,6 +291,37 @@ export interface IssuerInput< * ``` */ providers: Providers + + /** + * The Oidc Providers that you want your OpenAuth server to support. + * + * @example + * + * ```ts title="issuer.ts" + * import { GithubProvider } from "@openauthjs/openauth/provider/github" + * + * issuer({ + * oidcProviders: { + * github: GithubActionOidcProvider() + * } + * }) + * ``` + * + * The key is just a string that you can use to identify the provider. It's passed back to + * the `success` callback. + * + * You can also specify multiple providers. + * + * ```ts + * { + * oidcProviders: { + * github: GithubActionOidcProvider(), + * } + * } + * ``` + */ + oidcProviders?: OidcProviders + /** * Array containing a list of the OAuth 2.0 [RFC6749] "scope" values that this authorization server advertises. * @@ -457,6 +496,7 @@ export interface IssuerInput< export function issuer< Providers extends Record>, Subjects extends SubjectSchema, + OidcProviders extends Record>, Result = { [key in keyof Providers]: Prettify< { @@ -464,7 +504,7 @@ export function issuer< } & (Providers[key] extends Provider ? T : {}) > }[keyof Providers], ->(input: IssuerInput) { +>(input: IssuerInput) { const error = input.error ?? function (err) { @@ -597,11 +637,11 @@ export function issuer< ) }, forward(ctx, response) { - return ctx.newResponse( - response.body, - response.status as any, - Object.fromEntries(response.headers.entries()), - ) + const headers: Record = {} + response.headers.forEach((value, name) => { + headers[name] = value + }) + return ctx.newResponse(response.body, response.status as any, headers) }, async set(ctx, key, maxAge, value) { setCookie(ctx, key, await encrypt(value), { @@ -1001,7 +1041,10 @@ export function issuer< const response = await match.client({ clientID: clientID.toString(), clientSecret: clientSecret.toString(), - params: Object.fromEntries(form) as Record, + params: Object.fromEntries(Array.from(form.entries())) as Record< + string, + string + >, }) return input.success( { @@ -1032,6 +1075,128 @@ export function issuer< ) } + // see https://datatracker.ietf.org/doc/html/rfc7521 and https://datatracker.ietf.org/doc/html/rfc7523 for jwt assertion grant-types spec + if (grantType === "urn:ietf:params:oauth:grant-type:jwt-bearer") { + const assertion = form.get("assertion") + if (!assertion) { + return c.json( + { + error: "invalid_grant", + error_description: "Missing assertion parameter", + }, + 400, + ) + } + + let claims + try { + claims = decodeJwt(assertion.toString()) + if (!claims) { + return c.json( + { + error: "invalid_grant", + error_description: "JWT assertion could not be decoded", + }, + 400, + ) + } + } catch (error) { + return c.json( + { + error: "invalid_grant", + error_description: "JWT assertion could not be decoded", + }, + 400, + ) + } + + if (claims == undefined) { + return c.json( + { + error: "invalid_grant", + error_description: "no claims found in JWT assertion", + }, + 400, + ) + } + + if (!claims.iss) { + return c.json( + { + error: "invalid_grant", + error_description: "JWT assertion missing required issuer claim", + }, + 400, + ) + } + + let oidcProvider + for (const provider in input.oidcProviders) { + if (input.oidcProviders[provider]?.issuer === claims.iss) { + oidcProvider = input.oidcProviders[provider] + break + } + } + + if (!oidcProvider) { + return c.json( + { + error: "invalid_grant", + error_description: "JWT assertion from untrusted issuer", + }, + 400, + ) + } + + try { + await oidcProvider.verifyIdToken(assertion.toString()) + } catch (error) { + return c.json( + { + error: "invalid_grant", + error_description: + "JWT assertion signature verification failed or token expired", + }, + 400, + ) + } + + // Call the success callback to handle JWT bearer token validation + return input.success( + { + async subject(type, properties, opts) { + const tokens = await generateTokens(c, { + type: type as string, + subject: opts?.subject || (claims.sub as string), + properties, + clientID: claims.aud as string, + // scopes: parseScopes(scope), validated? + ttl: { + access: + opts?.ttl?.access ?? + (claims.exp as number) - Math.floor(Date.now() / 1000), + refresh: opts?.ttl?.refresh ?? ttlRefresh, + }, + }) + return c.json({ + access_token: tokens.access, + refresh_token: tokens.refresh, + // scope: parseScopes(scope)?.join(" "), + expires_in: tokens.expiresIn, + }) + }, + }, + { + provider: oidcProvider.type, + claims: claims, + issuer: claims.iss, + subject: claims.sub, + audience: claims.aud, + } as Result, + c.req.raw, + ) + } + throw new Error("Invalid grant_type") }, ) @@ -1093,7 +1258,7 @@ export function issuer< await auth.set(c, "authorization", 60 * 60 * 24, authorization) if (provider) return c.redirect(`/${provider}/authorize`) const providers = Object.keys(input.providers) - // if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`) + if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`) return auth.forward( c, await select()( diff --git a/packages/openauth/src/provider/code.ts b/packages/openauth/src/provider/code.ts index e8f7b709..6e0bba4e 100644 --- a/packages/openauth/src/provider/code.ts +++ b/packages/openauth/src/provider/code.ts @@ -173,7 +173,7 @@ export function CodeProvider< const action = fd.get("action")?.toString() if (action === "request" || action === "resend") { - const claims = Object.fromEntries(fd) as Claims + const claims = Object.fromEntries(fd.entries()) as Claims delete claims.action const err = await config.sendCode(claims, code) if (err) return transition(c, { type: "start" }, fd, err) diff --git a/packages/openauth/src/provider/github.ts b/packages/openauth/src/provider/github.ts index ca93ba3b..99036c6e 100644 --- a/packages/openauth/src/provider/github.ts +++ b/packages/openauth/src/provider/github.ts @@ -18,8 +18,10 @@ */ import { Oauth2Provider, Oauth2WrappedConfig } from "./oauth2.js" +import { OidcProvider, OidcWrappedConfig } from "./oidc.js" export interface GithubConfig extends Oauth2WrappedConfig {} +export interface GithubOidcConfig extends OidcWrappedConfig {} /** * Create a Github OAuth2 provider. @@ -43,3 +45,22 @@ export function GithubProvider(config: GithubConfig) { }, }) } + +/** + * Create a Github OIDC provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * GithubOidcProvider({ + * clientId: "1234567890" + * }) + * ``` + */ +export function GithubActionsOidcProvider(config: GithubOidcConfig) { + return OidcProvider({ + ...config, + type: "github", + issuer: "https://token.actions.githubusercontent.com", + }) +} diff --git a/packages/openauth/src/provider/gitlab.ts b/packages/openauth/src/provider/gitlab.ts index 850d0b5c..fb9fd398 100644 --- a/packages/openauth/src/provider/gitlab.ts +++ b/packages/openauth/src/provider/gitlab.ts @@ -18,8 +18,10 @@ */ import { Oauth2Provider, Oauth2WrappedConfig } from "./oauth2.js" +import { OidcProvider, OidcWrappedConfig } from "./oidc.js" export interface GitlabConfig extends Oauth2WrappedConfig {} +export interface GitlabOidcConfig extends OidcWrappedConfig {} /** * Create a Gitlab OAuth2 provider. @@ -43,3 +45,22 @@ export function GitlabProvider(config: GitlabConfig) { }, }) } + +/** + * Create a Gitlab OIDC provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * GitlabOidcProvider({ + * clientId: "1234567890" + * }) + * ``` + */ +export function GitlabOidcProvider(config: GitlabOidcConfig) { + return OidcProvider({ + ...config, + type: "gitlab", + issuer: "https://gitlab.com", + }) +} diff --git a/packages/openauth/src/provider/oauth2.ts b/packages/openauth/src/provider/oauth2.ts index 5a0f6583..b3ae11fc 100644 --- a/packages/openauth/src/provider/oauth2.ts +++ b/packages/openauth/src/provider/oauth2.ts @@ -184,7 +184,6 @@ export function Oauth2Provider( let idTokenPayload: Record | null = null if (config.endpoint.jwks) { const jwksEndpoint = new URL(config.endpoint.jwks) - // @ts-expect-error bun/node mismatch const jwks = createRemoteJWKSet(jwksEndpoint) const { payload } = await jwtVerify(json.id_token, jwks, { audience: config.clientID, diff --git a/packages/openauth/src/provider/oidc.ts b/packages/openauth/src/provider/oidc.ts index 8e21fde7..b1a3d5ac 100644 --- a/packages/openauth/src/provider/oidc.ts +++ b/packages/openauth/src/provider/oidc.ts @@ -24,6 +24,14 @@ import { OauthError } from "../error.js" import { Provider } from "./provider.js" import { JWTPayload } from "hono/utils/jwt/types" import { getRelativeUrl, lazy } from "../util.js" +import { verify } from "crypto" + +interface ResponseLike { + json(): Promise + ok: Response["ok"] + text(): Promise +} +type FetchLike = (...args: any[]) => Promise export interface OidcConfig { /** @@ -65,6 +73,18 @@ export interface OidcConfig { * ``` */ scopes?: string[] + /** + * The expected audience for JWT verification. + * If not provided, defaults to clientID. + * + * @example + * ```ts + * { + * audience: "https://github.com/owner/repo" + * } + * ``` + */ + audience?: string /** * Any additional parameters that you want to pass to the authorization endpoint. * @example @@ -77,6 +97,14 @@ export interface OidcConfig { * ``` */ query?: Record + + /** + * Optionally, override the internally used fetch function. + * + * This is useful if you are using a polyfilled fetch function in your application and you + * want the client to use it too. + */ + fetch?: FetchLike } /** @@ -99,33 +127,54 @@ export interface IdTokenResponse { raw: Record } +export interface OidcProvider extends Provider { + issuer: string + verifyIdToken: ( + id_token: string, + ) => Promise<{ payload: JWTPayload; protectedHeader: Record }> +} + export function OidcProvider( config: OidcConfig, -): Provider<{ id: JWTPayload; clientID: string }> { +): OidcProvider<{ id: JWTPayload; clientID: string }> { const query = config.query || {} const scopes = config.scopes || [] + const f = config.fetch || fetch const wk = lazy(() => - fetch(config.issuer + "/.well-known/openid-configuration").then( - async (r) => { - if (!r.ok) throw new Error(await r.text()) - return r.json() as Promise - }, - ), + f(config.issuer + "/.well-known/openid-configuration").then(async (r) => { + if (!r.ok) throw new Error(await r.text()) + return r.json() as Promise + }), ) const jwks = lazy(() => wk() .then((r) => r.jwks_uri) .then(async (uri) => { - const r = await fetch(uri) + const r = await f(uri) if (!r.ok) throw new Error(await r.text()) return createLocalJWKSet((await r.json()) as JSONWebKeySet) }), ) + const verifyIdToken = async (id_token: string) => { + console.log("Verifying ID token with config:", config) + const verifyOptions: any = { + issuer: config.issuer, + } + + // Only include audience validation if audience is specified + if (config.audience) { + verifyOptions.audience = config.audience + } + + return jwtVerify(id_token, await jwks(), verifyOptions) + } + return { type: config.type || "oidc", + issuer: config.issuer, init(routes, ctx) { routes.get("/authorize", async (c) => { const provider: ProviderState = { @@ -163,9 +212,8 @@ export function OidcProvider( const idToken = body.get("id_token") if (!idToken) throw new OauthError("invalid_request", "Missing id_token") - const result = await jwtVerify(idToken.toString(), await jwks(), { - audience: config.clientID, - }) + + const result = await verifyIdToken(idToken.toString()) if (result.payload.nonce !== provider.nonce) { throw new OauthError("invalid_request", "Invalid nonce") } @@ -175,5 +223,6 @@ export function OidcProvider( }) }) }, + verifyIdToken, } } diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts index 369d4649..41998845 100644 --- a/packages/openauth/test/issuer.test.ts +++ b/packages/openauth/test/issuer.test.ts @@ -7,11 +7,13 @@ import { test, } from "bun:test" import { object, string } from "valibot" +import { generateKeyPair, SignJWT, exportJWK } from "jose" import { createClient } from "../src/client.js" import { issuer } from "../src/issuer.js" import { Provider } from "../src/provider/provider.js" import { MemoryStorage } from "../src/storage/memory.js" import { createSubjects } from "../src/subject.js" +import { OidcProvider } from "../src/provider/oidc.js" const subjects = createSubjects({ user: object({ @@ -20,16 +22,58 @@ const subjects = createSubjects({ }) let storage = MemoryStorage() + +const encryptAlgo = "RS256" +// Generate a key pair for testing +const { privateKey, publicKey } = await generateKeyPair(encryptAlgo, { + modulusLength: 2048, +}) + +const mockProvider: OidcProvider = OidcProvider({ + clientID: "https://auth.example.com/token", + issuer: "https://external-issuer.com", + type: "jwt-bearer", + fetch: async (url: string | URL, init?: RequestInit): Promise => { + if ( + url.toString() === + "https://external-issuer.com/.well-known/openid-configuration" + ) { + return new Response( + JSON.stringify({ + issuer: "https://external-issuer.com", + authorization_endpoint: "https://external-issuer.com/authorize", + jwks_uri: "https://external-issuer.com/.well-known/jwks.json", + }), + ) + } + + if ( + url.toString() === "https://external-issuer.com/.well-known/jwks.json" + ) { + const jwk = await exportJWK(publicKey) + const jwks = { + keys: [{ ...jwk, kid: "test-key", use: "sig", alg: encryptAlgo }], + } + return new Response(JSON.stringify(jwks), { + headers: { "Content-Type": "application/json" }, + }) + } + return new Response("Not Found", { status: 404 }) + }, +}) + const issuerConfig = { storage, subjects, allow: async () => true, + oidcProviders: { mockProvider }, ttl: { access: 60, refresh: 6000, refreshReuse: 60, refreshRetention: 6000, }, + providers: { dummy: { type: "dummy", @@ -48,7 +92,7 @@ const issuerConfig = { email: "foo@bar.com", } }, - } satisfies Provider<{ email: string }>, + }, }, success: async (ctx, value) => { if (value.provider === "dummy") { @@ -56,6 +100,12 @@ const issuerConfig = { userID: "123", }) } + if (value.provider === "jwt-bearer") { + // No need to validate issuers here since we're using trustedIssuers config + return ctx.subject("user", { + userID: value.subject, + }) + } throw new Error("Invalid provider: " + value.provider) }, } @@ -167,6 +217,72 @@ describe("client credentials flow", () => { }) }) +describe("jwt-bearer grant type", () => { + test("success", async () => { + // Mock the JWKS endpoint + const client = createClient({ + issuer: "https://external-issuer.com", + clientID: "https://auth.example.com/token", // This should match the 'aud' claim in the JWT + fetch: async ( + url: string | URL, + init?: RequestInit, + ): Promise => { + return auth.request(url, init) + }, + }) + + const now = Math.floor(Date.now() / 1000) + const jwt = await new SignJWT({ + sub: "123", + iss: "https://external-issuer.com", + aud: "https://auth.example.com/token", + exp: now + 60, + provider: "dummy", + email: "foo@bar.com", + }) + .setProtectedHeader({ alg: encryptAlgo, kid: "test-key" }) + .sign(privateKey) + + const result = await client.exchangeJWT(jwt) + if (result.err) throw result.err + const tokens = result.tokens + expect(tokens).toStrictEqual({ + access: expectNonEmptyString, + refresh: expectNonEmptyString, + expiresIn: expect.any(Number), + }) + + const verified = await client.verify(subjects, tokens.access) + if (verified.err) throw verified.err + expect(verified).toStrictEqual({ + aud: "https://auth.example.com/token", + subject: { + type: "user", + properties: { + userID: "123", + }, + }, + }) + }) + + test("failure with invalid assertion", async () => { + const response = await auth.request("https://auth.example.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: "invalid.jwt.token", + }).toString(), + }) + + expect(response.status).toBe(400) + const responseText = await response.text() + expect(responseText).toContain("unknown state") // Expecting error about invalid JWT + }) +}) + describe("refresh token", () => { let tokens: { access: string; refresh: string } let client: ReturnType diff --git a/packages/openauth/tsconfig.json b/packages/openauth/tsconfig.json index b6e6b8c5..0d8538dd 100644 --- a/packages/openauth/tsconfig.json +++ b/packages/openauth/tsconfig.json @@ -4,10 +4,12 @@ "outDir": "dist", "declaration": true, "skipLibCheck": true, + "lib": ["dom", "esnext"], // or ["dom", "es2023"] "module": "NodeNext", "moduleResolution": "NodeNext", "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" + "jsxImportSource": "hono/jsx", + "types": ["bun"] }, "include": ["src"] }