1659 lines · 71.0 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// OAuth 2.0 helpers — generic, isomorphic building blocks.//// Thin wrappers around `oauth4webapi` (stateless; pure Web Crypto +// `fetch`, no deps; runs unchanged in Node, CF Workers, and browsers).// Each public helper is a single `Effect.tryPromise` call that delegates// the RFC work to the library and normalises the failure surface into// `OAuth2Error`.//// What stays hand-rolled://   - `OAuth2Error` — our tagged error; we want a stable shape across//     every token-endpoint call//   - `shouldRefreshToken` — skew check, trivial//   - `buildAuthorizationUrl` — the library doesn't expose a raw//     authorization-URL builder (it prefers PAR); a 30-line manual//     construction keeps the call sync and lets callers opt out of PAR// --------------------------------------------------------------------------- import { Data, Effect, Option, Predicate, Schema } from "effect";import * as oauth from "oauth4webapi"; import type { SubjectTokenType } from "./oauth-client"; // ---------------------------------------------------------------------------// Errors// --------------------------------------------------------------------------- export class OAuth2Error extends Data.TaggedError("OAuth2Error")<{  readonly message: string;  /**   * RFC 6749 §5.2 error code, when the token endpoint returned one   * (`invalid_grant`, `invalid_client`, `unauthorized_client`, ...).   * Callers use this to distinguish terminal failures (a refresh token   * the AS no longer honours → re-auth required) from transient ones.   */  readonly error?: string;  /**   * HTTP status the token endpoint answered with, when this failure came from   * a complete HTTP response at all. Absent for transport failures (DNS, TLS,   * timeout, reset) — which is precisely what separates "the server said no"   * from "we never got an answer", a distinction `error` cannot express   * because the majority of real refusals carry no RFC 6749 §5.2 code.   */  readonly status?: number;  /**   * The library rejection this failure was built from, kept so a transport   * failure stays diagnosable — the chain is what separates a DNS miss from a   * refused connection, and neither is expressible in `status` or `error`.   *   * It is DROPPED on the malformed-HTTP-200 path. There the rejection carries   * the token response oauth4webapi already parsed, so retaining it would hand   * the raw access and refresh tokens to anything that renders the whole   * failure (`Cause.pretty`, `JSON.stringify`) — around the allowlist that   * keeps them out of `message`. Everything that path needs from the body is   * already lifted onto `status`, `error`, and the redacted preview, and   * nothing downstream reads this field, so nothing is lost by omitting it.   *   * Treat whatever is here as INTERNAL diagnostic input, never display   * material: anything RENDERED goes through `redactedBodyPreview` first.   */  readonly cause?: unknown;}> {} /** * The token endpoint answered 2xx and still handed back no usable access token. * Whatever verdict such a body carries is about THIS grant rather than the app * registration: an authorization server that reports a real error inside a * response it called successful is naming a dead credential (GitHub answers a * dead refresh token with HTTP 200 and `{"error":"bad_refresh_token"}`). On a * 4xx the §5.2 code alone decides, so a fleet-wide `invalid_client` is never * mistaken for one user's dead grant. */export const isUnusableSuccessTokenResponse = (error: OAuth2Error): boolean =>  error.status !== undefined && error.status < 300; /** * Did the token endpoint answer in a way that re-sending the identical grant * cannot change? * * Yes for a 4xx — §5.2 mandates 400 for a grant the authorization server will * not honour, 401/403 are refusals, and a token endpoint answering 404 does not * start existing on the next attempt — and yes for a 2xx that carried no usable * token, because the server called it a success and still issued nothing. * * No for a 5xx (the AS is having a bad minute) and no when there is no response * at all (transport). Those are exactly the failures a later attempt survives, * so they must stay retryable. */export const isPermanentTokenRejection = (error: OAuth2Error): boolean =>  isUnusableSuccessTokenResponse(error) ||  (error.status !== undefined && error.status >= 400 && error.status < 500); // ---------------------------------------------------------------------------// Token response shape (RFC 6749 §5.1)// --------------------------------------------------------------------------- export type OAuth2TokenResponse = {  readonly access_token: string;  readonly token_type?: string;  readonly refresh_token?: string;  readonly expires_in?: number;  readonly scope?: string;  readonly idTokenIdentityLabel?: string;}; // ---------------------------------------------------------------------------// Constants// --------------------------------------------------------------------------- /** Refresh tokens this many ms before expiry to avoid mid-request expiration. */export const OAUTH2_REFRESH_SKEW_MS = 60_000; /** Default token-endpoint timeout. */export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; /** RFC 8693 §2.1 token-exchange grant. */export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; /** RFC 7523 §2.1 JWT bearer authorization grant — how an ID-JAG is redeemed *  at the Resource Authorization Server (id-jag draft §4.4). */export const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; /** id-jag draft §4.3 `requested_token_type` / §4.3.4 `issued_token_type`. */export const ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"; /** id-jag draft §4.3.4: an ID-JAG is not an OAuth access token, so the token *  exchange response MUST carry this `token_type` sentinel. */export const ID_JAG_TOKEN_TYPE_SENTINEL = "N_A"; export interface OAuthEndpointUrlPolicy {  readonly allowHttp?: boolean;} export const isLoopbackHttpUrl = (value: string): boolean => {  if (!URL.canParse(value)) return false;  const url = new URL(value);  if (url.protocol !== "http:") return false;  const hostname = url.hostname.toLowerCase();  return (    hostname === "localhost" ||    hostname === "0.0.0.0" ||    hostname === "::1" ||    hostname === "[::1]" ||    hostname.startsWith("127.")  );}; export const isSupportedOAuthEndpointUrl = (  value: string,  policy: OAuthEndpointUrlPolicy = {},): boolean => {  if (!URL.canParse(value)) return false;  const url = new URL(value);  return (    url.protocol === "https:" ||    isLoopbackHttpUrl(value) ||    (url.protocol === "http:" && policy.allowHttp === true)  );}; export const assertSupportedOAuthEndpointUrl = (  value: string,  label = "OAuth endpoint URL",  policy: OAuthEndpointUrlPolicy = {},): string => {  if (isSupportedOAuthEndpointUrl(value, policy)) return value;  // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: synchronous assertion helper used by URL constructors and Effect.try wrappers  throw new TypeError(`${label} must use https: or loopback http:`);}; // ---------------------------------------------------------------------------// PKCE (RFC 7636) — straight delegation to `oauth4webapi`// --------------------------------------------------------------------------- export const createPkceCodeVerifier = (): string => oauth.generateRandomCodeVerifier(); export const createPkceCodeChallenge = (verifier: string): Promise<string> =>  oauth.calculatePKCECodeChallenge(verifier); /** RFC 6749 `state` — an unguessable correlation token minted by `oauth.start` *  and redeemed by `oauth.complete`. */export const createOAuthState = (): string => oauth.generateRandomState(); // ---------------------------------------------------------------------------// Authorization URL builder// --------------------------------------------------------------------------- export type BuildAuthorizationUrlInput = {  readonly authorizationUrl: string;  readonly clientId: string;  readonly redirectUrl: string;  readonly scopes: readonly string[];  readonly state: string;  /** Pre-computed base64url S256 challenge (from `createPkceCodeChallenge`). */  readonly codeChallenge: string;  /** Separator between scopes. RFC 6749 says space; some providers use comma. */  readonly scopeSeparator?: string;  /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource   *  Parameter Implementation" requires clients to send this on every   *  authorization request, regardless of AS support. */  readonly resource?: string;  /** Provider-specific extras (e.g. Google's `access_type=offline`). */  readonly extraParams?: Readonly<Record<string, string>>;  readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;}; /** Build an RFC 6749 §4.1.1 authorization URL. Sync; pre-computed *  challenge lets this stay out of the Promise world. */export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string => {  const url = new URL(    assertSupportedOAuthEndpointUrl(      input.authorizationUrl,      "Authorization URL",      input.endpointUrlPolicy,    ),  );  // Benign default kept by design: a single space is the RFC 6749 scope  // separator. Callers targeting a legacy comma-separated provider pass  // `scopeSeparator` explicitly (see the field's JSDoc).  const separator = input.scopeSeparator ?? " ";  url.searchParams.set("client_id", input.clientId);  url.searchParams.set("redirect_uri", input.redirectUrl);  url.searchParams.set("response_type", "code");  if (input.scopes.length > 0) {    url.searchParams.set("scope", input.scopes.join(separator));  }  url.searchParams.set("state", input.state);  url.searchParams.set("code_challenge_method", "S256");  url.searchParams.set("code_challenge", input.codeChallenge);  if (input.resource) {    url.searchParams.set("resource", input.resource);  }  if (input.extraParams) {    for (const [k, v] of Object.entries(input.extraParams)) {      url.searchParams.set(k, v);    }  }  return url.toString();}; /** Provider-specific authorize-URL extras that are NOT RFC 6749 params, so the *  generic flow must add them per-provider (keyed off the authorization host). * *  Google: `access_type=offline` + `prompt=consent` are required to receive (and *  keep receiving, across reconnects / scope changes) a REFRESH TOKEN — without *  them Google issues an access-token-only grant that dies in ~1h and a *  re-consent can silently keep the old scope set. Do not add *  `include_granted_scopes=true` here: with historical grants on the same Google *  consent app, Google folds those unrelated scopes into the new consent flow and *  can fail inside accounts.google.com before returning to our callback. */export const providerAuthorizeExtras = (  authorizationUrl: string,): Readonly<Record<string, string>> => {  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input → no provider extras  try {    const host = new URL(authorizationUrl).host.toLowerCase();    if (host === "accounts.google.com") {      return { access_type: "offline", prompt: "consent" };    }  } catch {    // Unparseable authorization URL — let buildAuthorizationUrl surface the error.  }  return {};}; // ---------------------------------------------------------------------------// Regional token-endpoint rebind//// Some authorization servers publish a single static metadata document that// advertises one region's token endpoint, but issue authorization codes that// are only redeemable at the *regional* host the user's org actually lives on.// The region comes back on the callback as a non-standard `domain` (or `site`)// query param: Datadog returns `domain=us5.datadoghq.com` while its metadata// statically advertises `app.datadoghq.com`. Redeeming the code at the// advertised host then fails with `invalid_grant`.//// `rebindTokenEndpointHostToCallbackDomain` swaps ONLY the hostname of the// configured token URL to the callback-supplied host, and ONLY when that host// is a sibling subdomain of the configured one (same parent after stripping the// leftmost DNS label, e.g. `app.datadoghq.com` and `us5.datadoghq.com` both// reduce to `datadoghq.com`). The token request carries the client secret, the// code, and the PKCE verifier, so an attacker-influenced `domain` must never be// able to point it at an arbitrary origin. Anything that fails the sibling// check, fails to parse, or isn't https falls back to the configured URL// unchanged.// --------------------------------------------------------------------------- const hostnameFromCallbackDomain = (callbackDomain: string): string | undefined => {  const trimmed = callbackDomain.trim();  if (trimmed.length === 0) return undefined;  // Datadog sends `domain` as a bare host and `site` as a full origin; accept  // either by tolerating an optional scheme, then taking only the hostname.  const candidate = trimmed.includes("://") ? trimmed : `https://${trimmed}`;  if (!URL.canParse(candidate)) return undefined;  const url = new URL(candidate);  // A legitimate regional host carries no port, credentials, or path.  if (url.port !== "" || url.username !== "" || url.password !== "") return undefined;  if (url.pathname !== "/" && url.pathname !== "") return undefined;  return url.hostname.toLowerCase();}; /** Parent domain after stripping the leftmost DNS label, or `undefined` when *  the host has no sibling space (a single label, or a parent that is a bare *  TLD). `app.datadoghq.com` -> `datadoghq.com`; `foo.com` -> undefined. */const siblingParentDomainOf = (hostname: string): string | undefined => {  const labels = hostname.split(".");  if (labels.length < 3) return undefined;  const parent = labels.slice(1).join(".");  // Require the parent to itself be multi-label so a 2-label configured host  // can never rebind across an entire TLD (e.g. foo.com -> bar.com).  return parent.includes(".") ? parent : undefined;}; export const rebindTokenEndpointHostToCallbackDomain = (  configuredTokenUrl: string,  callbackDomain: string | null | undefined,): string => {  if (!callbackDomain) return configuredTokenUrl;  if (!URL.canParse(configuredTokenUrl)) return configuredTokenUrl;  const configured = new URL(configuredTokenUrl);  if (configured.protocol !== "https:") return configuredTokenUrl;  const targetHost = hostnameFromCallbackDomain(callbackDomain);  if (!targetHost) return configuredTokenUrl;  const configuredHost = configured.hostname.toLowerCase();  if (targetHost === configuredHost) return configuredTokenUrl;  const configuredParent = siblingParentDomainOf(configuredHost);  const targetParent = siblingParentDomainOf(targetHost);  if (!configuredParent || !targetParent || configuredParent !== targetParent) {    return configuredTokenUrl;  }  const rebound = new URL(configuredTokenUrl);  rebound.hostname = targetHost;  return rebound.toString();}; // ---------------------------------------------------------------------------// Error mapping — `oauth4webapi`'s `process*Response` failure shapes are// either a WWW-Authenticate challenge or an RFC 6749 §5.2 error body,// both exposed via `.error` / `.error_description`. Probing the envelope// preserves RFC 6749 error-code semantics (e.g., mapping `invalid_grant`// to reauth-required) across wrappers.// --------------------------------------------------------------------------- const isOAuth2Error = Predicate.isTagged("OAuth2Error") as (cause: unknown) => cause is OAuth2Error; const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => {  if (cause instanceof Response) return cause;  if (typeof cause !== "object" || cause === null) return undefined;  const envelope = cause as {    readonly cause?: unknown;    readonly response?: unknown;  };  if (envelope.response instanceof Response) return envelope.response;  if (envelope.cause instanceof Response) return envelope.cause;  return undefined;}; /** oauth4webapi's OTHER failure shape: when a response it already accepted as *  successful turns out not to describe a token, it throws with the ALREADY *  PARSED body as `cause.cause.body` and attaches no `Response` at all *  (`assertString(json.access_token, …, { body: json })`). Without this probe *  that whole class is invisible — no status, no body, no verdict — which is *  how a GitHub-style `HTTP 200 {"error":"bad_refresh_token"}` reached *  classification as an unreadable parse failure. */const parsedBodyFromOAuthErrorCause = (cause: unknown): unknown => {  if (typeof cause !== "object" || cause === null) return undefined;  const inner = (cause as { readonly cause?: unknown }).cause;  if (typeof inner !== "object" || inner === null || inner instanceof Response) return undefined;  return (inner as { readonly body?: unknown }).body;}; /** The status such a parsed-body failure came from. oauth4webapi only reaches *  the body asserts AFTER `checkOAuthBodyError` confirmed the exact expected *  status, which at the token endpoint is 200 — so the status is known even *  though the Response itself never made it into the error. */const PARSED_BODY_CAUSE_STATUS = 200; /** RFC 6749 §5.2's own error fields — the names whose STRING value is safe to *  show wherever they appear. Everything here describes a failure; none of it *  is credential material. */export const PREVIEWABLE_BODY_FIELDS = new Set([  "error",  "errors",  "error_description",  "error_uri",]); /** Safe only INSIDE one of the fields above. * *  Providers wrap the real error in an envelope — `{"error":{"code":…, *  "message":…}}` — so these have to be readable there. They must NOT be *  readable at the top level: `code` in particular is the RFC 6749 *  authorization code, which is credential material, and the form-encoded scrub *  in this same file has always redacted `code=` for exactly that reason. */export const PREVIEWABLE_WITHIN_ERROR_FIELDS = new Set(["code", "message", "detail"]); /** Deepest body this walker will descend. A token endpoint's error body is a *  handful of levels; anything past this is not something an operator was going *  to read anyway. The bound exists because the walk is recursive and this runs *  on a failure path: without it a pathologically nested body turns a leak into *  an uncontained stack overflow, which is a worse bug than the one being fixed. */const MAX_PREVIEW_DEPTH = 32; const isPreviewableKey = (key: string, insideError: boolean): boolean => {  const name = key.toLowerCase();  return (    PREVIEWABLE_BODY_FIELDS.has(name) || (insideError && PREVIEWABLE_WITHIN_ERROR_FIELDS.has(name))  );}; const redactJsonValues = (value: unknown, keyIsPreviewable = false, depth = 0): unknown => {  if (depth > MAX_PREVIEW_DEPTH) return "[redacted]";  if (typeof value === "string") return keyIsPreviewable ? value : "[redacted]";  if (Array.isArray(value)) {    return value.map((item) => redactJsonValues(item, keyIsPreviewable, depth + 1));  }  if (typeof value === "object" && value !== null) {    return Object.fromEntries(      Object.entries(value).map(([key, item]) => [        key,        redactJsonValues(item, isPreviewableKey(key, keyIsPreviewable), depth + 1),      ]),    );  }  return value;}; /** Redact a token-endpoint body for display. * *  ALLOWLIST, deliberately. This used to name the four fields to hide, which *  silently trusted every field it had not thought of: a provider that returns *  its token under any other key — or that echoes a submitted secret back *  inside an arbitrary error field — walked straight through. That is not *  hypothetical on the malformed-200 path, where the body the library rejected *  IS a successful token response. This preview is not just a log line; it *  reaches persisted connection health and the caller, so an unknown field is *  exactly the case that must fail closed. * *  Structure is preserved rather than dropped: every key stays visible and only *  non-allowlisted STRING values become `[redacted]`, so an operator can still *  see the shape of what the server sent — and so the dead-grant classifier's *  own evidence stays legible in the message it produced it from. Non-strings *  are left alone; a number or boolean cannot carry a token. * *  This governs only what is RENDERED. The classifier reads the parsed body *  through `cause`, unredacted, and is unaffected. */const redactTokenEndpointBody = (body: string): string => {  // A JSON body is the token-endpoint shape, so it gets the structural  // allowlist above. Anything else (an HTML error page, a plain-text 404) is  // not a token response; keep the legacy name-based scrub so those stay  // readable, which is the only thing that made them useful to begin with.  const json: unknown = (() => {    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing an untrusted upstream body for display; a parse failure just means "not a JSON token response"    try {      // oxlint-disable-next-line executor/no-json-parse -- boundary: same untrusted-body probe; the value is only re-serialised for a redacted preview, never decoded into domain types      return JSON.parse(body) as unknown;    } catch {      return undefined;    }  })();  // Anything that parsed as JSON goes through the walker, not just an object.  // A body that is a bare JSON string is still a body the server chose to send,  // and gating on `object` let exactly that case fall through to the name-based  // scrub below — which cannot match a value that has no field name.  if (json !== undefined) {    return JSON.stringify(redactJsonValues(json));  }  // A form-encoded body is the OTHER shape a token endpoint answers in, and it  // gets the same allowlist. It used to fall through to a name-based scrub,  // which meant a server returning its token as `session_token=…` — any name  // the scrub had not enumerated — rendered it verbatim into a message that is  // persisted onto the connection.  if (isFormEncoded(body)) {    const params = new URLSearchParams(body);    return [...params]      .map(([key, value]) => `${key}=${isPreviewableKey(key, false) ? value : "[redacted]"}`)      .join("&");  }  // Neither shape: an HTML error page or a plain-text status line. There is no  // field structure to reason about, so keep it readable — that legibility is  // the only reason the preview earns its place for these responses — but still  // scrub the named credentials, since such a page can echo a submitted one.  return body    .replaceAll(      /("(?:access_token|refresh_token|id_token|client_secret)"\s*:\s*")[^"]*(")/gi,      "$1[redacted]$2",    )    .replaceAll(      /((?:access_token|refresh_token|id_token|client_secret|code)=)[^&\s]*/gi,      "$1[redacted]",    );}; /** `a=b&c=d` — no whitespace, at least one `key=`. Deliberately strict: a prose *  body like `route not found` must NOT be mistaken for one field. */const isFormEncoded = (body: string): boolean =>  /^[^=&\s]+=[^&\s]*(?:&[^=&\s]+=[^&\s]*)*$/.test(body); const tokenEndpointHttpSummary = async (response: Response): Promise<string> => {  const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;

Lines 1–500 of 1659.