import { createHash, randomBytes } from "node:crypto"; import type { Context } from "@yaakapp/api"; import { getRedirectUrlViaExternalBrowser } from "../callbackServer"; import { fetchAccessToken } from "../fetchAccessToken"; import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken"; import type { AccessToken, TokenStoreArgs } from "../store"; import { getDataDirKey, storeToken } from "../store"; import { extractCode } from "../util"; export const PKCE_SHA256 = "S256"; export const PKCE_PLAIN = "plain"; export const DEFAULT_PKCE_METHOD = PKCE_SHA256; export type CallbackType = "localhost" | "hosted"; export interface ExternalBrowserOptions { useExternalBrowser: boolean; callbackType: CallbackType; /** Port for localhost callback (only used when callbackType is 'localhost') */ callbackPort?: number; } export async function getAuthorizationCode( ctx: Context, contextId: string, { authorizationUrl: authorizationUrlRaw, accessTokenUrl, clientId, clientSecret, redirectUri, scope, state, audience, credentialsInBody, pkce, tokenName, externalBrowser, }: { authorizationUrl: string; accessTokenUrl: string; clientId: string; clientSecret: string; redirectUri: string | null; scope: string | null; state: string | null; audience: string | null; credentialsInBody: boolean; pkce: { challengeMethod: string; codeVerifier: string; } | null; tokenName: "access_token" | "id_token"; externalBrowser?: ExternalBrowserOptions; }, ): Promise { const tokenArgs: TokenStoreArgs = { contextId, clientId, accessTokenUrl, authorizationUrl: authorizationUrlRaw, }; const token = await getOrRefreshAccessToken(ctx, tokenArgs, { accessTokenUrl, scope, clientId, clientSecret, credentialsInBody, }); if (token != null) { return token; } let authorizationUrl: URL; try { authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`); } catch { throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`); } authorizationUrl.searchParams.set("response_type", "code"); authorizationUrl.searchParams.set("client_id", clientId); if (scope) authorizationUrl.searchParams.set("scope", scope); if (state) authorizationUrl.searchParams.set("state", state); if (audience) authorizationUrl.searchParams.set("audience", audience); if (pkce) { authorizationUrl.searchParams.set( "code_challenge", pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod), ); authorizationUrl.searchParams.set("code_challenge_method", pkce.challengeMethod); } let code: string; let actualRedirectUri: string | null = redirectUri; // Use external browser flow if enabled if (externalBrowser?.useExternalBrowser) { const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, { callbackType: externalBrowser.callbackType, callbackPort: externalBrowser.callbackPort, }); // Pass null to skip redirect URI matching — the callback came from our own local server const extractedCode = extractCode(result.callbackUrl, null); if (!extractedCode) { throw new Error("No authorization code found in callback URL"); } code = extractedCode; actualRedirectUri = result.redirectUri; } else { // Use embedded browser flow (original behavior) if (redirectUri) { authorizationUrl.searchParams.set("redirect_uri", redirectUri); } code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri); } console.log("[oauth2] Code found"); const response = await fetchAccessToken(ctx, { grantType: "authorization_code", accessTokenUrl, clientId, clientSecret, scope, audience, credentialsInBody, params: [ { name: "code", value: code }, ...(pkce ? [{ name: "code_verifier", value: pkce.codeVerifier }] : []), ...(actualRedirectUri ? [{ name: "redirect_uri", value: actualRedirectUri }] : []), ], }); return storeToken(ctx, tokenArgs, response, tokenName); } /** * Get authorization code using the embedded browser window. * This is the original flow that monitors navigation events. */ async function getCodeViaEmbeddedBrowser( ctx: Context, contextId: string, authorizationUrl: URL, redirectUri: string | null, ): Promise { const dataDirKey = await getDataDirKey(ctx, contextId); const authorizationUrlStr = authorizationUrl.toString(); console.log("[oauth2] Authorizing via embedded browser", authorizationUrlStr); // oxlint-disable-next-line no-async-promise-executor -- Required for this pattern return new Promise(async (resolve, reject) => { let foundCode = false; const { close } = await ctx.window.openUrl({ dataDirKey, url: authorizationUrlStr, label: "oauth-authorization-url", async onClose() { if (!foundCode) { reject(new Error("Authorization window closed")); } }, async onNavigate({ url: urlStr }) { let code: string | null; try { code = extractCode(urlStr, redirectUri); } catch (err) { reject(err); close(); return; } if (!code) { return; } foundCode = true; close(); resolve(code); }, }); }); } export function genPkceCodeVerifier() { return encodeForPkce(randomBytes(32)); } function pkceCodeChallenge(verifier: string, method: string) { if (method === "plain") { return verifier; } const hash = encodeForPkce(createHash("sha256").update(verifier).digest()); return hash .replace(/=/g, "") // Remove padding '=' .replace(/\+/g, "-") // Replace '+' with '-' .replace(/\//g, "_"); // Replace '/' with '_' } function encodeForPkce(bytes: Buffer) { return bytes .toString("base64") .replace(/=/g, "") // Remove padding '=' .replace(/\+/g, "-") // Replace '+' with '-' .replace(/\//g, "_"); // Replace '/' with '_' }