/** * Use this to connect authentication providers that support OIDC. * * ```ts {5-8} * import { OidcProvider } from "@openauthjs/openauth/provider/oidc" * * export default issuer({ * providers: { * oauth2: OidcProvider({ * clientId: "1234567890", * issuer: "https://auth.myserver.com" * }) * } * }) * ``` * * * @packageDocumentation */ import { JWTPayload } from 'hono/utils/jwt/types'; import { createLocalJWKSet, JSONWebKeySet, jwtVerify } from 'jose'; import { WellKnown } from '../client.js'; import { OauthError } from '../error.js'; import { getRelativeUrl, lazy } from '../util.js'; import { Provider } from './provider.js'; export interface OidcConfig { /** * @internal */ type?: string; /** * The client ID. * * This is just a string to identify your app. * * @example * ```ts * { * clientID: "my-client" * } * ``` */ clientID: string; /** * The URL of your authorization server. * * @example * ```ts * { * issuer: "https://auth.myserver.com" * } * ``` */ issuer: string; /** * A list of OIDC scopes that you want to request. * * @example * ```ts * { * scopes: ["openid", "profile", "email"] * } * ``` */ scopes?: string[]; /** * Any additional parameters that you want to pass to the authorization endpoint. * @example * ```ts * { * query: { * prompt: "consent" * } * } * ``` */ query?: Record; } /** * @internal */ export type OidcWrappedConfig = Omit; interface ProviderState { state: string; nonce: string; redirect: string; } /** * @internal */ export interface IdTokenResponse { idToken: string; claims: Record; raw: Record; } export function OidcProvider(config: OidcConfig): Provider<{ id: JWTPayload; clientID: string }> { const query = config.query || {}; const scopes = config.scopes || []; 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; }) ); const jwks = lazy(() => wk() .then((r) => r.jwks_uri) .then(async (uri) => { const r = await fetch(uri); if (!r.ok) throw new Error(await r.text()); return createLocalJWKSet((await r.json()) as JSONWebKeySet); }) ); return { type: config.type || 'oidc', init(routes, ctx) { routes.get('/authorize', async (c) => { const provider: ProviderState = { state: crypto.randomUUID(), nonce: crypto.randomUUID(), redirect: getRelativeUrl(c, './callback') }; await ctx.set(c, 'provider', 60 * 10, provider); const authorization = new URL(await wk().then((r) => r.authorization_endpoint)); authorization.searchParams.set('client_id', config.clientID); authorization.searchParams.set('response_type', 'id_token'); authorization.searchParams.set('response_mode', 'form_post'); authorization.searchParams.set('state', provider.state); authorization.searchParams.set('nonce', provider.nonce); authorization.searchParams.set('redirect_uri', provider.redirect); authorization.searchParams.set('scope', ['openid', ...scopes].join(' ')); for (const [key, value] of Object.entries(query)) { authorization.searchParams.set(key, value); } return c.redirect(authorization.toString()); }); routes.post('/callback', async (c) => { const provider = await ctx.get(c, 'provider'); if (!provider) return c.redirect(getRelativeUrl(c, './authorize')); const body = await c.req.formData(); const error = body.get('error'); if (error) throw new OauthError( error.toString() as any, body.get('error_description')?.toString() || '' ); 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 }); if (result.payload.nonce !== provider.nonce) { throw new OauthError('invalid_request', 'Invalid nonce'); } return ctx.success(c, { id: result.payload, clientID: config.clientID }); }); } }; }