/** * Use this to connect authentication providers that support OAuth 2.0. * * ```ts {5-12} * import { Oauth2Provider } from "@aryalabs/openauth/provider/oauth2" * * export default issuer({ * providers: { * oauth2: Oauth2Provider({ * clientID: "1234567890", * clientSecret: "0987654321", * endpoint: { * authorization: "https://auth.myserver.com/authorize", * token: "https://auth.myserver.com/token" * } * }) * } * }) * ``` * * * @packageDocumentation */ import { createRemoteJWKSet, jwtVerify } from "jose"; import { OauthError } from "../error.js"; import { generatePKCE } from "../pkce.js"; import { getRelativeUrl } from "../util.js"; import type { Provider } from "./provider.js"; export interface Oauth2Config { /** * @internal */ type?: string; /** * The client ID. * * This is just a string to identify your app. * * @example * ```ts * { * clientID: "my-client" * } * ``` */ clientID: string; /** * The client secret. * * This is a private key that's used to authenticate your app. It should be kept secret. * * @example * ```ts * { * clientSecret: "0987654321" * } * ``` */ clientSecret: string; /** * The URLs of the authorization and token endpoints. * * @example * ```ts * { * endpoint: { * authorization: "https://auth.myserver.com/authorize", * token: "https://auth.myserver.com/token", * jwks: "https://auth.myserver.com/auth/keys" * } * } * ``` */ endpoint: { /** * The URL of the authorization endpoint. */ authorization: string; /** * The URL of the token endpoint. */ token: string; /** * The URL of the JWKS endpoint. */ jwks?: string; }; /** * A list of OAuth scopes that you want to request. * * @example * ```ts * { * scopes: ["email", "profile"] * } * ``` */ scopes: string[]; /** * Whether to use PKCE (Proof Key for Code Exchange) for the authorization code flow. * Some providers like x.com require this. * @default false */ pkce?: boolean; /** * Any additional parameters that you want to pass to the authorization endpoint. * @example * ```ts * { * query: { * access_type: "offline", * prompt: "consent" * } * } * ``` */ query?: Record; } /** * @internal */ export type Oauth2WrappedConfig = Omit; /** * @internal */ export interface Oauth2Token { access: string; refresh: string; expiry: number; id?: Record; raw: Record; } interface ProviderState { state: string; redirect: string; codeVerifier?: string; } export function Oauth2Provider(config: Oauth2Config): Provider<{ tokenset: Oauth2Token; clientID: string }> { const query = config.query || {}; // Helper function to handle token exchange and response building async function handleCallbackLogic(c: any, ctx: any, provider: ProviderState, code: string | undefined) { if (!provider || !code) { return c.redirect(getRelativeUrl(c, "./authorize")); } const body = new URLSearchParams({ client_id: config.clientID, client_secret: config.clientSecret, code, grant_type: "authorization_code", redirect_uri: provider.redirect, ...(provider.codeVerifier ? { code_verifier: provider.codeVerifier } : {}), }); const json: any = await fetch(config.endpoint.token, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: body.toString(), }).then((r) => r.json()); if ("error" in json) { throw new OauthError(json.error, json.error_description); } 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, }); idTokenPayload = payload; } return ctx.success(c, { clientID: config.clientID, tokenset: { get access() { return json.access_token; }, get refresh() { return json.refresh_token; }, get expiry() { return json.expires_in; }, get id() { if (!idTokenPayload) return null; return idTokenPayload; }, get raw() { return json; }, }, }); } return { type: config.type || "oauth2", init(routes, ctx) { routes.get("/authorize", async (c) => { const state = crypto.randomUUID(); const pkce = config.pkce ? await generatePKCE() : undefined; await ctx.set(c, "provider", 60 * 10, { state, redirect: getRelativeUrl(c, "./callback"), codeVerifier: pkce?.verifier, }); const authorization = new URL(config.endpoint.authorization); authorization.searchParams.set("client_id", config.clientID); authorization.searchParams.set("redirect_uri", getRelativeUrl(c, "./callback")); authorization.searchParams.set("response_type", "code"); authorization.searchParams.set("state", state); authorization.searchParams.set("scope", config.scopes.join(" ")); if (pkce) { authorization.searchParams.set("code_challenge", pkce.challenge); authorization.searchParams.set("code_challenge_method", pkce.method); } for (const [key, value] of Object.entries(query)) { authorization.searchParams.set(key, value); } return c.redirect(authorization.toString()); }); routes.get("/callback", async (c) => { const provider = (await ctx.get(c, "provider")) as ProviderState; const code = c.req.query("code"); const state = c.req.query("state"); const error = c.req.query("error"); if (error) throw new OauthError(error.toString() as any, c.req.query("error_description")?.toString() || ""); if (!provider || !code || (provider.state && state !== provider.state)) { return c.redirect(getRelativeUrl(c, "./authorize")); } return handleCallbackLogic(c, ctx, provider, code); }); routes.post("/callback", async (c) => { const provider = (await ctx.get(c, "provider")) as ProviderState; // Handle form data from POST request const formData = await c.req.formData(); const code = formData.get("code")?.toString(); const state = formData.get("state")?.toString(); const error = formData.get("error")?.toString(); if (error) throw new OauthError(error as any, formData.get("error_description")?.toString() || ""); if (!provider || !code || (provider.state && state !== provider.state)) { return c.redirect(getRelativeUrl(c, "./authorize")); } return handleCallbackLogic(c, ctx, provider, code); }); }, }; }