/** * Use the OpenAuth client kick off your OAuth flows, exchange tokens, refresh tokens, * and verify tokens. * * First, create a client. * * ```ts title="client.ts" * import { createClient } from "@openauthjs/openauth/client" * * const client = createClient({ * clientID: "my-client", * issuer: "https://auth.myserver.com" * }) * ``` * * Kick off the OAuth flow by calling `authorize`. * * ```ts * const redirect_uri = "https://myserver.com/callback" * * const { url } = await client.authorize( * redirect_uri, * "code" * ) * ``` * * When the user completes the flow, `exchange` the code for tokens. * * ```ts * const tokens = await client.exchange(query.get("code"), redirect_uri) * ``` * * And `verify` the tokens. * * ```ts * const verified = await client.verify(subjects, tokens.access) * ``` * * @packageDocumentation */ import { createLocalJWKSet, errors, JSONWebKeySet, jwtVerify, decodeJwt, } from "jose" import { SubjectSchema } from "./subject.js" import type { v1 } from "@standard-schema/spec" import { InvalidAccessTokenError, InvalidAuthorizationCodeError, InvalidRefreshTokenError, InvalidSubjectError, } from "./error.js" import { generatePKCE } from "./pkce.js" /** * The well-known information for an OAuth 2.0 authorization server. * @internal */ export interface WellKnown { /** * The URI to the JWKS endpoint. */ jwks_uri: string /** * The URI to the token endpoint. */ token_endpoint: string /** * The URI to the authorization endpoint. */ authorization_endpoint: string } /** * The tokens returned by the auth server. */ export interface Tokens { /** * The access token. */ access: string /** * The refresh token. */ refresh: string /** * The number of seconds until the access token expires. */ expiresIn: number } interface ResponseLike { json(): Promise ok: Response["ok"] } type FetchLike = (...args: any[]) => Promise /** * The challenge that you can use to verify the code. */ export type Challenge = { /** * The state that was sent to the redirect URI. */ state: string /** * The verifier that was sent to the redirect URI. */ verifier?: string } /** * Configure the client. */ export interface ClientInput { /** * The client ID. This is just a string to identify your app. * * If you have a web app and a mobile app, you want to use different client IDs both. * * @example * ```ts * { * clientID: "my-client" * } * ``` */ clientID: string /** * The URL of your OpenAuth server. * * @example * ```ts * { * issuer: "https://auth.myserver.com" * } * ``` */ issuer?: string /** * 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 } export interface AuthorizeOptions { /** * Enable the PKCE flow. This is for SPA apps. * * ```ts * { * pkce: true * } * ``` * * @default false */ pkce?: boolean /** * The provider you want to use for the OAuth flow. * * ```ts * { * provider: "google" * } * ``` * * If no provider is specified, the user is directed to a page where they can select from the * list of configured providers. * * If there's only one provider configured, the user will be redirected to that. */ provider?: string } export interface AuthorizeResult { /** * The challenge that you can use to verify the code. This is for the PKCE flow for SPA apps. * * This is an object that you _stringify_ and store it in session storage. * * ```ts * sessionStorage.setItem("challenge", JSON.stringify(challenge)) * ``` */ challenge: Challenge /** * The URL to redirect the user to. This starts the OAuth flow. * * For example, for SPA apps. * * ```ts * location.href = url * ``` */ url: string } /** * Returned when the exchange is successful. */ export interface ExchangeSuccess { /** * This is always `false` when the exchange is successful. */ err: false /** * The access and refresh tokens. */ tokens: Tokens } /** * Returned when the exchange fails. */ export interface ExchangeError { /** * The type of error that occurred. You can handle this by checking the type. * * @example * ```ts * import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error" * * console.log(err instanceof InvalidAuthorizationCodeError) *``` */ err: InvalidAuthorizationCodeError } export interface RefreshOptions { /** * Optionally, pass in the access token. */ access?: string } /** * Returned when the refresh is successful. */ export interface RefreshSuccess { /** * This is always `false` when the refresh is successful. */ err: false /** * Returns the refreshed tokens only if they've been refreshed. * * If they are still valid, this will be `undefined`. */ tokens?: Tokens } /** * Returned when the refresh fails. */ export interface RefreshError { /** * The type of error that occurred. You can handle this by checking the type. * * @example * ```ts * import { InvalidRefreshTokenError } from "@openauthjs/openauth/error" * * console.log(err instanceof InvalidRefreshTokenError) *``` */ err: InvalidRefreshTokenError | InvalidAccessTokenError } export interface VerifyOptions { /** * Optionally, pass in the refresh token. * * If passed in, this will automatically refresh the access token if it has expired. */ refresh?: string /** * @internal */ issuer?: string /** * @internal */ audience?: string /** * 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 } export interface VerifyResult { /** * This is always `undefined` when the verify is successful. */ err?: undefined /** * Returns the refreshed tokens only if they’ve been refreshed. * * If they are still valid, this will be undefined. */ tokens?: Tokens /** * @internal */ aud: string /** * The decoded subjects from the access token. * * Has the same shape as the subjects you defined when creating the issuer. */ subject: { [type in keyof T]: { type: type; properties: v1.InferOutput } }[keyof T] } /** * Returned when the verify call fails. */ export interface VerifyError { /** * The type of error that occurred. You can handle this by checking the type. * * @example * ```ts * import { InvalidRefreshTokenError } from "@openauthjs/openauth/error" * * console.log(err instanceof InvalidRefreshTokenError) *``` */ err: InvalidRefreshTokenError | InvalidAccessTokenError } /** * An instance of the OpenAuth client contains the following methods. */ export interface Client { /** * Start the autorization flow. For example, in SSR sites. * * ```ts * const { url } = await client.authorize(, "code") * ``` * * This takes a redirect URI and the type of flow you want to use. The redirect URI is the * location where the user will be redirected to after the flow is complete. * * Supports both the _code_ and _token_ flows. We recommend using the _code_ flow as it's more * secure. * * :::tip * This returns a URL to redirect the user to. This starts the OAuth flow. * ::: * * This returns a URL to the auth server. You can redirect the user to the URL to start the * OAuth flow. * * For SPA apps, we recommend using the PKCE flow. * * ```ts {4} * const { challenge, url } = await client.authorize( * , * "code", * { pkce: true } * ) * ``` * * This returns a redirect URL and a challenge that you need to use later to verify the code. */ authorize( redirectURI: string, response: "code" | "token", opts?: AuthorizeOptions, ): Promise /** * Exchange the code for access and refresh tokens. * * ```ts * const exchanged = await client.exchange(, ) * ``` * * 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( * , * , * * ) * ``` * * 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 * ``` */ exchange( code: string, redirectURI: string, verifier?: 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. * * ```ts * const next = await client.refresh() * ``` * * Can optionally take the access token as well. If passed in, this will skip the refresh * if the access token is still valid. * * ```ts * const next = await client.refresh(, { access: }) * ``` * * This returns the refreshed tokens only if they've been refreshed. * * ```ts * if (!next.err) { * // tokens are still valid * } * if (next.tokens) { * const { access, refresh } = next.tokens * } * ``` * * Or if it fails, it returns an error that you can handle depending on the error. * * ```ts * import { InvalidRefreshTokenError } from "@openauthjs/openauth/error" * * if (next.err) { * if (next.err instanceof InvalidRefreshTokenError) { * // handle invalid refresh token error * } * else { * // handle other errors * } * } * ``` */ refresh( refresh: string, opts?: RefreshOptions, ): Promise /** * Verify the token in the incoming request. * * This is typically used for SSR sites where the token is stored in an HTTP only cookie. And * is passed to the server on every request. * * ```ts * const verified = await client.verify(, ) * ``` * * This takes the subjects that you had previously defined when creating the issuer. * * :::tip * If the refresh token is passed in, it'll automatically refresh the access token. * ::: * * This can optionally take the refresh token as well. If passed in, it'll automatically * refresh the access token if it has expired. * * ```ts * const verified = await client.verify(, , { refresh: }) * ``` * * This returns the decoded subjects from the access token. And the tokens if they've been * refreshed. * * ```ts * // based on the subjects you defined earlier * console.log(verified.subject.properties.userID) * * if (verified.tokens) { * const { access, refresh } = verified.tokens * } * ``` * * Or if it fails, it returns an error that you can handle depending on the error. * * ```ts * import { InvalidRefreshTokenError } from "@openauthjs/openauth/error" * * if (verified.err) { * if (verified.err instanceof InvalidRefreshTokenError) { * // handle invalid refresh token error * } * else { * // handle other errors * } * } * ``` */ verify( subjects: T, token: string, options?: VerifyOptions, ): Promise | VerifyError> } /** * Create an OpenAuth client. * * @param input - Configure the client. */ export function createClient(input: ClientInput): Client { const jwksCache = new Map>() const issuerCache = new Map() const issuer = input.issuer || process.env.OPENAUTH_ISSUER if (!issuer) throw new Error("No issuer") const f = input.fetch ?? fetch async function getIssuer() { const cached = issuerCache.get(issuer!) if (cached) return cached const wellKnown = (await (f || fetch)( `${issuer}/.well-known/oauth-authorization-server`, ).then((r) => r.json())) as WellKnown issuerCache.set(issuer!, wellKnown) return wellKnown } async function getJWKS() { const wk = await getIssuer() const cached = jwksCache.get(issuer!) if (cached) return cached const keyset = (await (f || fetch)(wk.jwks_uri).then((r) => r.json(), )) as JSONWebKeySet const result = createLocalJWKSet(keyset) jwksCache.set(issuer!, result) return result } const result = { async authorize( redirectURI: string, response: "code" | "token", opts?: AuthorizeOptions, ) { const result = new URL(issuer + "/authorize") const challenge: Challenge = { state: crypto.randomUUID(), } result.searchParams.set("client_id", input.clientID) result.searchParams.set("redirect_uri", redirectURI) result.searchParams.set("response_type", response) result.searchParams.set("state", challenge.state) if (opts?.provider) result.searchParams.set("provider", opts.provider) if (opts?.pkce && response === "code") { const pkce = await generatePKCE() result.searchParams.set("code_challenge_method", "S256") result.searchParams.set("code_challenge", pkce.challenge) challenge.verifier = pkce.verifier } return { challenge, url: result.toString(), } }, /** * @deprecated use `authorize` instead, it will do pkce by default unless disabled with `opts.pkce = false` */ async pkce( redirectURI: string, opts?: { provider?: string }, ) { const result = new URL(issuer + "/authorize") if (opts?.provider) result.searchParams.set("provider", opts.provider) result.searchParams.set("client_id", input.clientID) result.searchParams.set("redirect_uri", redirectURI) result.searchParams.set("response_type", "code") const pkce = await generatePKCE() result.searchParams.set("code_challenge_method", "S256") result.searchParams.set("code_challenge", pkce.challenge) return [pkce.verifier, result.toString()] }, async exchange( code: string, redirectURI: string, verifier?: string, ): Promise { const tokens = await f(issuer + "/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ code, redirect_uri: redirectURI, grant_type: "authorization_code", client_id: input.clientID, code_verifier: verifier || "", }).toString(), }) const json = (await tokens.json()) as any if (!tokens.ok) { return { err: new InvalidAuthorizationCodeError(), } } 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, ): Promise { if (opts && opts.access) { const decoded = decodeJwt(opts.access) if (!decoded) { return { err: new InvalidAccessTokenError(), } } // allow 30s window for expiration if ((decoded.exp || 0) > Date.now() / 1000 + 30) { return { err: false, } } } const tokens = await f(issuer + "/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh, }).toString(), }) const json = (await tokens.json()) as any if (!tokens.ok) { return { err: new InvalidRefreshTokenError(), } } return { err: false, tokens: { access: json.access_token as string, refresh: json.refresh_token as string, expiresIn: json.expires_in as number, }, } }, async verify( subjects: T, token: string, options?: VerifyOptions, ): Promise | VerifyError> { const jwks = await getJWKS() try { const result = await jwtVerify<{ mode: "access" type: keyof T properties: v1.InferInput }>(token, jwks, { issuer, }) const validated = await subjects[result.payload.type][ "~standard" ].validate(result.payload.properties) if (!validated.issues && result.payload.mode === "access") return { aud: result.payload.aud as string, subject: { type: result.payload.type, properties: validated.value, } as any, } return { err: new InvalidSubjectError(), } } catch (e) { if (e instanceof errors.JWTExpired && options?.refresh) { const refreshed = await this.refresh(options.refresh) if (refreshed.err) return refreshed const verified = await result.verify( subjects, refreshed.tokens!.access, { refresh: refreshed.tokens!.refresh, issuer, fetch: options?.fetch, }, ) if (verified.err) return verified verified.tokens = refreshed.tokens return verified } return { err: new InvalidAccessTokenError(), } } }, } return result }