/** * google-oauth-loopback.ts, the OAuth 2.0 authorization-code + PKCE flow used * by the Path B ("oauth") Google setup, driven through a local loopback * redirect (the Desktop app client type; see google-setup-plan.ts). * * Two of Google's own rules shape this file directly (see the header comment * in google-setup-plan.ts for sources): the authorization URL must carry both * `access_type=offline` and `prompt=consent`, or Google will not reliably * hand back a refresh token, without a refresh token the seven-day Testing * expiry (also documented there) has no automatic renewal path at all. * * Every function returns a typed result and never throws for an expected * failure. No token, refresh token, client secret, authorization code, or * PKCE verifier ever appears in a returned message, a thrown error, or a log * line, `redactSecretsFromMessage` is the one place that scrubs incidental * leakage (for example a raw network-error message that happened to echo * back part of a request). */ export interface AuthorizationUrlOptions { readonly clientId: string; readonly redirectUri: string; readonly scopes: readonly string[]; readonly codeChallenge: string; readonly state: string; /** * The Google address this consent is meant to be granted by. * * Sent as `login_hint`, which pre-selects that account on Google's picker. * This is the cheapest available defense against the most expensive mistake * in the whole flow: approving the consent screen as a personal account by * reflex, which mints a perfectly valid refresh token belonging to the wrong * identity. Nothing then fails until a real call is made, and the error at * that point says nothing about accounts. See grant-diagnosis.ts. * * A hint, not a constraint, Google still lets the person switch accounts, * which is correct, because only they know which one they meant. */ readonly loginHint?: string; } /** * Builds the Google authorization URL for the loopback flow. Always includes * `access_type=offline` and `prompt=consent`, both are required for Google * to reliably issue a refresh token on this and every later authorization. */ export declare function buildAuthorizationUrl(options: AuthorizationUrlOptions): string; export interface PkcePair { readonly codeVerifier: string; readonly codeChallenge: string; } /** Generates an RFC 7636 S256 code-verifier/code-challenge pair. */ export declare function generatePkcePair(): PkcePair; export interface StartLoopbackListenerOptions { /** * The `state` value this run generated. A redirect whose `state` does not * match is rejected rather than accepted, this is the CSRF defense for the * flow and it is enforced here, at the point the redirect is received. */ readonly expectedState: string; /** Ephemeral port when omitted (0 asks the OS to pick a free one). */ readonly port?: number; /** Defaults to 127.0.0.1; loopback flows must never bind a public interface. */ readonly host?: string; } export interface LoopbackCodeResult { readonly code: string; readonly state: string; } export interface LoopbackListener { readonly redirectUri: string; /** Resolves with the captured code, or rejects on error/mismatch/timeout. */ waitForCode(timeoutMs: number): Promise; close(): void; } /** * How a product opens the local redirect target. * * Binding a port is real machine I/O, so it is injected rather than performed * here: a test hands back a listener whose `waitForCode` resolves from a * fixture, and the whole consent exchange runs with no socket. The shipped * bun/node implementation is `startLoopbackListener` in this module's `node` * entry, and it is the only place a port is actually bound. */ export type GoogleLoopbackListenerFactory = (options: StartLoopbackListenerOptions) => LoopbackListener; /** * The page bodies the redirect target answers with. * * They live here, beside the flow, rather than in the node adapter: what the * person sees in the browser tab at the end of a consent is part of the * product's behaviour, and every implementation of the listener should show * the same two pages. */ export declare function renderLoopbackSuccessPage(): string; /** See `renderLoopbackSuccessPage`. `message` is escaped before it is embedded. */ export declare function renderLoopbackErrorPage(message: string): string; /** * Classifies one redirect hit on the loopback listener. * * Pulled out of the listener so the decision, is this the redirect we * generated, does it carry a code, did Google report an error, is testable * without binding a port, and so every listener implementation makes the same * decision. The `state` check is the CSRF defense for this flow: a redirect * whose `state` does not match the value this run generated is rejected, never * accepted and never searched for a better answer. */ export type LoopbackRedirectOutcome = { readonly kind: 'code'; readonly result: LoopbackCodeResult; readonly body: string; readonly status: 200; } | { readonly kind: 'error'; readonly error: Error; readonly body: string; readonly status: 400; }; export declare function classifyLoopbackRedirect(requestUrl: string, expectedState: string): LoopbackRedirectOutcome; /** The narrow fetch surface the token calls need, injected so they are testable. */ export interface GoogleFetchPort { fetch(url: string, init: RequestInit): Promise; } export interface TokenResponseOk { readonly ok: true; readonly accessToken: string; readonly refreshToken?: string; readonly expiresInSeconds: number; readonly scope: string; readonly tokenType: string; } export interface TokenResponseFailed { readonly ok: false; readonly problem: string; readonly fix: string; } export type TokenResponse = TokenResponseOk | TokenResponseFailed; export interface ExchangeCodeOptions { readonly clientId: string; readonly clientSecret: string; readonly code: string; readonly codeVerifier: string; readonly redirectUri: string; } /** Exchanges an authorization code (+ PKCE verifier) for tokens. */ export declare function exchangeCodeForTokens(options: ExchangeCodeOptions, fetchPort: GoogleFetchPort): Promise; export interface RefreshTokenOptions { readonly clientId: string; readonly clientSecret: string; readonly refreshToken: string; } /** Exchanges a refresh token for a fresh access token. */ export declare function refreshAccessToken(options: RefreshTokenOptions, fetchPort: GoogleFetchPort): Promise; /** * Scrubs any of `secrets` out of `message`, replacing each occurrence with * `[redacted]`. Used as a last line of defense on incidental strings (for * example a network-error message) that might otherwise echo a secret value * back into a returned problem string or a log line. Ignores blank or very * short candidate values so it never redacts on an empty string. */ export declare function redactSecretsFromMessage(message: string, secrets: readonly (string | undefined)[]): string; //# sourceMappingURL=oauth-loopback.d.ts.map