From 2c9373d372cc64d140efffbe55c7b5b5412ebf78 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 26 Jul 2026 00:36:11 -0700 Subject: [PATCH 1/6] feat(auth): resolve provider callbacks and identities (#1120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add CP-owned admission policy with existing OR semantics, deny-by-default behavior, complete verified-email evaluation, and same-flow GitHub organization membership checks - resolve browser identities only by canonical issuer plus exact immutable provider subject, with provenance-bearing verified-email reservations and explicit account-link-required collisions - orchestrate consumed provider callbacks through provider-specific callback handlers, admission, identity resolution, credential capture, and exact client-bound authorization-code issuance - separate browser sign-in policy from persistence: the resolver owns evidence, collision, and retry policy while the DB store owns row decoding and atomic multi-table writes - add a GitHub App permission preflight that verifies both registration and installation permissions before cutover ## Security properties - revalidates the persisted client and exact redirect URI after single-use state consumption and before provider exchange - isolates provider-specific callback mechanics (including Google's OIDC nonce binding) behind an exhaustive provider-handler registry; adding Okta requires an explicit handler instead of falling through to another provider - keeps Google credentials out of storage and uses the maintained provider adapters from #1118 for OAuth and OIDC protocol validation - preserves provider subjects exactly; identity remains the case-sensitive `(issuer, subject)` tuple - never reparents an established immutable subject based on email - creates each new user, issuer-qualified identity, verified-email claims, and GitHub provider credential in one atomic D1 batch - refreshes existing identity metadata, claims, and versioned provider credentials in one retryable D1 batch - bounds verified-email evidence and uses JSON-table statements so a valid GitHub result cannot exceed D1's 100-bind limit or require one statement per email - retries uniqueness races by re-reading issuer-subject and claim ownership; it never falls back to an email owner - preserves legacy canonical-email claim provenance while allowing current provider claims to advance verification timestamps - maps provider, admission, and collision failures to bounded callback errors without retaining OAuth state, raw causes, tokens, provider bodies, emails, subjects, or authorization codes - makes GitHub `email_addresses: read` an intrinsic preflight requirement because sign-in always reads `/user/emails` ## Scope This PR adds inert control-plane domain services and tests only. It does not expose routes, change cookies, alter deployment configuration, or activate the new authentication path. HTTP parsing, rate limiting, telemetry, composition, and cutover wiring remain follow-up work. Account linking remains intentionally deferred. A new subject that collides with another trusted email reservation fails with `account_link_required` and creates nothing. ## TDD and validation Red-green-refactor coverage includes admission parity, provider-mixup rejection, exact redirect rebinding, provider-handler dispatch, bounded and sanitized provider failures, exact subject preservation, immutable identity races, bounded D1-safe email fan-out, schema-pinned stale-credential retry and atomic rollback, email collisions, legacy claim provenance, atomic credential capture and rollback, Google credential rejection, and independent App/installation permission and suspension checks. The real D1 integration path proves provider callback -> canonical user and credential -> authorization code -> browser session redemption and authentication. - `npm test -w @open-inspect/control-plane` (141 files, 2,174 tests) - `npm run test:integration -w @open-inspect/control-plane` (61 files, 714 tests) - `npm run typecheck` - `npm run lint -w @open-inspect/control-plane` - `npm run format:check` - `npm run build -w @open-inspect/shared` - `npm run build -w @open-inspect/control-plane` - `git diff --check` ## Summary by CodeRabbit * **New Features** * Added configurable sign-in admission policies (email, domain, GitHub user, and optional GitHub organization checks) with “unsafe allow all” behavior. * Added GitHub App permission preflight validation for app JWT + installation permission readiness. * Implemented OAuth callback handling for both GitHub and Google, including correct success/denial redirects. * Strengthened browser sign-in identity resolution and verified-email handling with account-linking safeguards. * **Bug Fixes** * Improved denial/unavailable vs server error classification, redirect safety, and failure rollback behavior. * **Tests** * Expanded unit and integration coverage across admission, OAuth callbacks, identity persistence, and credential concurrency/version conflicts. --- .../src/auth/admission-policy.test.ts | 167 ++++ .../src/auth/admission-policy.ts | 167 ++++ .../auth/browser-sign-in-identity-store.ts | 54 ++ .../src/auth/browser-sign-in-identity.ts | 211 +++++ .../github-app-permission-preflight.test.ts | 291 +++++++ .../auth/github-app-permission-preflight.ts | 170 ++++ .../oauth-provider-callback-handler.test.ts | 113 +++ .../auth/oauth-provider-callback-handler.ts | 55 ++ .../oauth-provider-callback-service.test.ts | 432 ++++++++++ .../auth/oauth-provider-callback-service.ts | 155 ++++ .../src/db/browser-sign-in-identities.ts | 224 +++++ packages/control-plane/src/db/errors.ts | 5 + .../src/db/provider-credentials.ts | 71 +- .../browser-sign-in-identity.test.ts | 807 ++++++++++++++++++ .../oauth-provider-callback-service.test.ts | 172 ++++ .../integration/provider-credentials.test.ts | 51 ++ 16 files changed, 3144 insertions(+), 1 deletion(-) create mode 100644 packages/control-plane/src/auth/admission-policy.test.ts create mode 100644 packages/control-plane/src/auth/admission-policy.ts create mode 100644 packages/control-plane/src/auth/browser-sign-in-identity-store.ts create mode 100644 packages/control-plane/src/auth/browser-sign-in-identity.ts create mode 100644 packages/control-plane/src/auth/github-app-permission-preflight.test.ts create mode 100644 packages/control-plane/src/auth/github-app-permission-preflight.ts create mode 100644 packages/control-plane/src/auth/oauth-provider-callback-handler.test.ts create mode 100644 packages/control-plane/src/auth/oauth-provider-callback-handler.ts create mode 100644 packages/control-plane/src/auth/oauth-provider-callback-service.test.ts create mode 100644 packages/control-plane/src/auth/oauth-provider-callback-service.ts create mode 100644 packages/control-plane/src/db/browser-sign-in-identities.ts create mode 100644 packages/control-plane/test/integration/browser-sign-in-identity.test.ts create mode 100644 packages/control-plane/test/integration/oauth-provider-callback-service.test.ts diff --git a/packages/control-plane/src/auth/admission-policy.test.ts b/packages/control-plane/src/auth/admission-policy.test.ts new file mode 100644 index 000000000..95382d755 --- /dev/null +++ b/packages/control-plane/src/auth/admission-policy.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; +import { + AdmissionDeniedError, + AdmissionPolicy, + AdmissionUnavailableError, + parseAdmissionAllowlist, + parseAdmissionBoolean, + type AdmissionPolicyConfig, +} from "./admission-policy"; +import type { ProviderCodeExchangeResult } from "./providers/types"; + +const BASE_CONFIG: AdmissionPolicyConfig = { + allowedGitHubUsers: [], + allowedEmails: [], + allowedEmailDomains: [], + allowedGitHubOrganizations: [], + unsafeAllowAllUsers: false, +}; + +const GOOGLE_SIGN_IN: ProviderCodeExchangeResult<"google"> = { + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["first@example.net", "allowed@corp.example"], + primaryEmail: "first@example.net", + }, + credential: null, +}; + +describe("AdmissionPolicy", () => { + it("evaluates the complete verified email set with OR semantics", async () => { + const policy = new AdmissionPolicy({ + ...BASE_CONFIG, + allowedEmailDomains: ["corp.example"], + }); + + await expect(policy.requireAdmission(GOOGLE_SIGN_IN)).resolves.toEqual({ + reason: "email_domain_allowlist", + }); + }); + + it("admits an active GitHub organization member with the current flow credential", async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ state: "active" })); + const policy = new AdmissionPolicy( + { + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }, + { fetcher } + ); + + await expect( + policy.requireAdmission({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "123", + login: "octocat", + verifiedEmails: [], + primaryEmail: null, + }, + credential: { + kind: "access_only_nonexpiring", + accessToken: "ghu_token", + }, + }) + ).resolves.toEqual({ + reason: "github_organization", + organization: "open-inspect", + }); + expect(fetcher).toHaveBeenCalledWith( + "https://api.github.com/user/memberships/orgs/open-inspect", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer ghu_token" }), + signal: expect.any(AbortSignal), + }) + ); + }); + + it("parses deployment admission settings conservatively", () => { + expect(parseAdmissionAllowlist(" Alice,alice, BOB ,, ")).toEqual(["alice", "bob"]); + expect(parseAdmissionBoolean(" TRUE ")).toBe(true); + expect(parseAdmissionBoolean("1")).toBe(false); + expect(parseAdmissionBoolean(undefined)).toBe(false); + }); + + it("keeps unsafe allow-all limited to an otherwise empty policy", async () => { + const emptyPolicy = new AdmissionPolicy({ + ...BASE_CONFIG, + unsafeAllowAllUsers: true, + }); + await expect(emptyPolicy.requireAdmission(GOOGLE_SIGN_IN)).resolves.toEqual({ + reason: "unsafe_allow_all", + }); + + const configuredPolicy = new AdmissionPolicy({ + ...BASE_CONFIG, + allowedEmails: ["someone@example.com"], + unsafeAllowAllUsers: true, + }); + await expect(configuredPolicy.requireAdmission(GOOGLE_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionDeniedError + ); + }); + + it("does not apply the GitHub username allowlist to another provider", async () => { + const policy = new AdmissionPolicy({ + ...BASE_CONFIG, + allowedGitHubUsers: ["google-subject"], + }); + + await expect(policy.requireAdmission(GOOGLE_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionDeniedError + ); + }); + + it("distinguishes definitive non-membership from an unavailable organization check", async () => { + const signIn: ProviderCodeExchangeResult<"github"> = { + identity: { + provider: "github", + issuer: "https://github.com", + subject: "123", + verifiedEmails: [], + primaryEmail: null, + }, + credential: { + kind: "access_only_nonexpiring", + accessToken: "ghu_token", + }, + }; + const unavailable = new AdmissionPolicy( + { + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }, + { + fetcher: vi.fn().mockResolvedValue(new Response(null, { status: 503 })), + } + ); + await expect(unavailable.requireAdmission(signIn)).rejects.toBeInstanceOf( + AdmissionUnavailableError + ); + + const denied = new AdmissionPolicy( + { + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }, + { + fetcher: vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + } + ); + await expect(denied.requireAdmission(signIn)).rejects.toBeInstanceOf(AdmissionDeniedError); + + const pending = new AdmissionPolicy( + { + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }, + { + fetcher: vi.fn().mockResolvedValue(Response.json({ state: "pending" })), + } + ); + await expect(pending.requireAdmission(signIn)).rejects.toBeInstanceOf(AdmissionDeniedError); + }); +}); diff --git a/packages/control-plane/src/auth/admission-policy.ts b/packages/control-plane/src/auth/admission-policy.ts new file mode 100644 index 000000000..1d3a16a65 --- /dev/null +++ b/packages/control-plane/src/auth/admission-policy.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; +import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./providers/constants"; +import type { ProviderCodeExchangeResult } from "./providers/types"; + +export type VerifiedProviderSignIn = + | ProviderCodeExchangeResult<"github"> + | ProviderCodeExchangeResult<"google">; + +export interface AdmissionPolicyConfig { + readonly allowedGitHubUsers: readonly string[]; + readonly allowedEmails: readonly string[]; + readonly allowedEmailDomains: readonly string[]; + readonly allowedGitHubOrganizations: readonly string[]; + readonly unsafeAllowAllUsers: boolean; +} + +export type AdmissionDecision = + | { readonly reason: "unsafe_allow_all" } + | { readonly reason: "github_user_allowlist" } + | { readonly reason: "email_allowlist" } + | { readonly reason: "email_domain_allowlist" } + | { readonly reason: "github_organization"; readonly organization: string }; + +export interface AdmissionPolicyDependencies { + readonly fetcher?: typeof fetch; +} + +export class AdmissionDeniedError extends Error { + constructor() { + super("User is not admitted by this deployment"); + this.name = "AdmissionDeniedError"; + } +} + +export class AdmissionUnavailableError extends Error { + constructor() { + super("Admission policy could not be evaluated"); + this.name = "AdmissionUnavailableError"; + } +} + +const membershipSchema = z.object({ + state: z.enum(["active", "pending"]), +}); + +function normalize(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean))]; +} + +export function parseAdmissionAllowlist(value: string | undefined): string[] { + return normalize(value?.split(",") ?? []); +} + +export function parseAdmissionBoolean(value: string | undefined): boolean { + return value?.trim().toLowerCase() === "true"; +} + +function emailDomain(email: string): string | null { + const separator = email.lastIndexOf("@"); + if (separator <= 0 || separator === email.length - 1) return null; + return email.slice(separator + 1).toLowerCase(); +} + +function isGitHubSignIn( + signIn: VerifiedProviderSignIn +): signIn is ProviderCodeExchangeResult<"github"> { + return signIn.identity.provider === "github"; +} + +export class AdmissionPolicy { + private readonly config: AdmissionPolicyConfig; + private readonly fetcher: typeof fetch; + + constructor(config: AdmissionPolicyConfig, dependencies: AdmissionPolicyDependencies = {}) { + this.config = { + allowedGitHubUsers: normalize(config.allowedGitHubUsers), + allowedEmails: normalize(config.allowedEmails), + allowedEmailDomains: normalize(config.allowedEmailDomains), + allowedGitHubOrganizations: normalize(config.allowedGitHubOrganizations), + unsafeAllowAllUsers: config.unsafeAllowAllUsers, + }; + this.fetcher = dependencies.fetcher ?? fetch; + } + + async requireAdmission(signIn: VerifiedProviderSignIn): Promise { + const hasConfiguredAllowlist = + this.config.allowedGitHubUsers.length > 0 || + this.config.allowedEmails.length > 0 || + this.config.allowedEmailDomains.length > 0 || + this.config.allowedGitHubOrganizations.length > 0; + if (!hasConfiguredAllowlist && this.config.unsafeAllowAllUsers) { + return { reason: "unsafe_allow_all" }; + } + + if ( + isGitHubSignIn(signIn) && + signIn.identity.login && + this.config.allowedGitHubUsers.includes(signIn.identity.login.toLowerCase()) + ) { + return { reason: "github_user_allowlist" }; + } + + const emails = normalize(signIn.identity.verifiedEmails); + if (emails.some((email) => this.config.allowedEmails.includes(email))) { + return { reason: "email_allowlist" }; + } + if ( + emails.some((email) => { + const domain = emailDomain(email); + return domain !== null && this.config.allowedEmailDomains.includes(domain); + }) + ) { + return { reason: "email_domain_allowlist" }; + } + + if (isGitHubSignIn(signIn) && this.config.allowedGitHubOrganizations.length > 0) { + return this.requireGitHubOrganization(signIn); + } + throw new AdmissionDeniedError(); + } + + private async requireGitHubOrganization( + signIn: ProviderCodeExchangeResult<"github"> + ): Promise { + const accessToken = signIn.credential.accessToken; + let unavailable = false; + + for (const organization of this.config.allowedGitHubOrganizations) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS); + try { + const response = await this.fetcher( + `https://api.github.com/user/memberships/orgs/${encodeURIComponent(organization)}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "Open-Inspect-Control-Plane", + }, + signal: controller.signal, + } + ); + if (response.status === 404) continue; + if (!response.ok) { + unavailable = true; + continue; + } + const parsed = membershipSchema.safeParse(await response.json().catch(() => null)); + if (!parsed.success) { + unavailable = true; + continue; + } + if (parsed.data.state === "active") { + return { reason: "github_organization", organization }; + } + } catch { + unavailable = true; + } finally { + clearTimeout(timer); + } + } + + if (unavailable) throw new AdmissionUnavailableError(); + throw new AdmissionDeniedError(); + } +} diff --git a/packages/control-plane/src/auth/browser-sign-in-identity-store.ts b/packages/control-plane/src/auth/browser-sign-in-identity-store.ts new file mode 100644 index 000000000..9f87f4bca --- /dev/null +++ b/packages/control-plane/src/auth/browser-sign-in-identity-store.ts @@ -0,0 +1,54 @@ +import type { ProviderCredentialInput } from "./provider-credential"; +import type { SignInProvider } from "./sign-in-provider"; + +export interface StoredBrowserSignInIdentity { + readonly providerIdentityId: string; + readonly userId: string; + readonly provider: SignInProvider; +} + +export interface BrowserSignInIdentityProfile { + readonly provider: SignInProvider; + readonly issuer: string; + readonly subject: string; + readonly login: string | null; + readonly displayName: string | null; + readonly avatarUrl: string | null; + readonly verifiedEmails: readonly string[]; + readonly primaryEmail: string | null; +} + +export interface CreateBrowserSignInIdentityInput { + readonly userId: string; + readonly providerIdentityId: string; + readonly profile: BrowserSignInIdentityProfile; + readonly credential: ProviderCredentialInput | null; + readonly now: number; +} + +export interface RefreshBrowserSignInIdentityInput { + readonly existing: StoredBrowserSignInIdentity; + readonly profile: BrowserSignInIdentityProfile; + readonly credential: ProviderCredentialInput | null; + readonly now: number; +} + +/** + * Persistence boundary for browser sign-in identity resolution. + * + * Implementations own row decoding and the atomic user, identity, email-claim, + * and provider-credential write batches. The resolver owns evidence + * validation, immutable subject-binding policy, collision policy, retry policy, + * and identifier generation. + */ +export interface BrowserSignInIdentityStorePort { + findByIssuerAndSubject( + issuer: string, + subject: string + ): Promise; + countConflictingEmails(emails: readonly string[], expectedUserId: string | null): Promise; + create(input: CreateBrowserSignInIdentityInput): Promise; + refresh(input: RefreshBrowserSignInIdentityInput): Promise; + isRetryableCreateConflict(error: unknown): boolean; + isRetryableRefreshConflict(error: unknown): boolean; +} diff --git a/packages/control-plane/src/auth/browser-sign-in-identity.ts b/packages/control-plane/src/auth/browser-sign-in-identity.ts new file mode 100644 index 000000000..2d3a9583f --- /dev/null +++ b/packages/control-plane/src/auth/browser-sign-in-identity.ts @@ -0,0 +1,211 @@ +import type { ProviderCredentialInput } from "./provider-credential"; +import type { ProviderCodeExchangeResult, VerifiedProviderIdentity } from "./providers/types"; +import type { SignInProvider } from "./sign-in-provider"; +import type { + BrowserSignInIdentityProfile, + BrowserSignInIdentityStorePort, + StoredBrowserSignInIdentity, +} from "./browser-sign-in-identity-store"; + +const CANONICAL_ISSUERS: Readonly> = { + github: "https://github.com", + google: "https://accounts.google.com", +}; +const MAX_RESOLUTION_ATTEMPTS = 3; +const MAX_VERIFIED_EMAIL_CLAIMS = 1_000; + +export interface ResolvedBrowserSignInIdentity { + readonly userId: string; + readonly providerIdentityId: string; + readonly isNewUser: boolean; + readonly collisionCount: number; +} + +export interface BrowserSignInIdentityResolverDependencies { + readonly clock: { now(): number }; + readonly idGenerator: { generate(): string }; + readonly store: BrowserSignInIdentityStorePort; +} + +export class InvalidProviderIdentityEvidenceError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidProviderIdentityEvidenceError"; + } +} + +export class AccountLinkRequiredError extends Error { + constructor(readonly collisionCount: number) { + super("This verified identity requires explicit account linking"); + this.name = "AccountLinkRequiredError"; + } +} + +export class ProviderIdentityAdapterMismatchError extends Error { + constructor() { + super("Stored provider identity does not match the authenticating adapter"); + this.name = "ProviderIdentityAdapterMismatchError"; + } +} + +function normalizeOptional(value: string | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +function normalizeIdentityEvidence( + identity: VerifiedProviderIdentity +): BrowserSignInIdentityProfile { + if (identity.issuer !== CANONICAL_ISSUERS[identity.provider]) { + throw new InvalidProviderIdentityEvidenceError( + "Provider identity issuer is not the configured canonical issuer" + ); + } + if (identity.subject.length === 0) { + throw new InvalidProviderIdentityEvidenceError("Provider identity subject is empty"); + } + + const verifiedEmails = [ + ...new Set(identity.verifiedEmails.map((email) => email.trim().toLowerCase()).filter(Boolean)), + ]; + if (verifiedEmails.length > MAX_VERIFIED_EMAIL_CLAIMS) { + throw new InvalidProviderIdentityEvidenceError( + "Provider identity has too many verified email claims" + ); + } + const primaryEmail = identity.primaryEmail?.trim().toLowerCase() || null; + if (primaryEmail !== null && !verifiedEmails.includes(primaryEmail)) { + throw new InvalidProviderIdentityEvidenceError( + "Primary display email is not provider-verified" + ); + } + + return { + provider: identity.provider, + issuer: identity.issuer, + subject: identity.subject, + login: normalizeOptional(identity.login), + displayName: normalizeOptional(identity.displayName), + avatarUrl: normalizeOptional(identity.avatarUrl), + verifiedEmails, + primaryEmail, + }; +} + +function requireGeneratedId(value: string, kind: string): string { + if (value.length === 0) { + throw new Error(`Provider identity ${kind} generator returned an invalid id`); + } + return value; +} + +/** + * Resolves a verified browser sign-in to a canonical user by exact + * (issuer, subject). Existing bindings are refreshed but never silently + * reparented; cross-user verified-email collisions require explicit linking. + */ +export class BrowserSignInIdentityResolver { + constructor(private readonly dependencies: BrowserSignInIdentityResolverDependencies) {} + + async resolve( + signIn: ProviderCodeExchangeResult + ): Promise { + const identity = normalizeIdentityEvidence(signIn.identity); + const credential = signIn.credential; + + for (let attempt = 1; attempt <= MAX_RESOLUTION_ATTEMPTS; attempt += 1) { + const existing = await this.dependencies.store.findByIssuerAndSubject( + identity.issuer, + identity.subject + ); + if (existing) { + try { + return await this.refreshExisting(existing, identity, credential); + } catch (error) { + if ( + attempt === MAX_RESOLUTION_ATTEMPTS || + !this.dependencies.store.isRetryableRefreshConflict(error) + ) { + throw error; + } + continue; + } + } + + const collisionCount = await this.dependencies.store.countConflictingEmails( + identity.verifiedEmails, + null + ); + if (collisionCount > 0) { + throw new AccountLinkRequiredError(collisionCount); + } + + try { + return await this.createIdentity(identity, credential); + } catch (error) { + if ( + attempt === MAX_RESOLUTION_ATTEMPTS || + !this.dependencies.store.isRetryableCreateConflict(error) + ) { + throw error; + } + } + } + + throw new Error("Provider identity resolution exhausted its retry budget"); + } + + private async createIdentity( + identity: BrowserSignInIdentityProfile, + credential: ProviderCredentialInput | null + ): Promise { + const now = this.dependencies.clock.now(); + const userId = requireGeneratedId(this.dependencies.idGenerator.generate(), "user id"); + const providerIdentityId = requireGeneratedId( + this.dependencies.idGenerator.generate(), + "identity id" + ); + + await this.dependencies.store.create({ + userId, + providerIdentityId, + profile: identity, + credential, + now, + }); + + return { + userId, + providerIdentityId, + isNewUser: true, + collisionCount: 0, + }; + } + + private async refreshExisting( + existing: StoredBrowserSignInIdentity, + identity: BrowserSignInIdentityProfile, + credential: ProviderCredentialInput | null + ): Promise { + if (existing.provider !== identity.provider) { + throw new ProviderIdentityAdapterMismatchError(); + } + + const now = this.dependencies.clock.now(); + await this.dependencies.store.refresh({ + existing, + profile: identity, + credential, + now, + }); + return { + userId: existing.userId, + providerIdentityId: existing.providerIdentityId, + isNewUser: false, + collisionCount: await this.dependencies.store.countConflictingEmails( + identity.verifiedEmails, + existing.userId + ), + }; + } +} diff --git a/packages/control-plane/src/auth/github-app-permission-preflight.test.ts b/packages/control-plane/src/auth/github-app-permission-preflight.test.ts new file mode 100644 index 000000000..3cee7cc38 --- /dev/null +++ b/packages/control-plane/src/auth/github-app-permission-preflight.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from "vitest"; +import { + GitHubAppPermissionPreflightError, + buildGitHubAppPermissionRequirements, + preflightGitHubAppPermissions, +} from "./github-app-permission-preflight"; + +const config = { + appId: "123", + installationId: "456", + privateKey: "private", +}; + +describe("GitHub App permission preflight", () => { + it("checks both registered and installation-approved permissions", async () => { + const permissions = { + contents: "write", + pull_requests: "write", + metadata: "read", + members: "read", + email_addresses: "read", + }; + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: 123, permissions })) + .mockResolvedValueOnce( + Response.json({ + id: 456, + app_id: 123, + permissions, + suspended_at: null, + }) + ); + const requirements = buildGitHubAppPermissionRequirements({ + requireOrganizationMembers: true, + requireIssues: false, + }); + + await expect( + preflightGitHubAppPermissions( + config, + { + requireOrganizationMembers: true, + requireIssues: false, + }, + { + fetcher, + generateAppJwt: vi.fn(async () => "app-jwt"), + } + ) + ).resolves.toEqual({ + appId: "123", + installationId: "456", + permissions: requirements, + }); + expect(fetcher).toHaveBeenNthCalledWith( + 1, + "https://api.github.com/app", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer app-jwt" }), + }) + ); + expect(fetcher).toHaveBeenNthCalledWith( + 2, + "https://api.github.com/app/installations/456", + expect.any(Object) + ); + }); + + it("ignores unrelated permissions with other GitHub access levels", async () => { + const permissions = { + contents: "write", + pull_requests: "write", + metadata: "read", + email_addresses: "read", + organization_projects: "admin", + }; + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: 123, permissions })) + .mockResolvedValueOnce( + Response.json({ + id: 456, + app_id: 123, + permissions, + suspended_at: null, + }) + ); + + await expect( + preflightGitHubAppPermissions( + config, + { + requireOrganizationMembers: false, + requireIssues: false, + }, + { + fetcher, + generateAppJwt: vi.fn(async () => "app-jwt"), + } + ) + ).resolves.toEqual({ + appId: "123", + installationId: "456", + permissions: buildGitHubAppPermissionRequirements({ + requireOrganizationMembers: false, + requireIssues: false, + }), + }); + }); + + it("always requires access to the provider email evidence used by sign-in", () => { + expect( + buildGitHubAppPermissionRequirements({ + requireOrganizationMembers: false, + requireIssues: false, + }) + ).toMatchObject({ email_addresses: "read" }); + }); + + it("wraps transport failures in the preflight error boundary", async () => { + await expect( + preflightGitHubAppPermissions( + config, + { + requireOrganizationMembers: false, + requireIssues: false, + }, + { + fetcher: vi.fn(async () => { + throw new TypeError("network down"); + }), + generateAppJwt: vi.fn(async () => "app-jwt"), + } + ) + ).rejects.toBeInstanceOf(GitHubAppPermissionPreflightError); + }); + + it("wraps GitHub App authentication failures in the preflight error boundary", async () => { + await expect( + preflightGitHubAppPermissions( + config, + { + requireOrganizationMembers: false, + requireIssues: false, + }, + { + fetcher: vi.fn(), + generateAppJwt: vi.fn(async () => { + throw new Error("invalid private key"); + }), + } + ) + ).rejects.toBeInstanceOf(GitHubAppPermissionPreflightError); + }); + + it("rejects permissions that were registered but not approved on the installation", async () => { + const registeredPermissions = { + contents: "write", + pull_requests: "write", + metadata: "read", + email_addresses: "read", + }; + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: 123, permissions: registeredPermissions })) + .mockResolvedValueOnce( + Response.json({ + id: 456, + app_id: 123, + permissions: { + contents: "write", + pull_requests: "write", + metadata: "read", + }, + suspended_at: null, + }) + ); + + await expect( + preflightGitHubAppPermissions( + config, + { + requireOrganizationMembers: false, + requireIssues: false, + }, + { + fetcher, + generateAppJwt: vi.fn(async () => "app-jwt"), + } + ) + ).rejects.toEqual( + expect.objectContaining({ + name: "GitHubAppPermissionPreflightError", + message: "GitHub App installation permission email_addresses must be read", + }) + ); + }); + + it("rejects a suspended installation", async () => { + const permissions = { + contents: "write", + pull_requests: "write", + metadata: "read", + email_addresses: "read", + }; + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: 123, permissions })) + .mockResolvedValueOnce( + Response.json({ + id: 456, + app_id: 123, + permissions, + suspended_at: "2026-07-25T00:00:00Z", + }) + ); + + await expect( + preflightGitHubAppPermissions( + config, + { + requireOrganizationMembers: false, + requireIssues: false, + }, + { + fetcher, + generateAppJwt: vi.fn(async () => "app-jwt"), + } + ) + ).rejects.toEqual( + expect.objectContaining({ + message: "GitHub App installation is suspended", + }) + ); + }); + + it("rejects an installation belonging to a different app", async () => { + const permissions = { + contents: "write", + pull_requests: "write", + metadata: "read", + email_addresses: "read", + }; + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: 123, permissions })) + .mockResolvedValueOnce( + Response.json({ + id: 456, + app_id: 999, + permissions, + suspended_at: null, + }) + ); + + await expect( + preflightGitHubAppPermissions( + config, + { + requireOrganizationMembers: false, + requireIssues: false, + }, + { + fetcher, + generateAppJwt: vi.fn(async () => "app-jwt"), + } + ) + ).rejects.toEqual( + expect.objectContaining({ + message: + "GitHub App installation response does not match the configured app and installation", + }) + ); + }); + + it("requires issues write only when GitHub bot behavior is enabled", () => { + expect( + buildGitHubAppPermissionRequirements({ + requireOrganizationMembers: false, + requireIssues: true, + }) + ).toMatchObject({ issues: "write" }); + expect( + buildGitHubAppPermissionRequirements({ + requireOrganizationMembers: false, + requireIssues: false, + }) + ).not.toHaveProperty("issues"); + }); +}); diff --git a/packages/control-plane/src/auth/github-app-permission-preflight.ts b/packages/control-plane/src/auth/github-app-permission-preflight.ts new file mode 100644 index 000000000..c918557fc --- /dev/null +++ b/packages/control-plane/src/auth/github-app-permission-preflight.ts @@ -0,0 +1,170 @@ +import { z } from "zod"; +import { fetchWithTimeout, generateAppJwt, type GitHubAppConfig } from "./github-app"; + +const GITHUB_API_VERSION = "2022-11-28"; +const grantedPermissionLevelSchema = z.enum(["read", "write", "admin"]); +const permissionsSchema = z.record(z.string(), grantedPermissionLevelSchema); +const appSchema = z.object({ + id: z.number().int().positive(), + permissions: permissionsSchema, +}); +const installationSchema = z.object({ + id: z.number().int().positive(), + app_id: z.number().int().positive(), + permissions: permissionsSchema, + suspended_at: z.string().nullable(), +}); + +export type GitHubAppPermissionLevel = "read" | "write"; +export type GitHubAppPermissionRequirements = Readonly>; +type GitHubAppGrantedPermissionLevel = z.infer; + +export interface GitHubAppPermissionOptions { + readonly requireOrganizationMembers: boolean; + readonly requireIssues: boolean; +} + +export interface GitHubAppPermissionPreflightReport { + readonly appId: string; + readonly installationId: string; + readonly permissions: GitHubAppPermissionRequirements; +} + +export interface GitHubAppPermissionPreflightDependencies { + readonly fetcher: (url: string, init: RequestInit) => Promise; + readonly generateAppJwt: (appId: string, privateKey: string) => Promise; +} + +const defaultDependencies: GitHubAppPermissionPreflightDependencies = { + fetcher: (url, init) => fetchWithTimeout(url, init), + generateAppJwt, +}; + +export class GitHubAppPermissionPreflightError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "GitHubAppPermissionPreflightError"; + } +} + +export function buildGitHubAppPermissionRequirements( + options: GitHubAppPermissionOptions +): GitHubAppPermissionRequirements { + return { + contents: "write", + pull_requests: "write", + metadata: "read", + email_addresses: "read", + ...(options.requireIssues ? { issues: "write" as const } : {}), + ...(options.requireOrganizationMembers ? { members: "read" as const } : {}), + }; +} + +function permissionSatisfies( + actual: GitHubAppGrantedPermissionLevel | undefined, + required: GitHubAppPermissionLevel +): boolean { + return actual === "admin" || actual === "write" || actual === required; +} + +async function fetchJson( + url: string, + jwt: string, + fetcher: GitHubAppPermissionPreflightDependencies["fetcher"] +): Promise { + let response: Response; + try { + response = await fetcher(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${jwt}`, + "X-GitHub-Api-Version": GITHUB_API_VERSION, + "User-Agent": "Open-Inspect-Control-Plane", + }, + }); + } catch (cause) { + throw new GitHubAppPermissionPreflightError("GitHub permission preflight request failed", { + cause, + }); + } + if (!response.ok) { + throw new GitHubAppPermissionPreflightError( + `GitHub permission preflight request failed with HTTP ${response.status}` + ); + } + try { + return await response.json(); + } catch (cause) { + throw new GitHubAppPermissionPreflightError( + "GitHub permission preflight returned invalid JSON", + { cause } + ); + } +} + +function assertPermissions( + boundary: "registration" | "installation", + actual: Record, + requirements: GitHubAppPermissionRequirements +): void { + for (const [permission, required] of Object.entries(requirements)) { + if (!permissionSatisfies(actual[permission], required)) { + throw new GitHubAppPermissionPreflightError( + `GitHub App ${boundary} permission ${permission} must be ${required}` + ); + } + } +} + +export async function preflightGitHubAppPermissions( + config: GitHubAppConfig, + options: GitHubAppPermissionOptions, + dependencies: GitHubAppPermissionPreflightDependencies = defaultDependencies +): Promise { + const requirements = buildGitHubAppPermissionRequirements(options); + let jwt: string; + try { + jwt = await dependencies.generateAppJwt(config.appId, config.privateKey); + } catch (cause) { + throw new GitHubAppPermissionPreflightError( + "GitHub App authentication failed during permission preflight", + { cause } + ); + } + const appResult = appSchema.safeParse( + await fetchJson("https://api.github.com/app", jwt, dependencies.fetcher) + ); + if (!appResult.success || String(appResult.data.id) !== config.appId) { + throw new GitHubAppPermissionPreflightError( + "GitHub App registration response does not match GITHUB_APP_ID" + ); + } + assertPermissions("registration", appResult.data.permissions, requirements); + + const installationResult = installationSchema.safeParse( + await fetchJson( + `https://api.github.com/app/installations/${encodeURIComponent(config.installationId)}`, + jwt, + dependencies.fetcher + ) + ); + if ( + !installationResult.success || + String(installationResult.data.id) !== config.installationId || + String(installationResult.data.app_id) !== config.appId + ) { + throw new GitHubAppPermissionPreflightError( + "GitHub App installation response does not match the configured app and installation" + ); + } + if (installationResult.data.suspended_at !== null) { + throw new GitHubAppPermissionPreflightError("GitHub App installation is suspended"); + } + assertPermissions("installation", installationResult.data.permissions, requirements); + + return { + appId: config.appId, + installationId: config.installationId, + permissions: requirements, + }; +} diff --git a/packages/control-plane/src/auth/oauth-provider-callback-handler.test.ts b/packages/control-plane/src/auth/oauth-provider-callback-handler.test.ts new file mode 100644 index 000000000..018b8e335 --- /dev/null +++ b/packages/control-plane/src/auth/oauth-provider-callback-handler.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OAuthFlowStateReader } from "./oauth-flow-state"; +import { createOAuthProviderCallbackHandlers } from "./oauth-provider-callback-handler"; +import type { OAuthSignInProviderRegistry } from "./providers/types"; + +const STATE = "s".repeat(43); +const PROVIDER_VERIFIER = "v".repeat(43); + +describe("createOAuthProviderCallbackHandlers", () => { + it("keeps GitHub callback mechanics behind the selected provider handler", async () => { + const consume = vi.fn(async () => ({ + flowId: "flow-github", + provider: "github" as const, + clientId: "web" as const, + redirectUri: "https://web.example/api/auth/callback", + clientCodeChallenge: "c".repeat(43), + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: null, + })); + const exchangeAuthorizationCode = vi.fn(async () => ({ + identity: { + provider: "github" as const, + issuer: "https://github.com", + subject: "github-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: { + kind: "access_only_nonexpiring" as const, + accessToken: "ghu_access", + }, + })); + const providers = { + github: { + provider: "github" as const, + createAuthorizationUrl: vi.fn(), + exchangeAuthorizationCode, + }, + google: { + provider: "google" as const, + createAuthorizationUrl: vi.fn(), + exchangeAuthorizationCode: vi.fn(), + }, + } satisfies OAuthSignInProviderRegistry; + + const handlers = createOAuthProviderCallbackHandlers({ + flowStateStore: { consume } as unknown as OAuthFlowStateReader, + providers, + }); + const callback = await handlers.github.consume(STATE); + + expect(consume).toHaveBeenCalledWith(STATE, "github"); + await expect(callback.exchange("provider-code")).resolves.toMatchObject({ + identity: { provider: "github", subject: "github-subject" }, + credential: { accessToken: "ghu_access" }, + }); + expect(exchangeAuthorizationCode).toHaveBeenCalledWith({ + code: "provider-code", + codeVerifier: PROVIDER_VERIFIER, + }); + }); + + it("keeps Google callback mechanics behind the selected provider handler", async () => { + const consume = vi.fn(async () => ({ + flowId: "flow-google", + provider: "google" as const, + clientId: "web" as const, + redirectUri: "https://web.example/api/auth/callback", + clientCodeChallenge: "c".repeat(43), + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: "f".repeat(64), + })); + const exchangeAuthorizationCode = vi.fn(async () => ({ + identity: { + provider: "google" as const, + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: null, + })); + const providers = { + github: { + provider: "github" as const, + createAuthorizationUrl: vi.fn(), + exchangeAuthorizationCode: vi.fn(), + }, + google: { + provider: "google" as const, + createAuthorizationUrl: vi.fn(), + exchangeAuthorizationCode, + }, + } satisfies OAuthSignInProviderRegistry; + + const handlers = createOAuthProviderCallbackHandlers({ + flowStateStore: { consume } as unknown as OAuthFlowStateReader, + providers, + }); + const callback = await handlers.google.consume(STATE); + + expect(consume).toHaveBeenCalledWith(STATE, "google"); + await expect(callback.exchange("provider-code")).resolves.toMatchObject({ + identity: { provider: "google", subject: "google-subject" }, + credential: null, + }); + expect(exchangeAuthorizationCode).toHaveBeenCalledWith({ + code: "provider-code", + codeVerifier: PROVIDER_VERIFIER, + oidcNonceHash: "f".repeat(64), + }); + }); +}); diff --git a/packages/control-plane/src/auth/oauth-provider-callback-handler.ts b/packages/control-plane/src/auth/oauth-provider-callback-handler.ts new file mode 100644 index 000000000..5ef96da46 --- /dev/null +++ b/packages/control-plane/src/auth/oauth-provider-callback-handler.ts @@ -0,0 +1,55 @@ +import type { ConsumedOAuthFlowStateFor, OAuthFlowStateReader } from "./oauth-flow-state"; +import type { OAuthSignInProviderRegistry, ProviderCodeExchangeResult } from "./providers/types"; +import type { SignInProvider } from "./sign-in-provider"; + +export interface ConsumedOAuthProviderCallback

{ + readonly flow: ConsumedOAuthFlowStateFor

; + exchange(code: string): Promise>; +} + +export interface OAuthProviderCallbackHandler

{ + consume(state: string): Promise>; +} + +export type OAuthProviderCallbackHandlerRegistry = { + readonly [P in SignInProvider]: OAuthProviderCallbackHandler

; +}; + +export interface OAuthProviderCallbackHandlerDependencies { + readonly flowStateStore: OAuthFlowStateReader; + readonly providers: OAuthSignInProviderRegistry; +} + +export function createOAuthProviderCallbackHandlers( + dependencies: OAuthProviderCallbackHandlerDependencies +): OAuthProviderCallbackHandlerRegistry { + return { + github: { + async consume(state) { + const flow = await dependencies.flowStateStore.consume(state, "github"); + return { + flow, + exchange: (code) => + dependencies.providers.github.exchangeAuthorizationCode({ + code, + codeVerifier: flow.providerPkceVerifier, + }), + }; + }, + }, + google: { + async consume(state) { + const flow = await dependencies.flowStateStore.consume(state, "google"); + return { + flow, + exchange: (code) => + dependencies.providers.google.exchangeAuthorizationCode({ + code, + codeVerifier: flow.providerPkceVerifier, + oidcNonceHash: flow.oidcNonceHash, + }), + }; + }, + }, + }; +} diff --git a/packages/control-plane/src/auth/oauth-provider-callback-service.test.ts b/packages/control-plane/src/auth/oauth-provider-callback-service.test.ts new file mode 100644 index 000000000..b15ee202d --- /dev/null +++ b/packages/control-plane/src/auth/oauth-provider-callback-service.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, it, vi } from "vitest"; +import { AdmissionDeniedError, AdmissionUnavailableError } from "./admission-policy"; +import { AccountLinkRequiredError } from "./browser-sign-in-identity"; +import { createOAuthProviderCallbackHandlers } from "./oauth-provider-callback-handler"; +import { OAuthProviderCallbackService } from "./oauth-provider-callback-service"; +import type { OAuthFlowStateReader } from "./oauth-flow-state"; +import { OAuthProviderError, type OAuthSignInProviderRegistry } from "./providers/types"; + +const STATE = "s".repeat(43); +const CLIENT_CHALLENGE = "c".repeat(43); +const PROVIDER_VERIFIER = "v".repeat(43); + +function providerRegistry(): OAuthSignInProviderRegistry { + return { + github: { + provider: "github", + createAuthorizationUrl: vi.fn(), + exchangeAuthorizationCode: vi.fn(async () => ({ + identity: { + provider: "github" as const, + issuer: "https://github.com", + subject: "github-subject", + login: "octocat", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: { + kind: "access_only_nonexpiring" as const, + accessToken: "ghu_token", + }, + })), + }, + google: { + provider: "google", + createAuthorizationUrl: vi.fn(), + exchangeAuthorizationCode: vi.fn(), + }, + }; +} + +describe("OAuthProviderCallbackService", () => { + it("delegates provider callback mechanics to the selected handler", async () => { + const exchange = vi.fn(async () => ({ + identity: { + provider: "google" as const, + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: null, + })); + const consume = vi.fn(async () => ({ + flow: { + flowId: "flow-1", + provider: "google" as const, + clientId: "web" as const, + redirectUri: "https://web.example/api/auth/callback", + clientCodeChallenge: CLIENT_CHALLENGE, + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: "f".repeat(64), + }, + exchange, + })); + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => true) }, + providerHandlers: { + github: { consume: vi.fn() }, + google: { consume }, + }, + admissionPolicy: { requireAdmission: vi.fn() }, + identityResolver: { + resolve: vi.fn(async () => ({ + userId: "user-1", + providerIdentityId: "identity-1", + isNewUser: true, + collisionCount: 0, + })), + }, + authorizationCodeStore: { + issue: vi.fn(async () => ({ + code: `oi_code_${"a".repeat(43)}`, + expiresAt: 1_800_000_060_000, + })), + }, + }); + + await service.completeAuthorization("google", { state: STATE, code: "google-code" }); + + expect(consume).toHaveBeenCalledWith(STATE); + expect(exchange).toHaveBeenCalledWith("google-code"); + }); + + it("rejects a missing provider code before consuming transaction state", async () => { + const consumeFlow = vi.fn(); + const providers = providerRegistry(); + const flowStateStore = { + consume: consumeFlow, + } as unknown as OAuthFlowStateReader; + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => true) }, + providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), + admissionPolicy: { requireAdmission: vi.fn() }, + identityResolver: { resolve: vi.fn() }, + authorizationCodeStore: { issue: vi.fn() }, + }); + + await expect( + service.completeAuthorization("github", { state: STATE, code: "" }) + ).rejects.toEqual(expect.objectContaining({ name: "OAuthProviderCallbackRequestError" })); + expect(consumeFlow).not.toHaveBeenCalled(); + }); + + it("rejects an oversized provider code before consuming transaction state", async () => { + const consumeFlow = vi.fn(); + const providers = providerRegistry(); + const flowStateStore = { + consume: consumeFlow, + } as unknown as OAuthFlowStateReader; + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => true) }, + providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), + admissionPolicy: { requireAdmission: vi.fn() }, + identityResolver: { resolve: vi.fn() }, + authorizationCodeStore: { issue: vi.fn() }, + }); + + await expect( + service.completeAuthorization("github", { state: STATE, code: "x".repeat(4_097) }) + ).rejects.toEqual(expect.objectContaining({ name: "OAuthProviderCallbackRequestError" })); + expect(consumeFlow).not.toHaveBeenCalled(); + }); + + it("turns a verified provider callback into a client-bound authorization code", async () => { + const providers = providerRegistry(); + const consumeFlow = vi.fn(async () => ({ + flowId: "flow-1", + provider: "github" as const, + clientId: "web" as const, + redirectUri: "https://web.example/api/auth/callback", + clientCodeChallenge: CLIENT_CHALLENGE, + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: null, + })); + const flowStateStore = { + consume: consumeFlow, + } as unknown as OAuthFlowStateReader; + const admissionPolicy = { + requireAdmission: vi.fn(async () => ({ reason: "email_allowlist" as const })), + }; + const identityResolver = { + resolve: vi.fn(async () => ({ + userId: "user-1", + providerIdentityId: "identity-1", + isNewUser: true, + collisionCount: 0, + })), + }; + const authorizationCodeStore = { + issue: vi.fn(async () => ({ + code: `oi_code_${"a".repeat(43)}`, + expiresAt: 1_800_000_060_000, + })), + }; + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => true) }, + providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), + admissionPolicy, + identityResolver, + authorizationCodeStore, + }); + + await expect( + service.completeAuthorization("github", { + state: STATE, + code: "github-code", + }) + ).resolves.toEqual( + new URL(`https://web.example/api/auth/callback?code=oi_code_${"a".repeat(43)}&state=${STATE}`) + ); + expect(providers.github.exchangeAuthorizationCode).toHaveBeenCalledWith({ + code: "github-code", + codeVerifier: PROVIDER_VERIFIER, + }); + expect(admissionPolicy.requireAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ subject: "github-subject" }), + }) + ); + expect(identityResolver.resolve).toHaveBeenCalledWith({ + identity: expect.objectContaining({ + provider: "github", + issuer: "https://github.com", + subject: "github-subject", + }), + credential: expect.objectContaining({ accessToken: "ghu_token" }), + }); + expect(authorizationCodeStore.issue).toHaveBeenCalledWith({ + userId: "user-1", + providerIdentityId: "identity-1", + clientId: "web", + redirectUri: "https://web.example/api/auth/callback", + codeChallenge: CLIENT_CHALLENGE, + }); + }); + + it("consumes provider-denied state and returns only a bounded client error", async () => { + const providers = providerRegistry(); + const consumeFlow = vi.fn(async () => ({ + flowId: "flow-1", + provider: "github" as const, + clientId: "web" as const, + redirectUri: "https://web.example/api/auth/callback", + clientCodeChallenge: CLIENT_CHALLENGE, + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: null, + })); + const flowStateStore = { + consume: consumeFlow, + } as unknown as OAuthFlowStateReader; + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => true) }, + providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), + admissionPolicy: { requireAdmission: vi.fn() }, + identityResolver: { resolve: vi.fn() }, + authorizationCodeStore: { issue: vi.fn() }, + }); + + await expect(service.completeDenial("github", STATE)).resolves.toEqual( + new URL(`https://web.example/api/auth/callback?error=access_denied&state=${STATE}`) + ); + expect(consumeFlow).toHaveBeenCalledWith(STATE, "github"); + expect(providers.github.exchangeAuthorizationCode).not.toHaveBeenCalled(); + }); + + it("maps an identity collision to a bounded client callback failure", async () => { + const consumeFlow = vi.fn(async () => ({ + flowId: "flow-1", + provider: "github" as const, + clientId: "web" as const, + redirectUri: "https://web.example/api/auth/callback", + clientCodeChallenge: CLIENT_CHALLENGE, + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: null, + })); + const providers = providerRegistry(); + const flowStateStore = { + consume: consumeFlow, + } as unknown as OAuthFlowStateReader; + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => true) }, + providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), + admissionPolicy: { + requireAdmission: vi.fn(async () => ({ reason: "email_allowlist" })), + }, + identityResolver: { + resolve: vi.fn(async () => { + throw new AccountLinkRequiredError(1); + }), + }, + authorizationCodeStore: { issue: vi.fn() }, + }); + + let rejection: unknown; + try { + await service.completeAuthorization("github", { + state: STATE, + code: "github-code", + }); + } catch (error) { + rejection = error; + } + + expect(rejection).toEqual( + expect.objectContaining({ + name: "OAuthProviderCallbackError", + failure: "account_link_required", + redirectUri: "https://web.example/api/auth/callback", + }) + ); + expect(rejection).not.toHaveProperty("state"); + expect(rejection).not.toHaveProperty("cause"); + }); + + it("rejects a consumed flow whose client redirect binding is no longer registered", async () => { + const providers = providerRegistry(); + const flowStateStore = { + consume: vi.fn(async () => ({ + flowId: "flow-1", + provider: "github" as const, + clientId: "web" as const, + redirectUri: "https://attacker.example/callback", + clientCodeChallenge: CLIENT_CHALLENGE, + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: null, + })), + } as unknown as OAuthFlowStateReader; + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => false) }, + providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), + admissionPolicy: { requireAdmission: vi.fn() }, + identityResolver: { resolve: vi.fn() }, + authorizationCodeStore: { issue: vi.fn() }, + }); + + await expect( + service.completeAuthorization("github", { + state: STATE, + code: "github-code", + }) + ).rejects.toEqual(expect.objectContaining({ name: "OAuthProviderCallbackBindingError" })); + expect(providers.github.exchangeAuthorizationCode).not.toHaveBeenCalled(); + }); + + it("carries the consumed Google nonce binding through verification without storing credentials", async () => { + const providers = providerRegistry(); + vi.mocked(providers.google.exchangeAuthorizationCode).mockResolvedValue({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: null, + }); + const identityResolver = { + resolve: vi.fn(async () => ({ + userId: "user-1", + providerIdentityId: "identity-1", + isNewUser: true, + collisionCount: 0, + })), + }; + const flowStateStore = { + consume: vi.fn(async () => ({ + flowId: "flow-1", + provider: "google" as const, + clientId: "web" as const, + redirectUri: "https://web.example/api/auth/callback", + clientCodeChallenge: CLIENT_CHALLENGE, + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: "f".repeat(64), + })), + } as unknown as OAuthFlowStateReader; + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => true) }, + providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), + admissionPolicy: { + requireAdmission: vi.fn(async () => ({ reason: "email_allowlist" })), + }, + identityResolver, + authorizationCodeStore: { + issue: vi.fn(async () => ({ + code: `oi_code_${"a".repeat(43)}`, + expiresAt: 1_800_000_060_000, + })), + }, + }); + + await service.completeAuthorization("google", { + state: STATE, + code: "google-code", + }); + + expect(providers.google.exchangeAuthorizationCode).toHaveBeenCalledWith({ + code: "google-code", + codeVerifier: PROVIDER_VERIFIER, + oidcNonceHash: "f".repeat(64), + }); + expect(identityResolver.resolve).toHaveBeenCalledWith({ + identity: expect.objectContaining({ + provider: "google", + subject: "google-subject", + }), + credential: null, + }); + }); + + it.each([ + [new AdmissionDeniedError(), "access_denied"], + [new AdmissionUnavailableError(), "temporarily_unavailable"], + [ + new OAuthProviderError("provider_unavailable", "provider unavailable"), + "temporarily_unavailable", + ], + [new Error("unexpected internal detail"), "server_error"], + ] as const)( + "maps callback failures to the bounded OAuth error taxonomy", + async (cause, failure) => { + const providers = providerRegistry(); + if (cause instanceof OAuthProviderError) { + vi.mocked(providers.github.exchangeAuthorizationCode).mockRejectedValue(cause); + } + const flowStateStore = { + consume: vi.fn(async () => ({ + flowId: "flow-1", + provider: "github" as const, + clientId: "web" as const, + redirectUri: "https://web.example/api/auth/callback", + clientCodeChallenge: CLIENT_CHALLENGE, + providerPkceVerifier: PROVIDER_VERIFIER, + oidcNonceHash: null, + })), + } as unknown as OAuthFlowStateReader; + const service = new OAuthProviderCallbackService({ + clients: { accepts: vi.fn(() => true) }, + providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), + admissionPolicy: { + requireAdmission: vi.fn(async () => { + if (!(cause instanceof OAuthProviderError)) throw cause; + }), + }, + identityResolver: { resolve: vi.fn() }, + authorizationCodeStore: { issue: vi.fn() }, + }); + + await expect( + service.completeAuthorization("github", { + state: STATE, + code: "github-code", + }) + ).rejects.toEqual( + expect.objectContaining({ + name: "OAuthProviderCallbackError", + message: "OAuth provider callback could not be completed", + failure, + }) + ); + } + ); +}); diff --git a/packages/control-plane/src/auth/oauth-provider-callback-service.ts b/packages/control-plane/src/auth/oauth-provider-callback-service.ts new file mode 100644 index 000000000..0132ca95a --- /dev/null +++ b/packages/control-plane/src/auth/oauth-provider-callback-service.ts @@ -0,0 +1,155 @@ +import { + AdmissionDeniedError, + AdmissionUnavailableError, + type VerifiedProviderSignIn, +} from "./admission-policy"; +import { + AccountLinkRequiredError, + type ResolvedBrowserSignInIdentity, +} from "./browser-sign-in-identity"; +import type { OAuthProviderCallbackHandlerRegistry } from "./oauth-provider-callback-handler"; +import type { ConsumedOAuthFlowState } from "./oauth-flow-state"; +import { OAuthProviderError } from "./providers/types"; +import type { SignInProvider } from "./sign-in-provider"; + +const MAX_PROVIDER_AUTHORIZATION_CODE_LENGTH = 4_096; + +export interface CompleteProviderAuthorizationInput { + readonly state: string; + readonly code: string; +} + +export interface AdmissionPolicyPort { + requireAdmission(signIn: VerifiedProviderSignIn): Promise; +} + +export interface BrowserSignInIdentityResolverPort { + resolve(signIn: VerifiedProviderSignIn): Promise; +} + +export interface OAuthAuthorizationCodeIssuer { + issue(input: { + readonly userId: string; + readonly providerIdentityId: string; + readonly clientId: "web"; + readonly redirectUri: string; + readonly codeChallenge: string; + }): Promise<{ readonly code: string; readonly expiresAt: number }>; +} + +export interface OAuthClientRegistryPort { + accepts(clientId: string, redirectUri: string): boolean; +} + +export interface OAuthProviderCallbackServiceDependencies { + readonly clients: OAuthClientRegistryPort; + readonly providerHandlers: OAuthProviderCallbackHandlerRegistry; + readonly admissionPolicy: AdmissionPolicyPort; + readonly identityResolver: BrowserSignInIdentityResolverPort; + readonly authorizationCodeStore: OAuthAuthorizationCodeIssuer; +} + +export class OAuthProviderCallbackBindingError extends Error { + constructor() { + super("Consumed OAuth flow has an invalid client binding"); + this.name = "OAuthProviderCallbackBindingError"; + } +} + +export class OAuthProviderCallbackRequestError extends Error { + constructor() { + super("OAuth provider callback request is invalid"); + this.name = "OAuthProviderCallbackRequestError"; + } +} + +export type OAuthProviderCallbackFailure = + | "access_denied" + | "account_link_required" + | "temporarily_unavailable" + | "server_error"; + +export class OAuthProviderCallbackError extends Error { + constructor( + readonly failure: OAuthProviderCallbackFailure, + readonly redirectUri: string + ) { + super("OAuth provider callback could not be completed"); + this.name = "OAuthProviderCallbackError"; + } +} + +function callbackFailure(error: unknown): OAuthProviderCallbackFailure { + if (error instanceof AccountLinkRequiredError) { + return "account_link_required"; + } + if (error instanceof AdmissionDeniedError) { + return "access_denied"; + } + if ( + error instanceof AdmissionUnavailableError || + (error instanceof OAuthProviderError && error.failure === "provider_unavailable") + ) { + return "temporarily_unavailable"; + } + return "server_error"; +} + +export class OAuthProviderCallbackService { + constructor(private readonly dependencies: OAuthProviderCallbackServiceDependencies) {} + + async completeAuthorization( + provider: SignInProvider, + input: CompleteProviderAuthorizationInput + ): Promise { + if (input.code.length === 0 || input.code.length > MAX_PROVIDER_AUTHORIZATION_CODE_LENGTH) { + throw new OAuthProviderCallbackRequestError(); + } + + const callback = await this.dependencies.providerHandlers[provider].consume(input.state); + const { flow } = callback; + this.requireTrustedFlowBinding(flow); + try { + const signIn = await callback.exchange(input.code); + return await this.completeVerifiedSignIn(flow, signIn, input.state); + } catch (error) { + throw new OAuthProviderCallbackError(callbackFailure(error), flow.redirectUri); + } + } + + async completeDenial(provider: SignInProvider, state: string): Promise { + const { flow } = await this.dependencies.providerHandlers[provider].consume(state); + this.requireTrustedFlowBinding(flow); + const redirect = new URL(flow.redirectUri); + redirect.searchParams.set("error", "access_denied"); + redirect.searchParams.set("state", state); + return redirect; + } + + private requireTrustedFlowBinding(flow: ConsumedOAuthFlowState): void { + if (!this.dependencies.clients.accepts(flow.clientId, flow.redirectUri)) { + throw new OAuthProviderCallbackBindingError(); + } + } + + private async completeVerifiedSignIn( + flow: ConsumedOAuthFlowState, + signIn: VerifiedProviderSignIn, + state: string + ): Promise { + await this.dependencies.admissionPolicy.requireAdmission(signIn); + const resolved = await this.dependencies.identityResolver.resolve(signIn); + const authorizationCode = await this.dependencies.authorizationCodeStore.issue({ + userId: resolved.userId, + providerIdentityId: resolved.providerIdentityId, + clientId: flow.clientId, + redirectUri: flow.redirectUri, + codeChallenge: flow.clientCodeChallenge, + }); + + const redirect = new URL(flow.redirectUri); + redirect.searchParams.set("code", authorizationCode.code); + redirect.searchParams.set("state", state); + return redirect; + } +} diff --git a/packages/control-plane/src/db/browser-sign-in-identities.ts b/packages/control-plane/src/db/browser-sign-in-identities.ts new file mode 100644 index 000000000..09f7df840 --- /dev/null +++ b/packages/control-plane/src/db/browser-sign-in-identities.ts @@ -0,0 +1,224 @@ +import type { + BrowserSignInIdentityStorePort, + CreateBrowserSignInIdentityInput, + RefreshBrowserSignInIdentityInput, + StoredBrowserSignInIdentity, +} from "../auth/browser-sign-in-identity-store"; +import type { ProviderCredentialInput } from "../auth/provider-credential"; +import { isSignInProvider } from "../auth/sign-in-provider"; +import { isUniqueConstraintError } from "./errors"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; + +export interface ProviderCredentialWriteStorePort { + prepareInitialInsert( + providerIdentityId: string, + credential: ProviderCredentialInput, + updatedAt: number + ): Promise; + prepareSignInUpsert( + providerIdentityId: string, + credential: ProviderCredentialInput, + updatedAt: number + ): Promise; + isSignInVersionConflict(error: unknown): boolean; +} + +interface EmailClaimRow { + email: string; + user_id: string; + source_kind: "legacy_canonical" | "provider_verified" | "trusted_bot_attribution"; +} + +interface IdentityRow { + id: string; + user_id: string; + provider: string; +} + +function decodeIdentityRow(row: IdentityRow): StoredBrowserSignInIdentity { + if ( + typeof row.id !== "string" || + typeof row.user_id !== "string" || + !isSignInProvider(row.provider) + ) { + throw new Error("Stored provider identity is corrupt"); + } + return { + providerIdentityId: row.id, + userId: row.user_id, + provider: row.provider, + }; +} + +function decodeEmailClaimRow(row: EmailClaimRow): EmailClaimRow { + if ( + typeof row.email !== "string" || + typeof row.user_id !== "string" || + (row.source_kind !== "legacy_canonical" && + row.source_kind !== "provider_verified" && + row.source_kind !== "trusted_bot_attribution") + ) { + throw new Error("Stored verified email claim is corrupt"); + } + return row; +} + +export class BrowserSignInIdentityStore implements BrowserSignInIdentityStorePort { + constructor( + private readonly db: SqlDatabase, + private readonly providerCredentialStore: ProviderCredentialWriteStorePort + ) {} + + async findByIssuerAndSubject( + issuer: string, + subject: string + ): Promise { + const row = await this.db + .prepare( + `SELECT id, user_id, provider + FROM user_identities + WHERE provider_issuer = ? AND provider_user_id = ?` + ) + .bind(issuer, subject) + .first(); + return row ? decodeIdentityRow(row) : null; + } + + async countConflictingEmails( + emails: readonly string[], + expectedUserId: string | null + ): Promise { + if (emails.length === 0) return 0; + const result = await this.db + .prepare( + `SELECT email, user_id, source_kind + FROM verified_email_claims + WHERE email IN (SELECT CAST(value AS TEXT) FROM json_each(?))` + ) + .bind(JSON.stringify(emails)) + .all(); + + return result.results + .map(decodeEmailClaimRow) + .filter((claim) => expectedUserId === null || claim.user_id !== expectedUserId).length; + } + + async create(input: CreateBrowserSignInIdentityInput): Promise { + const { userId, providerIdentityId, profile, credential, now } = input; + const statements: SqlStatement[] = [ + this.db + .prepare( + `INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind(userId, profile.displayName, profile.primaryEmail, profile.avatarUrl, now, now), + this.db + .prepare( + `INSERT INTO user_identities ( + id, user_id, provider, provider_issuer, provider_user_id, + provider_login, provider_email, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + providerIdentityId, + userId, + profile.provider, + profile.issuer, + profile.subject, + profile.login, + profile.primaryEmail, + now + ), + ]; + if (profile.verifiedEmails.length > 0) { + statements.push( + this.db + .prepare( + `INSERT INTO verified_email_claims ( + email, user_id, source_kind, source_provider_identity_id, + created_at, last_verified_at + ) + SELECT CAST(value AS TEXT), ?, 'provider_verified', ?, ?, ? + FROM json_each(?)` + ) + .bind(userId, providerIdentityId, now, now, JSON.stringify(profile.verifiedEmails)) + ); + } + if (credential) { + statements.push( + await this.providerCredentialStore.prepareInitialInsert(providerIdentityId, credential, now) + ); + } + + await this.db.batch(statements); + } + + async refresh(input: RefreshBrowserSignInIdentityInput): Promise { + const { existing, profile, credential, now } = input; + const statements: SqlStatement[] = [ + this.db + .prepare( + `UPDATE user_identities + SET provider_login = ?, provider_email = ? + WHERE id = ? AND user_id = ?` + ) + .bind(profile.login, profile.primaryEmail, existing.providerIdentityId, existing.userId), + // users.email is stable canonical account metadata, not a mirror of a + // provider's mutable primary email. Current provider display metadata + // lives on user_identities; verified ownership evidence lives in claims. + this.db + .prepare( + `UPDATE users + SET display_name = ?, avatar_url = ?, updated_at = ? + WHERE id = ?` + ) + .bind(profile.displayName, profile.avatarUrl, now, existing.userId), + ]; + if (profile.verifiedEmails.length > 0) { + const serializedEmails = JSON.stringify(profile.verifiedEmails); + statements.push( + this.db + .prepare( + `UPDATE verified_email_claims + SET last_verified_at = ? + WHERE user_id = ? + AND source_kind != 'legacy_canonical' + AND email IN (SELECT CAST(value AS TEXT) FROM json_each(?))` + ) + .bind(now, existing.userId, serializedEmails), + this.db + .prepare( + `INSERT OR IGNORE INTO verified_email_claims ( + email, user_id, source_kind, source_provider_identity_id, + created_at, last_verified_at + ) + SELECT CAST(value AS TEXT), ?, 'provider_verified', ?, ?, ? + FROM json_each(?)` + ) + .bind(existing.userId, existing.providerIdentityId, now, now, serializedEmails) + ); + } + if (credential) { + statements.push( + await this.providerCredentialStore.prepareSignInUpsert( + existing.providerIdentityId, + credential, + now + ) + ); + } + + await this.db.batch(statements); + } + + isRetryableCreateConflict(error: unknown): boolean { + return isUniqueConstraintError(error); + } + + isRetryableRefreshConflict(error: unknown): boolean { + return ( + isUniqueConstraintError(error) || this.providerCredentialStore.isSignInVersionConflict(error) + ); + } +} diff --git a/packages/control-plane/src/db/errors.ts b/packages/control-plane/src/db/errors.ts index 065dc82ee..c400e48a7 100644 --- a/packages/control-plane/src/db/errors.ts +++ b/packages/control-plane/src/db/errors.ts @@ -7,3 +7,8 @@ export function isUniqueConstraintError(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); return msg.toLowerCase().includes("unique constraint failed"); } + +export function isCheckConstraintError(err: unknown, constraint: string): boolean { + const message = err instanceof Error ? err.message : String(err); + return message.toLowerCase().includes(`check constraint failed: ${constraint.toLowerCase()}`); +} diff --git a/packages/control-plane/src/db/provider-credentials.ts b/packages/control-plane/src/db/provider-credentials.ts index 009d2b20f..89f214564 100644 --- a/packages/control-plane/src/db/provider-credentials.ts +++ b/packages/control-plane/src/db/provider-credentials.ts @@ -5,10 +5,15 @@ import { } from "../auth/provider-credential-cipher"; import type { ProviderCredentialInput, ProviderCredentialKind } from "../auth/provider-credential"; import type { Clock } from "./browser-auth-sessions"; -import { isUniqueConstraintError } from "./errors"; +import { isCheckConstraintError, isUniqueConstraintError } from "./errors"; import type { SqlDatabase, SqlStatement } from "./sql-database"; export const CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION = 1; +/** + * Migration 0047's CHECK expression is part of stale sign-in conflict + * detection: SQLite/D1 includes it in the constraint error message. + */ +export const PROVIDER_CREDENTIAL_ROW_VERSION_CHECK = "row_version >= 1"; const SUPPORTED_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSIONS: ReadonlySet = new Set([ CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, ]); @@ -261,6 +266,70 @@ export class ProviderCredentialStore { ); } + /** + * Prepares a sign-in upsert for a caller-owned batch. A concurrent version + * change deliberately violates the row-version check, aborting the entire + * batch instead of committing the caller's related writes partially. + */ + async prepareSignInUpsert( + providerIdentityId: string, + credential: ProviderCredentialInput, + updatedAt = this.clock.now() + ): Promise { + validateInput(providerIdentityId, credential); + if (!isFiniteInteger(updatedAt)) { + throw new InvalidProviderCredentialInputError( + "Provider credential update time must be an integer" + ); + } + + const previousVersion = await this.readRowVersion(providerIdentityId); + if (previousVersion === null) { + return await this.prepareInitialInsert(providerIdentityId, credential, updatedAt); + } + + const rowVersion = previousVersion + 1; + const encrypted = await this.encryptCredential(providerIdentityId, credential, rowVersion); + return this.db + .prepare( + `INSERT INTO provider_credentials ( + provider_identity_id, credential_kind, + access_token_ciphertext, access_expires_at, + refresh_token_ciphertext, refresh_expires_at, + encryption_key_version, row_version, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_identity_id) DO UPDATE SET + credential_kind = excluded.credential_kind, + access_token_ciphertext = excluded.access_token_ciphertext, + access_expires_at = excluded.access_expires_at, + refresh_token_ciphertext = excluded.refresh_token_ciphertext, + refresh_expires_at = excluded.refresh_expires_at, + encryption_key_version = excluded.encryption_key_version, + row_version = CASE + WHEN provider_credentials.row_version = ? + THEN excluded.row_version + ELSE 0 + END, + updated_at = excluded.updated_at` + ) + .bind( + providerIdentityId, + credential.kind, + encrypted.accessTokenCiphertext, + encrypted.accessExpiresAt, + encrypted.refreshTokenCiphertext, + encrypted.refreshExpiresAt, + CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, + rowVersion, + updatedAt, + previousVersion + ); + } + + isSignInVersionConflict(error: unknown): boolean { + return isCheckConstraintError(error, PROVIDER_CREDENTIAL_ROW_VERSION_CHECK); + } + async upsertFromSignIn( providerIdentityId: string, credential: ProviderCredentialInput diff --git a/packages/control-plane/test/integration/browser-sign-in-identity.test.ts b/packages/control-plane/test/integration/browser-sign-in-identity.test.ts new file mode 100644 index 000000000..b54101064 --- /dev/null +++ b/packages/control-plane/test/integration/browser-sign-in-identity.test.ts @@ -0,0 +1,807 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { env } from "cloudflare:test"; +import { + AccountLinkRequiredError, + BrowserSignInIdentityResolver, + InvalidProviderIdentityEvidenceError, + ProviderIdentityAdapterMismatchError, + type BrowserSignInIdentityResolverDependencies, +} from "../../src/auth/browser-sign-in-identity"; +import { + BrowserSignInIdentityStore, + type ProviderCredentialWriteStorePort, +} from "../../src/db/browser-sign-in-identities"; +import { ProviderCredentialStore } from "../../src/db/provider-credentials"; +import { cleanD1Tables } from "./cleanup"; + +const NOW_MS = 1_800_000_000_000; +const GITHUB_CREDENTIAL = { + kind: "access_only_nonexpiring" as const, + accessToken: "ghu_test_access", +}; + +function createProviderCredentialStore(now = NOW_MS): ProviderCredentialStore { + return new ProviderCredentialStore( + env.DB, + { + encrypt: async (plaintext, context) => btoa(JSON.stringify({ plaintext, context })), + decrypt: async (encrypted) => + (JSON.parse(atob(encrypted)) as { plaintext: string }).plaintext, + }, + { now: () => now } + ); +} + +function createIdentityResolver( + dependencies: Omit & { + providerCredentialStore?: ProviderCredentialWriteStorePort; + } +): BrowserSignInIdentityResolver { + const { providerCredentialStore = createProviderCredentialStore(), ...serviceDependencies } = + dependencies; + return new BrowserSignInIdentityResolver({ + ...serviceDependencies, + store: new BrowserSignInIdentityStore(env.DB, providerCredentialStore), + }); +} + +describe("BrowserSignInIdentityResolver", () => { + beforeEach(cleanD1Tables); + + it("creates an issuer-qualified canonical identity without requiring email evidence", async () => { + const ids = ["user-1", "identity-1"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { + generate: () => ids.shift() ?? "unexpected-id", + }, + }); + + await expect( + service.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-user-1", + login: "octocat", + displayName: "Octo Cat", + avatarUrl: "https://avatars.example/octocat", + verifiedEmails: [], + primaryEmail: null, + }, + credential: GITHUB_CREDENTIAL, + }) + ).resolves.toEqual({ + userId: "user-1", + providerIdentityId: "identity-1", + isNewUser: true, + collisionCount: 0, + }); + + await expect( + env.DB.prepare( + `SELECT + users.id, users.email, user_identities.provider, + user_identities.provider_issuer, user_identities.provider_user_id + FROM users + JOIN user_identities ON user_identities.user_id = users.id` + ).first() + ).resolves.toEqual({ + id: "user-1", + email: null, + provider: "github", + provider_issuer: "https://github.com", + provider_user_id: "github-user-1", + }); + }); + + it("fails closed without creating a user when a new subject collides", async () => { + const ids = ["existing-user", "existing-identity", "rejected-user", "rejected-identity"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + await service.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-user-1", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: GITHUB_CREDENTIAL, + }); + + let rejection: unknown; + try { + await service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-user-1", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: null, + }); + } catch (error) { + rejection = error; + } + + expect(rejection).toBeInstanceOf(AccountLinkRequiredError); + expect(rejection).toMatchObject({ collisionCount: 1 }); + expect(rejection).not.toHaveProperty("conflictingEmails"); + + await expect( + env.DB.prepare( + `SELECT + (SELECT count(*) FROM users) AS users, + (SELECT count(*) FROM user_identities) AS identities, + (SELECT count(*) FROM verified_email_claims) AS claims` + ).first() + ).resolves.toEqual({ users: 1, identities: 1, claims: 1 }); + }); + + it("preserves an established subject while maintaining its unclaimed email evidence", async () => { + const ids = ["github-user", "github-identity", "google-user", "google-identity"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + await service.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-subject", + verifiedEmails: ["github@example.com"], + primaryEmail: "github@example.com", + }, + credential: GITHUB_CREDENTIAL, + }); + await service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["google@example.com"], + primaryEmail: "google@example.com", + }, + credential: null, + }); + + await expect( + service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + displayName: "Updated Google User", + verifiedEmails: ["google@example.com", "new@example.com", "github@example.com"], + primaryEmail: "google@example.com", + }, + credential: null, + }) + ).resolves.toEqual({ + userId: "google-user", + providerIdentityId: "google-identity", + isNewUser: false, + collisionCount: 1, + }); + + await expect( + env.DB.prepare( + `SELECT email, user_id, source_provider_identity_id + FROM verified_email_claims + WHERE email = 'new@example.com'` + ).first() + ).resolves.toEqual({ + email: "new@example.com", + user_id: "google-user", + source_provider_identity_id: "google-identity", + }); + await expect( + env.DB.prepare( + `SELECT user_id + FROM user_identities + WHERE id = 'google-identity'` + ).first() + ).resolves.toEqual({ user_id: "google-user" }); + }); + + it("keeps canonical users.email stable while refreshing provider email metadata", async () => { + const ids = ["user-1", "identity-1"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + await service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["original@example.com"], + primaryEmail: "original@example.com", + }, + credential: null, + }); + + await service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["current@example.com"], + primaryEmail: "current@example.com", + }, + credential: null, + }); + + await expect( + env.DB.prepare( + `SELECT users.email, user_identities.provider_email + FROM users + JOIN user_identities ON user_identities.user_id = users.id + WHERE users.id = 'user-1'` + ).first() + ).resolves.toEqual({ + email: "original@example.com", + provider_email: "current@example.com", + }); + }); + + it("uses claim uniqueness as the concurrency authority", async () => { + const github = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { + generate: (() => { + const ids = ["github-user", "github-identity"]; + return () => ids.shift() ?? "unexpected-github-id"; + })(), + }, + }); + const google = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { + generate: (() => { + const ids = ["google-user", "google-identity"]; + return () => ids.shift() ?? "unexpected-google-id"; + })(), + }, + }); + + const results = await Promise.allSettled([ + github.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-subject", + verifiedEmails: ["same@example.com"], + primaryEmail: "same@example.com", + }, + credential: GITHUB_CREDENTIAL, + }), + google.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["same@example.com"], + primaryEmail: "same@example.com", + }, + credential: null, + }), + ]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.find((result) => result.status === "rejected")).toEqual( + expect.objectContaining({ + reason: expect.objectContaining({ name: "AccountLinkRequiredError" }), + }) + ); + await expect( + env.DB.prepare( + `SELECT + (SELECT count(*) FROM users) AS users, + (SELECT count(*) FROM user_identities) AS identities, + (SELECT count(*) FROM verified_email_claims) AS claims` + ).first() + ).resolves.toEqual({ users: 1, identities: 1, claims: 1 }); + }); + + it("commits a new identity and encrypted provider credential in one transaction", async () => { + const credentialStore = new ProviderCredentialStore( + env.DB, + { + encrypt: async (plaintext, context) => btoa(JSON.stringify({ plaintext, context })), + decrypt: async (encrypted) => + (JSON.parse(atob(encrypted)) as { plaintext: string }).plaintext, + }, + { now: () => NOW_MS } + ); + const ids = ["user-1", "identity-1"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + providerCredentialStore: credentialStore, + }); + + await service.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: { + kind: "refreshable", + accessToken: "ghu_access", + accessExpiresAt: NOW_MS + 10_000, + refreshToken: "ghr_refresh", + refreshExpiresAt: null, + }, + }); + + await expect(credentialStore.get("identity-1")).resolves.toMatchObject({ + providerIdentityId: "identity-1", + kind: "refreshable", + accessToken: "ghu_access", + refreshToken: "ghr_refresh", + rowVersion: 1, + }); + }); + + it("retries an atomic identity refresh when the prepared credential version becomes stale", async () => { + const credentialStore = createProviderCredentialStore(); + const ids = ["user-1", "identity-1"]; + const initial = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + providerCredentialStore: credentialStore, + }); + await initial.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-subject", + displayName: "Original Name", + verifiedEmails: ["original@example.com"], + primaryEmail: "original@example.com", + }, + credential: GITHUB_CREDENTIAL, + }); + + const staleMutation = await credentialStore.prepareSignInUpsert("identity-1", { + kind: "access_only_nonexpiring", + accessToken: "stale-access-token", + }); + await credentialStore.upsertFromSignIn("identity-1", { + kind: "access_only_nonexpiring", + accessToken: "concurrent-access-token", + }); + const prepareSignInUpsert = vi + .fn() + .mockResolvedValueOnce(staleMutation) + .mockImplementation((providerIdentityId, credential, updatedAt) => + credentialStore.prepareSignInUpsert(providerIdentityId, credential, updatedAt) + ); + const retryingStore: ProviderCredentialWriteStorePort = { + prepareInitialInsert: (...args) => credentialStore.prepareInitialInsert(...args), + prepareSignInUpsert, + isSignInVersionConflict: (error) => credentialStore.isSignInVersionConflict(error), + }; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS + 1_000 }, + idGenerator: { generate: () => "must-not-generate" }, + providerCredentialStore: retryingStore, + }); + + await expect( + service.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-subject", + displayName: "Updated Name", + verifiedEmails: ["original@example.com", "new@example.com"], + primaryEmail: "new@example.com", + }, + credential: { + kind: "access_only_nonexpiring", + accessToken: "final-access-token", + }, + }) + ).resolves.toMatchObject({ + userId: "user-1", + providerIdentityId: "identity-1", + isNewUser: false, + }); + + expect(prepareSignInUpsert).toHaveBeenCalledTimes(2); + await expect( + env.DB.prepare("SELECT display_name FROM users WHERE id = 'user-1'").first() + ).resolves.toEqual({ display_name: "Updated Name" }); + await expect( + env.DB.prepare( + "SELECT user_id FROM verified_email_claims WHERE email = 'new@example.com'" + ).first() + ).resolves.toEqual({ user_id: "user-1" }); + await expect(credentialStore.get("identity-1")).resolves.toMatchObject({ + accessToken: "final-access-token", + rowVersion: 3, + }); + }); + + it("does not start identity creation when credential preparation fails", async () => { + const credentialStore = new ProviderCredentialStore( + env.DB, + { + encrypt: async () => { + throw new Error("cipher unavailable"); + }, + decrypt: async () => { + throw new Error("not reached"); + }, + }, + { now: () => NOW_MS } + ); + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => crypto.randomUUID() }, + providerCredentialStore: credentialStore, + }); + + await expect( + service.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: { + kind: "access_only_nonexpiring", + accessToken: "ghu_access", + }, + }) + ).rejects.toThrow("cipher unavailable"); + await expect(env.DB.prepare("SELECT count(*) AS count FROM users").first()).resolves.toEqual({ + count: 0, + }); + }); + + it("rolls back user, identity, and claims when credential execution fails in the batch", async () => { + const failingCredentialStore: ProviderCredentialWriteStorePort = { + prepareInitialInsert: async () => + env.DB.prepare( + `INSERT INTO provider_credentials ( + provider_identity_id, credential_kind, + access_token_ciphertext, access_expires_at, + refresh_token_ciphertext, refresh_expires_at, + encryption_key_version, row_version, updated_at + ) VALUES (?, 'invalid-kind', 'ciphertext', NULL, NULL, NULL, 1, 1, ?)` + ).bind("identity-1", NOW_MS), + prepareSignInUpsert: async () => { + throw new Error("not reached"); + }, + isSignInVersionConflict: () => false, + }; + const ids = ["user-1", "identity-1"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + providerCredentialStore: failingCredentialStore, + }); + + await expect( + service.resolve({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "github-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: GITHUB_CREDENTIAL, + }) + ).rejects.toThrow(); + + await expect( + env.DB.prepare( + `SELECT + (SELECT count(*) FROM users) AS users, + (SELECT count(*) FROM user_identities) AS identities, + (SELECT count(*) FROM verified_email_claims) AS claims, + (SELECT count(*) FROM provider_credentials) AS credentials` + ).first() + ).resolves.toEqual({ users: 0, identities: 0, claims: 0, credentials: 0 }); + }); + + it("rejects an issuer that was not selected by the configured provider adapter", async () => { + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => crypto.randomUUID() }, + }); + + await expect( + service.resolve({ + identity: { + provider: "google", + issuer: "https://attacker.example", + subject: "subject", + verifiedEmails: [], + primaryEmail: null, + }, + credential: null, + }) + ).rejects.toBeInstanceOf(InvalidProviderIdentityEvidenceError); + }); + + it("preserves the provider subject exactly", async () => { + const ids = ["user-1", "identity-1"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + + await service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: " subject-with-significant-spaces ", + verifiedEmails: [], + primaryEmail: null, + }, + credential: null, + }); + + await expect( + env.DB.prepare( + `SELECT provider_user_id + FROM user_identities + WHERE id = 'identity-1'` + ).first() + ).resolves.toEqual({ + provider_user_id: " subject-with-significant-spaces ", + }); + }); + + it("resolves a bounded provider email set without one D1 binding or statement per email", async () => { + const ids = ["user-1", "identity-1"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + const verifiedEmails = Array.from({ length: 101 }, (_, index) => `person-${index}@example.com`); + + await expect( + service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-many-emails", + verifiedEmails, + primaryEmail: verifiedEmails[0], + }, + credential: null, + }) + ).resolves.toMatchObject({ + userId: "user-1", + providerIdentityId: "identity-1", + isNewUser: true, + collisionCount: 0, + }); + + await expect( + service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-many-emails", + verifiedEmails, + primaryEmail: verifiedEmails[0], + }, + credential: null, + }) + ).resolves.toMatchObject({ + userId: "user-1", + providerIdentityId: "identity-1", + isNewUser: false, + collisionCount: 0, + }); + + await expect( + env.DB.prepare("SELECT count(*) AS count FROM verified_email_claims").first() + ).resolves.toEqual({ count: 101 }); + }); + + it("rejects an unbounded provider email set before writing identity state", async () => { + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => crypto.randomUUID() }, + }); + const verifiedEmails = Array.from( + { length: 1_001 }, + (_, index) => `person-${index}@example.com` + ); + + await expect( + service.resolve({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-too-many-emails", + verifiedEmails, + primaryEmail: verifiedEmails[0], + }, + credential: null, + }) + ).rejects.toBeInstanceOf(InvalidProviderIdentityEvidenceError); + + await expect( + env.DB.prepare( + `SELECT + (SELECT count(*) FROM users) AS users, + (SELECT count(*) FROM user_identities) AS identities, + (SELECT count(*) FROM verified_email_claims) AS claims` + ).first() + ).resolves.toEqual({ users: 0, identities: 0, claims: 0 }); + }); + + it("converges concurrent callbacks for the same immutable subject", async () => { + function service(userId: string, identityId: string) { + const ids = [userId, identityId]; + return createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + } + const evidence = { + identity: { + provider: "google" as const, + issuer: "https://accounts.google.com", + subject: "same-subject", + verifiedEmails: [], + primaryEmail: null, + }, + credential: null, + }; + + const [first, second] = await Promise.all([ + service("user-1", "identity-1").resolve(evidence), + service("user-2", "identity-2").resolve(evidence), + ]); + + expect(first.userId).toBe(second.userId); + expect(first.providerIdentityId).toBe(second.providerIdentityId); + await expect( + env.DB.prepare( + `SELECT + (SELECT count(*) FROM users) AS users, + (SELECT count(*) FROM user_identities) AS identities` + ).first() + ).resolves.toEqual({ users: 1, identities: 1 }); + }); + + it("advances verification time without rewriting claim provenance", async () => { + const ids = ["user-1", "identity-1"]; + const initial = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + const evidence = { + identity: { + provider: "google" as const, + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: null, + }; + await initial.resolve(evidence); + + const later = createIdentityResolver({ + clock: { now: () => NOW_MS + 1_000 }, + idGenerator: { generate: () => "must-not-generate" }, + }); + await later.resolve(evidence); + + await expect( + env.DB.prepare( + `SELECT + source_kind, source_provider_identity_id, created_at, last_verified_at + FROM verified_email_claims + WHERE email = 'person@example.com'` + ).first() + ).resolves.toEqual({ + source_kind: "provider_verified", + source_provider_identity_id: "identity-1", + created_at: NOW_MS, + last_verified_at: NOW_MS + 1_000, + }); + }); + + it("preserves a legacy canonical reservation when the same user verifies it", async () => { + const ids = ["user-1", "identity-1"]; + const initial = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + const evidence = { + identity: { + provider: "google" as const, + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: null, + }; + await initial.resolve(evidence); + await env.DB.prepare( + `UPDATE verified_email_claims + SET source_kind = 'legacy_canonical', + source_provider_identity_id = NULL, + last_verified_at = NULL + WHERE email = 'person@example.com'` + ).run(); + + const later = createIdentityResolver({ + clock: { now: () => NOW_MS + 1_000 }, + idGenerator: { generate: () => "must-not-generate" }, + }); + await expect(later.resolve(evidence)).resolves.toMatchObject({ + userId: "user-1", + providerIdentityId: "identity-1", + isNewUser: false, + }); + await expect( + env.DB.prepare( + `SELECT + source_kind, source_provider_identity_id, created_at, last_verified_at + FROM verified_email_claims + WHERE email = 'person@example.com'` + ).first() + ).resolves.toEqual({ + source_kind: "legacy_canonical", + source_provider_identity_id: null, + created_at: NOW_MS, + last_verified_at: null, + }); + }); + + it("rejects a stored adapter mismatch without reparenting the subject", async () => { + const ids = ["user-1", "identity-1"]; + const service = createIdentityResolver({ + clock: { now: () => NOW_MS }, + idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, + }); + const evidence = { + identity: { + provider: "google" as const, + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: [], + primaryEmail: null, + }, + credential: null, + }; + await service.resolve(evidence); + await env.DB.prepare( + "UPDATE user_identities SET provider = 'github' WHERE id = 'identity-1'" + ).run(); + + let rejection: unknown; + try { + await service.resolve(evidence); + } catch (error) { + rejection = error; + } + expect(rejection).toBeInstanceOf(ProviderIdentityAdapterMismatchError); + await expect( + env.DB.prepare("SELECT user_id FROM user_identities WHERE id = 'identity-1'").first() + ).resolves.toEqual({ user_id: "user-1" }); + }); +}); diff --git a/packages/control-plane/test/integration/oauth-provider-callback-service.test.ts b/packages/control-plane/test/integration/oauth-provider-callback-service.test.ts new file mode 100644 index 000000000..639756e36 --- /dev/null +++ b/packages/control-plane/test/integration/oauth-provider-callback-service.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { env } from "cloudflare:test"; +import { AdmissionPolicy } from "../../src/auth/admission-policy"; +import { hashToken } from "../../src/auth/crypto"; +import { BrowserSignInIdentityResolver } from "../../src/auth/browser-sign-in-identity"; +import { StaticOAuthClientRegistry } from "../../src/auth/oauth-authorization-service"; +import { createOAuthProviderCallbackHandlers } from "../../src/auth/oauth-provider-callback-handler"; +import { OAuthProviderCallbackService } from "../../src/auth/oauth-provider-callback-service"; +import { createPkceS256Challenge } from "../../src/auth/pkce"; +import type { + OAuthFlowVerifierBinding, + OAuthFlowVerifierCipher, +} from "../../src/auth/oauth-flow-verifier"; +import type { + ProviderCredentialCipherBinding, + ProviderCredentialCipherPort, +} from "../../src/auth/provider-credential-cipher"; +import type { OAuthSignInProviderRegistry } from "../../src/auth/providers/types"; +import { BrowserAuthSessionStore } from "../../src/db/browser-auth-sessions"; +import { BrowserSignInIdentityStore } from "../../src/db/browser-sign-in-identities"; +import { OAuthAuthorizationCodeStore } from "../../src/db/oauth-authorization-codes"; +import { OAuthFlowStateStore } from "../../src/db/oauth-flow-state"; +import { ProviderCredentialStore } from "../../src/db/provider-credentials"; +import { cleanD1Tables } from "./cleanup"; + +const NOW_MS = 1_800_000_000_000; +const STATE = "s".repeat(43); +const CLIENT_VERIFIER = "c".repeat(43); +const PROVIDER_VERIFIER = "p".repeat(43); +const REDIRECT_URI = "https://web.example/api/auth/callback"; + +function testCipher(): { + encrypt(plaintext: string, binding: TBinding): Promise; + decrypt(ciphertext: string, binding: TBinding): Promise; +} { + return { + encrypt: async (plaintext, binding) => btoa(JSON.stringify({ plaintext, binding })), + decrypt: async (ciphertext, binding) => { + const parsed = JSON.parse(atob(ciphertext)) as { + plaintext: string; + binding: TBinding; + }; + expect(parsed.binding).toEqual(binding); + return parsed.plaintext; + }, + }; +} + +describe("OAuth provider callback transaction", () => { + beforeEach(cleanD1Tables); + + it("persists the exact identity and credential redeemed into the browser session", async () => { + const clock = { now: () => NOW_MS }; + const flowStore = new OAuthFlowStateStore( + env.DB, + testCipher() satisfies OAuthFlowVerifierCipher, + { + clock, + idGenerator: { generate: () => "flow-1" }, + tokenHasher: { hash: hashToken }, + } + ); + const providerCredentialStore = new ProviderCredentialStore( + env.DB, + testCipher() satisfies ProviderCredentialCipherPort, + clock + ); + const identityIds = ["user-1", "identity-1"]; + const identityResolver = new BrowserSignInIdentityResolver({ + clock, + idGenerator: { + generate: () => identityIds.shift() ?? "unexpected-identity-id", + }, + store: new BrowserSignInIdentityStore(env.DB, providerCredentialStore), + }); + const authorizationCodeIds = ["authorization-code-1", "browser-session-1"]; + const authorizationCodeStore = new OAuthAuthorizationCodeStore(env.DB, { + clock, + tokenHasher: { hash: hashToken }, + authorizationCodeGenerator: { + generate: () => `oi_code_${"a".repeat(43)}`, + }, + browserCredentialGenerator: { + generate: () => `oi_bsess_${"b".repeat(43)}`, + }, + idGenerator: { + generate: () => authorizationCodeIds.shift() ?? "unexpected-authorization-code-id", + }, + }); + const providers = { + github: { + provider: "github" as const, + createAuthorizationUrl: async () => new URL("https://github.com/login/oauth/authorize"), + exchangeAuthorizationCode: async () => ({ + identity: { + provider: "github" as const, + issuer: "https://github.com", + subject: "github-subject", + login: "octocat", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: { + kind: "access_only_nonexpiring" as const, + accessToken: "ghu_token", + }, + }), + }, + google: { + provider: "google" as const, + createAuthorizationUrl: async () => new URL("https://accounts.google.com/o/oauth2/v2/auth"), + exchangeAuthorizationCode: async () => { + throw new Error("Google provider was not selected"); + }, + }, + } satisfies OAuthSignInProviderRegistry; + const callbackService = new OAuthProviderCallbackService({ + clients: new StaticOAuthClientRegistry([REDIRECT_URI]), + providerHandlers: createOAuthProviderCallbackHandlers({ + providers, + flowStateStore: flowStore, + }), + admissionPolicy: new AdmissionPolicy({ + allowedGitHubUsers: [], + allowedEmails: ["person@example.com"], + allowedEmailDomains: [], + allowedGitHubOrganizations: [], + unsafeAllowAllUsers: false, + }), + identityResolver, + authorizationCodeStore, + }); + + await flowStore.create({ + state: STATE, + provider: "github", + clientId: "web", + redirectUri: REDIRECT_URI, + clientCodeChallenge: await createPkceS256Challenge(CLIENT_VERIFIER), + providerPkceVerifier: PROVIDER_VERIFIER, + }); + const redirect = await callbackService.completeAuthorization("github", { + state: STATE, + code: "provider-code", + }); + const code = redirect.searchParams.get("code"); + if (code === null) throw new Error("Callback did not return an authorization code"); + + const browserSession = await authorizationCodeStore.redeem({ + code, + clientId: "web", + redirectUri: REDIRECT_URI, + codeVerifier: CLIENT_VERIFIER, + }); + const authenticated = await new BrowserAuthSessionStore(env.DB, { + clock, + credentialGenerator: { generate: () => "unused" }, + idGenerator: { generate: () => "unused" }, + tokenHasher: { hash: hashToken }, + }).authenticate(browserSession.credential); + + expect(authenticated).toMatchObject({ + userId: "user-1", + providerIdentityId: "identity-1", + }); + await expect(providerCredentialStore.get("identity-1")).resolves.toMatchObject({ + kind: "access_only_nonexpiring", + accessToken: "ghu_token", + rowVersion: 1, + }); + }); +}); diff --git a/packages/control-plane/test/integration/provider-credentials.test.ts b/packages/control-plane/test/integration/provider-credentials.test.ts index b0459ca7b..dce988c26 100644 --- a/packages/control-plane/test/integration/provider-credentials.test.ts +++ b/packages/control-plane/test/integration/provider-credentials.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { ProviderCredentialCipher } from "../../src/auth/auth-encryption"; import type { ProviderCredentialCipherPort } from "../../src/auth/provider-credential-cipher"; import { + PROVIDER_CREDENTIAL_ROW_VERSION_CHECK, ProviderCredentialStore, ProviderCredentialVersionConflictError, StoredProviderCredentialCorruptError, @@ -40,6 +41,21 @@ describe("ProviderCredentialStore", () => { }); }); + it("pins the row-version check used to detect stale sign-in writes", async () => { + await expect( + env.DB.prepare( + `INSERT INTO provider_credentials ( + provider_identity_id, credential_kind, + access_token_ciphertext, access_expires_at, + refresh_token_ciphertext, refresh_expires_at, + encryption_key_version, row_version, updated_at + ) VALUES (?, 'access_only_nonexpiring', ?, NULL, NULL, NULL, 1, 0, ?)` + ) + .bind("identity-1", "ciphertext", NOW_MS) + .run() + ).rejects.toThrow(`CHECK constraint failed: ${PROVIDER_CREDENTIAL_ROW_VERSION_CHECK}`); + }); + it("round-trips refreshable credentials without storing plaintext tokens", async () => { await expect( store.upsertFromSignIn("identity-1", { @@ -234,6 +250,41 @@ describe("ProviderCredentialStore", () => { }); }); + it("makes a stale prepared sign-in mutation fail its caller-owned batch atomically", async () => { + await store.upsertFromSignIn("identity-1", { + kind: "access_only_nonexpiring", + accessToken: "initial-access-token", + }); + const staleCredentialMutation = await store.prepareSignInUpsert("identity-1", { + kind: "access_only_nonexpiring", + accessToken: "stale-sign-in-token", + }); + await store.upsertFromSignIn("identity-1", { + kind: "access_only_nonexpiring", + accessToken: "concurrent-sign-in-token", + }); + + let rejection: unknown; + try { + await env.DB.batch([ + env.DB.prepare("UPDATE users SET display_name = 'must-roll-back' WHERE id = 'user-1'"), + staleCredentialMutation, + ]); + } catch (error) { + rejection = error; + } + + expect(rejection).toBeInstanceOf(Error); + expect(store.isSignInVersionConflict(rejection)).toBe(true); + await expect( + env.DB.prepare("SELECT display_name FROM users WHERE id = 'user-1'").first() + ).resolves.toEqual({ display_name: null }); + await expect(store.get("identity-1")).resolves.toMatchObject({ + accessToken: "concurrent-sign-in-token", + rowVersion: 2, + }); + }); + it("fails closed when authenticated ciphertext decodes to an empty token", async () => { const emptyPlaintextCipher: ProviderCredentialCipherPort = { encrypt: async () => "authenticated-ciphertext", From 17351af361a4a3f37c1ae127c17d2ef939fdfd7e Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 26 Jul 2026 17:21:47 -0700 Subject: [PATCH 2/6] Allow control-plane integration tests to complete (#1124) ## Summary - increase the control-plane integration job timeout from five to ten minutes - leave every test command and all other CI limits unchanged ## Why The complete integration suite now runs close to five minutes after dependency installation and workerd setup. A production validation run reached the existing job limit and GitHub cancelled the still-running test process without a failing assertion. Ten minutes preserves a finite bound while allowing the suite to report its actual result. ## Impact This changes CI scheduling only. It does not change application code, deployment behavior, or test semantics. ## Validation - Prettier check passed for the workflow - git diff --check passed - the full control-plane integration suite passed locally before this timeout adjustment ## Summary by CodeRabbit * **Chores** * Increased the integration test workflow timeout to reduce failures caused by slow test runs. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 595c0d853..9bd2a19d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,7 +221,7 @@ jobs: test-cp-integration: name: Test (control-plane integration) runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 needs: [lint-typescript, typecheck-typescript] steps: - name: Checkout From 882bcabbb2ccb8986a32f60bc74a706124e75681 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 26 Jul 2026 17:37:20 -0700 Subject: [PATCH 3/6] Add the Better Auth runtime and D1 schema (#1125) ## Summary - pin Better Auth 1.6.25 in the control plane - add the minimal control-plane-owned browser-auth configuration boundary - make Better Auth generate canonical Open Inspect user IDs - add the additive Better Auth user, account, session, and verification schema - enforce uniqueness for immutable provider identities - make the pinned runtime validate the complete static schema contract in workerd with real D1 ## Why This is the inert foundation for replacing the custom browser OAuth runtime with the Better Auth implementation already validated in production. No routes call this configuration yet, so this PR does not change current browser authentication behavior. The schema is intentionally browser-specific and additive. It does not predict CLI, PAT, MFA, magic-link, or future Okta storage. Implicit account linking is disabled; explicit linking remains follow-up work. Before routes activate, Better Auth user IDs are projected unchanged into canonical users.id and auth_accounts becomes the browser-provider credential authority; the legacy credential table is not dual-written. ## Rollout The CI timeout prerequisite in #1124 has merged. This branch is rebased directly on current main and contains only the Better Auth runtime and schema foundation. ## Validation - control-plane lint passed - control-plane typecheck passed - control-plane build passed - 141 control-plane unit files, 2,175 tests passed - 62 control-plane integration files, 716 tests passed - focused Better Auth workerd/D1 integration: 3 tests passed - schema contract checks cover pinned-runtime drift, every column shape, foreign keys, and the custom provider-identity index - git diff --check passed ## Dependency audit The production audit reports the existing public-main Next.js/PostCSS/sharp advisories. Better Auth adds another dependency path to the same installed Next version; it does not introduce a new advisory or a second vulnerable package version. ## Summary by CodeRabbit * **New Features** * Enabled browser authentication for the control plane with secure, fixed cookie behavior. * Added the core authentication database schema for users, sessions, linked accounts, and verification records. * Added an API behavior for anonymous session checks (returns `200` with `null` session). * **Bug Fixes** * Enforced provider/account uniqueness, email uniqueness, and cascading cleanup for user-related auth records. * **Tests** * Added integration coverage to validate the browser-auth runtime schema and request/session boundary behavior. --- package-lock.json | 454 ++++++++++++++++-- packages/control-plane/package.json | 1 + .../control-plane/src/auth/browser-auth.ts | 59 +++ .../test/integration/browser-auth.test.ts | 151 ++++++ .../d1/migrations/0048_better_auth_core.sql | 72 +++ 5 files changed, 694 insertions(+), 43 deletions(-) create mode 100644 packages/control-plane/src/auth/browser-auth.ts create mode 100644 packages/control-plane/test/integration/browser-auth.test.ts create mode 100644 terraform/d1/migrations/0048_better_auth_core.sql diff --git a/package-lock.json b/package-lock.json index 1ef2011e6..60fd1e182 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1413,6 +1413,33 @@ "node": ">=18" } }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-auth/utils/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1817,6 +1844,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2572,6 +2600,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2594,6 +2623,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2616,6 +2646,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2632,6 +2663,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2648,6 +2680,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2664,6 +2697,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2680,6 +2714,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2696,6 +2731,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2712,6 +2748,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2728,6 +2765,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2744,6 +2782,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2760,6 +2799,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2776,6 +2816,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2798,6 +2839,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2820,6 +2862,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2842,6 +2885,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2864,6 +2908,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2886,6 +2931,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2908,6 +2954,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2930,6 +2977,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2952,6 +3000,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2971,6 +3020,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2990,6 +3040,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3009,6 +3060,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3163,9 +3215,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3182,9 +3231,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3201,9 +3247,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3220,9 +3263,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3504,11 +3544,20 @@ "node": ">=18" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -4830,7 +4879,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@shikijs/core": { @@ -5641,7 +5690,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -5724,7 +5773,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -6120,7 +6169,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -6138,7 +6187,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/spy": "4.1.9", @@ -6165,7 +6214,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tinyrainbow": "^3.1.0" @@ -6178,7 +6227,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/utils": "4.1.9", @@ -6192,7 +6241,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.9", @@ -6208,7 +6257,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -6218,7 +6267,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.9", @@ -6600,7 +6649,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -6744,6 +6793,282 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.25.tgz", + "integrity": "sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.25", + "@better-auth/drizzle-adapter": "1.6.25", + "@better-auth/kysely-adapter": "1.6.25", + "@better-auth/memory-adapter": "1.6.25", + "@better-auth/mongo-adapter": "1.6.25", + "@better-auth/prisma-adapter": "1.6.25", + "@better-auth/telemetry": "1.6.25", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.7", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/core": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.25.tgz", + "integrity": "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.7", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.25.tgz", + "integrity": "sha512-ru/DeKjFPQUVeKkxF/ScazmPqIY7lwfkAV5Yt4j24wmn1Y8vFwoiPRnHgXUeZqBs10+nubaRwEqLF39CP6EhRw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/kysely-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.25.tgz", + "integrity": "sha512-zxiePhtN1YClS1irKYPVwWfN6kYp+QoYlz1hdQUOj8hXyo2aE/ny4RNAb6v332b0+U6Vu88EhYITRPdmvCo6uA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/memory-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.25.tgz", + "integrity": "sha512-GhEzTumc8yfTz+OZ6pMg06BA49xob49x1bX+1mEl/FStDJoSF+6mTfI5M2ytFxaiN89336/aUjkW8u+qRyLexw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/better-auth/node_modules/@better-auth/mongo-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.25.tgz", + "integrity": "sha512-ZtMmjcOdXR2Ziqx5y8ptTOaNpe0snNfALbBUPXJsgeyeRkDJDYzyLZ8MpuvNBTNllNeIFDbiXWAK5k+pEBZrUQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/prisma-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.25.tgz", + "integrity": "sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/telemetry": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.25.tgz", + "integrity": "sha512-2ZfC9lp7tU6Jw/q2Lz/bKfQqGMdMwc/IQDTYdBhvtGi24qInYVnhp2ZCW57hHM9j+fq1ULOtxgg6M3T1LEaihw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/better-auth/node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/better-call": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6997,7 +7322,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -7362,7 +7687,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cookie": { @@ -7746,6 +8071,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8130,7 +8461,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -8509,7 +8840,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -8601,7 +8932,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -10466,6 +10797,15 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10484,7 +10824,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -10952,7 +11292,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -12099,6 +12439,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.1.tgz", + "integrity": "sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -12513,7 +12868,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -12821,7 +13176,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -13772,7 +14127,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.133.0", @@ -13802,6 +14157,12 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -13991,6 +14352,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -14227,7 +14594,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -14327,7 +14694,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/statuses": { @@ -14344,7 +14711,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -14842,14 +15209,14 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -14875,7 +15242,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -15502,7 +15869,7 @@ "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -15580,7 +15947,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/expect": "4.1.9", @@ -15832,7 +16199,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -16164,6 +16531,7 @@ "dependencies": { "@cloudflare/workers-types": "^4.20241230.0", "@open-inspect/shared": "file:../shared", + "better-auth": "1.6.25", "oauth4webapi": "3.8.6", "zod": "^4.1.13" }, diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index e1a10dbda..4a077fe78 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -16,6 +16,7 @@ "dependencies": { "@cloudflare/workers-types": "^4.20241230.0", "@open-inspect/shared": "file:../shared", + "better-auth": "1.6.25", "oauth4webapi": "3.8.6", "zod": "^4.1.13" }, diff --git a/packages/control-plane/src/auth/browser-auth.ts b/packages/control-plane/src/auth/browser-auth.ts new file mode 100644 index 000000000..2245b06ba --- /dev/null +++ b/packages/control-plane/src/auth/browser-auth.ts @@ -0,0 +1,59 @@ +import { betterAuth } from "better-auth"; +import { generateId } from "./crypto"; + +const MS_PER_SECOND = 1000; + +export const BROWSER_AUTH_SESSION_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * MS_PER_SECOND; +export const BROWSER_AUTH_SESSION_UPDATE_AGE_MS = 24 * 60 * 60 * MS_PER_SECOND; + +export interface BrowserAuthConfig { + readonly database: D1Database; + readonly publicWebOrigin: string; + readonly secret: string; +} + +/** + * Creates the control plane's browser-authentication authority. + * + * `publicWebOrigin` is deliberately the browser-visible web origin rather than + * the control-plane origin. The web transparently proxies this handler, so all + * redirects and host-only cookies remain scoped to the web application. + */ +export function createBrowserAuth(config: BrowserAuthConfig) { + return betterAuth({ + baseURL: config.publicWebOrigin, + database: config.database, + secret: config.secret, + trustedOrigins: [config.publicWebOrigin], + telemetry: { enabled: false }, + advanced: { + cookiePrefix: "openinspect", + useSecureCookies: true, + // Browser authentication and application authorization share the same + // canonical user ID. The activation layer projects this ID into users.id + // before any Better Auth route is exposed. + database: { + generateId: () => generateId(), + }, + }, + user: { + modelName: "auth_users", + }, + session: { + modelName: "auth_sessions", + expiresIn: BROWSER_AUTH_SESSION_EXPIRES_IN_MS / MS_PER_SECOND, + updateAge: BROWSER_AUTH_SESSION_UPDATE_AGE_MS / MS_PER_SECOND, + }, + account: { + modelName: "auth_accounts", + accountLinking: { + disableImplicitLinking: true, + }, + encryptOAuthTokens: true, + }, + verification: { + modelName: "auth_verifications", + storeIdentifier: "hashed", + }, + }); +} diff --git a/packages/control-plane/test/integration/browser-auth.test.ts b/packages/control-plane/test/integration/browser-auth.test.ts new file mode 100644 index 000000000..f57631645 --- /dev/null +++ b/packages/control-plane/test/integration/browser-auth.test.ts @@ -0,0 +1,151 @@ +import { env } from "cloudflare:test"; +import { getMigrations } from "better-auth/db/migration"; +import { describe, expect, it } from "vitest"; +import { + BROWSER_AUTH_SESSION_EXPIRES_IN_MS, + BROWSER_AUTH_SESSION_UPDATE_AGE_MS, + createBrowserAuth, +} from "../../src/auth/browser-auth"; + +const PUBLIC_WEB_ORIGIN = "https://web.test.local"; +const SECRET = "test-only-better-auth-secret-with-at-least-32-characters"; +const MS_PER_SECOND = 1000; + +const EXPECTED_COLUMNS = { + auth_users: [ + ["id", "TEXT", 1, 1], + ["name", "TEXT", 1, 0], + ["email", "TEXT", 1, 0], + ["emailVerified", "INTEGER", 1, 0], + ["image", "TEXT", 0, 0], + ["createdAt", "DATE", 1, 0], + ["updatedAt", "DATE", 1, 0], + ], + auth_sessions: [ + ["id", "TEXT", 1, 1], + ["expiresAt", "DATE", 1, 0], + ["token", "TEXT", 1, 0], + ["createdAt", "DATE", 1, 0], + ["updatedAt", "DATE", 1, 0], + ["ipAddress", "TEXT", 0, 0], + ["userAgent", "TEXT", 0, 0], + ["userId", "TEXT", 1, 0], + ], + auth_accounts: [ + ["id", "TEXT", 1, 1], + ["accountId", "TEXT", 1, 0], + ["providerId", "TEXT", 1, 0], + ["userId", "TEXT", 1, 0], + ["accessToken", "TEXT", 0, 0], + ["refreshToken", "TEXT", 0, 0], + ["idToken", "TEXT", 0, 0], + ["accessTokenExpiresAt", "DATE", 0, 0], + ["refreshTokenExpiresAt", "DATE", 0, 0], + ["scope", "TEXT", 0, 0], + ["password", "TEXT", 0, 0], + ["createdAt", "DATE", 1, 0], + ["updatedAt", "DATE", 1, 0], + ], + auth_verifications: [ + ["id", "TEXT", 1, 1], + ["identifier", "TEXT", 1, 0], + ["value", "TEXT", 1, 0], + ["expiresAt", "DATE", 1, 0], + ["createdAt", "DATE", 1, 0], + ["updatedAt", "DATE", 1, 0], + ], +} as const; + +function createTestAuth() { + return createBrowserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + }); +} + +describe("browser authentication", () => { + it("keeps the static schema aligned with the pinned Better Auth runtime", async () => { + const migrations = await getMigrations(createTestAuth().options); + expect(migrations.toBeCreated).toEqual([]); + expect(migrations.toBeAdded).toEqual([]); + + for (const [table, expectedColumns] of Object.entries(EXPECTED_COLUMNS)) { + const columns = await env.DB.prepare(`PRAGMA table_info(${table})`).all<{ + name: string; + type: string; + notnull: number; + pk: number; + }>(); + expect( + columns.results.map(({ name, type, notnull, pk }) => [name, type, notnull, pk]) + ).toEqual(expectedColumns); + } + + const providerIdentityIndex = await env.DB.prepare( + `SELECT "unique" + FROM pragma_index_list('auth_accounts') + WHERE name = 'idx_auth_accounts_provider_identity'` + ).first<{ unique: number }>(); + expect(providerIdentityIndex?.unique).toBe(1); + + const providerIdentityColumns = await env.DB.prepare( + `SELECT name + FROM pragma_index_info('idx_auth_accounts_provider_identity') + ORDER BY seqno` + ).all<{ name: string }>(); + expect(providerIdentityColumns.results.map(({ name }) => name)).toEqual([ + "providerId", + "accountId", + ]); + + for (const table of ["auth_sessions", "auth_accounts"]) { + const foreignKeys = await env.DB.prepare(`PRAGMA foreign_key_list(${table})`).all<{ + table: string; + from: string; + to: string; + on_delete: string; + }>(); + expect( + foreignKeys.results.map(({ table, from, to, on_delete }) => ({ + table, + from, + to, + onDelete: on_delete, + })) + ).toEqual([ + { + table: "auth_users", + from: "userId", + to: "id", + onDelete: "CASCADE", + }, + ]); + } + }); + + it("serves an anonymous session through Better Auth on Workers and D1", async () => { + const auth = createTestAuth(); + const response = await auth.handler(new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/get-session`)); + + expect(response.status).toBe(200); + expect(await response.json()).toBeNull(); + }); + + it("uses canonical ids and converts millisecond durations at the library boundary", () => { + const auth = createTestAuth(); + const generateId = auth.options.advanced?.database?.generateId; + + expect(generateId).toBeTypeOf("function"); + if (typeof generateId !== "function") { + throw new Error("Better Auth canonical ID generator is not configured"); + } + expect(generateId({ model: "user" })).toMatch(/^[a-f0-9]{32}$/); + expect(auth.options.session?.expiresIn).toBe( + BROWSER_AUTH_SESSION_EXPIRES_IN_MS / MS_PER_SECOND + ); + expect(auth.options.session?.updateAge).toBe( + BROWSER_AUTH_SESSION_UPDATE_AGE_MS / MS_PER_SECOND + ); + }); +}); diff --git a/terraform/d1/migrations/0048_better_auth_core.sql b/terraform/d1/migrations/0048_better_auth_core.sql new file mode 100644 index 000000000..266c6dcf9 --- /dev/null +++ b/terraform/d1/migrations/0048_better_auth_core.sql @@ -0,0 +1,72 @@ +-- Better Auth browser identity, account, and session authority. +-- +-- This schema is generated from the exact-pinned Better Auth 1.6.25 core +-- configuration in packages/control-plane/src/auth/browser-auth.ts. It is +-- additive and inert until the final browser-auth routes are activated. +-- +-- At activation, auth_users.id is projected unchanged into canonical users.id. +-- auth_accounts then owns browser-provider credentials; the legacy +-- provider_credentials table is not dual-written by this runtime. + +CREATE TABLE auth_users ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + emailVerified INTEGER NOT NULL, + image TEXT, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL, + CHECK (emailVerified IN (0, 1)) +); + +CREATE TABLE auth_sessions ( + id TEXT NOT NULL PRIMARY KEY, + expiresAt DATE NOT NULL, + token TEXT NOT NULL UNIQUE, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL, + ipAddress TEXT, + userAgent TEXT, + userId TEXT NOT NULL, + FOREIGN KEY (userId) REFERENCES auth_users(id) ON DELETE CASCADE +); + +CREATE INDEX auth_sessions_userId_idx + ON auth_sessions(userId); + +CREATE TABLE auth_accounts ( + id TEXT NOT NULL PRIMARY KEY, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + userId TEXT NOT NULL, + accessToken TEXT, + refreshToken TEXT, + idToken TEXT, + accessTokenExpiresAt DATE, + refreshTokenExpiresAt DATE, + scope TEXT, + password TEXT, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL, + FOREIGN KEY (userId) REFERENCES auth_users(id) ON DELETE CASCADE +); + +CREATE INDEX auth_accounts_userId_idx + ON auth_accounts(userId); + +-- Better Auth 1.6.25 does not generate this uniqueness constraint. Provider +-- subject is the immutable sign-in identity and must resolve to one account. +CREATE UNIQUE INDEX idx_auth_accounts_provider_identity + ON auth_accounts(providerId, accountId); + +CREATE TABLE auth_verifications ( + id TEXT NOT NULL PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expiresAt DATE NOT NULL, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL +); + +CREATE INDEX auth_verifications_identifier_idx + ON auth_verifications(identifier); From 30727147bbc37c534eefebd65d591551645ce2fc Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Sun, 26 Jul 2026 22:36:32 -0700 Subject: [PATCH 4/6] Complete the Better Auth browser authentication cutover (#1126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Completes the browser-authentication cutover that began with #1125. The control plane now owns Better Auth, provider verification, admission, canonical user projection, sessions, and provider credentials. The Next.js app is a framework-free BFF that forwards an exact auth-route allowlist and authenticates application requests with both the web service's `sig1` channel and the browser's opaque session cookie. This is intentionally one complete cutover PR. The implementation has already been deployed and exercised in the production repository, including the production fixes discovered during that rehearsal. Merging it as one unit avoids leaving public `main` with overlapping Better Auth, NextAuth, bearer-token, and hand-rolled OAuth architectures. ## Resulting architecture 1. The browser calls the web application's `/api/auth/*` routes. 2. The web BFF forwards only the shared exact Better Auth route allowlist. 3. Every forwarded request is body-bound and signed as `service:web` with `sig1`. 4. The control plane verifies GitHub or Google identity evidence and applies admission policy. 5. Better Auth owns browser users, provider accounts, OAuth credentials, and opaque sessions in D1. 6. Better Auth user IDs are projected unchanged into canonical `users.id`. 7. Browser resource requests require both: - a valid `service:web` channel; and - a valid Better Auth session cookie. 8. Authorization receives `{ kind: "user", userId }`; provider provenance remains in the authentication context. 9. GitHub access and refresh remain Better Auth-owned. Session creation/prompt flows request a current access token through Better Auth and never copy the long-lived refresh token. ## Review guide The commits remain logically ordered so this large PR can be reviewed as vertical slices: 1. **Provider identity and admission** - defensive GitHub response validation and bounded pagination/retry behavior; - Google ID-token verification using Better Auth's provider implementation; - verified-email, GitHub-user, email-domain, and GitHub-organization admission. 2. **Signed auth surface and BFF proxy** - one shared exact route allowlist; - control-plane `sig1` enforcement; - transparent callback, cookie, redirect, and decoded-body header handling. 3. **Canonical identity and compound authentication** - canonical ID generation and user projection; - provider provenance separated from the authorized principal; - browser session plus web-channel validation. 4. **Web cutover and provider credentials** - Better Auth sign-in, sign-out, and session consumption; - authenticated BFF resource requests; - Better Auth-owned GitHub credential refresh and attribution. 5. **Deployment and legacy removal** - control-plane provider/auth configuration; - removal of NextAuth, web bearer tokens, and the superseded custom OAuth server; - provider secrets and admission policy removed from the web runtime. 6. **Production-rehearsal fixes** - stale response encoding/length headers; - bound Workers `fetch`; - legacy-user collision cleanup and account backfill; - multi-provider session handling; - workerd-compatible package resolution; - explicit rate limiting and trusted client-IP propagation. The final reconciliation commits remove custom OAuth files that were merged into public `main` after the production validation branch was created, remove the unused direct OAuth client dependency, and retain the schema-alignment and duration-unit improvements from #1125. The latest organization pass groups user and service authentication by responsibility, moves GitHub credential authority into source control, and narrows Better Auth dependencies at both boundaries. ## Migration and deployment behavior - `0049_backfill_better_auth_accounts.sql` idempotently seeds Better Auth users and immutable GitHub/Google accounts from the existing canonical model. - The backfill removes only partial Better Auth identity graphs left by the accepted non-atomic D1 adapter behavior when an existing canonical email caused projection to fail. - Existing provider access and refresh tokens are not copied. The next successful sign-in captures fresh Better Auth-owned credentials. - Existing browser sessions are intentionally invalidated; users sign in again after cutover. - The existing Terraform `nextauth_secret` input and Actions `NEXTAUTH_SECRET` name are retained as operator-facing compatibility names, but now supply control-plane `BROWSER_AUTH_SECRET`. - GitHub and Google callback URLs remain on the browser-visible web origin: - `/api/auth/callback/github` - `/api/auth/callback/google` - Legacy custom-auth D1 tables remain additive residue for now, but no runtime route or service consumes them. For existing Cloudflare web deployments, the uploader no longer sends OAuth or NextAuth secrets, but Wrangler preserves already-uploaded secrets. Operators should delete stale `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_SECRET`, and `NEXTAUTH_SECRET` values from the web Worker after validating the cutover. ## Security properties - Provider identities are immutable `(provider, subject)` accounts. - GitHub and Google successful responses are runtime-validated before admission. - Implicit account linking is disabled. - OAuth provider secrets and admission allowlists exist only in the control plane. - Browser resource routes have no service-credential-to-user fallback. - The BFF forwards only the opaque Better Auth session cookie and rejects malformed or duplicate session-cookie data. - Auth proxy and resource request bodies are covered by `sig1`. - Production cookies are secure, HTTP-only, host-only, and `SameSite=Lax`; insecure cookies are limited to exact loopback HTTP development origins. - Retired bearer-token and provider-identity endpoints are no longer routed. ## Accepted scope decisions - Better Auth's exact-pinned D1 adapter does not provide an interactive transaction. Initial sign-in can therefore leave recoverable partial state if a later write fails. This rollout accepts that risk; the migration repairs the known legacy-email collision. - Explicit account linking is deferred. The canonical model supports a future linking operation, but implicit email linking remains disabled. - Better Auth's in-memory limiter is best-effort per Worker isolate, not a global abuse-control system. - Better Auth stores its random session identifier in D1 and authenticates the browser cookie with `BROWSER_AUTH_SECRET`; this is not the literal hash-at-rest representation from the superseded custom design. ## Validation - Control-plane focused auth unit tests: **23 files, 207 tests passed** - Control-plane focused workerd/D1 integration tests: **4 files, 28 tests passed** - Control-plane unit tests: **136 files, 2,111 tests passed** - Control-plane integration tests: **57 files, 653 tests passed** - Web tests: **101 files, 790 tests passed** - Shared tests: **36 files, 489 tests passed** - GitHub bot tests: **7 files, 128 tests passed** - Slack bot tests: **28 files, 338 tests passed** - Linear bot tests: **13 files, 185 tests passed** - Full TypeScript typecheck: passed - Full ESLint: passed - Full workspace build, including the production web build: passed - Terraform recursive formatting check: passed - Terraform initialization and validation: passed - Thermo-nuclear maintainability review: passed with scoped cleanup applied ## Summary by CodeRabbit * **New Features** * Rolled out control-plane–driven browser authentication (GitHub, with optional Google) and a secure browser-auth proxy for web requests, enabling authenticated enrichment for session-related flows. * **Bug Fixes** * Prevented forwarding stale `Content-Length`/`Content-Encoding` when returning decoded streamed content (media/attachments/session diffs). * Improved access-denied messaging to consistently handle both legacy and new denial codes. * **Documentation** * Updated setup/deployment guides and environment examples to use the new browser-auth secret terminology and configuration expectations. --- .github/workflows/deploy-web.yml | 3 - docs/GETTING_STARTED.md | 7 +- docs/SETUP_GUIDE.md | 56 +- eslint.config.js | 42 +- package-lock.json | 169 +--- packages/control-plane/README.md | 16 + packages/control-plane/package.json | 3 +- .../src/auth/auth-encryption.test.ts | 143 --- .../control-plane/src/auth/auth-encryption.ts | 258 ------ .../src/auth/authenticate.test.ts | 72 +- .../control-plane/src/auth/authenticate.ts | 351 ++------ .../control-plane/src/auth/browser-auth.ts | 59 -- .../auth/browser-sign-in-identity-store.ts | 54 -- .../src/auth/browser-sign-in-identity.ts | 211 ----- .../github-app-permission-preflight.test.ts | 291 ------- .../auth/github-app-permission-preflight.ts | 170 ---- .../src/auth/identity-enforcement.test.ts | 71 +- .../src/auth/identity-enforcement.ts | 65 +- packages/control-plane/src/auth/index.ts | 7 - .../auth/oauth-authorization-service.test.ts | 193 ----- .../src/auth/oauth-authorization-service.ts | 130 --- .../src/auth/oauth-flow-state.ts | 53 -- .../src/auth/oauth-flow-verifier.ts | 23 - .../oauth-provider-callback-handler.test.ts | 113 --- .../auth/oauth-provider-callback-handler.ts | 55 -- .../oauth-provider-callback-service.test.ts | 432 ---------- .../auth/oauth-provider-callback-service.ts | 155 ---- packages/control-plane/src/auth/pkce.test.ts | 19 - packages/control-plane/src/auth/pkce.ts | 27 - packages/control-plane/src/auth/principal.ts | 12 +- .../src/auth/provider-credential-cipher.ts | 27 - .../src/auth/providers/github.test.ts | 292 ------- .../src/auth/providers/github.ts | 330 ------- .../src/auth/providers/google.test.ts | 303 ------- .../src/auth/providers/google.ts | 99 --- .../oidc-authorization-code-client.ts | 188 ---- .../src/auth/providers/types.test.ts | 81 -- .../control-plane/src/auth/providers/types.ts | 103 --- packages/control-plane/src/auth/result.ts | 25 + .../auth/{ => service}/callback-signing.ts | 2 +- .../control-plane/src/auth/service/config.ts | 26 + .../src/auth/service/request-authenticator.ts | 198 +++++ .../src/auth/subject-verification.test.ts | 253 ------ .../src/auth/subject-verification.ts | 187 ---- .../control-plane/src/auth/token-exchange.ts | 73 -- .../auth/{ => user}/admission-policy.test.ts | 85 +- .../src/auth/{ => user}/admission-policy.ts | 14 +- .../src/auth/user/better-auth.ts | 107 +++ .../auth/user/canonical-user-projection.ts | 18 + .../auth/{ => user}/provider-credential.ts | 0 .../src/auth/user/provider-profile.ts | 21 + .../auth/{ => user}/providers/constants.ts | 0 .../user/providers/github-identity.test.ts | 245 ++++++ .../auth/user/providers/github-identity.ts | 172 ++++ .../user/providers/github-profile.test.ts | 102 +++ .../src/auth/user/providers/github-profile.ts | 89 ++ .../user/providers/google-profile.test.ts | 90 ++ .../src/auth/user/providers/google-profile.ts | 79 ++ .../src/auth/user/providers/types.ts | 57 ++ .../src/auth/user/runtime.test.ts | 26 + .../control-plane/src/auth/user/runtime.ts | 165 ++++ .../auth/user/session-authenticator.test.ts | 48 ++ .../src/auth/user/session-authenticator.ts | 79 ++ .../auth/{ => user}/sign-in-provider.test.ts | 0 .../src/auth/{ => user}/sign-in-provider.ts | 2 +- .../src/auth/web-session-tokens.test.ts | 374 -------- .../src/auth/web-session-tokens.ts | 249 ------ .../control-plane/src/db/api-tokens.test.ts | 72 -- packages/control-plane/src/db/api-tokens.ts | 207 ----- .../db/browser-auth-legacy-migration.test.ts | 179 ++++ .../src/db/browser-auth-sessions.ts | 311 ------- .../src/db/browser-sign-in-identities.ts | 224 ----- .../src/db/canonical-user-projection.ts | 50 ++ .../src/db/oauth-authorization-codes.ts | 301 ------- .../control-plane/src/db/oauth-flow-state.ts | 305 ------- .../src/db/provider-credentials.ts | 613 ------------- .../src/router.analytics.test.ts | 4 +- .../control-plane/src/router.auth.test.ts | 37 +- .../src/router.create-session.test.ts | 8 +- .../src/router.provider-identities.test.ts | 83 -- .../src/router.scm-credentials.test.ts | 6 +- .../src/router.session-prompt.test.ts | 79 +- .../src/router.spawn-child.test.ts | 4 + packages/control-plane/src/router.ts | 118 +-- .../control-plane/src/routes/auth-tokens.ts | 150 ---- .../src/routes/automations.test.ts | 8 +- .../src/routes/browser-auth.test.ts | 27 + .../control-plane/src/routes/browser-auth.ts | 91 ++ .../src/routes/provider-identities.test.ts | 160 ---- .../src/routes/provider-identities.ts | 52 -- .../src/routes/session-create.ts | 28 +- .../src/routes/session-prompt.ts | 16 +- .../src/routes/session-runtime-proxy.test.ts | 8 +- packages/control-plane/src/routes/shared.ts | 7 +- .../src/scheduler/durable-object.ts | 30 +- .../session/callback-notification-service.ts | 2 +- .../src/session/identity.test.ts | 90 +- .../control-plane/src/session/identity.ts | 111 +++ .../github-credential-authority.test.ts | 135 +++ .../github-credential-authority.ts | 92 ++ packages/control-plane/src/types.ts | 8 + packages/control-plane/src/types/error.d.ts | 8 +- .../control-plane/src/worker-build.test.ts | 32 + .../test/integration/auth-tokens.test.ts | 669 -------------- .../test/integration/auth.test.ts | 2 +- .../integration/browser-auth-callback.test.ts | 368 ++++++++ .../integration/browser-auth-router.test.ts | 114 +++ .../integration/browser-auth-sessions.test.ts | 311 ------- .../test/integration/browser-auth.test.ts | 226 ++++- .../browser-sign-in-identity.test.ts | 807 ----------------- .../control-plane/test/integration/cleanup.ts | 2 +- .../control-plane/test/integration/helpers.ts | 111 ++- .../oauth-authorization-codes.test.ts | 333 ------- .../test/integration/oauth-flow-state.test.ts | 224 ----- .../oauth-provider-callback-service.test.ts | 172 ---- .../integration/provider-credentials.test.ts | 305 ------- .../test/integration/service-auth.test.ts | 59 +- packages/control-plane/tsconfig.test.json | 5 +- .../vitest.integration.config.ts | 31 + packages/control-plane/wrangler.jsonc | 3 +- .../shared/src/browser-auth-routes.test.ts | 19 + packages/shared/src/browser-auth-routes.ts | 22 + packages/shared/src/index.ts | 1 + packages/web/.env.example | 37 +- packages/web/README.md | 34 +- packages/web/package.json | 1 - packages/web/src/app/access-denied/page.tsx | 3 +- .../src/app/api/auth/[...auth]/route.test.ts | 27 + .../web/src/app/api/auth/[...auth]/route.ts | 4 + .../src/app/api/auth/[...nextauth]/route.ts | 6 - .../src/app/api/auth/oi-refresh/route.test.ts | 167 ---- .../web/src/app/api/auth/oi-refresh/route.ts | 66 -- .../web/src/app/api/automations/route.test.ts | 21 +- packages/web/src/app/api/automations/route.ts | 12 +- .../attachments/[attachmentId]/route.test.ts | 21 + .../[id]/attachments/[attachmentId]/route.ts | 8 +- .../[revisionId]/files/[fileId]/route.test.ts | 19 + .../diff/[revisionId]/files/[fileId]/route.ts | 6 +- .../app/api/sessions/[id]/diff/retry/route.ts | 2 +- .../[id]/media/[artifactId]/route.test.ts | 30 +- .../sessions/[id]/media/[artifactId]/route.ts | 8 +- .../api/sessions/[id]/ws-token/route.test.ts | 15 +- .../app/api/sessions/[id]/ws-token/route.ts | 12 +- .../web/src/app/api/sessions/route.test.ts | 117 +-- packages/web/src/app/api/sessions/route.ts | 20 +- packages/web/src/app/providers.test.tsx | 18 +- packages/web/src/app/providers.tsx | 19 +- .../src/components/sidebar-layout.test.tsx | 2 +- .../web-session-gate.integration.test.tsx | 77 -- .../src/components/web-session-gate.test.tsx | 204 ----- .../web/src/components/web-session-gate.tsx | 110 --- packages/web/src/lib/access-control.test.ts | 401 --------- packages/web/src/lib/access-control.ts | 103 --- packages/web/src/lib/auth-session.test.tsx | 169 +++- packages/web/src/lib/auth-session.tsx | 83 +- packages/web/src/lib/auth.test.ts | 816 ------------------ packages/web/src/lib/auth.ts | 469 ---------- .../web/src/lib/browser-auth-proxy.test.ts | 250 ++++++ packages/web/src/lib/browser-auth-proxy.ts | 162 ++++ .../src/lib/browser-auth-session-contract.ts | 27 + .../src/lib/browser-session-cookie.test.ts | 55 ++ .../web/src/lib/browser-session-cookie.ts | 41 + .../web/src/lib/build-auth-identity.test.ts | 111 +-- packages/web/src/lib/build-auth-identity.ts | 114 +-- .../lib/client-auth-boundary-eslint.test.ts | 4 +- .../src/lib/control-plane-transport.test.ts | 60 -- .../web/src/lib/control-plane-transport.ts | 85 +- packages/web/src/lib/control-plane.test.ts | 216 +++-- packages/web/src/lib/control-plane.ts | 164 ++-- packages/web/src/lib/current-user.test.ts | 68 -- packages/web/src/lib/current-user.ts | 108 --- .../web/src/lib/github-email-schema.test.ts | 35 - packages/web/src/lib/github-email-schema.ts | 12 - .../web/src/lib/github-org-membership.test.ts | 302 ------- packages/web/src/lib/github-org-membership.ts | 172 ---- packages/web/src/lib/oi-session.test.ts | 306 ------- packages/web/src/lib/oi-session.ts | 291 ------- .../lib/server-auth-boundary-eslint.test.ts | 4 +- .../web/src/lib/server-auth-session.test.ts | 110 ++- packages/web/src/lib/server-auth-session.ts | 41 +- packages/web/src/lib/session-cookie.test.ts | 159 ---- packages/web/src/lib/session-cookie.ts | 90 -- packages/web/src/lib/site-config.ts | 7 +- scripts/wrangler-secrets.sh | 25 +- terraform/README.md | 5 +- .../d1/migrations/0048_better_auth_core.sql | 2 +- .../0049_backfill_better_auth_accounts.sql | 104 +++ .../0050_purge_retired_api_tokens.sql | 4 + .../production/.terraform.lock.hcl | 21 + terraform/environments/production/checks.tf | 4 +- terraform/environments/production/locals.tf | 2 +- .../production/terraform.tfvars.example | 2 +- .../environments/production/variables.tf | 7 +- .../environments/production/web-cloudflare.tf | 17 +- .../environments/production/web-vercel.tf | 80 +- .../production/workers-control-plane.tf | 13 + 196 files changed, 5117 insertions(+), 16179 deletions(-) delete mode 100644 packages/control-plane/src/auth/auth-encryption.test.ts delete mode 100644 packages/control-plane/src/auth/auth-encryption.ts delete mode 100644 packages/control-plane/src/auth/browser-auth.ts delete mode 100644 packages/control-plane/src/auth/browser-sign-in-identity-store.ts delete mode 100644 packages/control-plane/src/auth/browser-sign-in-identity.ts delete mode 100644 packages/control-plane/src/auth/github-app-permission-preflight.test.ts delete mode 100644 packages/control-plane/src/auth/github-app-permission-preflight.ts delete mode 100644 packages/control-plane/src/auth/index.ts delete mode 100644 packages/control-plane/src/auth/oauth-authorization-service.test.ts delete mode 100644 packages/control-plane/src/auth/oauth-authorization-service.ts delete mode 100644 packages/control-plane/src/auth/oauth-flow-state.ts delete mode 100644 packages/control-plane/src/auth/oauth-flow-verifier.ts delete mode 100644 packages/control-plane/src/auth/oauth-provider-callback-handler.test.ts delete mode 100644 packages/control-plane/src/auth/oauth-provider-callback-handler.ts delete mode 100644 packages/control-plane/src/auth/oauth-provider-callback-service.test.ts delete mode 100644 packages/control-plane/src/auth/oauth-provider-callback-service.ts delete mode 100644 packages/control-plane/src/auth/pkce.test.ts delete mode 100644 packages/control-plane/src/auth/pkce.ts delete mode 100644 packages/control-plane/src/auth/provider-credential-cipher.ts delete mode 100644 packages/control-plane/src/auth/providers/github.test.ts delete mode 100644 packages/control-plane/src/auth/providers/github.ts delete mode 100644 packages/control-plane/src/auth/providers/google.test.ts delete mode 100644 packages/control-plane/src/auth/providers/google.ts delete mode 100644 packages/control-plane/src/auth/providers/oidc-authorization-code-client.ts delete mode 100644 packages/control-plane/src/auth/providers/types.test.ts delete mode 100644 packages/control-plane/src/auth/providers/types.ts create mode 100644 packages/control-plane/src/auth/result.ts rename packages/control-plane/src/auth/{ => service}/callback-signing.ts (90%) create mode 100644 packages/control-plane/src/auth/service/config.ts create mode 100644 packages/control-plane/src/auth/service/request-authenticator.ts delete mode 100644 packages/control-plane/src/auth/subject-verification.test.ts delete mode 100644 packages/control-plane/src/auth/subject-verification.ts delete mode 100644 packages/control-plane/src/auth/token-exchange.ts rename packages/control-plane/src/auth/{ => user}/admission-policy.test.ts (70%) rename packages/control-plane/src/auth/{ => user}/admission-policy.ts (93%) create mode 100644 packages/control-plane/src/auth/user/better-auth.ts create mode 100644 packages/control-plane/src/auth/user/canonical-user-projection.ts rename packages/control-plane/src/auth/{ => user}/provider-credential.ts (100%) create mode 100644 packages/control-plane/src/auth/user/provider-profile.ts rename packages/control-plane/src/auth/{ => user}/providers/constants.ts (100%) create mode 100644 packages/control-plane/src/auth/user/providers/github-identity.test.ts create mode 100644 packages/control-plane/src/auth/user/providers/github-identity.ts create mode 100644 packages/control-plane/src/auth/user/providers/github-profile.test.ts create mode 100644 packages/control-plane/src/auth/user/providers/github-profile.ts create mode 100644 packages/control-plane/src/auth/user/providers/google-profile.test.ts create mode 100644 packages/control-plane/src/auth/user/providers/google-profile.ts create mode 100644 packages/control-plane/src/auth/user/providers/types.ts create mode 100644 packages/control-plane/src/auth/user/runtime.test.ts create mode 100644 packages/control-plane/src/auth/user/runtime.ts create mode 100644 packages/control-plane/src/auth/user/session-authenticator.test.ts create mode 100644 packages/control-plane/src/auth/user/session-authenticator.ts rename packages/control-plane/src/auth/{ => user}/sign-in-provider.test.ts (100%) rename packages/control-plane/src/auth/{ => user}/sign-in-provider.ts (77%) delete mode 100644 packages/control-plane/src/auth/web-session-tokens.test.ts delete mode 100644 packages/control-plane/src/auth/web-session-tokens.ts delete mode 100644 packages/control-plane/src/db/api-tokens.test.ts delete mode 100644 packages/control-plane/src/db/api-tokens.ts create mode 100644 packages/control-plane/src/db/browser-auth-legacy-migration.test.ts delete mode 100644 packages/control-plane/src/db/browser-auth-sessions.ts delete mode 100644 packages/control-plane/src/db/browser-sign-in-identities.ts create mode 100644 packages/control-plane/src/db/canonical-user-projection.ts delete mode 100644 packages/control-plane/src/db/oauth-authorization-codes.ts delete mode 100644 packages/control-plane/src/db/oauth-flow-state.ts delete mode 100644 packages/control-plane/src/db/provider-credentials.ts delete mode 100644 packages/control-plane/src/router.provider-identities.test.ts delete mode 100644 packages/control-plane/src/routes/auth-tokens.ts create mode 100644 packages/control-plane/src/routes/browser-auth.test.ts create mode 100644 packages/control-plane/src/routes/browser-auth.ts delete mode 100644 packages/control-plane/src/routes/provider-identities.test.ts delete mode 100644 packages/control-plane/src/routes/provider-identities.ts create mode 100644 packages/control-plane/src/source-control/github-credential-authority.test.ts create mode 100644 packages/control-plane/src/source-control/github-credential-authority.ts create mode 100644 packages/control-plane/src/worker-build.test.ts delete mode 100644 packages/control-plane/test/integration/auth-tokens.test.ts create mode 100644 packages/control-plane/test/integration/browser-auth-callback.test.ts create mode 100644 packages/control-plane/test/integration/browser-auth-router.test.ts delete mode 100644 packages/control-plane/test/integration/browser-auth-sessions.test.ts delete mode 100644 packages/control-plane/test/integration/browser-sign-in-identity.test.ts delete mode 100644 packages/control-plane/test/integration/oauth-authorization-codes.test.ts delete mode 100644 packages/control-plane/test/integration/oauth-flow-state.test.ts delete mode 100644 packages/control-plane/test/integration/oauth-provider-callback-service.test.ts delete mode 100644 packages/control-plane/test/integration/provider-credentials.test.ts create mode 100644 packages/shared/src/browser-auth-routes.test.ts create mode 100644 packages/shared/src/browser-auth-routes.ts create mode 100644 packages/web/src/app/api/auth/[...auth]/route.test.ts create mode 100644 packages/web/src/app/api/auth/[...auth]/route.ts delete mode 100644 packages/web/src/app/api/auth/[...nextauth]/route.ts delete mode 100644 packages/web/src/app/api/auth/oi-refresh/route.test.ts delete mode 100644 packages/web/src/app/api/auth/oi-refresh/route.ts delete mode 100644 packages/web/src/components/web-session-gate.integration.test.tsx delete mode 100644 packages/web/src/components/web-session-gate.test.tsx delete mode 100644 packages/web/src/components/web-session-gate.tsx delete mode 100644 packages/web/src/lib/access-control.test.ts delete mode 100644 packages/web/src/lib/access-control.ts delete mode 100644 packages/web/src/lib/auth.test.ts delete mode 100644 packages/web/src/lib/auth.ts create mode 100644 packages/web/src/lib/browser-auth-proxy.test.ts create mode 100644 packages/web/src/lib/browser-auth-proxy.ts create mode 100644 packages/web/src/lib/browser-auth-session-contract.ts create mode 100644 packages/web/src/lib/browser-session-cookie.test.ts create mode 100644 packages/web/src/lib/browser-session-cookie.ts delete mode 100644 packages/web/src/lib/control-plane-transport.test.ts delete mode 100644 packages/web/src/lib/current-user.test.ts delete mode 100644 packages/web/src/lib/current-user.ts delete mode 100644 packages/web/src/lib/github-email-schema.test.ts delete mode 100644 packages/web/src/lib/github-email-schema.ts delete mode 100644 packages/web/src/lib/github-org-membership.test.ts delete mode 100644 packages/web/src/lib/github-org-membership.ts delete mode 100644 packages/web/src/lib/oi-session.test.ts delete mode 100644 packages/web/src/lib/oi-session.ts delete mode 100644 packages/web/src/lib/session-cookie.test.ts delete mode 100644 packages/web/src/lib/session-cookie.ts create mode 100644 terraform/d1/migrations/0049_backfill_better_auth_accounts.sql create mode 100644 terraform/d1/migrations/0050_purge_retired_api_tokens.sql diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index ee7a14dc0..99702dcea 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -67,9 +67,6 @@ jobs: - name: Build Project if: steps.check-secrets.outputs.configured == 'true' run: vercel build --prod --token=${{ secrets.VERCEL_API_TOKEN }} - env: - # Required for NextAuth static generation - set via GitHub secret or Vercel will provide - NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL || 'https://localhost:3000' }} - name: Deploy to Vercel if: steps.check-secrets.outputs.configured == 'true' diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 14ae79ef8..2aae97810 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -440,7 +440,7 @@ echo "repo_secrets_encryption_key: $(openssl rand -base64 32)" # Modal API secret (use hex for this one) echo "modal_api_secret: $(openssl rand -hex 32)" -# NextAuth secret +# Browser authentication secret (Terraform retains the legacy input name) echo "nextauth_secret: $(openssl rand -base64 32)" # GitHub webhook secret (only if enabling GitHub bot) @@ -822,7 +822,7 @@ cloudflare_custom_domain = "app.example.com" # bare hostname, no scheme Cloudflare provisions the DNS record and edge certificate automatically. Notes: -- The web app URL — including `NEXTAUTH_URL` and the links the bots send — becomes +- The canonical browser-auth origin and the links the bots send become `https://{your-custom-domain}`, and the workers.dev route for the web Worker is disabled so the app has a single canonical origin. - Update the GitHub App callback URL (and the Google redirect URI, if Google login is enabled) to @@ -918,7 +918,6 @@ Go to your fork's Settings → Secrets and variables → Actions, and add: | `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ | | `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ | | `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ | -| `NEXTAUTH_URL` | Your web app URL | | `MODAL_TOKEN_ID` | Modal token ID | | `MODAL_TOKEN_SECRET` | Modal token secret | | `MODAL_WORKSPACE` | Modal workspace name | @@ -955,7 +954,7 @@ Go to your fork's Settings → Secrets and variables → Actions, and add: | `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) | | `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) | | `MODAL_API_SECRET` | Generated Modal API secret | -| `NEXTAUTH_SECRET` | Generated NextAuth secret | +| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) | | `ALLOWED_USERS` | Comma-separated GitHub usernames (or empty for all users) | | `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (or empty for all domains) | | `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) | diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index 81deaf1d0..dc5343715 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -60,8 +60,9 @@ What this does: ## Path A: Run the Web App Locally (Recommended Quick Start) -Use this when you already have a deployed control plane and sandbox backend, and only need local UI -development. +Use this with a dedicated development control plane whose `WEB_APP_URL` is `http://localhost:3000`. +Browser auth is origin-bound, so a production control plane configured for its deployed web origin +cannot authenticate a localhost web process. ### 1. Create local env file @@ -74,23 +75,11 @@ cp packages/web/.env.example packages/web/.env.local Edit `packages/web/.env.local`: ```bash -# GitHub App OAuth -GITHUB_CLIENT_ID=your_github_app_client_id -GITHUB_CLIENT_SECRET=your_github_app_client_secret - -# Google OAuth (optional — enables "Sign in with Google"). Create a Web OAuth -# client at https://console.cloud.google.com/apis/credentials with redirect URI -# http://localhost:3000/api/auth/callback/google. Set NEXT_PUBLIC_GOOGLE_ENABLED=true -# to reveal the button (inlined at build time — restart the dev server after changing). -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= +# Match the providers configured on the development control plane. This value +# is inlined at build time, so restart the dev server after changing it. NEXT_PUBLIC_GOOGLE_ENABLED= -# NextAuth -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=your_generated_secret - -# Backend endpoints (deployed) +# Development control-plane endpoints CONTROL_PLANE_URL=https://open-inspect-control-plane-..workers.dev NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane-..workers.dev @@ -99,17 +88,6 @@ NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane-..workers.d # terraform state or the deployed web app's env). SERVICE_AUTH_SECRET=your_web_service_secret -# Optional access control (a user is admitted if they match ANY allowlist) -ALLOWED_USERS= -ALLOWED_EMAIL_DOMAINS= -# Exact emails (any provider's verified email) — for users on shared domains -ALLOWED_EMAILS= -# GitHub orgs whose active members can sign in. Requests read:org only when set, -# then checks active org membership with the user's OAuth token. Requires GitHub -# App Organization permissions: Members read-only. -ALLOWED_GITHUB_ORGS= -UNSAFE_ALLOW_ALL_USERS=false - # Optional whitelabel branding (defaults shown). NEXT_PUBLIC_* vars are # inlined into the client bundle at build time — restart `npm run dev` # after changing them. @@ -121,16 +99,18 @@ NEXT_PUBLIC_APP_ICON_URL= Do not commit `packages/web/.env.local`. -Generate a secret value for `NEXTAUTH_SECRET` (never for `SERVICE_AUTH_SECRET`, which must be read -from the deployment as described above): - -```bash -openssl rand -base64 32 -``` +OAuth provider credentials are not web environment variables. Better Auth runs in the control plane, +so configure `github_client_id` and `github_client_secret`—and, when enabled, `google_client_id` and +`google_client_secret`—on the development control plane through Terraform. See +[Create GitHub App](GETTING_STARTED.md#step-3-create-github-app) and +[Enable Google Login](GETTING_STARTED.md#enable-google-login-optional) for the complete provider +setup. `NEXT_PUBLIC_GOOGLE_ENABLED` only controls whether the web UI offers Google sign-in and must +match the providers configured on the control plane. If you are using someone else's deployed backend, do not generate your own `SERVICE_AUTH_SECRET`. Use the web service secret configured in that backend deployment (the control plane only accepts -signatures under its own copy). +signatures under its own copy). That backend must also be configured with +`WEB_APP_URL=http://localhost:3000`; otherwise use its deployed web app rather than a local UI. ### 3. Configure GitHub callback URL @@ -235,9 +215,9 @@ Your GitHub callback URL does not exactly match the running app URL. ### Access denied after sign-in -Check `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, and `ALLOWED_GITHUB_ORGS` in -`packages/web/.env.local`. If `ALLOWED_GITHUB_ORGS` is set, make sure your GitHub App has -Organization permissions: Members read-only and that the updated permission was republished and +Check `allowed_users`, `allowed_email_domains`, `allowed_emails`, and `allowed_github_orgs` in the +control plane's Terraform configuration. If `allowed_github_orgs` is set, make sure your GitHub App +has Organization permissions: Members read-only and that the updated permission was republished and approved for the installation. ### Web can load, but session APIs return 401 diff --git a/eslint.config.js b/eslint.config.js index e1ed4fa64..902c65617 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -113,15 +113,10 @@ export default tseslint.config( }, }, - // Web BFF routes depend on the server-auth seam, not directly on the - // current authentication framework. The auth endpoints own the framework - // integration and are intentionally excluded. + // Web code depends on app-owned auth and request seams. OAuth and session + // protocol code is owned by the control plane. { - files: [ - "packages/web/src/app/api/**/*.{ts,tsx}", - "packages/web/src/lib/integration-settings-proxy.ts", - ], - ignores: ["packages/web/src/app/api/auth/**"], + files: ["packages/web/src/**/*.{ts,tsx}"], rules: { "no-restricted-imports": [ "error", @@ -129,38 +124,17 @@ export default tseslint.config( paths: [ { name: "next-auth", - message: "Use getServerAuthSession from @/lib/server-auth-session.", + message: "Use the app-owned browser authentication seams.", }, ], patterns: [ { - regex: "(?:^|/)lib/auth$", - message: "Use getServerAuthSession from @/lib/server-auth-session.", + group: ["next-auth/*"], + message: "Use the app-owned browser authentication seams.", }, - ], - }, - ], - }, - }, - - // Web code depends on app-owned auth and request seams so the terminal - // browser-auth implementation can replace NextAuth and add its request - // contract without another consumer migration. - { - files: ["packages/web/src/**/*.{ts,tsx}"], - ignores: [ - "packages/web/src/app/api/**", - "packages/web/src/lib/auth-session.tsx", - "packages/web/src/lib/auth-session.test.tsx", - ], - rules: { - "no-restricted-imports": [ - "error", - { - paths: [ { - name: "next-auth/react", - message: "Use the app-owned boundary from @/lib/auth-session.", + regex: "(?:^|/)lib/auth$", + message: "Use getServerAuthSession from @/lib/server-auth-session.", }, ], }, diff --git a/package-lock.json b/package-lock.json index 60fd1e182..2a816b15c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1384,6 +1384,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3563,15 +3564,6 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@panva/hkdf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", - "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/@pierre/diffs": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.12.tgz", @@ -10660,15 +10652,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -12524,47 +12507,6 @@ } } }, - "node_modules/next-auth": { - "version": "4.24.15", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.15.tgz", - "integrity": "sha512-NnjYtjrSOAx/TIVFGTX4IfI/9yHnNpi4B7FuLUwuV20v2Zxgr2OGP/YN0ynJuI7y8QOnTBPitfOdEXZrVvhIuA==", - "license": "ISC", - "dependencies": { - "@babel/runtime": "^7.20.13", - "@panva/hkdf": "^1.0.2", - "cookie": "^0.7.0", - "jose": "^4.15.5", - "oauth": "^0.9.15", - "openid-client": "^5.4.0", - "preact": "^10.6.3", - "preact-render-to-string": "^5.1.19", - "uuid": "^11.1.1" - }, - "peerDependencies": { - "@auth/core": "0.34.3", - "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", - "nodemailer": "^7.0.7", - "react": "^17.0.2 || ^18 || ^19", - "react-dom": "^17.0.2 || ^18 || ^19" - }, - "peerDependenciesMeta": { - "@auth/core": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, - "node_modules/next-auth/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -12716,21 +12658,6 @@ "node": ">=8" } }, - "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", - "license": "MIT" - }, - "node_modules/oauth4webapi": { - "version": "3.8.6", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", - "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -12740,15 +12667,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12878,15 +12796,6 @@ "node": ">=12.20.0" } }, - "node_modules/oidc-token-hash": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", - "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", - "license": "MIT", - "engines": { - "node": "^10.13.0 || >=12.0.0" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12943,33 +12852,6 @@ "regex-recursion": "^6.0.2" } }, - "node_modules/openid-client": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", - "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", - "license": "MIT", - "dependencies": { - "jose": "^4.15.9", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13415,34 +13297,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/preact": { - "version": "10.29.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", - "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/preact-render-to-string": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", - "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", - "license": "MIT", - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, - "node_modules/preact-render-to-string/node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -15792,19 +15646,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -16400,12 +16241,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -16532,7 +16367,6 @@ "@cloudflare/workers-types": "^4.20241230.0", "@open-inspect/shared": "file:../shared", "better-auth": "1.6.25", - "oauth4webapi": "3.8.6", "zod": "^4.1.13" }, "devDependencies": { @@ -16640,7 +16474,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "next": "^16.2.11", - "next-auth": "^4.24.15", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index c184f9d7f..7f9721a06 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -317,6 +317,22 @@ Automations: firing-time repository snapshot (`repo_owner/repo_name/repo_id/base_branch`) and the session linkage. Firing keys live on the invocation, not the run. +## Browser Authentication + +The control plane is the browser authentication authority and runs exact-pinned Better Auth. The +Next.js web app is a BFF: it proxies a small `/api/auth/*` allowlist with its `sig1` service +credential and never stores provider secrets. + +Browser resource requests require both the signed `service:web` channel and Better Auth's opaque +session cookie. The application principal is the canonical `users.id`, and the authentication +context contains only the browser-session and signed-channel evidence. Provider-specific credential +authorities resolve linked accounts on demand for workflows such as GitHub SCM enrichment; linked +accounts do not participate in browser-session authentication. + +Terraform configures `WEB_APP_URL`, provider credentials, admission allowlists, and +`BROWSER_AUTH_SECRET` on this worker. `WEB_APP_URL` must be the exact browser-visible HTTPS origin, +except that an HTTP loopback origin is accepted for local development. + ## Token Encryption GitHub OAuth tokens are encrypted at rest using AES-256-GCM: diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index 4a077fe78..5d764fd7c 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --platform=browser --target=es2022 --external:cloudflare:* --external:node:*", + "build": "esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --platform=browser --target=es2022 --conditions=workerd,module --external:cloudflare:* --external:node:*", "build:vercel-base-snapshot": "esbuild scripts/build-vercel-base-snapshot.ts --bundle --format=esm --platform=node --target=node22 --outfile=dist/vercel-base-snapshot.js", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -17,7 +17,6 @@ "@cloudflare/workers-types": "^4.20241230.0", "@open-inspect/shared": "file:../shared", "better-auth": "1.6.25", - "oauth4webapi": "3.8.6", "zod": "^4.1.13" }, "devDependencies": { diff --git a/packages/control-plane/src/auth/auth-encryption.test.ts b/packages/control-plane/src/auth/auth-encryption.test.ts deleted file mode 100644 index 083aa4c04..000000000 --- a/packages/control-plane/src/auth/auth-encryption.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { OAuthFlowVerifierIntegrityError } from "./oauth-flow-verifier"; -import { ProviderCredentialIntegrityError } from "./provider-credential-cipher"; -import { - InvalidAuthEncryptionRootError, - ProviderCredentialCipher, - ProviderPkceFlowCipher, - UnsupportedAuthEncryptionVersionError, - deriveAuthEncryptionKeyBytes, -} from "./auth-encryption"; - -const ROOT_KEY_BASE64 = Buffer.from( - "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", - "hex" -).toString("base64"); - -describe("browser auth encryption key derivation", () => { - it("derives stable, purpose-separated v1 keys from the configured root", async () => { - const providerKey = await deriveAuthEncryptionKeyBytes( - ROOT_KEY_BASE64, - "provider_credentials", - 1 - ); - const flowKey = await deriveAuthEncryptionKeyBytes(ROOT_KEY_BASE64, "provider_pkce_flow", 1); - - expect(Buffer.from(providerKey).toString("hex")).toBe( - "435bee8840d77453bcf2a1e08d603cb781b24d71d4b0488fbc475c45b21f4939" - ); - expect(Buffer.from(flowKey).toString("hex")).toBe( - "41a9432e1831f5202c5561c2d31e98d3ab1eff1df5dbd774f8a4c99ce567bdd4" - ); - expect(providerKey).not.toEqual(flowKey); - }); - - it("rejects malformed roots and unsupported key versions", async () => { - await expect( - deriveAuthEncryptionKeyBytes("not-a-32-byte-base64-key", "provider_pkce_flow", 1) - ).rejects.toBeInstanceOf(InvalidAuthEncryptionRootError); - await expect( - deriveAuthEncryptionKeyBytes(ROOT_KEY_BASE64, "provider_pkce_flow", 2) - ).rejects.toBeInstanceOf(UnsupportedAuthEncryptionVersionError); - }); -}); - -describe("ProviderPkceFlowCipher", () => { - it("binds a verifier to its flow, provider, and key version", async () => { - const cipher = new ProviderPkceFlowCipher(ROOT_KEY_BASE64); - const context = { - flowId: "flow-1", - provider: "google" as const, - keyVersion: 1, - }; - - const encrypted = await cipher.encrypt("provider-pkce-verifier", context); - - await expect(cipher.decrypt(encrypted, context)).resolves.toBe("provider-pkce-verifier"); - await expect( - cipher.decrypt(encrypted, { ...context, provider: "github" }) - ).rejects.toBeInstanceOf(OAuthFlowVerifierIntegrityError); - }); - - it("uses an injected initialization vector and preserves round-trip decryption", async () => { - const initializationVector = Uint8Array.from({ length: 12 }, (_, index) => index); - const cipher = new ProviderPkceFlowCipher(ROOT_KEY_BASE64, { - ivGenerator: { generate: () => initializationVector }, - }); - const context = { - flowId: "flow-1", - provider: "google" as const, - keyVersion: 1, - }; - - const encrypted = await cipher.encrypt("provider-pkce-verifier", context); - - expect(encrypted).toBe("AAECAwQFBgcICQoLU7ir/zg3qDSh4hffgBH4d57nSJPZYWPWIpEfJ+mJY7P039F+Idk="); - expect(Buffer.from(encrypted, "base64").subarray(0, initializationVector.byteLength)).toEqual( - Buffer.from(initializationVector) - ); - await expect(cipher.decrypt(encrypted, context)).resolves.toBe("provider-pkce-verifier"); - }); - - it("rejects an invalid initialization-vector length", async () => { - const cipher = new ProviderPkceFlowCipher(ROOT_KEY_BASE64, { - ivGenerator: { generate: () => new Uint8Array(11) }, - }); - - await expect( - cipher.encrypt("provider-pkce-verifier", { - flowId: "flow-1", - provider: "google", - keyVersion: 1, - }) - ).rejects.toThrow("Provider PKCE flow IV generator returned an invalid IV"); - }); -}); - -describe("ProviderCredentialCipher", () => { - it("binds ciphertext to its identity, shape, token role, and row version", async () => { - const initializationVector = Uint8Array.from({ length: 12 }, (_, index) => index); - const cipher = new ProviderCredentialCipher(ROOT_KEY_BASE64, { - ivGenerator: { generate: () => initializationVector }, - }); - const binding = { - providerIdentityId: "identity-1", - credentialKind: "refreshable" as const, - tokenRole: "access" as const, - encryptionKeyVersion: 1, - rowVersion: 3, - }; - const encrypted = await cipher.encrypt("provider-access-token", binding); - - expect(encrypted).toBe("AAECAwQFBgcICQoLiRyTliBzKgjV8xzRi0vqSQPLGq0sVzWGmPuFl+yGTHHBQtlqZQ=="); - await expect(cipher.decrypt(encrypted, binding)).resolves.toBe("provider-access-token"); - await expect( - cipher.decrypt(encrypted, { ...binding, providerIdentityId: "identity-2" }) - ).rejects.toBeInstanceOf(ProviderCredentialIntegrityError); - await expect( - cipher.decrypt(encrypted, { ...binding, credentialKind: "access_only_expiring" }) - ).rejects.toBeInstanceOf(ProviderCredentialIntegrityError); - await expect( - cipher.decrypt(encrypted, { ...binding, tokenRole: "refresh" }) - ).rejects.toBeInstanceOf(ProviderCredentialIntegrityError); - await expect(cipher.decrypt(encrypted, { ...binding, rowVersion: 4 })).rejects.toBeInstanceOf( - ProviderCredentialIntegrityError - ); - }); - - it("rejects an invalid initialization-vector length", async () => { - const cipher = new ProviderCredentialCipher(ROOT_KEY_BASE64, { - ivGenerator: { generate: () => new Uint8Array(11) }, - }); - - await expect( - cipher.encrypt("provider-access-token", { - providerIdentityId: "identity-1", - credentialKind: "access_only_nonexpiring", - tokenRole: "access", - encryptionKeyVersion: 1, - rowVersion: 1, - }) - ).rejects.toThrow("Provider credential IV generator returned an invalid IV"); - }); -}); diff --git a/packages/control-plane/src/auth/auth-encryption.ts b/packages/control-plane/src/auth/auth-encryption.ts deleted file mode 100644 index 00375103c..000000000 --- a/packages/control-plane/src/auth/auth-encryption.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { - OAuthFlowVerifierIntegrityError, - type OAuthFlowVerifierBinding, - type OAuthFlowVerifierCipher, -} from "./oauth-flow-verifier"; -import { - ProviderCredentialIntegrityError, - type ProviderCredentialCipherBinding, - type ProviderCredentialCipherPort, -} from "./provider-credential-cipher"; - -export type AuthEncryptionPurpose = "provider_credentials" | "provider_pkce_flow"; - -const HKDF_SALT = "openinspect/auth-key-derivation/v1"; -const V1_PURPOSE_INFO: Readonly> = { - provider_credentials: "openinspect/provider-credentials/v1", - provider_pkce_flow: "openinspect/provider-pkce-flow/v1", -}; - -export class UnsupportedAuthEncryptionVersionError extends Error { - constructor(readonly version: number) { - super("Unsupported authentication encryption version"); - this.name = "UnsupportedAuthEncryptionVersionError"; - } -} - -export class InvalidAuthEncryptionRootError extends Error { - constructor() { - super("Authentication encryption root must be exactly 32 base64-encoded bytes"); - this.name = "InvalidAuthEncryptionRootError"; - } -} - -function decodeRootKey(rootKeyBase64: string): Uint8Array { - if (!/^[A-Za-z0-9+/]{43}=$/.test(rootKeyBase64)) { - throw new InvalidAuthEncryptionRootError(); - } - - try { - const decoded = Uint8Array.from(atob(rootKeyBase64), (character) => character.charCodeAt(0)); - if (decoded.byteLength === 32) return decoded; - } catch { - // Normalize platform decoding errors into the stable configuration error. - } - throw new InvalidAuthEncryptionRootError(); -} - -export async function deriveAuthEncryptionKeyBytes( - rootKeyBase64: string, - purpose: AuthEncryptionPurpose, - version: number -): Promise { - if (version !== 1) throw new UnsupportedAuthEncryptionVersionError(version); - - const encoder = new TextEncoder(); - const rootKey = await crypto.subtle.importKey( - "raw", - decodeRootKey(rootKeyBase64), - "HKDF", - false, - ["deriveBits"] - ); - const bits = await crypto.subtle.deriveBits( - { - name: "HKDF", - hash: "SHA-256", - salt: encoder.encode(HKDF_SALT), - info: encoder.encode(V1_PURPOSE_INFO[purpose]), - }, - rootKey, - 256 - ); - return new Uint8Array(bits); -} - -export interface InitializationVectorGenerator { - generate(): Uint8Array; -} - -const AES_GCM_IV_BYTES = 12; -const AES_GCM_TAG_BYTES = 16; - -function providerPkceFlowAssociatedData(context: OAuthFlowVerifierBinding): Uint8Array { - return new TextEncoder().encode( - JSON.stringify(["provider_pkce_flow", context.flowId, context.provider, context.keyVersion]) - ); -} - -function encodeCiphertext(iv: Uint8Array, ciphertext: ArrayBuffer): string { - const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); - combined.set(iv); - combined.set(new Uint8Array(ciphertext), iv.byteLength); - return btoa(String.fromCharCode(...combined)); -} - -function decodeCiphertext( - value: string, - integrityError: () => Error -): { iv: Uint8Array; ciphertext: Uint8Array } { - try { - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { - throw new Error("Ciphertext is not canonical base64"); - } - const combined = Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); - if (combined.byteLength <= AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES) { - throw new Error("Ciphertext is too short"); - } - return { - iv: combined.slice(0, AES_GCM_IV_BYTES), - ciphertext: combined.slice(AES_GCM_IV_BYTES), - }; - } catch { - throw integrityError(); - } -} - -interface AesGcmPurposeCipherOptions { - readonly rootKeyBase64: string; - readonly purpose: AuthEncryptionPurpose; - readonly associatedData: (binding: Binding) => Uint8Array; - readonly keyVersion: (binding: Binding) => number; - readonly integrityError: () => Error; - readonly invalidIvError: () => Error; - readonly ivGenerator?: InitializationVectorGenerator; -} - -class AesGcmPurposeCipher { - private readonly keys = new Map>(); - private readonly ivGenerator: InitializationVectorGenerator; - - constructor(private readonly options: AesGcmPurposeCipherOptions) { - this.ivGenerator = options.ivGenerator ?? { - generate: () => crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES)), - }; - } - - async encrypt(plaintext: string, binding: Binding): Promise { - const iv = this.ivGenerator.generate(); - if (iv.byteLength !== AES_GCM_IV_BYTES) { - throw this.options.invalidIvError(); - } - const ciphertext = await crypto.subtle.encrypt( - { - name: "AES-GCM", - iv, - additionalData: this.options.associatedData(binding), - }, - await this.getKey(this.options.keyVersion(binding)), - new TextEncoder().encode(plaintext) - ); - return encodeCiphertext(iv, ciphertext); - } - - async decrypt(encrypted: string, binding: Binding): Promise { - const { iv, ciphertext } = decodeCiphertext(encrypted, this.options.integrityError); - try { - const plaintext = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv, - additionalData: this.options.associatedData(binding), - }, - await this.getKey(this.options.keyVersion(binding)), - ciphertext - ); - return new TextDecoder().decode(plaintext); - } catch (error) { - if (error instanceof UnsupportedAuthEncryptionVersionError) throw error; - if (error instanceof InvalidAuthEncryptionRootError) throw error; - throw this.options.integrityError(); - } - } - - private getKey(version: number): Promise { - const existing = this.keys.get(version); - if (existing) return existing; - - const derived = deriveAuthEncryptionKeyBytes( - this.options.rootKeyBase64, - this.options.purpose, - version - ).then((bytes) => - crypto.subtle.importKey("raw", bytes, { name: "AES-GCM", length: 256 }, false, [ - "encrypt", - "decrypt", - ]) - ); - this.keys.set(version, derived); - return derived; - } -} - -export class ProviderPkceFlowCipher implements OAuthFlowVerifierCipher { - private readonly cipher: AesGcmPurposeCipher; - - constructor( - rootKeyBase64: string, - dependencies: { readonly ivGenerator?: InitializationVectorGenerator } = {} - ) { - this.cipher = new AesGcmPurposeCipher({ - rootKeyBase64, - purpose: "provider_pkce_flow", - associatedData: providerPkceFlowAssociatedData, - keyVersion: (binding) => binding.keyVersion, - integrityError: () => new OAuthFlowVerifierIntegrityError(), - invalidIvError: () => new Error("Provider PKCE flow IV generator returned an invalid IV"), - ivGenerator: dependencies.ivGenerator, - }); - } - - encrypt(plaintext: string, context: OAuthFlowVerifierBinding): Promise { - return this.cipher.encrypt(plaintext, context); - } - - decrypt(encrypted: string, context: OAuthFlowVerifierBinding): Promise { - return this.cipher.decrypt(encrypted, context); - } -} - -function providerCredentialAssociatedData(context: ProviderCredentialCipherBinding): Uint8Array { - return new TextEncoder().encode( - JSON.stringify([ - "provider_credentials", - context.providerIdentityId, - context.credentialKind, - context.tokenRole, - context.encryptionKeyVersion, - context.rowVersion, - ]) - ); -} - -export class ProviderCredentialCipher implements ProviderCredentialCipherPort { - private readonly cipher: AesGcmPurposeCipher; - - constructor( - rootKeyBase64: string, - dependencies: { readonly ivGenerator?: InitializationVectorGenerator } = {} - ) { - this.cipher = new AesGcmPurposeCipher({ - rootKeyBase64, - purpose: "provider_credentials", - associatedData: providerCredentialAssociatedData, - keyVersion: (binding) => binding.encryptionKeyVersion, - integrityError: () => new ProviderCredentialIntegrityError(), - invalidIvError: () => new Error("Provider credential IV generator returned an invalid IV"), - ivGenerator: dependencies.ivGenerator, - }); - } - - encrypt(plaintext: string, context: ProviderCredentialCipherBinding): Promise { - return this.cipher.encrypt(plaintext, context); - } - - decrypt(encrypted: string, context: ProviderCredentialCipherBinding): Promise { - return this.cipher.decrypt(encrypted, context); - } -} diff --git a/packages/control-plane/src/auth/authenticate.test.ts b/packages/control-plane/src/auth/authenticate.test.ts index b56dbd422..5ec0e0975 100644 --- a/packages/control-plane/src/auth/authenticate.test.ts +++ b/packages/control-plane/src/auth/authenticate.test.ts @@ -309,6 +309,70 @@ describe("authenticate — service credentials", () => { }); }); +describe("authenticate — compound browser credentials", () => { + function createUserAuthContext( + session: { + session: { id: string; userId: string }; + user: { id: string }; + } | null + ): RequestContext { + const ctx = createCtx(); + ctx.getUserAuth = () => + ({ + api: { + getSession: vi.fn(async () => session), + }, + }) as never; + return ctx; + } + + it("requires the web sig1 channel and Better Auth session for a browser resource", async () => { + const request = await signedRequest({ + service: "web", + method: "GET", + url: "https://cp.test.local/sessions", + mutate: (headers) => { + headers.Cookie = "__Secure-openinspect.session_token=signed-session-token"; + }, + }); + const ctx = createUserAuthContext({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1" }, + }); + + const result = await authenticate(request, createEnv(), ctx, { + webService: "user", + }); + + expect(isAuthError(result)).toBe(false); + if (isAuthError(result)) return; + expect(result.principal).toEqual({ kind: "user", userId: "user-1" }); + expect(result.authentication).toEqual({ + mechanism: "browser_session", + credentialId: "session-1", + channel: { kind: "sig1", service: "web" }, + }); + }); + + it("does not let a valid web channel fall back when its browser session is absent", async () => { + const request = await signedRequest({ + service: "web", + method: "GET", + url: "https://cp.test.local/sessions", + }); + + const result = await authenticate(request, createEnv(), createUserAuthContext(null), { + webService: "user", + }); + + expect(result).toEqual({ + reason: "Unauthorized", + status: 401, + failedScheme: "browser-session", + }); + }); +}); + describe("authenticate — nonce replay logging", () => { it("warns on nonce reuse inside the validity window but still authenticates", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -340,10 +404,8 @@ describe("authenticate — nonce replay logging", () => { }); }); -describe("authenticate — web session token dispatch", () => { - it("dispatches oi_at_ bearers to token verification, never the shared bearer", async () => { - // An unknown token must fail as a user-token attempt (terminal), even - // though the same header would otherwise reach the shared-bearer arm. +describe("authenticate — retired web session tokens", () => { + it("treats oi_at_ bearers as unrecognized after the browser-session cutover", async () => { const request = new Request("https://cp.test.local/sessions", { headers: { Authorization: "Bearer oi_at_unknown-token-value" }, }); @@ -351,7 +413,7 @@ describe("authenticate — web session token dispatch", () => { expect(result).toEqual({ reason: "Unauthorized", status: 401, - failedScheme: "user-token", + failedScheme: "none", }); }); }); diff --git a/packages/control-plane/src/auth/authenticate.ts b/packages/control-plane/src/auth/authenticate.ts index d1b4c5d64..8d06a789e 100644 --- a/packages/control-plane/src/auth/authenticate.ts +++ b/packages/control-plane/src/auth/authenticate.ts @@ -2,325 +2,92 @@ * Edge authentication: resolve every non-public, non-sandbox request to a * typed `Principal` before any handler runs. * - * Dispatch order: a `sig1` service signature (verified against - * that service's own secret — a failed attempt is terminal), then an - * `oi_at_` web session token. Anything else is not a recognized credential. + * A `sig1` service signature is verified against that service's own secret. + * User requests additionally require a Better Auth session. Anything else + * is not a recognized credential. * * Sandbox tokens stay router-verified (they need the session id from the * path and a DO round-trip), so they are not dispatched here. */ -import { - ACTOR_HEADER, - SERVICE_HEADER, - SERVICE_SIGNATURE_HEADER, - TOKEN_VALIDITY_MS, - isServiceName, - parseServiceSignatureHeader, - readBodyCapped, - sha256Hex, - verifyServiceSignature, - type ServiceName, -} from "@open-inspect/shared"; - -import { - ASSERTION_RIGHTS, - isActorNamespace, - type ActorNamespace, - type Principal, -} from "./principal"; -import { ACCESS_TOKEN_PREFIX, WebSessionTokenService } from "./web-session-tokens"; -import { ApiTokenStore } from "../db/api-tokens"; -import { UserStore } from "../db/user-store"; +import { SERVICE_SIGNATURE_HEADER } from "@open-inspect/shared"; +import { authenticateSession, SessionIntegrityError } from "./user/session-authenticator"; +import { isAuthError, type AuthResult } from "./result"; +import { authenticateServiceRequest } from "./service/request-authenticator"; import { createLogger } from "../logger"; import type { RequestContext } from "../routes/shared"; import type { Env } from "../types"; const logger = createLogger("auth"); -export interface AuthError { - /** Response body message (also the log detail). Never carries token material. */ - reason: string; - status: 401 | 413 | 500; +export { isAuthError, type AuthError, type AuthResult } from "./result"; +export { SERVICE_REQUEST_MAX_BODY_BYTES } from "./service/request-authenticator"; + +export interface AuthenticationRequirement { /** - * Which scheme was attempted and failed. A per-service or user-token - * attempt is terminal; "none" means no recognized credential was presented - * at all, and the router may still try sandbox auth on sandbox routes. + * Whether a verified service:web request is acting as the service itself or + * as a user through a Better Auth session. */ - failedScheme: "per-service" | "user-token" | "none"; -} - -/** - * Hard cap on a service-signed request body. The signature covers the body - * hash, so the body must be buffered and hashed before verification can - * finish — this cap bounds what an unauthenticated sender can make the edge - * buffer. The largest legitimate signed body is a session-attachment - * multipart upload (see SESSION_ATTACHMENT_MAX_REQUEST_BYTES, ~10MB); sandbox - * media uploads authenticate with sandbox tokens and never pass through here. - */ -export const SERVICE_REQUEST_MAX_BODY_BYTES = 16 * 1024 * 1024; - -export type AuthResult = { principal: Principal; request: Request } | AuthError; - -export function isAuthError(result: AuthResult): result is AuthError { - return !("principal" in result); -} - -/** The per-service verification keys the CP holds. */ -export interface ServiceKeyEnv { - SERVICE_AUTH_SECRET_WEB?: string; - SERVICE_AUTH_SECRET_SLACK_BOT?: string; - SERVICE_AUTH_SECRET_GITHUB_BOT?: string; - SERVICE_AUTH_SECRET_LINEAR_BOT?: string; - SERVICE_AUTH_SECRET_MODAL?: string; -} - -/** The verification key the CP holds for each service (also the signing key for CP→bot callbacks). */ -export function serviceAuthSecret(env: ServiceKeyEnv, service: ServiceName): string | undefined { - switch (service) { - case "web": - return env.SERVICE_AUTH_SECRET_WEB; - case "slack-bot": - return env.SERVICE_AUTH_SECRET_SLACK_BOT; - case "github-bot": - return env.SERVICE_AUTH_SECRET_GITHUB_BOT; - case "linear-bot": - return env.SERVICE_AUTH_SECRET_LINEAR_BOT; - case "modal": - return env.SERVICE_AUTH_SECRET_MODAL; - } -} - -/** Parse `:` into a typed actor reference; null when malformed. */ -function parseActor(actor: string): { provider: ActorNamespace; providerUserId: string } | null { - const separator = actor.indexOf(":"); - if (separator <= 0) return null; - const namespace = actor.slice(0, separator); - const providerUserId = actor.slice(separator + 1); - if (providerUserId === "" || !isActorNamespace(namespace)) return null; - return { provider: namespace, providerUserId }; + readonly webService?: "service" | "user"; } -/** - * Best-effort nonce-reuse detection (log-only for now; a future change may - * reject). In-isolate only — a replay against a different isolate is not - * observed. Entries expire with the signature validity window. - */ -const seenNonces = new Map(); -const SEEN_NONCE_LIMIT = 5000; - -function recordNonce(service: ServiceName, nonce: string, ctx: RequestContext): void { - const now = Date.now(); - const key = `${service}:${nonce}`; - const expiresAt = seenNonces.get(key); - if (expiresAt !== undefined && expiresAt > now) { - logger.warn("Service auth nonce reused", { - event: "auth.nonce_reuse", - service, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return; - } - if (seenNonces.size >= SEEN_NONCE_LIMIT) { - for (const [candidate, expiry] of seenNonces) { - if (expiry <= now) seenNonces.delete(candidate); - } - // Still over cap: shed the oldest entries (Map iteration is insertion - // order, and entries are inserted with monotonically increasing expiry). - // A flood must degrade detection gradually, never erase all memory. - let excess = seenNonces.size - SEEN_NONCE_LIMIT + 1; - for (const candidate of seenNonces.keys()) { - if (excess-- <= 0) break; - seenNonces.delete(candidate); - } - } - seenNonces.set(key, now + TOKEN_VALIDITY_MS); -} - -async function authenticateServiceCredential( +export async function authenticate( request: Request, env: Env, ctx: RequestContext, - signatureHeader: string + requirement: AuthenticationRequirement = {} ): Promise { - const serviceHeader = request.headers.get(SERVICE_HEADER) ?? ""; - if (!isServiceName(serviceHeader)) { - logger.warn("Service auth failed: unknown service", { - event: "auth.service_failed", - failure: "unknown_service", - service: serviceHeader, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; - } - const service = serviceHeader; - - const secret = serviceAuthSecret(env, service); - if (!secret) { - logger.error("Service auth secret not configured - rejecting request", { - event: "auth.misconfigured", - service, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { - reason: "Service authentication not configured", - status: 500, - failedScheme: "per-service", - }; - } - - // Reject everything rejectable before paying for the body: a malformed or - // stale header must not cost a body buffer + hash. - const parsedSignature = parseServiceSignatureHeader(signatureHeader); - if (!parsedSignature.ok) { - logger.warn("Service auth failed: signature rejected", { - event: "auth.service_failed", - failure: parsedSignature.reason, - service, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; - } - - let bodyBuffer: Uint8Array | null = null; - if (request.body !== null) { - bodyBuffer = await readBodyCapped(request.body, SERVICE_REQUEST_MAX_BODY_BYTES); - if (bodyBuffer === null) { - logger.warn("Service auth failed: body over size cap", { - event: "auth.service_failed", - failure: "body_too_large", - service, + const signatureHeader = request.headers.get(SERVICE_SIGNATURE_HEADER); + if (signatureHeader !== null) { + const channel = await authenticateServiceRequest(request, env, ctx, signatureHeader); + if ( + isAuthError(channel) || + channel.principal.kind !== "service" || + channel.principal.service !== "web" || + requirement.webService !== "user" + ) { + return channel; + } + if (!ctx.getUserAuth) { + logger.error("User authentication runtime unavailable", { + event: "auth.browser.misconfigured", request_id: ctx.request_id, trace_id: ctx.trace_id, }); - return { reason: "Request body too large", status: 413, failedScheme: "per-service" }; + return { + reason: "User authentication is not configured", + status: 500, + failedScheme: "browser-session", + }; } - } - const bodySha256Hex = await sha256Hex(bodyBuffer ?? ""); - const actor = request.headers.get(ACTOR_HEADER) ?? ""; - - const verification = await verifyServiceSignature({ - signatureHeader, - service, - secret, - method: request.method, - url: request.url, - bodySha256Hex, - actor, - }); - if (!verification.ok) { - logger.warn("Service auth failed: signature rejected", { - event: "auth.service_failed", - failure: verification.reason, - service, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; - } - - recordNonce(service, verification.nonce, ctx); - - let resolvedActor = null; - if (actor !== "") { - const parsed = parseActor(actor); - if (!parsed || ASSERTION_RIGHTS[service] !== parsed.provider) { - logger.warn("Actor assertion denied", { - event: "auth.assertion_denied", - service, - actor, + try { + const userSession = await authenticateSession(ctx.getUserAuth().api, channel.request.headers); + if (!userSession) { + return { + reason: "Unauthorized", + status: 401, + failedScheme: "browser-session", + }; + } + return { + principal: { kind: "user", userId: userSession.userId }, + authentication: userSession.authentication, + request: channel.request, + }; + } catch (cause) { + logger.error("User session validation failed", { + event: "auth.browser.failed", + failure: cause instanceof SessionIntegrityError ? "integrity" : "runtime", + error: cause, request_id: ctx.request_id, trace_id: ctx.trace_id, }); - return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + return { + reason: "User authentication failed", + status: 500, + failedScheme: "browser-session", + }; } - const identity = await new UserStore(ctx.db).getIdentity( - parsed.provider, - parsed.providerUserId - ); - resolvedActor = { - provider: parsed.provider, - providerUserId: parsed.providerUserId, - canonicalUserId: identity?.userId ?? null, - participantUserId: actor, - }; - } - - // The body was consumed to hash it; hand the handler a request that can - // still be read. Bodyless requests pass through untouched. Built from - // parts deliberately: the `new Request(request, { body })` copy-constructor - // throws in workerd once the source request's body has been disturbed. - const handlerRequest = - bodyBuffer === null - ? request - : new Request(request.url, { - method: request.method, - headers: request.headers, - body: bodyBuffer, - }); - - return { - principal: { kind: "service", service, actor: resolvedActor }, - request: handlerRequest, - }; -} - -async function authenticateWebSessionToken( - token: string, - request: Request, - ctx: RequestContext -): Promise { - const store = new ApiTokenStore(ctx.db); - const verification = await new WebSessionTokenService(store).verifyAccessToken(token); - if (!verification.ok) { - logger.warn("Web session token rejected", { - event: "auth.user_token_failed", - failure: verification.failure, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { reason: "Unauthorized", status: 401, failedScheme: "user-token" }; - } - - ctx.executionCtx?.waitUntil( - store.touchLastUsed(verification.tokenId).catch(() => { - // Best-effort usage stamp; never block or fail the request for it. - }) - ); - - return { - principal: { - kind: "user", - user: { - provider: verification.provider, - providerUserId: verification.providerUserId, - canonicalUserId: verification.userId, - // Web users participate under their bare canonical id. - participantUserId: verification.userId, - }, - tokenId: verification.tokenId, - }, - request, - }; -} - -export async function authenticate( - request: Request, - env: Env, - ctx: RequestContext -): Promise { - const signatureHeader = request.headers.get(SERVICE_SIGNATURE_HEADER); - if (signatureHeader !== null) { - return authenticateServiceCredential(request, env, ctx, signatureHeader); - } - - const authHeader = request.headers.get("Authorization"); - if (authHeader?.startsWith(`Bearer ${ACCESS_TOKEN_PREFIX}`)) { - return authenticateWebSessionToken(authHeader.slice("Bearer ".length), request, ctx); } // No recognized credential. The shared bearer is retired — a diff --git a/packages/control-plane/src/auth/browser-auth.ts b/packages/control-plane/src/auth/browser-auth.ts deleted file mode 100644 index 2245b06ba..000000000 --- a/packages/control-plane/src/auth/browser-auth.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { betterAuth } from "better-auth"; -import { generateId } from "./crypto"; - -const MS_PER_SECOND = 1000; - -export const BROWSER_AUTH_SESSION_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * MS_PER_SECOND; -export const BROWSER_AUTH_SESSION_UPDATE_AGE_MS = 24 * 60 * 60 * MS_PER_SECOND; - -export interface BrowserAuthConfig { - readonly database: D1Database; - readonly publicWebOrigin: string; - readonly secret: string; -} - -/** - * Creates the control plane's browser-authentication authority. - * - * `publicWebOrigin` is deliberately the browser-visible web origin rather than - * the control-plane origin. The web transparently proxies this handler, so all - * redirects and host-only cookies remain scoped to the web application. - */ -export function createBrowserAuth(config: BrowserAuthConfig) { - return betterAuth({ - baseURL: config.publicWebOrigin, - database: config.database, - secret: config.secret, - trustedOrigins: [config.publicWebOrigin], - telemetry: { enabled: false }, - advanced: { - cookiePrefix: "openinspect", - useSecureCookies: true, - // Browser authentication and application authorization share the same - // canonical user ID. The activation layer projects this ID into users.id - // before any Better Auth route is exposed. - database: { - generateId: () => generateId(), - }, - }, - user: { - modelName: "auth_users", - }, - session: { - modelName: "auth_sessions", - expiresIn: BROWSER_AUTH_SESSION_EXPIRES_IN_MS / MS_PER_SECOND, - updateAge: BROWSER_AUTH_SESSION_UPDATE_AGE_MS / MS_PER_SECOND, - }, - account: { - modelName: "auth_accounts", - accountLinking: { - disableImplicitLinking: true, - }, - encryptOAuthTokens: true, - }, - verification: { - modelName: "auth_verifications", - storeIdentifier: "hashed", - }, - }); -} diff --git a/packages/control-plane/src/auth/browser-sign-in-identity-store.ts b/packages/control-plane/src/auth/browser-sign-in-identity-store.ts deleted file mode 100644 index 9f87f4bca..000000000 --- a/packages/control-plane/src/auth/browser-sign-in-identity-store.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ProviderCredentialInput } from "./provider-credential"; -import type { SignInProvider } from "./sign-in-provider"; - -export interface StoredBrowserSignInIdentity { - readonly providerIdentityId: string; - readonly userId: string; - readonly provider: SignInProvider; -} - -export interface BrowserSignInIdentityProfile { - readonly provider: SignInProvider; - readonly issuer: string; - readonly subject: string; - readonly login: string | null; - readonly displayName: string | null; - readonly avatarUrl: string | null; - readonly verifiedEmails: readonly string[]; - readonly primaryEmail: string | null; -} - -export interface CreateBrowserSignInIdentityInput { - readonly userId: string; - readonly providerIdentityId: string; - readonly profile: BrowserSignInIdentityProfile; - readonly credential: ProviderCredentialInput | null; - readonly now: number; -} - -export interface RefreshBrowserSignInIdentityInput { - readonly existing: StoredBrowserSignInIdentity; - readonly profile: BrowserSignInIdentityProfile; - readonly credential: ProviderCredentialInput | null; - readonly now: number; -} - -/** - * Persistence boundary for browser sign-in identity resolution. - * - * Implementations own row decoding and the atomic user, identity, email-claim, - * and provider-credential write batches. The resolver owns evidence - * validation, immutable subject-binding policy, collision policy, retry policy, - * and identifier generation. - */ -export interface BrowserSignInIdentityStorePort { - findByIssuerAndSubject( - issuer: string, - subject: string - ): Promise; - countConflictingEmails(emails: readonly string[], expectedUserId: string | null): Promise; - create(input: CreateBrowserSignInIdentityInput): Promise; - refresh(input: RefreshBrowserSignInIdentityInput): Promise; - isRetryableCreateConflict(error: unknown): boolean; - isRetryableRefreshConflict(error: unknown): boolean; -} diff --git a/packages/control-plane/src/auth/browser-sign-in-identity.ts b/packages/control-plane/src/auth/browser-sign-in-identity.ts deleted file mode 100644 index 2d3a9583f..000000000 --- a/packages/control-plane/src/auth/browser-sign-in-identity.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { ProviderCredentialInput } from "./provider-credential"; -import type { ProviderCodeExchangeResult, VerifiedProviderIdentity } from "./providers/types"; -import type { SignInProvider } from "./sign-in-provider"; -import type { - BrowserSignInIdentityProfile, - BrowserSignInIdentityStorePort, - StoredBrowserSignInIdentity, -} from "./browser-sign-in-identity-store"; - -const CANONICAL_ISSUERS: Readonly> = { - github: "https://github.com", - google: "https://accounts.google.com", -}; -const MAX_RESOLUTION_ATTEMPTS = 3; -const MAX_VERIFIED_EMAIL_CLAIMS = 1_000; - -export interface ResolvedBrowserSignInIdentity { - readonly userId: string; - readonly providerIdentityId: string; - readonly isNewUser: boolean; - readonly collisionCount: number; -} - -export interface BrowserSignInIdentityResolverDependencies { - readonly clock: { now(): number }; - readonly idGenerator: { generate(): string }; - readonly store: BrowserSignInIdentityStorePort; -} - -export class InvalidProviderIdentityEvidenceError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidProviderIdentityEvidenceError"; - } -} - -export class AccountLinkRequiredError extends Error { - constructor(readonly collisionCount: number) { - super("This verified identity requires explicit account linking"); - this.name = "AccountLinkRequiredError"; - } -} - -export class ProviderIdentityAdapterMismatchError extends Error { - constructor() { - super("Stored provider identity does not match the authenticating adapter"); - this.name = "ProviderIdentityAdapterMismatchError"; - } -} - -function normalizeOptional(value: string | undefined): string | null { - const normalized = value?.trim(); - return normalized ? normalized : null; -} - -function normalizeIdentityEvidence( - identity: VerifiedProviderIdentity -): BrowserSignInIdentityProfile { - if (identity.issuer !== CANONICAL_ISSUERS[identity.provider]) { - throw new InvalidProviderIdentityEvidenceError( - "Provider identity issuer is not the configured canonical issuer" - ); - } - if (identity.subject.length === 0) { - throw new InvalidProviderIdentityEvidenceError("Provider identity subject is empty"); - } - - const verifiedEmails = [ - ...new Set(identity.verifiedEmails.map((email) => email.trim().toLowerCase()).filter(Boolean)), - ]; - if (verifiedEmails.length > MAX_VERIFIED_EMAIL_CLAIMS) { - throw new InvalidProviderIdentityEvidenceError( - "Provider identity has too many verified email claims" - ); - } - const primaryEmail = identity.primaryEmail?.trim().toLowerCase() || null; - if (primaryEmail !== null && !verifiedEmails.includes(primaryEmail)) { - throw new InvalidProviderIdentityEvidenceError( - "Primary display email is not provider-verified" - ); - } - - return { - provider: identity.provider, - issuer: identity.issuer, - subject: identity.subject, - login: normalizeOptional(identity.login), - displayName: normalizeOptional(identity.displayName), - avatarUrl: normalizeOptional(identity.avatarUrl), - verifiedEmails, - primaryEmail, - }; -} - -function requireGeneratedId(value: string, kind: string): string { - if (value.length === 0) { - throw new Error(`Provider identity ${kind} generator returned an invalid id`); - } - return value; -} - -/** - * Resolves a verified browser sign-in to a canonical user by exact - * (issuer, subject). Existing bindings are refreshed but never silently - * reparented; cross-user verified-email collisions require explicit linking. - */ -export class BrowserSignInIdentityResolver { - constructor(private readonly dependencies: BrowserSignInIdentityResolverDependencies) {} - - async resolve( - signIn: ProviderCodeExchangeResult - ): Promise { - const identity = normalizeIdentityEvidence(signIn.identity); - const credential = signIn.credential; - - for (let attempt = 1; attempt <= MAX_RESOLUTION_ATTEMPTS; attempt += 1) { - const existing = await this.dependencies.store.findByIssuerAndSubject( - identity.issuer, - identity.subject - ); - if (existing) { - try { - return await this.refreshExisting(existing, identity, credential); - } catch (error) { - if ( - attempt === MAX_RESOLUTION_ATTEMPTS || - !this.dependencies.store.isRetryableRefreshConflict(error) - ) { - throw error; - } - continue; - } - } - - const collisionCount = await this.dependencies.store.countConflictingEmails( - identity.verifiedEmails, - null - ); - if (collisionCount > 0) { - throw new AccountLinkRequiredError(collisionCount); - } - - try { - return await this.createIdentity(identity, credential); - } catch (error) { - if ( - attempt === MAX_RESOLUTION_ATTEMPTS || - !this.dependencies.store.isRetryableCreateConflict(error) - ) { - throw error; - } - } - } - - throw new Error("Provider identity resolution exhausted its retry budget"); - } - - private async createIdentity( - identity: BrowserSignInIdentityProfile, - credential: ProviderCredentialInput | null - ): Promise { - const now = this.dependencies.clock.now(); - const userId = requireGeneratedId(this.dependencies.idGenerator.generate(), "user id"); - const providerIdentityId = requireGeneratedId( - this.dependencies.idGenerator.generate(), - "identity id" - ); - - await this.dependencies.store.create({ - userId, - providerIdentityId, - profile: identity, - credential, - now, - }); - - return { - userId, - providerIdentityId, - isNewUser: true, - collisionCount: 0, - }; - } - - private async refreshExisting( - existing: StoredBrowserSignInIdentity, - identity: BrowserSignInIdentityProfile, - credential: ProviderCredentialInput | null - ): Promise { - if (existing.provider !== identity.provider) { - throw new ProviderIdentityAdapterMismatchError(); - } - - const now = this.dependencies.clock.now(); - await this.dependencies.store.refresh({ - existing, - profile: identity, - credential, - now, - }); - return { - userId: existing.userId, - providerIdentityId: existing.providerIdentityId, - isNewUser: false, - collisionCount: await this.dependencies.store.countConflictingEmails( - identity.verifiedEmails, - existing.userId - ), - }; - } -} diff --git a/packages/control-plane/src/auth/github-app-permission-preflight.test.ts b/packages/control-plane/src/auth/github-app-permission-preflight.test.ts deleted file mode 100644 index 3cee7cc38..000000000 --- a/packages/control-plane/src/auth/github-app-permission-preflight.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - GitHubAppPermissionPreflightError, - buildGitHubAppPermissionRequirements, - preflightGitHubAppPermissions, -} from "./github-app-permission-preflight"; - -const config = { - appId: "123", - installationId: "456", - privateKey: "private", -}; - -describe("GitHub App permission preflight", () => { - it("checks both registered and installation-approved permissions", async () => { - const permissions = { - contents: "write", - pull_requests: "write", - metadata: "read", - members: "read", - email_addresses: "read", - }; - const fetcher = vi - .fn() - .mockResolvedValueOnce(Response.json({ id: 123, permissions })) - .mockResolvedValueOnce( - Response.json({ - id: 456, - app_id: 123, - permissions, - suspended_at: null, - }) - ); - const requirements = buildGitHubAppPermissionRequirements({ - requireOrganizationMembers: true, - requireIssues: false, - }); - - await expect( - preflightGitHubAppPermissions( - config, - { - requireOrganizationMembers: true, - requireIssues: false, - }, - { - fetcher, - generateAppJwt: vi.fn(async () => "app-jwt"), - } - ) - ).resolves.toEqual({ - appId: "123", - installationId: "456", - permissions: requirements, - }); - expect(fetcher).toHaveBeenNthCalledWith( - 1, - "https://api.github.com/app", - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer app-jwt" }), - }) - ); - expect(fetcher).toHaveBeenNthCalledWith( - 2, - "https://api.github.com/app/installations/456", - expect.any(Object) - ); - }); - - it("ignores unrelated permissions with other GitHub access levels", async () => { - const permissions = { - contents: "write", - pull_requests: "write", - metadata: "read", - email_addresses: "read", - organization_projects: "admin", - }; - const fetcher = vi - .fn() - .mockResolvedValueOnce(Response.json({ id: 123, permissions })) - .mockResolvedValueOnce( - Response.json({ - id: 456, - app_id: 123, - permissions, - suspended_at: null, - }) - ); - - await expect( - preflightGitHubAppPermissions( - config, - { - requireOrganizationMembers: false, - requireIssues: false, - }, - { - fetcher, - generateAppJwt: vi.fn(async () => "app-jwt"), - } - ) - ).resolves.toEqual({ - appId: "123", - installationId: "456", - permissions: buildGitHubAppPermissionRequirements({ - requireOrganizationMembers: false, - requireIssues: false, - }), - }); - }); - - it("always requires access to the provider email evidence used by sign-in", () => { - expect( - buildGitHubAppPermissionRequirements({ - requireOrganizationMembers: false, - requireIssues: false, - }) - ).toMatchObject({ email_addresses: "read" }); - }); - - it("wraps transport failures in the preflight error boundary", async () => { - await expect( - preflightGitHubAppPermissions( - config, - { - requireOrganizationMembers: false, - requireIssues: false, - }, - { - fetcher: vi.fn(async () => { - throw new TypeError("network down"); - }), - generateAppJwt: vi.fn(async () => "app-jwt"), - } - ) - ).rejects.toBeInstanceOf(GitHubAppPermissionPreflightError); - }); - - it("wraps GitHub App authentication failures in the preflight error boundary", async () => { - await expect( - preflightGitHubAppPermissions( - config, - { - requireOrganizationMembers: false, - requireIssues: false, - }, - { - fetcher: vi.fn(), - generateAppJwt: vi.fn(async () => { - throw new Error("invalid private key"); - }), - } - ) - ).rejects.toBeInstanceOf(GitHubAppPermissionPreflightError); - }); - - it("rejects permissions that were registered but not approved on the installation", async () => { - const registeredPermissions = { - contents: "write", - pull_requests: "write", - metadata: "read", - email_addresses: "read", - }; - const fetcher = vi - .fn() - .mockResolvedValueOnce(Response.json({ id: 123, permissions: registeredPermissions })) - .mockResolvedValueOnce( - Response.json({ - id: 456, - app_id: 123, - permissions: { - contents: "write", - pull_requests: "write", - metadata: "read", - }, - suspended_at: null, - }) - ); - - await expect( - preflightGitHubAppPermissions( - config, - { - requireOrganizationMembers: false, - requireIssues: false, - }, - { - fetcher, - generateAppJwt: vi.fn(async () => "app-jwt"), - } - ) - ).rejects.toEqual( - expect.objectContaining({ - name: "GitHubAppPermissionPreflightError", - message: "GitHub App installation permission email_addresses must be read", - }) - ); - }); - - it("rejects a suspended installation", async () => { - const permissions = { - contents: "write", - pull_requests: "write", - metadata: "read", - email_addresses: "read", - }; - const fetcher = vi - .fn() - .mockResolvedValueOnce(Response.json({ id: 123, permissions })) - .mockResolvedValueOnce( - Response.json({ - id: 456, - app_id: 123, - permissions, - suspended_at: "2026-07-25T00:00:00Z", - }) - ); - - await expect( - preflightGitHubAppPermissions( - config, - { - requireOrganizationMembers: false, - requireIssues: false, - }, - { - fetcher, - generateAppJwt: vi.fn(async () => "app-jwt"), - } - ) - ).rejects.toEqual( - expect.objectContaining({ - message: "GitHub App installation is suspended", - }) - ); - }); - - it("rejects an installation belonging to a different app", async () => { - const permissions = { - contents: "write", - pull_requests: "write", - metadata: "read", - email_addresses: "read", - }; - const fetcher = vi - .fn() - .mockResolvedValueOnce(Response.json({ id: 123, permissions })) - .mockResolvedValueOnce( - Response.json({ - id: 456, - app_id: 999, - permissions, - suspended_at: null, - }) - ); - - await expect( - preflightGitHubAppPermissions( - config, - { - requireOrganizationMembers: false, - requireIssues: false, - }, - { - fetcher, - generateAppJwt: vi.fn(async () => "app-jwt"), - } - ) - ).rejects.toEqual( - expect.objectContaining({ - message: - "GitHub App installation response does not match the configured app and installation", - }) - ); - }); - - it("requires issues write only when GitHub bot behavior is enabled", () => { - expect( - buildGitHubAppPermissionRequirements({ - requireOrganizationMembers: false, - requireIssues: true, - }) - ).toMatchObject({ issues: "write" }); - expect( - buildGitHubAppPermissionRequirements({ - requireOrganizationMembers: false, - requireIssues: false, - }) - ).not.toHaveProperty("issues"); - }); -}); diff --git a/packages/control-plane/src/auth/github-app-permission-preflight.ts b/packages/control-plane/src/auth/github-app-permission-preflight.ts deleted file mode 100644 index c918557fc..000000000 --- a/packages/control-plane/src/auth/github-app-permission-preflight.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { z } from "zod"; -import { fetchWithTimeout, generateAppJwt, type GitHubAppConfig } from "./github-app"; - -const GITHUB_API_VERSION = "2022-11-28"; -const grantedPermissionLevelSchema = z.enum(["read", "write", "admin"]); -const permissionsSchema = z.record(z.string(), grantedPermissionLevelSchema); -const appSchema = z.object({ - id: z.number().int().positive(), - permissions: permissionsSchema, -}); -const installationSchema = z.object({ - id: z.number().int().positive(), - app_id: z.number().int().positive(), - permissions: permissionsSchema, - suspended_at: z.string().nullable(), -}); - -export type GitHubAppPermissionLevel = "read" | "write"; -export type GitHubAppPermissionRequirements = Readonly>; -type GitHubAppGrantedPermissionLevel = z.infer; - -export interface GitHubAppPermissionOptions { - readonly requireOrganizationMembers: boolean; - readonly requireIssues: boolean; -} - -export interface GitHubAppPermissionPreflightReport { - readonly appId: string; - readonly installationId: string; - readonly permissions: GitHubAppPermissionRequirements; -} - -export interface GitHubAppPermissionPreflightDependencies { - readonly fetcher: (url: string, init: RequestInit) => Promise; - readonly generateAppJwt: (appId: string, privateKey: string) => Promise; -} - -const defaultDependencies: GitHubAppPermissionPreflightDependencies = { - fetcher: (url, init) => fetchWithTimeout(url, init), - generateAppJwt, -}; - -export class GitHubAppPermissionPreflightError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "GitHubAppPermissionPreflightError"; - } -} - -export function buildGitHubAppPermissionRequirements( - options: GitHubAppPermissionOptions -): GitHubAppPermissionRequirements { - return { - contents: "write", - pull_requests: "write", - metadata: "read", - email_addresses: "read", - ...(options.requireIssues ? { issues: "write" as const } : {}), - ...(options.requireOrganizationMembers ? { members: "read" as const } : {}), - }; -} - -function permissionSatisfies( - actual: GitHubAppGrantedPermissionLevel | undefined, - required: GitHubAppPermissionLevel -): boolean { - return actual === "admin" || actual === "write" || actual === required; -} - -async function fetchJson( - url: string, - jwt: string, - fetcher: GitHubAppPermissionPreflightDependencies["fetcher"] -): Promise { - let response: Response; - try { - response = await fetcher(url, { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${jwt}`, - "X-GitHub-Api-Version": GITHUB_API_VERSION, - "User-Agent": "Open-Inspect-Control-Plane", - }, - }); - } catch (cause) { - throw new GitHubAppPermissionPreflightError("GitHub permission preflight request failed", { - cause, - }); - } - if (!response.ok) { - throw new GitHubAppPermissionPreflightError( - `GitHub permission preflight request failed with HTTP ${response.status}` - ); - } - try { - return await response.json(); - } catch (cause) { - throw new GitHubAppPermissionPreflightError( - "GitHub permission preflight returned invalid JSON", - { cause } - ); - } -} - -function assertPermissions( - boundary: "registration" | "installation", - actual: Record, - requirements: GitHubAppPermissionRequirements -): void { - for (const [permission, required] of Object.entries(requirements)) { - if (!permissionSatisfies(actual[permission], required)) { - throw new GitHubAppPermissionPreflightError( - `GitHub App ${boundary} permission ${permission} must be ${required}` - ); - } - } -} - -export async function preflightGitHubAppPermissions( - config: GitHubAppConfig, - options: GitHubAppPermissionOptions, - dependencies: GitHubAppPermissionPreflightDependencies = defaultDependencies -): Promise { - const requirements = buildGitHubAppPermissionRequirements(options); - let jwt: string; - try { - jwt = await dependencies.generateAppJwt(config.appId, config.privateKey); - } catch (cause) { - throw new GitHubAppPermissionPreflightError( - "GitHub App authentication failed during permission preflight", - { cause } - ); - } - const appResult = appSchema.safeParse( - await fetchJson("https://api.github.com/app", jwt, dependencies.fetcher) - ); - if (!appResult.success || String(appResult.data.id) !== config.appId) { - throw new GitHubAppPermissionPreflightError( - "GitHub App registration response does not match GITHUB_APP_ID" - ); - } - assertPermissions("registration", appResult.data.permissions, requirements); - - const installationResult = installationSchema.safeParse( - await fetchJson( - `https://api.github.com/app/installations/${encodeURIComponent(config.installationId)}`, - jwt, - dependencies.fetcher - ) - ); - if ( - !installationResult.success || - String(installationResult.data.id) !== config.installationId || - String(installationResult.data.app_id) !== config.appId - ) { - throw new GitHubAppPermissionPreflightError( - "GitHub App installation response does not match the configured app and installation" - ); - } - if (installationResult.data.suspended_at !== null) { - throw new GitHubAppPermissionPreflightError("GitHub App installation is suspended"); - } - assertPermissions("installation", installationResult.data.permissions, requirements); - - return { - appId: config.appId, - installationId: config.installationId, - permissions: requirements, - }; -} diff --git a/packages/control-plane/src/auth/identity-enforcement.test.ts b/packages/control-plane/src/auth/identity-enforcement.test.ts index 5d0807f85..8d51c05d8 100644 --- a/packages/control-plane/src/auth/identity-enforcement.test.ts +++ b/packages/control-plane/src/auth/identity-enforcement.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { applyIdentityEnforcement, deriveIdentity, - authorizeProviderIdentityRequest, mayAttachCallbackContext, requireEventPoster, resolveCanonicalUserId, @@ -14,13 +13,7 @@ import type { RequestContext } from "../routes/shared"; const USER_PRINCIPAL: Principal = { kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: "canon-1", - participantUserId: "canon-1", - }, - tokenId: "token-1", + userId: "canon-1", }; const SLACK_ACTOR: ResolvedIdentity = { @@ -345,65 +338,3 @@ describe("requireEventPoster", () => { expect(requireEventPoster(createCtx(GITHUB_BOT), "sentry")).toBeNull(); }); }); - -describe("authorizeProviderIdentityRequest", () => { - it("logs and denies a user principal upserting another identity", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const authz = authorizeProviderIdentityRequest(createCtx(USER_PRINCIPAL), "github", "999999"); - expect(authz.action).toBe("deny"); - expect(authz.action === "deny" && authz.response.status).toBe(403); - const mismatch = loggedEvents(warn).find((e) => e.event === "identity.mismatch_rejected"); - expect(mismatch).toMatchObject({ - expected: "github:583231", - actual: "github:999999", - }); - }); - - it("resolves a matching user to its token-fixed canonical id, never an upsert", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - // The takeover guard: a matching user resolves to the id its token already - // carries — it must NEVER return `upsert`, which would let the request - // body's providerEmail re-link the identity to another user. - const authz = authorizeProviderIdentityRequest(createCtx(USER_PRINCIPAL), "github", "583231"); - expect(authz).toEqual({ action: "resolve", canonicalUserId: "canon-1" }); - expect(warn).not.toHaveBeenCalled(); - }); - - it("denies the web service now that provider identity linking requires a user token", () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const authz = authorizeProviderIdentityRequest( - createCtx({ kind: "service", service: "web", actor: null }), - "github", - "999999" - ); - expect(authz.action === "deny" && authz.response.status).toBe(403); - }); - - it("fails closed if a user principal ever lacks a canonical id", () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const principal: Principal = { - kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: null, - participantUserId: "583231", - }, - tokenId: "token-1", - }; - const authz = authorizeProviderIdentityRequest(createCtx(principal), "github", "583231"); - expect(authz.action === "deny" && authz.response.status).toBe(500); - }); - - it("denies every other service principal", () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const slack = authorizeProviderIdentityRequest(createCtx(SLACK_BOT_PRINCIPAL), "slack", "UANY"); - expect(slack.action === "deny" && slack.response.status).toBe(403); - const modal = authorizeProviderIdentityRequest( - createCtx({ kind: "service", service: "modal", actor: null }), - "github", - "999999" - ); - expect(modal.action === "deny" && modal.response.status).toBe(403); - }); -}); diff --git a/packages/control-plane/src/auth/identity-enforcement.ts b/packages/control-plane/src/auth/identity-enforcement.ts index f50c59c34..256b706bb 100644 --- a/packages/control-plane/src/auth/identity-enforcement.ts +++ b/packages/control-plane/src/auth/identity-enforcement.ts @@ -12,7 +12,7 @@ import type { AutomationEventSource, ServiceName, SpawnSource } from "@open-inspect/shared"; import { createLogger } from "./../logger"; -import { CALLBACK_DESTINATIONS } from "./callback-signing"; +import { CALLBACK_DESTINATIONS } from "./service/callback-signing"; import type { Principal, ResolvedIdentity } from "./principal"; import type { UserStore } from "../db/user-store"; import { error, type RequestContext } from "../routes/shared"; @@ -108,8 +108,8 @@ export function deriveIdentity(principal: Principal | undefined): DerivedIdentit switch (principal.kind) { case "user": return { - participantUserId: principal.user.participantUserId, - canonicalUserId: principal.user.canonicalUserId, + participantUserId: principal.userId, + canonicalUserId: principal.userId, actor: null, spawnSource: "user", }; @@ -297,62 +297,3 @@ export function requireEventPoster( logMismatchRejected(`internal-${source}-event`, "service", expected, principal.service, ctx); return error("Unauthorized", 401); } - -/** - * The action a caller is authorized to take on - * `PUT /provider-identities/:provider/:id`. - * - * - `resolve`: a user principal matching the path identity. Its canonical - * user is already fixed by the token it presented, so the route returns - * that id verbatim and never touches identity linkage. Crucially, the - * request body (and any `providerEmail` in it) is IGNORED — otherwise an - * `oi_at_` holder could assert an arbitrary email and have - * `resolveOrCreateUser` re-link its provider identity onto another user's - * canonical account. Linking stays provider-verified, in the exchange flow. - * - `deny`: everyone else. - */ -type ProviderIdentityAuthorization = - | { action: "resolve"; canonicalUserId: string } - | { action: "deny"; response: Response }; - -export function authorizeProviderIdentityRequest( - ctx: RequestContext, - provider: string, - providerUserId: string -): ProviderIdentityAuthorization { - const principal = ctx.principal; - if (principal?.kind === "user") { - if (provider === principal.user.provider && providerUserId === principal.user.providerUserId) { - // canonicalUserId is always set for user principals (minted from the - // token row); the body is deliberately never consulted here. Fail - // closed on the impossible null rather than emitting an invalid id. - const canonicalUserId = principal.user.canonicalUserId; - if (!canonicalUserId) { - return { action: "deny", response: error("User principal has no canonical id", 500) }; - } - return { action: "resolve", canonicalUserId }; - } - logMismatchRejected( - "provider-identities", - "provider-identity-path", - `${principal.user.provider}:${principal.user.providerUserId}`, - `${provider}:${providerUserId}`, - ctx - ); - return { - action: "deny", - response: error("Path identity does not match the authenticated user", 403), - }; - } - logMismatchRejected( - "provider-identities", - "principal", - "matching user", - principal?.kind === "service" ? principal.service : (principal?.kind ?? "none"), - ctx - ); - return { - action: "deny", - response: error("Only the matching user may resolve a provider identity", 403), - }; -} diff --git a/packages/control-plane/src/auth/index.ts b/packages/control-plane/src/auth/index.ts deleted file mode 100644 index d8c6ac556..000000000 --- a/packages/control-plane/src/auth/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Auth module exports. - */ - -export { encryptToken, decryptToken, generateEncryptionKey, generateId } from "./crypto"; - -export { isGitHubAppConfigured, getGitHubAppConfig, type GitHubAppConfig } from "./github-app"; diff --git a/packages/control-plane/src/auth/oauth-authorization-service.test.ts b/packages/control-plane/src/auth/oauth-authorization-service.test.ts deleted file mode 100644 index 3d442bd85..000000000 --- a/packages/control-plane/src/auth/oauth-authorization-service.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - OAuthAuthorizationRequestError, - OAuthAuthorizationService, - StaticOAuthClientRegistry, - WebCryptoOpaqueValueGenerator, -} from "./oauth-authorization-service"; -import type { CreateOAuthFlowStateInput } from "./oauth-flow-state"; -import type { - OAuthSignInProvider, - ProviderAuthorizationRequest, - ProviderCodeExchangeRequest, - ProviderCodeExchangeResult, -} from "./providers/types"; -import type { SignInProvider } from "./sign-in-provider"; - -class FakeProvider

implements OAuthSignInProvider

{ - readonly createAuthorizationUrl = vi.fn( - async (request: ProviderAuthorizationRequest

): Promise => { - const url = new URL(`https://${this.provider}.example/authorize`); - url.searchParams.set("state", request.state); - return url; - } - ); - - constructor(readonly provider: P) {} - - exchangeAuthorizationCode( - _request: ProviderCodeExchangeRequest

- ): Promise> { - throw new Error("not used"); - } -} - -describe("OAuthAuthorizationService", () => { - it("generates independent 32-byte base64url values for upstream secrets", () => { - const generator = new WebCryptoOpaqueValueGenerator(); - - const first = generator.generate(); - const second = generator.generate(); - - expect(first).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(second).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(second).not.toBe(first); - }); - - it("validates the web client and persists a GitHub flow before returning its redirect", async () => { - const github = new FakeProvider("github"); - const google = new FakeProvider("google"); - const create = vi.fn(async (_input: CreateOAuthFlowStateInput) => ({ flowId: "flow-1" })); - const verifier = "v".repeat(43); - const service = new OAuthAuthorizationService({ - clients: new StaticOAuthClientRegistry(["https://web.example.com/api/auth/callback"]), - providers: { github, google }, - flowStateStore: { create }, - opaqueValueGenerator: { generate: () => verifier }, - }); - - const redirect = await service.authorize({ - responseType: "code", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - state: "s".repeat(43), - codeChallenge: "c".repeat(43), - codeChallengeMethod: "S256", - provider: "github", - }); - - expect(redirect.toString()).toBe(`https://github.example/authorize?state=${"s".repeat(43)}`); - expect(create).toHaveBeenCalledWith({ - state: "s".repeat(43), - provider: "github", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - clientCodeChallenge: "c".repeat(43), - providerPkceVerifier: verifier, - }); - expect(github.createAuthorizationUrl).toHaveBeenCalledWith({ - state: "s".repeat(43), - codeChallenge: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), - }); - expect(google.createAuthorizationUrl).not.toHaveBeenCalled(); - }); - - it("rejects an unregistered redirect before provider or storage work", async () => { - const github = new FakeProvider("github"); - const google = new FakeProvider("google"); - const create = vi.fn(); - const generate = vi.fn(() => "v".repeat(43)); - const service = new OAuthAuthorizationService({ - clients: new StaticOAuthClientRegistry(["https://web.example.com/api/auth/callback"]), - providers: { github, google }, - flowStateStore: { create }, - opaqueValueGenerator: { generate }, - }); - - const rejection = service.authorize({ - responseType: "code", - clientId: "web", - redirectUri: "https://attacker.example/callback", - state: "s".repeat(43), - codeChallenge: "c".repeat(43), - codeChallengeMethod: "S256", - provider: "github", - }); - const error = await rejection.catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(OAuthAuthorizationRequestError); - expect(error).toMatchObject({ - name: "OAuthAuthorizationRequestError", - code: "invalid_request", - }); - expect(generate).not.toHaveBeenCalled(); - expect(create).not.toHaveBeenCalled(); - expect(github.createAuthorizationUrl).not.toHaveBeenCalled(); - }); - - it.each([ - ["unsupported response type", { responseType: "token" }, "unsupported_response_type"], - ["unknown client", { clientId: "cli" }, "invalid_client"], - ["short state", { state: "short" }, "invalid_request"], - ["plain PKCE", { codeChallengeMethod: "plain" }, "invalid_request"], - ["malformed challenge", { codeChallenge: "short" }, "invalid_request"], - ["unknown provider", { provider: "okta" }, "invalid_request"], - ] as const)("rejects %s before generating or persisting flow state", async (_, patch, code) => { - const github = new FakeProvider("github"); - const google = new FakeProvider("google"); - const create = vi.fn(); - const generate = vi.fn(() => "v".repeat(43)); - const service = new OAuthAuthorizationService({ - clients: new StaticOAuthClientRegistry(["https://web.example.com/api/auth/callback"]), - providers: { github, google }, - flowStateStore: { create }, - opaqueValueGenerator: { generate }, - }); - - await expect( - service.authorize({ - responseType: "code", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - state: "s".repeat(43), - codeChallenge: "c".repeat(43), - codeChallengeMethod: "S256", - provider: "github", - ...patch, - }) - ).rejects.toMatchObject({ code }); - expect(generate).not.toHaveBeenCalled(); - expect(create).not.toHaveBeenCalled(); - expect(github.createAuthorizationUrl).not.toHaveBeenCalled(); - expect(google.createAuthorizationUrl).not.toHaveBeenCalled(); - }); - - it("persists the generated Google nonce for hash-only storage", async () => { - const github = new FakeProvider("github"); - const google = new FakeProvider("google"); - const create = vi.fn(async (_input: CreateOAuthFlowStateInput) => ({ flowId: "flow-1" })); - const verifier = "v".repeat(43); - const nonce = "n".repeat(43); - const generate = vi.fn<() => string>().mockReturnValueOnce(verifier).mockReturnValueOnce(nonce); - const service = new OAuthAuthorizationService({ - clients: new StaticOAuthClientRegistry(["https://web.example.com/api/auth/callback"]), - providers: { github, google }, - flowStateStore: { create }, - opaqueValueGenerator: { generate }, - }); - - await service.authorize({ - responseType: "code", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - state: "s".repeat(43), - codeChallenge: "c".repeat(43), - codeChallengeMethod: "S256", - provider: "google", - }); - - expect(google.createAuthorizationUrl).toHaveBeenCalledWith({ - state: "s".repeat(43), - codeChallenge: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), - oidcNonce: nonce, - }); - expect(create).toHaveBeenCalledWith({ - state: "s".repeat(43), - provider: "google", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - clientCodeChallenge: "c".repeat(43), - providerPkceVerifier: verifier, - oidcNonce: nonce, - }); - }); -}); diff --git a/packages/control-plane/src/auth/oauth-authorization-service.ts b/packages/control-plane/src/auth/oauth-authorization-service.ts deleted file mode 100644 index 4b238da79..000000000 --- a/packages/control-plane/src/auth/oauth-authorization-service.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { base64UrlEncode } from "./encoding"; -import type { OAuthFlowStateWriter } from "./oauth-flow-state"; -import { createPkceS256Challenge, isPkceS256Challenge, isPkceVerifier } from "./pkce"; -import type { OAuthSignInProviderRegistry } from "./providers/types"; -import { isSignInProvider, type SignInProvider } from "./sign-in-provider"; - -const OPAQUE_STATE_PATTERN = /^[A-Za-z0-9_-]{32,128}$/; - -export class StaticOAuthClientRegistry { - private readonly redirectUris: ReadonlySet; - - constructor(redirectUris: readonly string[]) { - if (redirectUris.length === 0 || redirectUris.some((uri) => uri.length === 0)) { - throw new Error("Web OAuth client requires at least one redirect URI"); - } - this.redirectUris = new Set(redirectUris); - } - - accepts(clientId: string, redirectUri: string): boolean { - return clientId === "web" && this.redirectUris.has(redirectUri); - } -} - -export type OAuthAuthorizationRequestErrorCode = - | "invalid_request" - | "invalid_client" - | "unsupported_response_type"; - -export class OAuthAuthorizationRequestError extends Error { - constructor(readonly code: OAuthAuthorizationRequestErrorCode) { - super("OAuth authorization request is invalid"); - this.name = "OAuthAuthorizationRequestError"; - } -} - -export interface OAuthAuthorizationRequest { - readonly responseType: string; - readonly clientId: string; - readonly redirectUri: string; - readonly state: string; - readonly codeChallenge: string; - readonly codeChallengeMethod: string; - readonly provider: string; -} - -export interface OpaqueValueGenerator { - generate(): string; -} - -export class WebCryptoOpaqueValueGenerator implements OpaqueValueGenerator { - generate(): string { - return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); - } -} - -export interface OAuthAuthorizationServiceDependencies { - readonly clients: StaticOAuthClientRegistry; - readonly providers: OAuthSignInProviderRegistry; - readonly flowStateStore: OAuthFlowStateWriter; - readonly opaqueValueGenerator: OpaqueValueGenerator; -} - -export class OAuthAuthorizationService { - constructor(private readonly dependencies: OAuthAuthorizationServiceDependencies) {} - - async authorize(request: OAuthAuthorizationRequest): Promise { - const provider = this.validateRequest(request); - const providerPkceVerifier = this.dependencies.opaqueValueGenerator.generate(); - if (!isPkceVerifier(providerPkceVerifier)) { - throw new Error("OAuth opaque-value generator returned an invalid PKCE verifier"); - } - const providerCodeChallenge = await createPkceS256Challenge(providerPkceVerifier); - - if (provider === "google") { - const oidcNonce = this.dependencies.opaqueValueGenerator.generate(); - if (!OPAQUE_STATE_PATTERN.test(oidcNonce)) { - throw new Error("OAuth opaque-value generator returned an invalid OIDC nonce"); - } - const redirect = await this.dependencies.providers.google.createAuthorizationUrl({ - state: request.state, - codeChallenge: providerCodeChallenge, - oidcNonce, - }); - await this.dependencies.flowStateStore.create({ - state: request.state, - provider, - clientId: "web", - redirectUri: request.redirectUri, - clientCodeChallenge: request.codeChallenge, - providerPkceVerifier, - oidcNonce, - }); - return redirect; - } - - const redirect = await this.dependencies.providers.github.createAuthorizationUrl({ - state: request.state, - codeChallenge: providerCodeChallenge, - }); - await this.dependencies.flowStateStore.create({ - state: request.state, - provider, - clientId: "web", - redirectUri: request.redirectUri, - clientCodeChallenge: request.codeChallenge, - providerPkceVerifier, - }); - return redirect; - } - - private validateRequest(request: OAuthAuthorizationRequest): SignInProvider { - if (request.responseType !== "code") { - throw new OAuthAuthorizationRequestError("unsupported_response_type"); - } - if (!this.dependencies.clients.accepts(request.clientId, request.redirectUri)) { - throw new OAuthAuthorizationRequestError( - request.clientId === "web" ? "invalid_request" : "invalid_client" - ); - } - if ( - !OPAQUE_STATE_PATTERN.test(request.state) || - !isPkceS256Challenge(request.codeChallenge) || - request.codeChallengeMethod !== "S256" || - !isSignInProvider(request.provider) - ) { - throw new OAuthAuthorizationRequestError("invalid_request"); - } - return request.provider; - } -} diff --git a/packages/control-plane/src/auth/oauth-flow-state.ts b/packages/control-plane/src/auth/oauth-flow-state.ts deleted file mode 100644 index 62abd6aef..000000000 --- a/packages/control-plane/src/auth/oauth-flow-state.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { SignInProvider } from "./sign-in-provider"; - -interface OAuthFlowStateBinding { - readonly state: string; - readonly clientId: "web"; - readonly redirectUri: string; - readonly clientCodeChallenge: string; - readonly providerPkceVerifier: string; -} - -export type CreateOAuthFlowStateInput = - | (OAuthFlowStateBinding & { - readonly provider: "github"; - readonly oidcNonce?: never; - }) - | (OAuthFlowStateBinding & { - readonly provider: "google"; - readonly oidcNonce: string; - }); - -export interface OAuthFlowStateWriter { - create(input: CreateOAuthFlowStateInput): Promise<{ flowId: string }>; -} - -export interface ConsumedOAuthFlowStateBinding { - readonly flowId: string; - readonly clientId: "web"; - readonly redirectUri: string; - readonly clientCodeChallenge: string; - readonly providerPkceVerifier: string; -} - -export type ConsumedOAuthFlowState = - | (ConsumedOAuthFlowStateBinding & { - readonly provider: "github"; - readonly oidcNonceHash: null; - }) - | (ConsumedOAuthFlowStateBinding & { - readonly provider: "google"; - readonly oidcNonceHash: string; - }); - -export type ConsumedOAuthFlowStateFor

= Extract< - ConsumedOAuthFlowState, - { provider: P } ->; - -export interface OAuthFlowStateReader { - consume

( - state: string, - expectedProvider: P - ): Promise>; -} diff --git a/packages/control-plane/src/auth/oauth-flow-verifier.ts b/packages/control-plane/src/auth/oauth-flow-verifier.ts deleted file mode 100644 index f3c420217..000000000 --- a/packages/control-plane/src/auth/oauth-flow-verifier.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { SignInProvider } from "./sign-in-provider"; - -export interface OAuthFlowVerifierBinding { - flowId: string; - provider: SignInProvider; - keyVersion: number; -} - -/** - * Stable integrity failure exposed by the verifier-cipher port. Implementations - * must throw this when ciphertext cannot be authenticated or decoded. - */ -export class OAuthFlowVerifierIntegrityError extends Error { - constructor() { - super("OAuth flow verifier ciphertext could not be verified"); - this.name = "OAuthFlowVerifierIntegrityError"; - } -} - -export interface OAuthFlowVerifierCipher { - encrypt(plaintext: string, binding: OAuthFlowVerifierBinding): Promise; - decrypt(ciphertext: string, binding: OAuthFlowVerifierBinding): Promise; -} diff --git a/packages/control-plane/src/auth/oauth-provider-callback-handler.test.ts b/packages/control-plane/src/auth/oauth-provider-callback-handler.test.ts deleted file mode 100644 index 018b8e335..000000000 --- a/packages/control-plane/src/auth/oauth-provider-callback-handler.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OAuthFlowStateReader } from "./oauth-flow-state"; -import { createOAuthProviderCallbackHandlers } from "./oauth-provider-callback-handler"; -import type { OAuthSignInProviderRegistry } from "./providers/types"; - -const STATE = "s".repeat(43); -const PROVIDER_VERIFIER = "v".repeat(43); - -describe("createOAuthProviderCallbackHandlers", () => { - it("keeps GitHub callback mechanics behind the selected provider handler", async () => { - const consume = vi.fn(async () => ({ - flowId: "flow-github", - provider: "github" as const, - clientId: "web" as const, - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: "c".repeat(43), - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: null, - })); - const exchangeAuthorizationCode = vi.fn(async () => ({ - identity: { - provider: "github" as const, - issuer: "https://github.com", - subject: "github-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: { - kind: "access_only_nonexpiring" as const, - accessToken: "ghu_access", - }, - })); - const providers = { - github: { - provider: "github" as const, - createAuthorizationUrl: vi.fn(), - exchangeAuthorizationCode, - }, - google: { - provider: "google" as const, - createAuthorizationUrl: vi.fn(), - exchangeAuthorizationCode: vi.fn(), - }, - } satisfies OAuthSignInProviderRegistry; - - const handlers = createOAuthProviderCallbackHandlers({ - flowStateStore: { consume } as unknown as OAuthFlowStateReader, - providers, - }); - const callback = await handlers.github.consume(STATE); - - expect(consume).toHaveBeenCalledWith(STATE, "github"); - await expect(callback.exchange("provider-code")).resolves.toMatchObject({ - identity: { provider: "github", subject: "github-subject" }, - credential: { accessToken: "ghu_access" }, - }); - expect(exchangeAuthorizationCode).toHaveBeenCalledWith({ - code: "provider-code", - codeVerifier: PROVIDER_VERIFIER, - }); - }); - - it("keeps Google callback mechanics behind the selected provider handler", async () => { - const consume = vi.fn(async () => ({ - flowId: "flow-google", - provider: "google" as const, - clientId: "web" as const, - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: "c".repeat(43), - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: "f".repeat(64), - })); - const exchangeAuthorizationCode = vi.fn(async () => ({ - identity: { - provider: "google" as const, - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: null, - })); - const providers = { - github: { - provider: "github" as const, - createAuthorizationUrl: vi.fn(), - exchangeAuthorizationCode: vi.fn(), - }, - google: { - provider: "google" as const, - createAuthorizationUrl: vi.fn(), - exchangeAuthorizationCode, - }, - } satisfies OAuthSignInProviderRegistry; - - const handlers = createOAuthProviderCallbackHandlers({ - flowStateStore: { consume } as unknown as OAuthFlowStateReader, - providers, - }); - const callback = await handlers.google.consume(STATE); - - expect(consume).toHaveBeenCalledWith(STATE, "google"); - await expect(callback.exchange("provider-code")).resolves.toMatchObject({ - identity: { provider: "google", subject: "google-subject" }, - credential: null, - }); - expect(exchangeAuthorizationCode).toHaveBeenCalledWith({ - code: "provider-code", - codeVerifier: PROVIDER_VERIFIER, - oidcNonceHash: "f".repeat(64), - }); - }); -}); diff --git a/packages/control-plane/src/auth/oauth-provider-callback-handler.ts b/packages/control-plane/src/auth/oauth-provider-callback-handler.ts deleted file mode 100644 index 5ef96da46..000000000 --- a/packages/control-plane/src/auth/oauth-provider-callback-handler.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ConsumedOAuthFlowStateFor, OAuthFlowStateReader } from "./oauth-flow-state"; -import type { OAuthSignInProviderRegistry, ProviderCodeExchangeResult } from "./providers/types"; -import type { SignInProvider } from "./sign-in-provider"; - -export interface ConsumedOAuthProviderCallback

{ - readonly flow: ConsumedOAuthFlowStateFor

; - exchange(code: string): Promise>; -} - -export interface OAuthProviderCallbackHandler

{ - consume(state: string): Promise>; -} - -export type OAuthProviderCallbackHandlerRegistry = { - readonly [P in SignInProvider]: OAuthProviderCallbackHandler

; -}; - -export interface OAuthProviderCallbackHandlerDependencies { - readonly flowStateStore: OAuthFlowStateReader; - readonly providers: OAuthSignInProviderRegistry; -} - -export function createOAuthProviderCallbackHandlers( - dependencies: OAuthProviderCallbackHandlerDependencies -): OAuthProviderCallbackHandlerRegistry { - return { - github: { - async consume(state) { - const flow = await dependencies.flowStateStore.consume(state, "github"); - return { - flow, - exchange: (code) => - dependencies.providers.github.exchangeAuthorizationCode({ - code, - codeVerifier: flow.providerPkceVerifier, - }), - }; - }, - }, - google: { - async consume(state) { - const flow = await dependencies.flowStateStore.consume(state, "google"); - return { - flow, - exchange: (code) => - dependencies.providers.google.exchangeAuthorizationCode({ - code, - codeVerifier: flow.providerPkceVerifier, - oidcNonceHash: flow.oidcNonceHash, - }), - }; - }, - }, - }; -} diff --git a/packages/control-plane/src/auth/oauth-provider-callback-service.test.ts b/packages/control-plane/src/auth/oauth-provider-callback-service.test.ts deleted file mode 100644 index b15ee202d..000000000 --- a/packages/control-plane/src/auth/oauth-provider-callback-service.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { AdmissionDeniedError, AdmissionUnavailableError } from "./admission-policy"; -import { AccountLinkRequiredError } from "./browser-sign-in-identity"; -import { createOAuthProviderCallbackHandlers } from "./oauth-provider-callback-handler"; -import { OAuthProviderCallbackService } from "./oauth-provider-callback-service"; -import type { OAuthFlowStateReader } from "./oauth-flow-state"; -import { OAuthProviderError, type OAuthSignInProviderRegistry } from "./providers/types"; - -const STATE = "s".repeat(43); -const CLIENT_CHALLENGE = "c".repeat(43); -const PROVIDER_VERIFIER = "v".repeat(43); - -function providerRegistry(): OAuthSignInProviderRegistry { - return { - github: { - provider: "github", - createAuthorizationUrl: vi.fn(), - exchangeAuthorizationCode: vi.fn(async () => ({ - identity: { - provider: "github" as const, - issuer: "https://github.com", - subject: "github-subject", - login: "octocat", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: { - kind: "access_only_nonexpiring" as const, - accessToken: "ghu_token", - }, - })), - }, - google: { - provider: "google", - createAuthorizationUrl: vi.fn(), - exchangeAuthorizationCode: vi.fn(), - }, - }; -} - -describe("OAuthProviderCallbackService", () => { - it("delegates provider callback mechanics to the selected handler", async () => { - const exchange = vi.fn(async () => ({ - identity: { - provider: "google" as const, - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: null, - })); - const consume = vi.fn(async () => ({ - flow: { - flowId: "flow-1", - provider: "google" as const, - clientId: "web" as const, - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: "f".repeat(64), - }, - exchange, - })); - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => true) }, - providerHandlers: { - github: { consume: vi.fn() }, - google: { consume }, - }, - admissionPolicy: { requireAdmission: vi.fn() }, - identityResolver: { - resolve: vi.fn(async () => ({ - userId: "user-1", - providerIdentityId: "identity-1", - isNewUser: true, - collisionCount: 0, - })), - }, - authorizationCodeStore: { - issue: vi.fn(async () => ({ - code: `oi_code_${"a".repeat(43)}`, - expiresAt: 1_800_000_060_000, - })), - }, - }); - - await service.completeAuthorization("google", { state: STATE, code: "google-code" }); - - expect(consume).toHaveBeenCalledWith(STATE); - expect(exchange).toHaveBeenCalledWith("google-code"); - }); - - it("rejects a missing provider code before consuming transaction state", async () => { - const consumeFlow = vi.fn(); - const providers = providerRegistry(); - const flowStateStore = { - consume: consumeFlow, - } as unknown as OAuthFlowStateReader; - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => true) }, - providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), - admissionPolicy: { requireAdmission: vi.fn() }, - identityResolver: { resolve: vi.fn() }, - authorizationCodeStore: { issue: vi.fn() }, - }); - - await expect( - service.completeAuthorization("github", { state: STATE, code: "" }) - ).rejects.toEqual(expect.objectContaining({ name: "OAuthProviderCallbackRequestError" })); - expect(consumeFlow).not.toHaveBeenCalled(); - }); - - it("rejects an oversized provider code before consuming transaction state", async () => { - const consumeFlow = vi.fn(); - const providers = providerRegistry(); - const flowStateStore = { - consume: consumeFlow, - } as unknown as OAuthFlowStateReader; - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => true) }, - providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), - admissionPolicy: { requireAdmission: vi.fn() }, - identityResolver: { resolve: vi.fn() }, - authorizationCodeStore: { issue: vi.fn() }, - }); - - await expect( - service.completeAuthorization("github", { state: STATE, code: "x".repeat(4_097) }) - ).rejects.toEqual(expect.objectContaining({ name: "OAuthProviderCallbackRequestError" })); - expect(consumeFlow).not.toHaveBeenCalled(); - }); - - it("turns a verified provider callback into a client-bound authorization code", async () => { - const providers = providerRegistry(); - const consumeFlow = vi.fn(async () => ({ - flowId: "flow-1", - provider: "github" as const, - clientId: "web" as const, - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: null, - })); - const flowStateStore = { - consume: consumeFlow, - } as unknown as OAuthFlowStateReader; - const admissionPolicy = { - requireAdmission: vi.fn(async () => ({ reason: "email_allowlist" as const })), - }; - const identityResolver = { - resolve: vi.fn(async () => ({ - userId: "user-1", - providerIdentityId: "identity-1", - isNewUser: true, - collisionCount: 0, - })), - }; - const authorizationCodeStore = { - issue: vi.fn(async () => ({ - code: `oi_code_${"a".repeat(43)}`, - expiresAt: 1_800_000_060_000, - })), - }; - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => true) }, - providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), - admissionPolicy, - identityResolver, - authorizationCodeStore, - }); - - await expect( - service.completeAuthorization("github", { - state: STATE, - code: "github-code", - }) - ).resolves.toEqual( - new URL(`https://web.example/api/auth/callback?code=oi_code_${"a".repeat(43)}&state=${STATE}`) - ); - expect(providers.github.exchangeAuthorizationCode).toHaveBeenCalledWith({ - code: "github-code", - codeVerifier: PROVIDER_VERIFIER, - }); - expect(admissionPolicy.requireAdmission).toHaveBeenCalledWith( - expect.objectContaining({ - identity: expect.objectContaining({ subject: "github-subject" }), - }) - ); - expect(identityResolver.resolve).toHaveBeenCalledWith({ - identity: expect.objectContaining({ - provider: "github", - issuer: "https://github.com", - subject: "github-subject", - }), - credential: expect.objectContaining({ accessToken: "ghu_token" }), - }); - expect(authorizationCodeStore.issue).toHaveBeenCalledWith({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - codeChallenge: CLIENT_CHALLENGE, - }); - }); - - it("consumes provider-denied state and returns only a bounded client error", async () => { - const providers = providerRegistry(); - const consumeFlow = vi.fn(async () => ({ - flowId: "flow-1", - provider: "github" as const, - clientId: "web" as const, - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: null, - })); - const flowStateStore = { - consume: consumeFlow, - } as unknown as OAuthFlowStateReader; - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => true) }, - providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), - admissionPolicy: { requireAdmission: vi.fn() }, - identityResolver: { resolve: vi.fn() }, - authorizationCodeStore: { issue: vi.fn() }, - }); - - await expect(service.completeDenial("github", STATE)).resolves.toEqual( - new URL(`https://web.example/api/auth/callback?error=access_denied&state=${STATE}`) - ); - expect(consumeFlow).toHaveBeenCalledWith(STATE, "github"); - expect(providers.github.exchangeAuthorizationCode).not.toHaveBeenCalled(); - }); - - it("maps an identity collision to a bounded client callback failure", async () => { - const consumeFlow = vi.fn(async () => ({ - flowId: "flow-1", - provider: "github" as const, - clientId: "web" as const, - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: null, - })); - const providers = providerRegistry(); - const flowStateStore = { - consume: consumeFlow, - } as unknown as OAuthFlowStateReader; - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => true) }, - providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), - admissionPolicy: { - requireAdmission: vi.fn(async () => ({ reason: "email_allowlist" })), - }, - identityResolver: { - resolve: vi.fn(async () => { - throw new AccountLinkRequiredError(1); - }), - }, - authorizationCodeStore: { issue: vi.fn() }, - }); - - let rejection: unknown; - try { - await service.completeAuthorization("github", { - state: STATE, - code: "github-code", - }); - } catch (error) { - rejection = error; - } - - expect(rejection).toEqual( - expect.objectContaining({ - name: "OAuthProviderCallbackError", - failure: "account_link_required", - redirectUri: "https://web.example/api/auth/callback", - }) - ); - expect(rejection).not.toHaveProperty("state"); - expect(rejection).not.toHaveProperty("cause"); - }); - - it("rejects a consumed flow whose client redirect binding is no longer registered", async () => { - const providers = providerRegistry(); - const flowStateStore = { - consume: vi.fn(async () => ({ - flowId: "flow-1", - provider: "github" as const, - clientId: "web" as const, - redirectUri: "https://attacker.example/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: null, - })), - } as unknown as OAuthFlowStateReader; - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => false) }, - providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), - admissionPolicy: { requireAdmission: vi.fn() }, - identityResolver: { resolve: vi.fn() }, - authorizationCodeStore: { issue: vi.fn() }, - }); - - await expect( - service.completeAuthorization("github", { - state: STATE, - code: "github-code", - }) - ).rejects.toEqual(expect.objectContaining({ name: "OAuthProviderCallbackBindingError" })); - expect(providers.github.exchangeAuthorizationCode).not.toHaveBeenCalled(); - }); - - it("carries the consumed Google nonce binding through verification without storing credentials", async () => { - const providers = providerRegistry(); - vi.mocked(providers.google.exchangeAuthorizationCode).mockResolvedValue({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: null, - }); - const identityResolver = { - resolve: vi.fn(async () => ({ - userId: "user-1", - providerIdentityId: "identity-1", - isNewUser: true, - collisionCount: 0, - })), - }; - const flowStateStore = { - consume: vi.fn(async () => ({ - flowId: "flow-1", - provider: "google" as const, - clientId: "web" as const, - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: "f".repeat(64), - })), - } as unknown as OAuthFlowStateReader; - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => true) }, - providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), - admissionPolicy: { - requireAdmission: vi.fn(async () => ({ reason: "email_allowlist" })), - }, - identityResolver, - authorizationCodeStore: { - issue: vi.fn(async () => ({ - code: `oi_code_${"a".repeat(43)}`, - expiresAt: 1_800_000_060_000, - })), - }, - }); - - await service.completeAuthorization("google", { - state: STATE, - code: "google-code", - }); - - expect(providers.google.exchangeAuthorizationCode).toHaveBeenCalledWith({ - code: "google-code", - codeVerifier: PROVIDER_VERIFIER, - oidcNonceHash: "f".repeat(64), - }); - expect(identityResolver.resolve).toHaveBeenCalledWith({ - identity: expect.objectContaining({ - provider: "google", - subject: "google-subject", - }), - credential: null, - }); - }); - - it.each([ - [new AdmissionDeniedError(), "access_denied"], - [new AdmissionUnavailableError(), "temporarily_unavailable"], - [ - new OAuthProviderError("provider_unavailable", "provider unavailable"), - "temporarily_unavailable", - ], - [new Error("unexpected internal detail"), "server_error"], - ] as const)( - "maps callback failures to the bounded OAuth error taxonomy", - async (cause, failure) => { - const providers = providerRegistry(); - if (cause instanceof OAuthProviderError) { - vi.mocked(providers.github.exchangeAuthorizationCode).mockRejectedValue(cause); - } - const flowStateStore = { - consume: vi.fn(async () => ({ - flowId: "flow-1", - provider: "github" as const, - clientId: "web" as const, - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: null, - })), - } as unknown as OAuthFlowStateReader; - const service = new OAuthProviderCallbackService({ - clients: { accepts: vi.fn(() => true) }, - providerHandlers: createOAuthProviderCallbackHandlers({ providers, flowStateStore }), - admissionPolicy: { - requireAdmission: vi.fn(async () => { - if (!(cause instanceof OAuthProviderError)) throw cause; - }), - }, - identityResolver: { resolve: vi.fn() }, - authorizationCodeStore: { issue: vi.fn() }, - }); - - await expect( - service.completeAuthorization("github", { - state: STATE, - code: "github-code", - }) - ).rejects.toEqual( - expect.objectContaining({ - name: "OAuthProviderCallbackError", - message: "OAuth provider callback could not be completed", - failure, - }) - ); - } - ); -}); diff --git a/packages/control-plane/src/auth/oauth-provider-callback-service.ts b/packages/control-plane/src/auth/oauth-provider-callback-service.ts deleted file mode 100644 index 0132ca95a..000000000 --- a/packages/control-plane/src/auth/oauth-provider-callback-service.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - AdmissionDeniedError, - AdmissionUnavailableError, - type VerifiedProviderSignIn, -} from "./admission-policy"; -import { - AccountLinkRequiredError, - type ResolvedBrowserSignInIdentity, -} from "./browser-sign-in-identity"; -import type { OAuthProviderCallbackHandlerRegistry } from "./oauth-provider-callback-handler"; -import type { ConsumedOAuthFlowState } from "./oauth-flow-state"; -import { OAuthProviderError } from "./providers/types"; -import type { SignInProvider } from "./sign-in-provider"; - -const MAX_PROVIDER_AUTHORIZATION_CODE_LENGTH = 4_096; - -export interface CompleteProviderAuthorizationInput { - readonly state: string; - readonly code: string; -} - -export interface AdmissionPolicyPort { - requireAdmission(signIn: VerifiedProviderSignIn): Promise; -} - -export interface BrowserSignInIdentityResolverPort { - resolve(signIn: VerifiedProviderSignIn): Promise; -} - -export interface OAuthAuthorizationCodeIssuer { - issue(input: { - readonly userId: string; - readonly providerIdentityId: string; - readonly clientId: "web"; - readonly redirectUri: string; - readonly codeChallenge: string; - }): Promise<{ readonly code: string; readonly expiresAt: number }>; -} - -export interface OAuthClientRegistryPort { - accepts(clientId: string, redirectUri: string): boolean; -} - -export interface OAuthProviderCallbackServiceDependencies { - readonly clients: OAuthClientRegistryPort; - readonly providerHandlers: OAuthProviderCallbackHandlerRegistry; - readonly admissionPolicy: AdmissionPolicyPort; - readonly identityResolver: BrowserSignInIdentityResolverPort; - readonly authorizationCodeStore: OAuthAuthorizationCodeIssuer; -} - -export class OAuthProviderCallbackBindingError extends Error { - constructor() { - super("Consumed OAuth flow has an invalid client binding"); - this.name = "OAuthProviderCallbackBindingError"; - } -} - -export class OAuthProviderCallbackRequestError extends Error { - constructor() { - super("OAuth provider callback request is invalid"); - this.name = "OAuthProviderCallbackRequestError"; - } -} - -export type OAuthProviderCallbackFailure = - | "access_denied" - | "account_link_required" - | "temporarily_unavailable" - | "server_error"; - -export class OAuthProviderCallbackError extends Error { - constructor( - readonly failure: OAuthProviderCallbackFailure, - readonly redirectUri: string - ) { - super("OAuth provider callback could not be completed"); - this.name = "OAuthProviderCallbackError"; - } -} - -function callbackFailure(error: unknown): OAuthProviderCallbackFailure { - if (error instanceof AccountLinkRequiredError) { - return "account_link_required"; - } - if (error instanceof AdmissionDeniedError) { - return "access_denied"; - } - if ( - error instanceof AdmissionUnavailableError || - (error instanceof OAuthProviderError && error.failure === "provider_unavailable") - ) { - return "temporarily_unavailable"; - } - return "server_error"; -} - -export class OAuthProviderCallbackService { - constructor(private readonly dependencies: OAuthProviderCallbackServiceDependencies) {} - - async completeAuthorization( - provider: SignInProvider, - input: CompleteProviderAuthorizationInput - ): Promise { - if (input.code.length === 0 || input.code.length > MAX_PROVIDER_AUTHORIZATION_CODE_LENGTH) { - throw new OAuthProviderCallbackRequestError(); - } - - const callback = await this.dependencies.providerHandlers[provider].consume(input.state); - const { flow } = callback; - this.requireTrustedFlowBinding(flow); - try { - const signIn = await callback.exchange(input.code); - return await this.completeVerifiedSignIn(flow, signIn, input.state); - } catch (error) { - throw new OAuthProviderCallbackError(callbackFailure(error), flow.redirectUri); - } - } - - async completeDenial(provider: SignInProvider, state: string): Promise { - const { flow } = await this.dependencies.providerHandlers[provider].consume(state); - this.requireTrustedFlowBinding(flow); - const redirect = new URL(flow.redirectUri); - redirect.searchParams.set("error", "access_denied"); - redirect.searchParams.set("state", state); - return redirect; - } - - private requireTrustedFlowBinding(flow: ConsumedOAuthFlowState): void { - if (!this.dependencies.clients.accepts(flow.clientId, flow.redirectUri)) { - throw new OAuthProviderCallbackBindingError(); - } - } - - private async completeVerifiedSignIn( - flow: ConsumedOAuthFlowState, - signIn: VerifiedProviderSignIn, - state: string - ): Promise { - await this.dependencies.admissionPolicy.requireAdmission(signIn); - const resolved = await this.dependencies.identityResolver.resolve(signIn); - const authorizationCode = await this.dependencies.authorizationCodeStore.issue({ - userId: resolved.userId, - providerIdentityId: resolved.providerIdentityId, - clientId: flow.clientId, - redirectUri: flow.redirectUri, - codeChallenge: flow.clientCodeChallenge, - }); - - const redirect = new URL(flow.redirectUri); - redirect.searchParams.set("code", authorizationCode.code); - redirect.searchParams.set("state", state); - return redirect; - } -} diff --git a/packages/control-plane/src/auth/pkce.test.ts b/packages/control-plane/src/auth/pkce.test.ts deleted file mode 100644 index ca5c04c48..000000000 --- a/packages/control-plane/src/auth/pkce.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InvalidPkceVerifierError, createPkceS256Challenge } from "./pkce"; - -describe("createPkceS256Challenge", () => { - it("matches the RFC 7636 S256 test vector", async () => { - await expect( - createPkceS256Challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") - ).resolves.toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); - }); - - it("rejects verifiers outside the RFC 7636 syntax and length bounds", async () => { - await expect(createPkceS256Challenge("too-short")).rejects.toBeInstanceOf( - InvalidPkceVerifierError - ); - await expect(createPkceS256Challenge("*".repeat(43))).rejects.toBeInstanceOf( - InvalidPkceVerifierError - ); - }); -}); diff --git a/packages/control-plane/src/auth/pkce.ts b/packages/control-plane/src/auth/pkce.ts deleted file mode 100644 index 098af41f3..000000000 --- a/packages/control-plane/src/auth/pkce.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { base64UrlEncode } from "./encoding"; - -const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; -const PKCE_S256_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; - -export class InvalidPkceVerifierError extends Error { - constructor() { - super("PKCE verifier is malformed"); - this.name = "InvalidPkceVerifierError"; - } -} - -export function isPkceS256Challenge(value: unknown): value is string { - return typeof value === "string" && PKCE_S256_CHALLENGE_PATTERN.test(value); -} - -export function isPkceVerifier(value: unknown): value is string { - return typeof value === "string" && PKCE_VERIFIER_PATTERN.test(value); -} - -export async function createPkceS256Challenge(verifier: string): Promise { - if (!isPkceVerifier(verifier)) { - throw new InvalidPkceVerifierError(); - } - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); - return base64UrlEncode(new Uint8Array(digest)); -} diff --git a/packages/control-plane/src/auth/principal.ts b/packages/control-plane/src/auth/principal.ts index d299ce95f..0a27f46e7 100644 --- a/packages/control-plane/src/auth/principal.ts +++ b/packages/control-plane/src/auth/principal.ts @@ -26,8 +26,18 @@ export interface ResolvedIdentity { participantUserId: string; } +/** Provider-independent evidence used to authenticate a browser request. */ +export interface AuthenticationContext { + mechanism: "browser_session"; + credentialId: string; + channel: { + kind: "sig1"; + service: "web"; + }; +} + export type Principal = - | { kind: "user"; user: ResolvedIdentity; tokenId: string } + | { kind: "user"; userId: string } | { kind: "service"; service: ServiceName; actor: ResolvedIdentity | null } | { kind: "sandbox"; sessionId: string }; diff --git a/packages/control-plane/src/auth/provider-credential-cipher.ts b/packages/control-plane/src/auth/provider-credential-cipher.ts deleted file mode 100644 index 3f8f56bb8..000000000 --- a/packages/control-plane/src/auth/provider-credential-cipher.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ProviderCredentialKind } from "./provider-credential"; - -export interface ProviderCredentialCipherBinding { - providerIdentityId: string; - credentialKind: ProviderCredentialKind; - tokenRole: "access" | "refresh"; - encryptionKeyVersion: number; - rowVersion: number; -} - -/** - * Stable integrity failure exposed by the provider-credential cipher port. - * Implementations must throw this when ciphertext cannot be authenticated or - * decoded. - */ -export class ProviderCredentialIntegrityError extends Error { - constructor() { - super("Provider credential ciphertext could not be verified"); - this.name = "ProviderCredentialIntegrityError"; - } -} - -/** Encrypts provider tokens while binding them to their exact persisted row. */ -export interface ProviderCredentialCipherPort { - encrypt(plaintext: string, binding: ProviderCredentialCipherBinding): Promise; - decrypt(ciphertext: string, binding: ProviderCredentialCipherBinding): Promise; -} diff --git a/packages/control-plane/src/auth/providers/github.test.ts b/packages/control-plane/src/auth/providers/github.test.ts deleted file mode 100644 index 4058ed143..000000000 --- a/packages/control-plane/src/auth/providers/github.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { GitHubOAuthProvider } from "./github"; - -describe("GitHubOAuthProvider", () => { - const config = { - clientId: "github-client", - clientSecret: "github-secret", - callbackUri: "https://cp.example.com/oauth/callback/github", - issuer: "https://github.com", - userAgent: "Open Inspect Test", - }; - - it("rejects a non-canonical GitHub issuer", () => { - expect( - () => - new GitHubOAuthProvider({ - ...config, - issuer: "https://github.com.attacker.example", - }) - ).toThrow(expect.objectContaining({ failure: "invalid_configuration" })); - }); - - it("creates a GitHub App authorization URL bound to state and PKCE without classic scopes", async () => { - const provider = new GitHubOAuthProvider(config); - - const authorizationUrl = await provider.createAuthorizationUrl({ - state: "state-value", - codeChallenge: "a".repeat(43), - }); - - expect(authorizationUrl.origin + authorizationUrl.pathname).toBe( - "https://github.com/login/oauth/authorize" - ); - expect(Object.fromEntries(authorizationUrl.searchParams)).toEqual({ - client_id: "github-client", - redirect_uri: "https://cp.example.com/oauth/callback/github", - state: "state-value", - code_challenge: "a".repeat(43), - code_challenge_method: "S256", - }); - expect(authorizationUrl.searchParams.has("scope")).toBe(false); - }); - - it("exchanges the code with PKCE and returns validated identity and credential evidence", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - expires_in: 28_800, - refresh_token: "ghr-refresh", - refresh_token_expires_in: 15_552_000, - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - name: "The Octocat", - avatar_url: "https://avatars.example/octocat", - }) - ) - .mockResolvedValueOnce( - Response.json([ - { - email: "Secondary@Example.com", - primary: false, - verified: true, - visibility: null, - }, - { - email: "Primary@Example.com", - primary: true, - verified: true, - visibility: "private", - }, - { - email: "PRIMARY@example.com", - primary: false, - verified: true, - visibility: null, - }, - { - email: "unverified@example.com", - primary: false, - verified: false, - visibility: null, - }, - ]) - ); - const now = 1_752_000_000_000; - const provider = new GitHubOAuthProvider(config, { fetch, clock: { now: () => now } }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }) - ).resolves.toEqual({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "583231", - login: "octocat", - displayName: "The Octocat", - avatarUrl: "https://avatars.example/octocat", - verifiedEmails: ["secondary@example.com", "primary@example.com"], - primaryEmail: "primary@example.com", - }, - credential: { - kind: "refreshable", - accessToken: "ghu-access", - accessExpiresAt: now + 28_800_000, - refreshToken: "ghr-refresh", - refreshExpiresAt: now + 15_552_000_000, - }, - }); - - const tokenRequest = fetch.mock.calls[0]; - expect(String(tokenRequest[0])).toBe("https://github.com/login/oauth/access_token"); - expect(tokenRequest[1]).toMatchObject({ - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - expect(Object.fromEntries(new URLSearchParams(String(tokenRequest[1]?.body)))).toEqual({ - client_id: "github-client", - client_secret: "github-secret", - code: "github-code", - redirect_uri: "https://cp.example.com/oauth/callback/github", - code_verifier: "v".repeat(43), - }); - }); - - it("paginates GitHub emails to exhaustion so later verified evidence is not missed", async () => { - const firstPage = Array.from({ length: 100 }, (_, index) => ({ - email: `unverified-${index}@example.com`, - primary: false, - verified: false, - visibility: null, - })); - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - name: null, - avatar_url: null, - }) - ) - .mockResolvedValueOnce( - Response.json(firstPage, { - headers: { - Link: '; rel="next"', - }, - }) - ) - .mockResolvedValueOnce( - Response.json([ - { - email: "later-page@example.com", - primary: true, - verified: true, - visibility: null, - }, - ]) - ); - const provider = new GitHubOAuthProvider(config, { fetch }); - - const result = await provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }); - - expect(result.identity.verifiedEmails).toEqual(["later-page@example.com"]); - expect(result.identity.primaryEmail).toBe("later-page@example.com"); - expect(String(fetch.mock.calls[3][0])).toBe( - "https://api.github.com/user/emails?per_page=100&page=2" - ); - }); - - it("fails closed when GitHub email pagination metadata is malformed", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - }) - ) - .mockResolvedValueOnce( - Response.json([], { - headers: { Link: "this is not a valid Link header" }, - }) - ); - const provider = new GitHubOAuthProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "malformed_response", - }); - }); - - it("maps a malformed GitHub email pagination URL to a bounded provider error", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - }) - ) - .mockResolvedValueOnce( - Response.json([], { - headers: { Link: '; rel="next"' }, - }) - ); - const provider = new GitHubOAuthProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "malformed_response", - }); - }); - - it("fails closed when GitHub repeats an email page", async () => { - const repeatedPage = "https://api.github.com/user/emails?per_page=100&page=1"; - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - }) - ) - .mockResolvedValueOnce( - Response.json([], { - headers: { Link: `<${repeatedPage}>; rel="next"` }, - }) - ); - const provider = new GitHubOAuthProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "malformed_response", - }); - expect(fetch).toHaveBeenCalledTimes(3); - }); -}); diff --git a/packages/control-plane/src/auth/providers/github.ts b/packages/control-plane/src/auth/providers/github.ts deleted file mode 100644 index fd8c08fe2..000000000 --- a/packages/control-plane/src/auth/providers/github.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { z } from "zod"; -import type { ProviderCredentialInput } from "../provider-credential"; -import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./constants"; -import { - assertCanonicalIssuer, - OAuthProviderError, - type OAuthSignInProvider, - type ProviderAuthorizationRequest, - type ProviderCodeExchangeRequest, - type ProviderCodeExchangeResult, -} from "./types"; - -const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; -const GITHUB_ISSUER = "https://github.com"; -const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; -const GITHUB_API_URL = "https://api.github.com"; -const GITHUB_API_VERSION = "2022-11-28"; -const GITHUB_EMAILS_PER_PAGE = 100; -const GITHUB_EMAILS_MAX_PAGES = 10; - -const githubTokenResponseSchema = z - .object({ - access_token: z.string().min(1), - token_type: z.string().transform((value, ctx) => { - if (value.toLowerCase() !== "bearer") { - ctx.addIssue({ code: "custom", message: "token_type must be bearer" }); - return z.NEVER; - } - return "bearer" as const; - }), - expires_in: z.number().int().positive().optional(), - refresh_token: z.string().min(1).optional(), - refresh_token_expires_in: z.number().int().positive().optional(), - }) - .superRefine((value, ctx) => { - if (value.refresh_token && value.expires_in === undefined) { - ctx.addIssue({ - code: "custom", - path: ["expires_in"], - message: "refreshable credentials require access expiry", - }); - } - if (value.refresh_token_expires_in !== undefined && !value.refresh_token) { - ctx.addIssue({ - code: "custom", - path: ["refresh_token_expires_in"], - message: "refresh expiry requires a refresh token", - }); - } - }); - -const githubOAuthErrorSchema = z.object({ - error: z.string().min(1), - error_description: z.string().optional(), -}); - -const githubUserSchema = z.object({ - id: z.number().int().positive(), - login: z.string().min(1), - name: z.string().nullable().optional(), - avatar_url: z.url().nullable().optional(), -}); - -const githubEmailSchema = z.object({ - email: z.email(), - primary: z.boolean(), - verified: z.boolean(), - visibility: z.string().nullable(), -}); - -const githubEmailPageSchema = z.array(githubEmailSchema); - -export interface GitHubOAuthProviderConfig { - readonly clientId: string; - readonly clientSecret: string; - readonly callbackUri: string; - readonly issuer: string; - readonly userAgent: string; -} - -export interface GitHubOAuthProviderDependencies { - readonly fetch?: typeof globalThis.fetch; - readonly clock?: { now(): number }; - readonly requestTimeoutMs?: number; -} - -export class GitHubOAuthProvider implements OAuthSignInProvider<"github"> { - readonly provider = "github" as const; - private readonly fetchImpl: typeof globalThis.fetch; - private readonly clock: { now(): number }; - private readonly requestTimeoutMs: number; - - constructor( - private readonly config: GitHubOAuthProviderConfig, - dependencies: GitHubOAuthProviderDependencies = {} - ) { - assertCanonicalIssuer(config.issuer, GITHUB_ISSUER); - this.fetchImpl = dependencies.fetch ?? globalThis.fetch; - this.clock = dependencies.clock ?? { now: () => Date.now() }; - this.requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS; - } - - async createAuthorizationUrl(request: ProviderAuthorizationRequest<"github">): Promise { - const url = new URL(GITHUB_AUTHORIZE_URL); - url.searchParams.set("client_id", this.config.clientId); - url.searchParams.set("redirect_uri", this.config.callbackUri); - url.searchParams.set("state", request.state); - url.searchParams.set("code_challenge", request.codeChallenge); - url.searchParams.set("code_challenge_method", "S256"); - return url; - } - - async exchangeAuthorizationCode( - request: ProviderCodeExchangeRequest<"github"> - ): Promise> { - const token = await this.exchangeCode(request); - const [user, emailEntries] = await Promise.all([ - this.fetchGitHubUser(token.access_token), - this.fetchVerifiedEmails(token.access_token), - ]); - const verifiedEmailEntries = emailEntries.filter((entry) => entry.verified); - const verifiedEmails = [ - ...new Set(verifiedEmailEntries.map((entry) => entry.email.toLowerCase())), - ]; - return { - identity: { - provider: this.provider, - issuer: GITHUB_ISSUER, - subject: String(user.id), - login: user.login, - displayName: user.name ?? user.login, - ...(user.avatar_url ? { avatarUrl: user.avatar_url } : {}), - verifiedEmails, - primaryEmail: - verifiedEmailEntries.find((entry) => entry.primary)?.email.toLowerCase() ?? null, - }, - credential: this.toCredential(token), - }; - } - - private async exchangeCode( - request: ProviderCodeExchangeRequest<"github"> - ): Promise> { - const body = new URLSearchParams({ - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - code: request.code, - redirect_uri: this.config.callbackUri, - code_verifier: request.codeVerifier, - }); - const response = await this.fetchWithTimeout(GITHUB_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }); - const raw = await this.parseJson(response, "GitHub token"); - const providerError = githubOAuthErrorSchema.safeParse(raw); - if (!response.ok || providerError.success) { - throw new OAuthProviderError("provider_rejected", "GitHub rejected the authorization code"); - } - const parsed = githubTokenResponseSchema.safeParse(raw); - if (!parsed.success) { - throw new OAuthProviderError("malformed_response", "GitHub returned an invalid token"); - } - return parsed.data; - } - - private async fetchGitHubUser(accessToken: string): Promise> { - const response = await this.fetchWithTimeout(`${GITHUB_API_URL}/user`, { - headers: this.apiHeaders(accessToken), - }); - if (!response.ok) { - throw new OAuthProviderError("provider_unavailable", "GitHub user lookup was not successful"); - } - const parsed = githubUserSchema.safeParse(await this.parseJson(response, "GitHub user")); - if (!parsed.success) { - throw new OAuthProviderError("malformed_response", "GitHub returned an invalid user"); - } - return parsed.data; - } - - private async fetchVerifiedEmails( - accessToken: string - ): Promise>> { - let nextUrl: URL | null = new URL(`${GITHUB_API_URL}/user/emails`); - nextUrl.searchParams.set("per_page", String(GITHUB_EMAILS_PER_PAGE)); - nextUrl.searchParams.set("page", "1"); - const seenUrls = new Set(); - const entries: Array> = []; - - for (let page = 1; nextUrl !== null && page <= GITHUB_EMAILS_MAX_PAGES; page += 1) { - const currentUrl: URL = nextUrl; - const serializedUrl = currentUrl.toString(); - if (seenUrls.has(serializedUrl)) { - throw new OAuthProviderError( - "malformed_response", - "GitHub repeated an email pagination page" - ); - } - seenUrls.add(serializedUrl); - - const response: Response = await this.fetchWithTimeout(currentUrl, { - headers: this.apiHeaders(accessToken), - }); - if (!response.ok) { - throw new OAuthProviderError( - "provider_unavailable", - "GitHub email lookup was not successful" - ); - } - const parsed = githubEmailPageSchema.safeParse( - await this.parseJson(response, "GitHub emails") - ); - if (!parsed.success) { - throw new OAuthProviderError("malformed_response", "GitHub returned invalid emails"); - } - entries.push(...parsed.data); - - nextUrl = this.parseEmailNextPage(response.headers.get("Link")); - if (nextUrl !== null && page === GITHUB_EMAILS_MAX_PAGES) { - throw new OAuthProviderError( - "malformed_response", - "GitHub email pagination exceeded its limit" - ); - } - } - return entries; - } - - private parseEmailNextPage(linkHeader: string | null): URL | null { - if (!linkHeader) return null; - const links = linkHeader - .split(",") - .map((value) => value.trim().match(/^<([^>]+)>;\s*rel="([^"]+)"$/)); - if (links.some((match) => match === null)) { - throw new OAuthProviderError( - "malformed_response", - "GitHub returned malformed email pagination" - ); - } - const nextLinks = links - .filter((match): match is RegExpMatchArray => match !== null) - .filter((match) => match[2].split(/\s+/).includes("next")); - if (nextLinks.length === 0) return null; - if (nextLinks.length !== 1) { - throw new OAuthProviderError( - "malformed_response", - "GitHub returned ambiguous email pagination" - ); - } - - let url: URL; - try { - url = new URL(nextLinks[0][1]); - } catch { - throw new OAuthProviderError( - "malformed_response", - "GitHub returned invalid email pagination" - ); - } - if ( - url.origin !== GITHUB_API_URL || - url.pathname !== "/user/emails" || - url.searchParams.get("per_page") !== String(GITHUB_EMAILS_PER_PAGE) || - !/^[1-9]\d*$/.test(url.searchParams.get("page") ?? "") - ) { - throw new OAuthProviderError( - "malformed_response", - "GitHub returned invalid email pagination" - ); - } - return url; - } - - private apiHeaders(accessToken: string): HeadersInit { - return { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": GITHUB_API_VERSION, - "User-Agent": this.config.userAgent, - }; - } - - private toCredential(token: z.infer): ProviderCredentialInput { - const now = this.clock.now(); - if (token.refresh_token && token.expires_in !== undefined) { - return { - kind: "refreshable", - accessToken: token.access_token, - accessExpiresAt: now + token.expires_in * 1000, - refreshToken: token.refresh_token, - refreshExpiresAt: - token.refresh_token_expires_in === undefined - ? null - : now + token.refresh_token_expires_in * 1000, - }; - } - if (token.expires_in !== undefined) { - return { - kind: "access_only_expiring", - accessToken: token.access_token, - accessExpiresAt: now + token.expires_in * 1000, - }; - } - return { kind: "access_only_nonexpiring", accessToken: token.access_token }; - } - - private async parseJson(response: Response, context: string): Promise { - try { - return await response.json(); - } catch { - throw new OAuthProviderError("malformed_response", `${context} response was not JSON`); - } - } - - private async fetchWithTimeout(input: RequestInfo | URL, init: RequestInit): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); - try { - return await this.fetchImpl(input, { ...init, signal: controller.signal }); - } catch { - throw new OAuthProviderError("provider_unavailable", "GitHub request failed"); - } finally { - clearTimeout(timeout); - } - } -} diff --git a/packages/control-plane/src/auth/providers/google.test.ts b/packages/control-plane/src/auth/providers/google.test.ts deleted file mode 100644 index 3dd6f0ed5..000000000 --- a/packages/control-plane/src/auth/providers/google.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { hashToken } from "../crypto"; -import { base64UrlEncode } from "../encoding"; -import { GoogleOidcProvider } from "./google"; - -const discoveryDocument = { - issuer: "https://accounts.google.com", - authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth", - token_endpoint: "https://oauth2.googleapis.com/token", - jwks_uri: "https://www.googleapis.com/oauth2/v3/certs", - response_types_supported: ["code"], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["RS256"], - token_endpoint_auth_methods_supported: ["client_secret_post"], - code_challenge_methods_supported: ["S256"], -}; - -const config = { - clientId: "google-client", - clientSecret: "google-secret", - callbackUri: "https://cp.example.com/oauth/callback/google", - issuer: "https://accounts.google.com", -}; - -async function createSignedIdToken( - claims: Record -): Promise<{ idToken: string; publicJwk: Record }> { - const keyPair = (await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["sign", "verify"] - )) as CryptoKeyPair; - const header = base64UrlEncode(JSON.stringify({ alg: "RS256", kid: "test-key", typ: "JWT" })); - const payload = base64UrlEncode(JSON.stringify(claims)); - const signingInput = `${header}.${payload}`; - const signature = await crypto.subtle.sign( - "RSASSA-PKCS1-v1_5", - keyPair.privateKey, - new TextEncoder().encode(signingInput) - ); - const publicJwk = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as unknown as Record< - string, - unknown - >; - return { - idToken: `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`, - publicJwk: { ...publicJwk, kid: "test-key", alg: "RS256", use: "sig" }, - }; -} - -async function expectSignedTokenRejection( - claims: Record, - failure = "malformed_response" -): Promise { - const { idToken, publicJwk } = await createSignedIdToken(claims); - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(discoveryDocument)) - .mockResolvedValueOnce( - Response.json({ - access_token: "google-access", - token_type: "Bearer", - id_token: idToken, - }) - ) - .mockResolvedValueOnce(Response.json({ keys: [publicJwk] })); - const provider = new GoogleOidcProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "google-code", - codeVerifier: "v".repeat(43), - oidcNonceHash: await hashToken("nonce-value"), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure, - }); -} - -describe("GoogleOidcProvider", () => { - it("rejects a non-canonical Google issuer before discovery", () => { - const fetch = vi.fn(); - - expect( - () => - new GoogleOidcProvider( - { - ...config, - issuer: "https://accounts.google.com.attacker.example", - }, - { fetch } - ) - ).toThrow(expect.objectContaining({ failure: "invalid_configuration" })); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("discovers Google and creates an OIDC authorization URL with PKCE and nonce", async () => { - const fetch = vi - .fn() - .mockResolvedValue(Response.json(discoveryDocument)); - const provider = new GoogleOidcProvider(config, { fetch }); - - const authorizationUrl = await provider.createAuthorizationUrl({ - state: "state-value", - codeChallenge: "a".repeat(43), - oidcNonce: "nonce-value", - }); - - expect(authorizationUrl.origin + authorizationUrl.pathname).toBe( - "https://accounts.google.com/o/oauth2/v2/auth" - ); - expect(Object.fromEntries(authorizationUrl.searchParams)).toEqual({ - client_id: "google-client", - redirect_uri: "https://cp.example.com/oauth/callback/google", - response_type: "code", - scope: "openid email profile", - state: "state-value", - code_challenge: "a".repeat(43), - code_challenge_method: "S256", - nonce: "nonce-value", - }); - expect(String(fetch.mock.calls[0][0])).toBe( - "https://accounts.google.com/.well-known/openid-configuration" - ); - }); - - it("validates the signed ID token and binds its nonce through the persisted hash", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - const nonce = "nonce-value"; - const { idToken, publicJwk } = await createSignedIdToken({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - azp: "google-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce, - email: "person@example.com", - email_verified: true, - name: "A Person", - picture: "https://images.example/person", - }); - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(discoveryDocument)) - .mockResolvedValueOnce( - Response.json({ - access_token: "google-access", - token_type: "Bearer", - expires_in: 3_600, - id_token: idToken, - }) - ) - .mockResolvedValueOnce(Response.json({ keys: [publicJwk] })); - const provider = new GoogleOidcProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "google-code", - codeVerifier: "v".repeat(43), - oidcNonceHash: await hashToken(nonce), - }) - ).resolves.toEqual({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-subject", - displayName: "A Person", - avatarUrl: "https://images.example/person", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: null, - }); - - expect(String(fetch.mock.calls[1][0])).toBe("https://oauth2.googleapis.com/token"); - const tokenBody = new URLSearchParams(String(fetch.mock.calls[1][1]?.body)); - expect(Object.fromEntries(tokenBody)).toEqual({ - redirect_uri: "https://cp.example.com/oauth/callback/google", - code: "google-code", - code_verifier: "v".repeat(43), - grant_type: "authorization_code", - client_id: "google-client", - client_secret: "google-secret", - }); - expect(String(fetch.mock.calls[2][0])).toBe("https://www.googleapis.com/oauth2/v3/certs"); - }); - - it("rejects an ID token that does not verify against the discovered JWKS", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - const { idToken, publicJwk } = await createSignedIdToken({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce: "nonce-value", - }); - const [header, payload, signature] = idToken.split("."); - const forgedIdToken = `${header}.${payload}.${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(discoveryDocument)) - .mockResolvedValueOnce( - Response.json({ - access_token: "google-access", - token_type: "Bearer", - id_token: forgedIdToken, - }) - ) - .mockResolvedValueOnce(Response.json({ keys: [publicJwk] })); - const provider = new GoogleOidcProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "google-code", - codeVerifier: "v".repeat(43), - oidcNonceHash: await hashToken("nonce-value"), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "malformed_response", - }); - }); - - it("rejects a valid signed token whose nonce does not match the consumed flow", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - const { idToken, publicJwk } = await createSignedIdToken({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce: "attacker-nonce", - }); - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(discoveryDocument)) - .mockResolvedValueOnce( - Response.json({ - access_token: "google-access", - token_type: "Bearer", - id_token: idToken, - }) - ) - .mockResolvedValueOnce(Response.json({ keys: [publicJwk] })); - const provider = new GoogleOidcProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "google-code", - codeVerifier: "v".repeat(43), - oidcNonceHash: await hashToken("expected-nonce"), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "provider_rejected", - }); - }); - - it("rejects a signed token with an unexpected authorized party", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - await expectSignedTokenRejection({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - azp: "another-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce: "nonce-value", - }); - }); - - it("rejects a signed token issued for a different audience", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - await expectSignedTokenRejection({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "another-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce: "nonce-value", - }); - }); - - it("rejects an expired signed token", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - await expectSignedTokenRejection({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - exp: nowEpochSeconds - 300, - iat: nowEpochSeconds - 600, - nonce: "nonce-value", - }); - }); -}); diff --git a/packages/control-plane/src/auth/providers/google.ts b/packages/control-plane/src/auth/providers/google.ts deleted file mode 100644 index 695da0326..000000000 --- a/packages/control-plane/src/auth/providers/google.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { z } from "zod"; -import { - OidcAuthorizationCodeClient, - type OidcAuthorizationCodeClientDependencies, -} from "./oidc-authorization-code-client"; -import { - assertCanonicalIssuer, - OAuthProviderError, - type OAuthSignInProvider, - type ProviderAuthorizationRequest, - type ProviderCodeExchangeRequest, - type ProviderCodeExchangeResult, -} from "./types"; - -const GOOGLE_ISSUER = "https://accounts.google.com"; -const GOOGLE_SCOPES = ["openid", "email", "profile"] as const; - -const googleIdentityClaimsSchema = z - .object({ - iss: z.literal(GOOGLE_ISSUER), - sub: z.string().min(1), - email: z.email().optional(), - email_verified: z.boolean().optional(), - name: z.string().min(1).optional(), - picture: z.url().optional(), - }) - .superRefine((claims, ctx) => { - if (claims.email_verified === true && claims.email === undefined) { - ctx.addIssue({ - code: "custom", - path: ["email"], - message: "verified email claim requires an email", - }); - } - }); - -export interface GoogleOidcProviderConfig { - readonly clientId: string; - readonly clientSecret: string; - readonly callbackUri: string; - readonly issuer: string; -} - -export type GoogleOidcProviderDependencies = OidcAuthorizationCodeClientDependencies; - -export class GoogleOidcProvider implements OAuthSignInProvider<"google"> { - readonly provider = "google" as const; - private readonly oidc: OidcAuthorizationCodeClient; - - constructor(config: GoogleOidcProviderConfig, dependencies: GoogleOidcProviderDependencies = {}) { - assertCanonicalIssuer(config.issuer, GOOGLE_ISSUER); - this.oidc = new OidcAuthorizationCodeClient( - { - issuer: GOOGLE_ISSUER, - clientId: config.clientId, - clientSecret: config.clientSecret, - callbackUri: config.callbackUri, - scopes: GOOGLE_SCOPES, - }, - dependencies - ); - } - - async createAuthorizationUrl(request: ProviderAuthorizationRequest<"google">): Promise { - return this.oidc.createAuthorizationUrl({ - state: request.state, - codeChallenge: request.codeChallenge, - nonce: request.oidcNonce, - }); - } - - async exchangeAuthorizationCode( - request: ProviderCodeExchangeRequest<"google"> - ): Promise> { - const rawClaims = await this.oidc.exchangeAuthorizationCode({ - code: request.code, - codeVerifier: request.codeVerifier, - nonceHash: request.oidcNonceHash, - }); - const claims = googleIdentityClaimsSchema.safeParse(rawClaims); - if (!claims.success) { - throw new OAuthProviderError("malformed_response", "Google returned invalid identity claims"); - } - - const verifiedEmail = claims.data.email_verified === true ? (claims.data.email ?? null) : null; - return { - identity: { - provider: this.provider, - issuer: claims.data.iss, - subject: claims.data.sub, - ...(claims.data.name ? { displayName: claims.data.name } : {}), - ...(claims.data.picture ? { avatarUrl: claims.data.picture } : {}), - verifiedEmails: verifiedEmail ? [verifiedEmail] : [], - primaryEmail: verifiedEmail, - }, - credential: null, - }; - } -} diff --git a/packages/control-plane/src/auth/providers/oidc-authorization-code-client.ts b/packages/control-plane/src/auth/providers/oidc-authorization-code-client.ts deleted file mode 100644 index fa05b39b7..000000000 --- a/packages/control-plane/src/auth/providers/oidc-authorization-code-client.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { timingSafeEqual } from "@open-inspect/shared"; -import * as oauth from "oauth4webapi"; -import { hashToken } from "../crypto"; -import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./constants"; -import { OAuthProviderError } from "./types"; - -const SHA_256_HEX_PATTERN = /^[0-9a-f]{64}$/; - -export interface OidcAuthorizationCodeClientConfig { - readonly issuer: string; - readonly clientId: string; - readonly clientSecret: string; - readonly callbackUri: string; - readonly scopes: readonly string[]; -} - -export interface OidcAuthorizationCodeClientDependencies { - readonly fetch?: typeof globalThis.fetch; - readonly requestTimeoutMs?: number; - readonly tokenHasher?: { hash(value: string): Promise }; -} - -export interface OidcAuthorizationRequest { - readonly state: string; - readonly codeChallenge: string; - readonly nonce: string; -} - -export interface OidcCodeExchangeRequest { - readonly code: string; - readonly codeVerifier: string; - readonly nonceHash: string; -} - -/** - * Maintained OIDC protocol boundary shared by executable OIDC provider - * adapters. Provider-specific code owns issuer allowlisting and claim policy; - * this client owns discovery, PKCE exchange, ID-token validation, JWKS - * signature verification, and hash-only nonce binding. - */ -export class OidcAuthorizationCodeClient { - private readonly issuer: URL; - private readonly fetchImpl: typeof globalThis.fetch; - private readonly requestTimeoutMs: number; - private readonly tokenHasher: { hash(value: string): Promise }; - private readonly client: oauth.Client; - private readonly jwksCache: oauth.JWKSCacheInput = {}; - private discovery: Promise | null = null; - - constructor( - private readonly config: OidcAuthorizationCodeClientConfig, - dependencies: OidcAuthorizationCodeClientDependencies = {} - ) { - this.issuer = new URL(config.issuer); - this.fetchImpl = dependencies.fetch ?? globalThis.fetch; - this.requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS; - this.tokenHasher = dependencies.tokenHasher ?? { hash: hashToken }; - this.client = { client_id: config.clientId }; - } - - async createAuthorizationUrl(request: OidcAuthorizationRequest): Promise { - const authorizationServer = await this.getAuthorizationServer(); - if (!authorizationServer.authorization_endpoint) { - throw new OAuthProviderError( - "invalid_configuration", - "OIDC discovery omitted the authorization endpoint" - ); - } - if (!authorizationServer.code_challenge_methods_supported?.includes("S256")) { - throw new OAuthProviderError( - "invalid_configuration", - "OIDC discovery does not advertise PKCE S256" - ); - } - - const url = new URL(authorizationServer.authorization_endpoint); - url.searchParams.set("client_id", this.config.clientId); - url.searchParams.set("redirect_uri", this.config.callbackUri); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", this.config.scopes.join(" ")); - url.searchParams.set("state", request.state); - url.searchParams.set("code_challenge", request.codeChallenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("nonce", request.nonce); - return url; - } - - async exchangeAuthorizationCode(request: OidcCodeExchangeRequest): Promise { - if (!SHA_256_HEX_PATTERN.test(request.nonceHash)) { - throw new OAuthProviderError( - "invalid_request", - "OIDC authorization-code exchange requires a nonce hash" - ); - } - - try { - const authorizationServer = await this.getAuthorizationServer(); - const callbackParameters = oauth.validateAuthResponse( - authorizationServer, - this.client, - new URLSearchParams({ code: request.code }), - oauth.expectNoState - ); - const tokenResponse = await oauth.authorizationCodeGrantRequest( - authorizationServer, - this.client, - oauth.ClientSecretPost(this.config.clientSecret), - callbackParameters, - this.config.callbackUri, - request.codeVerifier, - { [oauth.customFetch]: this.fetchWithTimeout } - ); - - // The raw nonce is deliberately not persisted. oauth4webapi validates - // issuer, audience, expiry, algorithm, and ID-token claim structure; the - // application then verifies the signed nonce claim against the stored - // SHA-256 value. - const tokenResult = await oauth.processGenericTokenEndpointResponse( - authorizationServer, - this.client, - tokenResponse - ); - await oauth.validateApplicationLevelSignature(authorizationServer, tokenResponse, { - [oauth.customFetch]: this.fetchWithTimeout, - [oauth.jwksCache]: this.jwksCache, - }); - const claims = oauth.getValidatedIdTokenClaims(tokenResult); - if (!claims || typeof claims.nonce !== "string" || claims.nonce.length === 0) { - throw new OAuthProviderError("malformed_response", "OIDC identity claims are invalid"); - } - if (claims.azp !== undefined && claims.azp !== this.config.clientId) { - throw new OAuthProviderError( - "malformed_response", - "OIDC returned an unexpected authorized party" - ); - } - const actualNonceHash = await this.tokenHasher.hash(claims.nonce); - if (!timingSafeEqual(actualNonceHash, request.nonceHash)) { - throw new OAuthProviderError("provider_rejected", "OIDC nonce validation failed"); - } - return claims; - } catch (error) { - if (error instanceof OAuthProviderError) throw error; - if ( - error instanceof oauth.ResponseBodyError || - error instanceof oauth.AuthorizationResponseError - ) { - throw new OAuthProviderError("provider_rejected", "OIDC provider rejected the code"); - } - throw new OAuthProviderError("malformed_response", "OIDC validation failed"); - } - } - - private getAuthorizationServer(): Promise { - if (!this.discovery) { - this.discovery = this.discover().catch((error) => { - this.discovery = null; - if (error instanceof OAuthProviderError) throw error; - throw new OAuthProviderError("provider_unavailable", "OIDC discovery failed"); - }); - } - return this.discovery; - } - - private async discover(): Promise { - const response = await oauth.discoveryRequest(this.issuer, { - algorithm: "oidc", - [oauth.customFetch]: this.fetchWithTimeout, - }); - return oauth.processDiscoveryResponse(this.issuer, response); - } - - private readonly fetchWithTimeout = async ( - input: string | URL | Request, - init?: RequestInit - ): Promise => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); - try { - return await this.fetchImpl(input, { ...init, signal: controller.signal }); - } catch (error) { - if (error instanceof OAuthProviderError) throw error; - throw new OAuthProviderError("provider_unavailable", "OIDC request failed"); - } finally { - clearTimeout(timeout); - } - }; -} diff --git a/packages/control-plane/src/auth/providers/types.test.ts b/packages/control-plane/src/auth/providers/types.test.ts deleted file mode 100644 index ada4784c6..000000000 --- a/packages/control-plane/src/auth/providers/types.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - OAuthSignInProvider, - OAuthSignInProviderRegistry, - ProviderAuthorizationRequest, - ProviderCodeExchangeRequest, -} from "./types"; - -describe("OAuth provider contracts", () => { - it("requires Google nonce bindings and excludes them from GitHub requests", () => { - const googleAuthorization: ProviderAuthorizationRequest<"google"> = { - state: "state", - codeChallenge: "challenge", - oidcNonce: "nonce", - }; - const githubAuthorization: ProviderAuthorizationRequest<"github"> = { - state: "state", - codeChallenge: "challenge", - }; - const googleExchange: ProviderCodeExchangeRequest<"google"> = { - code: "code", - codeVerifier: "verifier", - oidcNonceHash: "nonce-hash", - }; - const githubExchange: ProviderCodeExchangeRequest<"github"> = { - code: "code", - codeVerifier: "verifier", - }; - - // @ts-expect-error Google authorization requires an OIDC nonce. - const googleWithoutNonce: ProviderAuthorizationRequest<"google"> = { - state: "state", - codeChallenge: "challenge", - }; - const githubWithNonce: ProviderAuthorizationRequest<"github"> = { - state: "state", - codeChallenge: "challenge", - // @ts-expect-error GitHub authorization cannot carry an OIDC nonce. - oidcNonce: "nonce", - }; - // @ts-expect-error Google exchange requires the persisted OIDC nonce hash. - const googleExchangeWithoutNonce: ProviderCodeExchangeRequest<"google"> = { - code: "code", - codeVerifier: "verifier", - }; - const githubExchangeWithNonce: ProviderCodeExchangeRequest<"github"> = { - code: "code", - codeVerifier: "verifier", - // @ts-expect-error GitHub exchange cannot carry an OIDC nonce hash. - oidcNonceHash: "nonce-hash", - }; - - expect(googleAuthorization.oidcNonce).toBe("nonce"); - expect(githubAuthorization).not.toHaveProperty("oidcNonce"); - expect(googleExchange.oidcNonceHash).toBe("nonce-hash"); - expect(githubExchange).not.toHaveProperty("oidcNonceHash"); - void googleWithoutNonce; - void githubWithNonce; - void googleExchangeWithoutNonce; - void githubExchangeWithNonce; - }); - - it("prevents provider adapters from being registered under another provider key", () => { - const googleProvider: OAuthSignInProvider<"google"> = { - provider: "google", - async createAuthorizationUrl() { - return new URL("https://google.example/authorize"); - }, - async exchangeAuthorizationCode() { - throw new Error("not used"); - }, - }; - const registry: OAuthSignInProviderRegistry = { - // @ts-expect-error The GitHub key cannot hold a Google adapter. - github: googleProvider, - google: googleProvider, - }; - - void registry; - }); -}); diff --git a/packages/control-plane/src/auth/providers/types.ts b/packages/control-plane/src/auth/providers/types.ts deleted file mode 100644 index dd556f8b8..000000000 --- a/packages/control-plane/src/auth/providers/types.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { SignInProvider } from "../sign-in-provider"; -import type { ProviderCredentialInput } from "../provider-credential"; - -interface ProviderAuthorizationRequestBinding { - readonly state: string; - readonly codeChallenge: string; -} - -interface ProviderAuthorizationRequestByProvider { - readonly github: ProviderAuthorizationRequestBinding & { - readonly oidcNonce?: never; - }; - readonly google: ProviderAuthorizationRequestBinding & { - readonly oidcNonce: string; - }; -} - -export type ProviderAuthorizationRequest

= - ProviderAuthorizationRequestByProvider[P]; - -interface ProviderCodeExchangeRequestBinding { - readonly code: string; - readonly codeVerifier: string; -} - -interface ProviderCodeExchangeRequestByProvider { - readonly github: ProviderCodeExchangeRequestBinding & { - readonly oidcNonceHash?: never; - }; - readonly google: ProviderCodeExchangeRequestBinding & { - readonly oidcNonceHash: string; - }; -} - -export type ProviderCodeExchangeRequest

= - ProviderCodeExchangeRequestByProvider[P]; - -export interface VerifiedProviderIdentity

{ - readonly provider: P; - readonly issuer: string; - readonly subject: string; - readonly login?: string; - readonly displayName?: string; - readonly avatarUrl?: string; - readonly verifiedEmails: readonly string[]; - readonly primaryEmail: string | null; -} - -interface ProviderCodeExchangeResultByProvider { - readonly github: { - readonly identity: VerifiedProviderIdentity<"github">; - readonly credential: ProviderCredentialInput; - }; - readonly google: { - readonly identity: VerifiedProviderIdentity<"google">; - readonly credential: null; - }; -} - -export type ProviderCodeExchangeResult

= - ProviderCodeExchangeResultByProvider[P]; - -export type OAuthProviderFailure = - | "invalid_configuration" - | "invalid_request" - | "provider_rejected" - | "provider_unavailable" - | "malformed_response"; - -export class OAuthProviderError extends Error { - constructor( - readonly failure: OAuthProviderFailure, - message: string - ) { - super(message); - this.name = "OAuthProviderError"; - } -} - -export function assertCanonicalIssuer(configuredIssuer: string, expectedIssuer: string): void { - let configured: URL; - try { - configured = new URL(configuredIssuer); - } catch { - throw new OAuthProviderError("invalid_configuration", "Provider issuer is invalid"); - } - const expected = new URL(expectedIssuer); - if (configured.href !== expected.href) { - throw new OAuthProviderError("invalid_configuration", "Provider issuer is not canonical"); - } -} - -export interface OAuthSignInProvider

{ - readonly provider: P; - createAuthorizationUrl(request: ProviderAuthorizationRequest

): Promise; - exchangeAuthorizationCode( - request: ProviderCodeExchangeRequest

- ): Promise>; -} - -export type OAuthSignInProviderRegistry = { - readonly [P in SignInProvider]: OAuthSignInProvider

; -}; diff --git a/packages/control-plane/src/auth/result.ts b/packages/control-plane/src/auth/result.ts new file mode 100644 index 000000000..957db7cfb --- /dev/null +++ b/packages/control-plane/src/auth/result.ts @@ -0,0 +1,25 @@ +import type { AuthenticationContext, Principal } from "./principal"; + +export interface AuthError { + /** Response body message (also the log detail). Never carries token material. */ + reason: string; + status: 401 | 413 | 500; + /** + * Which scheme was attempted and failed. A per-service attempt is terminal; + * "none" means no recognized credential was presented at all, and the + * router may still try sandbox auth on sandbox routes. + */ + failedScheme: "per-service" | "browser-session" | "none"; +} + +export type AuthResult = + | { + principal: Principal; + request: Request; + authentication?: AuthenticationContext; + } + | AuthError; + +export function isAuthError(result: AuthResult): result is AuthError { + return !("principal" in result); +} diff --git a/packages/control-plane/src/auth/callback-signing.ts b/packages/control-plane/src/auth/service/callback-signing.ts similarity index 90% rename from packages/control-plane/src/auth/callback-signing.ts rename to packages/control-plane/src/auth/service/callback-signing.ts index dc4e3f5c6..5412e6fe3 100644 --- a/packages/control-plane/src/auth/callback-signing.ts +++ b/packages/control-plane/src/auth/service/callback-signing.ts @@ -6,7 +6,7 @@ * with its own. */ -import { serviceAuthSecret, type ServiceKeyEnv } from "./authenticate"; +import { serviceAuthSecret, type ServiceKeyEnv } from "./config"; /** The bots the CP delivers callbacks to — also the only services that may attach a `callbackContext`. */ export const CALLBACK_DESTINATIONS = ["slack-bot", "linear-bot"] as const; diff --git a/packages/control-plane/src/auth/service/config.ts b/packages/control-plane/src/auth/service/config.ts new file mode 100644 index 000000000..614686c5b --- /dev/null +++ b/packages/control-plane/src/auth/service/config.ts @@ -0,0 +1,26 @@ +import type { ServiceName } from "@open-inspect/shared"; + +/** The per-service verification keys held by the control plane. */ +export interface ServiceKeyEnv { + SERVICE_AUTH_SECRET_WEB?: string; + SERVICE_AUTH_SECRET_SLACK_BOT?: string; + SERVICE_AUTH_SECRET_GITHUB_BOT?: string; + SERVICE_AUTH_SECRET_LINEAR_BOT?: string; + SERVICE_AUTH_SECRET_MODAL?: string; +} + +/** Resolve the verification key for one authenticated service. */ +export function serviceAuthSecret(env: ServiceKeyEnv, service: ServiceName): string | undefined { + switch (service) { + case "web": + return env.SERVICE_AUTH_SECRET_WEB; + case "slack-bot": + return env.SERVICE_AUTH_SECRET_SLACK_BOT; + case "github-bot": + return env.SERVICE_AUTH_SECRET_GITHUB_BOT; + case "linear-bot": + return env.SERVICE_AUTH_SECRET_LINEAR_BOT; + case "modal": + return env.SERVICE_AUTH_SECRET_MODAL; + } +} diff --git a/packages/control-plane/src/auth/service/request-authenticator.ts b/packages/control-plane/src/auth/service/request-authenticator.ts new file mode 100644 index 000000000..ee5287964 --- /dev/null +++ b/packages/control-plane/src/auth/service/request-authenticator.ts @@ -0,0 +1,198 @@ +import { + ACTOR_HEADER, + SERVICE_HEADER, + TOKEN_VALIDITY_MS, + isServiceName, + parseServiceSignatureHeader, + readBodyCapped, + sha256Hex, + verifyServiceSignature, + type ServiceName, +} from "@open-inspect/shared"; +import { UserStore } from "../../db/user-store"; +import { createLogger } from "../../logger"; +import type { RequestContext } from "../../routes/shared"; +import type { Env } from "../../types"; +import { ASSERTION_RIGHTS, isActorNamespace, type ActorNamespace } from "../principal"; +import type { AuthResult } from "../result"; +import { serviceAuthSecret } from "./config"; + +const logger = createLogger("auth"); + +/** + * Hard cap on a service-signed request body. The signature covers the body + * hash, so the body must be buffered and hashed before verification can + * finish. The largest legitimate signed body is a session attachment upload. + */ +export const SERVICE_REQUEST_MAX_BODY_BYTES = 16 * 1024 * 1024; + +/** Parse `:` into a typed actor reference; null when malformed. */ +function parseActor(actor: string): { provider: ActorNamespace; providerUserId: string } | null { + const separator = actor.indexOf(":"); + if (separator <= 0) return null; + const namespace = actor.slice(0, separator); + const providerUserId = actor.slice(separator + 1); + if (providerUserId === "" || !isActorNamespace(namespace)) return null; + return { provider: namespace, providerUserId }; +} + +/** + * Best-effort nonce-reuse detection (log-only for now). This is in-isolate + * state; entries expire with the signature validity window. + */ +const seenNonces = new Map(); +const SEEN_NONCE_LIMIT = 5000; + +function recordNonce(service: ServiceName, nonce: string, ctx: RequestContext): void { + const now = Date.now(); + const key = `${service}:${nonce}`; + const expiresAt = seenNonces.get(key); + if (expiresAt !== undefined && expiresAt > now) { + logger.warn("Service auth nonce reused", { + event: "auth.nonce_reuse", + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return; + } + if (seenNonces.size >= SEEN_NONCE_LIMIT) { + for (const [candidate, expiry] of seenNonces) { + if (expiry <= now) seenNonces.delete(candidate); + } + // Map iteration follows insertion order, which also follows expiry order. + let excess = seenNonces.size - SEEN_NONCE_LIMIT + 1; + for (const candidate of seenNonces.keys()) { + if (excess-- <= 0) break; + seenNonces.delete(candidate); + } + } + seenNonces.set(key, now + TOKEN_VALIDITY_MS); +} + +export async function authenticateServiceRequest( + request: Request, + env: Env, + ctx: RequestContext, + signatureHeader: string +): Promise { + const serviceHeader = request.headers.get(SERVICE_HEADER) ?? ""; + if (!isServiceName(serviceHeader)) { + logger.warn("Service auth failed: unknown service", { + event: "auth.service_failed", + failure: "unknown_service", + service: serviceHeader, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + const service = serviceHeader; + + const secret = serviceAuthSecret(env, service); + if (!secret) { + logger.error("Service auth secret not configured - rejecting request", { + event: "auth.misconfigured", + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { + reason: "Service authentication not configured", + status: 500, + failedScheme: "per-service", + }; + } + + // Reject malformed or stale signatures before buffering the request body. + const parsedSignature = parseServiceSignatureHeader(signatureHeader); + if (!parsedSignature.ok) { + logger.warn("Service auth failed: signature rejected", { + event: "auth.service_failed", + failure: parsedSignature.reason, + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + + let bodyBuffer: Uint8Array | null = null; + if (request.body !== null) { + bodyBuffer = await readBodyCapped(request.body, SERVICE_REQUEST_MAX_BODY_BYTES); + if (bodyBuffer === null) { + logger.warn("Service auth failed: body over size cap", { + event: "auth.service_failed", + failure: "body_too_large", + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Request body too large", status: 413, failedScheme: "per-service" }; + } + } + const bodySha256Hex = await sha256Hex(bodyBuffer ?? ""); + const actor = request.headers.get(ACTOR_HEADER) ?? ""; + + const verification = await verifyServiceSignature({ + signatureHeader, + service, + secret, + method: request.method, + url: request.url, + bodySha256Hex, + actor, + }); + if (!verification.ok) { + logger.warn("Service auth failed: signature rejected", { + event: "auth.service_failed", + failure: verification.reason, + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + + recordNonce(service, verification.nonce, ctx); + + let resolvedActor = null; + if (actor !== "") { + const parsed = parseActor(actor); + if (!parsed || ASSERTION_RIGHTS[service] !== parsed.provider) { + logger.warn("Actor assertion denied", { + event: "auth.assertion_denied", + service, + actor, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + const identity = await new UserStore(ctx.db).getIdentity( + parsed.provider, + parsed.providerUserId + ); + resolvedActor = { + provider: parsed.provider, + providerUserId: parsed.providerUserId, + canonicalUserId: identity?.userId ?? null, + participantUserId: actor, + }; + } + + // Rebuild requests whose body was consumed for signature verification. + const handlerRequest = + bodyBuffer === null + ? request + : new Request(request.url, { + method: request.method, + headers: request.headers, + body: bodyBuffer, + }); + + return { + principal: { kind: "service", service, actor: resolvedActor }, + request: handlerRequest, + }; +} diff --git a/packages/control-plane/src/auth/subject-verification.test.ts b/packages/control-plane/src/auth/subject-verification.test.ts deleted file mode 100644 index 953a5624e..000000000 --- a/packages/control-plane/src/auth/subject-verification.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { verifySubjectToken } from "./subject-verification"; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = vi.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); -}); - -function mockFetchResponse(status: number, body: unknown): void { - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) - ); -} - -const OCTOCAT = { - id: 583231, - login: "octocat", - name: "The Octocat", - email: null, - avatar_url: "https://avatars.example/octocat", -}; - -/** URL-aware GitHub mock: /user and /user/emails answer independently. */ -function mockGitHubFetch(opts: { - user?: { status?: number; body?: unknown }; - emails?: { status?: number; body?: unknown; reject?: boolean }; -}): void { - vi.mocked(globalThis.fetch).mockImplementation(async (input) => { - const url = String(input); - if (url === "https://api.github.com/user") { - return new Response(JSON.stringify(opts.user?.body ?? OCTOCAT), { - status: opts.user?.status ?? 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "https://api.github.com/user/emails") { - if (opts.emails?.reject) throw new TypeError("network down"); - return new Response(JSON.stringify(opts.emails?.body ?? []), { - status: opts.emails?.status ?? 200, - headers: { "Content-Type": "application/json" }, - }); - } - throw new Error(`unexpected url ${url}`); - }); -} - -describe("github-access-token", () => { - it("resolves the verified primary email even when the public profile email is null", async () => { - mockGitHubFetch({ - emails: { - body: [ - { email: "secondary@example.com", primary: false, verified: true }, - { email: "primary@example.com", primary: true, verified: true }, - ], - }, - }); - const result = await verifySubjectToken("github-access-token", "gho_token"); - expect(result).toEqual({ - ok: true, - subject: { - provider: "github", - providerUserId: "583231", - providerLogin: "octocat", - // The verified primary from /user/emails, NOT /user.email (null) — so - // email-based cross-provider linking mints the family on the right user. - providerEmail: "primary@example.com", - displayName: "The Octocat", - avatarUrl: "https://avatars.example/octocat", - }, - }); - const urls = vi.mocked(globalThis.fetch).mock.calls.map((c) => String(c[0])); - expect(urls).toEqual(["https://api.github.com/user", "https://api.github.com/user/emails"]); - }); - - it("degrades to no email when /user/emails access is deterministically unsupported", async () => { - // 403 = GitHub App missing the "Email addresses" permission; 404 = OAuth - // token without the email scope. Retrying cannot produce an email in - // these deployments, so the exchange proceeds with id-based resolution. - for (const status of [403, 404]) { - mockGitHubFetch({ emails: { status, body: { message: "nope" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toMatchObject({ - ok: true, - subject: { providerUserId: "583231", providerEmail: undefined }, - }); - } - }); - - it("fails closed when the /user/emails request fails transiently", async () => { - // A transient failure must NOT read as "no email": on a first exchange it - // would mint the family on a fresh duplicate canonical user and fix the - // provider identity to it permanently. Fail retryable instead. - mockGitHubFetch({ emails: { reject: true } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - mockGitHubFetch({ emails: { status: 500, body: { message: "boom" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - mockGitHubFetch({ emails: { status: 429, body: { message: "rate limited" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("fails closed on a malformed /user/emails body instead of reading it as no email", async () => { - mockGitHubFetch({ emails: { body: [{ email: "x@example.com" }] } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("never treats an unverified or non-primary email as the verified identity", async () => { - mockGitHubFetch({ - emails: { - body: [ - { email: "unverified-primary@example.com", primary: true, verified: false }, - { email: "verified-secondary@example.com", primary: false, verified: true }, - ], - }, - }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toMatchObject({ - ok: true, - subject: { providerEmail: undefined }, - }); - }); - - it("resolves an identity from id+login even when the display fields are absent", async () => { - // Validation is fail-closed on the identity keys only; a valid-but-partial - // body (no name/avatar) must still resolve, not be rejected. - mockGitHubFetch({ user: { body: { id: 583231, login: "octocat" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toMatchObject({ - ok: true, - subject: { providerUserId: "583231", providerLogin: "octocat", displayName: "octocat" }, - }); - }); - - it("fails closed on a malformed 200 /user body instead of minting a 'undefined' subject", async () => { - // A 200 whose body is missing `id` must not collapse to providerUserId - // "undefined" — treat it as the provider failing, fail closed, retryable. - mockGitHubFetch({ user: { body: { login: "octocat", avatar_url: "https://a/x" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("maps provider 401 to subject_rejected", async () => { - mockFetchResponse(401, { message: "Bad credentials" }); - expect(await verifySubjectToken("github-access-token", "bad")).toEqual({ - ok: false, - failure: "subject_rejected", - }); - }); - - it("maps provider 5xx and network failures to provider_unavailable", async () => { - mockFetchResponse(502, {}); - expect(await verifySubjectToken("github-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - vi.mocked(globalThis.fetch).mockRejectedValue(new TypeError("network down")); - expect(await verifySubjectToken("github-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("maps 429 throttling to provider_unavailable, not subject_rejected", async () => { - mockFetchResponse(429, { message: "rate limited" }); - expect(await verifySubjectToken("github-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); -}); - -describe("google-access-token", () => { - it("returns the provider-verified identity from userinfo", async () => { - mockFetchResponse(200, { - sub: "1078462347", - email: "person@example.com", - email_verified: true, - name: "A Person", - picture: "https://lh3.example/photo", - }); - const result = await verifySubjectToken("google-access-token", "ya29.token"); - expect(result).toEqual({ - ok: true, - subject: { - provider: "google", - providerUserId: "1078462347", - providerEmail: "person@example.com", - displayName: "A Person", - avatarUrl: "https://lh3.example/photo", - }, - }); - expect(String(vi.mocked(globalThis.fetch).mock.calls[0][0])).toBe( - "https://openidconnect.googleapis.com/v1/userinfo" - ); - }); - - it("maps provider 401 to subject_rejected", async () => { - mockFetchResponse(401, { error: "invalid_token" }); - expect(await verifySubjectToken("google-access-token", "bad")).toEqual({ - ok: false, - failure: "subject_rejected", - }); - }); - - it("never exposes an unverified email as the subject's providerEmail", async () => { - for (const email_verified of [false, undefined]) { - mockFetchResponse(200, { - sub: "1078462347", - email: "victim@example.com", - ...(email_verified === undefined ? {} : { email_verified }), - name: "A Person", - }); - const result = await verifySubjectToken("google-access-token", "ya29.token"); - expect(result).toMatchObject({ - ok: true, - subject: { providerUserId: "1078462347", providerEmail: undefined }, - }); - } - }); - - it("treats malformed userinfo responses as provider_unavailable", async () => { - mockFetchResponse(200, { not_sub: "x" }); - expect(await verifySubjectToken("google-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("maps network failures to provider_unavailable", async () => { - vi.mocked(globalThis.fetch).mockRejectedValue(new TypeError("network down")); - expect(await verifySubjectToken("google-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); -}); diff --git a/packages/control-plane/src/auth/subject-verification.ts b/packages/control-plane/src/auth/subject-verification.ts deleted file mode 100644 index c65ee6a72..000000000 --- a/packages/control-plane/src/auth/subject-verification.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Subject-token verification for the exchange. - * - * The CP verifies the presented provider credential WITH the provider — - * never trusting asserted claims — and returns the provider's own account - * identity as the verified subject. - */ - -import { z } from "zod"; - -import { getGitHubUser, getGitHubUserEmails, GitHubUserApiError } from "./github"; -import type { SignInProvider } from "./sign-in-provider"; -import { createLogger } from "../logger"; - -const logger = createLogger("subject-verification"); - -export const SUBJECT_TOKEN_TYPES = ["github-access-token", "google-access-token"] as const; -export type SubjectTokenType = (typeof SUBJECT_TOKEN_TYPES)[number]; - -export interface VerifiedSubject { - provider: SignInProvider; - providerUserId: string; - providerLogin?: string; - providerEmail?: string; - displayName?: string; - avatarUrl?: string; -} - -export type SubjectVerificationResult = - | { ok: true; subject: VerifiedSubject } - /** - * `subject_rejected`: the provider says the token is invalid (401/403) — - * the caller's assertion failed verification. `provider_unavailable`: the - * provider itself failed (429/other 4xx/5xx/timeout/network) — fail - * closed, retryable. - */ - | { ok: false; failure: "subject_rejected" | "provider_unavailable" }; - -const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"; -const PROVIDER_FETCH_TIMEOUT_MS = 10_000; - -const googleUserinfoSchema = z.object({ - sub: z.string().min(1), - email: z.string().optional(), - email_verified: z.boolean().optional(), - name: z.string().optional(), - picture: z.string().optional(), -}); - -/** Non-2xx from a provider identity endpoint, carrying the status for classification. */ -class ProviderStatusError extends Error { - constructor(readonly status: number) { - super(`Provider identity endpoint returned ${status}`); - this.name = "ProviderStatusError"; - } -} - -/** - * Run one provider identity fetch under the shared contract: a - * PROVIDER_FETCH_TIMEOUT_MS abort, status classification (401/403 means the - * subject was rejected, everything else — 429/other 4xx/5xx/timeout/network/ - * malformed — means the provider failed), and the single failure log site. - * Per-provider code contributes only the URL/schema/shape mapping. - */ -async function fetchProviderIdentity( - provider: SignInProvider, - fetchSubject: (signal: AbortSignal) => Promise -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PROVIDER_FETCH_TIMEOUT_MS); - try { - return { ok: true, subject: await fetchSubject(controller.signal) }; - } catch (error) { - const status = - error instanceof GitHubUserApiError || error instanceof ProviderStatusError - ? error.status - : undefined; - // 401/403 are the provider judging the credential. Everything else — - // 429 throttling, other 4xx (our request shape), 5xx/timeout/network — - // is the provider failing, so fail closed and retryable. - const failure = status === 401 || status === 403 ? "subject_rejected" : "provider_unavailable"; - logger.warn("Subject verification failed", { - event: "auth.subject_verification_failed", - provider, - provider_status: status, - failure, - }); - return { ok: false, failure }; - } finally { - clearTimeout(timer); - } -} - -function verifyGitHubSubject(accessToken: string): Promise { - return fetchProviderIdentity("github", async (signal) => { - const user = await getGitHubUser(accessToken, undefined, signal); - return { - provider: "github", - providerUserId: String(user.id), - providerLogin: user.login, - // Resolve the VERIFIED PRIMARY email, exactly as the web sign-in flow - // does — NOT the public profile email. `/user.email` is null when the - // user has no public email, which would skip email-based cross-provider - // linking in resolveOrCreateUser and mint the 90-day token family on a - // fresh, orphaned canonical user instead of the existing one. Using the - // same source as sign-in keeps the exchange and the provider-identity - // upsert resolving to the same canonical user. - providerEmail: await resolveVerifiedGitHubEmail(accessToken, signal), - displayName: user.name ?? user.login, - avatarUrl: user.avatar_url, - }; - }); -} - -/** - * `/user/emails` statuses that deterministically mean the deployment cannot - * grant email access: 403 (GitHub App missing the "Email addresses" - * permission) and 404 (OAuth token without the email scope). Retrying cannot - * produce an email in these deployments — the web session carries no email - * there either — so account linking correctly degrades to id-based - * resolution. - */ -const GITHUB_EMAILS_UNSUPPORTED_STATUSES = new Set([403, 404]); - -/** - * The verified primary email GitHub reports for the token's user, or - * undefined ONLY when the deployment deterministically has no email access. - * Every other failure — 5xx, throttling, network/timeout, malformed body — - * propagates and fails the exchange closed (`provider_unavailable`, - * retryable): the email is an identity-linking key, and treating a transient - * failure as "no email" would mint the token family on a fresh duplicate - * canonical user, permanently fixing the provider identity to it. - */ -async function resolveVerifiedGitHubEmail( - accessToken: string, - signal: AbortSignal -): Promise { - try { - const emails = await getGitHubUserEmails(accessToken, undefined, signal); - return emails.find((entry) => entry.primary && entry.verified)?.email; - } catch (error) { - if ( - error instanceof GitHubUserApiError && - GITHUB_EMAILS_UNSUPPORTED_STATUSES.has(error.status) - ) { - return undefined; - } - throw error; - } -} - -function verifyGoogleSubject(accessToken: string): Promise { - return fetchProviderIdentity("google", async (signal) => { - const response = await fetch(GOOGLE_USERINFO_URL, { - headers: { Authorization: `Bearer ${accessToken}` }, - signal, - }); - if (!response.ok) { - throw new ProviderStatusError(response.status); - } - const parsed = googleUserinfoSchema.safeParse(await response.json().catch(() => null)); - if (!parsed.success) { - throw new Error("Malformed Google userinfo response"); - } - return { - provider: "google", - providerUserId: parsed.data.sub, - // Email-keyed account linking downstream means an unverified email - // must never leave this boundary — the subject stays valid by `sub`. - providerEmail: parsed.data.email_verified === true ? parsed.data.email : undefined, - displayName: parsed.data.name, - avatarUrl: parsed.data.picture, - }; - }); -} - -export async function verifySubjectToken( - subjectTokenType: SubjectTokenType, - subjectToken: string -): Promise { - switch (subjectTokenType) { - case "github-access-token": - return verifyGitHubSubject(subjectToken); - case "google-access-token": - return verifyGoogleSubject(subjectToken); - } -} diff --git a/packages/control-plane/src/auth/token-exchange.ts b/packages/control-plane/src/auth/token-exchange.ts deleted file mode 100644 index ae0fb0679..000000000 --- a/packages/control-plane/src/auth/token-exchange.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * The provider-verified token exchange: verify the presented - * subject with its provider, resolve the canonical user, capture SCM - * credentials, and mint a web session token pair. Lives beside - * WebSessionTokenService so P2's public OAuth surface can reuse the same - * sequence without going through the internal route. - */ - -import type { SignInProvider } from "./sign-in-provider"; -import { verifySubjectToken, type SubjectTokenType } from "./subject-verification"; -import type { WebSessionTokenService, WebSessionTokenPair } from "./web-session-tokens"; -import type { SqlDatabase } from "../db/sql-database"; -import { UserStore } from "../db/user-store"; -import { UserScmTokenStore, DEFAULT_TOKEN_LIFETIME_MS } from "../db/user-scm-tokens"; - -export interface ExchangeRequest { - subjectTokenType: SubjectTokenType; - subjectToken: string; - scmRefreshToken?: string; - scmTokenExpiresAt?: number; -} - -export type ExchangeResult = - | { ok: true; userId: string; provider: SignInProvider; pair: WebSessionTokenPair } - | { ok: false; failure: "subject_rejected" | "provider_unavailable" }; - -/** - * Run the exchange. SCM capture is awaited — a failure fails the exchange - * (fail closed) rather than minting tokens for a user whose credentials were - * silently dropped. - */ -export async function performExchange( - request: ExchangeRequest, - db: SqlDatabase, - tokenService: WebSessionTokenService, - tokenEncryptionKey: string | undefined -): Promise { - const verification = await verifySubjectToken(request.subjectTokenType, request.subjectToken); - if (!verification.ok) { - return { ok: false, failure: verification.failure }; - } - const subject = verification.subject; - - // Resolve the canonical user from the VERIFIED identity — this is the - // identity-creating path for web, replacing trust in body fields. - const user = await new UserStore(db).resolveOrCreateUser({ - provider: subject.provider, - providerUserId: subject.providerUserId, - providerLogin: subject.providerLogin, - providerEmail: subject.providerEmail, - displayName: subject.displayName, - avatarUrl: subject.avatarUrl, - }); - - // Capture SCM credentials once, keyed by the provider-verified id — the - // same store session-create feeds today, now from a verified source. - if (subject.provider === "github" && request.scmRefreshToken && tokenEncryptionKey) { - await new UserScmTokenStore(db, tokenEncryptionKey).upsertTokens( - subject.providerUserId, - request.subjectToken, - request.scmRefreshToken, - request.scmTokenExpiresAt ?? Date.now() + DEFAULT_TOKEN_LIFETIME_MS, - user.id - ); - } - - const pair = await tokenService.mintPair(user.id, { - provider: subject.provider, - providerUserId: subject.providerUserId, - }); - - return { ok: true, userId: user.id, provider: subject.provider, pair }; -} diff --git a/packages/control-plane/src/auth/admission-policy.test.ts b/packages/control-plane/src/auth/user/admission-policy.test.ts similarity index 70% rename from packages/control-plane/src/auth/admission-policy.test.ts rename to packages/control-plane/src/auth/user/admission-policy.test.ts index 95382d755..75cc9bb1d 100644 --- a/packages/control-plane/src/auth/admission-policy.test.ts +++ b/packages/control-plane/src/auth/user/admission-policy.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { AdmissionDeniedError, AdmissionPolicy, @@ -7,7 +7,7 @@ import { parseAdmissionBoolean, type AdmissionPolicyConfig, } from "./admission-policy"; -import type { ProviderCodeExchangeResult } from "./providers/types"; +import type { ProviderSignInResult } from "./providers/types"; const BASE_CONFIG: AdmissionPolicyConfig = { allowedGitHubUsers: [], @@ -17,7 +17,7 @@ const BASE_CONFIG: AdmissionPolicyConfig = { unsafeAllowAllUsers: false, }; -const GOOGLE_SIGN_IN: ProviderCodeExchangeResult<"google"> = { +const GOOGLE_SIGN_IN: ProviderSignInResult<"google"> = { identity: { provider: "google", issuer: "https://accounts.google.com", @@ -28,7 +28,26 @@ const GOOGLE_SIGN_IN: ProviderCodeExchangeResult<"google"> = { credential: null, }; +const GITHUB_SIGN_IN: ProviderSignInResult<"github"> = { + identity: { + provider: "github", + issuer: "https://github.com", + subject: "123", + login: "octocat", + verifiedEmails: [], + primaryEmail: null, + }, + credential: { + kind: "access_only_nonexpiring", + accessToken: "ghu_token", + }, +}; + describe("AdmissionPolicy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("evaluates the complete verified email set with OR semantics", async () => { const policy = new AdmissionPolicy({ ...BASE_CONFIG, @@ -50,22 +69,7 @@ describe("AdmissionPolicy", () => { { fetcher } ); - await expect( - policy.requireAdmission({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "123", - login: "octocat", - verifiedEmails: [], - primaryEmail: null, - }, - credential: { - kind: "access_only_nonexpiring", - accessToken: "ghu_token", - }, - }) - ).resolves.toEqual({ + await expect(policy.requireAdmission(GITHUB_SIGN_IN)).resolves.toEqual({ reason: "github_organization", organization: "open-inspect", }); @@ -78,6 +82,26 @@ describe("AdmissionPolicy", () => { ); }); + it("preserves the Worker receiver when using the default global fetch", async () => { + const runtimeFetch = vi.fn(function (this: unknown) { + if (this !== globalThis) { + throw new TypeError("Illegal invocation"); + } + return Promise.resolve(Response.json({ state: "active" })); + }); + vi.stubGlobal("fetch", runtimeFetch); + const policy = new AdmissionPolicy({ + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }); + + await expect(policy.requireAdmission(GITHUB_SIGN_IN)).resolves.toEqual({ + reason: "github_organization", + organization: "open-inspect", + }); + expect(runtimeFetch).toHaveBeenCalledOnce(); + }); + it("parses deployment admission settings conservatively", () => { expect(parseAdmissionAllowlist(" Alice,alice, BOB ,, ")).toEqual(["alice", "bob"]); expect(parseAdmissionBoolean(" TRUE ")).toBe(true); @@ -116,19 +140,6 @@ describe("AdmissionPolicy", () => { }); it("distinguishes definitive non-membership from an unavailable organization check", async () => { - const signIn: ProviderCodeExchangeResult<"github"> = { - identity: { - provider: "github", - issuer: "https://github.com", - subject: "123", - verifiedEmails: [], - primaryEmail: null, - }, - credential: { - kind: "access_only_nonexpiring", - accessToken: "ghu_token", - }, - }; const unavailable = new AdmissionPolicy( { ...BASE_CONFIG, @@ -138,7 +149,7 @@ describe("AdmissionPolicy", () => { fetcher: vi.fn().mockResolvedValue(new Response(null, { status: 503 })), } ); - await expect(unavailable.requireAdmission(signIn)).rejects.toBeInstanceOf( + await expect(unavailable.requireAdmission(GITHUB_SIGN_IN)).rejects.toBeInstanceOf( AdmissionUnavailableError ); @@ -151,7 +162,9 @@ describe("AdmissionPolicy", () => { fetcher: vi.fn().mockResolvedValue(new Response(null, { status: 404 })), } ); - await expect(denied.requireAdmission(signIn)).rejects.toBeInstanceOf(AdmissionDeniedError); + await expect(denied.requireAdmission(GITHUB_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionDeniedError + ); const pending = new AdmissionPolicy( { @@ -162,6 +175,8 @@ describe("AdmissionPolicy", () => { fetcher: vi.fn().mockResolvedValue(Response.json({ state: "pending" })), } ); - await expect(pending.requireAdmission(signIn)).rejects.toBeInstanceOf(AdmissionDeniedError); + await expect(pending.requireAdmission(GITHUB_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionDeniedError + ); }); }); diff --git a/packages/control-plane/src/auth/admission-policy.ts b/packages/control-plane/src/auth/user/admission-policy.ts similarity index 93% rename from packages/control-plane/src/auth/admission-policy.ts rename to packages/control-plane/src/auth/user/admission-policy.ts index 1d3a16a65..328e8e0ac 100644 --- a/packages/control-plane/src/auth/admission-policy.ts +++ b/packages/control-plane/src/auth/user/admission-policy.ts @@ -1,10 +1,10 @@ import { z } from "zod"; import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./providers/constants"; -import type { ProviderCodeExchangeResult } from "./providers/types"; +import type { ProviderSignInResult } from "./providers/types"; export type VerifiedProviderSignIn = - | ProviderCodeExchangeResult<"github"> - | ProviderCodeExchangeResult<"google">; + | ProviderSignInResult<"github"> + | ProviderSignInResult<"google">; export interface AdmissionPolicyConfig { readonly allowedGitHubUsers: readonly string[]; @@ -61,9 +61,7 @@ function emailDomain(email: string): string | null { return email.slice(separator + 1).toLowerCase(); } -function isGitHubSignIn( - signIn: VerifiedProviderSignIn -): signIn is ProviderCodeExchangeResult<"github"> { +function isGitHubSignIn(signIn: VerifiedProviderSignIn): signIn is ProviderSignInResult<"github"> { return signIn.identity.provider === "github"; } @@ -79,7 +77,7 @@ export class AdmissionPolicy { allowedGitHubOrganizations: normalize(config.allowedGitHubOrganizations), unsafeAllowAllUsers: config.unsafeAllowAllUsers, }; - this.fetcher = dependencies.fetcher ?? fetch; + this.fetcher = dependencies.fetcher ?? globalThis.fetch.bind(globalThis); } async requireAdmission(signIn: VerifiedProviderSignIn): Promise { @@ -120,7 +118,7 @@ export class AdmissionPolicy { } private async requireGitHubOrganization( - signIn: ProviderCodeExchangeResult<"github"> + signIn: ProviderSignInResult<"github"> ): Promise { const accessToken = signIn.credential.accessToken; let unavailable = false; diff --git a/packages/control-plane/src/auth/user/better-auth.ts b/packages/control-plane/src/auth/user/better-auth.ts new file mode 100644 index 000000000..893dcfda5 --- /dev/null +++ b/packages/control-plane/src/auth/user/better-auth.ts @@ -0,0 +1,107 @@ +import { BROWSER_AUTH_CLIENT_IP_HEADER } from "@open-inspect/shared"; +import { betterAuth } from "better-auth"; +import { generateId } from "../crypto"; +import type { CanonicalUserProjection } from "./canonical-user-projection"; +import type { ProviderProfileResolver } from "./provider-profile"; + +const MS_PER_SECOND = 1000; + +export const SESSION_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * MS_PER_SECOND; +export const SESSION_UPDATE_AGE_MS = 24 * 60 * 60 * MS_PER_SECOND; + +export interface UserAuthConfig { + readonly database: D1Database; + readonly publicWebOrigin: string; + readonly secret: string; + readonly userProjection: CanonicalUserProjection; + readonly github?: { + readonly clientId: string; + readonly clientSecret: string; + readonly getUserInfo: ProviderProfileResolver; + }; + readonly google?: { + readonly clientId: string; + readonly clientSecret: string; + readonly getUserInfo: ProviderProfileResolver; + }; +} + +/** + * Creates the control plane's user-authentication authority. + * + * `publicWebOrigin` is deliberately the browser-visible web origin rather than + * the control-plane origin. The web transparently proxies this handler, so all + * redirects and host-only cookies remain scoped to the web application. + */ +export function createUserAuth(config: UserAuthConfig) { + return betterAuth({ + baseURL: config.publicWebOrigin, + database: config.database, + secret: config.secret, + trustedOrigins: [config.publicWebOrigin], + telemetry: { enabled: false }, + // Workers do not expose NODE_ENV through process.env under every supported + // compatibility date. Keep the production security behavior explicit. + rateLimit: { + enabled: true, + window: 60, + max: 100, + storage: "memory", + }, + advanced: { + cookiePrefix: "openinspect", + ipAddress: { + ipAddressHeaders: [BROWSER_AUTH_CLIENT_IP_HEADER], + }, + // Production is HTTPS-only. Loopback HTTP remains available for local + // development, where browsers reject Secure cookies by design. + useSecureCookies: new URL(config.publicWebOrigin).protocol === "https:", + // Application authorization names users by the existing canonical + // 32-character lowercase-hex ID. Keep Better Auth authoritative for ID + // creation while preserving that stable cross-service contract. + database: { + generateId: () => generateId(), + }, + }, + socialProviders: { + ...(config.github + ? { + github: { + ...config.github, + disableDefaultScope: true, + }, + } + : {}), + ...(config.google ? { google: config.google } : {}), + }, + user: { + modelName: "auth_users", + }, + session: { + modelName: "auth_sessions", + expiresIn: SESSION_EXPIRES_IN_MS / MS_PER_SECOND, + updateAge: SESSION_UPDATE_AGE_MS / MS_PER_SECOND, + }, + account: { + modelName: "auth_accounts", + accountLinking: { + disableImplicitLinking: true, + }, + encryptOAuthTokens: true, + }, + verification: { + modelName: "auth_verifications", + storeIdentifier: "hashed", + }, + databaseHooks: { + user: { + create: { + after: (user) => config.userProjection.project(user), + }, + update: { + after: (user) => config.userProjection.project(user), + }, + }, + }, + }); +} diff --git a/packages/control-plane/src/auth/user/canonical-user-projection.ts b/packages/control-plane/src/auth/user/canonical-user-projection.ts new file mode 100644 index 000000000..56e80a611 --- /dev/null +++ b/packages/control-plane/src/auth/user/canonical-user-projection.ts @@ -0,0 +1,18 @@ +export interface UserProjectionInput { + readonly id: string; + readonly name: string; + readonly email: string; + readonly image?: string | null; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +/** + * Projects Better Auth's user into the application's actor model. + * + * The ids must remain identical: authorization always names `users.id`, while + * Better Auth remains authoritative for authentication state. + */ +export interface CanonicalUserProjection { + project(user: UserProjectionInput): Promise; +} diff --git a/packages/control-plane/src/auth/provider-credential.ts b/packages/control-plane/src/auth/user/provider-credential.ts similarity index 100% rename from packages/control-plane/src/auth/provider-credential.ts rename to packages/control-plane/src/auth/user/provider-credential.ts diff --git a/packages/control-plane/src/auth/user/provider-profile.ts b/packages/control-plane/src/auth/user/provider-profile.ts new file mode 100644 index 000000000..04fdc7e6f --- /dev/null +++ b/packages/control-plane/src/auth/user/provider-profile.ts @@ -0,0 +1,21 @@ +export interface ProviderTokens { + readonly accessToken?: string; + readonly refreshToken?: string; + readonly accessTokenExpiresAt?: Date; + readonly refreshTokenExpiresAt?: Date; + readonly idToken?: string; + readonly scopes?: readonly string[]; +} + +export interface ProviderProfile { + readonly user: { + readonly id: string; + readonly name?: string; + readonly email?: string | null; + readonly image?: string; + readonly emailVerified: boolean; + }; + readonly data: unknown; +} + +export type ProviderProfileResolver = (tokens: ProviderTokens) => Promise; diff --git a/packages/control-plane/src/auth/providers/constants.ts b/packages/control-plane/src/auth/user/providers/constants.ts similarity index 100% rename from packages/control-plane/src/auth/providers/constants.ts rename to packages/control-plane/src/auth/user/providers/constants.ts diff --git a/packages/control-plane/src/auth/user/providers/github-identity.test.ts b/packages/control-plane/src/auth/user/providers/github-identity.test.ts new file mode 100644 index 000000000..415099d6b --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/github-identity.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from "vitest"; +import { GitHubProviderIdentityResolver } from "./github-identity"; + +describe("GitHubProviderIdentityResolver", () => { + const config = { + issuer: "https://github.com", + userAgent: "Open Inspect Test", + }; + + it("rejects a non-canonical GitHub issuer", () => { + expect( + () => + new GitHubProviderIdentityResolver({ + ...config, + issuer: "https://github.com.attacker.example", + }) + ).toThrow(expect.objectContaining({ failure: "invalid_configuration" })); + }); + + it("resolves a verified provider identity from an existing access token", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + id: 583_231, + login: "octocat", + name: "The Octocat", + avatar_url: "https://avatars.example/octocat", + }) + ) + .mockResolvedValueOnce( + Response.json([ + { + email: "Primary@Example.com", + primary: true, + verified: true, + visibility: "private", + }, + { + email: "PRIMARY@example.com", + primary: false, + verified: true, + visibility: null, + }, + { + email: "unverified@example.com", + primary: false, + verified: false, + visibility: null, + }, + ]) + ); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + await expect(resolver.resolveIdentity("ghu-access")).resolves.toEqual({ + provider: "github", + issuer: "https://github.com", + subject: "583231", + login: "octocat", + displayName: "The Octocat", + avatarUrl: "https://avatars.example/octocat", + verifiedEmails: ["primary@example.com"], + primaryEmail: "primary@example.com", + }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("preserves the Workers receiver when using the default global fetch", async () => { + const runtimeFetch = vi.fn(async function ( + this: unknown, + input: RequestInfo | URL + ): Promise { + if (this !== globalThis) { + throw new TypeError("Illegal invocation"); + } + if (String(input) === "https://api.github.com/user") { + return Response.json({ + id: 583_231, + login: "octocat", + name: "The Octocat", + avatar_url: null, + }); + } + return Response.json([ + { + email: "octocat@example.com", + primary: true, + verified: true, + visibility: null, + }, + ]); + }); + vi.stubGlobal("fetch", runtimeFetch); + + try { + const resolver = new GitHubProviderIdentityResolver(config); + + await expect(resolver.resolveIdentity("ghu-access")).resolves.toMatchObject({ + subject: "583231", + primaryEmail: "octocat@example.com", + }); + expect(runtimeFetch).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("retries one transient GitHub network failure", async () => { + let userAttempts = 0; + const fetch = vi.fn().mockImplementation(async (input) => { + const url = String(input); + if (url === "https://api.github.com/user") { + userAttempts += 1; + if (userAttempts === 1) { + throw new TypeError("temporary network failure"); + } + return Response.json({ + id: 583_231, + login: "octocat", + name: "The Octocat", + avatar_url: null, + }); + } + return Response.json([ + { + email: "octocat@example.com", + primary: true, + verified: true, + visibility: null, + }, + ]); + }); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + await expect(resolver.resolveIdentity("ghu-access")).resolves.toMatchObject({ + subject: "583231", + primaryEmail: "octocat@example.com", + }); + expect(userAttempts).toBe(2); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("preserves the final network failure for provider diagnostics", async () => { + const cause = new TypeError("persistent network failure"); + const fetch = vi.fn().mockRejectedValue(cause); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + await expect(resolver.resolveIdentity("ghu-access")).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "provider_unavailable", + cause, + }); + }); + + it("logs a sanitized diagnostic after the network retry is exhausted", async () => { + const cause = new TypeError("persistent network failure"); + const fetch = vi.fn().mockRejectedValue(cause); + const logger = { error: vi.fn() }; + const resolver = new GitHubProviderIdentityResolver(config, { fetch, logger }); + + await expect(resolver.resolveIdentity("ghu-access")).rejects.toMatchObject({ + failure: "provider_unavailable", + }); + expect(logger.error).toHaveBeenCalledWith("GitHub provider request failed", { + event: "auth.github_provider_request_failed", + attempts: 2, + error: cause, + }); + }); + + it("constructs subsequent GitHub email page URLs locally", async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + email: `unverified-${index}@example.com`, + primary: false, + verified: false, + visibility: null, + })); + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + id: 583_231, + login: "octocat", + name: null, + avatar_url: null, + }) + ) + .mockResolvedValueOnce( + Response.json(firstPage, { + headers: { + Link: '; rel="next"', + }, + }) + ) + .mockResolvedValueOnce( + Response.json([ + { + email: "later-page@example.com", + primary: true, + verified: true, + visibility: null, + }, + ]) + ); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + const result = await resolver.resolveIdentity("ghu-access"); + + expect(result.verifiedEmails).toEqual(["later-page@example.com"]); + expect(result.primaryEmail).toBe("later-page@example.com"); + expect(String(fetch.mock.calls[2][0])).toBe( + "https://api.github.com/user/emails?per_page=100&page=2" + ); + }); + + it("bounds GitHub email pagination", async () => { + const fullPage = Array.from({ length: 100 }, (_, index) => ({ + email: `email-${index}@example.com`, + primary: false, + verified: true, + visibility: null, + })); + const fetch = vi.fn().mockImplementation(async (input) => { + if (String(input) === "https://api.github.com/user") { + return Response.json({ id: 583_231, login: "octocat" }); + } + return Response.json(fullPage, { + headers: { + Link: '; rel="next"', + }, + }); + }); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + await expect(resolver.resolveIdentity("ghu-access")).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + message: "GitHub email pagination exceeded its limit", + }); + expect(fetch).toHaveBeenCalledTimes(11); + expect(String(fetch.mock.calls[10][0])).toBe( + "https://api.github.com/user/emails?per_page=100&page=10" + ); + }); +}); diff --git a/packages/control-plane/src/auth/user/providers/github-identity.ts b/packages/control-plane/src/auth/user/providers/github-identity.ts new file mode 100644 index 000000000..18e424e77 --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/github-identity.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; +import { createLogger, type Logger } from "../../../logger"; +import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./constants"; +import { assertCanonicalIssuer, OAuthProviderError, type VerifiedProviderIdentity } from "./types"; + +const GITHUB_ISSUER = "https://github.com"; +const GITHUB_API_URL = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const GITHUB_EMAILS_PER_PAGE = 100; +const GITHUB_EMAILS_MAX_PAGES = 10; +const GITHUB_NETWORK_REQUEST_ATTEMPTS = 2; + +const githubUserSchema = z.object({ + id: z.number().int().positive(), + login: z.string().min(1), + name: z.string().nullable().optional(), + avatar_url: z.url().nullable().optional(), +}); + +const githubEmailSchema = z.object({ + email: z.email(), + primary: z.boolean(), + verified: z.boolean(), + visibility: z.string().nullable(), +}); + +const githubEmailPageSchema = z.array(githubEmailSchema); + +export interface GitHubProviderIdentityResolverConfig { + readonly issuer: string; + readonly userAgent: string; +} + +export interface GitHubProviderIdentityResolverDependencies { + readonly fetch?: typeof globalThis.fetch; + readonly requestTimeoutMs?: number; + readonly logger?: Pick; +} + +/** + * Resolves GitHub identity evidence from an access token exchanged and owned + * by Better Auth. This boundary deliberately implements no OAuth protocol. + */ +export class GitHubProviderIdentityResolver { + private readonly fetchImpl: typeof globalThis.fetch; + private readonly requestTimeoutMs: number; + private readonly logger: Pick; + + constructor( + private readonly config: GitHubProviderIdentityResolverConfig, + dependencies: GitHubProviderIdentityResolverDependencies = {} + ) { + assertCanonicalIssuer(config.issuer, GITHUB_ISSUER); + this.fetchImpl = dependencies.fetch ?? globalThis.fetch.bind(globalThis); + this.requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS; + this.logger = dependencies.logger ?? createLogger("github-provider-identity"); + } + + async resolveIdentity(accessToken: string): Promise> { + const [user, emailEntries] = await Promise.all([ + this.fetchGitHubUser(accessToken), + this.fetchVerifiedEmails(accessToken), + ]); + const verifiedEmailEntries = emailEntries.filter((entry) => entry.verified); + const verifiedEmails = [ + ...new Set(verifiedEmailEntries.map((entry) => entry.email.toLowerCase())), + ]; + return { + provider: "github", + issuer: GITHUB_ISSUER, + subject: String(user.id), + login: user.login, + displayName: user.name ?? user.login, + ...(user.avatar_url ? { avatarUrl: user.avatar_url } : {}), + verifiedEmails, + primaryEmail: + verifiedEmailEntries.find((entry) => entry.primary)?.email.toLowerCase() ?? null, + }; + } + + private async fetchGitHubUser(accessToken: string): Promise> { + const response = await this.fetchWithTimeout(`${GITHUB_API_URL}/user`, { + headers: this.apiHeaders(accessToken), + }); + if (!response.ok) { + throw new OAuthProviderError("provider_unavailable", "GitHub user lookup was not successful"); + } + const parsed = githubUserSchema.safeParse(await this.parseJson(response, "GitHub user")); + if (!parsed.success) { + throw new OAuthProviderError("malformed_response", "GitHub returned an invalid user"); + } + return parsed.data; + } + + private async fetchVerifiedEmails( + accessToken: string + ): Promise>> { + const entries: Array> = []; + + for (let page = 1; page <= GITHUB_EMAILS_MAX_PAGES; page += 1) { + const pageUrl = new URL(`${GITHUB_API_URL}/user/emails`); + pageUrl.searchParams.set("per_page", String(GITHUB_EMAILS_PER_PAGE)); + pageUrl.searchParams.set("page", String(page)); + + const response = await this.fetchWithTimeout(pageUrl, { + headers: this.apiHeaders(accessToken), + }); + if (!response.ok) { + throw new OAuthProviderError( + "provider_unavailable", + "GitHub email lookup was not successful" + ); + } + const parsed = githubEmailPageSchema.safeParse( + await this.parseJson(response, "GitHub emails") + ); + if (!parsed.success) { + throw new OAuthProviderError("malformed_response", "GitHub returned invalid emails"); + } + entries.push(...parsed.data); + + const hasNextPage = response.headers.get("Link")?.includes('rel="next"') ?? false; + if (!hasNextPage) { + return entries; + } + } + throw new OAuthProviderError( + "malformed_response", + "GitHub email pagination exceeded its limit" + ); + } + + private apiHeaders(accessToken: string): HeadersInit { + return { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + "User-Agent": this.config.userAgent, + }; + } + + private async parseJson(response: Response, context: string): Promise { + try { + return await response.json(); + } catch { + throw new OAuthProviderError("malformed_response", `${context} response was not JSON`); + } + } + + private async fetchWithTimeout(input: RequestInfo | URL, init: RequestInit): Promise { + let lastCause: unknown; + for (let attempt = 1; attempt <= GITHUB_NETWORK_REQUEST_ATTEMPTS; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); + try { + return await this.fetchImpl(input, { ...init, signal: controller.signal }); + } catch (cause) { + lastCause = cause; + } finally { + clearTimeout(timeout); + } + } + this.logger.error("GitHub provider request failed", { + event: "auth.github_provider_request_failed", + attempts: GITHUB_NETWORK_REQUEST_ATTEMPTS, + error: lastCause instanceof Error ? lastCause : new Error(String(lastCause)), + }); + throw new OAuthProviderError("provider_unavailable", "GitHub request failed", { + cause: lastCause, + }); + } +} diff --git a/packages/control-plane/src/auth/user/providers/github-profile.test.ts b/packages/control-plane/src/auth/user/providers/github-profile.test.ts new file mode 100644 index 000000000..92242b3d1 --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/github-profile.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { GitHubSignInProfileResolver } from "./github-profile"; + +describe("GitHubSignInProfileResolver", () => { + it("resolves verified GitHub evidence before admission and profile mapping", async () => { + const resolveIdentity = vi.fn(async () => ({ + provider: "github" as const, + issuer: "https://github.com", + subject: "583231", + login: "octocat", + displayName: "The Octocat", + avatarUrl: "https://github.com/images/error/octocat_happy.gif", + verifiedEmails: ["secondary@example.com", "primary@example.com"], + primaryEmail: "primary@example.com", + })); + const requireAdmission = vi.fn(async () => ({ + reason: "github_user_allowlist" as const, + })); + const resolver = new GitHubSignInProfileResolver({ + identityResolver: { resolveIdentity }, + admissionPolicy: { requireAdmission }, + }); + const accessTokenExpiresAt = new Date("2026-07-27T00:00:00.000Z"); + const refreshTokenExpiresAt = new Date("2026-08-27T00:00:00.000Z"); + + const result = await resolver.getUserInfo({ + accessToken: "github-access-token", + accessTokenExpiresAt, + refreshToken: "github-refresh-token", + refreshTokenExpiresAt, + }); + + expect(resolveIdentity).toHaveBeenCalledWith("github-access-token"); + expect(requireAdmission).toHaveBeenCalledWith({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "583231", + login: "octocat", + displayName: "The Octocat", + avatarUrl: "https://github.com/images/error/octocat_happy.gif", + verifiedEmails: ["secondary@example.com", "primary@example.com"], + primaryEmail: "primary@example.com", + }, + credential: { + kind: "refreshable", + accessToken: "github-access-token", + accessExpiresAt: accessTokenExpiresAt.getTime(), + refreshToken: "github-refresh-token", + refreshExpiresAt: refreshTokenExpiresAt.getTime(), + }, + }); + expect(result.user).toEqual({ + id: "583231", + name: "The Octocat", + email: "primary@example.com", + image: "https://github.com/images/error/octocat_happy.gif", + emailVerified: true, + }); + }); + + it("rejects missing access-token evidence before lookup or admission", async () => { + const resolveIdentity = vi.fn(); + const requireAdmission = vi.fn(); + const resolver = new GitHubSignInProfileResolver({ + identityResolver: { resolveIdentity }, + admissionPolicy: { requireAdmission }, + }); + + await expect(resolver.getUserInfo({})).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + }); + expect(resolveIdentity).not.toHaveBeenCalled(); + expect(requireAdmission).not.toHaveBeenCalled(); + }); + + it("rejects an identity without a verified email before admission", async () => { + const requireAdmission = vi.fn(); + const resolver = new GitHubSignInProfileResolver({ + identityResolver: { + resolveIdentity: vi.fn(async () => ({ + provider: "github" as const, + issuer: "https://github.com", + subject: "583231", + login: "octocat", + verifiedEmails: [], + primaryEmail: null, + })), + }, + admissionPolicy: { requireAdmission }, + }); + + await expect( + resolver.getUserInfo({ accessToken: "github-access-token" }) + ).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + }); + expect(requireAdmission).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/auth/user/providers/github-profile.ts b/packages/control-plane/src/auth/user/providers/github-profile.ts new file mode 100644 index 000000000..fc81a3902 --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/github-profile.ts @@ -0,0 +1,89 @@ +import type { AdmissionPolicy } from "../admission-policy"; +import type { ProviderProfile, ProviderTokens } from "../provider-profile"; +import type { ProviderCredentialInput } from "../provider-credential"; +import { + OAuthProviderError, + type ProviderSignInResult, + type VerifiedProviderIdentity, +} from "./types"; + +export interface GitHubIdentityResolver { + resolveIdentity(accessToken: string): Promise>; +} + +export interface GitHubSignInProfileResolverConfig { + readonly identityResolver: GitHubIdentityResolver; + readonly admissionPolicy: Pick; +} + +export class GitHubSignInProfileResolver { + constructor(private readonly config: GitHubSignInProfileResolverConfig) {} + + readonly getUserInfo = async (tokens: ProviderTokens): Promise => { + if (!tokens.accessToken) { + throw new OAuthProviderError("malformed_response", "GitHub did not return an access token"); + } + + const identity = await this.config.identityResolver.resolveIdentity(tokens.accessToken); + const email = identity.primaryEmail ?? identity.verifiedEmails[0]; + if (!email) { + throw new OAuthProviderError("malformed_response", "GitHub did not return a verified email"); + } + + const signIn: ProviderSignInResult<"github"> = { + identity, + credential: toProviderCredential(tokens), + }; + await this.config.admissionPolicy.requireAdmission(signIn); + + return { + user: { + id: identity.subject, + name: identity.displayName ?? identity.login ?? email, + email, + ...(identity.avatarUrl ? { image: identity.avatarUrl } : {}), + emailVerified: true, + }, + data: identity, + }; + }; +} + +function toProviderCredential(tokens: ProviderTokens): ProviderCredentialInput { + const accessToken = tokens.accessToken; + if (!accessToken) { + throw new OAuthProviderError("malformed_response", "GitHub did not return an access token"); + } + if (tokens.refreshToken && !tokens.accessTokenExpiresAt) { + throw new OAuthProviderError( + "malformed_response", + "GitHub returned a refresh token without access expiry" + ); + } + if (tokens.refreshTokenExpiresAt && !tokens.refreshToken) { + throw new OAuthProviderError( + "malformed_response", + "GitHub returned refresh expiry without a refresh token" + ); + } + if (tokens.refreshToken && tokens.accessTokenExpiresAt) { + return { + kind: "refreshable", + accessToken, + accessExpiresAt: tokens.accessTokenExpiresAt.getTime(), + refreshToken: tokens.refreshToken, + refreshExpiresAt: tokens.refreshTokenExpiresAt?.getTime() ?? null, + }; + } + if (tokens.accessTokenExpiresAt) { + return { + kind: "access_only_expiring", + accessToken, + accessExpiresAt: tokens.accessTokenExpiresAt.getTime(), + }; + } + return { + kind: "access_only_nonexpiring", + accessToken, + }; +} diff --git a/packages/control-plane/src/auth/user/providers/google-profile.test.ts b/packages/control-plane/src/auth/user/providers/google-profile.test.ts new file mode 100644 index 000000000..a00f04d3a --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/google-profile.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { GoogleSignInProfileResolver } from "./google-profile"; + +describe("GoogleSignInProfileResolver", () => { + it("verifies Google claims before admission and profile mapping", async () => { + const verifyIdToken = vi.fn(async () => ({ + iss: "https://accounts.google.com", + sub: "google-subject", + email: "Person@Example.com", + email_verified: true, + name: "Person Example", + picture: "https://example.com/avatar.png", + })); + const requireAdmission = vi.fn(async () => ({ reason: "email_allowlist" as const })); + const resolver = new GoogleSignInProfileResolver( + { + clientId: "google-client-id", + admissionPolicy: { requireAdmission }, + }, + { verifyIdToken } + ); + + const result = await resolver.getUserInfo({ idToken: "signed-google-id-token" }); + + expect(verifyIdToken).toHaveBeenCalledWith({ + token: "signed-google-id-token", + audience: "google-client-id", + }); + expect(requireAdmission).toHaveBeenCalledWith({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + displayName: "Person Example", + avatarUrl: "https://example.com/avatar.png", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: null, + }); + expect(result.user).toEqual({ + id: "google-subject", + name: "Person Example", + email: "person@example.com", + image: "https://example.com/avatar.png", + emailVerified: true, + }); + }); + + it("rejects an unverifiable ID token before admission", async () => { + const requireAdmission = vi.fn(); + const resolver = new GoogleSignInProfileResolver( + { + clientId: "google-client-id", + admissionPolicy: { requireAdmission }, + }, + { verifyIdToken: vi.fn(async () => null) } + ); + + await expect(resolver.getUserInfo({ idToken: "invalid-id-token" })).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + }); + expect(requireAdmission).not.toHaveBeenCalled(); + }); + + it("rejects an unverified provider email before admission", async () => { + const requireAdmission = vi.fn(); + const resolver = new GoogleSignInProfileResolver( + { + clientId: "google-client-id", + admissionPolicy: { requireAdmission }, + }, + { + verifyIdToken: vi.fn(async () => ({ + iss: "https://accounts.google.com", + sub: "google-subject", + email: "person@example.com", + email_verified: false, + })), + } + ); + + await expect(resolver.getUserInfo({ idToken: "signed-id-token" })).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + }); + expect(requireAdmission).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/auth/user/providers/google-profile.ts b/packages/control-plane/src/auth/user/providers/google-profile.ts new file mode 100644 index 000000000..ce3a6b89f --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/google-profile.ts @@ -0,0 +1,79 @@ +import { verifyGoogleIdToken } from "better-auth/social-providers"; +import { z } from "zod"; +import type { AdmissionPolicy } from "../admission-policy"; +import type { ProviderProfile, ProviderTokens } from "../provider-profile"; +import { OAuthProviderError, type ProviderSignInResult } from "./types"; + +const GOOGLE_ISSUER = "https://accounts.google.com"; + +const googleClaimsSchema = z.object({ + iss: z.union([z.literal(GOOGLE_ISSUER), z.literal("accounts.google.com")]), + sub: z.string().min(1), + email: z.email(), + email_verified: z.literal(true), + name: z.string().min(1).optional(), + picture: z.url().optional(), +}); + +type VerifyGoogleIdToken = typeof verifyGoogleIdToken; + +export interface GoogleSignInProfileResolverConfig { + readonly clientId: string; + readonly admissionPolicy: Pick; +} + +export interface GoogleSignInProfileResolverDependencies { + readonly verifyIdToken?: VerifyGoogleIdToken; +} + +export class GoogleSignInProfileResolver { + private readonly verifyIdToken: VerifyGoogleIdToken; + + constructor( + private readonly config: GoogleSignInProfileResolverConfig, + dependencies: GoogleSignInProfileResolverDependencies = {} + ) { + this.verifyIdToken = dependencies.verifyIdToken ?? verifyGoogleIdToken; + } + + readonly getUserInfo = async (tokens: ProviderTokens): Promise => { + if (!tokens.idToken) { + throw new OAuthProviderError("malformed_response", "Google did not return an ID token"); + } + + const rawClaims = await this.verifyIdToken({ + token: tokens.idToken, + audience: this.config.clientId, + }); + const parsedClaims = googleClaimsSchema.safeParse(rawClaims); + if (!parsedClaims.success) { + throw new OAuthProviderError("malformed_response", "Google returned an invalid ID token"); + } + + const email = parsedClaims.data.email.toLowerCase(); + const signIn: ProviderSignInResult<"google"> = { + identity: { + provider: "google", + issuer: GOOGLE_ISSUER, + subject: parsedClaims.data.sub, + ...(parsedClaims.data.name ? { displayName: parsedClaims.data.name } : {}), + ...(parsedClaims.data.picture ? { avatarUrl: parsedClaims.data.picture } : {}), + verifiedEmails: [email], + primaryEmail: email, + }, + credential: null, + }; + await this.config.admissionPolicy.requireAdmission(signIn); + + return { + user: { + id: signIn.identity.subject, + name: signIn.identity.displayName ?? email, + email, + ...(signIn.identity.avatarUrl ? { image: signIn.identity.avatarUrl } : {}), + emailVerified: true, + }, + data: parsedClaims.data, + }; + }; +} diff --git a/packages/control-plane/src/auth/user/providers/types.ts b/packages/control-plane/src/auth/user/providers/types.ts new file mode 100644 index 000000000..b40dc3833 --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/types.ts @@ -0,0 +1,57 @@ +import type { SignInProvider } from "../sign-in-provider"; +import type { ProviderCredentialInput } from "../provider-credential"; + +export interface VerifiedProviderIdentity

{ + readonly provider: P; + readonly issuer: string; + readonly subject: string; + readonly login?: string; + readonly displayName?: string; + readonly avatarUrl?: string; + readonly verifiedEmails: readonly string[]; + readonly primaryEmail: string | null; +} + +interface ProviderSignInResultByProvider { + readonly github: { + readonly identity: VerifiedProviderIdentity<"github">; + readonly credential: ProviderCredentialInput; + }; + readonly google: { + readonly identity: VerifiedProviderIdentity<"google">; + readonly credential: null; + }; +} + +export type ProviderSignInResult

= ProviderSignInResultByProvider[P]; + +export type OAuthProviderFailure = + | "invalid_configuration" + | "invalid_request" + | "provider_rejected" + | "provider_unavailable" + | "malformed_response"; + +export class OAuthProviderError extends Error { + constructor( + readonly failure: OAuthProviderFailure, + message: string, + options?: ErrorOptions + ) { + super(message, options); + this.name = "OAuthProviderError"; + } +} + +export function assertCanonicalIssuer(configuredIssuer: string, expectedIssuer: string): void { + let configured: URL; + try { + configured = new URL(configuredIssuer); + } catch { + throw new OAuthProviderError("invalid_configuration", "Provider issuer is invalid"); + } + const expected = new URL(expectedIssuer); + if (configured.href !== expected.href) { + throw new OAuthProviderError("invalid_configuration", "Provider issuer is not canonical"); + } +} diff --git a/packages/control-plane/src/auth/user/runtime.test.ts b/packages/control-plane/src/auth/user/runtime.test.ts new file mode 100644 index 000000000..65f878627 --- /dev/null +++ b/packages/control-plane/src/auth/user/runtime.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parsePublicWebOrigin } from "./runtime"; + +describe("parsePublicWebOrigin", () => { + it.each([ + ["https://open-inspect.example", "https://open-inspect.example"], + ["https://open-inspect.example/", "https://open-inspect.example"], + ["http://localhost:3000", "http://localhost:3000"], + ["http://127.0.0.1:3000", "http://127.0.0.1:3000"], + ["http://[::1]:3000", "http://[::1]:3000"], + ])("accepts a browser-reachable web origin: %s", (configured, expected) => { + expect(parsePublicWebOrigin(configured)).toBe(expected); + }); + + it.each([ + undefined, + "", + "not-a-url", + "http://open-inspect.example", + "http://localhost.evil.example:3000", + "https://open-inspect.example/path", + "https://open-inspect.example?query=1", + ])("rejects an unsafe or non-origin WEB_APP_URL: %s", (configured) => { + expect(() => parsePublicWebOrigin(configured)).toThrow(); + }); +}); diff --git a/packages/control-plane/src/auth/user/runtime.ts b/packages/control-plane/src/auth/user/runtime.ts new file mode 100644 index 000000000..dcf30628b --- /dev/null +++ b/packages/control-plane/src/auth/user/runtime.ts @@ -0,0 +1,165 @@ +import { + AdmissionPolicy, + parseAdmissionAllowlist, + parseAdmissionBoolean, +} from "./admission-policy"; +import { createUserAuth } from "./better-auth"; +import { GitHubProviderIdentityResolver } from "./providers/github-identity"; +import { GitHubSignInProfileResolver } from "./providers/github-profile"; +import { GoogleSignInProfileResolver } from "./providers/google-profile"; +import { D1CanonicalUserProjection } from "../../db/canonical-user-projection"; +import type { Env } from "../../types"; + +const GITHUB_ISSUER = "https://github.com"; +const MINIMUM_SECRET_LENGTH = 32; + +export class UserAuthConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "UserAuthConfigurationError"; + } +} + +function requireConfig(value: string | undefined, name: string): string { + const normalized = value?.trim(); + if (!normalized) { + throw new UserAuthConfigurationError(`${name} is not configured`); + } + return normalized; +} + +function isLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; +} + +export function parsePublicWebOrigin(value: string | undefined): string { + const configured = requireConfig(value, "WEB_APP_URL"); + let url: URL; + try { + url = new URL(configured); + } catch { + throw new UserAuthConfigurationError("WEB_APP_URL is invalid"); + } + + const isOriginOnly = + url.username === "" && + url.password === "" && + url.pathname === "/" && + url.search === "" && + url.hash === ""; + const isSecure = url.protocol === "https:"; + const isLocalDevelopment = url.protocol === "http:" && isLoopbackHost(url.hostname); + if (!isOriginOnly || (!isSecure && !isLocalDevelopment)) { + throw new UserAuthConfigurationError( + "WEB_APP_URL must be an HTTPS origin or an HTTP loopback origin" + ); + } + return url.origin; +} + +function createAdmissionPolicy(env: Env): AdmissionPolicy { + return new AdmissionPolicy({ + allowedGitHubUsers: parseAdmissionAllowlist(env.ALLOWED_USERS), + allowedEmails: parseAdmissionAllowlist(env.ALLOWED_EMAILS), + allowedEmailDomains: parseAdmissionAllowlist(env.ALLOWED_EMAIL_DOMAINS), + allowedGitHubOrganizations: parseAdmissionAllowlist(env.ALLOWED_GITHUB_ORGS), + unsafeAllowAllUsers: parseAdmissionBoolean(env.UNSAFE_ALLOW_ALL_USERS), + }); +} + +export function createUserAuthFromEnv(env: Env, database: D1Database) { + const publicWebOrigin = parsePublicWebOrigin(env.WEB_APP_URL); + const secret = requireConfig(env.BROWSER_AUTH_SECRET, "BROWSER_AUTH_SECRET"); + if (secret.length < MINIMUM_SECRET_LENGTH) { + throw new UserAuthConfigurationError( + `BROWSER_AUTH_SECRET must be at least ${MINIMUM_SECRET_LENGTH} characters` + ); + } + + const githubClientId = requireConfig(env.GITHUB_CLIENT_ID, "GITHUB_CLIENT_ID"); + const githubClientSecret = requireConfig(env.GITHUB_CLIENT_SECRET, "GITHUB_CLIENT_SECRET"); + const admissionPolicy = createAdmissionPolicy(env); + const githubIdentityResolver = new GitHubProviderIdentityResolver({ + issuer: GITHUB_ISSUER, + userAgent: `${env.APP_NAME?.trim() || "Open-Inspect"} Control Plane`, + }); + const githubProfile = new GitHubSignInProfileResolver({ + identityResolver: githubIdentityResolver, + admissionPolicy, + }); + + const googleClientId = env.GOOGLE_CLIENT_ID?.trim(); + const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim(); + if (Boolean(googleClientId) !== Boolean(googleClientSecret)) { + throw new UserAuthConfigurationError( + "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be configured together" + ); + } + + const googleProfile = + googleClientId && googleClientSecret + ? new GoogleSignInProfileResolver({ + clientId: googleClientId, + admissionPolicy, + }) + : null; + + return createUserAuth({ + database, + publicWebOrigin, + secret, + userProjection: new D1CanonicalUserProjection(database), + github: { + clientId: githubClientId, + clientSecret: githubClientSecret, + getUserInfo: githubProfile.getUserInfo, + }, + ...(googleProfile && googleClientId && googleClientSecret + ? { + google: { + clientId: googleClientId, + clientSecret: googleClientSecret, + getUserInfo: googleProfile.getUserInfo, + }, + } + : {}), + }); +} + +type BetterAuthInstance = ReturnType; + +interface CachedUserAuth { + readonly fingerprint: string; + readonly auth: BetterAuthInstance; +} + +const userAuthByDatabase = new WeakMap(); + +function configurationFingerprint(env: Env): string { + return [ + env.WEB_APP_URL, + env.BROWSER_AUTH_SECRET, + env.GITHUB_CLIENT_ID, + env.GITHUB_CLIENT_SECRET, + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + env.ALLOWED_USERS, + env.ALLOWED_EMAILS, + env.ALLOWED_EMAIL_DOMAINS, + env.ALLOWED_GITHUB_ORGS, + env.UNSAFE_ALLOW_ALL_USERS, + ].join("\u0000"); +} + +export function getUserAuth(env: Env, database: D1Database): BetterAuthInstance { + const fingerprint = configurationFingerprint(env); + const cached = userAuthByDatabase.get(database); + if (cached?.fingerprint === fingerprint) { + return cached.auth; + } + const auth = createUserAuthFromEnv(env, database); + userAuthByDatabase.set(database, { fingerprint, auth }); + return auth; +} + +export type BetterAuthRuntime = BetterAuthInstance; diff --git a/packages/control-plane/src/auth/user/session-authenticator.test.ts b/packages/control-plane/src/auth/user/session-authenticator.test.ts new file mode 100644 index 000000000..911a28b8b --- /dev/null +++ b/packages/control-plane/src/auth/user/session-authenticator.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { authenticateSession, type SessionReader } from "./session-authenticator"; + +describe("authenticateSession", () => { + it("authenticates a browser session without enumerating provider accounts", async () => { + const sessionReader: SessionReader = { + getSession: vi.fn(async () => ({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1" }, + })), + }; + const headers = new Headers({ Cookie: "openinspect.session_token=session.signature" }); + + await expect(authenticateSession(sessionReader, headers)).resolves.toEqual({ + userId: "user-1", + authentication: { + mechanism: "browser_session", + credentialId: "session-1", + channel: { kind: "sig1", service: "web" }, + }, + }); + expect(sessionReader.getSession).toHaveBeenCalledWith({ + headers, + query: { disableRefresh: true }, + }); + }); + + it("returns null when Better Auth does not resolve a session", async () => { + const sessionReader: SessionReader = { + getSession: vi.fn(async () => null), + }; + + await expect(authenticateSession(sessionReader, new Headers())).resolves.toBeNull(); + }); + + it("rejects a session whose user does not match", async () => { + const sessionReader: SessionReader = { + getSession: vi.fn(async () => ({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "different-user" }, + })), + }; + + await expect(authenticateSession(sessionReader, new Headers())).rejects.toThrow( + "Better Auth returned a cross-user session" + ); + }); +}); diff --git a/packages/control-plane/src/auth/user/session-authenticator.ts b/packages/control-plane/src/auth/user/session-authenticator.ts new file mode 100644 index 000000000..8ff6b8314 --- /dev/null +++ b/packages/control-plane/src/auth/user/session-authenticator.ts @@ -0,0 +1,79 @@ +/** + * Adapts a Better Auth session into the control plane's authentication model. + * + * `authenticate()` calls this module only after validating the `service:web` + * sig1 channel. This adapter delegates opaque session-cookie verification and + * session lookup to Better Auth, verifies that the returned session and user + * agree, and emits provider-independent authentication evidence for the + * control-plane router. It does not perform authorization or resolve the + * provider account originally used to sign in. + * + * Protected resource requests use a non-refreshing session read so validation + * does not extend session expiry or write D1 as a side effect. Browser-facing + * Better Auth endpoints remain responsible for session refresh. + */ + +import { z } from "zod"; +import type { AuthenticationContext } from "../principal"; + +const sessionSchema = z.object({ + session: z.object({ + id: z.string().min(1), + userId: z.string().min(1), + }), + user: z.object({ + id: z.string().min(1), + }), +}); + +export interface AuthenticatedUserSession { + readonly userId: string; + readonly authentication: AuthenticationContext; +} + +export interface SessionReader { + getSession(input: { + readonly headers: Headers; + readonly query: { readonly disableRefresh: true }; + }): Promise; +} + +export class SessionIntegrityError extends Error { + constructor(message: string) { + super(message); + this.name = "SessionIntegrityError"; + } +} + +export async function authenticateSession( + sessionReader: SessionReader, + headers: Headers +): Promise { + // Resource authentication is a read-only hot path, not a session-lifecycle endpoint. + const candidate = await sessionReader.getSession({ + headers, + query: { disableRefresh: true }, + }); + if (candidate === null) return null; + + const parsedSession = sessionSchema.safeParse(candidate); + if (!parsedSession.success) { + throw new SessionIntegrityError("Better Auth returned a malformed session"); + } + const { session, user } = parsedSession.data; + if (session.userId !== user.id) { + throw new SessionIntegrityError("Better Auth returned a cross-user session"); + } + + return { + userId: user.id, + authentication: { + mechanism: "browser_session", + credentialId: session.id, + channel: { + kind: "sig1", + service: "web", + }, + }, + }; +} diff --git a/packages/control-plane/src/auth/sign-in-provider.test.ts b/packages/control-plane/src/auth/user/sign-in-provider.test.ts similarity index 100% rename from packages/control-plane/src/auth/sign-in-provider.test.ts rename to packages/control-plane/src/auth/user/sign-in-provider.test.ts diff --git a/packages/control-plane/src/auth/sign-in-provider.ts b/packages/control-plane/src/auth/user/sign-in-provider.ts similarity index 77% rename from packages/control-plane/src/auth/sign-in-provider.ts rename to packages/control-plane/src/auth/user/sign-in-provider.ts index c1263b783..31c3bbb2a 100644 --- a/packages/control-plane/src/auth/sign-in-provider.ts +++ b/packages/control-plane/src/auth/user/sign-in-provider.ts @@ -1,4 +1,4 @@ -/** Browser sign-in providers with executable authentication adapters. */ +/** Sign-in providers with executable authentication adapters. */ export const SIGN_IN_PROVIDERS = ["github", "google"] as const; export type SignInProvider = (typeof SIGN_IN_PROVIDERS)[number]; diff --git a/packages/control-plane/src/auth/web-session-tokens.test.ts b/packages/control-plane/src/auth/web-session-tokens.test.ts deleted file mode 100644 index 76a695471..000000000 --- a/packages/control-plane/src/auth/web-session-tokens.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import type { ApiTokenRow, NewApiToken, WebSessionTokenStore } from "../db/api-tokens"; -import { - ACCESS_TOKEN_PREFIX, - REFRESH_REUSE_GRACE_MS, - REFRESH_TOKEN_PREFIX, - WEB_SESSION_FAMILY_TTL_MS, - WEB_SESSION_REFRESH_TTL_MS, - WEB_SESSION_TOKEN_TTL_MS, - WebSessionTokenService, -} from "./web-session-tokens"; - -/** In-memory ApiTokenStore double with the same semantics as the D1 store. */ -class FakeApiTokenStore implements WebSessionTokenStore { - rows = new Map(); - private nextId = 0; - - async createPair(tokens: [NewApiToken, NewApiToken]): Promise<[string, string]> { - const ids = tokens.map((token) => { - const id = `token-${this.nextId++}`; - this.rows.set(id, { - id, - tokenHash: token.tokenHash, - kind: token.kind, - userId: token.userId, - provider: token.provider, - providerUserId: token.providerUserId, - familyId: token.familyId, - rotatedTo: null, - createdAt: Date.now(), - expiresAt: token.expiresAt, - familyExpiresAt: token.familyExpiresAt, - revokedAt: null, - lastUsedAt: null, - }); - return id; - }); - return ids as [string, string]; - } - - async getByHash(tokenHash: string): Promise { - for (const row of this.rows.values()) { - if (row.tokenHash === tokenHash) return { ...row }; - } - return null; - } - - async getById(id: string): Promise { - const row = this.rows.get(id); - return row ? { ...row } : null; - } - - async consumeRefreshToken(id: string, successorId: string): Promise { - const row = this.rows.get(id); - if (!row || row.rotatedTo !== null || row.revokedAt !== null) return false; - row.rotatedTo = successorId; - return true; - } - - async revokeFamily(familyId: string): Promise { - for (const row of this.rows.values()) { - if (row.familyId === familyId && row.revokedAt === null) { - row.revokedAt = Date.now(); - } - } - } - - async revokeToken(id: string): Promise { - const row = this.rows.get(id); - if (row && row.revokedAt === null) row.revokedAt = Date.now(); - } -} - -const SUBJECT = { provider: "github" as const, providerUserId: "424242" }; - -function createService(): { service: WebSessionTokenService; store: FakeApiTokenStore } { - const store = new FakeApiTokenStore(); - return { service: new WebSessionTokenService(store), store }; -} - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("mintPair", () => { - it("mints prefixed opaque tokens with hash-at-rest storage", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - - expect(pair.accessToken).toMatch(new RegExp(`^${ACCESS_TOKEN_PREFIX}[A-Za-z0-9_-]{43}$`)); - expect(pair.refreshToken).toMatch(new RegExp(`^${REFRESH_TOKEN_PREFIX}[A-Za-z0-9_-]{43}$`)); - - const rows = [...store.rows.values()]; - expect(rows).toHaveLength(2); - for (const row of rows) { - expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/); - expect(row.tokenHash).not.toContain(pair.accessToken); - expect(row.userId).toBe("user-1"); - expect(row.provider).toBe("github"); - expect(row.providerUserId).toBe("424242"); - } - const [access, refresh] = rows; - expect(access.kind).toBe("web_session"); - expect(refresh.kind).toBe("web_session_refresh"); - expect(access.familyId).toBe(refresh.familyId); - }); - - it("applies the access, refresh, and family TTLs", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - expect(pair.accessTokenExpiresAtEpochMs).toBe(1_000_000 + WEB_SESSION_TOKEN_TTL_MS); - expect(pair.refreshTokenExpiresAtEpochMs).toBe(1_000_000 + WEB_SESSION_REFRESH_TTL_MS); - }); -}); - -describe("verifyAccessToken", () => { - it("verifies a freshly minted token", async () => { - const { service } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const result = await service.verifyAccessToken(pair.accessToken); - expect(result).toMatchObject({ - ok: true, - userId: "user-1", - provider: "github", - providerUserId: "424242", - }); - }); - - it("rejects unknown tokens and refresh tokens presented as access tokens", async () => { - const { service } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - expect(await service.verifyAccessToken("oi_at_nonexistent")).toEqual({ - ok: false, - failure: "unknown", - }); - expect(await service.verifyAccessToken(pair.refreshToken)).toEqual({ - ok: false, - failure: "unknown", - }); - }); - - it("rejects expired tokens", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - vi.setSystemTime(1_000_000 + WEB_SESSION_TOKEN_TTL_MS + 1); - expect(await service.verifyAccessToken(pair.accessToken)).toEqual({ - ok: false, - failure: "expired", - }); - }); - - it("rejects revoked tokens", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const accessRow = [...store.rows.values()].find((r) => r.kind === "web_session")!; - await store.revokeToken(accessRow.id); - expect(await service.verifyAccessToken(pair.accessToken)).toEqual({ - ok: false, - failure: "revoked", - }); - }); - - it("fails closed on rows missing the minted subject/family shape", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const accessRow = [...store.rows.values()].find((r) => r.kind === "web_session")!; - accessRow.familyId = null; - expect(await service.verifyAccessToken(pair.accessToken)).toEqual({ - ok: false, - failure: "unknown", - }); - }); -}); - -describe("redeemRefreshToken", () => { - it("rotates: mints a new pair in the same family and consumes the old token", async () => { - const { service, store } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - const result = await service.redeemRefreshToken(first.refreshToken); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.pair.accessToken).not.toBe(first.accessToken); - expect((await service.verifyAccessToken(result.pair.accessToken)).ok).toBe(true); - - const families = new Set([...store.rows.values()].map((r) => r.familyId)); - expect(families.size).toBe(1); - const oldRefresh = [...store.rows.values()].find( - (r) => r.kind === "web_session_refresh" && r.rotatedTo !== null - ); - expect(oldRefresh).toBeDefined(); - }); - - it("caps the rotated leaf's expiry at the family cap", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - // Advance close to the family cap: a fresh 30d leaf would overshoot it. - vi.setSystemTime(1_000_000 + WEB_SESSION_FAMILY_TTL_MS - 1000); - const result = await service.redeemRefreshToken(first.refreshToken); - expect(result.ok).toBe(false); - if (result.ok) return; - // The original leaf itself expired (30d < 90d elapsed) — invalid, not reuse. - expect(result.failure).toBe("invalid_refresh_token"); - }); - - it("caps a mid-family rotation at family_expires_at", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const DAY_MS = 24 * 60 * 60 * 1000; - const { service } = createService(); - let latest = await service.mintPair("user-1", SUBJECT); - // Rotate at 29d and 58d (each leaf still valid), then at 61d, where a - // fresh 30d leaf would outlive the 90d family cap and must be truncated. - for (const day of [29, 58, 61]) { - vi.setSystemTime(1_000_000 + day * DAY_MS); - const result = await service.redeemRefreshToken(latest.refreshToken); - expect(result.ok, `rotation at day ${day}`).toBe(true); - if (!result.ok) return; - latest = result.pair; - } - expect(latest.refreshTokenExpiresAtEpochMs).toBe(1_000_000 + WEB_SESSION_FAMILY_TTL_MS); - }); - - it("clamps the rotated access token to the family cap, not just the refresh leaf", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const DAY_MS = 24 * 60 * 60 * 1000; - const { service } = createService(); - let latest = await service.mintPair("user-1", SUBJECT); - const familyCap = 1_000_000 + WEB_SESSION_FAMILY_TTL_MS; - // Keep the family alive up to the last few hours, then rotate 4h before the - // 90d cap — inside the 8h access window, so a fresh access leaf would - // otherwise outlive the family's absolute deadline by ~4h. - const rotations = [ - 1_000_000 + 29 * DAY_MS, - 1_000_000 + 58 * DAY_MS, - 1_000_000 + 87 * DAY_MS, - familyCap - 4 * 60 * 60 * 1000, - ]; - for (const at of rotations) { - vi.setSystemTime(at); - const result = await service.redeemRefreshToken(latest.refreshToken); - expect(result.ok, `rotation at ${at}`).toBe(true); - if (!result.ok) return; - latest = result.pair; - } - // Both leaves stop at the cap — the access token never survives the family. - expect(latest.accessTokenExpiresAtEpochMs).toBe(familyCap); - expect(latest.refreshTokenExpiresAtEpochMs).toBe(familyCap); - // And that cap is sooner than a naive now+8h would have landed. - expect(latest.accessTokenExpiresAtEpochMs).toBeLessThan( - familyCap - 4 * 60 * 60 * 1000 + WEB_SESSION_TOKEN_TTL_MS - ); - }); - - it("tolerates replay within the grace window without revoking the family", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - const rotated = await service.redeemRefreshToken(first.refreshToken); - expect(rotated.ok).toBe(true); - if (!rotated.ok) return; - - // A concurrent renewal that lost the race replays the consumed token - // almost immediately: superseded (NOT a dead grant), rotated pair live. - vi.setSystemTime(1_000_000 + 5_000); - const replay = await service.redeemRefreshToken(first.refreshToken); - expect(replay).toEqual({ - ok: false, - failure: "refresh_superseded", - familyId: expect.any(String), - }); - expect((await service.verifyAccessToken(rotated.pair.accessToken)).ok).toBe(true); - }); - - it("detects reuse after the grace window and revokes the whole family", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - const rotated = await service.redeemRefreshToken(first.refreshToken); - expect(rotated.ok).toBe(true); - if (!rotated.ok) return; - - vi.setSystemTime(1_000_000 + REFRESH_REUSE_GRACE_MS + 1000); - const replay = await service.redeemRefreshToken(first.refreshToken); - expect(replay).toEqual({ - ok: false, - failure: "refresh_reuse_detected", - familyId: expect.any(String), - }); - - // Family revocation kills the live pair minted by the legitimate rotation. - expect(await service.verifyAccessToken(rotated.pair.accessToken)).toEqual({ - ok: false, - failure: "revoked", - }); - expect(await service.redeemRefreshToken(rotated.pair.refreshToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: expect.any(String), - }); - }); - - it("treats losing the consume race as benign and revokes only the orphaned pair", async () => { - const { service, store } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - const originalConsume = store.consumeRefreshToken.bind(store); - store.consumeRefreshToken = async () => false; - const result = await service.redeemRefreshToken(first.refreshToken); - store.consumeRefreshToken = originalConsume; - expect(result).toEqual({ - ok: false, - failure: "refresh_superseded", - familyId: expect.any(String), - }); - - // Only the loser's freshly minted pair is revoked; the original refresh - // token row (the presumed race winner's input) is untouched. - const revoked = [...store.rows.values()].filter((r) => r.revokedAt !== null); - expect(revoked).toHaveLength(2); - const firstRefreshHashRow = [...store.rows.values()].find( - (r) => r.kind === "web_session_refresh" && r.revokedAt === null && r.rotatedTo === null - ); - expect(firstRefreshHashRow).toBeDefined(); - }); - - it("rejects unknown, revoked, and access tokens", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - expect(await service.redeemRefreshToken("oi_rt_nonexistent")).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: null, - }); - expect(await service.redeemRefreshToken(pair.accessToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: null, - }); - const refreshRow = [...store.rows.values()].find((r) => r.kind === "web_session_refresh")!; - await store.revokeToken(refreshRow.id); - expect(await service.redeemRefreshToken(pair.refreshToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: expect.any(String), - }); - }); - - it("fails closed on refresh rows missing the minted subject/family shape", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const refreshRow = [...store.rows.values()].find((r) => r.kind === "web_session_refresh")!; - refreshRow.provider = "not-a-web-provider"; - expect(await service.redeemRefreshToken(pair.refreshToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: null, - }); - }); - - it("fails closed on refresh rows missing the family cap", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const refreshRow = [...store.rows.values()].find((r) => r.kind === "web_session_refresh")!; - refreshRow.familyExpiresAt = null; - expect(await service.redeemRefreshToken(pair.refreshToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: expect.any(String), - }); - }); -}); diff --git a/packages/control-plane/src/auth/web-session-tokens.ts b/packages/control-plane/src/auth/web-session-tokens.ts deleted file mode 100644 index 95283c33b..000000000 --- a/packages/control-plane/src/auth/web-session-tokens.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Web session tokens (`oi_at_`) and their rotating refresh tokens (`oi_rt_`). - * - * Minted only by the provider-verified exchange; opaque, - * hash-at-rest, individually revocable. Renewal is a refresh grant with - * rotation — redeeming a refresh token mints a new pair and consumes the old - * one, and reuse of a consumed token revokes its whole family. - * - * Reuse within REFRESH_REUSE_GRACE_MS of the original rotation is treated as - * a benign concurrent renewal (NextAuth's jwt callback runs in contexts that - * cannot all persist the rotated cookie), rejected without family revocation. - * Reuse after the grace window is the attack signal and revokes the family. - */ - -import { generateId, hashToken } from "./crypto"; -import { base64UrlEncode } from "./encoding"; -import { isSignInProvider, type SignInProvider } from "./sign-in-provider"; -import type { ApiTokenRow, WebSessionTokenStore } from "../db/api-tokens"; - -export const WEB_SESSION_TOKEN_TTL_MS = 8 * 60 * 60 * 1000; -export const WEB_SESSION_REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; -export const WEB_SESSION_FAMILY_TTL_MS = 90 * 24 * 60 * 60 * 1000; -export const REFRESH_REUSE_GRACE_MS = 60 * 1000; - -export const ACCESS_TOKEN_PREFIX = "oi_at_"; -export const REFRESH_TOKEN_PREFIX = "oi_rt_"; - -/** The provider-verified subject a token pair was minted for. */ -export interface TokenSubject { - provider: SignInProvider; - providerUserId: string; -} - -export interface WebSessionTokenPair { - accessToken: string; - accessTokenExpiresAtEpochMs: number; - refreshToken: string; - refreshTokenExpiresAtEpochMs: number; -} - -export type AccessTokenVerification = - | { - ok: true; - tokenId: string; - userId: string; - provider: SignInProvider; - providerUserId: string; - } - | { ok: false; failure: "unknown" | "expired" | "revoked" }; - -export type RefreshRedemption = - | { ok: true; pair: WebSessionTokenPair; userId: string; familyId: string } - | { - ok: false; - /** - * `refresh_superseded`: a benign concurrent renewal already rotated - * this token (grace-window replay or a lost consume race) — the - * winner's pair is live and the caller must NOT treat the grant as - * dead. `invalid_refresh_token`: the grant is genuinely dead (unknown, - * revoked, expired, or family-expired). `refresh_reuse_detected`: - * replay outside the grace window — the theft signal; the family has - * been revoked. The distinction is made HERE, where the row state is - * known — callers must never infer it from access-token freshness. - */ - failure: "invalid_refresh_token" | "refresh_superseded" | "refresh_reuse_detected"; - /** The rotation family when the presented token resolved to a row. */ - familyId: string | null; - }; - -/** 32 random bytes as unpadded base64url — the opaque token body. */ -function randomTokenBody(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return base64UrlEncode(bytes); -} - -/** - * An api_tokens row as this service mints it: subject and family columns are - * always populated (they are nullable in the schema only for future P2 token - * kinds). - */ -interface WebSessionRow extends ApiTokenRow { - provider: SignInProvider; - providerUserId: string; - familyId: string; -} - -/** - * Narrow a raw row to the shape this service mints — fail closed: a row - * missing the verified subject or its rotation family was not minted by this - * service in this shape, so the presented token is not a valid web session - * token. - */ -function isWebSessionRow(row: ApiTokenRow): row is WebSessionRow { - return isSignInProvider(row.provider) && row.providerUserId !== null && row.familyId !== null; -} - -export class WebSessionTokenService { - constructor(private readonly store: WebSessionTokenStore) {} - - /** Mint a fresh pair in a new rotation family (exchange path). */ - async mintPair(userId: string, subject: TokenSubject): Promise { - const familyId = generateId(); - const familyExpiresAt = Date.now() + WEB_SESSION_FAMILY_TTL_MS; - const minted = await this.mintPairInFamily(userId, subject, familyId, familyExpiresAt); - return minted.pair; - } - - private async mintPairInFamily( - userId: string, - subject: TokenSubject, - familyId: string, - familyExpiresAt: number - ): Promise<{ pair: WebSessionTokenPair; accessTokenId: string; refreshTokenId: string }> { - const now = Date.now(); - const accessToken = `${ACCESS_TOKEN_PREFIX}${randomTokenBody()}`; - const refreshToken = `${REFRESH_TOKEN_PREFIX}${randomTokenBody()}`; - // Both leaves are clamped to the family cap: the family's absolute lifetime - // is the ceiling for everything it mints, so a rotation near the deadline - // must not hand out an access token that outlives the family it belongs to. - const accessTokenExpiresAtEpochMs = Math.min(now + WEB_SESSION_TOKEN_TTL_MS, familyExpiresAt); - const refreshTokenExpiresAtEpochMs = Math.min( - now + WEB_SESSION_REFRESH_TTL_MS, - familyExpiresAt - ); - - const [accessHash, refreshHash] = await Promise.all([ - hashToken(accessToken), - hashToken(refreshToken), - ]); - const [accessTokenId, refreshTokenId] = await this.store.createPair([ - { - tokenHash: accessHash, - kind: "web_session", - userId, - provider: subject.provider, - providerUserId: subject.providerUserId, - familyId, - expiresAt: accessTokenExpiresAtEpochMs, - familyExpiresAt: null, - }, - { - tokenHash: refreshHash, - kind: "web_session_refresh", - userId, - provider: subject.provider, - providerUserId: subject.providerUserId, - familyId, - expiresAt: refreshTokenExpiresAtEpochMs, - familyExpiresAt, - }, - ]); - - return { - pair: { - accessToken, - accessTokenExpiresAtEpochMs, - refreshToken, - refreshTokenExpiresAtEpochMs, - }, - accessTokenId, - refreshTokenId, - }; - } - - async verifyAccessToken(token: string): Promise { - const row = await this.store.getByHash(await hashToken(token)); - if (!row || row.kind !== "web_session") { - return { ok: false, failure: "unknown" }; - } - if (!isWebSessionRow(row)) { - return { ok: false, failure: "unknown" }; - } - if (row.revokedAt !== null) { - return { ok: false, failure: "revoked" }; - } - if (row.expiresAt <= Date.now()) { - return { ok: false, failure: "expired" }; - } - return { - ok: true, - tokenId: row.id, - userId: row.userId, - provider: row.provider, - providerUserId: row.providerUserId, - }; - } - - /** - * Redeem a refresh token for a new pair, consuming it. Reuse of a consumed - * token — or losing the consume race — revokes the whole family. - */ - async redeemRefreshToken(token: string): Promise { - const row = await this.store.getByHash(await hashToken(token)); - if (!row || row.kind !== "web_session_refresh") { - return { ok: false, failure: "invalid_refresh_token", familyId: null }; - } - if (!isWebSessionRow(row)) { - return { ok: false, failure: "invalid_refresh_token", familyId: null }; - } - // Ordering is load-bearing: the replay (rotatedTo) check runs BEFORE the - // revoked/expired checks so that reuse of a consumed-and-since-expired - // token still counts as the attack signal and revokes the family. - if (row.rotatedTo !== null) { - // Replay of an already-consumed token. Within the grace window this is - // a benign concurrent renewal — reject without a new pair, but leave - // the family alive. Beyond it, assume the family is compromised. - const successor = await this.store.getById(row.rotatedTo); - if (successor !== null && Date.now() - successor.createdAt <= REFRESH_REUSE_GRACE_MS) { - return { ok: false, failure: "refresh_superseded", familyId: row.familyId }; - } - await this.store.revokeFamily(row.familyId); - return { ok: false, failure: "refresh_reuse_detected", familyId: row.familyId }; - } - const now = Date.now(); - // A null familyExpiresAt is rejected fail-closed: this service always - // stamps the family cap on refresh rows it mints, so a row without one - // was not minted here and must not seed a fresh rotation family. - if ( - row.revokedAt !== null || - row.expiresAt <= now || - row.familyExpiresAt === null || - row.familyExpiresAt <= now - ) { - return { ok: false, failure: "invalid_refresh_token", familyId: row.familyId }; - } - - const minted = await this.mintPairInFamily( - row.userId, - { provider: row.provider, providerUserId: row.providerUserId }, - row.familyId, - row.familyExpiresAt - ); - - const consumed = await this.store.consumeRefreshToken(row.id, minted.refreshTokenId); - if (!consumed) { - // Lost a concurrent redeem race — by definition within the grace - // window. Revoke only the orphaned pair this call minted; the race - // winner's pair stays live. - await Promise.all([ - this.store.revokeToken(minted.accessTokenId), - this.store.revokeToken(minted.refreshTokenId), - ]); - return { ok: false, failure: "refresh_superseded", familyId: row.familyId }; - } - - return { ok: true, pair: minted.pair, userId: row.userId, familyId: row.familyId }; - } -} diff --git a/packages/control-plane/src/db/api-tokens.test.ts b/packages/control-plane/src/db/api-tokens.test.ts deleted file mode 100644 index c2bad6b42..000000000 --- a/packages/control-plane/src/db/api-tokens.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ApiTokenStore, EXPIRED_TOKEN_RETENTION_MS } from "./api-tokens"; -import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database"; - -class RecordingStatement implements SqlStatement { - boundValues: unknown[] = []; - - constructor( - readonly query: string, - private readonly changes: number - ) {} - - bind(...values: unknown[]): SqlStatement { - this.boundValues = values; - return this; - } - - async first>(): Promise { - return null; - } - - async run>(): Promise> { - return { results: [], meta: { changes: this.changes } }; - } - - async all>(): Promise> { - return { results: [], meta: { changes: 0 } }; - } -} - -class RecordingDatabase implements SqlDatabase { - statements: RecordingStatement[] = []; - - constructor(private readonly changes: number) {} - - prepare(query: string): SqlStatement { - const statement = new RecordingStatement(query, this.changes); - this.statements.push(statement); - return statement; - } - - async batch(statements: SqlStatement[]): Promise[]> { - return Promise.all(statements.map((statement) => statement.run())); - } -} - -describe("deleteExpired", () => { - it("sweeps access rows by expires_at and family rows by family_expires_at", async () => { - const db = new RecordingDatabase(3); - const store = new ApiTokenStore(db); - const now = 1_750_000_000_000; - - const deleted = await store.deleteExpired(now); - - expect(deleted).toBe(6); - // Bare-column predicates on purpose: anything fancier skips the plain - // indexes (migrations 0044/0045). - expect(db.statements.map((statement) => statement.query)).toEqual([ - "DELETE FROM api_tokens WHERE family_expires_at IS NULL AND expires_at <= ?", - "DELETE FROM api_tokens WHERE family_expires_at <= ?", - ]); - for (const statement of db.statements) { - expect(statement.boundValues).toEqual([now - EXPIRED_TOKEN_RETENTION_MS]); - } - }); - - it("returns 0 when nothing is past the retention window", async () => { - const store = new ApiTokenStore(new RecordingDatabase(0)); - expect(await store.deleteExpired(Date.now())).toBe(0); - }); -}); diff --git a/packages/control-plane/src/db/api-tokens.ts b/packages/control-plane/src/db/api-tokens.ts deleted file mode 100644 index a102aea7c..000000000 --- a/packages/control-plane/src/db/api-tokens.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Store for CP-issued opaque credentials (`api_tokens`): web session tokens - * and their rotating refresh tokens. Rows hold SHA-256 hashes — plaintext - * tokens never reach storage or logs. - */ - -import { generateId } from "../auth/crypto"; -import type { SqlDatabase } from "./sql-database"; - -export type ApiTokenKind = "web_session" | "web_session_refresh"; - -export interface ApiTokenRow { - id: string; - tokenHash: string; - kind: ApiTokenKind; - userId: string; - provider: string | null; - providerUserId: string | null; - familyId: string | null; - rotatedTo: string | null; - createdAt: number; - expiresAt: number; - familyExpiresAt: number | null; - revokedAt: number | null; - lastUsedAt: number | null; -} - -export interface NewApiToken { - tokenHash: string; - kind: ApiTokenKind; - userId: string; - provider: string; - providerUserId: string; - familyId: string; - expiresAt: number; - familyExpiresAt: number | null; -} - -/** - * The store surface WebSessionTokenService consumes (engine-neutral, like - * SqlDatabase). ApiTokenStore is the D1 implementation; test doubles declare - * this interface so their conformance is compiler-checked. - */ -export interface WebSessionTokenStore { - createPair(tokens: [NewApiToken, NewApiToken]): Promise<[string, string]>; - getByHash(tokenHash: string): Promise; - getById(id: string): Promise; - consumeRefreshToken(id: string, successorId: string): Promise; - revokeFamily(familyId: string): Promise; - revokeToken(id: string): Promise; -} - -/** - * How long past a row's retention anchor — `expires_at` for access tokens, - * `family_expires_at` for family-scoped refresh rows — the sweep waits before - * deleting it. Generous compared to REFRESH_REUSE_GRACE_MS: the grace check - * resolves a rotated token's successor by id, so both rows must outlive the - * window — a day past the anchor, nothing can still legitimately reference - * the row. - */ -export const EXPIRED_TOKEN_RETENTION_MS = 24 * 60 * 60 * 1000; - -interface ApiTokenDbRow { - id: string; - token_hash: string; - kind: ApiTokenKind; - user_id: string; - provider: string | null; - provider_user_id: string | null; - family_id: string | null; - rotated_to: string | null; - created_at: number; - expires_at: number; - family_expires_at: number | null; - revoked_at: number | null; - last_used_at: number | null; -} - -function toApiTokenRow(row: ApiTokenDbRow): ApiTokenRow { - return { - id: row.id, - tokenHash: row.token_hash, - kind: row.kind, - userId: row.user_id, - provider: row.provider, - providerUserId: row.provider_user_id, - familyId: row.family_id, - rotatedTo: row.rotated_to, - createdAt: row.created_at, - expiresAt: row.expires_at, - familyExpiresAt: row.family_expires_at, - revokedAt: row.revoked_at, - lastUsedAt: row.last_used_at, - }; -} - -const INSERT_TOKEN_SQL = `INSERT INTO api_tokens - (id, token_hash, kind, user_id, provider, provider_user_id, family_id, created_at, expires_at, family_expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; - -export class ApiTokenStore implements WebSessionTokenStore { - constructor(private readonly db: SqlDatabase) {} - - /** - * Insert an access/refresh token pair atomically. Returns the generated row - * ids in input order. - */ - async createPair(tokens: [NewApiToken, NewApiToken]): Promise<[string, string]> { - const now = Date.now(); - const ids: [string, string] = [generateId(), generateId()]; - await this.db.batch( - tokens.map((token, i) => - this.db - .prepare(INSERT_TOKEN_SQL) - .bind( - ids[i], - token.tokenHash, - token.kind, - token.userId, - token.provider, - token.providerUserId, - token.familyId, - now, - token.expiresAt, - token.familyExpiresAt - ) - ) - ); - return ids; - } - - async getByHash(tokenHash: string): Promise { - const row = await this.db - .prepare("SELECT * FROM api_tokens WHERE token_hash = ?") - .bind(tokenHash) - .first(); - return row ? toApiTokenRow(row) : null; - } - - async getById(id: string): Promise { - const row = await this.db - .prepare("SELECT * FROM api_tokens WHERE id = ?") - .bind(id) - .first(); - return row ? toApiTokenRow(row) : null; - } - - /** - * Mark a refresh token consumed by its successor. Compare-and-set: returns - * false when the token was already consumed or revoked (a concurrent redeem - * or a replay), in which case the caller must treat the redeem as reuse. - */ - async consumeRefreshToken(id: string, successorId: string): Promise { - const result = await this.db - .prepare( - "UPDATE api_tokens SET rotated_to = ? WHERE id = ? AND rotated_to IS NULL AND revoked_at IS NULL" - ) - .bind(successorId, id) - .run(); - return (result.meta?.changes ?? 0) > 0; - } - - /** Revoke every token in a rotation family (refresh-reuse response). */ - async revokeFamily(familyId: string): Promise { - await this.db - .prepare("UPDATE api_tokens SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL") - .bind(Date.now(), familyId) - .run(); - } - - async revokeToken(id: string): Promise { - await this.db - .prepare("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL") - .bind(Date.now(), id) - .run(); - } - - /** Best-effort usage stamp; callers run it via waitUntil, never awaited inline. */ - async touchLastUsed(id: string): Promise { - await this.db - .prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?") - .bind(Date.now(), id) - .run(); - } - - /** - * Retention sweep. Rows without family scope (access tokens) go - * EXPIRED_TOKEN_RETENTION_MS past their own expiry. Family-scoped refresh - * rows are kept until the same margin past `family_expires_at`: a consumed - * ancestor must stay resolvable for the family's whole lifetime so a late - * replay still reads as reuse (revoking the family) rather than as an - * unknown token. Bare-column comparisons on purpose — anything fancier - * skips the plain indexes (migrations 0044/0045; see the 0024 lesson). - * NULL family_expires_at never satisfies `<=`, so the second delete only - * touches family-scoped rows. Returns the number of rows deleted. - */ - async deleteExpired(now: number): Promise { - const cutoff = now - EXPIRED_TOKEN_RETENTION_MS; - const results = await this.db.batch([ - this.db - .prepare("DELETE FROM api_tokens WHERE family_expires_at IS NULL AND expires_at <= ?") - .bind(cutoff), - this.db.prepare("DELETE FROM api_tokens WHERE family_expires_at <= ?").bind(cutoff), - ]); - return results.reduce((sum, result) => sum + (result.meta?.changes ?? 0), 0); - } -} diff --git a/packages/control-plane/src/db/browser-auth-legacy-migration.test.ts b/packages/control-plane/src/db/browser-auth-legacy-migration.test.ts new file mode 100644 index 000000000..9f515cf7e --- /dev/null +++ b/packages/control-plane/src/db/browser-auth-legacy-migration.test.ts @@ -0,0 +1,179 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const MIGRATIONS_DIRECTORY = fileURLToPath( + new URL("../../../../terraform/d1/migrations/", import.meta.url) +); +const BACKFILL_MIGRATION = "0049_backfill_better_auth_accounts.sql"; + +function applyMigrationsBeforeBackfill(db: DatabaseSync): void { + const migrationFiles = readdirSync(MIGRATIONS_DIRECTORY) + .filter((file) => /^\d{4}_.+\.sql$/.test(file) && file < BACKFILL_MIGRATION) + .sort(); + + for (const migrationFile of migrationFiles) { + db.exec(readFileSync(`${MIGRATIONS_DIRECTORY}/${migrationFile}`, "utf8")); + } +} + +describe("Better Auth legacy account backfill migration", () => { + it("preserves the canonical user for an existing immutable provider identity", () => { + const db = new DatabaseSync(":memory:"); + try { + db.exec("PRAGMA foreign_keys = ON"); + applyMigrationsBeforeBackfill(db); + db.exec(` + INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES ( + '11111111111111111111111111111111', + 'Legacy User', + 'legacy@example.com', + 'https://avatars.example/legacy', + 1785000000000, + 1785000001000 + ); + + INSERT INTO user_identities ( + id, user_id, provider, provider_user_id, provider_login, + provider_email, created_at, provider_issuer + ) VALUES ( + '22222222222222222222222222222222', + '11111111111111111111111111111111', + 'github', + '583231', + 'legacy-user', + 'legacy@example.com', + 1785000000000, + 'https://github.com' + ); + + -- Better Auth's non-atomic D1 fallback inserted this row before the + -- canonical-user projection rejected its duplicate email. + INSERT INTO auth_users ( + id, name, email, emailVerified, image, createdAt, updatedAt + ) VALUES ( + '33333333333333333333333333333333', + 'Legacy User', + 'legacy@example.com', + 1, + 'https://avatars.example/legacy', + '2026-07-26T21:47:56.000Z', + '2026-07-26T21:47:56.000Z' + ); + + -- Better Auth creates the provider account in the same logical + -- transaction as the user. D1's non-atomic transaction fallback can + -- retain both rows when the post-create canonical projection fails. + INSERT INTO auth_accounts ( + id, accountId, providerId, userId, createdAt, updatedAt + ) VALUES ( + 'partial-account', + '583231', + 'github', + '33333333333333333333333333333333', + '2026-07-26T21:47:56.000Z', + '2026-07-26T21:47:56.000Z' + ); + + INSERT INTO auth_sessions ( + id, expiresAt, token, createdAt, updatedAt, userId + ) VALUES ( + 'partial-session', + '2026-08-02T21:47:56.000Z', + 'partial-session-token', + '2026-07-26T21:47:56.000Z', + '2026-07-26T21:47:56.000Z', + '33333333333333333333333333333333' + ); + `); + + const migrationSql = readFileSync(`${MIGRATIONS_DIRECTORY}/${BACKFILL_MIGRATION}`, "utf8"); + db.exec(migrationSql); + + expect( + db + .prepare( + `SELECT id, name, email, emailVerified, image + FROM auth_users` + ) + .get() + ).toEqual({ + id: "11111111111111111111111111111111", + name: "Legacy User", + email: "legacy@example.com", + emailVerified: 0, + image: "https://avatars.example/legacy", + }); + expect( + db + .prepare( + `SELECT id, accountId, providerId, userId, accessToken, refreshToken + FROM auth_accounts` + ) + .get() + ).toEqual({ + id: "22222222222222222222222222222222", + accountId: "583231", + providerId: "github", + userId: "11111111111111111111111111111111", + accessToken: null, + refreshToken: null, + }); + + // A lost migration response can safely be retried. + db.exec(migrationSql); + expect(db.prepare("SELECT COUNT(*) AS count FROM auth_users").get()).toEqual({ count: 1 }); + expect(db.prepare("SELECT COUNT(*) AS count FROM auth_accounts").get()).toEqual({ count: 1 }); + expect(db.prepare("SELECT COUNT(*) AS count FROM auth_sessions").get()).toEqual({ count: 0 }); + } finally { + db.close(); + } + }); + + it("reserves a legacy canonical email without implicitly linking a provider", () => { + const db = new DatabaseSync(":memory:"); + try { + db.exec("PRAGMA foreign_keys = ON"); + applyMigrationsBeforeBackfill(db); + db.exec(` + INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES ( + '44444444444444444444444444444444', + 'Bot-created User', + 'bot-created@example.com', + NULL, + 1785000000000, + 1785000001000 + ); + + INSERT INTO auth_users ( + id, name, email, emailVerified, image, createdAt, updatedAt + ) VALUES ( + '55555555555555555555555555555555', + 'Bot-created User', + 'bot-created@example.com', + 1, + NULL, + '2026-07-26T21:47:56.000Z', + '2026-07-26T21:47:56.000Z' + ); + `); + + db.exec(readFileSync(`${MIGRATIONS_DIRECTORY}/${BACKFILL_MIGRATION}`, "utf8")); + + expect(db.prepare("SELECT id, emailVerified FROM auth_users").get()).toEqual({ + id: "44444444444444444444444444444444", + emailVerified: 0, + }); + expect(db.prepare("SELECT COUNT(*) AS count FROM auth_accounts").get()).toEqual({ + count: 0, + }); + } finally { + db.close(); + } + }); +}); diff --git a/packages/control-plane/src/db/browser-auth-sessions.ts b/packages/control-plane/src/db/browser-auth-sessions.ts deleted file mode 100644 index dba70eb21..000000000 --- a/packages/control-plane/src/db/browser-auth-sessions.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { base64UrlEncode } from "../auth/encoding"; -import { hashToken } from "../auth/crypto"; -import type { SqlDatabase } from "./sql-database"; - -export const BROWSER_SESSION_PREFIX = "oi_bsess_"; -export const BROWSER_SESSION_IDLE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; -export const BROWSER_SESSION_ABSOLUTE_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; -export const BROWSER_SESSION_TOUCH_INTERVAL_MS = 24 * 60 * 60 * 1000; - -const BROWSER_SESSION_CREDENTIAL_PATTERN = /^oi_bsess_[A-Za-z0-9_-]{43}$/; - -declare const browserSessionCredentialBrand: unique symbol; -declare const browserSessionIdBrand: unique symbol; - -export type BrowserSessionCredential = string & { - readonly [browserSessionCredentialBrand]: true; -}; - -export type BrowserSessionId = string & { - readonly [browserSessionIdBrand]: true; -}; - -export interface Clock { - now(): number; -} - -export interface BrowserSessionCredentialGenerator { - generate(): string; -} - -export interface BrowserSessionIdGenerator { - generate(): string; -} - -export interface TokenHasher { - hash(value: string): Promise; -} - -export interface BrowserAuthSessionStoreDependencies { - readonly clock: Clock; - readonly credentialGenerator: BrowserSessionCredentialGenerator; - readonly idGenerator: BrowserSessionIdGenerator; - readonly tokenHasher: TokenHasher; -} - -export interface CreateBrowserAuthSessionInput { - userId: string; - providerIdentityId: string; -} - -export interface CreatedBrowserAuthSession { - credential: BrowserSessionCredential; - credentialId: BrowserSessionId; - expiresAt: number; - absoluteExpiresAt: number; -} - -export interface AuthenticatedBrowserSession { - credentialId: BrowserSessionId; - userId: string; - providerIdentityId: string; - lastUsedAt: number; - expiresAt: number; - absoluteExpiresAt: number; -} - -export type BrowserSessionRevocationReason = "logout" | "operator" | "provider_identity"; - -type BrowserAuthSessionRow = { - id: string; - client_id: string; - user_id: string; - provider_identity_id: string; - last_used_at: number; - expires_at: number; - absolute_expires_at: number; - revoked_at: number | null; -}; - -export type BrowserSessionRejection = - | "malformed" - | "unknown" - | "revoked" - | "idle_expired" - | "absolute_expired" - | "corrupt"; - -export class BrowserSessionAuthenticationError extends Error { - constructor(readonly rejection: BrowserSessionRejection) { - super("Browser session is not valid"); - this.name = "BrowserSessionAuthenticationError"; - } -} - -function defaultCredential(): string { - return `${BROWSER_SESSION_PREFIX}${base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))}`; -} - -function isFiniteInteger(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -export function parseBrowserSessionCredential(value: string): BrowserSessionCredential { - if (!BROWSER_SESSION_CREDENTIAL_PATTERN.test(value)) { - throw new BrowserSessionAuthenticationError("malformed"); - } - return value as BrowserSessionCredential; -} - -export function parseBrowserSessionId(value: string): BrowserSessionId { - if (!isNonEmptyString(value)) { - throw new BrowserSessionAuthenticationError("malformed"); - } - return value as BrowserSessionId; -} - -function decodeBrowserAuthSessionRow(row: BrowserAuthSessionRow): AuthenticatedBrowserSession { - if ( - !isNonEmptyString(row.id) || - row.client_id !== "web" || - !isNonEmptyString(row.user_id) || - !isNonEmptyString(row.provider_identity_id) || - !isFiniteInteger(row.last_used_at) || - !isFiniteInteger(row.expires_at) || - !isFiniteInteger(row.absolute_expires_at) || - (row.revoked_at !== null && !isFiniteInteger(row.revoked_at)) - ) { - throw new BrowserSessionAuthenticationError("corrupt"); - } - - return { - credentialId: row.id as BrowserSessionId, - userId: row.user_id, - providerIdentityId: row.provider_identity_id, - lastUsedAt: row.last_used_at, - expiresAt: row.expires_at, - absoluteExpiresAt: row.absolute_expires_at, - }; -} - -export class BrowserAuthSessionStore { - constructor( - private readonly db: SqlDatabase, - private readonly dependencies: BrowserAuthSessionStoreDependencies - ) {} - - async create(input: CreateBrowserAuthSessionInput): Promise { - if (!isNonEmptyString(input.userId) || !isNonEmptyString(input.providerIdentityId)) { - throw new Error("Browser session requires a user and provider identity"); - } - - const now = this.dependencies.clock.now(); - const generatedCredential = this.dependencies.credentialGenerator.generate(); - if (!BROWSER_SESSION_CREDENTIAL_PATTERN.test(generatedCredential)) { - throw new Error("Browser session credential generator returned an invalid credential"); - } - const credential = generatedCredential as BrowserSessionCredential; - - const generatedCredentialId = this.dependencies.idGenerator.generate(); - if (!isNonEmptyString(generatedCredentialId)) { - throw new Error("Browser session id generator returned an invalid id"); - } - const credentialId = generatedCredentialId as BrowserSessionId; - const tokenHash = await this.dependencies.tokenHasher.hash(credential); - const expiresAt = now + BROWSER_SESSION_IDLE_LIFETIME_MS; - const absoluteExpiresAt = now + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS; - - const result = await this.db - .prepare( - `INSERT INTO browser_auth_sessions ( - id, token_hash, user_id, client_id, provider_identity_id, - created_at, last_used_at, expires_at, absolute_expires_at, - revoked_at, revoked_reason - ) VALUES (?, ?, ?, 'web', ?, ?, ?, ?, ?, NULL, NULL)` - ) - .bind( - credentialId, - tokenHash, - input.userId, - input.providerIdentityId, - now, - now, - expiresAt, - absoluteExpiresAt - ) - .run(); - - if (result.meta.changes !== 1) { - throw new Error("Browser session was not created"); - } - - return { credential, credentialId, expiresAt, absoluteExpiresAt }; - } - - async authenticate(credential: BrowserSessionCredential): Promise { - const tokenHash = await this.dependencies.tokenHasher.hash(credential); - const row = await this.db - .prepare( - `SELECT - id, client_id, user_id, provider_identity_id, last_used_at, expires_at, - absolute_expires_at, revoked_at - FROM browser_auth_sessions - WHERE token_hash = ?` - ) - .bind(tokenHash) - .first(); - - return this.validateAuthenticatedRow(row); - } - - /** - * Revalidates a derived credential's parent without retaining or replaying - * the raw browser bearer. - */ - async authenticateById(credentialId: BrowserSessionId): Promise { - const row = await this.db - .prepare( - `SELECT - id, client_id, user_id, provider_identity_id, last_used_at, expires_at, - absolute_expires_at, revoked_at - FROM browser_auth_sessions - WHERE id = ?` - ) - .bind(credentialId) - .first(); - - return this.validateAuthenticatedRow(row); - } - - async revoke( - credential: BrowserSessionCredential, - reason: BrowserSessionRevocationReason - ): Promise { - const tokenHash = await this.dependencies.tokenHasher.hash(credential); - const result = await this.db - .prepare( - `UPDATE browser_auth_sessions - SET revoked_at = ?, revoked_reason = ? - WHERE token_hash = ? AND revoked_at IS NULL` - ) - .bind(this.dependencies.clock.now(), reason, tokenHash) - .run(); - - return result.meta.changes === 1; - } - - async revokeById( - credentialId: BrowserSessionId, - reason: BrowserSessionRevocationReason - ): Promise { - const result = await this.db - .prepare( - `UPDATE browser_auth_sessions - SET revoked_at = ?, revoked_reason = ? - WHERE id = ? AND revoked_at IS NULL` - ) - .bind(this.dependencies.clock.now(), reason, credentialId) - .run(); - - return result.meta.changes === 1; - } - - async touchQualifyingActivity(credentialId: BrowserSessionId): Promise { - const now = this.dependencies.clock.now(); - await this.db - .prepare( - `UPDATE browser_auth_sessions - SET last_used_at = ?, - expires_at = min(?, absolute_expires_at) - WHERE id = ? - AND revoked_at IS NULL - AND expires_at > ? - AND absolute_expires_at > ? - AND last_used_at <= ?` - ) - .bind( - now, - now + BROWSER_SESSION_IDLE_LIFETIME_MS, - credentialId, - now, - now, - now - BROWSER_SESSION_TOUCH_INTERVAL_MS - ) - .run(); - } - - private validateAuthenticatedRow(row: BrowserAuthSessionRow | null): AuthenticatedBrowserSession { - if (!row) throw new BrowserSessionAuthenticationError("unknown"); - const session = decodeBrowserAuthSessionRow(row); - const now = this.dependencies.clock.now(); - if (row.revoked_at !== null) throw new BrowserSessionAuthenticationError("revoked"); - if (session.absoluteExpiresAt <= now) { - throw new BrowserSessionAuthenticationError("absolute_expired"); - } - if (session.expiresAt <= now) { - throw new BrowserSessionAuthenticationError("idle_expired"); - } - return session; - } -} - -export const defaultBrowserAuthSessionStoreDependencies: BrowserAuthSessionStoreDependencies = { - clock: { now: () => Date.now() }, - credentialGenerator: { generate: defaultCredential }, - idGenerator: { generate: () => crypto.randomUUID() }, - tokenHasher: { hash: hashToken }, -}; diff --git a/packages/control-plane/src/db/browser-sign-in-identities.ts b/packages/control-plane/src/db/browser-sign-in-identities.ts deleted file mode 100644 index 09f7df840..000000000 --- a/packages/control-plane/src/db/browser-sign-in-identities.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { - BrowserSignInIdentityStorePort, - CreateBrowserSignInIdentityInput, - RefreshBrowserSignInIdentityInput, - StoredBrowserSignInIdentity, -} from "../auth/browser-sign-in-identity-store"; -import type { ProviderCredentialInput } from "../auth/provider-credential"; -import { isSignInProvider } from "../auth/sign-in-provider"; -import { isUniqueConstraintError } from "./errors"; -import type { SqlDatabase, SqlStatement } from "./sql-database"; - -export interface ProviderCredentialWriteStorePort { - prepareInitialInsert( - providerIdentityId: string, - credential: ProviderCredentialInput, - updatedAt: number - ): Promise; - prepareSignInUpsert( - providerIdentityId: string, - credential: ProviderCredentialInput, - updatedAt: number - ): Promise; - isSignInVersionConflict(error: unknown): boolean; -} - -interface EmailClaimRow { - email: string; - user_id: string; - source_kind: "legacy_canonical" | "provider_verified" | "trusted_bot_attribution"; -} - -interface IdentityRow { - id: string; - user_id: string; - provider: string; -} - -function decodeIdentityRow(row: IdentityRow): StoredBrowserSignInIdentity { - if ( - typeof row.id !== "string" || - typeof row.user_id !== "string" || - !isSignInProvider(row.provider) - ) { - throw new Error("Stored provider identity is corrupt"); - } - return { - providerIdentityId: row.id, - userId: row.user_id, - provider: row.provider, - }; -} - -function decodeEmailClaimRow(row: EmailClaimRow): EmailClaimRow { - if ( - typeof row.email !== "string" || - typeof row.user_id !== "string" || - (row.source_kind !== "legacy_canonical" && - row.source_kind !== "provider_verified" && - row.source_kind !== "trusted_bot_attribution") - ) { - throw new Error("Stored verified email claim is corrupt"); - } - return row; -} - -export class BrowserSignInIdentityStore implements BrowserSignInIdentityStorePort { - constructor( - private readonly db: SqlDatabase, - private readonly providerCredentialStore: ProviderCredentialWriteStorePort - ) {} - - async findByIssuerAndSubject( - issuer: string, - subject: string - ): Promise { - const row = await this.db - .prepare( - `SELECT id, user_id, provider - FROM user_identities - WHERE provider_issuer = ? AND provider_user_id = ?` - ) - .bind(issuer, subject) - .first(); - return row ? decodeIdentityRow(row) : null; - } - - async countConflictingEmails( - emails: readonly string[], - expectedUserId: string | null - ): Promise { - if (emails.length === 0) return 0; - const result = await this.db - .prepare( - `SELECT email, user_id, source_kind - FROM verified_email_claims - WHERE email IN (SELECT CAST(value AS TEXT) FROM json_each(?))` - ) - .bind(JSON.stringify(emails)) - .all(); - - return result.results - .map(decodeEmailClaimRow) - .filter((claim) => expectedUserId === null || claim.user_id !== expectedUserId).length; - } - - async create(input: CreateBrowserSignInIdentityInput): Promise { - const { userId, providerIdentityId, profile, credential, now } = input; - const statements: SqlStatement[] = [ - this.db - .prepare( - `INSERT INTO users ( - id, display_name, email, avatar_url, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?)` - ) - .bind(userId, profile.displayName, profile.primaryEmail, profile.avatarUrl, now, now), - this.db - .prepare( - `INSERT INTO user_identities ( - id, user_id, provider, provider_issuer, provider_user_id, - provider_login, provider_email, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - providerIdentityId, - userId, - profile.provider, - profile.issuer, - profile.subject, - profile.login, - profile.primaryEmail, - now - ), - ]; - if (profile.verifiedEmails.length > 0) { - statements.push( - this.db - .prepare( - `INSERT INTO verified_email_claims ( - email, user_id, source_kind, source_provider_identity_id, - created_at, last_verified_at - ) - SELECT CAST(value AS TEXT), ?, 'provider_verified', ?, ?, ? - FROM json_each(?)` - ) - .bind(userId, providerIdentityId, now, now, JSON.stringify(profile.verifiedEmails)) - ); - } - if (credential) { - statements.push( - await this.providerCredentialStore.prepareInitialInsert(providerIdentityId, credential, now) - ); - } - - await this.db.batch(statements); - } - - async refresh(input: RefreshBrowserSignInIdentityInput): Promise { - const { existing, profile, credential, now } = input; - const statements: SqlStatement[] = [ - this.db - .prepare( - `UPDATE user_identities - SET provider_login = ?, provider_email = ? - WHERE id = ? AND user_id = ?` - ) - .bind(profile.login, profile.primaryEmail, existing.providerIdentityId, existing.userId), - // users.email is stable canonical account metadata, not a mirror of a - // provider's mutable primary email. Current provider display metadata - // lives on user_identities; verified ownership evidence lives in claims. - this.db - .prepare( - `UPDATE users - SET display_name = ?, avatar_url = ?, updated_at = ? - WHERE id = ?` - ) - .bind(profile.displayName, profile.avatarUrl, now, existing.userId), - ]; - if (profile.verifiedEmails.length > 0) { - const serializedEmails = JSON.stringify(profile.verifiedEmails); - statements.push( - this.db - .prepare( - `UPDATE verified_email_claims - SET last_verified_at = ? - WHERE user_id = ? - AND source_kind != 'legacy_canonical' - AND email IN (SELECT CAST(value AS TEXT) FROM json_each(?))` - ) - .bind(now, existing.userId, serializedEmails), - this.db - .prepare( - `INSERT OR IGNORE INTO verified_email_claims ( - email, user_id, source_kind, source_provider_identity_id, - created_at, last_verified_at - ) - SELECT CAST(value AS TEXT), ?, 'provider_verified', ?, ?, ? - FROM json_each(?)` - ) - .bind(existing.userId, existing.providerIdentityId, now, now, serializedEmails) - ); - } - if (credential) { - statements.push( - await this.providerCredentialStore.prepareSignInUpsert( - existing.providerIdentityId, - credential, - now - ) - ); - } - - await this.db.batch(statements); - } - - isRetryableCreateConflict(error: unknown): boolean { - return isUniqueConstraintError(error); - } - - isRetryableRefreshConflict(error: unknown): boolean { - return ( - isUniqueConstraintError(error) || this.providerCredentialStore.isSignInVersionConflict(error) - ); - } -} diff --git a/packages/control-plane/src/db/canonical-user-projection.ts b/packages/control-plane/src/db/canonical-user-projection.ts new file mode 100644 index 000000000..0cb15ad35 --- /dev/null +++ b/packages/control-plane/src/db/canonical-user-projection.ts @@ -0,0 +1,50 @@ +import { isCanonicalUserId } from "@open-inspect/shared"; +import type { + CanonicalUserProjection, + UserProjectionInput, +} from "../auth/user/canonical-user-projection"; +import type { SqlDatabase } from "./sql-database"; + +function requireNonEmpty(value: string, field: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new Error(`Canonical user projection ${field} is empty`); + } + return normalized; +} + +function requireTimestamp(value: Date, field: string): number { + const timestamp = value.getTime(); + if (!Number.isFinite(timestamp)) { + throw new Error(`Canonical user projection ${field} is invalid`); + } + return timestamp; +} + +export class D1CanonicalUserProjection implements CanonicalUserProjection { + constructor(private readonly db: SqlDatabase) {} + + async project(user: UserProjectionInput): Promise { + const id = requireNonEmpty(user.id, "id"); + if (!isCanonicalUserId(id)) { + throw new Error("Projected user id is not canonical"); + } + const email = requireNonEmpty(user.email, "email").toLowerCase(); + const createdAt = requireTimestamp(user.createdAt, "createdAt"); + const updatedAt = requireTimestamp(user.updatedAt, "updatedAt"); + + await this.db + .prepare( + `INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + email = excluded.email, + avatar_url = excluded.avatar_url, + updated_at = excluded.updated_at` + ) + .bind(id, user.name.trim() || null, email, user.image ?? null, createdAt, updatedAt) + .run(); + } +} diff --git a/packages/control-plane/src/db/oauth-authorization-codes.ts b/packages/control-plane/src/db/oauth-authorization-codes.ts deleted file mode 100644 index ca2fd1249..000000000 --- a/packages/control-plane/src/db/oauth-authorization-codes.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { timingSafeEqual } from "@open-inspect/shared"; -import { - InvalidPkceVerifierError, - createPkceS256Challenge, - isPkceS256Challenge, -} from "../auth/pkce"; -import { - BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - BROWSER_SESSION_IDLE_LIFETIME_MS, - parseBrowserSessionCredential, - parseBrowserSessionId, - type Clock, - type CreatedBrowserAuthSession, - type TokenHasher, -} from "./browser-auth-sessions"; -import type { SqlDatabase } from "./sql-database"; - -export const OAUTH_AUTHORIZATION_CODE_LIFETIME_MS = 60 * 1000; - -const AUTHORIZATION_CODE_PATTERN = /^oi_code_[A-Za-z0-9_-]{43}$/; - -export interface OAuthAuthorizationCodeStoreDependencies { - readonly clock: Clock; - readonly tokenHasher: TokenHasher; - readonly authorizationCodeGenerator: { generate(): string }; - readonly browserCredentialGenerator: { generate(): string }; - readonly idGenerator: { generate(): string }; -} - -export interface IssueOAuthAuthorizationCodeInput { - userId: string; - providerIdentityId: string; - clientId: "web"; - redirectUri: string; - codeChallenge: string; -} - -export interface RedeemOAuthAuthorizationCodeInput { - code: string; - clientId: "web"; - redirectUri: string; - codeVerifier: string; -} - -type AuthorizationCodeRow = { - id: string; - client_id: string; - redirect_uri: string; - code_challenge: string; - expires_at: number; - consumed_at: number | null; -}; - -export type OAuthAuthorizationCodeRejection = - | "malformed" - | "unknown" - | "binding_mismatch" - | "pkce_failed" - | "expired" - | "already_consumed" - | "race_lost" - | "corrupt"; - -export class OAuthAuthorizationCodeRedemptionError extends Error { - constructor(readonly rejection: OAuthAuthorizationCodeRejection) { - super("OAuth authorization code is not valid"); - this.name = "OAuthAuthorizationCodeRedemptionError"; - } -} - -export class InvalidOAuthAuthorizationCodeInputError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidOAuthAuthorizationCodeInputError"; - } -} - -function isFiniteInteger(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -function decodeAuthorizationCodeRow(value: unknown): AuthorizationCodeRow { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new OAuthAuthorizationCodeRedemptionError("corrupt"); - } - const row = value as Record; - if ( - !isNonEmptyString(row.id) || - row.client_id !== "web" || - !isNonEmptyString(row.redirect_uri) || - !isPkceS256Challenge(row.code_challenge) || - !isFiniteInteger(row.expires_at) || - (row.consumed_at !== null && !isFiniteInteger(row.consumed_at)) - ) { - throw new OAuthAuthorizationCodeRedemptionError("corrupt"); - } - return { - id: row.id, - client_id: row.client_id, - redirect_uri: row.redirect_uri, - code_challenge: row.code_challenge, - expires_at: row.expires_at, - consumed_at: row.consumed_at, - }; -} - -function validateIssueInput(input: IssueOAuthAuthorizationCodeInput): void { - if ( - !isNonEmptyString(input.userId) || - !isNonEmptyString(input.providerIdentityId) || - input.clientId !== "web" || - !isNonEmptyString(input.redirectUri) || - !isPkceS256Challenge(input.codeChallenge) - ) { - throw new InvalidOAuthAuthorizationCodeInputError( - "OAuth authorization code binding is malformed" - ); - } -} - -export class OAuthAuthorizationCodeStore { - constructor( - private readonly db: SqlDatabase, - private readonly dependencies: OAuthAuthorizationCodeStoreDependencies - ) {} - - async issue( - input: IssueOAuthAuthorizationCodeInput - ): Promise<{ code: string; expiresAt: number }> { - validateIssueInput(input); - const code = this.dependencies.authorizationCodeGenerator.generate(); - if (!AUTHORIZATION_CODE_PATTERN.test(code)) { - throw new Error("OAuth authorization code generator returned an invalid code"); - } - const codeId = this.dependencies.idGenerator.generate(); - if (!isNonEmptyString(codeId)) { - throw new Error("OAuth authorization code id generator returned an invalid id"); - } - - const now = this.dependencies.clock.now(); - const expiresAt = now + OAUTH_AUTHORIZATION_CODE_LIFETIME_MS; - const codeHash = await this.dependencies.tokenHasher.hash(code); - const result = await this.db - .prepare( - `INSERT INTO oauth_authorization_codes ( - id, code_hash, user_id, provider_identity_id, client_id, - redirect_uri, code_challenge, created_at, expires_at, - consumed_at, consumed_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)` - ) - .bind( - codeId, - codeHash, - input.userId, - input.providerIdentityId, - input.clientId, - input.redirectUri, - input.codeChallenge, - now, - expiresAt - ) - .run(); - if (result.meta.changes !== 1) { - throw new Error("OAuth authorization code was not created"); - } - return { code, expiresAt }; - } - - async redeem(input: RedeemOAuthAuthorizationCodeInput): Promise { - if (!AUTHORIZATION_CODE_PATTERN.test(input.code)) { - throw new OAuthAuthorizationCodeRedemptionError("malformed"); - } - - let presentedChallenge: string; - try { - presentedChallenge = await createPkceS256Challenge(input.codeVerifier); - } catch (error) { - if (error instanceof InvalidPkceVerifierError) { - throw new OAuthAuthorizationCodeRedemptionError("pkce_failed"); - } - throw error; - } - - const codeHash = await this.dependencies.tokenHasher.hash(input.code); - const found = await this.db - .prepare( - `SELECT - id, client_id, redirect_uri, code_challenge, expires_at, consumed_at - FROM oauth_authorization_codes - WHERE code_hash = ?` - ) - .bind(codeHash) - .first>(); - if (!found) throw new OAuthAuthorizationCodeRedemptionError("unknown"); - - const row = decodeAuthorizationCodeRow(found); - if (row.client_id !== input.clientId || row.redirect_uri !== input.redirectUri) { - throw new OAuthAuthorizationCodeRedemptionError("binding_mismatch"); - } - if (!timingSafeEqual(row.code_challenge, presentedChallenge)) { - throw new OAuthAuthorizationCodeRedemptionError("pkce_failed"); - } - if (row.consumed_at !== null) { - throw new OAuthAuthorizationCodeRedemptionError("already_consumed"); - } - - const now = this.dependencies.clock.now(); - if (row.expires_at <= now) { - throw new OAuthAuthorizationCodeRedemptionError("expired"); - } - - const credential = parseBrowserSessionCredential( - this.dependencies.browserCredentialGenerator.generate() - ); - const credentialId = parseBrowserSessionId(this.dependencies.idGenerator.generate()); - const credentialHash = await this.dependencies.tokenHasher.hash(credential); - const expiresAt = now + BROWSER_SESSION_IDLE_LIFETIME_MS; - const absoluteExpiresAt = now + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS; - - const results = await this.db.batch([ - this.db - .prepare( - `UPDATE oauth_authorization_codes - SET consumed_at = ?, consumed_by = ? - WHERE code_hash = ? - AND client_id = ? - AND redirect_uri = ? - AND consumed_at IS NULL - AND expires_at > ?` - ) - .bind(now, credentialId, codeHash, input.clientId, input.redirectUri, now), - this.db - .prepare( - `INSERT INTO browser_auth_sessions ( - id, token_hash, user_id, client_id, provider_identity_id, - created_at, last_used_at, expires_at, absolute_expires_at, - revoked_at, revoked_reason - ) - SELECT - ?, ?, user_id, 'web', provider_identity_id, - ?, ?, ?, ?, NULL, NULL - FROM oauth_authorization_codes - WHERE code_hash = ? - AND consumed_by = ? - AND consumed_at = ?` - ) - .bind( - credentialId, - credentialHash, - now, - now, - expiresAt, - absoluteExpiresAt, - codeHash, - credentialId, - now - ), - ]); - const [consumeResult, sessionResult] = results; - if (!consumeResult || !sessionResult) { - throw new Error("OAuth authorization code redemption batch returned an invalid result"); - } - - if (consumeResult.meta.changes === 0 && sessionResult.meta.changes === 0) { - return this.throwCurrentRejection(codeHash, now); - } - if (consumeResult.meta.changes !== 1 || sessionResult.meta.changes !== 1) { - throw new Error("OAuth authorization code redemption batch violated its result invariant"); - } - return { credential, credentialId, expiresAt, absoluteExpiresAt }; - } - - private async throwCurrentRejection(codeHash: string, now: number): Promise { - const current = await this.db - .prepare( - `SELECT consumed_at, expires_at - FROM oauth_authorization_codes - WHERE code_hash = ?` - ) - .bind(codeHash) - .first>(); - if (!current) throw new OAuthAuthorizationCodeRedemptionError("race_lost"); - if ( - (current.consumed_at !== null && !isFiniteInteger(current.consumed_at)) || - !isFiniteInteger(current.expires_at) - ) { - throw new OAuthAuthorizationCodeRedemptionError("corrupt"); - } - if (current.consumed_at !== null) { - throw new OAuthAuthorizationCodeRedemptionError("already_consumed"); - } - if (current.expires_at <= now) { - throw new OAuthAuthorizationCodeRedemptionError("expired"); - } - throw new OAuthAuthorizationCodeRedemptionError("race_lost"); - } -} diff --git a/packages/control-plane/src/db/oauth-flow-state.ts b/packages/control-plane/src/db/oauth-flow-state.ts deleted file mode 100644 index b061b3291..000000000 --- a/packages/control-plane/src/db/oauth-flow-state.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { isPkceS256Challenge, isPkceVerifier } from "../auth/pkce"; -import { - OAuthFlowVerifierIntegrityError, - type OAuthFlowVerifierBinding, - type OAuthFlowVerifierCipher, -} from "../auth/oauth-flow-verifier"; -import type { - ConsumedOAuthFlowState, - ConsumedOAuthFlowStateFor, - CreateOAuthFlowStateInput, -} from "../auth/oauth-flow-state"; -import { isSignInProvider, type SignInProvider } from "../auth/sign-in-provider"; -import type { Clock, TokenHasher } from "./browser-auth-sessions"; -import type { SqlDatabase } from "./sql-database"; - -export const OAUTH_FLOW_LIFETIME_MS = 10 * 60 * 1000; -export const OAUTH_FLOW_KEY_VERSION = 1; - -const OPAQUE_VALUE_PATTERN = /^[A-Za-z0-9_-]{32,128}$/; - -export interface OAuthFlowStateStoreDependencies { - readonly clock: Clock; - readonly idGenerator: { generate(): string }; - readonly tokenHasher: TokenHasher; -} - -interface OAuthFlowRowBinding { - id: string; - clientId: "web"; - redirectUri: string; - clientCodeChallenge: string; - providerPkceVerifierCiphertext: string; - providerPkceKeyVersion: number; - expiresAt: number; - consumedAt: number | null; -} - -type OAuthFlowRow = - | (OAuthFlowRowBinding & { - provider: "github"; - oidcNonceHash: null; - }) - | (OAuthFlowRowBinding & { - provider: "google"; - oidcNonceHash: string; - }); - -export type OAuthFlowStateRejection = - | "malformed" - | "unknown" - | "provider_mismatch" - | "expired" - | "already_consumed" - | "race_lost" - | "corrupt"; - -export class OAuthFlowStateConsumptionError extends Error { - constructor(readonly rejection: OAuthFlowStateRejection) { - super("OAuth flow state is not valid"); - this.name = "OAuthFlowStateConsumptionError"; - } -} - -export class InvalidOAuthFlowStateInputError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidOAuthFlowStateInputError"; - } -} - -function isFiniteInteger(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -function decodeOAuthFlowRow(value: unknown): OAuthFlowRow { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - const row = value as Record; - if ( - !isNonEmptyString(row.id) || - !isSignInProvider(row.provider) || - row.client_id !== "web" || - !isNonEmptyString(row.redirect_uri) || - !isPkceS256Challenge(row.client_code_challenge) || - !isNonEmptyString(row.provider_pkce_verifier_ciphertext) || - row.provider_pkce_key_version !== OAUTH_FLOW_KEY_VERSION || - !isFiniteInteger(row.expires_at) || - (row.consumed_at !== null && !isFiniteInteger(row.consumed_at)) - ) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - - const binding = { - id: row.id, - clientId: "web" as const, - redirectUri: row.redirect_uri, - clientCodeChallenge: row.client_code_challenge, - providerPkceVerifierCiphertext: row.provider_pkce_verifier_ciphertext, - providerPkceKeyVersion: row.provider_pkce_key_version, - expiresAt: row.expires_at, - consumedAt: row.consumed_at, - }; - if (row.provider === "github") { - if (row.oidc_nonce_hash !== null) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - return { ...binding, provider: "github", oidcNonceHash: null }; - } - if (typeof row.oidc_nonce_hash !== "string" || !/^[0-9a-f]{64}$/.test(row.oidc_nonce_hash)) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - return { - ...binding, - provider: "google", - oidcNonceHash: row.oidc_nonce_hash, - }; -} - -function validateCreateInput(input: CreateOAuthFlowStateInput): void { - if (!OPAQUE_VALUE_PATTERN.test(input.state)) { - throw new InvalidOAuthFlowStateInputError("OAuth state is malformed"); - } - if ( - input.clientId !== "web" || - !isNonEmptyString(input.redirectUri) || - !isPkceS256Challenge(input.clientCodeChallenge) || - !isPkceVerifier(input.providerPkceVerifier) - ) { - throw new InvalidOAuthFlowStateInputError("OAuth flow binding is malformed"); - } - if (input.provider === "google" && !OPAQUE_VALUE_PATTERN.test(input.oidcNonce ?? "")) { - throw new InvalidOAuthFlowStateInputError("Google OAuth flows require a valid OIDC nonce"); - } - if (input.provider === "github" && input.oidcNonce !== undefined) { - throw new InvalidOAuthFlowStateInputError("GitHub OAuth flows cannot carry an OIDC nonce"); - } -} - -export class OAuthFlowStateStore { - constructor( - private readonly db: SqlDatabase, - private readonly verifierCipher: OAuthFlowVerifierCipher, - private readonly dependencies: OAuthFlowStateStoreDependencies - ) {} - - async create(input: CreateOAuthFlowStateInput): Promise<{ flowId: string }> { - validateCreateInput(input); - const flowId = this.dependencies.idGenerator.generate(); - if (!isNonEmptyString(flowId)) { - throw new Error("OAuth flow id generator returned an invalid id"); - } - - const now = this.dependencies.clock.now(); - const binding: OAuthFlowVerifierBinding = { - flowId, - provider: input.provider, - keyVersion: OAUTH_FLOW_KEY_VERSION, - }; - const [stateHash, verifierCiphertext, oidcNonceHash] = await Promise.all([ - this.dependencies.tokenHasher.hash(input.state), - this.verifierCipher.encrypt(input.providerPkceVerifier, binding), - input.provider === "google" - ? this.dependencies.tokenHasher.hash(input.oidcNonce) - : Promise.resolve(null), - ]); - - const result = await this.db - .prepare( - `INSERT INTO oauth_flow_state ( - id, state_hash, provider, client_id, redirect_uri, - client_code_challenge, provider_pkce_verifier_ciphertext, - provider_pkce_key_version, oidc_nonce_hash, - created_at, expires_at, consumed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)` - ) - .bind( - flowId, - stateHash, - input.provider, - input.clientId, - input.redirectUri, - input.clientCodeChallenge, - verifierCiphertext, - OAUTH_FLOW_KEY_VERSION, - oidcNonceHash, - now, - now + OAUTH_FLOW_LIFETIME_MS - ) - .run(); - if (result.meta.changes !== 1) { - throw new Error("OAuth flow state was not created"); - } - return { flowId }; - } - - async consume

( - state: string, - expectedProvider: P - ): Promise> { - if (!OPAQUE_VALUE_PATTERN.test(state)) { - throw new OAuthFlowStateConsumptionError("malformed"); - } - - const stateHash = await this.dependencies.tokenHasher.hash(state); - const found = await this.db - .prepare( - `SELECT - id, provider, client_id, redirect_uri, client_code_challenge, - provider_pkce_verifier_ciphertext, provider_pkce_key_version, - oidc_nonce_hash, expires_at, consumed_at - FROM oauth_flow_state - WHERE state_hash = ?` - ) - .bind(stateHash) - .first>(); - if (!found) throw new OAuthFlowStateConsumptionError("unknown"); - - const row = decodeOAuthFlowRow(found); - if (row.provider !== expectedProvider) { - throw new OAuthFlowStateConsumptionError("provider_mismatch"); - } - if (row.consumedAt !== null) { - throw new OAuthFlowStateConsumptionError("already_consumed"); - } - const now = this.dependencies.clock.now(); - if (row.expiresAt <= now) { - throw new OAuthFlowStateConsumptionError("expired"); - } - - let providerPkceVerifier: string; - try { - providerPkceVerifier = await this.verifierCipher.decrypt(row.providerPkceVerifierCiphertext, { - flowId: row.id, - provider: row.provider, - keyVersion: row.providerPkceKeyVersion, - }); - } catch (error) { - if (error instanceof OAuthFlowVerifierIntegrityError) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - throw error; - } - if (!isPkceVerifier(providerPkceVerifier)) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - - const consumed = await this.db - .prepare( - `UPDATE oauth_flow_state - SET consumed_at = ? - WHERE id = ? AND consumed_at IS NULL AND expires_at > ?` - ) - .bind(now, row.id, now) - .run(); - if (consumed.meta.changes !== 1) { - await this.throwCurrentRejection(row.id, now); - } - - const consumedBinding = { - flowId: row.id, - clientId: row.clientId, - redirectUri: row.redirectUri, - clientCodeChallenge: row.clientCodeChallenge, - providerPkceVerifier, - }; - const result: ConsumedOAuthFlowState = - row.provider === "github" - ? { ...consumedBinding, provider: "github", oidcNonceHash: null } - : { - ...consumedBinding, - provider: "google", - oidcNonceHash: row.oidcNonceHash, - }; - // The provider equality check above establishes the generic correlation. - return result as ConsumedOAuthFlowStateFor

; - } - - private async throwCurrentRejection(flowId: string, now: number): Promise { - const current = await this.db - .prepare("SELECT consumed_at, expires_at FROM oauth_flow_state WHERE id = ?") - .bind(flowId) - .first>(); - if (!current) { - throw new OAuthFlowStateConsumptionError("race_lost"); - } - if ( - (current.consumed_at !== null && !isFiniteInteger(current.consumed_at)) || - !isFiniteInteger(current.expires_at) - ) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - if (current.consumed_at !== null) { - throw new OAuthFlowStateConsumptionError("already_consumed"); - } - if (current.expires_at <= now) { - throw new OAuthFlowStateConsumptionError("expired"); - } - throw new OAuthFlowStateConsumptionError("race_lost"); - } -} diff --git a/packages/control-plane/src/db/provider-credentials.ts b/packages/control-plane/src/db/provider-credentials.ts deleted file mode 100644 index 89f214564..000000000 --- a/packages/control-plane/src/db/provider-credentials.ts +++ /dev/null @@ -1,613 +0,0 @@ -import { - ProviderCredentialIntegrityError, - type ProviderCredentialCipherBinding, - type ProviderCredentialCipherPort, -} from "../auth/provider-credential-cipher"; -import type { ProviderCredentialInput, ProviderCredentialKind } from "../auth/provider-credential"; -import type { Clock } from "./browser-auth-sessions"; -import { isCheckConstraintError, isUniqueConstraintError } from "./errors"; -import type { SqlDatabase, SqlStatement } from "./sql-database"; - -export const CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION = 1; -/** - * Migration 0047's CHECK expression is part of stale sign-in conflict - * detection: SQLite/D1 includes it in the constraint error message. - */ -export const PROVIDER_CREDENTIAL_ROW_VERSION_CHECK = "row_version >= 1"; -const SUPPORTED_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSIONS: ReadonlySet = new Set([ - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, -]); -const MAX_SIGN_IN_UPSERT_ATTEMPTS = 4; - -interface ProviderCredentialMetadata { - providerIdentityId: string; - encryptionKeyVersion: number; - rowVersion: number; - updatedAt: number; -} - -export type ProviderCredential = - | (ProviderCredentialMetadata & { - kind: "refreshable"; - accessToken: string; - accessExpiresAt: number; - refreshToken: string; - refreshExpiresAt: number | null; - }) - | (ProviderCredentialMetadata & { - kind: "access_only_expiring"; - accessToken: string; - accessExpiresAt: number; - }) - | (ProviderCredentialMetadata & { - kind: "access_only_nonexpiring"; - accessToken: string; - }); - -interface ProviderCredentialRowMetadata { - providerIdentityId: string; - accessTokenCiphertext: string; - encryptionKeyVersion: number; - rowVersion: number; - updatedAt: number; -} - -type ProviderCredentialRow = - | (ProviderCredentialRowMetadata & { - credentialKind: "refreshable"; - accessExpiresAt: number; - refreshTokenCiphertext: string; - refreshExpiresAt: number | null; - }) - | (ProviderCredentialRowMetadata & { - credentialKind: "access_only_expiring"; - accessExpiresAt: number; - refreshTokenCiphertext: null; - refreshExpiresAt: null; - }) - | (ProviderCredentialRowMetadata & { - credentialKind: "access_only_nonexpiring"; - accessExpiresAt: null; - refreshTokenCiphertext: null; - refreshExpiresAt: null; - }); - -interface EncryptedProviderCredential { - accessTokenCiphertext: string; - accessExpiresAt: number | null; - refreshTokenCiphertext: string | null; - refreshExpiresAt: number | null; -} - -export class InvalidProviderCredentialInputError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidProviderCredentialInputError"; - } -} - -export class StoredProviderCredentialCorruptError extends Error { - constructor() { - super("Stored provider credential is invalid"); - this.name = "StoredProviderCredentialCorruptError"; - } -} - -export class ProviderCredentialVersionConflictError extends Error { - constructor() { - super("Provider credential changed concurrently"); - this.name = "ProviderCredentialVersionConflictError"; - } -} - -function isFiniteInteger(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -function isSupportedProviderCredentialEncryptionKeyVersion(value: unknown): value is number { - return ( - isFiniteInteger(value) && - value > 0 && - SUPPORTED_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSIONS.has(value) - ); -} - -function validateInput(providerIdentityId: string, input: ProviderCredentialInput): void { - if (!isNonEmptyString(providerIdentityId) || !isNonEmptyString(input.accessToken)) { - throw new InvalidProviderCredentialInputError( - "Provider credential requires an identity and access token" - ); - } - if ("accessExpiresAt" in input && !isFiniteInteger(input.accessExpiresAt)) { - throw new InvalidProviderCredentialInputError( - "Provider access-token expiry must be an integer" - ); - } - if ( - input.kind === "refreshable" && - (!isNonEmptyString(input.refreshToken) || - (input.refreshExpiresAt !== null && !isFiniteInteger(input.refreshExpiresAt))) - ) { - throw new InvalidProviderCredentialInputError("Refreshable provider credential is malformed"); - } -} - -function validateObservedVersion(providerIdentityId: string, observedRowVersion: number): void { - if ( - !isNonEmptyString(providerIdentityId) || - !isFiniteInteger(observedRowVersion) || - observedRowVersion < 1 - ) { - throw new InvalidProviderCredentialInputError( - "Provider identity and positive observed row version are required" - ); - } -} - -function decodeProviderCredentialRow(value: unknown): ProviderCredentialRow { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new StoredProviderCredentialCorruptError(); - } - const row = value as Record; - if ( - !isNonEmptyString(row.provider_identity_id) || - !isNonEmptyString(row.access_token_ciphertext) || - !isSupportedProviderCredentialEncryptionKeyVersion(row.encryption_key_version) || - !isFiniteInteger(row.row_version) || - row.row_version < 1 || - !isFiniteInteger(row.updated_at) - ) { - throw new StoredProviderCredentialCorruptError(); - } - - const metadata = { - providerIdentityId: row.provider_identity_id, - accessTokenCiphertext: row.access_token_ciphertext, - encryptionKeyVersion: row.encryption_key_version, - rowVersion: row.row_version, - updatedAt: row.updated_at, - }; - if ( - row.credential_kind === "refreshable" && - isFiniteInteger(row.access_expires_at) && - isNonEmptyString(row.refresh_token_ciphertext) && - (row.refresh_expires_at === null || isFiniteInteger(row.refresh_expires_at)) - ) { - return { - ...metadata, - credentialKind: "refreshable", - accessExpiresAt: row.access_expires_at, - refreshTokenCiphertext: row.refresh_token_ciphertext, - refreshExpiresAt: row.refresh_expires_at, - }; - } - if ( - row.credential_kind === "access_only_expiring" && - isFiniteInteger(row.access_expires_at) && - row.refresh_token_ciphertext === null && - row.refresh_expires_at === null - ) { - return { - ...metadata, - credentialKind: "access_only_expiring", - accessExpiresAt: row.access_expires_at, - refreshTokenCiphertext: null, - refreshExpiresAt: null, - }; - } - if ( - row.credential_kind === "access_only_nonexpiring" && - row.access_expires_at === null && - row.refresh_token_ciphertext === null && - row.refresh_expires_at === null - ) { - return { - ...metadata, - credentialKind: "access_only_nonexpiring", - accessExpiresAt: null, - refreshTokenCiphertext: null, - refreshExpiresAt: null, - }; - } - throw new StoredProviderCredentialCorruptError(); -} - -function cipherBinding( - providerIdentityId: string, - credentialKind: ProviderCredentialKind, - tokenRole: "access" | "refresh", - encryptionKeyVersion: number, - rowVersion: number -): ProviderCredentialCipherBinding { - return { - providerIdentityId, - credentialKind, - tokenRole, - encryptionKeyVersion, - rowVersion, - }; -} - -export class ProviderCredentialStore { - constructor( - private readonly db: SqlDatabase, - private readonly cipher: ProviderCredentialCipherPort, - private readonly clock: Clock - ) {} - - /** - * Prepares, but does not execute, an initial credential insert so identity - * resolution can commit the user, identity, email claim, and credential in - * one caller-owned database batch. - */ - async prepareInitialInsert( - providerIdentityId: string, - credential: ProviderCredentialInput, - updatedAt = this.clock.now() - ): Promise { - validateInput(providerIdentityId, credential); - if (!isFiniteInteger(updatedAt)) { - throw new InvalidProviderCredentialInputError( - "Provider credential update time must be an integer" - ); - } - const rowVersion = 1; - const encrypted = await this.encryptCredential(providerIdentityId, credential, rowVersion); - return this.prepareInsertStatement( - providerIdentityId, - credential.kind, - encrypted, - rowVersion, - updatedAt - ); - } - - /** - * Prepares a sign-in upsert for a caller-owned batch. A concurrent version - * change deliberately violates the row-version check, aborting the entire - * batch instead of committing the caller's related writes partially. - */ - async prepareSignInUpsert( - providerIdentityId: string, - credential: ProviderCredentialInput, - updatedAt = this.clock.now() - ): Promise { - validateInput(providerIdentityId, credential); - if (!isFiniteInteger(updatedAt)) { - throw new InvalidProviderCredentialInputError( - "Provider credential update time must be an integer" - ); - } - - const previousVersion = await this.readRowVersion(providerIdentityId); - if (previousVersion === null) { - return await this.prepareInitialInsert(providerIdentityId, credential, updatedAt); - } - - const rowVersion = previousVersion + 1; - const encrypted = await this.encryptCredential(providerIdentityId, credential, rowVersion); - return this.db - .prepare( - `INSERT INTO provider_credentials ( - provider_identity_id, credential_kind, - access_token_ciphertext, access_expires_at, - refresh_token_ciphertext, refresh_expires_at, - encryption_key_version, row_version, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(provider_identity_id) DO UPDATE SET - credential_kind = excluded.credential_kind, - access_token_ciphertext = excluded.access_token_ciphertext, - access_expires_at = excluded.access_expires_at, - refresh_token_ciphertext = excluded.refresh_token_ciphertext, - refresh_expires_at = excluded.refresh_expires_at, - encryption_key_version = excluded.encryption_key_version, - row_version = CASE - WHEN provider_credentials.row_version = ? - THEN excluded.row_version - ELSE 0 - END, - updated_at = excluded.updated_at` - ) - .bind( - providerIdentityId, - credential.kind, - encrypted.accessTokenCiphertext, - encrypted.accessExpiresAt, - encrypted.refreshTokenCiphertext, - encrypted.refreshExpiresAt, - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion, - updatedAt, - previousVersion - ); - } - - isSignInVersionConflict(error: unknown): boolean { - return isCheckConstraintError(error, PROVIDER_CREDENTIAL_ROW_VERSION_CHECK); - } - - async upsertFromSignIn( - providerIdentityId: string, - credential: ProviderCredentialInput - ): Promise { - validateInput(providerIdentityId, credential); - - for (let attempt = 0; attempt < MAX_SIGN_IN_UPSERT_ATTEMPTS; attempt += 1) { - const previousVersion = await this.readRowVersion(providerIdentityId); - const rowVersion = (previousVersion ?? 0) + 1; - const encrypted = await this.encryptCredential(providerIdentityId, credential, rowVersion); - const updatedAt = this.clock.now(); - - if (previousVersion === null) { - try { - const inserted = await this.prepareInsertStatement( - providerIdentityId, - credential.kind, - encrypted, - rowVersion, - updatedAt - ).run(); - if (inserted.meta.changes === 1) return rowVersion; - } catch (error) { - if (!isUniqueConstraintError(error)) throw error; - } - continue; - } - - if ( - await this.updateObservedVersion( - providerIdentityId, - previousVersion, - rowVersion, - credential.kind, - encrypted, - updatedAt - ) - ) { - return rowVersion; - } - } - - throw new ProviderCredentialVersionConflictError(); - } - - private async readRowVersion(providerIdentityId: string): Promise { - const row = await this.db - .prepare( - `SELECT row_version - FROM provider_credentials - WHERE provider_identity_id = ?` - ) - .bind(providerIdentityId) - .first>(); - if (!row) return null; - if (!isFiniteInteger(row.row_version) || row.row_version < 1) { - throw new StoredProviderCredentialCorruptError(); - } - return row.row_version; - } - - private async encryptCredential( - providerIdentityId: string, - credential: ProviderCredentialInput, - rowVersion: number - ): Promise { - const [accessTokenCiphertext, refreshTokenCiphertext] = await Promise.all([ - this.cipher.encrypt( - credential.accessToken, - cipherBinding( - providerIdentityId, - credential.kind, - "access", - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion - ) - ), - credential.kind === "refreshable" - ? this.cipher.encrypt( - credential.refreshToken, - cipherBinding( - providerIdentityId, - credential.kind, - "refresh", - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion - ) - ) - : Promise.resolve(null), - ]); - return { - accessTokenCiphertext, - accessExpiresAt: - credential.kind === "access_only_nonexpiring" ? null : credential.accessExpiresAt, - refreshTokenCiphertext, - refreshExpiresAt: credential.kind === "refreshable" ? credential.refreshExpiresAt : null, - }; - } - - private prepareInsertStatement( - providerIdentityId: string, - credentialKind: ProviderCredentialKind, - encrypted: EncryptedProviderCredential, - rowVersion: number, - updatedAt: number - ): SqlStatement { - return this.db - .prepare( - `INSERT INTO provider_credentials ( - provider_identity_id, credential_kind, - access_token_ciphertext, access_expires_at, - refresh_token_ciphertext, refresh_expires_at, - encryption_key_version, row_version, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - providerIdentityId, - credentialKind, - encrypted.accessTokenCiphertext, - encrypted.accessExpiresAt, - encrypted.refreshTokenCiphertext, - encrypted.refreshExpiresAt, - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion, - updatedAt - ); - } - - private async updateObservedVersion( - providerIdentityId: string, - observedRowVersion: number, - rowVersion: number, - credentialKind: ProviderCredentialKind, - encrypted: EncryptedProviderCredential, - updatedAt: number - ): Promise { - const result = await this.db - .prepare( - `UPDATE provider_credentials - SET credential_kind = ?, - access_token_ciphertext = ?, - access_expires_at = ?, - refresh_token_ciphertext = ?, - refresh_expires_at = ?, - encryption_key_version = ?, - row_version = ?, - updated_at = ? - WHERE provider_identity_id = ? AND row_version = ?` - ) - .bind( - credentialKind, - encrypted.accessTokenCiphertext, - encrypted.accessExpiresAt, - encrypted.refreshTokenCiphertext, - encrypted.refreshExpiresAt, - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion, - updatedAt, - providerIdentityId, - observedRowVersion - ) - .run(); - return result.meta.changes === 1; - } - - async get(providerIdentityId: string): Promise { - if (!isNonEmptyString(providerIdentityId)) { - throw new InvalidProviderCredentialInputError("Provider identity is required"); - } - const found = await this.db - .prepare( - `SELECT - provider_identity_id, credential_kind, access_token_ciphertext, - access_expires_at, refresh_token_ciphertext, refresh_expires_at, - encryption_key_version, row_version, updated_at - FROM provider_credentials - WHERE provider_identity_id = ?` - ) - .bind(providerIdentityId) - .first>(); - if (!found) return null; - const row = decodeProviderCredentialRow(found); - - try { - const accessToken = await this.cipher.decrypt( - row.accessTokenCiphertext, - cipherBinding( - row.providerIdentityId, - row.credentialKind, - "access", - row.encryptionKeyVersion, - row.rowVersion - ) - ); - if (!isNonEmptyString(accessToken)) { - throw new StoredProviderCredentialCorruptError(); - } - const metadata = { - providerIdentityId: row.providerIdentityId, - accessToken, - encryptionKeyVersion: row.encryptionKeyVersion, - rowVersion: row.rowVersion, - updatedAt: row.updatedAt, - }; - if (row.credentialKind === "refreshable") { - const refreshToken = await this.cipher.decrypt( - row.refreshTokenCiphertext, - cipherBinding( - row.providerIdentityId, - row.credentialKind, - "refresh", - row.encryptionKeyVersion, - row.rowVersion - ) - ); - if (!isNonEmptyString(refreshToken)) { - throw new StoredProviderCredentialCorruptError(); - } - return { - ...metadata, - kind: row.credentialKind, - accessExpiresAt: row.accessExpiresAt, - refreshToken, - refreshExpiresAt: row.refreshExpiresAt, - }; - } - if (row.credentialKind === "access_only_expiring") { - return { - ...metadata, - kind: row.credentialKind, - accessExpiresAt: row.accessExpiresAt, - }; - } - return { ...metadata, kind: row.credentialKind }; - } catch (error) { - if (error instanceof ProviderCredentialIntegrityError) { - throw new StoredProviderCredentialCorruptError(); - } - throw error; - } - } - - async invalidateObservedVersion( - providerIdentityId: string, - observedRowVersion: number - ): Promise { - validateObservedVersion(providerIdentityId, observedRowVersion); - const result = await this.db - .prepare( - `DELETE FROM provider_credentials - WHERE provider_identity_id = ? AND row_version = ?` - ) - .bind(providerIdentityId, observedRowVersion) - .run(); - return result.meta.changes === 1; - } - - async replaceObservedVersion( - providerIdentityId: string, - observedRowVersion: number, - credential: ProviderCredentialInput - ): Promise { - validateObservedVersion(providerIdentityId, observedRowVersion); - validateInput(providerIdentityId, credential); - const rowVersion = observedRowVersion + 1; - const encrypted = await this.encryptCredential(providerIdentityId, credential, rowVersion); - if ( - !(await this.updateObservedVersion( - providerIdentityId, - observedRowVersion, - rowVersion, - credential.kind, - encrypted, - this.clock.now() - )) - ) { - throw new ProviderCredentialVersionConflictError(); - } - return rowVersion; - } -} diff --git a/packages/control-plane/src/router.analytics.test.ts b/packages/control-plane/src/router.analytics.test.ts index 722e039c4..4d162fcb7 100644 --- a/packages/control-plane/src/router.analytics.test.ts +++ b/packages/control-plane/src/router.analytics.test.ts @@ -52,7 +52,9 @@ describe("analytics router integration", () => { }; const response = await handleRequest( - await signedServiceRequest("https://test.local/analytics/summary"), + await signedServiceRequest("https://test.local/analytics/summary", { + service: "modal", + }), env as never ); diff --git a/packages/control-plane/src/router.auth.test.ts b/packages/control-plane/src/router.auth.test.ts index cf0bea00a..b7f617953 100644 --- a/packages/control-plane/src/router.auth.test.ts +++ b/packages/control-plane/src/router.auth.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { handleRequest } from "./router"; -import { signedServiceRequest, TEST_SERVICE_SECRETS } from "./router.test-support"; function createEnv(verifyStatus: number) { const fetch = vi @@ -79,28 +78,18 @@ describe("router sandbox-token fallback", () => { }); }); -describe("auth token routes are SCM-agnostic", () => { - // Guards the isScmAgnosticRoute entry for /auth/tokens/*: dropping it would - // 501 exchange/refresh on non-GitHub deployments before the handlers run. - it.each(["exchange", "refresh"])( - "reaches /auth/tokens/%s under a gitlab provider", - async (route) => { - const env = { - ...TEST_SERVICE_SECRETS, - SCM_PROVIDER: "gitlab", - DB: { prepare: vi.fn(), batch: vi.fn(), exec: vi.fn(), dump: vi.fn() }, - }; - - const response = await handleRequest( - await signedServiceRequest(`https://test.local/auth/tokens/${route}`, { - method: "POST", - body: JSON.stringify({}), - }), - env as never - ); +describe("retired browser-auth routes", () => { + it.each([ + ["POST", "/auth/tokens/exchange"], + ["POST", "/auth/tokens/refresh"], + ["PUT", "/provider-identities/github/583231"], + ])("does not expose %s %s", async (method, path) => { + const { env } = createEnv(401); + const response = await handleRequest( + new Request(`https://test.local${path}`, { method }), + env as never + ); - // 400 = the handler's schema rejection; the SCM gate (501) did not fire. - expect(response.status).toBe(400); - } - ); + expect(response.status).toBe(404); + }); }); diff --git a/packages/control-plane/src/router.create-session.test.ts b/packages/control-plane/src/router.create-session.test.ts index 6ef5eec1e..aff27b100 100644 --- a/packages/control-plane/src/router.create-session.test.ts +++ b/packages/control-plane/src/router.create-session.test.ts @@ -26,13 +26,7 @@ vi.mock("./routes/shared", async (importOriginal) => { const USER_PRINCIPAL: Principal = { kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: "user-1", - participantUserId: "user-1", - }, - tokenId: "token-1", + userId: "user-1", }; describe("handleCreateSession D1 ordering", () => { diff --git a/packages/control-plane/src/router.provider-identities.test.ts b/packages/control-plane/src/router.provider-identities.test.ts deleted file mode 100644 index e85a94a14..000000000 --- a/packages/control-plane/src/router.provider-identities.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { handleRequest } from "./router"; - -vi.mock("./auth/web-session-tokens", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - WebSessionTokenService: vi.fn(function () { - return { - verifyAccessToken: async (token: string) => ({ - ok: true, - tokenId: token, - userId: - token === "oi_at_google" - ? "fedcba9876543210fedcba9876543210" - : "0123456789abcdef0123456789abcdef", - provider: token === "oi_at_google" ? "google" : "github", - providerUserId: token === "oi_at_google" ? "google-sub-1" : "12345", - }), - }; - }), - }; -}); - -function createEnv() { - return { - SCM_PROVIDER: "gitlab", - DB: { - prepare: vi.fn(), - batch: vi.fn(), - exec: vi.fn(), - dump: vi.fn(), - }, - }; -} - -function userRequest(path: string, token: string): Request { - return new Request(`https://test.local${path}`, { - method: "PUT", - headers: { Authorization: `Bearer ${token}` }, - }); -} - -describe("provider identity router integration", () => { - it("resolves a GitHub identity for its matching user when the SCM provider is not github", async () => { - const response = await handleRequest( - userRequest("/provider-identities/github/12345", "oi_at_github"), - createEnv() as never - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - userId: "0123456789abcdef0123456789abcdef", - }); - }); - - it("resolves a Google identity for its matching user when the SCM provider is not github", async () => { - // Guards the widened isScmAgnosticRoute regex: a typo dropping `google` - // would make this 501 (SCM not implemented) instead of reaching the handler. - const response = await handleRequest( - userRequest("/provider-identities/google/google-sub-1", "oi_at_google"), - createEnv() as never - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - userId: "fedcba9876543210fedcba9876543210", - }); - }); - - it("rejects non-GitHub provider identity paths when the SCM provider is not github", async () => { - const response = await handleRequest( - userRequest("/provider-identities/gitlab/U123", "oi_at_github"), - createEnv() as never - ); - - expect(response.status).toBe(501); - await expect(response.json()).resolves.toEqual({ - error: "SCM provider 'gitlab' is not implemented in this deployment.", - }); - }); -}); diff --git a/packages/control-plane/src/router.scm-credentials.test.ts b/packages/control-plane/src/router.scm-credentials.test.ts index 5ba6189fc..aaa8528cb 100644 --- a/packages/control-plane/src/router.scm-credentials.test.ts +++ b/packages/control-plane/src/router.scm-credentials.test.ts @@ -44,6 +44,7 @@ describe("SCM credentials router provider gate", () => { const response = await handleRequest( await signedServiceRequest("https://test.local/sessions/session-1/scm-credentials", { method: "POST", + service: "modal", }), env as never ); @@ -58,7 +59,9 @@ describe("SCM credentials router provider gate", () => { const { env, fetch } = createEnv(); const response = await handleRequest( - await signedServiceRequest("https://test.local/sessions/session-1/tunnel-urls"), + await signedServiceRequest("https://test.local/sessions/session-1/tunnel-urls", { + service: "modal", + }), env as never ); @@ -102,6 +105,7 @@ describe("SCM credentials router provider gate", () => { const response = await handleRequest( await signedServiceRequest("https://test.local/sessions/session-1/pr", { method: "POST", + service: "modal", }), env as never ); diff --git a/packages/control-plane/src/router.session-prompt.test.ts b/packages/control-plane/src/router.session-prompt.test.ts index dce816c43..d352f560e 100644 --- a/packages/control-plane/src/router.session-prompt.test.ts +++ b/packages/control-plane/src/router.session-prompt.test.ts @@ -1,41 +1,53 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { UserStore } from "./db/user-store"; +import { resolveGitHubEnrichmentForRequest } from "./session/identity"; import { handleRequest } from "./router"; +import { signedServiceRequest, TEST_SERVICE_SECRETS } from "./router.test-support"; vi.mock("./db/user-store", () => ({ UserStore: vi.fn(), })); -// Prompts attribute to the verified principal, never a body field. Resolve -// the bearer token to a fixed user principal so the tests exercise the -// principal-derived author path through the real router. -vi.mock("./auth/web-session-tokens", async (importOriginal) => { +vi.mock("./session/identity", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, - WebSessionTokenService: vi.fn(function () { - return { - verifyAccessToken: async () => ({ - ok: true, - tokenId: "token-1", - userId: "user-1", - provider: "github", - providerUserId: "583231", - }), - }; - }), + resolveGitHubEnrichmentForRequest: vi.fn(), }; }); -function userPromptRequest(body: Record): Request { - return new Request("https://test.local/sessions/session-1/prompt", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer oi_at_test-token", +vi.mock("./auth/user/runtime", () => ({ + getUserAuth: vi.fn(() => ({ + api: { + listUserAccounts: vi.fn(async () => [ + { + providerId: "github", + accountId: "583231", + userId: "user-1", + }, + ]), + }, + })), +})); + +vi.mock("./auth/user/session-authenticator", () => ({ + SessionIntegrityError: class SessionIntegrityError extends Error {}, + authenticateSession: vi.fn(async () => ({ + userId: "user-1", + authentication: { + mechanism: "browser_session", + credentialId: "session-1", + channel: { kind: "sig1", service: "web" }, }, + })), +})); + +function userPromptRequest(body: Record): Promise { + return signedServiceRequest("https://test.local/sessions/session-1/prompt", { + method: "POST", body: JSON.stringify(body), + headers: { Cookie: "__Secure-openinspect.session_token=session.signature" }, }); } @@ -47,6 +59,7 @@ function createEnv(sessionFetch: ReturnType): Record ({ meta: { changes: 0 } })), }; return { + ...TEST_SERVICE_SECRETS, SCM_PROVIDER: "github", DB: { prepare: vi.fn(() => statement), @@ -70,16 +83,14 @@ describe("session prompt identity enrichment", () => { vi.mocked(UserStore).mockImplementation(function () { return { getUserById: async () => ({ id: "user-1", displayName: "Trusted Ada" }), - getIdentitiesForUser: async () => [ - { - provider: "github", - providerUserId: "1001", - providerLogin: "ada", - providerEmail: "private@example.com", - }, - ], } as never; }); + vi.mocked(resolveGitHubEnrichmentForRequest).mockResolvedValue({ + scmUserId: "1001", + scmLogin: "ada", + displayName: "Trusted Ada", + email: "1001+ada@users.noreply.github.com", + }); const sessionFetch = vi.fn(async (request: Request) => { const body = (await request.json()) as Record; expect(body).toMatchObject({ @@ -97,7 +108,7 @@ describe("session prompt identity enrichment", () => { return Response.json({ status: "queued" }); }); const response = await handleRequest( - userPromptRequest({ content: "Fix the bug" }), + await userPromptRequest({ content: "Fix the bug" }), createEnv(sessionFetch) as never ); @@ -120,7 +131,7 @@ describe("session prompt identity enrichment", () => { return Response.json({ status: "queued" }); }); const response = await handleRequest( - userPromptRequest({ content: "Fix the bug" }), + await userPromptRequest({ content: "Fix the bug" }), createEnv(sessionFetch) as never ); @@ -132,9 +143,9 @@ describe("session prompt identity enrichment", () => { vi.mocked(UserStore).mockImplementation(function () { return { getUserById: async () => ({ id: "user-1", displayName: "Unlinked User" }), - getIdentitiesForUser: async () => [], } as never; }); + vi.mocked(resolveGitHubEnrichmentForRequest).mockResolvedValue(null); const sessionFetch = vi.fn(async (request: Request) => { const body = (await request.json()) as Record; expect(body.authorId).toBe("user-1"); @@ -142,7 +153,7 @@ describe("session prompt identity enrichment", () => { return Response.json({ status: "queued" }); }); const response = await handleRequest( - userPromptRequest({ content: "Fix the bug" }), + await userPromptRequest({ content: "Fix the bug" }), createEnv(sessionFetch) as never ); @@ -153,7 +164,7 @@ describe("session prompt identity enrichment", () => { it("rejects a caller-asserted authorId without forwarding to the runtime", async () => { const sessionFetch = vi.fn(async () => Response.json({ status: "queued" })); const response = await handleRequest( - userPromptRequest({ content: "Fix the bug", authorId: "someone-else" }), + await userPromptRequest({ content: "Fix the bug", authorId: "someone-else" }), createEnv(sessionFetch) as never ); diff --git a/packages/control-plane/src/router.spawn-child.test.ts b/packages/control-plane/src/router.spawn-child.test.ts index 774b54ff1..227bac0b7 100644 --- a/packages/control-plane/src/router.spawn-child.test.ts +++ b/packages/control-plane/src/router.spawn-child.test.ts @@ -90,6 +90,7 @@ describe("handleSpawnChild prompt enqueue handling", () => { await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { method: "POST", body: JSON.stringify({ title: "Child task", prompt: "Do the thing" }), + service: "modal", }), env as never ); @@ -217,6 +218,7 @@ describe("handleSpawnChild prompt enqueue handling", () => { const response = await handleRequest( await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { method: "POST", + service: "modal", body: JSON.stringify({ title: "Child task", prompt: "Do the thing", @@ -278,6 +280,7 @@ describe("handleSpawnChild prompt enqueue handling", () => { const response = await handleRequest( await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { method: "POST", + service: "modal", body: JSON.stringify({ title: "Child task" }), }), env as never @@ -414,6 +417,7 @@ describe("handleSpawnChild prompt enqueue handling", () => { const response = await handleRequest( await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { method: "POST", + service: "modal", body: JSON.stringify({ title: "Child task", prompt: "Do the thing", diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts index 16f62c294..238f2520e 100644 --- a/packages/control-plane/src/router.ts +++ b/packages/control-plane/src/router.ts @@ -3,8 +3,10 @@ */ import type { Env } from "./types"; +import { isBrowserAuthProxyRoute } from "@open-inspect/shared"; import { authenticate, isAuthError } from "./auth/authenticate"; import type { Principal } from "./auth/principal"; +import { getUserAuth } from "./auth/user/runtime"; import { resolveScmProviderFromEnv, SourceControlProviderError, @@ -23,7 +25,7 @@ import { error, HttpError, } from "./routes/shared"; -import { authTokenRoutes } from "./routes/auth-tokens"; +import { browserAuthRoutes } from "./routes/browser-auth"; import { integrationSettingsRoutes } from "./routes/integration-settings"; import { commitSigningRoutes } from "./routes/commit-signing"; import { modelPreferencesRoutes } from "./routes/model-preferences"; @@ -35,7 +37,6 @@ import { imageBuildRoutes } from "./routes/image-builds"; import { automationRoutes } from "./routes/automations"; import { mcpServerRoutes } from "./routes/mcp-servers"; import { analyticsRoutes } from "./routes/analytics"; -import { providerIdentityRoutes } from "./routes/provider-identities"; import { sessionRoutes } from "./routes/sessions"; import { handleSlackNotify } from "./routes/slack-notify"; import { webhookRoutes } from "./webhooks"; @@ -157,14 +158,10 @@ function isSandboxAuthOnlyRoute(path: string): boolean { return SANDBOX_AUTH_ONLY_ROUTES.some((pattern) => pattern.test(path)); } -function isScmAgnosticRoute(path: string): boolean { +function isScmAgnosticRoute(method: string, path: string): boolean { return ( - // Token issuance is identity work, independent of the SCM provider. - /^\/auth\/tokens\/(exchange|refresh)$/.test(path) || + isBrowserAuthProxyRoute(method, path) || /^\/analytics\/(summary|timeseries|breakdown|pull-requests)$/.test(path) || - // Identity resolution is independent of the SCM provider. Only the known - // auth providers are agnostic; an unimplemented SCM (e.g. gitlab) still 501s. - /^\/provider-identities\/(github|slack|linear|google)\/[^/]+$/.test(path) || /^\/sessions\/[^/]+\/(tunnel-urls|commit-signing)$/.test(path) || /^\/sessions\/[^/]+\/diff(?:\/.*)?$/.test(path) ); @@ -176,6 +173,7 @@ function isProviderImplementedRoute(provider: SourceControlProviderName, path: s } function enforceImplementedScmProvider( + method: string, path: string, env: Env, ctx: RequestContext @@ -185,7 +183,7 @@ function enforceImplementedScmProvider( if ( !isProviderImplementedRoute(provider, path) && !isPublicRoute(path) && - !isScmAgnosticRoute(path) + !isScmAgnosticRoute(method, path) ) { logger.warn("SCM provider not implemented", { event: "scm.provider_not_implemented", @@ -292,7 +290,7 @@ function logPrincipal(principal: Principal, ctx: RequestContext, path: string): fields.session_id = principal.sessionId; break; case "user": - fields.user_id = principal.user.canonicalUserId ?? undefined; + fields.user_id = principal.userId; break; } logger.info("auth.principal", { @@ -315,8 +313,7 @@ const routes: Route[] = [ handler: async () => json({ status: "healthy", service: "open-inspect-control-plane" }), }, - // Token issuance (exchange + refresh; web service principal only) - ...authTokenRoutes, + ...browserAuthRoutes, // Session management ...sessionRoutes, @@ -358,9 +355,6 @@ const routes: Route[] = [ // Analytics ...analyticsRoutes, - // Provider identities - ...providerIdentityRoutes, - // Webhooks (public routes — auth handled per-route) ...webhookRoutes, ]; @@ -401,6 +395,8 @@ export async function handleRequest( metrics, // eslint-disable-next-line no-restricted-syntax -- composition root: the one route-layer env.DB read db: instrumentD1(env.DB, metrics), + // eslint-disable-next-line no-restricted-syntax -- composition root injects the raw D1 adapter required by Better Auth + getUserAuth: () => getUserAuth(env, env.DB), executionCtx, }; @@ -418,6 +414,17 @@ export async function handleRequest( }); } + const matchedRoute = routes + .filter((route) => route.method === method) + .map((route) => ({ route, match: path.match(route.pattern) })) + .find( + (candidate): candidate is { route: Route; match: RegExpMatchArray } => + candidate.match !== null + ); + if (!matchedRoute) { + return withCorsAndTraceHeaders(error("Not found", 404), ctx); + } + // Require authentication for non-public routes if (!isPublicRoute(path)) { const requiresSandboxAuth = isSandboxAuthOnlyRoute(path); @@ -431,12 +438,14 @@ export async function handleRequest( ? await verifySandboxAuth(request, env, sandboxSessionId, ctx) : error("Unauthorized: Invalid session path", 401); } else { - const authResult = await authenticate(request, env, ctx); + const authResult = await authenticate(request, env, ctx, { + webService: isBrowserAuthProxyRoute(method, path) ? "service" : "user", + }); if (isAuthError(authResult)) { - // A service-credential or user-token attempt is terminal; only a - // request with no recognized credential may still be a sandbox-token - // call on a sandbox-accepting route. + // A service-credential attempt is terminal; only a request with no + // recognized credential may still be a sandbox-token call on a + // sandbox-accepting route. authError = error(authResult.reason, authResult.status); if ( @@ -449,6 +458,7 @@ export async function handleRequest( } else { authError = null; ctx.principal = authResult.principal; + ctx.authentication = authResult.authentication; request = authResult.request; } } @@ -462,60 +472,50 @@ export async function handleRequest( } } - const providerCheck = enforceImplementedScmProvider(path, env, ctx); + const providerCheck = enforceImplementedScmProvider(method, path, env, ctx); if (providerCheck) { return providerCheck; } - // Find matching route - for (const route of routes) { - if (route.method !== method) continue; - - const match = path.match(route.pattern); - if (match) { - let response: Response; - let outcome: "success" | "error"; - try { - response = await route.handler(request, env, match, ctx); - outcome = response.status >= 500 ? "error" : "success"; - } catch (e) { - if (e instanceof HttpError) { - response = error(e.message, e.status); - outcome = e.status >= 500 ? "error" : "success"; - } else { - const durationMs = Date.now() - startTime; - logger.error("http.request", { - event: "http.request", - request_id: ctx.request_id, - trace_id: ctx.trace_id, - http_method: method, - http_path: path, - http_status: 500, - duration_ms: durationMs, - outcome: "error", - error: e instanceof Error ? e : String(e), - ...ctx.metrics.summarize(), - }); - return withCorsAndTraceHeaders(error("Internal server error", 500), ctx); - } - } - + let response: Response; + let outcome: "success" | "error"; + try { + response = await matchedRoute.route.handler(request, env, matchedRoute.match, ctx); + outcome = response.status >= 500 ? "error" : "success"; + } catch (e) { + if (e instanceof HttpError) { + response = error(e.message, e.status); + outcome = e.status >= 500 ? "error" : "success"; + } else { const durationMs = Date.now() - startTime; - logger.info("http.request", { + logger.error("http.request", { event: "http.request", request_id: ctx.request_id, trace_id: ctx.trace_id, http_method: method, http_path: path, - http_status: response.status, + http_status: 500, duration_ms: durationMs, - outcome, + outcome: "error", + error: e instanceof Error ? e : String(e), ...ctx.metrics.summarize(), }); - - return withCorsAndTraceHeaders(response, ctx); + return withCorsAndTraceHeaders(error("Internal server error", 500), ctx); } } - return error("Not found", 404); + const durationMs = Date.now() - startTime; + logger.info("http.request", { + event: "http.request", + request_id: ctx.request_id, + trace_id: ctx.trace_id, + http_method: method, + http_path: path, + http_status: response.status, + duration_ms: durationMs, + outcome, + ...ctx.metrics.summarize(), + }); + + return withCorsAndTraceHeaders(response, ctx); } diff --git a/packages/control-plane/src/routes/auth-tokens.ts b/packages/control-plane/src/routes/auth-tokens.ts deleted file mode 100644 index 93478aebb..000000000 --- a/packages/control-plane/src/routes/auth-tokens.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Token issuance for web user identity: the exchange and the - * refresh grant. Internal, service-authenticated (`web` only) — this is NOT - * the public OAuth surface (that is P2's job; both feed the same store). - */ - -import { z } from "zod"; - -import { SUBJECT_TOKEN_TYPES } from "../auth/subject-verification"; -import { performExchange } from "../auth/token-exchange"; -import { WebSessionTokenService, type WebSessionTokenPair } from "../auth/web-session-tokens"; -import { ApiTokenStore } from "../db/api-tokens"; -import { createLogger } from "../logger"; -import type { Env } from "../types"; -import { type RequestContext, type Route, error, json, parsePattern } from "./shared"; - -const logger = createLogger("auth-tokens"); - -const exchangeRequestSchema = z.strictObject({ - subjectTokenType: z.enum(SUBJECT_TOKEN_TYPES), - subjectToken: z.string().min(1), - scmRefreshToken: z.string().min(1).optional(), - scmTokenExpiresAt: z.number().int().positive().optional(), -}); - -const refreshRequestSchema = z.strictObject({ - refreshToken: z.string().min(1), -}); - -/** Only web's own service credential may mint or refresh user tokens. */ -function requireWebServicePrincipal(ctx: RequestContext): Response | null { - const principal = ctx.principal; - if (!principal || principal.kind !== "service" || principal.service !== "web") { - return error("exchange_forbidden", 403); - } - return null; -} - -function createTokenService(ctx: RequestContext): WebSessionTokenService { - return new WebSessionTokenService(new ApiTokenStore(ctx.db)); -} - -/** WebSessionTokenPair is exactly the wire shape — the pair is the body. */ -function tokenPairResponse(pair: WebSessionTokenPair): Response { - return json(pair); -} - -async function handleTokenExchange( - request: Request, - env: Env, - _match: RegExpMatchArray, - ctx: RequestContext -): Promise { - const gate = requireWebServicePrincipal(ctx); - if (gate) return gate; - - const raw: unknown = await request.json().catch(() => null); - const parsed = exchangeRequestSchema.safeParse(raw); - if (!parsed.success) { - return error("invalid_request", 400); - } - const body = parsed.data; - - const result = await performExchange( - body, - ctx.db, - createTokenService(ctx), - env.TOKEN_ENCRYPTION_KEY - ); - if (!result.ok) { - logger.warn("Token exchange rejected", { - event: "auth.token.exchange_rejected", - subject_token_type: body.subjectTokenType, - failure: result.failure, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return result.failure === "subject_rejected" - ? error("subject_rejected", 401) - : error("provider_unavailable", 502); - } - - logger.info("Web session token pair minted", { - event: "auth.token.minted", - user_id: result.userId, - provider: result.provider, - token_kind: "web_session", - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return tokenPairResponse(result.pair); -} - -async function handleTokenRefresh( - request: Request, - _env: Env, - _match: RegExpMatchArray, - ctx: RequestContext -): Promise { - const gate = requireWebServicePrincipal(ctx); - if (gate) return gate; - - const raw: unknown = await request.json().catch(() => null); - const parsed = refreshRequestSchema.safeParse(raw); - if (!parsed.success) { - return error("invalid_request", 400); - } - - const redemption = await createTokenService(ctx).redeemRefreshToken(parsed.data.refreshToken); - if (!redemption.ok) { - if (redemption.failure === "refresh_superseded") { - // Benign concurrent renewal — the winner's pair is live. Info, not - // warn: this is expected multi-tab/wake concurrency, not a fault. - logger.info("Refresh grant superseded by a concurrent renewal", { - event: "auth.token.refresh_superseded", - family_id: redemption.familyId, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - } else { - logger.warn("Refresh grant rejected", { - event: - redemption.failure === "refresh_reuse_detected" - ? "auth.token.refresh_reuse_detected" - : "auth.token.refresh_rejected", - failure: redemption.failure, - family_id: redemption.familyId, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - } - return error(redemption.failure, 401); - } - - logger.info("Web session token pair refreshed", { - event: "auth.token.refreshed", - user_id: redemption.userId, - family_id: redemption.familyId, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return tokenPairResponse(redemption.pair); -} - -export const authTokenRoutes: Route[] = [ - { method: "POST", pattern: parsePattern("/auth/tokens/exchange"), handler: handleTokenExchange }, - { method: "POST", pattern: parsePattern("/auth/tokens/refresh"), handler: handleTokenRefresh }, -]; diff --git a/packages/control-plane/src/routes/automations.test.ts b/packages/control-plane/src/routes/automations.test.ts index 75a2bac95..8da2002a4 100644 --- a/packages/control-plane/src/routes/automations.test.ts +++ b/packages/control-plane/src/routes/automations.test.ts @@ -117,13 +117,7 @@ function createEnv(): Env { const USER_PRINCIPAL: Principal = { kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: "user-1", - participantUserId: "user-1", - }, - tokenId: "token-1", + userId: "user-1", }; const SLACK_BOT_PRINCIPAL: Principal = { diff --git a/packages/control-plane/src/routes/browser-auth.test.ts b/packages/control-plane/src/routes/browser-auth.test.ts new file mode 100644 index 000000000..363433e1b --- /dev/null +++ b/packages/control-plane/src/routes/browser-auth.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { forwardBrowserAuthRequest } from "./browser-auth"; + +describe("forwardBrowserAuthRequest", () => { + it("uses the direct API wrapper for session lookup", async () => { + const getSession = vi.fn(async () => Response.json({ user: { id: "user-1" } })); + const handler = vi.fn(async () => { + throw new Error("HTTP handler should not serve session lookup"); + }); + const auth = { + api: { getSession }, + handler, + } as never; + const request = new Request("https://control-plane.test/api/auth/get-session", { + headers: { Cookie: "session=value" }, + }); + + const response = await forwardBrowserAuthRequest(auth, request); + + expect(response.status).toBe(200); + expect(getSession).toHaveBeenCalledWith({ + headers: request.headers, + asResponse: true, + }); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/routes/browser-auth.ts b/packages/control-plane/src/routes/browser-auth.ts new file mode 100644 index 000000000..c27d108b1 --- /dev/null +++ b/packages/control-plane/src/routes/browser-auth.ts @@ -0,0 +1,91 @@ +import { BROWSER_AUTH_PROXY_ROUTES } from "@open-inspect/shared"; +import { type BetterAuthRuntime, UserAuthConfigurationError } from "../auth/user/runtime"; +import { createLogger } from "../logger"; +import { error, parsePattern, type Route } from "./shared"; + +const logger = createLogger("browser-auth"); + +function copyBrowserAuthResponseHeaders(upstream: Headers): Headers { + const headers = new Headers(); + upstream.forEach((value, name) => { + if (name.toLowerCase() !== "set-cookie") { + headers.append(name, value); + } + }); + const getSetCookie = (upstream as Headers & { getSetCookie?: () => string[] }).getSetCookie; + const setCookieValues = getSetCookie?.call(upstream) ?? []; + if (setCookieValues.length === 0) { + const value = upstream.get("Set-Cookie"); + if (value) setCookieValues.push(value); + } + for (const value of setCookieValues) { + headers.append("Set-Cookie", value); + } + return headers; +} + +/** + * Better Auth's direct API establishes its request-state context explicitly. + * Use it for session reads because Cloudflare Workers can lose the HTTP + * handler's AsyncLocalStorage state before session-refresh policy is read. + */ +export async function forwardBrowserAuthRequest( + auth: BetterAuthRuntime, + request: Request +): Promise { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/api/auth/get-session") { + return auth.api.getSession({ + headers: request.headers, + asResponse: true, + }); + } + return auth.handler(request); +} + +function requireWebService(route: Route["handler"]): Route["handler"] { + return async (request, env, match, ctx) => { + if (ctx.principal?.kind !== "service" || ctx.principal.service !== "web") { + return error("Unauthorized", 401); + } + return route(request, env, match, ctx); + }; +} + +const handleBrowserAuth: Route["handler"] = async (request, _env, _match, ctx) => { + try { + if (!ctx.getUserAuth) { + throw new UserAuthConfigurationError("User authentication runtime is unavailable"); + } + const response = await forwardBrowserAuthRequest(ctx.getUserAuth(), request); + const headers = copyBrowserAuthResponseHeaders(response.headers); + headers.set("Cache-Control", "no-store"); + headers.set("Referrer-Policy", "no-referrer"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } catch (cause) { + if (cause instanceof UserAuthConfigurationError) { + logger.error("Browser authentication is not configured", { + event: "auth.browser.misconfigured", + error: cause, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("Browser authentication is not configured", 503); + } + throw cause; + } +}; + +/** + * The browser can reach only this positive Better Auth allowlist, and only + * through a freshly signed service:web proxy request. + */ +export const browserAuthRoutes: Route[] = BROWSER_AUTH_PROXY_ROUTES.map(([method, path]) => ({ + method, + pattern: parsePattern(path), + handler: requireWebService(handleBrowserAuth), +})); diff --git a/packages/control-plane/src/routes/provider-identities.test.ts b/packages/control-plane/src/routes/provider-identities.test.ts deleted file mode 100644 index 1950063c6..000000000 --- a/packages/control-plane/src/routes/provider-identities.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { SqlDatabase } from "../db/sql-database"; -import type { Env } from "../types"; -import { providerIdentityRoutes } from "./provider-identities"; -import type { RequestContext } from "./shared"; - -function createEnv(): Env { - return { - DB: {} as D1Database, - } as Env; -} - -function createCtx(): RequestContext { - return { - trace_id: "trace-1", - request_id: "req-1", - db: {} as SqlDatabase, - principal: { kind: "service", service: "web", actor: null }, - metrics: { - d1Queries: [], - spans: {}, - time: async (_name: string, fn: () => Promise) => fn(), - summarize: () => ({}), - }, - }; -} - -function userCtx( - canonicalUserId: string, - provider: "github" | "google" | "slack" | "linear" = "github", - providerUserId = "12345" -): RequestContext { - return { - ...createCtx(), - principal: { - kind: "user", - user: { - provider, - providerUserId, - canonicalUserId, - participantUserId: canonicalUserId, - }, - tokenId: "tok-1", - }, - }; -} - -async function callProviderIdentityRoute( - path: string, - ctx: RequestContext = createCtx(), - body?: unknown -): Promise { - const route = providerIdentityRoutes.find((candidate) => candidate.method === "PUT")!; - const match = path.match(route.pattern); - if (!match) throw new Error(`No route match for ${path}`); - - return route.handler( - new Request(`https://test.local${path}`, { - method: "PUT", - ...(body === undefined - ? {} - : { - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - }), - createEnv(), - match, - ctx - ); -} - -describe("PUT /provider-identities/:provider/:providerUserId", () => { - it("denies the web service now that identity creation happens only during token exchange", async () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - - const response = await callProviderIdentityRoute( - "/provider-identities/github/12345", - createCtx(), - { - providerEmail: "victim@example.com", - } - ); - - expect(response.status).toBe(403); - }); - - it("matches every supported provider identity path and captures the provider", () => { - const route = providerIdentityRoutes.find((candidate) => candidate.method === "PUT")!; - - for (const [path, provider, providerUserId] of [ - ["/provider-identities/github/12345", "github", "12345"], - ["/provider-identities/slack/U123", "slack", "U123"], - ["/provider-identities/linear/abc", "linear", "abc"], - ["/provider-identities/google/google-sub-1", "google", "google-sub-1"], - ] as const) { - const match = path.match(route.pattern); - expect(match?.groups).toMatchObject({ provider, providerUserId }); - } - }); - - it("rejects unsupported providers before authorization", async () => { - const response = await callProviderIdentityRoute("/provider-identities/gitlab/U123"); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: "provider must be one of: github, slack, linear, google", - }); - }); - - it("rejects blank provider user IDs", async () => { - const response = await callProviderIdentityRoute("/provider-identities/github/%20%20%20"); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: "providerUserId is required" }); - }); - - it("rejects invalid path encoding for provider user IDs", async () => { - const response = await callProviderIdentityRoute("/provider-identities/github/%E0%A4%A"); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: "providerUserId is required" }); - }); - - it("returns a matching user's token-fixed canonical id without requiring a body", async () => { - const response = await callProviderIdentityRoute( - "/provider-identities/github/12345", - userCtx("0123456789abcdef0123456789abcdef") - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - userId: "0123456789abcdef0123456789abcdef", - }); - }); - - it("ignores body identity fields when resolving the matching user", async () => { - const response = await callProviderIdentityRoute( - "/provider-identities/github/12345", - userCtx("0123456789abcdef0123456789abcdef"), - { providerEmail: "victim@example.com" } - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - userId: "0123456789abcdef0123456789abcdef", - }); - }); - - it("403s a user principal targeting a different identity path", async () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const response = await callProviderIdentityRoute( - "/provider-identities/github/999999", - userCtx("0123456789abcdef0123456789abcdef") - ); - - expect(response.status).toBe(403); - }); -}); diff --git a/packages/control-plane/src/routes/provider-identities.ts b/packages/control-plane/src/routes/provider-identities.ts deleted file mode 100644 index 9cdf4924c..000000000 --- a/packages/control-plane/src/routes/provider-identities.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { authorizeProviderIdentityRequest } from "../auth/identity-enforcement"; -import type { Env } from "../types"; -import { type RequestContext, type Route, error, json, parsePattern } from "./shared"; - -/** Providers that may be resolved through this authenticated route. */ -const ALLOWED_PROVIDERS = ["github", "slack", "linear", "google"] as const; -type AllowedProvider = (typeof ALLOWED_PROVIDERS)[number]; - -function isAllowedProvider(value: string | undefined): value is AllowedProvider { - return value !== undefined && (ALLOWED_PROVIDERS as readonly string[]).includes(value); -} - -function pathSegment(value: string | undefined): string | undefined { - if (!value) return undefined; - try { - const decoded = decodeURIComponent(value).trim(); - return decoded.length > 0 ? decoded : undefined; - } catch { - return undefined; - } -} - -export async function handleResolveProviderIdentity( - _request: Request, - _env: Env, - match: RegExpMatchArray, - ctx: RequestContext -): Promise { - const provider = match.groups?.provider; - if (!isAllowedProvider(provider)) { - return error(`provider must be one of: ${ALLOWED_PROVIDERS.join(", ")}`, 400); - } - - const providerUserId = pathSegment(match.groups?.providerUserId); - if (!providerUserId) { - return error("providerUserId is required", 400); - } - - const authz = authorizeProviderIdentityRequest(ctx, provider, providerUserId); - if (authz.action === "deny") return authz.response; - // The matching user token already fixes the canonical id. The request body - // is deliberately ignored so this route cannot mutate identity linkage. - return json({ userId: authz.canonicalUserId }); -} - -export const providerIdentityRoutes: Route[] = [ - { - method: "PUT", - pattern: parsePattern("/provider-identities/:provider/:providerUserId"), - handler: handleResolveProviderIdentity, - }, -]; diff --git a/packages/control-plane/src/routes/session-create.ts b/packages/control-plane/src/routes/session-create.ts index acbc9c0e5..0ce2361a7 100644 --- a/packages/control-plane/src/routes/session-create.ts +++ b/packages/control-plane/src/routes/session-create.ts @@ -4,6 +4,7 @@ import { type RepositoryRef, } from "@open-inspect/shared"; import { generateId } from "../auth/crypto"; +import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; import { applyIdentityEnforcement, resolveCanonicalUserId } from "../auth/identity-enforcement"; import { resolveEnvironmentTarget, resolveSessionRepositories } from "../repos/resolve"; import { resolveScmProviderFromEnv } from "../source-control"; @@ -12,7 +13,7 @@ import { UserStore } from "../db/user-store"; import { createLogger } from "../logger"; import { parseCreateSessionInput } from "../session/create-session-input"; import { initializeSession, type SessionInitInput } from "../session/initialize"; -import { resolveGitHubEnrichment } from "../session/identity"; +import { resolveGitHubEnrichmentForRequest } from "../session/identity"; import { resolveSessionScopedSettings } from "../session/integration-settings-resolution"; import type { CreateSessionResponse, Env } from "../types"; import { @@ -136,22 +137,19 @@ async function handleCreateSession( let scmTokenEncrypted: string | null = null; let scmRefreshTokenEncrypted: string | null = null; - // On GitHub deployments, enrich the owner with their linked GitHub identity - // from D1: fill in SCM fields the caller didn't provide (email, display name, - // OAuth token). Other SCM deployments retain their provider-native identity - // and credentials unchanged. - // - // This intentionally applies even when the session was authenticated via a - // non-GitHub provider (e.g. Google): if the canonical user has ALSO linked a - // verified-email GitHub identity, enrichment surfaces THAT identity's token so - // the same human keeps GitHub-attributed commits/PRs. resolveGitHubEnrichment - // keys off the linked `provider === "github"` identity, never the Google - // credential; a user with no linked GitHub identity gets null here and falls - // back to the App bot. The invariant is "a Google credential is never used as - // an SCM credential", not "a Google-authenticated session carries no SCM state". + // Browser sessions resolve a linked GitHub identity/token through Better + // Auth only when SCM enrichment is needed. Transitional callers retain the + // legacy D1 lookup. A user without a linked GitHub account uses the GitHub + // App bot fallback; account linking is intentionally deferred. if (githubDeployment) { try { - const enrichment = await resolveGitHubEnrichment(env, ctx.db, userStore, resolvedUserId); + const enrichment = await resolveGitHubEnrichmentForRequest( + env, + ctx.db, + userStore, + resolvedUserId, + await resolveGitHubCredentialAuthority(ctx, request.headers) + ); if (enrichment) { scmUserId = enrichment.scmUserId; scmLogin ??= enrichment.scmLogin; diff --git a/packages/control-plane/src/routes/session-prompt.ts b/packages/control-plane/src/routes/session-prompt.ts index 7ae518f8e..d69a08d77 100644 --- a/packages/control-plane/src/routes/session-prompt.ts +++ b/packages/control-plane/src/routes/session-prompt.ts @@ -5,11 +5,16 @@ import { type SessionAttachmentReference, } from "@open-inspect/shared"; import { applyIdentityEnforcement, mayAttachCallbackContext } from "../auth/identity-enforcement"; +import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; import { SessionIndexStore } from "../db/session-index"; import { UserStore } from "../db/user-store"; import { createLogger } from "../logger"; import { SessionInternalPaths } from "../session/contracts"; -import { parseAuthorId, resolveGitHubEnrichment, type GitHubEnrichment } from "../session/identity"; +import { + parseAuthorId, + resolveGitHubEnrichmentForRequest, + type GitHubEnrichment, +} from "../session/identity"; import type { Env } from "../types"; import { error, parsePattern, type Route } from "./shared"; import { sessionRoute, type SessionRouteContext } from "./session-route"; @@ -85,7 +90,14 @@ async function handleSessionPrompt( userId = (await userStore.getUserById(authorId))?.id; } if (userId) { - enrichment = (await resolveGitHubEnrichment(env, ctx.db, userStore, userId)) ?? undefined; + enrichment = + (await resolveGitHubEnrichmentForRequest( + env, + ctx.db, + userStore, + userId, + await resolveGitHubCredentialAuthority(ctx, request.headers) + )) ?? undefined; } } catch (e) { logger.warn("Failed to enrich prompt with GitHub identity", { diff --git a/packages/control-plane/src/routes/session-runtime-proxy.test.ts b/packages/control-plane/src/routes/session-runtime-proxy.test.ts index 717d87676..cbdef115a 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts @@ -12,13 +12,7 @@ function createCtx(): RequestContext { db: {} as SqlDatabase, principal: { kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: "user-1", - participantUserId: "user-1", - }, - tokenId: "token-1", + userId: "user-1", }, metrics: { d1Queries: [], diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index 5ef96adbe..d28c7b624 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -4,11 +4,12 @@ import { decodeRepositoryPathSegments } from "@open-inspect/shared"; import type { CorrelationContext } from "../logger"; -import type { Principal } from "../auth/principal"; +import type { AuthenticationContext, Principal } from "../auth/principal"; import type { RequestMetrics } from "../db/instrumented-d1"; import type { SqlDatabase } from "../db/sql-database"; import type { Env } from "../types"; import type { Logger } from "../logger"; +import type { BetterAuthRuntime } from "../auth/user/runtime"; import { createSourceControlProviderFromEnv, SourceControlProviderError, @@ -30,11 +31,15 @@ export type RequestContext = CorrelationContext & { db: SqlDatabase; /** Worker ExecutionContext for waitUntil (background tasks). */ executionCtx?: ExecutionContext; + /** Lazy runtime dependency used by user-session authentication and credential access. */ + getUserAuth?: () => BetterAuthRuntime; /** * The request's verified principal. Absent only on public routes and CORS * preflights — every authenticated request carries one. */ principal?: Principal; + /** Authentication provenance, separate from the principal being authorized. */ + authentication?: AuthenticationContext; }; /** diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 0c5813834..92de0686d 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -22,7 +22,7 @@ import { type TriggerConfig, } from "@open-inspect/shared"; import { z } from "zod"; -import { callbackSigningSecret } from "../auth/callback-signing"; +import { callbackSigningSecret } from "../auth/service/callback-signing"; import { AutomationStore, toAutomationRun, @@ -34,7 +34,6 @@ import { type AutomationRepositoryInsert, type AutomationEnvironmentRow, } from "../db/automation-store"; -import { ApiTokenStore } from "../db/api-tokens"; import { SlackChannelStore } from "../db/slack-channel-store"; import { buildSlackCompletionNotification, @@ -510,11 +509,7 @@ export class SchedulerDO extends DurableObject { // 1. Recovery sweep await this.recoverySweep(store); - // 2. Retention sweep: purge api_tokens rows long past expiry — nothing - // else ever deletes them (rotation mints 2 rows per user per period). - await this.apiTokenRetentionSweep(now); - - // 3. Process overdue automations, bounded by the per-tick child budget. + // 2. Process overdue automations, bounded by the per-tick child budget. const overdue = await store.getOverdueAutomations(now, MAX_PER_TICK); const [repositoriesByAutomation, environmentsByAutomation] = await Promise.all([ store.getRepositoriesForAutomationIds(overdue.map((automation) => automation.id)), @@ -596,25 +591,6 @@ export class SchedulerDO extends DurableObject { }); } - // ─── Retention sweep ───────────────────────────────────────────────────── - - private async apiTokenRetentionSweep(now: number): Promise { - try { - const deleted = await new ApiTokenStore(this.db).deleteExpired(now); - if (deleted > 0) { - this.log.info("Expired api_tokens rows purged", { - event: "scheduler.api_token_retention", - deleted, - }); - } - } catch (e) { - this.log.error("api_tokens retention sweep failed", { - event: "scheduler.api_token_retention_error", - error: e instanceof Error ? e.message : String(e), - }); - } - } - // ─── Recovery sweep ────────────────────────────────────────────────────── private async recoverySweep(store: AutomationStore): Promise { @@ -1238,7 +1214,7 @@ export class SchedulerDO extends DurableObject { // (handleCreateAutomation resolves it for both GitHub and Google users), so this // lookup is skipped for them. The fallback below only covers legacy rows with // user_id = NULL: those predate Google login and store the GitHub numeric user ID - // in created_by (from NextAuth session.user.id), so a github-only identity lookup + // in created_by (from the canonical browser principal), so a GitHub-only identity lookup // recovers the canonical user. It becomes dead code once legacy rows are backfilled. let userId = automation.user_id; if (!userId && automation.created_by && automation.created_by !== "anonymous") { diff --git a/packages/control-plane/src/session/callback-notification-service.ts b/packages/control-plane/src/session/callback-notification-service.ts index 74e8a13df..2b7598b44 100644 --- a/packages/control-plane/src/session/callback-notification-service.ts +++ b/packages/control-plane/src/session/callback-notification-service.ts @@ -8,7 +8,7 @@ */ import { computeHmacHex } from "@open-inspect/shared"; -import { callbackSigningSecret, type CallbackDestination } from "../auth/callback-signing"; +import { callbackSigningSecret, type CallbackDestination } from "../auth/service/callback-signing"; import type { Logger } from "../logger"; import { deliverWithRetry } from "./callback-delivery"; import { notifyLinearStarted } from "./linear-start-callback"; diff --git a/packages/control-plane/src/session/identity.test.ts b/packages/control-plane/src/session/identity.test.ts index 4816f41a8..25846a435 100644 --- a/packages/control-plane/src/session/identity.test.ts +++ b/packages/control-plane/src/session/identity.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { UserStore } from "../db/user-store"; import type { Env } from "../types"; -import { parseAuthorId, resolveGitAuthorIdentity, resolveGitHubEnrichment } from "./identity"; +import { + parseAuthorId, + resolveBrowserGitHubEnrichment, + resolveGitAuthorIdentity, + resolveGitHubEnrichment, +} from "./identity"; describe("resolveGitAuthorIdentity", () => { it("derives a canonical noreply author from a trusted GitHub id and login", () => { @@ -184,3 +189,84 @@ describe("resolveGitHubEnrichment", () => { expect(enrichment?.email).toBe("42+pm-dev@users.noreply.github.com"); }); }); + +describe("resolveBrowserGitHubEnrichment", () => { + const githubAccount = { + subject: "42", + }; + + it("gets a current Better Auth token and binds it to the verified GitHub profile", async () => { + const getAccessToken = vi.fn(async () => ({ + accessToken: "current-access-token", + accessTokenExpiresAt: new Date("2030-01-01T00:00:00.000Z"), + scopes: [], + })); + const getAccountInfo = vi.fn(async () => ({ + user: { + id: "42", + name: "Ada Lovelace", + email: "private@example.com", + emailVerified: true, + }, + data: { + provider: "github", + issuer: "https://github.com", + subject: "42", + login: "ada", + displayName: "Ada Lovelace", + verifiedEmails: ["private@example.com"], + primaryEmail: "private@example.com", + }, + })); + const encryptAccessToken = vi.fn(async () => "encrypted-current-access-token"); + + await expect( + resolveBrowserGitHubEnrichment("0123456789abcdef0123456789abcdef", githubAccount, { + getAccessToken, + getAccountInfo, + encryptAccessToken, + }) + ).resolves.toEqual({ + scmUserId: "42", + scmLogin: "ada", + displayName: "Ada Lovelace", + email: "42+ada@users.noreply.github.com", + accessTokenEncrypted: "encrypted-current-access-token", + tokenExpiresAt: new Date("2030-01-01T00:00:00.000Z").getTime(), + }); + + const accountSelection = { + providerId: "github", + accountId: "42", + userId: "0123456789abcdef0123456789abcdef", + }; + expect(getAccessToken).toHaveBeenCalledWith(accountSelection); + expect(getAccountInfo).toHaveBeenCalledWith(accountSelection); + expect(encryptAccessToken).toHaveBeenCalledWith("current-access-token"); + }); + + it("rejects provider profile substitution", async () => { + await expect( + resolveBrowserGitHubEnrichment("0123456789abcdef0123456789abcdef", githubAccount, { + getAccessToken: async () => ({ accessToken: "token" }), + getAccountInfo: async () => ({ + user: { + id: "7", + name: "Mallory", + email: "mallory@example.com", + emailVerified: true, + }, + data: { + provider: "github", + issuer: "https://github.com", + subject: "7", + login: "mallory", + verifiedEmails: ["mallory@example.com"], + primaryEmail: "mallory@example.com", + }, + }), + encryptAccessToken: async () => "encrypted", + }) + ).rejects.toThrow("Better Auth returned a mismatched GitHub account"); + }); +}); diff --git a/packages/control-plane/src/session/identity.ts b/packages/control-plane/src/session/identity.ts index 776e1d5f8..8048ee4c4 100644 --- a/packages/control-plane/src/session/identity.ts +++ b/packages/control-plane/src/session/identity.ts @@ -1,4 +1,10 @@ import { formatGitHubNoreplyEmail, githubLoginSchema } from "@open-inspect/shared"; +import { z } from "zod"; +import { encryptToken } from "../auth/crypto"; +import type { + GitHubAccountSelection, + GitHubCredentialAuthority, +} from "../source-control/github-credential-authority"; import { UserScmTokenStore } from "../db/user-scm-tokens"; import type { UserStore } from "../db/user-store"; import type { SourceControlProviderName } from "../source-control"; @@ -52,6 +58,84 @@ export interface GitHubEnrichment { tokenExpiresAt?: number; } +const browserAccessTokenSchema = z.object({ + accessToken: z.string().min(1), + accessTokenExpiresAt: z.coerce.date().optional(), +}); + +const browserGitHubAccountInfoSchema = z.object({ + user: z.object({ + id: z.string().min(1), + }), + data: z.object({ + provider: z.literal("github"), + issuer: z.literal("https://github.com"), + subject: z.string().min(1), + login: githubLoginSchema, + displayName: z.string().min(1).optional(), + verifiedEmails: z.array(z.string()), + primaryEmail: z.string().nullable(), + }), +}); + +export interface BrowserGitHubEnrichmentDependencies { + readonly getAccessToken: (selection: { + providerId: "github"; + accountId: string; + userId: string; + }) => Promise; + readonly getAccountInfo: (selection: { + providerId: "github"; + accountId: string; + userId: string; + }) => Promise; + readonly encryptAccessToken: (accessToken: string) => Promise; +} + +/** + * Resolve GitHub attribution and a current provider token from Better Auth. + * + * Better Auth owns refresh-token storage and rotation. Session state receives + * only a re-encrypted, currently valid access token; it never copies the + * long-lived refresh credential into a second store. + */ +export async function resolveBrowserGitHubEnrichment( + userId: string, + account: GitHubAccountSelection, + dependencies: BrowserGitHubEnrichmentDependencies +): Promise { + const selection = { + providerId: "github" as const, + accountId: account.subject, + userId, + }; + const token = browserAccessTokenSchema.parse(await dependencies.getAccessToken(selection)); + const profile = browserGitHubAccountInfoSchema.parse( + await dependencies.getAccountInfo(selection) + ); + if (profile.user.id !== account.subject || profile.data.subject !== account.subject) { + throw new Error("Better Auth returned a mismatched GitHub account"); + } + + const accessTokenEncrypted = await dependencies.encryptAccessToken(token.accessToken); + const author = resolveGitAuthorIdentity({ + scmProvider: "github", + scmUserId: profile.data.subject, + scmLogin: profile.data.login, + scmName: profile.data.displayName, + scmEmail: profile.data.primaryEmail, + }); + + return { + scmUserId: profile.data.subject, + scmLogin: profile.data.login, + displayName: profile.data.displayName ?? profile.data.login, + email: author?.email, + accessTokenEncrypted, + ...(token.accessTokenExpiresAt ? { tokenExpiresAt: token.accessTokenExpiresAt.getTime() } : {}), + }; +} + /** * Parse a bot-format authorId into provider + providerUserId. * Returns null for web client authorIds (plain user IDs without a prefix). @@ -106,3 +190,30 @@ export async function resolveGitHubEnrichment( tokenExpiresAt: tokens?.expiresAt, }; } + +/** + * Select the credential authority associated with the authenticated request. + * + * Browser sessions read/refresh through Better Auth. Bot identities retain + * their existing actor identity/token-store lookup. + */ +export async function resolveGitHubEnrichmentForRequest( + env: Env, + db: SqlDatabase, + userStore: UserStore, + userId: string, + authority: GitHubCredentialAuthority +): Promise { + if (authority.kind === "legacy") { + return resolveGitHubEnrichment(env, db, userStore, userId); + } + + const accountClient = authority.accountClient; + const githubAccount = authority.githubAccount; + if (!githubAccount) return null; + return resolveBrowserGitHubEnrichment(userId, githubAccount, { + getAccessToken: (selection) => accountClient.getAccessToken({ body: selection }), + getAccountInfo: (selection) => accountClient.accountInfo({ query: selection }), + encryptAccessToken: (accessToken) => encryptToken(accessToken, env.TOKEN_ENCRYPTION_KEY), + }); +} diff --git a/packages/control-plane/src/source-control/github-credential-authority.test.ts b/packages/control-plane/src/source-control/github-credential-authority.test.ts new file mode 100644 index 000000000..4a2c5b8c9 --- /dev/null +++ b/packages/control-plane/src/source-control/github-credential-authority.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import { + resolveGitHubCredentialAuthority, + type GitHubCredentialAuthorityContext, + type ProviderAccountClient, +} from "./github-credential-authority"; +import type { AuthenticationContext } from "../auth/principal"; + +const BROWSER_AUTHENTICATION: AuthenticationContext = { + mechanism: "browser_session", + credentialId: "session-1", + channel: { + kind: "sig1", + service: "web", + }, +}; + +const BROWSER_HEADERS = new Headers({ + Cookie: "openinspect.session_token=session-token", +}); + +function createContext( + overrides: Partial +): GitHubCredentialAuthorityContext { + return { + principal: { + kind: "service", + service: "modal", + actor: null, + }, + ...overrides, + }; +} + +function createUserContext(accounts: unknown[]) { + const listUserAccounts = vi.fn(async () => accounts); + const accountClient: ProviderAccountClient = { + listUserAccounts, + getAccessToken: vi.fn(async () => null), + accountInfo: vi.fn(async () => null), + }; + const runtime = { + api: accountClient, + }; + return { + context: createContext({ + principal: { kind: "user", userId: "user-1" }, + authentication: BROWSER_AUTHENTICATION, + getUserAuth: () => runtime, + }), + listUserAccounts, + accountClient, + }; +} + +describe("resolveGitHubCredentialAuthority", () => { + it("selects a linked GitHub account only when credential authority is requested", async () => { + const { context, listUserAccounts, accountClient } = createUserContext([ + { + providerId: "github", + accountId: "583231", + userId: "user-1", + }, + { + providerId: "google", + accountId: "google-subject", + userId: "user-1", + }, + ]); + + await expect(resolveGitHubCredentialAuthority(context, BROWSER_HEADERS)).resolves.toEqual({ + kind: "browser_session", + accountClient, + githubAccount: { subject: "583231" }, + }); + expect(listUserAccounts).toHaveBeenCalledWith({ headers: BROWSER_HEADERS }); + }); + + it("allows browser users without a linked GitHub account", async () => { + const { context, accountClient } = createUserContext([ + { + providerId: "google", + accountId: "google-subject", + userId: "user-1", + }, + ]); + + await expect(resolveGitHubCredentialAuthority(context, BROWSER_HEADERS)).resolves.toEqual({ + kind: "browser_session", + accountClient, + githubAccount: null, + }); + }); + + it("rejects cross-user GitHub account authority", async () => { + const { context } = createUserContext([ + { + providerId: "github", + accountId: "583231", + userId: "different-user", + }, + ]); + + await expect(resolveGitHubCredentialAuthority(context, BROWSER_HEADERS)).rejects.toThrow( + "GitHub account authority is corrupt" + ); + }); + + it("rejects multiple linked GitHub accounts", async () => { + const { context } = createUserContext([ + { providerId: "github", accountId: "583231", userId: "user-1" }, + { providerId: "github", accountId: "987654", userId: "user-1" }, + ]); + await expect(resolveGitHubCredentialAuthority(context, BROWSER_HEADERS)).rejects.toThrow( + "User resolves to multiple GitHub provider accounts" + ); + }); + + it("rejects a user principal without browser-session provenance", async () => { + await expect( + resolveGitHubCredentialAuthority( + createContext({ + principal: { kind: "user", userId: "user-1" }, + }), + BROWSER_HEADERS + ) + ).rejects.toThrow("User principal is missing browser-session provenance"); + }); + + it("uses the legacy credential authority only for non-browser principals", async () => { + await expect( + resolveGitHubCredentialAuthority(createContext({}), BROWSER_HEADERS) + ).resolves.toEqual({ kind: "legacy" }); + }); +}); diff --git a/packages/control-plane/src/source-control/github-credential-authority.ts b/packages/control-plane/src/source-control/github-credential-authority.ts new file mode 100644 index 000000000..e47118e8e --- /dev/null +++ b/packages/control-plane/src/source-control/github-credential-authority.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import type { AuthenticationContext, Principal } from "../auth/principal"; + +const providerAccountSchema = z.object({ + providerId: z.string().min(1), + accountId: z.string().min(1), + userId: z.string().min(1), +}); + +export interface GitHubAccountSelection { + readonly subject: string; +} + +interface ProviderAccountSelection { + readonly providerId: "github"; + readonly accountId: string; + readonly userId: string; +} + +export interface ProviderAccountClient { + listUserAccounts(input: { readonly headers: Headers }): Promise; + getAccessToken(input: { readonly body: ProviderAccountSelection }): Promise; + accountInfo(input: { readonly query: ProviderAccountSelection }): Promise; +} + +export type GitHubCredentialAuthority = + | { + readonly kind: "browser_session"; + readonly accountClient: ProviderAccountClient; + readonly githubAccount: GitHubAccountSelection | null; + } + | { + readonly kind: "legacy"; + }; + +export interface GitHubCredentialAuthorityContext { + readonly principal?: Principal; + readonly authentication?: AuthenticationContext; + readonly getUserAuth?: () => { readonly api: ProviderAccountClient }; +} + +/** + * Select the credential store associated with the verified principal. + * + * A browser user must never silently fall back to the legacy token store when + * its authentication provenance is missing. Linked GitHub accounts are + * enumerated here, only when an SCM workflow requests them; they are not part + * of browser-session authentication. Service actors are the only transitional + * callers that retain the legacy authority. + */ +export async function resolveGitHubCredentialAuthority( + context: GitHubCredentialAuthorityContext, + headers: Headers +): Promise { + if (!context.principal) { + throw new Error("Verified principal is unavailable"); + } + + if (context.principal.kind === "user") { + const userId = context.principal.userId; + if (!context.authentication) { + throw new Error("User principal is missing browser-session provenance"); + } + if (!context.getUserAuth) { + throw new Error("User authentication runtime is unavailable"); + } + const accountClient = context.getUserAuth().api; + const parsedAccounts = z + .array(providerAccountSchema) + .safeParse(await accountClient.listUserAccounts({ headers })); + if ( + !parsedAccounts.success || + parsedAccounts.data.some((account) => account.userId !== userId) + ) { + throw new Error("GitHub account authority is corrupt"); + } + const githubAccounts = parsedAccounts.data.filter((account) => account.providerId === "github"); + if (githubAccounts.length > 1) { + throw new Error("User resolves to multiple GitHub provider accounts"); + } + return { + kind: "browser_session", + accountClient, + githubAccount: githubAccounts[0] ? { subject: githubAccounts[0].accountId } : null, + }; + } + + if (context.authentication) { + throw new Error("Non-user principal cannot carry browser-session provenance"); + } + return { kind: "legacy" }; +} diff --git a/packages/control-plane/src/types.ts b/packages/control-plane/src/types.ts index cae32b2c9..7244c5fd9 100644 --- a/packages/control-plane/src/types.ts +++ b/packages/control-plane/src/types.ts @@ -59,6 +59,9 @@ export interface Env { // Secrets GITHUB_CLIENT_ID?: string; GITHUB_CLIENT_SECRET?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; + BROWSER_AUTH_SECRET?: string; TOKEN_ENCRYPTION_KEY: string; REPO_SECRETS_ENCRYPTION_KEY?: string; MODAL_TOKEN_ID?: string; @@ -94,6 +97,11 @@ export interface Env { SCM_PROVIDER?: string; // Source control provider for this deployment (default: github) WORKER_URL?: string; // Base URL for the worker (for callbacks) WEB_APP_URL?: string; // Base URL for the web app (for PR links) + ALLOWED_USERS?: string; + ALLOWED_EMAIL_DOMAINS?: string; + ALLOWED_EMAILS?: string; + ALLOWED_GITHUB_ORGS?: string; + UNSAFE_ALLOW_ALL_USERS?: string; CF_ACCOUNT_ID?: string; // Cloudflare account ID SANDBOX_PROVIDER?: string; // "modal" (default), "daytona", "vercel", "opencomputer", or "e2b" MODAL_WORKSPACE?: string; // Modal workspace name diff --git a/packages/control-plane/src/types/error.d.ts b/packages/control-plane/src/types/error.d.ts index d4c03a8ec..98a1afc6a 100644 --- a/packages/control-plane/src/types/error.d.ts +++ b/packages/control-plane/src/types/error.d.ts @@ -1,9 +1,9 @@ // workerd (V8) provides Error.captureStackTrace at runtime, but // @cloudflare/workers-types no longer declares it. Declare just this optional -// API rather than adding @types/node to the production type surface: this -// worker has no `nodejs_compat`, so keeping node:*/process/Buffer untyped makes -// accidental Node usage in worker code a typecheck error instead of a runtime -// failure (see tsconfig.json). +// API rather than adding @types/node to the production type surface. The +// runtime enables `nodejs_compat` for audited dependencies, while keeping +// node:*/process/Buffer untyped here makes accidental application-level Node +// usage a typecheck error (see tsconfig.json). interface ErrorConstructor { captureStackTrace?( targetObject: object, diff --git a/packages/control-plane/src/worker-build.test.ts b/packages/control-plane/src/worker-build.test.ts new file mode 100644 index 000000000..42f7a59c4 --- /dev/null +++ b/packages/control-plane/src/worker-build.test.ts @@ -0,0 +1,32 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const packageDirectory = fileURLToPath(new URL("..", import.meta.url)); +const repositoryDirectory = fileURLToPath(new URL("../../..", import.meta.url)); +const WORKER_BUILD_TIMEOUT_MS = 60_000; + +describe("control-plane worker build", () => { + it( + "uses the workerd AsyncLocalStorage implementation", + () => { + execFileSync("npm", ["run", "build", "-w", "@open-inspect/shared"], { + cwd: repositoryDirectory, + stdio: "pipe", + }); + execFileSync("npm", ["run", "build"], { + cwd: packageDirectory, + stdio: "pipe", + }); + + const bundle = readFileSync(new URL("../dist/index.js", import.meta.url), "utf8"); + + expect(bundle.includes('"node:async_hooks"')).toBe(true); + expect(bundle.includes("AsyncLocalStoragePolyfill")).toBe(false); + expect(bundle.includes("@opentelemetry/semantic-conventions/build/esm/")).toBe(true); + expect(bundle.includes("@opentelemetry/semantic-conventions/build/src/")).toBe(false); + }, + WORKER_BUILD_TIMEOUT_MS + ); +}); diff --git a/packages/control-plane/test/integration/auth-tokens.test.ts b/packages/control-plane/test/integration/auth-tokens.test.ts deleted file mode 100644 index a195d5c85..000000000 --- a/packages/control-plane/test/integration/auth-tokens.test.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SELF, env } from "cloudflare:test"; -import { - buildServiceAuthHeaders, - generateInternalToken, - type ServiceName, -} from "@open-inspect/shared"; -import { - ApiTokenStore, - EXPIRED_TOKEN_RETENTION_MS, - type NewApiToken, -} from "../../src/db/api-tokens"; -import { REFRESH_REUSE_GRACE_MS } from "../../src/auth/web-session-tokens"; -import { UserStore } from "../../src/db/user-store"; -import { cleanD1Tables } from "./cleanup"; - -const originalFetch = globalThis.fetch; - -interface ProviderMockState { - githubUserStatus: number; - githubEmailsStatus: number; - githubEmailsBody: unknown; - googleStatus: number; -} - -const providerMock: ProviderMockState = { - githubUserStatus: 200, - githubEmailsStatus: 200, - githubEmailsBody: [{ email: "octocat@example.com", primary: true, verified: true }], - googleStatus: 200, -}; - -function installProviderFetchMock(): void { - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - - if (url === "https://api.github.com/user") { - if (providerMock.githubUserStatus !== 200) { - return Response.json({ message: "nope" }, { status: providerMock.githubUserStatus }); - } - return Response.json({ - id: 583231, - login: "octocat", - // No public profile email — the exchange must resolve the verified - // primary from /user/emails, matching the web sign-in flow. - email: null, - name: "The Octocat", - avatar_url: "https://avatars.example/octocat", - }); - } - if (url === "https://api.github.com/user/emails") { - if (providerMock.githubEmailsStatus !== 200) { - return Response.json({ message: "nope" }, { status: providerMock.githubEmailsStatus }); - } - return Response.json(providerMock.githubEmailsBody); - } - if (url === "https://openidconnect.googleapis.com/v1/userinfo") { - if (providerMock.googleStatus !== 200) { - return Response.json({ error: "invalid_token" }, { status: providerMock.googleStatus }); - } - return Response.json({ - sub: "1078462347", - email: "person@example.com", - email_verified: true, - name: "A Person", - }); - } - return originalFetch(input, init); - }) - ); -} - -async function serviceFetch(p: { - service?: ServiceName; - path: string; - body: unknown; -}): Promise { - const service = p.service ?? "web"; - const url = `https://test.local${p.path}`; - const body = JSON.stringify(p.body); - const headers = await buildServiceAuthHeaders({ - service, - secret: `test-service-secret-${service}`, - method: "POST", - url, - body, - }); - return SELF.fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json", ...headers }, - body, - }); -} - -interface TokenPair { - accessToken: string; - accessTokenExpiresAtEpochMs: number; - refreshToken: string; - refreshTokenExpiresAtEpochMs: number; -} - -async function exchangeGitHub(): Promise { - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { - subjectTokenType: "github-access-token", - subjectToken: "gho_valid", - scmRefreshToken: "ghr_refresh", - scmTokenExpiresAt: Date.now() + 60_000, - }, - }); - expect(response.status).toBe(200); - return response.json(); -} - -async function expectNoDurableAuthState(): Promise { - for (const table of ["users", "user_identities", "user_scm_tokens", "api_tokens"]) { - const count = await env.DB.prepare(`SELECT COUNT(*) AS n FROM ${table}`).first<{ - n: number; - }>(); - expect(count?.n, table).toBe(0); - } -} - -describe("token exchange and refresh grant", () => { - beforeEach(async () => { - await cleanD1Tables(); - providerMock.githubUserStatus = 200; - providerMock.githubEmailsStatus = 200; - providerMock.githubEmailsBody = [ - { email: "octocat@example.com", primary: true, verified: true }, - ]; - providerMock.googleStatus = 200; - installProviderFetchMock(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("exchanges a GitHub subject: verified identity, canonical user, SCM capture, token pair", async () => { - const pair = await exchangeGitHub(); - expect(pair.accessToken).toMatch(/^oi_at_/); - expect(pair.refreshToken).toMatch(/^oi_rt_/); - expect(pair.accessTokenExpiresAtEpochMs).toBeGreaterThan(Date.now()); - - // Canonical user created from the VERIFIED identity (id 583231), not any asserted field. - const identity = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(identity).not.toBeNull(); - expect(identity!.providerLogin).toBe("octocat"); - - // SCM tokens captured under the verified provider id. - const scmRow = await env.DB.prepare( - "SELECT user_id FROM user_scm_tokens WHERE provider_user_id = ?" - ) - .bind("583231") - .first<{ user_id: string }>(); - expect(scmRow?.user_id).toBe(identity!.userId); - }); - - it("authenticates CP requests with the minted access token (user principal end-to-end)", async () => { - const pair = await exchangeGitHub(); - const response = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${pair.accessToken}` }, - }); - expect(response.status).toBe(200); - }); - - it("never re-links a user principal from providerEmail supplied to the identity route", async () => { - const victim = await new UserStore(env.DB).createUser({ - displayName: "Victim", - email: "victim@example.com", - avatarUrl: null, - }); - const pair = await exchangeGitHub(); - const before = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(before).not.toBeNull(); - expect(before!.userId).not.toBe(victim.id); - - const response = await SELF.fetch("https://test.local/provider-identities/github/583231", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify({ providerEmail: "victim@example.com" }), - }); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ userId: before!.userId }); - const after = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(after?.userId).toBe(before!.userId); - }); - - it("forbids a user token from resolving a different provider identity", async () => { - const pair = await exchangeGitHub(); - - const response = await SELF.fetch("https://test.local/provider-identities/github/999999", { - method: "PUT", - headers: { Authorization: `Bearer ${pair.accessToken}` }, - }); - - expect(response.status).toBe(403); - expect(await response.json()).toEqual({ - error: "Path identity does not match the authenticated user", - }); - }); - - it("persists session ownership from the user token rather than the request body", async () => { - const pair = await exchangeGitHub(); - const identity = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(identity).not.toBeNull(); - - const created = await SELF.fetch("https://test.local/sessions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify({ - title: "Token-owned session", - model: "anthropic/claude-haiku-4-5", - }), - }); - expect(created.status).toBe(201); - - const listed = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${pair.accessToken}` }, - }); - expect(listed.status).toBe(200); - const body = await listed.json<{ sessions: Array<{ title: string; userId: string }> }>(); - expect(body.sessions).toContainEqual( - expect.objectContaining({ - title: "Token-owned session", - userId: identity!.userId, - }) - ); - }); - - it("rejects caller-supplied session identity before creating durable state", async () => { - const pair = await exchangeGitHub(); - - const created = await SELF.fetch("https://test.local/sessions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify({ - title: "Forged owner", - model: "anthropic/claude-haiku-4-5", - userId: "victim-user-id", - }), - }); - - expect(created.status).toBe(400); - expect(await created.json()).toEqual({ - error: "Field 'userId' is not accepted from verified callers", - }); - const sessionCount = await env.DB.prepare("SELECT COUNT(*) AS n FROM sessions").first<{ - n: number; - }>(); - expect(sessionCount?.n).toBe(0); - }); - - it("exchanges a Google subject without SCM capture", async () => { - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { subjectTokenType: "google-access-token", subjectToken: "ya29.valid" }, - }); - expect(response.status).toBe(200); - const identity = await new UserStore(env.DB).getIdentity("google", "1078462347"); - expect(identity).not.toBeNull(); - const scmCount = await env.DB.prepare("SELECT COUNT(*) AS n FROM user_scm_tokens").first<{ - n: number; - }>(); - expect(scmCount?.n).toBe(0); - }); - - it("returns the same canonical user across repeated exchanges", async () => { - await exchangeGitHub(); - await exchangeGitHub(); - const users = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); - expect(users?.n).toBe(1); - }); - - it("links a GitHub exchange with no public email to the email owner and mints the family there", async () => { - // A canonical user already owns the email (e.g. a prior Google sign-in). - const existing = await new UserStore(env.DB).createUser({ - displayName: "Octo", - email: "octocat@example.com", - avatarUrl: null, - }); - - // GitHub /user.email is null; the verified primary resolved from - // /user/emails must link this exchange to `existing` instead of forking a - // second canonical user and stranding the 90-day family on the orphan. - await exchangeGitHub(); - - const users = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); - expect(users?.n).toBe(1); - - const identity = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(identity?.userId).toBe(existing.id); - - // The minted token family is attached to the existing user, not an orphan. - const familyRow = await env.DB.prepare( - "SELECT DISTINCT user_id FROM api_tokens WHERE kind = 'web_session'" - ).all<{ user_id: string }>(); - expect(familyRow.results.map((r) => r.user_id)).toEqual([existing.id]); - }); - - it("fails closed without durable identity state when GitHub email evidence is transiently unavailable", async () => { - providerMock.githubEmailsStatus = 500; - - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { - subjectTokenType: "github-access-token", - subjectToken: "gho_valid", - scmRefreshToken: "ghr_refresh", - }, - }); - - expect(response.status).toBe(502); - expect(await response.json()).toEqual({ error: "provider_unavailable" }); - await expectNoDurableAuthState(); - }); - - it("fails closed without durable identity state on malformed GitHub email evidence", async () => { - providerMock.githubEmailsBody = { email: "octocat@example.com" }; - - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { - subjectTokenType: "github-access-token", - subjectToken: "gho_valid", - scmRefreshToken: "ghr_refresh", - }, - }); - - expect(response.status).toBe(502); - expect(await response.json()).toEqual({ error: "provider_unavailable" }); - await expectNoDurableAuthState(); - }); - - it("rotates via the refresh grant; immediate replay is rejected without revoking the family", async () => { - const first = await exchangeGitHub(); - - const refreshResponse = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }); - expect(refreshResponse.status).toBe(200); - const second = await refreshResponse.json(); - expect(second.accessToken).not.toBe(first.accessToken); - - // The rotated pair works. - const ok = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${second.accessToken}` }, - }); - expect(ok.status).toBe(200); - - // Immediate replay of the consumed token = benign concurrent renewal: - // superseded (NOT a dead grant), family left alive (grace window). - // Post-grace replay revokes the family — covered by the service unit - // tests. - const replay = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }); - expect(replay.status).toBe(401); - expect(await replay.json()).toMatchObject({ error: "refresh_superseded" }); - - const stillValid = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${second.accessToken}` }, - }); - expect(stillValid.status).toBe(200); - }); - - it("returns one winner when two refresh requests overlap", async () => { - const first = await exchangeGitHub(); - const requests = await Promise.all([ - serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }), - serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }), - ]); - - expect(requests.map((response) => response.status).sort()).toEqual([200, 401]); - const winnerResponse = requests.find((response) => response.status === 200)!; - const loserResponse = requests.find((response) => response.status === 401)!; - const winner = await winnerResponse.json(); - expect(await loserResponse.json()).toEqual({ error: "refresh_superseded" }); - - const winnerAccess = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${winner.accessToken}` }, - }); - expect(winnerAccess.status).toBe(200); - - const liveRefreshLeaves = await env.DB.prepare( - `SELECT COUNT(*) AS n FROM api_tokens - WHERE kind = 'web_session_refresh' AND rotated_to IS NULL AND revoked_at IS NULL` - ).first<{ n: number }>(); - expect(liveRefreshLeaves?.n).toBe(1); - }); - - it("revokes the real D1 family when a consumed refresh token is replayed after grace", async () => { - const first = await exchangeGitHub(); - const rotated = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }); - expect(rotated.status).toBe(200); - const second = await rotated.json(); - - const ancestor = await env.DB.prepare( - "SELECT family_id, rotated_to FROM api_tokens WHERE kind = 'web_session_refresh' AND rotated_to IS NOT NULL" - ).first<{ family_id: string; rotated_to: string }>(); - expect(ancestor).not.toBeNull(); - await env.DB.prepare("UPDATE api_tokens SET created_at = ? WHERE id = ?") - .bind(Date.now() - REFRESH_REUSE_GRACE_MS - 1000, ancestor!.rotated_to) - .run(); - - const replay = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }); - expect(replay.status).toBe(401); - expect(await replay.json()).toEqual({ error: "refresh_reuse_detected" }); - - const winnerAccess = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${second.accessToken}` }, - }); - expect(winnerAccess.status).toBe(401); - - const familyRows = await env.DB.prepare("SELECT revoked_at FROM api_tokens WHERE family_id = ?") - .bind(ancestor!.family_id) - .all<{ revoked_at: number | null }>(); - expect(familyRows.results.length).toBeGreaterThan(0); - expect(familyRows.results.every((row) => row.revoked_at !== null)).toBe(true); - }); - - it("rejects invalid refresh tokens", async () => { - const response = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: "oi_rt_never_issued" }, - }); - expect(response.status).toBe(401); - expect(await response.json()).toMatchObject({ error: "invalid_refresh_token" }); - }); - - it("forbids refresh to every principal except the web service", async () => { - const pair = await exchangeGitHub(); - const body = { refreshToken: pair.refreshToken }; - - for (const service of ["slack-bot", "github-bot", "linear-bot", "modal"] as const) { - const response = await serviceFetch({ - service, - path: "/auth/tokens/refresh", - body, - }); - expect(response.status, service).toBe(403); - } - - const asUser = await SELF.fetch("https://test.local/auth/tokens/refresh", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify(body), - }); - expect(asUser.status).toBe(403); - - const unauthenticated = await SELF.fetch("https://test.local/auth/tokens/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - expect(unauthenticated.status).toBe(401); - - const sharedToken = await generateInternalToken("test-hmac-secret-for-integration-tests"); - const asSharedBearer = await SELF.fetch("https://test.local/auth/tokens/refresh", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sharedToken}`, - }, - body: JSON.stringify(body), - }); - expect(asSharedBearer.status).toBe(401); - - const asWeb = await serviceFetch({ - path: "/auth/tokens/refresh", - body, - }); - expect(asWeb.status).toBe(200); - }); - - it("maps provider rejection to subject_rejected and provider outage to provider_unavailable", async () => { - providerMock.githubUserStatus = 401; - const rejected = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { subjectTokenType: "github-access-token", subjectToken: "gho_bad" }, - }); - expect(rejected.status).toBe(401); - expect(await rejected.json()).toMatchObject({ error: "subject_rejected" }); - - providerMock.githubUserStatus = 500; - const unavailable = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { subjectTokenType: "github-access-token", subjectToken: "gho_any" }, - }); - expect(unavailable.status).toBe(502); - expect(await unavailable.json()).toMatchObject({ error: "provider_unavailable" }); - - // Fail closed: no user, no tokens. - const users = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); - expect(users?.n).toBe(0); - }); - - it("rejects malformed exchange bodies", async () => { - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { subjectTokenType: "github-access-token", subjectToken: "", extra: true }, - }); - expect(response.status).toBe(400); - expect(await response.json()).toMatchObject({ error: "invalid_request" }); - }); - - it("forbids exchange to any principal but web's service credential", async () => { - const bodies = { - subjectTokenType: "github-access-token", - subjectToken: "gho_valid", - }; - - const asSlackBot = await serviceFetch({ - service: "slack-bot", - path: "/auth/tokens/exchange", - body: bodies, - }); - expect(asSlackBot.status).toBe(403); - - // The retired shared bearer no longer authenticates at all — rejected at - // the edge (401), before the route's 403 gate is even reached. - const sharedToken = await generateInternalToken("test-hmac-secret-for-integration-tests"); - const asSharedBearer = await SELF.fetch("https://test.local/auth/tokens/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sharedToken}`, - }, - body: JSON.stringify(bodies), - }); - expect(asSharedBearer.status).toBe(401); - - // A minted user token cannot mint further tokens either. - const pair = await exchangeGitHub(); - const asUser = await SELF.fetch("https://test.local/auth/tokens/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify(bodies), - }); - expect(asUser.status).toBe(403); - }); -}); - -describe("api_tokens retention sweep", () => { - beforeEach(async () => { - await cleanD1Tables(); - }); - - function newToken(suffix: string, expiresAt: number): NewApiToken { - return { - tokenHash: `hash-${suffix}`, - kind: "web_session", - userId: "user-1", - provider: "github", - providerUserId: "583231", - familyId: `family-${suffix}`, - expiresAt, - familyExpiresAt: null, - }; - } - - it("deletes only rows past the retention window", async () => { - const store = new ApiTokenStore(env.DB); - const now = Date.now(); - // One pair long past expiry, one expired but within retention, one live. - await store.createPair([ - newToken("stale-a", now - EXPIRED_TOKEN_RETENTION_MS - 60_000), - newToken("stale-b", now - EXPIRED_TOKEN_RETENTION_MS - 60_000), - ]); - await store.createPair([newToken("recent-a", now - 60_000), newToken("live-a", now + 60_000)]); - - expect(await store.deleteExpired(now)).toBe(2); - - const remaining = await env.DB.prepare("SELECT token_hash FROM api_tokens").all<{ - token_hash: string; - }>(); - expect(remaining.results.map((r) => r.token_hash).sort()).toEqual([ - "hash-live-a", - "hash-recent-a", - ]); - }); - - it("retains family-scoped refresh rows until the family expires", async () => { - const store = new ApiTokenStore(env.DB); - const now = Date.now(); - const longPast = now - EXPIRED_TOKEN_RETENTION_MS - 60_000; - // Both rows are long past their own expiry; only the dead family's row - // may go — a consumed ancestor in a live family must survive so its - // replay still reads as reuse instead of an unknown token. - await store.createPair([ - { - ...newToken("live-family", longPast), - kind: "web_session_refresh", - familyExpiresAt: now + 60_000, - }, - { - ...newToken("dead-family", longPast), - kind: "web_session_refresh", - familyExpiresAt: longPast, - }, - ]); - - expect(await store.deleteExpired(now)).toBe(1); - - const remaining = await env.DB.prepare("SELECT token_hash FROM api_tokens").all<{ - token_hash: string; - }>(); - expect(remaining.results.map((r) => r.token_hash)).toEqual(["hash-live-family"]); - }); - - it("admits exactly one successor when refresh consumers race in D1", async () => { - const store = new ApiTokenStore(env.DB); - const now = Date.now(); - const [, refreshId] = await store.createPair([ - newToken("race-access", now + 60_000), - { - ...newToken("race-refresh", now + 60_000), - kind: "web_session_refresh", - familyId: "family-race", - familyExpiresAt: now + 120_000, - }, - ]); - - const outcomes = await Promise.all([ - store.consumeRefreshToken(refreshId, "successor-a"), - store.consumeRefreshToken(refreshId, "successor-b"), - ]); - - expect(outcomes.filter(Boolean)).toHaveLength(1); - expect((await store.getById(refreshId))?.rotatedTo).toBe( - outcomes[0] ? "successor-a" : "successor-b" - ); - }); -}); diff --git a/packages/control-plane/test/integration/auth.test.ts b/packages/control-plane/test/integration/auth.test.ts index 8e87aa8ad..69a864fa2 100644 --- a/packages/control-plane/test/integration/auth.test.ts +++ b/packages/control-plane/test/integration/auth.test.ts @@ -30,7 +30,7 @@ describe("Edge authentication", () => { expect(response.status).toBe(401); }); - it("accepts a service-signed request and returns the session list", async () => { + it("accepts a compound browser request and returns the session list", async () => { const response = await serviceFetch("https://test.local/sessions"); expect(response.status).toBe(200); const body = await response.json<{ sessions: unknown[]; hasMore: boolean }>(); diff --git a/packages/control-plane/test/integration/browser-auth-callback.test.ts b/packages/control-plane/test/integration/browser-auth-callback.test.ts new file mode 100644 index 000000000..5259d9921 --- /dev/null +++ b/packages/control-plane/test/integration/browser-auth-callback.test.ts @@ -0,0 +1,368 @@ +import { env } from "cloudflare:test"; +import { buildServiceAuthHeaders, isCanonicalUserId } from "@open-inspect/shared"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getUserAuth } from "../../src/auth/user/runtime"; +import { resolveGitHubCredentialAuthority } from "../../src/source-control/github-credential-authority"; +import { decryptToken } from "../../src/auth/crypto"; +import { UserStore } from "../../src/db/user-store"; +import { handleRequest } from "../../src/router"; +import { resolveGitHubEnrichmentForRequest } from "../../src/session/identity"; +import { cleanD1Tables } from "./cleanup"; + +const CONTROL_PLANE_ORIGIN = "https://control-plane.test.local"; +const PUBLIC_WEB_ORIGIN = "https://app.test.local"; +const WEB_SERVICE_SECRET = "test-service-secret-web"; + +async function signedWebRequest( + path: string, + init: { + method: "GET" | "POST"; + body?: string; + cookie?: string; + } +): Promise { + const url = `${CONTROL_PLANE_ORIGIN}${path}`; + return new Request(url, { + method: init.method, + headers: { + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...(init.cookie ? { Cookie: init.cookie } : {}), + Origin: PUBLIC_WEB_ORIGIN, + ...(await buildServiceAuthHeaders({ + service: "web", + secret: WEB_SERVICE_SECRET, + method: init.method, + url, + body: init.body, + })), + }, + body: init.body, + }); +} + +function cookiePair(response: Response, cookieName: string): string { + const cookie = response.headers + .getSetCookie() + .find((value) => value.startsWith(`${cookieName}=`)); + if (!cookie) throw new Error(`Missing ${cookieName} cookie`); + return cookie.split(";", 1)[0]; +} + +beforeAll(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://github.com/login/oauth/access_token") { + return Response.json({ + access_token: "github-access-token", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "github-refresh-token", + refresh_token_expires_in: 15_897_600, + }); + } + if (url === "https://api.github.com/user") { + return Response.json({ + id: 583_231, + login: "octocat", + name: "The Octocat", + avatar_url: "https://avatars.example/octocat", + }); + } + if (url.startsWith("https://api.github.com/user/emails")) { + return Response.json([ + { + email: "octocat@example.com", + primary: true, + verified: true, + visibility: "private", + }, + ]); + } + throw new Error(`Unexpected external request: ${url}`); + }); +}); + +beforeEach(cleanD1Tables); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe("browser auth callback", () => { + it("creates and resolves a GitHub browser session through the signed proxy", async () => { + const initiationBody = JSON.stringify({ + provider: "github", + callbackURL: "/after-sign-in", + disableRedirect: true, + }); + const initiationResponse = await handleRequest( + await signedWebRequest("/api/auth/sign-in/social", { + method: "POST", + body: initiationBody, + }), + env + ); + expect(initiationResponse.status).toBe(200); + const providerUrl = new URL((await initiationResponse.json<{ url: string }>()).url); + const state = providerUrl.searchParams.get("state"); + expect(state).toBeTruthy(); + const stateCookie = cookiePair(initiationResponse, "__Secure-openinspect.state"); + + const callbackResponse = await handleRequest( + await signedWebRequest( + `/api/auth/callback/github?code=authorization-code&state=${encodeURIComponent(state ?? "")}`, + { + method: "GET", + cookie: stateCookie, + } + ), + env + ); + + expect(callbackResponse.status).toBe(302); + expect(callbackResponse.headers.get("Location")).toBe("/after-sign-in"); + expect( + callbackResponse.headers + .getSetCookie() + .some((cookie) => cookie.startsWith("__Secure-openinspect.state=")) + ).toBe(true); + const sessionCookie = cookiePair(callbackResponse, "__Secure-openinspect.session_token"); + + const sessionResponse = await handleRequest( + await signedWebRequest("/api/auth/get-session", { + method: "GET", + cookie: sessionCookie, + }), + env + ); + + expect(sessionResponse.status).toBe(200); + const session = await sessionResponse.json<{ + user: { id: string; name: string; email: string }; + session: { id: string; userId: string }; + }>(); + expect(isCanonicalUserId(session.user.id)).toBe(true); + expect(session).toMatchObject({ + user: { + id: expect.any(String), + name: "The Octocat", + email: "octocat@example.com", + }, + session: { + userId: expect.any(String), + }, + }); + + const account = await env.DB.prepare( + `SELECT id + FROM auth_accounts + WHERE userId = ?` + ) + .bind(session.user.id) + .first<{ id: string }>(); + expect(account).not.toBeNull(); + + const enrichment = await resolveGitHubEnrichmentForRequest( + env, + env.DB, + new UserStore(env.DB), + session.user.id, + await resolveGitHubCredentialAuthority( + { + principal: { kind: "user", userId: session.user.id }, + authentication: { + mechanism: "browser_session", + credentialId: session.session.id, + channel: { kind: "sig1", service: "web" }, + }, + getUserAuth: () => getUserAuth(env, env.DB), + }, + new Headers({ Cookie: sessionCookie }) + ) + ); + expect(enrichment).toMatchObject({ + scmUserId: "583231", + scmLogin: "octocat", + email: "583231+octocat@users.noreply.github.com", + accessTokenEncrypted: expect.any(String), + }); + await expect( + decryptToken(enrichment?.accessTokenEncrypted ?? "", env.TOKEN_ENCRYPTION_KEY) + ).resolves.toBe("github-access-token"); + + await expect( + env.DB.prepare( + `SELECT id, display_name, email, avatar_url + FROM users + WHERE id = ?` + ) + .bind(session.user.id) + .first() + ).resolves.toEqual({ + id: session.user.id, + display_name: "The Octocat", + email: "octocat@example.com", + avatar_url: "https://avatars.example/octocat", + }); + + const resourceResponse = await handleRequest( + await signedWebRequest("/model-preferences", { + method: "GET", + cookie: sessionCookie, + }), + env + ); + expect(resourceResponse.status).toBe(200); + + const channelOnlyResponse = await handleRequest( + await signedWebRequest("/model-preferences", { + method: "GET", + }), + env + ); + expect(channelOnlyResponse.status).toBe(401); + }); + + it("signs an existing canonical user in through a migrated GitHub account", async () => { + const canonicalUserId = "11111111111111111111111111111111"; + const providerIdentityId = "22222222222222222222222222222222"; + const now = new Date("2026-07-26T21:47:56.000Z"); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ).bind( + canonicalUserId, + "Legacy User", + "octocat@example.com", + null, + now.getTime(), + now.getTime() + ), + env.DB.prepare( + `INSERT INTO user_identities ( + id, user_id, provider, provider_user_id, provider_login, + provider_email, created_at, provider_issuer + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + providerIdentityId, + canonicalUserId, + "github", + "583231", + "octocat", + "octocat@example.com", + now.getTime(), + "https://github.com" + ), + env.DB.prepare( + `INSERT INTO auth_users ( + id, name, email, emailVerified, image, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ).bind( + canonicalUserId, + "Legacy User", + "octocat@example.com", + 0, + null, + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT INTO auth_accounts ( + id, accountId, providerId, userId, accessToken, refreshToken, + idToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope, + password, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?)` + ).bind( + providerIdentityId, + "583231", + "github", + canonicalUserId, + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT INTO auth_accounts ( + id, accountId, providerId, userId, accessToken, refreshToken, + idToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope, + password, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?)` + ).bind( + "33333333333333333333333333333333", + "google-subject", + "google", + canonicalUserId, + now.toISOString(), + now.toISOString() + ), + ]); + + const initiationBody = JSON.stringify({ + provider: "github", + callbackURL: "/after-sign-in", + disableRedirect: true, + }); + const initiationResponse = await handleRequest( + await signedWebRequest("/api/auth/sign-in/social", { + method: "POST", + body: initiationBody, + }), + env + ); + const providerUrl = new URL((await initiationResponse.json<{ url: string }>()).url); + const state = providerUrl.searchParams.get("state"); + const stateCookie = cookiePair(initiationResponse, "__Secure-openinspect.state"); + + const callbackResponse = await handleRequest( + await signedWebRequest( + `/api/auth/callback/github?code=authorization-code&state=${encodeURIComponent(state ?? "")}`, + { + method: "GET", + cookie: stateCookie, + } + ), + env + ); + + expect(callbackResponse.status).toBe(302); + const sessionCookie = cookiePair(callbackResponse, "__Secure-openinspect.session_token"); + const sessionResponse = await handleRequest( + await signedWebRequest("/api/auth/get-session", { + method: "GET", + cookie: sessionCookie, + }), + env + ); + expect(await sessionResponse.json<{ user: { id: string } }>()).toMatchObject({ + user: { id: canonicalUserId }, + }); + expect( + await env.DB.prepare( + `SELECT COUNT(*) AS count + FROM users + WHERE email = ?` + ) + .bind("octocat@example.com") + .first<{ count: number }>() + ).toEqual({ count: 1 }); + expect( + await env.DB.prepare( + `SELECT emailVerified + FROM auth_users + WHERE id = ?` + ) + .bind(canonicalUserId) + .first<{ emailVerified: number }>() + ).toEqual({ emailVerified: 1 }); + + const resourceResponse = await handleRequest( + await signedWebRequest("/model-preferences", { + method: "GET", + cookie: sessionCookie, + }), + env + ); + expect(resourceResponse.status).toBe(200); + }); +}); diff --git a/packages/control-plane/test/integration/browser-auth-router.test.ts b/packages/control-plane/test/integration/browser-auth-router.test.ts new file mode 100644 index 000000000..98b3d77e6 --- /dev/null +++ b/packages/control-plane/test/integration/browser-auth-router.test.ts @@ -0,0 +1,114 @@ +import { env } from "cloudflare:test"; +import { buildServiceAuthHeaders, type ServiceName } from "@open-inspect/shared"; +import { describe, expect, it } from "vitest"; +import { handleRequest } from "../../src/router"; +import type { Env } from "../../src/types"; + +const CONTROL_PLANE_ORIGIN = "https://control-plane.test.local"; +const PUBLIC_WEB_ORIGIN = "https://app.test.local"; +const WEB_SERVICE_SECRET = "test-service-secret-web"; + +async function signedServiceRequest( + path: string, + body: unknown, + service: ServiceName = "web", + secret = WEB_SERVICE_SECRET +): Promise { + const url = `${CONTROL_PLANE_ORIGIN}${path}`; + const serializedBody = JSON.stringify(body); + return new Request(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + ...(await buildServiceAuthHeaders({ + service, + secret, + method: "POST", + url, + body: serializedBody, + })), + }, + body: serializedBody, + }); +} + +describe("browser auth router", () => { + it("accepts a signed web-channel request on the social sign-in endpoint", async () => { + const request = await signedServiceRequest("/api/auth/sign-in/social", { + provider: "github", + callbackURL: "/", + disableRedirect: true, + }); + + const response = await handleRequest(request, env); + + expect(response.status).toBe(200); + const body = await response.json<{ url: string }>(); + const providerUrl = new URL(body.url); + expect(providerUrl.origin).toBe("https://github.com"); + expect(providerUrl.searchParams.get("redirect_uri")).toBe( + `${PUBLIC_WEB_ORIGIN}/api/auth/callback/github` + ); + }); + + it("keeps browser authentication available on GitLab deployments", async () => { + const request = await signedServiceRequest("/api/auth/sign-in/social", { + provider: "github", + callbackURL: "/", + disableRedirect: true, + }); + + const response = await handleRequest(request, { + ...env, + SCM_PROVIDER: "gitlab", + } as Env); + + expect(response.status).toBe(200); + }); + + it("rejects a direct browser request without the web channel", async () => { + const response = await handleRequest( + new Request(`${CONTROL_PLANE_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }), + env + ); + + expect(response.status).toBe(401); + }); + + it("rejects a non-web service on the browser-auth proxy", async () => { + const request = await signedServiceRequest( + "/api/auth/sign-in/social", + { + provider: "github", + callbackURL: "/", + disableRedirect: true, + }, + "modal", + "test-service-secret-modal" + ); + + const response = await handleRequest(request, env); + + expect(response.status).toBe(401); + }); + + it("does not expose Better Auth endpoints outside the positive allowlist", async () => { + const request = await signedServiceRequest("/api/auth/list-sessions", {}); + + const response = await handleRequest(request, env); + + expect(response.status).toBe(404); + }); +}); diff --git a/packages/control-plane/test/integration/browser-auth-sessions.test.ts b/packages/control-plane/test/integration/browser-auth-sessions.test.ts deleted file mode 100644 index 29ed5a00b..000000000 --- a/packages/control-plane/test/integration/browser-auth-sessions.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { env } from "cloudflare:test"; -import { - BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - BROWSER_SESSION_IDLE_LIFETIME_MS, - BROWSER_SESSION_TOUCH_INTERVAL_MS, - BrowserAuthSessionStore, - parseBrowserSessionCredential, - parseBrowserSessionId, -} from "../../src/db/browser-auth-sessions"; -import type { BrowserSessionAuthenticationError } from "../../src/db/browser-auth-sessions"; -import { hashToken } from "../../src/auth/crypto"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const DAY_MS = 24 * 60 * 60 * 1000; -const CREDENTIAL = parseBrowserSessionCredential(`oi_bsess_${"a".repeat(43)}`); -const TOKEN_HASHER = { hash: hashToken }; - -describe("BrowserAuthSessionStore", () => { - beforeEach(async () => { - await cleanD1Tables(); - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO users - (id, display_name, email, avatar_url, created_at, updated_at) - VALUES ('user-1', NULL, 'user@example.com', NULL, ?, ?)` - ).bind(NOW_MS, NOW_MS), - env.DB.prepare( - `INSERT INTO user_identities - (id, user_id, provider, provider_issuer, provider_user_id, created_at) - VALUES ( - 'identity-1', 'user-1', 'github', 'https://github.com', - 'github-subject', ? - )` - ).bind(NOW_MS), - ]); - }); - - it("locks the browser-session lifetime policy", () => { - expect(BROWSER_SESSION_IDLE_LIFETIME_MS).toBe(7 * DAY_MS); - expect(BROWSER_SESSION_ABSOLUTE_LIFETIME_MS).toBe(30 * DAY_MS); - expect(BROWSER_SESSION_TOUCH_INTERVAL_MS).toBe(DAY_MS); - }); - - it("returns an opaque credential once and authenticates it without persisting the raw value", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: { hash: hashToken }, - }); - - await expect( - store.create({ userId: "user-1", providerIdentityId: "identity-1" }) - ).resolves.toEqual({ - credential: CREDENTIAL, - credentialId: "browser-session-1", - expiresAt: NOW_MS + BROWSER_SESSION_IDLE_LIFETIME_MS, - absoluteExpiresAt: NOW_MS + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - }); - - const persisted = await env.DB.prepare( - `SELECT token_hash, user_id, provider_identity_id - FROM browser_auth_sessions - WHERE id = 'browser-session-1'` - ).first(); - expect(persisted).toEqual({ - token_hash: await hashToken(CREDENTIAL), - user_id: "user-1", - provider_identity_id: "identity-1", - }); - expect(JSON.stringify(persisted)).not.toContain(CREDENTIAL); - - await expect(store.authenticate(CREDENTIAL)).resolves.toMatchObject({ - credentialId: "browser-session-1", - userId: "user-1", - providerIdentityId: "identity-1", - }); - }); - - it("rejects malformed creation inputs before persisting a session", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "" }, - tokenHasher: TOKEN_HASHER, - }); - - await expect(store.create({ userId: "", providerIdentityId: "identity-1" })).rejects.toThrow( - "requires a user and provider identity" - ); - await expect( - store.create({ userId: "user-1", providerIdentityId: "identity-1" }) - ).rejects.toThrow("id generator returned an invalid id"); - - await expect( - env.DB.prepare("SELECT COUNT(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 0 }); - }); - - it("revokes a browser session idempotently and rejects a copied credential", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - await expect(store.revoke(CREDENTIAL, "logout")).resolves.toBe(true); - await expect(store.revoke(CREDENTIAL, "logout")).resolves.toBe(false); - await expect(store.authenticate(CREDENTIAL)).rejects.toEqual( - expect.objectContaining>({ - rejection: "revoked", - }) - ); - }); - - it("distinguishes malformed, unknown, idle-expired, and absolute-expired credentials", async () => { - let now = NOW_MS; - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => now }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - expect(() => parseBrowserSessionCredential("not-a-browser-session")).toThrow( - expect.objectContaining>({ - rejection: "malformed", - }) - ); - expect(() => parseBrowserSessionId("")).toThrow( - expect.objectContaining>({ - rejection: "malformed", - }) - ); - await expect( - store.authenticate(parseBrowserSessionCredential(`oi_bsess_${"b".repeat(43)}`)) - ).rejects.toEqual( - expect.objectContaining>({ - rejection: "unknown", - }) - ); - - now += BROWSER_SESSION_IDLE_LIFETIME_MS; - await expect(store.authenticate(CREDENTIAL)).rejects.toEqual( - expect.objectContaining>({ - rejection: "idle_expired", - }) - ); - - now = NOW_MS + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS; - await expect(store.authenticate(CREDENTIAL)).rejects.toEqual( - expect.objectContaining>({ - rejection: "absolute_expired", - }) - ); - }); - - it("revalidates a live parent session by credential id without the raw bearer", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - const created = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - await expect(store.authenticateById(created.credentialId)).resolves.toMatchObject({ - credentialId: "browser-session-1", - userId: "user-1", - providerIdentityId: "identity-1", - }); - await expect(store.authenticateById(parseBrowserSessionId("missing"))).rejects.toEqual( - expect.objectContaining>({ - rejection: "unknown", - }) - ); - }); - - it("revokes an operator-selected session by credential id", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - const created = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - await expect(store.revokeById(created.credentialId, "operator")).resolves.toBe(true); - await expect(store.revokeById(created.credentialId, "operator")).resolves.toBe(false); - await expect(store.authenticateById(created.credentialId)).rejects.toEqual( - expect.objectContaining>({ - rejection: "revoked", - }) - ); - }); - - it("coalesces qualifying activity and never extends past the absolute deadline", async () => { - let now = NOW_MS; - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => now }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - const created = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - now += BROWSER_SESSION_TOUCH_INTERVAL_MS - 1; - await store.touchQualifyingActivity(created.credentialId); - let row = await env.DB.prepare( - "SELECT last_used_at, expires_at FROM browser_auth_sessions WHERE id = ?" - ) - .bind(created.credentialId) - .first<{ last_used_at: number; expires_at: number }>(); - expect(row).toEqual({ - last_used_at: NOW_MS, - expires_at: NOW_MS + BROWSER_SESSION_IDLE_LIFETIME_MS, - }); - - now = NOW_MS + BROWSER_SESSION_TOUCH_INTERVAL_MS; - await store.touchQualifyingActivity(created.credentialId); - row = await env.DB.prepare( - "SELECT last_used_at, expires_at FROM browser_auth_sessions WHERE id = ?" - ) - .bind(created.credentialId) - .first<{ last_used_at: number; expires_at: number }>(); - expect(row).toEqual({ - last_used_at: now, - expires_at: now + BROWSER_SESSION_IDLE_LIFETIME_MS, - }); - - for (const day of [6, 12, 18, 24, 28]) { - now = NOW_MS + day * BROWSER_SESSION_TOUCH_INTERVAL_MS; - await store.touchQualifyingActivity(created.credentialId); - } - row = await env.DB.prepare( - "SELECT last_used_at, expires_at FROM browser_auth_sessions WHERE id = ?" - ) - .bind(created.credentialId) - .first<{ last_used_at: number; expires_at: number }>(); - expect(row).toEqual({ - last_used_at: now, - expires_at: created.absoluteExpiresAt, - }); - }); - - it("does not renew a revoked or expired session", async () => { - let now = NOW_MS; - let credentialSequence = 0; - let idSequence = 0; - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => now }, - credentialGenerator: { - generate: () => - `oi_bsess_${String.fromCharCode("a".charCodeAt(0) + credentialSequence++).repeat(43)}`, - }, - idGenerator: { generate: () => `browser-session-${++idSequence}` }, - tokenHasher: TOKEN_HASHER, - }); - const revoked = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - const expired = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - await store.revoke(revoked.credential, "operator"); - - now += BROWSER_SESSION_IDLE_LIFETIME_MS; - await store.touchQualifyingActivity(revoked.credentialId); - await store.touchQualifyingActivity(expired.credentialId); - - const rows = await env.DB.prepare( - `SELECT id, last_used_at, expires_at - FROM browser_auth_sessions - ORDER BY id` - ).all<{ id: string; last_used_at: number; expires_at: number }>(); - expect(rows.results).toEqual([ - { - id: revoked.credentialId, - last_used_at: NOW_MS, - expires_at: revoked.expiresAt, - }, - { - id: expired.credentialId, - last_used_at: NOW_MS, - expires_at: expired.expiresAt, - }, - ]); - }); -}); diff --git a/packages/control-plane/test/integration/browser-auth.test.ts b/packages/control-plane/test/integration/browser-auth.test.ts index f57631645..b49c7f577 100644 --- a/packages/control-plane/test/integration/browser-auth.test.ts +++ b/packages/control-plane/test/integration/browser-auth.test.ts @@ -1,15 +1,18 @@ import { env } from "cloudflare:test"; +import { BROWSER_AUTH_CLIENT_IP_HEADER } from "@open-inspect/shared"; import { getMigrations } from "better-auth/db/migration"; import { describe, expect, it } from "vitest"; import { - BROWSER_AUTH_SESSION_EXPIRES_IN_MS, - BROWSER_AUTH_SESSION_UPDATE_AGE_MS, - createBrowserAuth, -} from "../../src/auth/browser-auth"; + SESSION_EXPIRES_IN_MS, + SESSION_UPDATE_AGE_MS, + createUserAuth, +} from "../../src/auth/user/better-auth"; const PUBLIC_WEB_ORIGIN = "https://web.test.local"; const SECRET = "test-only-better-auth-secret-with-at-least-32-characters"; const MS_PER_SECOND = 1000; +const UNUSED_PROFILE_RESOLVER = async () => null; +const UNUSED_USER_PROJECTION = { project: async () => {} }; const EXPECTED_COLUMNS = { auth_users: [ @@ -57,10 +60,11 @@ const EXPECTED_COLUMNS = { } as const; function createTestAuth() { - return createBrowserAuth({ + return createUserAuth({ database: env.DB, publicWebOrigin: PUBLIC_WEB_ORIGIN, secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, }); } @@ -88,40 +92,6 @@ describe("browser authentication", () => { WHERE name = 'idx_auth_accounts_provider_identity'` ).first<{ unique: number }>(); expect(providerIdentityIndex?.unique).toBe(1); - - const providerIdentityColumns = await env.DB.prepare( - `SELECT name - FROM pragma_index_info('idx_auth_accounts_provider_identity') - ORDER BY seqno` - ).all<{ name: string }>(); - expect(providerIdentityColumns.results.map(({ name }) => name)).toEqual([ - "providerId", - "accountId", - ]); - - for (const table of ["auth_sessions", "auth_accounts"]) { - const foreignKeys = await env.DB.prepare(`PRAGMA foreign_key_list(${table})`).all<{ - table: string; - from: string; - to: string; - on_delete: string; - }>(); - expect( - foreignKeys.results.map(({ table, from, to, on_delete }) => ({ - table, - from, - to, - onDelete: on_delete, - })) - ).toEqual([ - { - table: "auth_users", - from: "userId", - to: "id", - onDelete: "CASCADE", - }, - ]); - } }); it("serves an anonymous session through Better Auth on Workers and D1", async () => { @@ -132,6 +102,176 @@ describe("browser authentication", () => { expect(await response.json()).toBeNull(); }); + it("initiates GitHub App sign-in with PKCE and no classic OAuth scopes", async () => { + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + github: { + clientId: "github-app-client-id", + clientSecret: "github-app-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + + const response = await auth.handler( + new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.status).toBe(200); + const body = await response.json<{ redirect: boolean; url: string }>(); + const providerUrl = new URL(body.url); + expect(body.redirect).toBe(false); + expect(providerUrl.origin).toBe("https://github.com"); + expect(providerUrl.pathname).toBe("/login/oauth/authorize"); + expect(providerUrl.searchParams.get("client_id")).toBe("github-app-client-id"); + expect(providerUrl.searchParams.get("redirect_uri")).toBe( + `${PUBLIC_WEB_ORIGIN}/api/auth/callback/github` + ); + expect(providerUrl.searchParams.get("scope")).toBe(""); + expect(providerUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(providerUrl.searchParams.get("state")).toBeTruthy(); + + const stateCookie = response.headers.get("set-cookie"); + expect(stateCookie).toContain("__Secure-openinspect.state="); + expect(stateCookie?.toLowerCase()).toContain("httponly"); + expect(stateCookie?.toLowerCase()).toContain("secure"); + expect(stateCookie?.toLowerCase()).toContain("samesite=lax"); + expect(stateCookie?.toLowerCase()).not.toContain("domain="); + }); + + it("rate limits repeated browser sign-in attempts by the trusted client IP", async () => { + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + github: { + clientId: "github-app-client-id", + clientSecret: "github-app-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + const signIn = () => + auth.handler( + new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + [BROWSER_AUTH_CLIENT_IP_HEADER]: "203.0.113.73", + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + await expect(signIn()).resolves.toMatchObject({ status: 200 }); + await expect(signIn()).resolves.toMatchObject({ status: 200 }); + await expect(signIn()).resolves.toMatchObject({ status: 200 }); + + const limited = await signIn(); + expect(limited.status).toBe(429); + expect(limited.headers.get("X-Retry-After")).toBeTruthy(); + }); + + it("uses a non-Secure host-only cookie only for loopback HTTP development", async () => { + const localOrigin = "http://localhost:3000"; + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: localOrigin, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + github: { + clientId: "github-app-client-id", + clientSecret: "github-app-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + + const response = await auth.handler( + new Request(`${localOrigin}/api/auth/sign-in/social`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: localOrigin, + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.status).toBe(200); + const stateCookie = response.headers.get("set-cookie"); + expect(stateCookie).toContain("openinspect.state="); + expect(stateCookie).not.toContain("__Secure-"); + expect(stateCookie?.toLowerCase()).not.toContain("; secure"); + expect(stateCookie?.toLowerCase()).toContain("httponly"); + }); + + it("initiates Google OIDC sign-in with PKCE and minimum identity scopes", async () => { + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + google: { + clientId: "google-client-id", + clientSecret: "google-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + + const response = await auth.handler( + new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "google", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.status).toBe(200); + const body = await response.json<{ redirect: boolean; url: string }>(); + const providerUrl = new URL(body.url); + expect(body.redirect).toBe(false); + expect(providerUrl.origin).toBe("https://accounts.google.com"); + expect(providerUrl.pathname).toBe("/o/oauth2/v2/auth"); + expect(providerUrl.searchParams.get("client_id")).toBe("google-client-id"); + expect(providerUrl.searchParams.get("redirect_uri")).toBe( + `${PUBLIC_WEB_ORIGIN}/api/auth/callback/google` + ); + expect(new Set(providerUrl.searchParams.get("scope")?.split(" "))).toEqual( + new Set(["email", "openid", "profile"]) + ); + expect(providerUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(providerUrl.searchParams.get("state")).toBeTruthy(); + }); + it("uses canonical ids and converts millisecond durations at the library boundary", () => { const auth = createTestAuth(); const generateId = auth.options.advanced?.database?.generateId; @@ -141,11 +281,7 @@ describe("browser authentication", () => { throw new Error("Better Auth canonical ID generator is not configured"); } expect(generateId({ model: "user" })).toMatch(/^[a-f0-9]{32}$/); - expect(auth.options.session?.expiresIn).toBe( - BROWSER_AUTH_SESSION_EXPIRES_IN_MS / MS_PER_SECOND - ); - expect(auth.options.session?.updateAge).toBe( - BROWSER_AUTH_SESSION_UPDATE_AGE_MS / MS_PER_SECOND - ); + expect(auth.options.session?.expiresIn).toBe(SESSION_EXPIRES_IN_MS / MS_PER_SECOND); + expect(auth.options.session?.updateAge).toBe(SESSION_UPDATE_AGE_MS / MS_PER_SECOND); }); }); diff --git a/packages/control-plane/test/integration/browser-sign-in-identity.test.ts b/packages/control-plane/test/integration/browser-sign-in-identity.test.ts deleted file mode 100644 index b54101064..000000000 --- a/packages/control-plane/test/integration/browser-sign-in-identity.test.ts +++ /dev/null @@ -1,807 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { env } from "cloudflare:test"; -import { - AccountLinkRequiredError, - BrowserSignInIdentityResolver, - InvalidProviderIdentityEvidenceError, - ProviderIdentityAdapterMismatchError, - type BrowserSignInIdentityResolverDependencies, -} from "../../src/auth/browser-sign-in-identity"; -import { - BrowserSignInIdentityStore, - type ProviderCredentialWriteStorePort, -} from "../../src/db/browser-sign-in-identities"; -import { ProviderCredentialStore } from "../../src/db/provider-credentials"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const GITHUB_CREDENTIAL = { - kind: "access_only_nonexpiring" as const, - accessToken: "ghu_test_access", -}; - -function createProviderCredentialStore(now = NOW_MS): ProviderCredentialStore { - return new ProviderCredentialStore( - env.DB, - { - encrypt: async (plaintext, context) => btoa(JSON.stringify({ plaintext, context })), - decrypt: async (encrypted) => - (JSON.parse(atob(encrypted)) as { plaintext: string }).plaintext, - }, - { now: () => now } - ); -} - -function createIdentityResolver( - dependencies: Omit & { - providerCredentialStore?: ProviderCredentialWriteStorePort; - } -): BrowserSignInIdentityResolver { - const { providerCredentialStore = createProviderCredentialStore(), ...serviceDependencies } = - dependencies; - return new BrowserSignInIdentityResolver({ - ...serviceDependencies, - store: new BrowserSignInIdentityStore(env.DB, providerCredentialStore), - }); -} - -describe("BrowserSignInIdentityResolver", () => { - beforeEach(cleanD1Tables); - - it("creates an issuer-qualified canonical identity without requiring email evidence", async () => { - const ids = ["user-1", "identity-1"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { - generate: () => ids.shift() ?? "unexpected-id", - }, - }); - - await expect( - service.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-user-1", - login: "octocat", - displayName: "Octo Cat", - avatarUrl: "https://avatars.example/octocat", - verifiedEmails: [], - primaryEmail: null, - }, - credential: GITHUB_CREDENTIAL, - }) - ).resolves.toEqual({ - userId: "user-1", - providerIdentityId: "identity-1", - isNewUser: true, - collisionCount: 0, - }); - - await expect( - env.DB.prepare( - `SELECT - users.id, users.email, user_identities.provider, - user_identities.provider_issuer, user_identities.provider_user_id - FROM users - JOIN user_identities ON user_identities.user_id = users.id` - ).first() - ).resolves.toEqual({ - id: "user-1", - email: null, - provider: "github", - provider_issuer: "https://github.com", - provider_user_id: "github-user-1", - }); - }); - - it("fails closed without creating a user when a new subject collides", async () => { - const ids = ["existing-user", "existing-identity", "rejected-user", "rejected-identity"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - await service.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-user-1", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: GITHUB_CREDENTIAL, - }); - - let rejection: unknown; - try { - await service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-user-1", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: null, - }); - } catch (error) { - rejection = error; - } - - expect(rejection).toBeInstanceOf(AccountLinkRequiredError); - expect(rejection).toMatchObject({ collisionCount: 1 }); - expect(rejection).not.toHaveProperty("conflictingEmails"); - - await expect( - env.DB.prepare( - `SELECT - (SELECT count(*) FROM users) AS users, - (SELECT count(*) FROM user_identities) AS identities, - (SELECT count(*) FROM verified_email_claims) AS claims` - ).first() - ).resolves.toEqual({ users: 1, identities: 1, claims: 1 }); - }); - - it("preserves an established subject while maintaining its unclaimed email evidence", async () => { - const ids = ["github-user", "github-identity", "google-user", "google-identity"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - await service.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-subject", - verifiedEmails: ["github@example.com"], - primaryEmail: "github@example.com", - }, - credential: GITHUB_CREDENTIAL, - }); - await service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["google@example.com"], - primaryEmail: "google@example.com", - }, - credential: null, - }); - - await expect( - service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-subject", - displayName: "Updated Google User", - verifiedEmails: ["google@example.com", "new@example.com", "github@example.com"], - primaryEmail: "google@example.com", - }, - credential: null, - }) - ).resolves.toEqual({ - userId: "google-user", - providerIdentityId: "google-identity", - isNewUser: false, - collisionCount: 1, - }); - - await expect( - env.DB.prepare( - `SELECT email, user_id, source_provider_identity_id - FROM verified_email_claims - WHERE email = 'new@example.com'` - ).first() - ).resolves.toEqual({ - email: "new@example.com", - user_id: "google-user", - source_provider_identity_id: "google-identity", - }); - await expect( - env.DB.prepare( - `SELECT user_id - FROM user_identities - WHERE id = 'google-identity'` - ).first() - ).resolves.toEqual({ user_id: "google-user" }); - }); - - it("keeps canonical users.email stable while refreshing provider email metadata", async () => { - const ids = ["user-1", "identity-1"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - await service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["original@example.com"], - primaryEmail: "original@example.com", - }, - credential: null, - }); - - await service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["current@example.com"], - primaryEmail: "current@example.com", - }, - credential: null, - }); - - await expect( - env.DB.prepare( - `SELECT users.email, user_identities.provider_email - FROM users - JOIN user_identities ON user_identities.user_id = users.id - WHERE users.id = 'user-1'` - ).first() - ).resolves.toEqual({ - email: "original@example.com", - provider_email: "current@example.com", - }); - }); - - it("uses claim uniqueness as the concurrency authority", async () => { - const github = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { - generate: (() => { - const ids = ["github-user", "github-identity"]; - return () => ids.shift() ?? "unexpected-github-id"; - })(), - }, - }); - const google = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { - generate: (() => { - const ids = ["google-user", "google-identity"]; - return () => ids.shift() ?? "unexpected-google-id"; - })(), - }, - }); - - const results = await Promise.allSettled([ - github.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-subject", - verifiedEmails: ["same@example.com"], - primaryEmail: "same@example.com", - }, - credential: GITHUB_CREDENTIAL, - }), - google.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["same@example.com"], - primaryEmail: "same@example.com", - }, - credential: null, - }), - ]); - - expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); - expect(results.find((result) => result.status === "rejected")).toEqual( - expect.objectContaining({ - reason: expect.objectContaining({ name: "AccountLinkRequiredError" }), - }) - ); - await expect( - env.DB.prepare( - `SELECT - (SELECT count(*) FROM users) AS users, - (SELECT count(*) FROM user_identities) AS identities, - (SELECT count(*) FROM verified_email_claims) AS claims` - ).first() - ).resolves.toEqual({ users: 1, identities: 1, claims: 1 }); - }); - - it("commits a new identity and encrypted provider credential in one transaction", async () => { - const credentialStore = new ProviderCredentialStore( - env.DB, - { - encrypt: async (plaintext, context) => btoa(JSON.stringify({ plaintext, context })), - decrypt: async (encrypted) => - (JSON.parse(atob(encrypted)) as { plaintext: string }).plaintext, - }, - { now: () => NOW_MS } - ); - const ids = ["user-1", "identity-1"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - providerCredentialStore: credentialStore, - }); - - await service.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: { - kind: "refreshable", - accessToken: "ghu_access", - accessExpiresAt: NOW_MS + 10_000, - refreshToken: "ghr_refresh", - refreshExpiresAt: null, - }, - }); - - await expect(credentialStore.get("identity-1")).resolves.toMatchObject({ - providerIdentityId: "identity-1", - kind: "refreshable", - accessToken: "ghu_access", - refreshToken: "ghr_refresh", - rowVersion: 1, - }); - }); - - it("retries an atomic identity refresh when the prepared credential version becomes stale", async () => { - const credentialStore = createProviderCredentialStore(); - const ids = ["user-1", "identity-1"]; - const initial = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - providerCredentialStore: credentialStore, - }); - await initial.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-subject", - displayName: "Original Name", - verifiedEmails: ["original@example.com"], - primaryEmail: "original@example.com", - }, - credential: GITHUB_CREDENTIAL, - }); - - const staleMutation = await credentialStore.prepareSignInUpsert("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "stale-access-token", - }); - await credentialStore.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "concurrent-access-token", - }); - const prepareSignInUpsert = vi - .fn() - .mockResolvedValueOnce(staleMutation) - .mockImplementation((providerIdentityId, credential, updatedAt) => - credentialStore.prepareSignInUpsert(providerIdentityId, credential, updatedAt) - ); - const retryingStore: ProviderCredentialWriteStorePort = { - prepareInitialInsert: (...args) => credentialStore.prepareInitialInsert(...args), - prepareSignInUpsert, - isSignInVersionConflict: (error) => credentialStore.isSignInVersionConflict(error), - }; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS + 1_000 }, - idGenerator: { generate: () => "must-not-generate" }, - providerCredentialStore: retryingStore, - }); - - await expect( - service.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-subject", - displayName: "Updated Name", - verifiedEmails: ["original@example.com", "new@example.com"], - primaryEmail: "new@example.com", - }, - credential: { - kind: "access_only_nonexpiring", - accessToken: "final-access-token", - }, - }) - ).resolves.toMatchObject({ - userId: "user-1", - providerIdentityId: "identity-1", - isNewUser: false, - }); - - expect(prepareSignInUpsert).toHaveBeenCalledTimes(2); - await expect( - env.DB.prepare("SELECT display_name FROM users WHERE id = 'user-1'").first() - ).resolves.toEqual({ display_name: "Updated Name" }); - await expect( - env.DB.prepare( - "SELECT user_id FROM verified_email_claims WHERE email = 'new@example.com'" - ).first() - ).resolves.toEqual({ user_id: "user-1" }); - await expect(credentialStore.get("identity-1")).resolves.toMatchObject({ - accessToken: "final-access-token", - rowVersion: 3, - }); - }); - - it("does not start identity creation when credential preparation fails", async () => { - const credentialStore = new ProviderCredentialStore( - env.DB, - { - encrypt: async () => { - throw new Error("cipher unavailable"); - }, - decrypt: async () => { - throw new Error("not reached"); - }, - }, - { now: () => NOW_MS } - ); - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => crypto.randomUUID() }, - providerCredentialStore: credentialStore, - }); - - await expect( - service.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: { - kind: "access_only_nonexpiring", - accessToken: "ghu_access", - }, - }) - ).rejects.toThrow("cipher unavailable"); - await expect(env.DB.prepare("SELECT count(*) AS count FROM users").first()).resolves.toEqual({ - count: 0, - }); - }); - - it("rolls back user, identity, and claims when credential execution fails in the batch", async () => { - const failingCredentialStore: ProviderCredentialWriteStorePort = { - prepareInitialInsert: async () => - env.DB.prepare( - `INSERT INTO provider_credentials ( - provider_identity_id, credential_kind, - access_token_ciphertext, access_expires_at, - refresh_token_ciphertext, refresh_expires_at, - encryption_key_version, row_version, updated_at - ) VALUES (?, 'invalid-kind', 'ciphertext', NULL, NULL, NULL, 1, 1, ?)` - ).bind("identity-1", NOW_MS), - prepareSignInUpsert: async () => { - throw new Error("not reached"); - }, - isSignInVersionConflict: () => false, - }; - const ids = ["user-1", "identity-1"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - providerCredentialStore: failingCredentialStore, - }); - - await expect( - service.resolve({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "github-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: GITHUB_CREDENTIAL, - }) - ).rejects.toThrow(); - - await expect( - env.DB.prepare( - `SELECT - (SELECT count(*) FROM users) AS users, - (SELECT count(*) FROM user_identities) AS identities, - (SELECT count(*) FROM verified_email_claims) AS claims, - (SELECT count(*) FROM provider_credentials) AS credentials` - ).first() - ).resolves.toEqual({ users: 0, identities: 0, claims: 0, credentials: 0 }); - }); - - it("rejects an issuer that was not selected by the configured provider adapter", async () => { - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => crypto.randomUUID() }, - }); - - await expect( - service.resolve({ - identity: { - provider: "google", - issuer: "https://attacker.example", - subject: "subject", - verifiedEmails: [], - primaryEmail: null, - }, - credential: null, - }) - ).rejects.toBeInstanceOf(InvalidProviderIdentityEvidenceError); - }); - - it("preserves the provider subject exactly", async () => { - const ids = ["user-1", "identity-1"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - - await service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: " subject-with-significant-spaces ", - verifiedEmails: [], - primaryEmail: null, - }, - credential: null, - }); - - await expect( - env.DB.prepare( - `SELECT provider_user_id - FROM user_identities - WHERE id = 'identity-1'` - ).first() - ).resolves.toEqual({ - provider_user_id: " subject-with-significant-spaces ", - }); - }); - - it("resolves a bounded provider email set without one D1 binding or statement per email", async () => { - const ids = ["user-1", "identity-1"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - const verifiedEmails = Array.from({ length: 101 }, (_, index) => `person-${index}@example.com`); - - await expect( - service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-many-emails", - verifiedEmails, - primaryEmail: verifiedEmails[0], - }, - credential: null, - }) - ).resolves.toMatchObject({ - userId: "user-1", - providerIdentityId: "identity-1", - isNewUser: true, - collisionCount: 0, - }); - - await expect( - service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-many-emails", - verifiedEmails, - primaryEmail: verifiedEmails[0], - }, - credential: null, - }) - ).resolves.toMatchObject({ - userId: "user-1", - providerIdentityId: "identity-1", - isNewUser: false, - collisionCount: 0, - }); - - await expect( - env.DB.prepare("SELECT count(*) AS count FROM verified_email_claims").first() - ).resolves.toEqual({ count: 101 }); - }); - - it("rejects an unbounded provider email set before writing identity state", async () => { - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => crypto.randomUUID() }, - }); - const verifiedEmails = Array.from( - { length: 1_001 }, - (_, index) => `person-${index}@example.com` - ); - - await expect( - service.resolve({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-too-many-emails", - verifiedEmails, - primaryEmail: verifiedEmails[0], - }, - credential: null, - }) - ).rejects.toBeInstanceOf(InvalidProviderIdentityEvidenceError); - - await expect( - env.DB.prepare( - `SELECT - (SELECT count(*) FROM users) AS users, - (SELECT count(*) FROM user_identities) AS identities, - (SELECT count(*) FROM verified_email_claims) AS claims` - ).first() - ).resolves.toEqual({ users: 0, identities: 0, claims: 0 }); - }); - - it("converges concurrent callbacks for the same immutable subject", async () => { - function service(userId: string, identityId: string) { - const ids = [userId, identityId]; - return createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - } - const evidence = { - identity: { - provider: "google" as const, - issuer: "https://accounts.google.com", - subject: "same-subject", - verifiedEmails: [], - primaryEmail: null, - }, - credential: null, - }; - - const [first, second] = await Promise.all([ - service("user-1", "identity-1").resolve(evidence), - service("user-2", "identity-2").resolve(evidence), - ]); - - expect(first.userId).toBe(second.userId); - expect(first.providerIdentityId).toBe(second.providerIdentityId); - await expect( - env.DB.prepare( - `SELECT - (SELECT count(*) FROM users) AS users, - (SELECT count(*) FROM user_identities) AS identities` - ).first() - ).resolves.toEqual({ users: 1, identities: 1 }); - }); - - it("advances verification time without rewriting claim provenance", async () => { - const ids = ["user-1", "identity-1"]; - const initial = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - const evidence = { - identity: { - provider: "google" as const, - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: null, - }; - await initial.resolve(evidence); - - const later = createIdentityResolver({ - clock: { now: () => NOW_MS + 1_000 }, - idGenerator: { generate: () => "must-not-generate" }, - }); - await later.resolve(evidence); - - await expect( - env.DB.prepare( - `SELECT - source_kind, source_provider_identity_id, created_at, last_verified_at - FROM verified_email_claims - WHERE email = 'person@example.com'` - ).first() - ).resolves.toEqual({ - source_kind: "provider_verified", - source_provider_identity_id: "identity-1", - created_at: NOW_MS, - last_verified_at: NOW_MS + 1_000, - }); - }); - - it("preserves a legacy canonical reservation when the same user verifies it", async () => { - const ids = ["user-1", "identity-1"]; - const initial = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - const evidence = { - identity: { - provider: "google" as const, - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: null, - }; - await initial.resolve(evidence); - await env.DB.prepare( - `UPDATE verified_email_claims - SET source_kind = 'legacy_canonical', - source_provider_identity_id = NULL, - last_verified_at = NULL - WHERE email = 'person@example.com'` - ).run(); - - const later = createIdentityResolver({ - clock: { now: () => NOW_MS + 1_000 }, - idGenerator: { generate: () => "must-not-generate" }, - }); - await expect(later.resolve(evidence)).resolves.toMatchObject({ - userId: "user-1", - providerIdentityId: "identity-1", - isNewUser: false, - }); - await expect( - env.DB.prepare( - `SELECT - source_kind, source_provider_identity_id, created_at, last_verified_at - FROM verified_email_claims - WHERE email = 'person@example.com'` - ).first() - ).resolves.toEqual({ - source_kind: "legacy_canonical", - source_provider_identity_id: null, - created_at: NOW_MS, - last_verified_at: null, - }); - }); - - it("rejects a stored adapter mismatch without reparenting the subject", async () => { - const ids = ["user-1", "identity-1"]; - const service = createIdentityResolver({ - clock: { now: () => NOW_MS }, - idGenerator: { generate: () => ids.shift() ?? "unexpected-id" }, - }); - const evidence = { - identity: { - provider: "google" as const, - issuer: "https://accounts.google.com", - subject: "google-subject", - verifiedEmails: [], - primaryEmail: null, - }, - credential: null, - }; - await service.resolve(evidence); - await env.DB.prepare( - "UPDATE user_identities SET provider = 'github' WHERE id = 'identity-1'" - ).run(); - - let rejection: unknown; - try { - await service.resolve(evidence); - } catch (error) { - rejection = error; - } - expect(rejection).toBeInstanceOf(ProviderIdentityAdapterMismatchError); - await expect( - env.DB.prepare("SELECT user_id FROM user_identities WHERE id = 'identity-1'").first() - ).resolves.toEqual({ user_id: "user-1" }); - }); -}); diff --git a/packages/control-plane/test/integration/cleanup.ts b/packages/control-plane/test/integration/cleanup.ts index 77ae21ec1..3917a0cbe 100644 --- a/packages/control-plane/test/integration/cleanup.ts +++ b/packages/control-plane/test/integration/cleanup.ts @@ -6,6 +6,6 @@ import { env } from "cloudflare:test"; */ export async function cleanD1Tables(): Promise { await env.DB.exec( - "DELETE FROM oauth_authorization_codes; DELETE FROM browser_auth_sessions; DELETE FROM provider_credentials; DELETE FROM verified_email_claims; DELETE FROM oauth_flow_state; DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automation_invocations; DELETE FROM automation_repositories; DELETE FROM automation_environments; DELETE FROM automations; DELETE FROM session_pull_requests; DELETE FROM session_repositories; DELETE FROM sessions; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM commit_signing_configuration; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM integration_environment_settings; DELETE FROM model_preferences; DELETE FROM mcp_servers; DELETE FROM user_identities; DELETE FROM users; DELETE FROM api_tokens; DELETE FROM image_builds; DELETE FROM environment_secrets; DELETE FROM environment_repositories; DELETE FROM environments;" + "DELETE FROM auth_verifications; DELETE FROM auth_sessions; DELETE FROM auth_accounts; DELETE FROM auth_users; DELETE FROM oauth_authorization_codes; DELETE FROM browser_auth_sessions; DELETE FROM provider_credentials; DELETE FROM verified_email_claims; DELETE FROM oauth_flow_state; DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automation_invocations; DELETE FROM automation_repositories; DELETE FROM automation_environments; DELETE FROM automations; DELETE FROM session_pull_requests; DELETE FROM session_repositories; DELETE FROM sessions; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM commit_signing_configuration; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM integration_environment_settings; DELETE FROM model_preferences; DELETE FROM mcp_servers; DELETE FROM user_identities; DELETE FROM users; DELETE FROM api_tokens; DELETE FROM image_builds; DELETE FROM environment_secrets; DELETE FROM environment_repositories; DELETE FROM environments;" ); } diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 0d9f5e66b..9f9e13ad3 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -5,12 +5,113 @@ import type { SessionDO } from "../../src/session/durable-object"; import { hashToken } from "../../src/auth/crypto"; const DEFAULT_WAIT_FOR_SANDBOX_STATUS_TIMEOUT_MS = 3000; +const TEST_BROWSER_USER_ID = "11111111111111111111111111111111"; +const TEST_BROWSER_ACCOUNT_ID = "test-browser-account"; +const TEST_BROWSER_PROVIDER_SUBJECT = "583231"; +const TEST_BROWSER_SESSION_ID = "test-browser-session"; +const TEST_BROWSER_SESSION_TOKEN = "test-browser-session-token"; +const TEST_BROWSER_SESSION_COOKIE = "__Secure-openinspect.session_token"; + +async function signCookieValue(value: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)) + ); + const signatureBase64 = btoa(String.fromCharCode(...signature)); + return encodeURIComponent(`${value}.${signatureBase64}`); +} + +/** + * Seed one real Better Auth user/account/session and return its signed cookie. + * + * Integration route tests exercise browser-owned endpoints, so their default + * web request must carry the same compound credential as production. Direct + * service-auth tests intentionally build their own bare sig1 requests. + */ +async function testBrowserSessionCookie(): Promise { + const secret = env.BROWSER_AUTH_SECRET; + if (!secret) throw new Error("BROWSER_AUTH_SECRET is not configured for integration tests"); + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const applicationTimestamp = now.getTime(); + await env.DB.batch([ + env.DB.prepare( + `INSERT OR IGNORE INTO auth_users + (id, name, email, emailVerified, image, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).bind( + TEST_BROWSER_USER_ID, + "Integration Browser User", + "browser@test.local", + 1, + null, + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT OR IGNORE INTO users + (id, display_name, email, avatar_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).bind( + TEST_BROWSER_USER_ID, + "Integration Browser User", + "browser@test.local", + null, + applicationTimestamp, + applicationTimestamp + ), + env.DB.prepare( + `INSERT OR IGNORE INTO auth_accounts + (id, accountId, providerId, userId, accessToken, refreshToken, idToken, + accessTokenExpiresAt, refreshTokenExpiresAt, scope, password, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + TEST_BROWSER_ACCOUNT_ID, + TEST_BROWSER_PROVIDER_SUBJECT, + "github", + TEST_BROWSER_USER_ID, + null, + null, + null, + null, + null, + null, + null, + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT OR IGNORE INTO auth_sessions + (id, expiresAt, token, createdAt, updatedAt, ipAddress, userAgent, userId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + TEST_BROWSER_SESSION_ID, + expiresAt.toISOString(), + TEST_BROWSER_SESSION_TOKEN, + now.toISOString(), + now.toISOString(), + "127.0.0.1", + "integration-test", + TEST_BROWSER_USER_ID + ), + ]); + + const signedToken = await signCookieValue(TEST_BROWSER_SESSION_TOKEN, secret); + return `${TEST_BROWSER_SESSION_COOKIE}=${signedToken}`; +} /** - * Fetch a control-plane route as a service principal. Signs per request — - * sig1 binds method, URL, and body, so headers can never be reused across - * calls. Defaults to the `web` service; secrets follow the - * `test-service-secret-` bindings in vitest.integration.config.ts. + * Fetch a control-plane route with production-equivalent credentials. Web + * calls carry both sig1 and a Better Auth browser session; other services + * carry their service credential. Signs per request because sig1 binds method, + * URL, and body. */ export async function serviceFetch( url: string, @@ -32,10 +133,12 @@ export async function serviceFetch( body: init?.body, actor: init?.actor, }); + const browserCookie = service === "web" ? await testBrowserSessionCookie() : undefined; return SELF.fetch(url, { method, headers: { ...(init?.body === undefined ? {} : { "Content-Type": "application/json" }), + ...(browserCookie ? { Cookie: browserCookie } : {}), ...init?.headers, ...auth, }, diff --git a/packages/control-plane/test/integration/oauth-authorization-codes.test.ts b/packages/control-plane/test/integration/oauth-authorization-codes.test.ts deleted file mode 100644 index ebdb8ba39..000000000 --- a/packages/control-plane/test/integration/oauth-authorization-codes.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; -import { hashToken } from "../../src/auth/crypto"; -import { createPkceS256Challenge } from "../../src/auth/pkce"; -import { - BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - BROWSER_SESSION_IDLE_LIFETIME_MS, - BrowserAuthSessionStore, - parseBrowserSessionCredential, - type BrowserAuthSessionStoreDependencies, -} from "../../src/db/browser-auth-sessions"; -import { - InvalidOAuthAuthorizationCodeInputError, - OAUTH_AUTHORIZATION_CODE_LIFETIME_MS, - OAuthAuthorizationCodeRedemptionError, - OAuthAuthorizationCodeStore, - type IssueOAuthAuthorizationCodeInput, -} from "../../src/db/oauth-authorization-codes"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const AUTHORIZATION_CODE = `oi_code_${"a".repeat(43)}`; -const BROWSER_CREDENTIAL = `oi_bsess_${"b".repeat(43)}`; -const CODE_VERIFIER = "v".repeat(43); -const REDIRECT_URI = "https://web.example/api/auth/callback"; - -describe("OAuthAuthorizationCodeStore", () => { - beforeEach(async () => { - await cleanD1Tables(); - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO users - (id, display_name, email, avatar_url, created_at, updated_at) - VALUES ('user-1', NULL, NULL, NULL, ?, ?)` - ).bind(NOW_MS, NOW_MS), - env.DB.prepare( - `INSERT INTO user_identities - (id, user_id, provider, provider_issuer, provider_user_id, created_at) - VALUES ( - 'identity-1', 'user-1', 'github', 'https://github.com', - 'github-subject', ? - )` - ).bind(NOW_MS), - ]); - }); - - function createStore(now = NOW_MS): OAuthAuthorizationCodeStore { - const ids = ["code-1", "browser-session-1", "browser-session-2"]; - return new OAuthAuthorizationCodeStore(env.DB, { - clock: { now: () => now }, - tokenHasher: { hash: hashToken }, - authorizationCodeGenerator: { generate: () => AUTHORIZATION_CODE }, - browserCredentialGenerator: { generate: () => BROWSER_CREDENTIAL }, - idGenerator: { - generate: () => { - const id = ids.shift(); - if (!id) throw new Error("Unexpected id request"); - return id; - }, - }, - }); - } - - it("redeems a bound code into an authenticatable browser session", async () => { - const store = createStore(); - const challenge = await createPkceS256Challenge(CODE_VERIFIER); - - await expect( - store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: challenge, - }) - ).resolves.toEqual({ - code: AUTHORIZATION_CODE, - expiresAt: NOW_MS + OAUTH_AUTHORIZATION_CODE_LIFETIME_MS, - }); - await expect( - env.DB.prepare("SELECT code_hash FROM oauth_authorization_codes WHERE id = 'code-1'").first() - ).resolves.toEqual({ code_hash: await hashToken(AUTHORIZATION_CODE) }); - - const redeemed = await store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }); - - expect(redeemed).toEqual({ - credential: BROWSER_CREDENTIAL, - credentialId: "browser-session-1", - expiresAt: NOW_MS + BROWSER_SESSION_IDLE_LIFETIME_MS, - absoluteExpiresAt: NOW_MS + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - }); - await expect( - env.DB.prepare( - "SELECT consumed_by FROM oauth_authorization_codes WHERE id = 'code-1'" - ).first() - ).resolves.toEqual({ consumed_by: redeemed.credentialId }); - - const sessionDependencies: BrowserAuthSessionStoreDependencies = { - clock: { now: () => NOW_MS }, - tokenHasher: { hash: hashToken }, - credentialGenerator: { generate: () => BROWSER_CREDENTIAL }, - idGenerator: { generate: () => "unused" }, - }; - const sessions = new BrowserAuthSessionStore(env.DB, sessionDependencies); - await expect( - sessions.authenticate(parseBrowserSessionCredential(BROWSER_CREDENTIAL)) - ).resolves.toMatchObject({ - credentialId: "browser-session-1", - userId: "user-1", - providerIdentityId: "identity-1", - }); - }); - - it("does not consume a code when its redirect binding or PKCE verifier is wrong", async () => { - const store = createStore(); - await store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - - await expect( - store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: "https://attacker.example/callback", - codeVerifier: CODE_VERIFIER, - }) - ).rejects.toMatchObject({ rejection: "binding_mismatch" }); - await expect( - store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: "x".repeat(43), - }) - ).rejects.toMatchObject({ rejection: "pkce_failed" }); - - await expect( - env.DB.prepare( - "SELECT consumed_at, consumed_by FROM oauth_authorization_codes WHERE id = 'code-1'" - ).first() - ).resolves.toEqual({ consumed_at: null, consumed_by: null }); - }); - - it("distinguishes malformed and unknown authorization codes", async () => { - const redemption = { - clientId: "web" as const, - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }; - - await expect( - createStore().redeem({ ...redemption, code: "not-an-authorization-code" }) - ).rejects.toMatchObject({ rejection: "malformed" }); - await expect( - createStore().redeem({ ...redemption, code: `oi_code_${"z".repeat(43)}` }) - ).rejects.toMatchObject({ rejection: "unknown" }); - }); - - it("rejects malformed authorization-code bindings before persistence", async () => { - const validInput: IssueOAuthAuthorizationCodeInput = { - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }; - const invalidBindings: Array< - [string, Partial>] - > = [ - ["missing user", { userId: "" }], - ["missing provider identity", { providerIdentityId: "" }], - ["unsupported client", { clientId: "cli" }], - ["missing redirect URI", { redirectUri: "" }], - ["malformed PKCE challenge", { codeChallenge: "not-a-challenge" }], - ]; - - for (const [name, override] of invalidBindings) { - const issue = createStore().issue({ - ...validInput, - ...override, - } as IssueOAuthAuthorizationCodeInput); - await expect(issue, name).rejects.toBeInstanceOf(InvalidOAuthAuthorizationCodeInputError); - } - await expect( - env.DB.prepare("SELECT count(*) AS count FROM oauth_authorization_codes").first() - ).resolves.toEqual({ count: 0 }); - }); - - it("rejects a code at its exact expiry boundary without creating a session", async () => { - await createStore().issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - - await expect( - createStore(NOW_MS + OAUTH_AUTHORIZATION_CODE_LIFETIME_MS).redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }) - ).rejects.toMatchObject({ rejection: "expired" }); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 0 }); - }); - - it("classifies sequential authorization-code replay as already consumed", async () => { - const store = createStore(); - await store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - const redemption = { - code: AUTHORIZATION_CODE, - clientId: "web" as const, - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }; - - await expect(store.redeem(redemption)).resolves.toMatchObject({ - credentialId: "browser-session-1", - }); - await expect(store.redeem(redemption)).rejects.toMatchObject({ - rejection: "already_consumed", - }); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 1 }); - }); - - it("allows exactly one concurrent redemption without creating an orphan session", async () => { - const store = createStore(); - await store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - const redemption = { - code: AUTHORIZATION_CODE, - clientId: "web" as const, - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }; - - const results = await Promise.allSettled([store.redeem(redemption), store.redeem(redemption)]); - - expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1); - const rejection = results.find(({ status }) => status === "rejected"); - expect(rejection).toMatchObject({ - status: "rejected", - reason: expect.any(OAuthAuthorizationCodeRedemptionError), - }); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 1 }); - }); - - it("rolls back code consumption when browser-session insertion fails", async () => { - const store = createStore(); - await store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - await env.DB.prepare( - `INSERT INTO browser_auth_sessions ( - id, token_hash, user_id, client_id, provider_identity_id, - created_at, last_used_at, expires_at, absolute_expires_at, - revoked_at, revoked_reason - ) VALUES (?, ?, 'user-1', 'web', 'identity-1', ?, ?, ?, ?, NULL, NULL)` - ) - .bind( - "browser-session-1", - "c".repeat(64), - NOW_MS, - NOW_MS, - NOW_MS + BROWSER_SESSION_IDLE_LIFETIME_MS, - NOW_MS + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS - ) - .run(); - - await expect( - store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }) - ).rejects.toThrow(); - await expect( - env.DB.prepare( - "SELECT consumed_at, consumed_by FROM oauth_authorization_codes WHERE id = 'code-1'" - ).first() - ).resolves.toEqual({ consumed_at: null, consumed_by: null }); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 1 }); - - await expect( - store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }) - ).resolves.toMatchObject({ credentialId: "browser-session-2" }); - await expect( - env.DB.prepare( - "SELECT consumed_by FROM oauth_authorization_codes WHERE id = 'code-1'" - ).first() - ).resolves.toEqual({ consumed_by: "browser-session-2" }); - }); -}); diff --git a/packages/control-plane/test/integration/oauth-flow-state.test.ts b/packages/control-plane/test/integration/oauth-flow-state.test.ts deleted file mode 100644 index a31ac6b57..000000000 --- a/packages/control-plane/test/integration/oauth-flow-state.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, expectTypeOf, it } from "vitest"; -import { hashToken } from "../../src/auth/crypto"; -import { - OAuthFlowVerifierIntegrityError, - type OAuthFlowVerifierCipher, -} from "../../src/auth/oauth-flow-verifier"; -import type { CreateOAuthFlowStateInput } from "../../src/auth/oauth-flow-state"; -import { ProviderPkceFlowCipher } from "../../src/auth/auth-encryption"; -import { - InvalidOAuthFlowStateInputError, - OAuthFlowStateStore, -} from "../../src/db/oauth-flow-state"; -import type { OAuthFlowStateConsumptionError } from "../../src/db/oauth-flow-state"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const ROOT_KEY_BASE64 = Buffer.from( - "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", - "hex" -).toString("base64"); -const STATE = "s".repeat(43); -const CLIENT_CHALLENGE = "c".repeat(43); -const PROVIDER_VERIFIER = "v".repeat(43); -const OIDC_NONCE = "n".repeat(43); - -describe("OAuthFlowStateStore", () => { - beforeEach(cleanD1Tables); - - function createStore( - now = NOW_MS, - verifierCipher: OAuthFlowVerifierCipher = new ProviderPkceFlowCipher(ROOT_KEY_BASE64) - ): OAuthFlowStateStore { - return new OAuthFlowStateStore(env.DB, verifierCipher, { - clock: { now: () => now }, - idGenerator: { generate: () => "flow-1" }, - tokenHasher: { hash: hashToken }, - }); - } - - it("stores only protected transaction values and consumes the bound flow once", async () => { - const store = createStore(); - await store.create({ - state: STATE, - provider: "google", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonce: OIDC_NONCE, - }); - - const row = await env.DB.prepare("SELECT * FROM oauth_flow_state").first(); - expect(row).toMatchObject({ - id: "flow-1", - state_hash: await hashToken(STATE), - provider: "google", - provider_pkce_key_version: 1, - oidc_nonce_hash: await hashToken(OIDC_NONCE), - consumed_at: null, - }); - expect(JSON.stringify(row)).not.toContain(STATE); - expect(JSON.stringify(row)).not.toContain(PROVIDER_VERIFIER); - expect(JSON.stringify(row)).not.toContain(OIDC_NONCE); - - const consumed = await store.consume(STATE, "google"); - expectTypeOf(consumed.oidcNonceHash).toEqualTypeOf(); - expect(consumed).toMatchObject({ - flowId: "flow-1", - provider: "google", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: await hashToken(OIDC_NONCE), - }); - await expect(store.consume(STATE, "google")).rejects.toEqual( - expect.objectContaining({ - name: "OAuthFlowStateConsumptionError", - rejection: "already_consumed", - }) - ); - }); - - it("does not consume a flow on provider mix-up", async () => { - const store = createStore(); - await store.create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - }); - - await expect(store.consume(STATE, "google")).rejects.toEqual( - expect.objectContaining({ - rejection: "provider_mismatch", - }) - ); - await expect(store.consume(STATE, "github")).resolves.toMatchObject({ - provider: "github", - }); - }); - - it("allows exactly one concurrent consumer", async () => { - const store = createStore(); - await store.create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - }); - - const results = await Promise.allSettled([ - store.consume(STATE, "github"), - store.consume(STATE, "github"), - ]); - - expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); - expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); - }); - - it("rejects a flow at the exact expiry boundary without consuming it", async () => { - await createStore().create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - }); - - const expiredStore = createStore(NOW_MS + 10 * 60 * 1000); - await expect(expiredStore.consume(STATE, "github")).rejects.toEqual( - expect.objectContaining({ - rejection: "expired", - }) - ); - await expect( - env.DB.prepare("SELECT consumed_at FROM oauth_flow_state WHERE id = 'flow-1'").first() - ).resolves.toEqual({ consumed_at: null }); - }); - - it("fails closed on ciphertext corruption without consuming the flow", async () => { - const store = createStore(); - await store.create({ - state: STATE, - provider: "google", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonce: OIDC_NONCE, - }); - await env.DB.prepare( - "UPDATE oauth_flow_state SET provider_pkce_verifier_ciphertext = ? WHERE id = ?" - ) - .bind(btoa("corrupt-ciphertext-that-is-long-enough"), "flow-1") - .run(); - - await expect(store.consume(STATE, "google")).rejects.toEqual( - expect.objectContaining({ - rejection: "corrupt", - }) - ); - await expect( - env.DB.prepare("SELECT consumed_at FROM oauth_flow_state WHERE id = 'flow-1'").first() - ).resolves.toEqual({ consumed_at: null }); - }); - - it("normalizes integrity failures declared by the cipher port", async () => { - await createStore().create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - }); - const failingCipher: OAuthFlowVerifierCipher = { - encrypt: async () => { - throw new Error("unexpected encryption"); - }, - decrypt: async () => { - throw new OAuthFlowVerifierIntegrityError(); - }, - }; - - await expect(createStore(NOW_MS, failingCipher).consume(STATE, "github")).rejects.toEqual( - expect.objectContaining({ - rejection: "corrupt", - }) - ); - }); - - it("rejects provider-inconsistent nonce input before writing state", async () => { - const store = createStore(); - await expect( - store.create({ - state: STATE, - provider: "google", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - } as unknown as CreateOAuthFlowStateInput) - ).rejects.toBeInstanceOf(InvalidOAuthFlowStateInputError); - await expect( - store.create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonce: OIDC_NONCE, - } as unknown as CreateOAuthFlowStateInput) - ).rejects.toBeInstanceOf(InvalidOAuthFlowStateInputError); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM oauth_flow_state").first() - ).resolves.toEqual({ count: 0 }); - }); -}); diff --git a/packages/control-plane/test/integration/oauth-provider-callback-service.test.ts b/packages/control-plane/test/integration/oauth-provider-callback-service.test.ts deleted file mode 100644 index 639756e36..000000000 --- a/packages/control-plane/test/integration/oauth-provider-callback-service.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { env } from "cloudflare:test"; -import { AdmissionPolicy } from "../../src/auth/admission-policy"; -import { hashToken } from "../../src/auth/crypto"; -import { BrowserSignInIdentityResolver } from "../../src/auth/browser-sign-in-identity"; -import { StaticOAuthClientRegistry } from "../../src/auth/oauth-authorization-service"; -import { createOAuthProviderCallbackHandlers } from "../../src/auth/oauth-provider-callback-handler"; -import { OAuthProviderCallbackService } from "../../src/auth/oauth-provider-callback-service"; -import { createPkceS256Challenge } from "../../src/auth/pkce"; -import type { - OAuthFlowVerifierBinding, - OAuthFlowVerifierCipher, -} from "../../src/auth/oauth-flow-verifier"; -import type { - ProviderCredentialCipherBinding, - ProviderCredentialCipherPort, -} from "../../src/auth/provider-credential-cipher"; -import type { OAuthSignInProviderRegistry } from "../../src/auth/providers/types"; -import { BrowserAuthSessionStore } from "../../src/db/browser-auth-sessions"; -import { BrowserSignInIdentityStore } from "../../src/db/browser-sign-in-identities"; -import { OAuthAuthorizationCodeStore } from "../../src/db/oauth-authorization-codes"; -import { OAuthFlowStateStore } from "../../src/db/oauth-flow-state"; -import { ProviderCredentialStore } from "../../src/db/provider-credentials"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const STATE = "s".repeat(43); -const CLIENT_VERIFIER = "c".repeat(43); -const PROVIDER_VERIFIER = "p".repeat(43); -const REDIRECT_URI = "https://web.example/api/auth/callback"; - -function testCipher(): { - encrypt(plaintext: string, binding: TBinding): Promise; - decrypt(ciphertext: string, binding: TBinding): Promise; -} { - return { - encrypt: async (plaintext, binding) => btoa(JSON.stringify({ plaintext, binding })), - decrypt: async (ciphertext, binding) => { - const parsed = JSON.parse(atob(ciphertext)) as { - plaintext: string; - binding: TBinding; - }; - expect(parsed.binding).toEqual(binding); - return parsed.plaintext; - }, - }; -} - -describe("OAuth provider callback transaction", () => { - beforeEach(cleanD1Tables); - - it("persists the exact identity and credential redeemed into the browser session", async () => { - const clock = { now: () => NOW_MS }; - const flowStore = new OAuthFlowStateStore( - env.DB, - testCipher() satisfies OAuthFlowVerifierCipher, - { - clock, - idGenerator: { generate: () => "flow-1" }, - tokenHasher: { hash: hashToken }, - } - ); - const providerCredentialStore = new ProviderCredentialStore( - env.DB, - testCipher() satisfies ProviderCredentialCipherPort, - clock - ); - const identityIds = ["user-1", "identity-1"]; - const identityResolver = new BrowserSignInIdentityResolver({ - clock, - idGenerator: { - generate: () => identityIds.shift() ?? "unexpected-identity-id", - }, - store: new BrowserSignInIdentityStore(env.DB, providerCredentialStore), - }); - const authorizationCodeIds = ["authorization-code-1", "browser-session-1"]; - const authorizationCodeStore = new OAuthAuthorizationCodeStore(env.DB, { - clock, - tokenHasher: { hash: hashToken }, - authorizationCodeGenerator: { - generate: () => `oi_code_${"a".repeat(43)}`, - }, - browserCredentialGenerator: { - generate: () => `oi_bsess_${"b".repeat(43)}`, - }, - idGenerator: { - generate: () => authorizationCodeIds.shift() ?? "unexpected-authorization-code-id", - }, - }); - const providers = { - github: { - provider: "github" as const, - createAuthorizationUrl: async () => new URL("https://github.com/login/oauth/authorize"), - exchangeAuthorizationCode: async () => ({ - identity: { - provider: "github" as const, - issuer: "https://github.com", - subject: "github-subject", - login: "octocat", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: { - kind: "access_only_nonexpiring" as const, - accessToken: "ghu_token", - }, - }), - }, - google: { - provider: "google" as const, - createAuthorizationUrl: async () => new URL("https://accounts.google.com/o/oauth2/v2/auth"), - exchangeAuthorizationCode: async () => { - throw new Error("Google provider was not selected"); - }, - }, - } satisfies OAuthSignInProviderRegistry; - const callbackService = new OAuthProviderCallbackService({ - clients: new StaticOAuthClientRegistry([REDIRECT_URI]), - providerHandlers: createOAuthProviderCallbackHandlers({ - providers, - flowStateStore: flowStore, - }), - admissionPolicy: new AdmissionPolicy({ - allowedGitHubUsers: [], - allowedEmails: ["person@example.com"], - allowedEmailDomains: [], - allowedGitHubOrganizations: [], - unsafeAllowAllUsers: false, - }), - identityResolver, - authorizationCodeStore, - }); - - await flowStore.create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: REDIRECT_URI, - clientCodeChallenge: await createPkceS256Challenge(CLIENT_VERIFIER), - providerPkceVerifier: PROVIDER_VERIFIER, - }); - const redirect = await callbackService.completeAuthorization("github", { - state: STATE, - code: "provider-code", - }); - const code = redirect.searchParams.get("code"); - if (code === null) throw new Error("Callback did not return an authorization code"); - - const browserSession = await authorizationCodeStore.redeem({ - code, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CLIENT_VERIFIER, - }); - const authenticated = await new BrowserAuthSessionStore(env.DB, { - clock, - credentialGenerator: { generate: () => "unused" }, - idGenerator: { generate: () => "unused" }, - tokenHasher: { hash: hashToken }, - }).authenticate(browserSession.credential); - - expect(authenticated).toMatchObject({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - await expect(providerCredentialStore.get("identity-1")).resolves.toMatchObject({ - kind: "access_only_nonexpiring", - accessToken: "ghu_token", - rowVersion: 1, - }); - }); -}); diff --git a/packages/control-plane/test/integration/provider-credentials.test.ts b/packages/control-plane/test/integration/provider-credentials.test.ts deleted file mode 100644 index dce988c26..000000000 --- a/packages/control-plane/test/integration/provider-credentials.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; -import { ProviderCredentialCipher } from "../../src/auth/auth-encryption"; -import type { ProviderCredentialCipherPort } from "../../src/auth/provider-credential-cipher"; -import { - PROVIDER_CREDENTIAL_ROW_VERSION_CHECK, - ProviderCredentialStore, - ProviderCredentialVersionConflictError, - StoredProviderCredentialCorruptError, -} from "../../src/db/provider-credentials"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const ROOT_KEY_BASE64 = Buffer.from( - "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", - "hex" -).toString("base64"); - -describe("ProviderCredentialStore", () => { - let store: ProviderCredentialStore; - - beforeEach(async () => { - await cleanD1Tables(); - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO users - (id, display_name, email, avatar_url, created_at, updated_at) - VALUES ('user-1', NULL, NULL, NULL, ?, ?)` - ).bind(NOW_MS, NOW_MS), - env.DB.prepare( - `INSERT INTO user_identities - (id, user_id, provider, provider_issuer, provider_user_id, created_at) - VALUES ( - 'identity-1', 'user-1', 'github', 'https://github.com', - 'github-subject', ? - )` - ).bind(NOW_MS), - ]); - store = new ProviderCredentialStore(env.DB, new ProviderCredentialCipher(ROOT_KEY_BASE64), { - now: () => NOW_MS, - }); - }); - - it("pins the row-version check used to detect stale sign-in writes", async () => { - await expect( - env.DB.prepare( - `INSERT INTO provider_credentials ( - provider_identity_id, credential_kind, - access_token_ciphertext, access_expires_at, - refresh_token_ciphertext, refresh_expires_at, - encryption_key_version, row_version, updated_at - ) VALUES (?, 'access_only_nonexpiring', ?, NULL, NULL, NULL, 1, 0, ?)` - ) - .bind("identity-1", "ciphertext", NOW_MS) - .run() - ).rejects.toThrow(`CHECK constraint failed: ${PROVIDER_CREDENTIAL_ROW_VERSION_CHECK}`); - }); - - it("round-trips refreshable credentials without storing plaintext tokens", async () => { - await expect( - store.upsertFromSignIn("identity-1", { - kind: "refreshable", - accessToken: "github-access-token", - accessExpiresAt: NOW_MS + 60_000, - refreshToken: "github-refresh-token", - refreshExpiresAt: null, - }) - ).resolves.toBe(1); - await expect(store.get("identity-1")).resolves.toEqual({ - providerIdentityId: "identity-1", - kind: "refreshable", - accessToken: "github-access-token", - accessExpiresAt: NOW_MS + 60_000, - refreshToken: "github-refresh-token", - refreshExpiresAt: null, - encryptionKeyVersion: 1, - rowVersion: 1, - updatedAt: NOW_MS, - }); - - const persisted = await env.DB.prepare( - `SELECT access_token_ciphertext, refresh_token_ciphertext - FROM provider_credentials - WHERE provider_identity_id = 'identity-1'` - ).first(); - expect(JSON.stringify(persisted)).not.toContain("github-access-token"); - expect(JSON.stringify(persisted)).not.toContain("github-refresh-token"); - }); - - it("does not let stale invalidation delete credentials from a newer sign-in", async () => { - const firstVersion = await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "old-access-token", - }); - - const secondVersion = await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "new-access-token", - }); - - await expect(store.invalidateObservedVersion("identity-1", firstVersion)).resolves.toBe(false); - await expect(store.get("identity-1")).resolves.toMatchObject({ - accessToken: "new-access-token", - rowVersion: secondVersion, - }); - }); - - it("serializes concurrent sign-ins through row-version retries", async () => { - const versions = await Promise.all([ - store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "first-concurrent-token", - }), - store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "second-concurrent-token", - }), - ]); - - expect(versions.sort()).toEqual([1, 2]); - await expect(store.get("identity-1")).resolves.toMatchObject({ - rowVersion: 2, - }); - }); - - it("does not let a stale refresh overwrite credentials from a newer sign-in", async () => { - const observedVersion = await store.upsertFromSignIn("identity-1", { - kind: "refreshable", - accessToken: "expired-access-token", - accessExpiresAt: NOW_MS - 1, - refreshToken: "refresh-token", - refreshExpiresAt: null, - }); - const signInVersion = await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "new-sign-in-token", - }); - - await expect( - store.replaceObservedVersion("identity-1", observedVersion, { - kind: "refreshable", - accessToken: "stale-refresh-access-token", - accessExpiresAt: NOW_MS + 60_000, - refreshToken: "stale-refresh-token", - refreshExpiresAt: null, - }) - ).rejects.toBeInstanceOf(ProviderCredentialVersionConflictError); - await expect(store.get("identity-1")).resolves.toMatchObject({ - accessToken: "new-sign-in-token", - rowVersion: signInVersion, - }); - }); - - it("replaces the exact credential version observed by a provider refresh", async () => { - const observedVersion = await store.upsertFromSignIn("identity-1", { - kind: "refreshable", - accessToken: "expired-access-token", - accessExpiresAt: NOW_MS - 1, - refreshToken: "refresh-token", - refreshExpiresAt: null, - }); - - await expect( - store.replaceObservedVersion("identity-1", observedVersion, { - kind: "refreshable", - accessToken: "refreshed-access-token", - accessExpiresAt: NOW_MS + 60_000, - refreshToken: "rotated-refresh-token", - refreshExpiresAt: NOW_MS + 120_000, - }) - ).resolves.toBe(2); - await expect(store.get("identity-1")).resolves.toMatchObject({ - accessToken: "refreshed-access-token", - refreshToken: "rotated-refresh-token", - refreshExpiresAt: NOW_MS + 120_000, - rowVersion: 2, - }); - }); - - it("supports both current access-only credential shapes", async () => { - await store.upsertFromSignIn("identity-1", { - kind: "access_only_expiring", - accessToken: "expiring-access-token", - accessExpiresAt: NOW_MS + 60_000, - }); - await expect(store.get("identity-1")).resolves.toMatchObject({ - kind: "access_only_expiring", - accessToken: "expiring-access-token", - accessExpiresAt: NOW_MS + 60_000, - rowVersion: 1, - }); - - await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "nonexpiring-access-token", - }); - await expect(store.get("identity-1")).resolves.toEqual({ - providerIdentityId: "identity-1", - kind: "access_only_nonexpiring", - accessToken: "nonexpiring-access-token", - encryptionKeyVersion: 1, - rowVersion: 2, - updatedAt: NOW_MS, - }); - }); - - it("fails closed when ciphertext is moved to a different row version", async () => { - await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "access-token", - }); - await env.DB.prepare( - `UPDATE provider_credentials - SET row_version = 2 - WHERE provider_identity_id = 'identity-1'` - ).run(); - - await expect(store.get("identity-1")).rejects.toBeInstanceOf( - StoredProviderCredentialCorruptError - ); - }); - - it("composes initial credential creation into an identity-owned atomic batch", async () => { - const credentialInsert = await store.prepareInitialInsert("identity-2", { - kind: "access_only_nonexpiring", - accessToken: "new-identity-access-token", - }); - - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO users - (id, display_name, email, avatar_url, created_at, updated_at) - VALUES ('user-2', NULL, NULL, NULL, ?, ?)` - ).bind(NOW_MS, NOW_MS), - env.DB.prepare( - `INSERT INTO user_identities - (id, user_id, provider, provider_issuer, provider_user_id, created_at) - VALUES ( - 'identity-2', 'user-2', 'github', 'https://github.com', - 'github-subject-2', ? - )` - ).bind(NOW_MS), - credentialInsert, - ]); - - await expect(store.get("identity-2")).resolves.toMatchObject({ - providerIdentityId: "identity-2", - accessToken: "new-identity-access-token", - rowVersion: 1, - }); - }); - - it("makes a stale prepared sign-in mutation fail its caller-owned batch atomically", async () => { - await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "initial-access-token", - }); - const staleCredentialMutation = await store.prepareSignInUpsert("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "stale-sign-in-token", - }); - await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "concurrent-sign-in-token", - }); - - let rejection: unknown; - try { - await env.DB.batch([ - env.DB.prepare("UPDATE users SET display_name = 'must-roll-back' WHERE id = 'user-1'"), - staleCredentialMutation, - ]); - } catch (error) { - rejection = error; - } - - expect(rejection).toBeInstanceOf(Error); - expect(store.isSignInVersionConflict(rejection)).toBe(true); - await expect( - env.DB.prepare("SELECT display_name FROM users WHERE id = 'user-1'").first() - ).resolves.toEqual({ display_name: null }); - await expect(store.get("identity-1")).resolves.toMatchObject({ - accessToken: "concurrent-sign-in-token", - rowVersion: 2, - }); - }); - - it("fails closed when authenticated ciphertext decodes to an empty token", async () => { - const emptyPlaintextCipher: ProviderCredentialCipherPort = { - encrypt: async () => "authenticated-ciphertext", - decrypt: async () => "", - }; - const corruptStore = new ProviderCredentialStore(env.DB, emptyPlaintextCipher, { - now: () => NOW_MS, - }); - await corruptStore.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "nonempty-input-token", - }); - - await expect(corruptStore.get("identity-1")).rejects.toBeInstanceOf( - StoredProviderCredentialCorruptError - ); - }); -}); diff --git a/packages/control-plane/test/integration/service-auth.test.ts b/packages/control-plane/test/integration/service-auth.test.ts index ad70cb46f..3e6f297ba 100644 --- a/packages/control-plane/test/integration/service-auth.test.ts +++ b/packages/control-plane/test/integration/service-auth.test.ts @@ -47,8 +47,10 @@ async function signedFetch(p: { describe("sig1 service-credential authentication", () => { beforeEach(cleanD1Tables); - it("accepts a signed GET from every registered service", async () => { - for (const service of Object.keys(SERVICE_SECRET) as ServiceName[]) { + it("accepts a signed GET from every non-web service", async () => { + for (const service of Object.keys(SERVICE_SECRET).filter( + (candidate): candidate is Exclude => candidate !== "web" + )) { const response = await signedFetch({ service, method: "GET", @@ -60,12 +62,21 @@ describe("sig1 service-credential authentication", () => { } }); + it("requires a browser session in addition to the web service channel", async () => { + const response = await signedFetch({ + service: "web", + method: "GET", + url: "https://test.local/sessions", + }); + expect(response.status).toBe(401); + }); + it("accepts a signed request with a query string regardless of param order", async () => { const createdBy = "a".repeat(32); const signedUrl = `https://test.local/sessions?limit=5&createdBy=${createdBy}`; const headers = await buildServiceAuthHeaders({ - service: "web", - secret: SERVICE_SECRET.web, + service: "modal", + secret: SERVICE_SECRET.modal, method: "GET", url: signedUrl, }); @@ -80,7 +91,7 @@ describe("sig1 service-credential authentication", () => { it("delivers the signed body intact to the handler (D1 write lands)", async () => { const response = await signedFetch({ - service: "web", + service: "modal", method: "PUT", url: "https://test.local/secrets", body: JSON.stringify({ secrets: { SIGNED_BODY_TEST: "intact" } }), @@ -94,32 +105,29 @@ describe("sig1 service-credential authentication", () => { expect(secrets.SIGNED_BODY_TEST).toBe("intact"); }); - it("does not let the web service credential mutate provider identities", async () => { - const response = await signedFetch({ - service: "web", - method: "PUT", - url: "https://test.local/provider-identities/github/424242", - body: JSON.stringify({ providerEmail: "victim@example.com" }), - }); - - expect(response.status).toBe(403); - }); - it("rejects a body tampered after signing", async () => { - const url = "https://test.local/provider-identities/github/424242"; + const url = "https://test.local/secrets"; + const intactBody = JSON.stringify({ secrets: { SIGNED_BODY_TEST: "intact" } }); const headers = await buildServiceAuthHeaders({ - service: "web", - secret: SERVICE_SECRET.web, + service: "modal", + secret: SERVICE_SECRET.modal, method: "PUT", url, - body: JSON.stringify({ providerLogin: "octocat" }), + body: intactBody, }); - const response = await SELF.fetch(url, { + const intact = await SELF.fetch(url, { method: "PUT", headers: { "Content-Type": "application/json", ...headers }, - body: JSON.stringify({ providerLogin: "evilcat" }), + body: intactBody, }); - expect(response.status).toBe(401); + expect(intact.status).toBe(200); + + const tampered = await SELF.fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ secrets: { SIGNED_BODY_TEST: "tampered" } }), + }); + expect(tampered.status).toBe(401); }); it("rejects a signature replayed against a different method or path", async () => { @@ -201,9 +209,10 @@ describe("sig1 service-credential authentication", () => { const identity = await new UserStore(env.DB).getIdentity("slack", "U0001"); expect(identity).not.toBeNull(); const listed = await signedFetch({ - service: "web", + service: "slack-bot", method: "GET", url: "https://test.local/sessions", + actor: "slack:U0001", }); const body = await listed.json<{ sessions: Array<{ title: string; userId: string; spawnSource: string }>; @@ -228,7 +237,7 @@ describe("sig1 service-credential authentication", () => { model: "anthropic/claude-haiku-4-5", }), }); - expect(response.status, service).toBe(403); + expect(response.status, service).toBe(service === "web" ? 401 : 403); } const sessionCount = await env.DB.prepare("SELECT COUNT(*) AS n FROM sessions").first<{ diff --git a/packages/control-plane/tsconfig.test.json b/packages/control-plane/tsconfig.test.json index cdad2f0c0..51cd3baef 100644 --- a/packages/control-plane/tsconfig.test.json +++ b/packages/control-plane/tsconfig.test.json @@ -1,8 +1,9 @@ { // Test-only typecheck pass. Co-located *.test.ts files use Node APIs // (node:crypto, node:fs, import.meta.url) that the production tsconfig.json - // deliberately excludes — the worker has no `nodejs_compat`, so Node types - // must stay off the production type surface (see tsconfig.json). + // deliberately excludes. Production enables `nodejs_compat` for audited + // dependencies, while Node globals stay off the application type surface so + // new runtime dependencies remain explicit (see tsconfig.json). "extends": "./tsconfig.json", "compilerOptions": { "types": ["@cloudflare/workers-types", "node"] diff --git a/packages/control-plane/vitest.integration.config.ts b/packages/control-plane/vitest.integration.config.ts index 3be880dd4..a3bb08c87 100644 --- a/packages/control-plane/vitest.integration.config.ts +++ b/packages/control-plane/vitest.integration.config.ts @@ -45,6 +45,10 @@ export default defineConfig({ configPath: "./wrangler.jsonc", }, miniflare: { + // Match the Terraform-managed production runtime. The Vitest pool + // otherwise defaults its runner to today's compatibility date. + compatibilityDate: "2024-09-23", + compatibilityFlags: ["nodejs_compat"], bindings: { IMAGE_CALLBACK_TOKEN_PEPPER: "test-callback-pepper", SERVICE_AUTH_SECRET_WEB: "test-service-secret-web", @@ -52,6 +56,12 @@ export default defineConfig({ SERVICE_AUTH_SECRET_GITHUB_BOT: "test-service-secret-github-bot", SERVICE_AUTH_SECRET_LINEAR_BOT: "test-service-secret-linear-bot", SERVICE_AUTH_SECRET_MODAL: "test-service-secret-modal", + BROWSER_AUTH_SECRET: "test-browser-auth-secret-with-at-least-32-characters", + GITHUB_CLIENT_ID: "github-app-client-id", + GITHUB_CLIENT_SECRET: "github-app-client-secret", + GOOGLE_CLIENT_ID: "google-client-id", + GOOGLE_CLIENT_SECRET: "google-client-secret", + UNSAFE_ALLOW_ALL_USERS: "true", // Must be valid base64 for 32 bytes — the exchange route's SCM // capture encrypts with it inline (fail-closed) rather than // inside a swallowed waitUntil. @@ -72,5 +82,26 @@ export default defineConfig({ test: { include: ["test/integration/**/*.test.ts"], setupFiles: ["test/integration/apply-migrations.ts"], + onUnhandledError(error) { + // Better Auth implements OAuth callback redirects as thrown APIError + // values. Its handler catches and converts them to the expected 3xx + // response, but the Workers pool reports the intermediate rejection as + // unhandled. Filter only that library-owned redirect control flow; every + // other unhandled error remains fatal. + const betterAuthStack = + "errorStack" in error && typeof error.errorStack === "string" + ? error.errorStack + : error.stack; + if ( + error.name === "APIError" && + "statusCode" in error && + typeof error.statusCode === "number" && + error.statusCode >= 300 && + error.statusCode < 400 && + betterAuthStack?.includes("/better-auth/dist/api/routes/") + ) { + return false; + } + }, }, }); diff --git a/packages/control-plane/wrangler.jsonc b/packages/control-plane/wrangler.jsonc index 07d90ce4c..a42e934a7 100644 --- a/packages/control-plane/wrangler.jsonc +++ b/packages/control-plane/wrangler.jsonc @@ -2,7 +2,8 @@ { "name": "open-inspect-control-plane-test", "main": "src/index.ts", - "compatibility_date": "2024-12-30", + "compatibility_date": "2024-09-23", + "compatibility_flags": ["nodejs_compat"], "durable_objects": { "bindings": [ { "name": "SESSION", "class_name": "SessionDO" }, diff --git a/packages/shared/src/browser-auth-routes.test.ts b/packages/shared/src/browser-auth-routes.test.ts new file mode 100644 index 000000000..d0122e5df --- /dev/null +++ b/packages/shared/src/browser-auth-routes.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { BROWSER_AUTH_PROXY_ROUTES, isBrowserAuthProxyRoute } from "./browser-auth-routes"; + +describe("browser auth proxy route contract", () => { + it("keeps the cross-service allowlist exact and method-bound", () => { + expect(BROWSER_AUTH_PROXY_ROUTES).toEqual([ + ["POST", "/api/auth/sign-in/social"], + ["GET", "/api/auth/callback/github"], + ["GET", "/api/auth/callback/google"], + ["GET", "/api/auth/get-session"], + ["POST", "/api/auth/sign-out"], + ["GET", "/api/auth/error"], + ]); + + expect(isBrowserAuthProxyRoute("get", "/api/auth/get-session")).toBe(true); + expect(isBrowserAuthProxyRoute("POST", "/api/auth/get-session")).toBe(false); + expect(isBrowserAuthProxyRoute("GET", "/api/auth/get-session/extra")).toBe(false); + }); +}); diff --git a/packages/shared/src/browser-auth-routes.ts b/packages/shared/src/browser-auth-routes.ts new file mode 100644 index 000000000..db32f4a2d --- /dev/null +++ b/packages/shared/src/browser-auth-routes.ts @@ -0,0 +1,22 @@ +/** + * Exact browser-reachable Better Auth surface shared by the web BFF and + * control plane. Keeping one declaration prevents either side from silently + * widening or narrowing the signed proxy contract. + */ +export const BROWSER_AUTH_CLIENT_IP_HEADER = "X-OpenInspect-Client-IP"; + +export const BROWSER_AUTH_PROXY_ROUTES = [ + ["POST", "/api/auth/sign-in/social"], + ["GET", "/api/auth/callback/github"], + ["GET", "/api/auth/callback/google"], + ["GET", "/api/auth/get-session"], + ["POST", "/api/auth/sign-out"], + ["GET", "/api/auth/error"], +] as const; + +export function isBrowserAuthProxyRoute(method: string, path: string): boolean { + const normalizedMethod = method.toUpperCase(); + return BROWSER_AUTH_PROXY_ROUTES.some( + ([allowedMethod, allowedPath]) => allowedMethod === normalizedMethod && allowedPath === path + ); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 485998259..ab96cb6bc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,4 +16,5 @@ export * from "./logger"; export * from "./cache-store"; export * from "./app-name"; export * from "./user-id"; +export * from "./browser-auth-routes"; export * from "./slack"; diff --git a/packages/web/.env.example b/packages/web/.env.example index 8a4d55c49..8e1200b0d 100644 --- a/packages/web/.env.example +++ b/packages/web/.env.example @@ -1,23 +1,8 @@ -# GitHub OAuth (create at https://github.com/settings/developers) -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= - -# Google OAuth (optional — enables "Sign in with Google"). -# Create an OAuth 2.0 Web Client at https://console.cloud.google.com/apis/credentials -# Authorized redirect URI: http://localhost:3000/api/auth/callback/google -# Set both to enable Google login; leave both empty for GitHub-only. -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -# Build-time flag that reveals the "Sign in with Google" button. Set to "true" -# when Google is configured. NEXT_PUBLIC_* vars are inlined at build time — -# restart `npm run dev` after changing. (For local dev only: deployed -# environments derive this automatically from local.google_enabled in Terraform.) +# Provider credentials and admission policy live in the control plane. +# Match this build-time flag to the providers enabled there, then restart the +# development server because NEXT_PUBLIC_* values are inlined at build time. NEXT_PUBLIC_GOOGLE_ENABLED= -# NextAuth (generate with: openssl rand -base64 32) -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET= - # Control Plane CONTROL_PLANE_URL=https://open-inspect-control-plane.YOUR-ACCOUNT.workers.dev NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane.YOUR-ACCOUNT.workers.dev @@ -27,19 +12,3 @@ NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane.YOUR-ACCOUNT.workers.dev # generates it — read it from terraform state or the deployed web app's env; # do not generate your own against an existing backend). SERVICE_AUTH_SECRET= - -# Access Control (comma-separated; configure at least one allowlist unless UNSAFE_ALLOW_ALL_USERS=true) -# OR-based: a user is admitted if they match ANY allowlist below (username, email -# domain, exact email, or active GitHub org membership). -ALLOWED_EMAIL_DOMAINS= -ALLOWED_USERS= -# Exact email addresses (matched against any provider's verified email) — for -# individual users on shared domains like gmail.com where a domain allowlist -# would be too broad. -ALLOWED_EMAILS= -# GitHub organizations whose active members can sign in. Requests read:org only -# when set, then checks active org membership with the signing-in user's OAuth -# token. Existing sessions last until session expiry. Requires GitHub App -# Organization permissions: Members read-only. -ALLOWED_GITHUB_ORGS= -UNSAFE_ALLOW_ALL_USERS=false diff --git a/packages/web/README.md b/packages/web/README.md index 3224bcea0..53d5f969c 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -4,7 +4,7 @@ Next.js web application for interacting with Open-Inspect coding sessions. ## Features -- GitHub OAuth authentication +- GitHub and optional Google authentication through the control plane - Session dashboard with list view - Real-time streaming via WebSocket - Message timeline with tool calls @@ -25,7 +25,7 @@ Next.js web application for interacting with Open-Inspect coding sessions. │ └──────────────────────────────────────────────────────────┘ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ API Routes │ │ -│ │ /api/auth/[...nextauth] - GitHub OAuth │ │ +│ │ /api/auth/[...auth] - Signed auth proxy │ │ │ │ /api/sessions - Session CRUD │ │ │ │ /api/repos - Repository list │ │ │ │ /api/repos/:owner/:name/secrets - Secrets CRUD │ │ @@ -73,34 +73,18 @@ Required permissions for the GitHub App: Create `.env.local`: ```bash -# GitHub App (for user authentication) -GITHUB_CLIENT_ID=your_github_app_client_id -GITHUB_CLIENT_SECRET=your_github_app_client_secret - -# NextAuth -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=your_random_secret # Generate: openssl rand -base64 32 - -# Access Control -ALLOWED_USERS=username1,username2 # Comma-separated GitHub usernames -ALLOWED_EMAIL_DOMAINS=example.com,corp.io # Comma-separated email domains -ALLOWED_GITHUB_ORGS=acme,umbrella # Comma-separated GitHub orgs with active members allowed -UNSAFE_ALLOW_ALL_USERS=false # Set true to explicitly allow all users when all lists are empty - # Control Plane CONTROL_PLANE_URL=http://localhost:8787 NEXT_PUBLIC_WS_URL=ws://localhost:8787 +SERVICE_AUTH_SECRET=your_web_service_sig1_secret + +# Match the control plane's enabled providers +NEXT_PUBLIC_GOOGLE_ENABLED=false ``` -> **Access Control**: If `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, and `ALLOWED_GITHUB_ORGS` are all -> empty, sign-in is denied unless `UNSAFE_ALLOW_ALL_USERS=true`. For Terraform-managed production -> deploys, Terraform also fails validation unless you set at least one allowlist or explicitly opt -> in with `unsafe_allow_all_users = true`. **Allowlists use OR semantics**: matching any configured -> username, email domain, or active GitHub org membership grants access. `ALLOWED_GITHUB_ORGS` is -> checked at sign-in with the signing-in user's OAuth token; existing sessions last until session -> expiry. The `read:org` OAuth scope is requested only when `ALLOWED_GITHUB_ORGS` is configured. -> GitHub Apps using org access need Organization permissions: Members read-only; existing GitHub -> Apps must republish/request approval after that permission changes. +The web app is a framework-free BFF. It signs requests with `SERVICE_AUTH_SECRET`, forwards only +Better Auth's opaque session cookie, and does not hold OAuth provider credentials or admission +policy. Configure those on the control plane through Terraform. ### Development diff --git a/packages/web/package.json b/packages/web/package.json index 4ddcda179..35f849434 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -35,7 +35,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "next": "^16.2.11", - "next-auth": "^4.24.15", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/web/src/app/access-denied/page.tsx b/packages/web/src/app/access-denied/page.tsx index d1d8463a5..eb570cd49 100644 --- a/packages/web/src/app/access-denied/page.tsx +++ b/packages/web/src/app/access-denied/page.tsx @@ -9,9 +9,8 @@ function AccessDeniedContent() { const searchParams = useSearchParams(); const error = searchParams.get("error"); - // NextAuth passes error=AccessDenied when signIn callback returns false const message = - error === "AccessDenied" + error === "AccessDenied" || error === "access_denied" ? "Your account is not authorized to use this application." : "An error occurred during sign in. Please try again."; diff --git a/packages/web/src/app/api/auth/[...auth]/route.test.ts b/packages/web/src/app/api/auth/[...auth]/route.test.ts new file mode 100644 index 000000000..45a25b845 --- /dev/null +++ b/packages/web/src/app/api/auth/[...auth]/route.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + proxyBrowserAuthRequest: vi.fn(), +})); + +vi.mock("@/lib/browser-auth-proxy", () => ({ + proxyBrowserAuthRequest: mocks.proxyBrowserAuthRequest, +})); + +import { GET, POST } from "./route"; + +describe("/api/auth/*", () => { + it.each([ + ["GET", GET], + ["POST", POST], + ] as const)("passes %s requests to the positive browser-auth proxy", async (_method, handler) => { + const upstream = new Response("proxied", { status: 202 }); + mocks.proxyBrowserAuthRequest.mockResolvedValueOnce(upstream); + const request = new Request("https://app.example/api/auth/get-session", { + method: _method, + }); + + await expect(handler(request)).resolves.toBe(upstream); + expect(mocks.proxyBrowserAuthRequest).toHaveBeenCalledWith(request); + }); +}); diff --git a/packages/web/src/app/api/auth/[...auth]/route.ts b/packages/web/src/app/api/auth/[...auth]/route.ts new file mode 100644 index 000000000..f4dc07ffb --- /dev/null +++ b/packages/web/src/app/api/auth/[...auth]/route.ts @@ -0,0 +1,4 @@ +import { proxyBrowserAuthRequest } from "@/lib/browser-auth-proxy"; + +export const GET = proxyBrowserAuthRequest; +export const POST = proxyBrowserAuthRequest; diff --git a/packages/web/src/app/api/auth/[...nextauth]/route.ts b/packages/web/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 7b38c1bb4..000000000 --- a/packages/web/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import NextAuth from "next-auth"; -import { authOptions } from "@/lib/auth"; - -const handler = NextAuth(authOptions); - -export { handler as GET, handler as POST }; diff --git a/packages/web/src/app/api/auth/oi-refresh/route.test.ts b/packages/web/src/app/api/auth/oi-refresh/route.test.ts deleted file mode 100644 index 88edd87e9..000000000 --- a/packages/web/src/app/api/auth/oi-refresh/route.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { encode, decode } from "next-auth/jwt"; - -vi.mock("next/headers", () => ({ - cookies: vi.fn(), -})); - -vi.mock("@/lib/control-plane-transport", () => ({ - controlPlaneTokenFetch: vi.fn(), -})); - -import { cookies } from "next/headers"; -import { controlPlaneTokenFetch } from "@/lib/control-plane-transport"; -import { OI_ACCESS_TOKEN_RENEW_WINDOW_MS } from "@/lib/oi-session"; -import { POST } from "./route"; - -const tokenFetch = vi.mocked(controlPlaneTokenFetch); - -const SECRET = "test-nextauth-secret-for-oi-refresh"; -const SECURE_COOKIE = "__Secure-next-auth.session-token"; - -const FRESH_PAIR = { - accessToken: "oi_at_rotated", - accessTokenExpiresAtEpochMs: Date.now() + 8 * 60 * 60 * 1000, - refreshToken: "oi_rt_rotated", - refreshTokenExpiresAtEpochMs: Date.now() + 30 * 24 * 60 * 60 * 1000, -}; - -interface SetCall { - name: string; - value: string; - options: { maxAge: number }; -} - -function fakeCookieStore(initial: Record) { - const sets: SetCall[] = []; - const store = { - sets, - getAll: () => Object.entries(initial).map(([name, value]) => ({ name, value })), - set: (name: string, value: string, options: SetCall["options"]) => { - sets.push({ name, value, options }); - }, - }; - vi.mocked(cookies).mockResolvedValue(store as never); - return store; -} - -async function encodeSession(oiFields: Record): Promise { - return encode({ - token: { sub: "user-1", provider: "github", ...oiFields }, - secret: SECRET, - }); -} - -beforeEach(() => { - tokenFetch.mockReset(); - vi.mocked(cookies).mockReset(); - vi.stubEnv("NEXTAUTH_SECRET", SECRET); - vi.stubEnv("NEXTAUTH_URL", "https://open-inspect.example"); -}); - -describe("POST /api/auth/oi-refresh", () => { - it("rotates a near-expiry pair and persists the re-encoded session cookie", async () => { - tokenFetch.mockResolvedValue(new Response(JSON.stringify(FRESH_PAIR), { status: 200 })); - const jwt = await encodeSession({ - oiAccessToken: "oi_at_near_expiry", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS - 60_000, - oiRefreshToken: "oi_rt_current", - }); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - const body = (await response.json()) as { renewed: boolean }; - - expect(response.status).toBe(200); - expect(body.renewed).toBe(true); - expect(tokenFetch).toHaveBeenCalledWith("/auth/tokens/refresh", { - method: "POST", - body: JSON.stringify({ refreshToken: "oi_rt_current" }), - }); - - // The rotated pair must round-trip out of the persisted cookie, and the - // untouched claims must survive the re-encode. - const written = store.sets.find((s) => s.name === SECURE_COOKIE && s.options.maxAge > 0); - expect(written).toBeDefined(); - const decoded = await decode({ token: written!.value, secret: SECRET }); - expect(decoded).toMatchObject({ - sub: "user-1", - provider: "github", - oiAccessToken: "oi_at_rotated", - oiRefreshToken: "oi_rt_rotated", - }); - }); - - it("does not write when the pair is still fresh", async () => { - const jwt = await encodeSession({ - oiAccessToken: "oi_at_live", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS + 60_000, - oiRefreshToken: "oi_rt_live", - }); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - const body = (await response.json()) as { renewed: boolean }; - - expect(response.status).toBe(200); - expect(body.renewed).toBe(false); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(store.sets).toHaveLength(0); - }); - - it("requires reauthentication when the NextAuth session predates OI token exchange", async () => { - const jwt = await encodeSession({}); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - - expect(response.status).toBe(401); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(store.sets).toHaveLength(0); - }); - - it("persists cleared fields and requires reauthentication when the refresh grant is dead", async () => { - tokenFetch.mockResolvedValue( - new Response(JSON.stringify({ error: "refresh_reuse_detected" }), { status: 401 }) - ); - const jwt = await encodeSession({ - oiAccessToken: "oi_at_stolen", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS - 60_000, - oiRefreshToken: "oi_rt_stolen", - }); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - - const written = store.sets.find((s) => s.name === SECURE_COOKIE && s.options.maxAge > 0); - const decoded = await decode({ token: written!.value, secret: SECRET }); - expect(decoded?.oiAccessToken).toBeUndefined(); - expect(decoded?.oiRefreshToken).toBeUndefined(); - expect(response.status).toBe(401); - }); - - it("returns a retryable failure without clearing the cookie when refresh is temporarily unavailable", async () => { - tokenFetch.mockRejectedValue(new Error("control plane unavailable")); - const jwt = await encodeSession({ - oiAccessToken: "oi_at_expired", - oiAccessTokenExpiresAt: Date.now() - 60_000, - oiRefreshToken: "oi_rt_retryable", - }); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - - expect(response.status).toBe(503); - expect(tokenFetch).toHaveBeenCalledWith("/auth/tokens/refresh", { - method: "POST", - body: JSON.stringify({ refreshToken: "oi_rt_retryable" }), - }); - expect(store.sets).toHaveLength(0); - }); - - it("401s when there is no decodable session", async () => { - fakeCookieStore({}); - const response = await POST(); - expect(response.status).toBe(401); - }); -}); diff --git a/packages/web/src/app/api/auth/oi-refresh/route.ts b/packages/web/src/app/api/auth/oi-refresh/route.ts deleted file mode 100644 index c938355a1..000000000 --- a/packages/web/src/app/api/auth/oi-refresh/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Client-invoked renewal of the web session token pair (`oi_at_`/`oi_rt_`). - * - * This is the ONLY place the rotating refresh grant is redeemed. NextAuth v4's - * jwt callback also runs under `getServerSession`, which cannot persist a - * rotated cookie — redeeming there would orphan the cookie's refresh token — - * so renewal lives in this route handler, which re-encodes the session JWT - * and writes it back (chunk-aware) in the same response. - * - * The client calls this route on mount, on window focus, and on an interval - * comfortably inside the renew window (see `WebSessionGate`). - * Concurrent refresh requests from multiple tabs are safe within the control - * plane's refresh-reuse grace window; the remaining stale-writer race is - * documented in `renewWebSessionTokens` and requires the Phase B cookie - * redesign. - */ - -import { NextResponse } from "next/server"; -import { getToken, encode } from "next-auth/jwt"; -import { cookies } from "next/headers"; -import { createLogger } from "@/lib/logger"; -import { renewWebSessionTokens } from "@/lib/oi-session"; -import { SESSION_COOKIE_MAX_AGE_SECONDS, writeSessionCookie } from "@/lib/session-cookie"; - -const log = createLogger("oi-refresh"); - -export async function POST(): Promise { - const secret = process.env.NEXTAUTH_SECRET; - if (!secret) { - log.error("oi_refresh.misconfigured", { reason: "NEXTAUTH_SECRET not configured" }); - return NextResponse.json({ error: "Auth not configured" }, { status: 500 }); - } - - const cookieStore = await cookies(); - const cookiePairs = Object.fromEntries( - cookieStore.getAll().map((cookie) => [cookie.name, cookie.value]) - ); - // getToken reads req.cookies only — pass the parsed pairs so chunked - // session cookies reassemble (same contract as oi-session's reader). - const token = await getToken({ - req: { headers: {}, cookies: cookiePairs } as Parameters[0]["req"], - }); - if (!token) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (!token.oiAccessToken || !token.oiAccessTokenExpiresAt || !token.oiRefreshToken) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const renewal = await renewWebSessionTokens(token); - if (renewal.changed) { - const encoded = await encode({ token, secret, maxAge: SESSION_COOKIE_MAX_AGE_SECONDS }); - writeSessionCookie(cookieStore, encoded); - } - if (renewal.status === "unauthenticated") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (renewal.status === "temporarily_unavailable") { - return NextResponse.json({ error: "Authentication temporarily unavailable" }, { status: 503 }); - } - - return NextResponse.json({ - renewed: renewal.changed, - accessTokenExpiresAt: token.oiAccessTokenExpiresAt ?? null, - }); -} diff --git a/packages/web/src/app/api/automations/route.test.ts b/packages/web/src/app/api/automations/route.test.ts index aebcac897..31fac7432 100644 --- a/packages/web/src/app/api/automations/route.test.ts +++ b/packages/web/src/app/api/automations/route.test.ts @@ -48,15 +48,13 @@ describe("automations API route (POST)", () => { expect(controlPlaneUserFetch).not.toHaveBeenCalled(); }); - it("sends auth* display and scm* attribution for a GitHub user — never identity or credentials", async () => { + it("sends cosmetic auth display without identity or SCM assertions", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", - provider: "github", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -77,10 +75,6 @@ describe("automations API route (POST)", () => { authEmail: "ada@example.com", authName: "Ada Lovelace", authAvatarUrl: "https://avatars.githubusercontent.com/u/12345", - scmLogin: "ada", - scmName: "Ada Lovelace", - scmEmail: "ada@example.com", - scmAvatarUrl: "https://avatars.githubusercontent.com/u/12345", }); // Forbidden under strict identity enforcement: the control plane derives // created_by from the Bearer principal. @@ -94,14 +88,13 @@ describe("automations API route (POST)", () => { expect(sent.scmTokenExpiresAt).toBeUndefined(); }); - it("sends auth* display but no scm* for a Google user (F1/F2: a Google sub must never become a GitHub identity)", async () => { + it("uses the same display-only shape for another provider", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "google-sub-1", + id: "fedcba9876543210fedcba9876543210", name: "Pat PM", email: "pm@gmail.com", image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -119,9 +112,7 @@ describe("automations API route (POST)", () => { expect(sent.userId).toBeUndefined(); expect(sent.authProvider).toBeUndefined(); expect(sent.authUserId).toBeUndefined(); - // Regression guard: the bug sent scmUserId = user.id = the Google sub, which - // the control plane then stored under provider='github'. After the fix there - // is no scm* block at all for a Google user. + // Provider identity and SCM provenance come from control-plane auth state. expect(sent.scmUserId).toBeUndefined(); expect(sent.scmToken).toBeUndefined(); expect(sent.scmLogin).toBeUndefined(); @@ -132,7 +123,7 @@ describe("automations API route (POST)", () => { it("drops non-allowlisted fields (including client-asserted identity) from the forwarded body", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { id: "12345", login: "ada", provider: "github" }, + user: { id: "0123456789abcdef0123456789abcdef" }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ automation: { id: "auto3" } }, { status: 201 }) diff --git a/packages/web/src/app/api/automations/route.ts b/packages/web/src/app/api/automations/route.ts index 0f66e85a8..91050b96c 100644 --- a/packages/web/src/app/api/automations/route.ts +++ b/packages/web/src/app/api/automations/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; -import { buildAuthDisplay, buildScmAttribution } from "@/lib/build-auth-identity"; +import { buildAuthDisplay } from "@/lib/build-auth-identity"; import { controlPlaneUserFetch } from "@/lib/control-plane"; import { buildControlPlanePath } from "@/lib/control-plane-query"; @@ -32,13 +32,8 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - // Explicitly pick allowed fields from the client body (the same pattern - // as the sessions route). Creator identity is derived by the control - // plane from the Bearer principal and rejected in the body — send only - // the automation definition plus the display/attribution blocks: auth* - // display for BOTH GitHub and Google, while the GitHub-only scm* - // attribution block is empty for Google — so a Google sub never reaches - // the SCM path (F1/F2). + // Explicitly pick allowed fields from the client body. Creator identity + // and SCM provenance derive from authenticated control-plane state. const user = session.user; const automationBody = { @@ -55,7 +50,6 @@ export async function POST(request: NextRequest) { repositories: body.repositories, environmentIds: body.environmentIds, ...buildAuthDisplay(user), - ...buildScmAttribution(user), }; const response = await controlPlaneUserFetch("/automations", { diff --git a/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.test.ts b/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.test.ts index ed8e32461..bd2cac808 100644 --- a/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.test.ts @@ -66,4 +66,25 @@ describe("session attachment download API route", () => { expect(response.headers.get("Content-Range")).toBe("bytes 0-4/10"); expect(response.headers.get("Cache-Control")).toBe("private, no-store"); }); + + it("does not reuse the encoded payload length for a decoded attachment stream", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + new Response("decoded attachment", { + headers: { + "Content-Type": "text/plain", + "Content-Encoding": "gzip", + "Content-Length": "8", + }, + }) + ); + + const response = await GET( + new Request("http://localhost/api/sessions/session-1/attachments/attachment-1"), + PARAMS + ); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + await expect(response.text()).resolves.toBe("decoded attachment"); + }); }); diff --git a/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.ts b/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.ts index ae78369cc..7542686f0 100644 --- a/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.ts @@ -42,13 +42,7 @@ export async function GET( Vary: "Cookie", }); - for (const headerName of [ - "Content-Type", - "Content-Length", - "Content-Range", - "Accept-Ranges", - "ETag", - ]) { + for (const headerName of ["Content-Type", "Content-Range", "Accept-Ranges", "ETag"]) { const headerValue = response.headers.get(headerName); if (headerValue) { headers.set(headerName, headerValue); diff --git a/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.test.ts b/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.test.ts index a9879a124..1543c6228 100644 --- a/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.test.ts @@ -45,6 +45,25 @@ describe("session diff file API route", () => { await expect(response.text()).resolves.toContain("diff --git"); }); + it("does not reuse the encoded payload length for a decoded patch stream", async () => { + vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } } as never); + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + new Response("decoded patch", { + headers: { + "Content-Type": "text/x-diff", + "Content-Encoding": "br", + "Content-Length": "5", + }, + }) + ); + + const response = await GET(new Request("http://local/patch"), context); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + await expect(response.text()).resolves.toBe("decoded patch"); + }); + it("preserves the stale-revision payload and status", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( diff --git a/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.ts b/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.ts index 418c41e2c..12a32423c 100644 --- a/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.ts @@ -33,10 +33,8 @@ export async function GET( "X-Content-Type-Options": "nosniff", Vary: "Cookie", }); - for (const name of ["Content-Length", "ETag"]) { - const value = upstream.headers.get(name); - if (value) headers.set(name, value); - } + const etag = upstream.headers.get("ETag"); + if (etag) headers.set("ETag", etag); return new Response(upstream.body, { status: upstream.status, headers }); } catch (error) { console.error("Failed to fetch session diff file:", error); diff --git a/packages/web/src/app/api/sessions/[id]/diff/retry/route.ts b/packages/web/src/app/api/sessions/[id]/diff/retry/route.ts index 6e10fb820..01351d0cc 100644 --- a/packages/web/src/app/api/sessions/[id]/diff/retry/route.ts +++ b/packages/web/src/app/api/sessions/[id]/diff/retry/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; import { controlPlaneUserFetch } from "@/lib/control-plane"; -/** Request a best-effort diff refresh after verifying the browser's NextAuth session. */ +/** Request a best-effort diff refresh after verifying the browser session. */ export async function POST( _request: Request, { params }: { params: Promise<{ id: string }> } diff --git a/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.test.ts b/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.test.ts index ee73c08cf..35cfff7cd 100644 --- a/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.test.ts @@ -93,13 +93,39 @@ describe("session media API route", () => { expect(response.headers.get("Cache-Control")).toBe("private, no-store"); expect(response.headers.get("Vary")).toBe("Cookie"); expect(response.headers.get("Content-Type")).toBe("image/png"); - expect(response.headers.get("Content-Length")).toBe(String(upstreamBody.byteLength)); + expect(response.headers.get("Content-Length")).toBeNull(); expect(response.headers.get("ETag")).toBe('"artifact-etag"'); expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual( Array.from(upstreamBody) ); }); + it("does not reuse the encoded payload length for a decoded media stream", async () => { + vi.mocked(getServerAuthSession).mockResolvedValue({ + user: { id: "user-1" }, + } as never); + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + new Response("decoded media", { + headers: { + "Content-Type": "text/plain", + "Content-Encoding": "br", + "Content-Length": "4", + }, + }) + ); + + const response = await GET(new Request("http://localhost/api/sessions/session-1/media/a1"), { + params: Promise.resolve({ + id: "session-1", + artifactId: "artifact-1", + }), + }); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + await expect(response.text()).resolves.toBe("decoded media"); + }); + it("forwards range requests and range response headers", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" }, @@ -135,7 +161,7 @@ describe("session media API route", () => { }); expect(response.status).toBe(206); expect(response.headers.get("Content-Type")).toBe("video/mp4"); - expect(response.headers.get("Content-Length")).toBe(String(upstreamBody.byteLength)); + expect(response.headers.get("Content-Length")).toBeNull(); expect(response.headers.get("Content-Range")).toBe("bytes 4-7/24"); expect(response.headers.get("Accept-Ranges")).toBe("bytes"); expect(response.headers.get("ETag")).toBe('"video-etag"'); diff --git a/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.ts b/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.ts index c02946e8e..223db5d0b 100644 --- a/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.ts @@ -39,13 +39,7 @@ export async function GET( Vary: "Cookie", }); - for (const headerName of [ - "Content-Type", - "Content-Length", - "Content-Range", - "Accept-Ranges", - "ETag", - ]) { + for (const headerName of ["Content-Type", "Content-Range", "Accept-Ranges", "ETag"]) { const headerValue = response.headers.get(headerName); if (headerValue) { headers.set(headerName, headerValue); diff --git a/packages/web/src/app/api/sessions/[id]/ws-token/route.test.ts b/packages/web/src/app/api/sessions/[id]/ws-token/route.test.ts index b81b3549c..d5288b5f2 100644 --- a/packages/web/src/app/api/sessions/[id]/ws-token/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/ws-token/route.test.ts @@ -40,15 +40,13 @@ describe("ws-token API route", () => { expect(controlPlaneUserFetch).not.toHaveBeenCalled(); }); - it("sends scm* attribution for a GitHub user — never userId or credentials (forbidden under strict)", async () => { + it("sends only provider-independent display data", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", - provider: "github", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -64,21 +62,16 @@ describe("ws-token API route", () => { ); expect(sentBody()).toEqual({ authName: "Ada Lovelace", - scmLogin: "ada", - scmName: "Ada Lovelace", - scmEmail: "ada@example.com", - scmAvatarUrl: "https://avatars.githubusercontent.com/u/12345", }); }); - it("omits scm* entirely for a Google user — identity comes from the Bearer principal", async () => { + it("uses the same body shape regardless of sign-in provider", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "google-sub-1", + id: "fedcba9876543210fedcba9876543210", name: "Pat PM", email: "pm@gmail.com", image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( diff --git a/packages/web/src/app/api/sessions/[id]/ws-token/route.ts b/packages/web/src/app/api/sessions/[id]/ws-token/route.ts index f198c01ed..8d43170e6 100644 --- a/packages/web/src/app/api/sessions/[id]/ws-token/route.ts +++ b/packages/web/src/app/api/sessions/[id]/ws-token/route.ts @@ -1,14 +1,14 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; -import { buildAuthDisplay, buildScmAttribution } from "@/lib/build-auth-identity"; +import { buildAuthDisplay } from "@/lib/build-auth-identity"; import { controlPlaneUserFetch } from "@/lib/control-plane"; /** * Generate a WebSocket authentication token for the current user. * * This endpoint: - * 1. Verifies the user is authenticated via NextAuth + * 1. Verifies the Better Auth browser session * 2. Extracts user info from the session * 3. Proxies the request to the control plane to generate a token * 4. Returns the token to the client for WebSocket connection @@ -26,10 +26,8 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< const { id: sessionId } = await params; try { - // Extract user info from NextAuth session. Participant identity (userId) - // and SCM credentials are derived by the control plane from the Bearer - // principal and are rejected in the body under strict enforcement — the - // body carries display/attribution fields only. + // Participant identity and SCM provenance are derived by the control + // plane. The request carries only a cosmetic presence name. const user = session.user; const { authName } = buildAuthDisplay(user); @@ -38,8 +36,6 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< method: "POST", body: JSON.stringify({ authName, - // GitHub-only commit attribution; empty for Google. - ...buildScmAttribution(user), }), }); const fetchMs = Date.now() - fetchStart; diff --git a/packages/web/src/app/api/sessions/route.test.ts b/packages/web/src/app/api/sessions/route.test.ts index eca61e526..b51af31e5 100644 --- a/packages/web/src/app/api/sessions/route.test.ts +++ b/packages/web/src/app/api/sessions/route.test.ts @@ -11,7 +11,6 @@ vi.mock("@/lib/control-plane", () => ({ import { getServerAuthSession } from "@/lib/server-auth-session"; import { controlPlaneUserFetch } from "@/lib/control-plane"; -import { clearCurrentUserIdCacheForTests } from "@/lib/current-user"; import { GET, POST } from "./route"; function request(path: string) { @@ -34,7 +33,6 @@ function controlPlaneBody(callIndex = 0): Record { describe("sessions API route", () => { beforeEach(() => { vi.resetAllMocks(); - clearCurrentUserIdCacheForTests(); }); it("returns 401 when the user session is missing", async () => { @@ -48,7 +46,9 @@ describe("sessions API route", () => { }); it("forwards allowed session query params", async () => { - vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "12345" } } as never); + vi.mocked(getServerAuthSession).mockResolvedValue({ + user: { id: "0123456789abcdef0123456789abcdef" }, + }); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ sessions: [], hasMore: false }, { status: 200 }) ); @@ -66,72 +66,46 @@ describe("sessions API route", () => { await expect(response.json()).resolves.toEqual({ sessions: [], hasMore: false }); }); - it("resolves createdBy=me before forwarding sessions to the control plane", async () => { + it("replaces createdBy=me with the canonical session principal", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", }, - } as never); - vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: "0123456789abcdef0123456789abcdef" })) - .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: false }, { status: 200 })); + }); + vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( + Response.json({ sessions: [], hasMore: false }, { status: 200 }) + ); const response = await GET( request("/api/sessions?limit=50&offset=0&excludeStatus=archived&createdBy=me") ); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(1, "/provider-identities/github/12345", { - method: "PUT", - }); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 2, + expect(controlPlaneUserFetch).toHaveBeenCalledWith( "/sessions?limit=50&offset=0&excludeStatus=archived&createdBy=0123456789abcdef0123456789abcdef" ); expect(response.status).toBe(200); }); - it("returns 409 when createdBy=me cannot resolve a user id", async () => { - vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { email: "ada@example.com" }, - } as never); - - const response = await GET(request("/api/sessions?createdBy=me")); - - expect(response.status).toBe(409); - await expect(response.json()).resolves.toEqual({ error: "User id unavailable" }); - expect(controlPlaneUserFetch).not.toHaveBeenCalled(); - }); - - it("resolves createdBy=me for a Google user via the google provider route", async () => { + it("does not branch on the provider used to authenticate the session", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "google-sub-1", + id: "fedcba9876543210fedcba9876543210", name: "Pat PM", email: "pm@gmail.com", image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", }, - } as never); - vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: "0123456789abcdef0123456789abcdef" })) - .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: false }, { status: 200 })); + }); + vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( + Response.json({ sessions: [], hasMore: false }, { status: 200 }) + ); const response = await GET(request("/api/sessions?limit=50&createdBy=me")); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 1, - "/provider-identities/google/google-sub-1", - { - method: "PUT", - } - ); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 2, - "/sessions?limit=50&createdBy=0123456789abcdef0123456789abcdef" + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/sessions?limit=50&createdBy=fedcba9876543210fedcba9876543210" ); expect(response.status).toBe(200); }); @@ -139,59 +113,49 @@ describe("sessions API route", () => { it("resolves createdBy=me alongside explicit creator filters", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", }, - } as never); - vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: "0123456789abcdef0123456789abcdef" })) - .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: false }, { status: 200 })); + }); + vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( + Response.json({ sessions: [], hasMore: false }, { status: 200 }) + ); const response = await GET( request("/api/sessions?createdBy=ffffffffffffffffffffffffffffffff&createdBy=me&limit=25") ); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(1, "/provider-identities/github/12345", { - method: "PUT", - }); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 2, + expect(controlPlaneUserFetch).toHaveBeenCalledWith( "/sessions?limit=25&createdBy=ffffffffffffffffffffffffffffffff&createdBy=0123456789abcdef0123456789abcdef" ); expect(response.status).toBe(200); }); - it("reuses the resolved current user across createdBy=me pagination requests", async () => { + it("uses the same canonical principal across pagination requests", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", }, - } as never); + }); vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: "0123456789abcdef0123456789abcdef" })) .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: true }, { status: 200 })) .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: false }, { status: 200 })); await GET(request("/api/sessions?limit=50&offset=0&excludeStatus=archived&createdBy=me")); await GET(request("/api/sessions?limit=50&offset=50&excludeStatus=archived&createdBy=me")); - expect(controlPlaneUserFetch).toHaveBeenCalledTimes(3); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(1, "/provider-identities/github/12345", { - method: "PUT", - }); + expect(controlPlaneUserFetch).toHaveBeenCalledTimes(2); expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 2, + 1, "/sessions?limit=50&offset=0&excludeStatus=archived&createdBy=0123456789abcdef0123456789abcdef" ); expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 3, + 2, "/sessions?limit=50&offset=50&excludeStatus=archived&createdBy=0123456789abcdef0123456789abcdef" ); }); @@ -211,15 +175,13 @@ describe("sessions API route (POST)", () => { expect(controlPlaneUserFetch).not.toHaveBeenCalled(); }); - it("sends auth* display and scm* attribution for a GitHub session — never identity or credentials", async () => { + it("sends display fields without identity or SCM assertions", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", - provider: "github", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -241,10 +203,6 @@ describe("sessions API route (POST)", () => { authEmail: "ada@example.com", authName: "Ada Lovelace", authAvatarUrl: "https://avatars.githubusercontent.com/u/12345", - scmLogin: "ada", - scmName: "Ada Lovelace", - scmEmail: "ada@example.com", - scmAvatarUrl: "https://avatars.githubusercontent.com/u/12345", }); // Forbidden under strict identity enforcement: the control plane derives // these from the Bearer principal, so the web must not send them. @@ -259,14 +217,13 @@ describe("sessions API route (POST)", () => { expect(sent.scmTokenExpiresAt).toBeUndefined(); }); - it("sends auth* display but no scm* for a Google session", async () => { + it("uses the same display-only body for another sign-in provider", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "google-sub-1", + id: "fedcba9876543210fedcba9876543210", name: "Pat PM", email: "pm@gmail.com", image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -292,7 +249,7 @@ describe("sessions API route (POST)", () => { it("forwards environmentId for environment launches", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { id: "12345", provider: "github" }, + user: { id: "0123456789abcdef0123456789abcdef" }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ id: "sess3" }, { status: 201 }) @@ -310,7 +267,7 @@ describe("sessions API route (POST)", () => { it("forwards the repositories list for ad-hoc multi-repo launches", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { id: "12345", provider: "github" }, + user: { id: "0123456789abcdef0123456789abcdef" }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ id: "sess4" }, { status: 201 }) @@ -330,7 +287,7 @@ describe("sessions API route (POST)", () => { it("still strips fields outside the allowlist", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { id: "12345", provider: "github" }, + user: { id: "0123456789abcdef0123456789abcdef" }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ id: "sess5" }, { status: 201 }) diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 3c738943f..c0b17ba42 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,13 +1,12 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; -import { buildAuthDisplay, buildScmAttribution } from "@/lib/build-auth-identity"; +import { buildAuthDisplay } from "@/lib/build-auth-identity"; import { controlPlaneUserFetch } from "@/lib/control-plane"; import { buildControlPlanePath, SESSION_CONTROL_PLANE_QUERY_PARAMS, } from "@/lib/control-plane-query"; -import { resolveCurrentUserId } from "@/lib/current-user"; import { CURRENT_USER_CREATED_BY } from "@/lib/session-list"; export async function GET(request: NextRequest) { @@ -25,16 +24,11 @@ export async function GET(request: NextRequest) { const createdByValues = searchParams.getAll("createdBy"); if (createdByValues.includes(CURRENT_USER_CREATED_BY)) { - const resolved = await resolveCurrentUserId(session.user); - if (!resolved.ok) { - return NextResponse.json(resolved.body, { status: resolved.status }); - } - searchParams.delete("createdBy"); for (const value of createdByValues) { searchParams.append( "createdBy", - value === CURRENT_USER_CREATED_BY ? resolved.userId : value + value === CURRENT_USER_CREATED_BY ? session.user.id : value ); } } @@ -71,11 +65,8 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - // Explicitly pick allowed fields from the client body. Identity - // (userId/spawnSource/authProvider/authUserId/SCM credentials) is derived - // by the control plane from the authenticated Bearer principal and is - // rejected in the body under strict enforcement — send only the - // display/attribution blocks, which stay body-carried by design. + // Explicitly pick allowed fields from the client body. Identity and SCM + // provenance derive from authenticated control-plane state. const user = session.user; const sessionBody = { @@ -90,10 +81,7 @@ export async function POST(request: NextRequest) { // side): a named environment or an ad-hoc repository list. environmentId: body.environmentId, repositories: body.repositories, - // Display-only auth block (GitHub or Google); GitHub-only scm* - // attribution is empty for Google. ...buildAuthDisplay(user), - ...buildScmAttribution(user), }; const response = await controlPlaneUserFetch("/sessions", { diff --git a/packages/web/src/app/providers.test.tsx b/packages/web/src/app/providers.test.tsx index c80ec19a3..fada1acaf 100644 --- a/packages/web/src/app/providers.test.tsx +++ b/packages/web/src/app/providers.test.tsx @@ -1,8 +1,7 @@ import { describe, expect, it } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { SWRConfig } from "swr"; -import { WebSessionGate } from "@/components/web-session-gate"; -import { AuthSessionProvider } from "@/lib/auth-session"; import { Providers } from "./providers"; function findByType(node: ReactNode, type: unknown): ReactElement | undefined { @@ -19,18 +18,11 @@ function findByType(node: ReactNode, type: unknown): ReactElement | undefined { } describe("Providers", () => { - it("nests the application gate and children inside the authentication provider", () => { + it("nests application children inside the shared SWR provider", () => { const child =

Protected application
; - const authProvider = findByType(Providers({ children: child }), AuthSessionProvider); + const provider = findByType(Providers({ children: child }), SWRConfig); - expect(authProvider).toBeDefined(); - - const gate = findByType( - (authProvider as ReactElement<{ children?: ReactNode }>).props.children, - WebSessionGate - ); - - expect(gate).toBeDefined(); - expect((gate as ReactElement<{ children?: ReactNode }>).props.children).toBe(child); + expect(provider).toBeDefined(); + expect((provider as ReactElement<{ children?: ReactNode }>).props.children).toContain(child); }); }); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index a76c9e9a8..1c1760f97 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -2,10 +2,8 @@ import { ThemeProvider } from "next-themes"; import { SWRConfig } from "swr"; -import { WebSessionGate } from "@/components/web-session-gate"; import { Toaster } from "@/components/ui/sonner"; import { SyntaxHighlightTheme } from "@/components/syntax-highlight-theme"; -import { AuthSessionProvider } from "@/lib/auth-session"; import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; async function swrFetcher(url: BrowserApiPath): Promise { @@ -18,20 +16,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {/* - refetchOnWindowFocus must stay off: /api/auth/session re-writes the - session cookie from the claims it decoded, so a focus refetch races - the oi-refresh rotation write and can re-persist an already-consumed - refresh token (family revocation once outside the reuse grace). - WebSessionGate owns focus/interval renewal; the one mount-time - session fetch is safe because WebSessionGate checks only after - it resolves. - */} - - {children} - - - + {children} + + ); diff --git a/packages/web/src/components/sidebar-layout.test.tsx b/packages/web/src/components/sidebar-layout.test.tsx index a8661c4b8..22ee0effc 100644 --- a/packages/web/src/components/sidebar-layout.test.tsx +++ b/packages/web/src/components/sidebar-layout.test.tsx @@ -38,7 +38,7 @@ afterEach(cleanup); describe("CollapsedSidebarControls", () => { it("renders the sidebar, search, and new session actions inline", () => { vi.mocked(useAuthSession).mockReturnValue({ - data: { user: { name: "Test User" } }, + data: { user: { id: "user-1", name: "Test User" } }, status: "authenticated", }); const push = vi.fn(); diff --git a/packages/web/src/components/web-session-gate.integration.test.tsx b/packages/web/src/components/web-session-gate.integration.test.tsx deleted file mode 100644 index fc8671afc..000000000 --- a/packages/web/src/components/web-session-gate.integration.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; - -const mocks = vi.hoisted(() => ({ - status: "authenticated", - signOut: vi.fn(), - cookies: vi.fn(), - controlPlaneTokenFetch: vi.fn(), - getToken: vi.fn(), -})); - -vi.mock("@/lib/auth-session", () => ({ - useAuthSession: () => ({ data: null, status: mocks.status }), - signOut: mocks.signOut, -})); - -vi.mock("next/headers", () => ({ - cookies: mocks.cookies, -})); - -vi.mock("@/lib/control-plane-transport", () => ({ - controlPlaneTokenFetch: mocks.controlPlaneTokenFetch, -})); - -vi.mock("next-auth/jwt", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { ...actual, getToken: mocks.getToken }; -}); - -import { POST } from "@/app/api/auth/oi-refresh/route"; -import { WebSessionGate } from "./web-session-gate"; - -const SECRET = "test-nextauth-secret-for-web-session-gate"; - -beforeEach(() => { - mocks.status = "authenticated"; - mocks.signOut.mockReset(); - mocks.cookies.mockReset(); - mocks.controlPlaneTokenFetch.mockReset(); - mocks.getToken.mockReset(); - vi.stubEnv("NEXTAUTH_SECRET", SECRET); - vi.stubEnv("NEXTAUTH_URL", "https://open-inspect.example"); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); -}); - -describe("pre-exchange web session composition", () => { - it("keeps application children hidden and signs out when the refresh route returns 401", async () => { - const cookieWrites: unknown[] = []; - mocks.getToken.mockResolvedValue({ sub: "user-1", provider: "github" }); - mocks.cookies.mockResolvedValue({ - getAll: () => [], - set: (...args: unknown[]) => cookieWrites.push(args), - }); - vi.stubGlobal( - "fetch", - vi.fn(async () => POST()) - ); - - render( - -
Protected application
-
- ); - - expect(screen.queryByText("Protected application")).toBeNull(); - await waitFor(() => expect(mocks.signOut).toHaveBeenCalledTimes(1)); - expect(mocks.controlPlaneTokenFetch).not.toHaveBeenCalled(); - expect(cookieWrites).toHaveLength(0); - }); -}); diff --git a/packages/web/src/components/web-session-gate.test.tsx b/packages/web/src/components/web-session-gate.test.tsx deleted file mode 100644 index eaa30fdc3..000000000 --- a/packages/web/src/components/web-session-gate.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; - -import { WebSessionGate } from "./web-session-gate"; - -const mocks = vi.hoisted(() => ({ - status: "loading", - signOut: vi.fn(), -})); - -vi.mock("@/lib/auth-session", () => ({ - useAuthSession: () => ({ data: null, status: mocks.status }), - signOut: mocks.signOut, -})); - -let fetchSpy: ReturnType; - -function setVisibilityState(state: DocumentVisibilityState): void { - Object.defineProperty(document, "visibilityState", { value: state, configurable: true }); -} - -beforeEach(() => { - mocks.status = "loading"; - mocks.signOut.mockReset(); - fetchSpy = vi.fn().mockResolvedValue(new Response("{}")); - vi.stubGlobal("fetch", fetchSpy); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -describe("WebSessionGate", () => { - it("waits for the SessionProvider's own session fetch before checking", () => { - // Mount-time sequencing: the one /api/auth/session cookie write must land - // before the first rotation write, or the two could interleave stale over - // fresh. Waiting for "authenticated" is what orders them. - const { rerender } = render(); - expect(fetchSpy).not.toHaveBeenCalled(); - - mocks.status = "authenticated"; - rerender(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledWith("/api/auth/oi-refresh", { - method: "POST", - mode: "same-origin", - credentials: "same-origin", - }); - }); - - it("signs out when renewal reports that the session is no longer authenticated", async () => { - mocks.status = "authenticated"; - fetchSpy.mockResolvedValue(Response.json({ error: "Unauthorized" }, { status: 401 })); - - render(); - - await waitFor(() => expect(mocks.signOut).toHaveBeenCalledTimes(1)); - }); - - it("can retry sign-out when NextAuth's first sign-out request fails", async () => { - mocks.status = "authenticated"; - fetchSpy.mockResolvedValue(Response.json({ error: "Unauthorized" }, { status: 401 })); - mocks.signOut - .mockRejectedValueOnce(new Error("sign-out request failed")) - .mockResolvedValueOnce(undefined); - - render(); - - expect(await screen.findByText("Authentication temporarily unavailable")).toBeTruthy(); - expect(mocks.signOut).toHaveBeenCalledTimes(1); - - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - - await waitFor(() => expect(mocks.signOut).toHaveBeenCalledTimes(2)); - }); - - it("holds authenticated children until web-session validity is confirmed", async () => { - mocks.status = "authenticated"; - let resolveCheck: ((response: Response) => void) | undefined; - fetchSpy.mockImplementation( - () => - new Promise((resolve) => { - resolveCheck = resolve; - }) - ); - - render( - -
Protected application
-
- ); - - expect(screen.queryByText("Protected application")).toBeNull(); - resolveCheck?.(new Response(null, { status: 204 })); - expect(await screen.findByText("Protected application")).toBeTruthy(); - }); - - it("offers retry without signing out when authentication is temporarily unavailable", async () => { - mocks.status = "authenticated"; - fetchSpy - .mockResolvedValueOnce( - Response.json({ error: "Authentication temporarily unavailable" }, { status: 503 }) - ) - .mockResolvedValueOnce(new Response(null, { status: 204 })); - - render( - -
Protected application
-
- ); - - expect(await screen.findByText("Authentication temporarily unavailable")).toBeTruthy(); - expect(mocks.signOut).not.toHaveBeenCalled(); - expect(screen.queryByText("Protected application")).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - - expect(await screen.findByText("Protected application")).toBeTruthy(); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it("checks a newly authenticated session before revealing children again", async () => { - mocks.status = "authenticated"; - fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); - const { rerender } = render( - -
Protected application
-
- ); - - expect(await screen.findByText("Protected application")).toBeTruthy(); - - mocks.status = "unauthenticated"; - rerender( - -
Protected application
-
- ); - await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); - - let resolveNewSession: ((response: Response) => void) | undefined; - fetchSpy.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveNewSession = resolve; - }) - ); - mocks.status = "authenticated"; - rerender( - -
Protected application
-
- ); - - expect(screen.queryByText("Protected application")).toBeNull(); - resolveNewSession?.(new Response(null, { status: 204 })); - expect(await screen.findByText("Protected application")).toBeTruthy(); - }); - - it("does not start an overlapping check when focus returns during renewal", () => { - mocks.status = "authenticated"; - fetchSpy.mockImplementation(() => new Promise(() => undefined)); - render(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - window.dispatchEvent(new Event("focus")); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it("checks again when the tab becomes visible, not while hidden", async () => { - mocks.status = "authenticated"; - render( - -
Protected application
-
- ); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(await screen.findByText("Protected application")).toBeTruthy(); - - // Explicit state on both sides — the handler gates on visibilityState, - // so the test must not lean on jsdom's default being "visible". - setVisibilityState("hidden"); - document.dispatchEvent(new Event("visibilitychange")); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - setVisibilityState("visible"); - document.dispatchEvent(new Event("visibilitychange")); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it("stops checking after unmount", () => { - mocks.status = "authenticated"; - const { unmount } = render(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - unmount(); - document.dispatchEvent(new Event("visibilitychange")); - window.dispatchEvent(new Event("focus")); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/web/src/components/web-session-gate.tsx b/packages/web/src/components/web-session-gate.tsx deleted file mode 100644 index 09bcf310f..000000000 --- a/packages/web/src/components/web-session-gate.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { signOut, useAuthSession } from "@/lib/auth-session"; -import { browserApiFetch } from "@/lib/browser-api-fetch"; - -/** - * Check interval for web session token renewal. Must sit comfortably inside - * OI_ACCESS_TOKEN_RENEW_WINDOW_MS (15 min) so a token entering the renew - * window is rotated well before it expires. - */ -const WEB_SESSION_CHECK_INTERVAL_MS = 5 * 60 * 1000; - -/** - * Confirms that NextAuth and the control-plane token pair form a usable web - * session before rendering authenticated children, then keeps that pair fresh. - * Renewal cannot live in the NextAuth jwt callback (getServerSession cannot - * persist rotated cookies), so this client-side gate drives rotation on - * mount, focus/visibility, and an interval. - */ -export function WebSessionGate({ children }: { children?: ReactNode }) { - const { status } = useAuthSession(); - const signingOutRef = useRef(false); - const [webSessionStatus, setWebSessionStatus] = useState< - "checking" | "ready" | "temporarily_unavailable" - >("checking"); - const [retryGeneration, setRetryGeneration] = useState(0); - - useEffect(() => { - if (status === "authenticated") return; - setWebSessionStatus("checking"); - signingOutRef.current = false; - }, [status]); - - useEffect(() => { - if (status !== "authenticated") return; - let cancelled = false; - let checkInFlight = false; - - const checkWebSession = async () => { - if (checkInFlight) return; - checkInFlight = true; - try { - const response = await browserApiFetch("/api/auth/oi-refresh", { method: "POST" }); - if (cancelled) return; - if (response.status === 401 && !signingOutRef.current) { - signingOutRef.current = true; - try { - await signOut(); - } catch { - if (!cancelled) { - signingOutRef.current = false; - setWebSessionStatus("temporarily_unavailable"); - } - } - return; - } - if (response.ok) { - setWebSessionStatus("ready"); - return; - } - setWebSessionStatus("temporarily_unavailable"); - } catch { - if (!cancelled) { - setWebSessionStatus("temporarily_unavailable"); - } - } finally { - checkInFlight = false; - } - }; - - void checkWebSession(); - const checkInterval = setInterval(() => void checkWebSession(), WEB_SESSION_CHECK_INTERVAL_MS); - const checkWhenVisible = () => { - if (document.visibilityState === "visible") void checkWebSession(); - }; - window.addEventListener("focus", checkWhenVisible); - document.addEventListener("visibilitychange", checkWhenVisible); - return () => { - cancelled = true; - clearInterval(checkInterval); - window.removeEventListener("focus", checkWhenVisible); - document.removeEventListener("visibilitychange", checkWhenVisible); - }; - }, [retryGeneration, status]); - - if (status === "unauthenticated") return children ?? null; - if (status !== "authenticated") return null; - if (webSessionStatus === "temporarily_unavailable") { - return ( -
-
-

Authentication temporarily unavailable

- -
-
- ); - } - if (webSessionStatus !== "ready") return null; - return children ?? null; -} diff --git a/packages/web/src/lib/access-control.test.ts b/packages/web/src/lib/access-control.test.ts deleted file mode 100644 index def41feef..000000000 --- a/packages/web/src/lib/access-control.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - parseAllowlist, - parseBooleanEnv, - checkAccessAllowed, - getAccessAllowReason, -} from "./access-control"; - -describe("parseAllowlist", () => { - it("returns empty array for undefined", () => { - expect(parseAllowlist(undefined)).toEqual([]); - }); - - it("returns empty array for empty string", () => { - expect(parseAllowlist("")).toEqual([]); - }); - - it("parses single value", () => { - expect(parseAllowlist("user1")).toEqual(["user1"]); - }); - - it("parses comma-separated values", () => { - expect(parseAllowlist("user1,user2,user3")).toEqual(["user1", "user2", "user3"]); - }); - - it("trims whitespace", () => { - expect(parseAllowlist(" user1 , user2 , user3 ")).toEqual(["user1", "user2", "user3"]); - }); - - it("converts to lowercase", () => { - expect(parseAllowlist("User1,USER2,UsEr3")).toEqual(["user1", "user2", "user3"]); - }); - - it("filters empty values", () => { - expect(parseAllowlist("user1,,user2, ,user3")).toEqual(["user1", "user2", "user3"]); - }); -}); - -describe("parseBooleanEnv", () => { - it("returns false for undefined and empty values", () => { - expect(parseBooleanEnv(undefined)).toBe(false); - expect(parseBooleanEnv("")).toBe(false); - expect(parseBooleanEnv(" ")).toBe(false); - }); - - it("returns true only for true", () => { - expect(parseBooleanEnv("true")).toBe(true); - expect(parseBooleanEnv(" TRUE ")).toBe(true); - expect(parseBooleanEnv("false")).toBe(false); - expect(parseBooleanEnv("1")).toBe(false); - }); -}); - -describe("checkAccessAllowed", () => { - describe("when all allowlists are empty", () => { - it("denies all users by default", () => { - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { githubUsername: "anyuser" })).toBe(false); - expect(checkAccessAllowed(config, { emails: ["anyone@example.com"] })).toBe(false); - }); - - it("allows all users when unsafeAllowAllUsers is enabled", () => { - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: true, - }; - - expect(checkAccessAllowed(config, {})).toBe(true); - expect(checkAccessAllowed(config, { githubUsername: "anyuser" })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["anyone@example.com"] })).toBe(true); - }); - - it("a populated allowedEmails disables the unsafe allow-all gate", () => { - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: ["listed@gmail.com"], - unsafeAllowAllUsers: true, - }; - - // The gate only fires when ALL three lists are empty; once allowedEmails is - // set, enforcement applies even with unsafeAllowAllUsers on. - expect(checkAccessAllowed(config, { emails: ["listed@gmail.com"] })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["other@gmail.com"] })).toBe(false); - expect(checkAccessAllowed(config, {})).toBe(false); - }); - }); - - describe("when only allowedOrganizations is set", () => { - // Org membership is resolved asynchronously (checkGitHubOrganizationAccess), - // never by this synchronous policy — so it can only deny here, while still - // refusing to fall back to unsafe allow-all. - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - allowedOrganizations: ["acme"], - unsafeAllowAllUsers: false, - }; - - it("denies synchronously — membership is checked asynchronously elsewhere", () => { - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { githubUsername: "anyuser" })).toBe(false); - expect(checkAccessAllowed(config, { emails: ["anyone@example.com"] })).toBe(false); - }); - - it("keeps the unsafe allow-all gate closed because an allowlist is configured", () => { - expect(checkAccessAllowed({ ...config, unsafeAllowAllUsers: true }, {})).toBe(false); - }); - }); - - describe("when allowedUsers is set", () => { - const config = { - allowedDomains: [], - allowedUsers: ["alloweduser"], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - it("allows users in the list", () => { - expect(checkAccessAllowed(config, { githubUsername: "alloweduser" })).toBe(true); - }); - - it("allows users with different case", () => { - expect(checkAccessAllowed(config, { githubUsername: "AllowedUser" })).toBe(true); - expect(checkAccessAllowed(config, { githubUsername: "ALLOWEDUSER" })).toBe(true); - }); - - it("denies users not in the list", () => { - expect(checkAccessAllowed(config, { githubUsername: "otheruser" })).toBe(false); - }); - - it("denies when no username provided", () => { - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { emails: ["user@example.com"] })).toBe(false); - }); - }); - - describe("when allowedEmails is set", () => { - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: ["pm@gmail.com", "support@gmail.com"], - unsafeAllowAllUsers: false, - }; - - it("allows an exact listed email — even on a shared domain like gmail.com", () => { - expect(checkAccessAllowed(config, { emails: ["pm@gmail.com"] })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["support@gmail.com"] })).toBe(true); - }); - - it("matches case-insensitively", () => { - expect(checkAccessAllowed(config, { emails: ["PM@Gmail.com"] })).toBe(true); - }); - - it("does NOT admit other addresses on the same shared domain", () => { - // The whole point of the exact-email list: a gmail.com address is admitted - // without admitting every gmail.com account. - expect(checkAccessAllowed(config, { emails: ["stranger@gmail.com"] })).toBe(false); - }); - - it("denies when no email provided", () => { - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { githubUsername: "pm" })).toBe(false); - }); - }); - - describe("when allowedDomains is set", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - it("allows users with matching email domain", () => { - expect(checkAccessAllowed(config, { emails: ["user@company.com"] })).toBe(true); - }); - - it("allows users with different case email", () => { - expect(checkAccessAllowed(config, { emails: ["User@COMPANY.COM"] })).toBe(true); - }); - - it("denies users with non-matching email domain", () => { - expect(checkAccessAllowed(config, { emails: ["user@other.com"] })).toBe(false); - }); - - it("denies when no email provided", () => { - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { githubUsername: "someuser" })).toBe(false); - }); - }); - - describe("when user has multiple emails", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - it("allows access when any email exactly matches the email allowlist", () => { - expect( - checkAccessAllowed( - { ...config, allowedDomains: [], allowedEmails: ["user@company.com"] }, - { - emails: ["user@personal.com", "user@company.com"], - } - ) - ).toBe(true); - }); - - it("allows access when any email matches the domain", () => { - expect( - checkAccessAllowed(config, { - emails: ["user@personal.com", "user@company.com"], - }) - ).toBe(true); - }); - - it("denies access when no email matches the domain", () => { - expect( - checkAccessAllowed(config, { - emails: ["user@personal.com", "user@gmail.com"], - }) - ).toBe(false); - }); - }); - - describe("when both allowedUsers, allowedEmails, and allowedDomains are set (OR logic)", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: ["specialuser"], - allowedEmails: ["contractor@gmail.com"], - unsafeAllowAllUsers: false, - }; - - it("allows users matching username", () => { - expect(checkAccessAllowed(config, { githubUsername: "specialuser" })).toBe(true); - }); - - it("allows users matching exact email", () => { - expect(checkAccessAllowed(config, { emails: ["contractor@gmail.com"] })).toBe(true); - }); - - it("allows users matching email domain", () => { - expect(checkAccessAllowed(config, { emails: ["someone@company.com"] })).toBe(true); - }); - - it("allows users matching any condition", () => { - expect( - checkAccessAllowed(config, { - githubUsername: "specialuser", - emails: ["user@other.com"], - }) - ).toBe(true); - - expect( - checkAccessAllowed(config, { - githubUsername: "otheruser", - emails: ["user@company.com"], - }) - ).toBe(true); - }); - - it("denies users matching no condition", () => { - expect( - checkAccessAllowed(config, { - githubUsername: "randomuser", - emails: ["user@other.com"], - }) - ).toBe(false); - }); - }); - - describe("when allowedUsers, allowedDomains, and allowedOrganizations are set (OR logic)", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: ["specialuser"], - allowedEmails: [], - allowedOrganizations: ["acme"], - unsafeAllowAllUsers: false, - }; - - it("allows users matching a synchronous list (username or domain)", () => { - expect(checkAccessAllowed(config, { githubUsername: "specialuser" })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["user@company.com"] })).toBe(true); - }); - - it("denies users matching none of the synchronous lists (org checked elsewhere)", () => { - expect( - checkAccessAllowed(config, { - githubUsername: "randomuser", - emails: ["user@other.com"], - }) - ).toBe(false); - }); - }); - - describe("when unsafeAllowAllUsers is true with populated allowlists", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: ["specialuser"], - allowedEmails: [], - unsafeAllowAllUsers: true, - }; - - it("still enforces the allowlist for matching users", () => { - expect(checkAccessAllowed(config, { githubUsername: "specialuser" })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["user@company.com"] })).toBe(true); - }); - - it("denies users not in the allowlist", () => { - expect(checkAccessAllowed(config, { githubUsername: "randomuser" })).toBe(false); - expect(checkAccessAllowed(config, { emails: ["user@other.com"] })).toBe(false); - }); - - it("does not bypass a populated organization allowlist", () => { - const orgConfig = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - allowedOrganizations: ["acme"], - unsafeAllowAllUsers: true, - }; - - // A populated org allowlist keeps the unsafe allow-all gate closed; the - // synchronous check then denies (membership is resolved asynchronously). - expect(checkAccessAllowed(orgConfig, {})).toBe(false); - expect(checkAccessAllowed(orgConfig, { githubUsername: "anyuser" })).toBe(false); - }); - }); - - describe("multiple values in allowlists", () => { - const config = { - allowedDomains: ["company.com", "partner.org"], - allowedUsers: ["admin", "developer"], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - it("allows any user from the list", () => { - expect(checkAccessAllowed(config, { githubUsername: "admin" })).toBe(true); - expect(checkAccessAllowed(config, { githubUsername: "developer" })).toBe(true); - }); - - it("allows any domain from the list", () => { - expect(checkAccessAllowed(config, { emails: ["user@company.com"] })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["user@partner.org"] })).toBe(true); - }); - }); -}); - -describe("getAccessAllowReason", () => { - it("returns the matching allow reason", () => { - expect( - getAccessAllowReason( - { - allowedDomains: [], - allowedUsers: ["alice"], - allowedEmails: [], - unsafeAllowAllUsers: false, - }, - { githubUsername: "Alice" } - ) - ).toBe("username_allowlist"); - - expect( - getAccessAllowReason( - { - allowedDomains: [], - allowedUsers: [], - allowedEmails: ["pm@gmail.com"], - unsafeAllowAllUsers: false, - }, - { emails: ["PM@gmail.com"] } - ) - ).toBe("email_allowlist"); - - expect( - getAccessAllowReason( - { - allowedDomains: ["company.com"], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - }, - { emails: ["user@company.com"] } - ) - ).toBe("email_domain_allowlist"); - }); -}); diff --git a/packages/web/src/lib/access-control.ts b/packages/web/src/lib/access-control.ts deleted file mode 100644 index 95f2aa8f2..000000000 --- a/packages/web/src/lib/access-control.ts +++ /dev/null @@ -1,103 +0,0 @@ -export interface AccessControlConfig { - allowedDomains: string[]; - allowedUsers: string[]; - allowedEmails: string[]; - allowedOrganizations?: string[]; - unsafeAllowAllUsers: boolean; -} - -export interface AccessCheckParams { - githubUsername?: string; - emails?: string[]; -} - -export type AccessAllowReason = - | "unsafe_allow_all" - | "username_allowlist" - | "email_allowlist" - | "email_domain_allowlist"; - -/** - * Parse comma-separated environment variable into a lowercase, trimmed array - */ -export function parseAllowlist(value: string | undefined): string[] { - if (!value) return []; - return value - .split(",") - .map((item) => item.trim().toLowerCase()) - .filter(Boolean); -} - -export function parseBooleanEnv(value: string | undefined): boolean { - return value?.trim().toLowerCase() === "true"; -} - -function parseDomain(email: string): string | null { - const parts = email.split("@"); - return parts.length === 2 ? parts[1].toLowerCase() : null; -} - -/** - * Boolean convenience over getAccessAllowReason: true when any synchronous - * allowlist admits the user. Does NOT cover GitHub organization membership, which - * is resolved asynchronously (see checkGitHubOrganizationAccess). - */ -export function checkAccessAllowed( - config: AccessControlConfig, - params: AccessCheckParams -): boolean { - return getAccessAllowReason(config, params) !== null; -} - -/** - * Resolve which allowlist (if any) admits a sign-in, or null to deny. - * - * Matching is OR-based across the username, exact-email, and email-domain lists. - * GitHub organization membership is deliberately NOT evaluated here — it requires - * an async GitHub API call and is owned entirely by checkGitHubOrganizationAccess, - * which the sign-in callback applies as a fallback when this returns null. - * `allowedOrganizations` still participates in the empty-allowlist guard below so - * an org-only configuration does not collapse into unsafe allow-all. - */ -export function getAccessAllowReason( - config: AccessControlConfig, - params: AccessCheckParams -): AccessAllowReason | null { - const { allowedDomains, allowedUsers, allowedEmails, unsafeAllowAllUsers } = config; - const allowedOrganizations = config.allowedOrganizations ?? []; - const { githubUsername, emails } = params; - - // Empty allowlists only permit sign-in when explicitly enabled. - if ( - allowedDomains.length === 0 && - allowedUsers.length === 0 && - allowedEmails.length === 0 && - allowedOrganizations.length === 0 - ) { - return unsafeAllowAllUsers ? "unsafe_allow_all" : null; - } - - // Check explicit user allowlist (GitHub username) - if (githubUsername && allowedUsers.includes(githubUsername.toLowerCase())) { - return "username_allowlist"; - } - - // Check exact email allowlist. Provider-agnostic, and the only way to admit a - // specific address on a shared domain (e.g. one gmail.com user) without - // domain-allowing every gmail.com account. - if (emails?.some((email) => allowedEmails.includes(email.toLowerCase()))) { - return "email_allowlist"; - } - - // Check email domain allowlist. - if ( - emails - ?.map((email) => parseDomain(email)) - ?.filter((domain) => domain !== null) - ?.some((domain) => allowedDomains.includes(domain)) - ) { - return "email_domain_allowlist"; - } - - return null; -} diff --git a/packages/web/src/lib/auth-session.test.tsx b/packages/web/src/lib/auth-session.test.tsx index 0e4f117f0..ab9af10d4 100644 --- a/packages/web/src/lib/auth-session.test.tsx +++ b/packages/web/src/lib/auth-session.test.tsx @@ -1,24 +1,19 @@ // @vitest-environment jsdom -import { cleanup, render, renderHook, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; +import { cleanup, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -vi.mock("next-auth/react", () => ({ - SessionProvider: vi.fn(({ children }: { children?: ReactNode }) => children), - signIn: vi.fn(), - signOut: vi.fn(), - useSession: vi.fn(), +const mocks = vi.hoisted(() => ({ + mutate: vi.fn(), + useSWR: vi.fn(), +})); + +vi.mock("swr", () => ({ + default: mocks.useSWR, + mutate: mocks.mutate, })); import { - SessionProvider, - signIn as nextAuthSignIn, - signOut as nextAuthSignOut, - useSession, -} from "next-auth/react"; -import { - AuthSessionProvider, signIn, signOut, useAuthSession, @@ -29,6 +24,7 @@ import { afterEach(() => { cleanup(); vi.resetAllMocks(); + vi.unstubAllGlobals(); }); describe("useAuthSession", () => { @@ -45,7 +41,7 @@ describe("useAuthSession", () => { assertState({ status: "loading", data: null }); }); - it("exposes the current NextAuth session through the app-owned hook", () => { + it("exposes the Better Auth session through the app-owned hook", () => { const data = { user: { id: "user-1", @@ -53,12 +49,16 @@ describe("useAuthSession", () => { email: "ada@example.com", image: null, }, - expires: "2099-01-01", + session: { + id: "session-1", + userId: "user-1", + expiresAt: "2099-01-01T00:00:00.000Z", + }, }; - vi.mocked(useSession).mockReturnValue({ + mocks.useSWR.mockReturnValue({ data, - status: "authenticated", - update: vi.fn(), + error: undefined, + isLoading: false, }); const { result } = renderHook(() => useAuthSession()); @@ -69,53 +69,142 @@ describe("useAuthSession", () => { }); }); - it("exposes no session data while NextAuth is loading", () => { - vi.mocked(useSession).mockReturnValue({ + it("exposes no session data while Better Auth is loading", () => { + mocks.useSWR.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: true, + }); + + const { result } = renderHook(() => useAuthSession()); + + expect(result.current).toEqual({ data: null, status: "loading", - update: vi.fn(), + }); + }); + + it("treats a completed empty response as unauthenticated", () => { + mocks.useSWR.mockReturnValue({ + data: null, + error: undefined, + isLoading: false, }); const { result } = renderHook(() => useAuthSession()); expect(result.current).toEqual({ data: null, - status: "loading", + status: "unauthenticated", }); }); -}); -describe("AuthSessionProvider", () => { - it("preserves the disabled NextAuth focus refetch behavior", () => { - render( - -
Application
-
- ); + it("fails closed without crashing when the session lookup fails", () => { + mocks.useSWR.mockReturnValue({ + data: undefined, + error: new Error("control plane unavailable"), + isLoading: false, + }); + + const { result } = renderHook(() => useAuthSession()); + + expect(result.current).toEqual({ + data: null, + status: "unauthenticated", + }); + }); + + it("retains a cached authenticated session during a failed revalidation", () => { + const data: AuthSession = { + user: { + id: "user-1", + name: "Ada", + email: "ada@example.com", + image: null, + }, + }; + mocks.useSWR.mockReturnValue({ + data, + error: new Error("transient revalidation failure"), + isLoading: false, + }); + + const { result } = renderHook(() => useAuthSession()); - expect(screen.getByText("Application")).toBeTruthy(); - expect(vi.mocked(SessionProvider).mock.calls[0]?.[0]).toMatchObject({ - refetchOnWindowFocus: false, + expect(result.current).toEqual({ + data, + status: "authenticated", }); }); }); describe("signIn", () => { - it("starts the existing NextAuth provider flow", async () => { - vi.mocked(nextAuthSignIn).mockResolvedValue(undefined); + it("starts a proxied Better Auth provider flow", async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + url: "https://github.com/login/oauth/authorize?state=state", + redirect: true, + }) + ); + const assign = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("location", { + origin: "https://app.example", + assign, + }); await signIn("github"); - expect(nextAuthSignIn).toHaveBeenCalledWith("github"); + expect(fetchMock).toHaveBeenCalledWith("/api/auth/sign-in/social", { + method: "POST", + mode: "same-origin", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }); + expect(assign).toHaveBeenCalledWith("https://github.com/login/oauth/authorize?state=state"); + }); + + it("throws instead of navigating when sign-in fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(Response.json({ error: "Unavailable" }, { status: 503 })) + ); + vi.stubGlobal("location", { origin: "https://app.example", assign: vi.fn() }); + + await expect(signIn("google")).rejects.toThrow("Sign-in failed with status 503"); + expect(location.assign).not.toHaveBeenCalled(); }); }); describe("signOut", () => { - it("ends the existing NextAuth session", async () => { - vi.mocked(nextAuthSignOut).mockResolvedValue(undefined); + it("ends the Better Auth session and clears the session cache", async () => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ success: true })); + vi.stubGlobal("fetch", fetchMock); await signOut(); - expect(nextAuthSignOut).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith("/api/auth/sign-out", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + mode: "same-origin", + credentials: "same-origin", + }); + expect(mocks.mutate).toHaveBeenCalledWith("/api/auth/get-session", null, false); + }); + + it("does not clear local state when server-side sign-out fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(Response.json({ error: "Unavailable" }, { status: 503 })) + ); + + await expect(signOut()).rejects.toThrow("Sign-out failed with status 503"); + expect(mocks.mutate).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/lib/auth-session.tsx b/packages/web/src/lib/auth-session.tsx index 84e167202..46e2ec5d0 100644 --- a/packages/web/src/lib/auth-session.tsx +++ b/packages/web/src/lib/auth-session.tsx @@ -1,21 +1,20 @@ "use client"; -import type { ReactNode } from "react"; +import useSWR, { mutate } from "swr"; +import { z } from "zod"; import { - SessionProvider, - signIn as nextAuthSignIn, - signOut as nextAuthSignOut, - useSession, -} from "next-auth/react"; + browserAuthSessionResponseSchema, + type BrowserAuthSessionUser, +} from "./browser-auth-session-contract"; +import { browserApiFetch } from "./browser-api-fetch"; import type { AuthProvider } from "./build-auth-identity"; -export interface AuthSessionUser { - name?: string | null; - image?: string | null; -} +const BROWSER_AUTH_SESSION_PATH = "/api/auth/get-session"; + +export type AuthSessionUser = BrowserAuthSessionUser; export interface AuthSession { - user?: AuthSessionUser | null; + user: AuthSessionUser; } export type SignInProvider = AuthProvider; @@ -32,28 +31,64 @@ export type AuthSessionState = export type AuthSessionStatus = AuthSessionState["status"]; -export function AuthSessionProvider({ children }: { children: ReactNode }) { - return {children}; -} - export async function signIn(provider: SignInProvider): Promise { - await nextAuthSignIn(provider); + const response = await browserApiFetch("/api/auth/sign-in/social", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + callbackURL: "/", + disableRedirect: true, + }), + }); + if (!response.ok) { + throw new Error(`Sign-in failed with status ${response.status}`); + } + + const { url } = z.object({ url: z.url() }).parse(await response.json()); + const destination = new URL(url); + if (destination.protocol !== "https:") { + throw new Error("Sign-in returned an insecure redirect URL"); + } + globalThis.location.assign(destination.href); } export async function signOut(): Promise { - await nextAuthSignOut(); + const response = await browserApiFetch("/api/auth/sign-out", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!response.ok) { + throw new Error(`Sign-out failed with status ${response.status}`); + } + await mutate(BROWSER_AUTH_SESSION_PATH, null, false); } /** * App-owned client authentication boundary. - * - * The current implementation delegates to NextAuth. Terminal browser auth can - * replace this module without another repository-wide consumer migration. */ export function useAuthSession(): AuthSessionState { - const state = useSession(); - if (state.status === "authenticated") { - return { data: state.data, status: state.status }; + const { data, error, isLoading } = useSWR( + BROWSER_AUTH_SESSION_PATH, + async () => { + const response = await browserApiFetch(BROWSER_AUTH_SESSION_PATH); + if (!response.ok) { + throw new Error(`Session lookup failed with status ${response.status}`); + } + const payload: unknown = await response.json(); + if (payload === null) return null; + const session = browserAuthSessionResponseSchema.parse(payload); + return { user: session.user }; + } + ); + + if (data) return { data, status: "authenticated" }; + if (error) { + return { data: null, status: "unauthenticated" }; + } + if (isLoading || data === undefined) { + return { data: null, status: "loading" }; } - return { data: null, status: state.status }; + return { data: null, status: "unauthenticated" }; } diff --git a/packages/web/src/lib/auth.test.ts b/packages/web/src/lib/auth.test.ts deleted file mode 100644 index cbd2e93cf..000000000 --- a/packages/web/src/lib/auth.test.ts +++ /dev/null @@ -1,816 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { Account, NextAuthOptions, Profile, Session } from "next-auth"; -import type { JWT } from "next-auth/jwt"; -import type { AccessControlConfig } from "./access-control"; -import { - applyJwtClaims, - applySessionUser, - getStaticSignInReason, - getVerifiedGitHubEmails, - normalizeGitHubUserInfoProfile, -} from "./auth"; - -vi.mock("@open-inspect/shared", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { ...actual, DEFAULT_APP_NAME: "Open-Inspect" }; -}); - -vi.mock("next-auth/providers/github", () => ({ - default: (config: unknown) => ({ - id: "github", - type: "oauth", - options: config, - }), -})); - -const ORIGINAL_ENV = { ...process.env }; - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - resetAuthEnv(); -}); - -function cfg(overrides: Partial = {}): AccessControlConfig { - return { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - ...overrides, - }; -} - -describe("buildGitHubOAuthScope", () => { - it("requests base scopes when organization access is disabled", async () => { - const { BASE_GITHUB_OAUTH_SCOPE, buildGitHubOAuthScope } = await importAuthModule(); - - expect(buildGitHubOAuthScope([])).toBe(BASE_GITHUB_OAUTH_SCOPE); - }); - - it("requests read:org only when organization access is configured", async () => { - const { BASE_GITHUB_OAUTH_SCOPE, buildGitHubOAuthScope } = await importAuthModule(); - - expect(buildGitHubOAuthScope(["acme"])).toBe(`${BASE_GITHUB_OAUTH_SCOPE} read:org`); - }); -}); - -describe("GitHub provider scope", () => { - it("omits read:org when organization access is disabled", async () => { - const { authOptions, BASE_GITHUB_OAUTH_SCOPE } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "", - }); - - expect(getGitHubProviderScope(authOptions)).toBe(BASE_GITHUB_OAUTH_SCOPE); - }); - - it("includes read:org when organization access is configured", async () => { - const { authOptions, BASE_GITHUB_OAUTH_SCOPE } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - - expect(getGitHubProviderScope(authOptions)).toBe(`${BASE_GITHUB_OAUTH_SCOPE} read:org`); - }); -}); - -describe("authOptions signIn", () => { - it("logs static allow decisions without sensitive token data", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_USERS: "alice", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "secret-token" }, - profile: { login: "Alice" }, - user: { email: "alice@example.com" }, - } as never) - ).resolves.toBe(true); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "Alice", - decision: "allow", - reason: "username_allowlist", - }); - expect(JSON.stringify(info.mock.calls)).not.toContain("secret-token"); - }); - - it("checks configured organization membership with the OAuth access token", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - NEXT_PUBLIC_APP_NAME: "Test App", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi.fn( - async () => new Response(JSON.stringify({ state: "active" })) - ) as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "oauth-token" }, - profile: { login: "member" }, - user: { email: "member@example.com" }, - } as never) - ).resolves.toBe(true); - - expect(fetchImpl).toHaveBeenCalledWith( - "https://api.github.com/user/memberships/orgs/acme", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer oauth-token", - "User-Agent": "Test App", - }) as HeadersInit, - }) - ); - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "member", - decision: "allow", - reason: "org_membership", - }); - }); - - it("denies organization access when the OAuth access token is missing", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn() as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: {}, - profile: { login: "member" }, - user: { email: "member@example.com" }, - } as never) - ).resolves.toBe(false); - - expect(fetchImpl).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledWith("[github-org-access] membership check skipped", { - reason: "missing_access_token", - organizationCount: 1, - }); - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "member", - decision: "deny", - reason: "org_membership_unavailable", - }); - }); - - it.each([ - ["404 response", () => new Response("Not Found", { status: 404 })], - ["pending membership", () => new Response(JSON.stringify({ state: "pending" }))], - ])("denies organization access for %s", async (_label, responseFactory) => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - vi.spyOn(console, "info").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => responseFactory()) as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "oauth-token" }, - profile: { login: "member" }, - user: { email: "member@example.com" }, - } as never) - ).resolves.toBe(false); - }); - - it.each([ - ["429 response", () => new Response("Rate Limited", { status: 429 })], - ["server error", () => new Response("Server Error", { status: 500 })], - [ - "network error", - () => { - throw new TypeError("fetch failed"); - }, - ], - ["malformed JSON", () => new Response("not-json")], - ])("reports organization verification unavailable for %s", async (_label, responseFactory) => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => responseFactory()) as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "oauth-token" }, - profile: { login: "member" }, - user: { email: "member@example.com" }, - } as never) - ).resolves.toBe(false); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "member", - decision: "deny", - reason: "org_membership_unavailable", - }); - }); - - it("does not let unsafe open access bypass configured org allowlists", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - UNSAFE_ALLOW_ALL_USERS: "true", - }); - vi.spyOn(console, "info").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response("Not Found", { status: 404 })); - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "oauth-token" }, - profile: { login: "outsider" }, - user: { email: "outsider@example.com" }, - } as never) - ).resolves.toBe(false); - }); - - it("denies a sign-in from an unrecognized provider", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_EMAIL_DOMAINS: "company.com", - }); - vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi.fn() as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { provider: "gitlab", access_token: "glpat-x" }, - profile: { login: "stranger", email_verified: true }, - user: { email: "stranger@company.com" }, - } as never) - ).resolves.toBe(false); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("does not run the GitHub org fallback for an unrecognized provider", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi.fn() as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { provider: "gitlab", access_token: "glpat-x" }, - profile: { login: "stranger" }, - user: { email: "stranger@example.com" }, - } as never) - ).resolves.toBe(false); - // The org fallback is GitHub-only, so a non-GitHub token never reaches GitHub. - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("admits a GitHub user whose non-primary verified email matches the domain allowlist", async () => { - // The core behavior of PR #829: the gate considers ALL verified emails, not - // just the primary. Here the primary (personal.com) does not match but a - // non-primary verified company.com email does. Before the fix this user was - // silently denied because only user.email (the primary) was checked. - const { authOptions } = await importAuthModule({ - ALLOWED_EMAIL_DOMAINS: "company.com", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - await expect( - getSignIn(authOptions)({ - account: { provider: "github", access_token: "gho_token" }, - profile: { - login: "octocat", - verifiedEmails: [ - { email: "octo@personal.com", primary: true, verified: true, visibility: "private" }, - { email: "octo@company.com", primary: false, verified: true, visibility: null }, - ], - }, - user: { email: "octo@personal.com" }, - } as never) - ).resolves.toBe(true); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "octocat", - decision: "allow", - reason: "email_domain_allowlist", - }); - }); - - it("denies a GitHub user when none of the verified emails match the allowlists", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_EMAIL_DOMAINS: "company.com", - ALLOWED_EMAILS: "exact@gmail.com", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - await expect( - getSignIn(authOptions)({ - account: { provider: "github", access_token: "gho_token" }, - profile: { - login: "stranger", - verifiedEmails: [ - { - email: "stranger@personal.com", - primary: true, - verified: true, - visibility: "private", - }, - { email: "stranger@other.com", primary: false, verified: true, visibility: null }, - ], - }, - user: { email: "stranger@personal.com" }, - } as never) - ).resolves.toBe(false); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "stranger", - decision: "deny", - reason: "no_matching_policy", - }); - }); - - it("does not trust user.email for the GitHub email/domain gate when verified emails are unavailable", async () => { - // Fail-closed guard: if the verified-email fetch came back empty (e.g. - // /user/emails 403'd for lack of the Email-addresses permission), the gate - // must NOT fall back to user.email — that value is not independently verified - // here. A user.email on an allowed domain must still be denied. - const { authOptions } = await importAuthModule({ - ALLOWED_EMAIL_DOMAINS: "company.com", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - await expect( - getSignIn(authOptions)({ - account: { provider: "github", access_token: "gho_token" }, - profile: { login: "octocat", verifiedEmails: [] }, - user: { email: "octo@company.com" }, - } as never) - ).resolves.toBe(false); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "octocat", - decision: "deny", - reason: "no_matching_policy", - }); - }); -}); - -describe("getVerifiedGitHubEmails", () => { - it("returns all verified emails", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify([ - { email: "other@example.com", primary: false, verified: true, visibility: "private" }, - { email: "user@company.com", primary: true, verified: true, visibility: "private" }, - ]) - ) - ); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([ - { email: "other@example.com", primary: false, verified: true, visibility: "private" }, - { email: "user@company.com", primary: true, verified: true, visibility: "private" }, - ]); - }); - - it("excludes unverified emails", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify([ - { email: "user@company.com", primary: true, verified: false, visibility: "private" }, - ]) - ) - ); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - }); - - it("returns empty array for malformed GitHub email responses", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify([{ email: "user@company.com", verified: true }])) - ); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - - expect(warn).toHaveBeenCalledWith( - "[github-email-fetch] invalid response", - expect.objectContaining({ elapsedMs: expect.any(Number) }) - ); - }); - - it("returns empty array when GitHub email lookup fails", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - }); - - it("hints at the missing Email-addresses permission on a 403", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - - expect(warn).toHaveBeenCalledWith( - "[github-email-fetch] request failed", - expect.objectContaining({ - status: 403, - hint: expect.stringContaining("Email addresses"), - }) - ); - }); - - it("does not attach the permission hint on non-403 failures", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 500 })); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - - expect(warn).toHaveBeenCalledWith( - "[github-email-fetch] request failed", - expect.not.objectContaining({ hint: expect.anything() }) - ); - }); -}); - -describe("getStaticSignInReason", () => { - describe("Google", () => { - const config = cfg({ allowedEmails: ["pm@gmail.com"] }); - - it("denies an unverified email (null) before any allowlist match", () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: false } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBeNull(); - }); - - it('denies an unverified email when email_verified is the string "false"', () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: "false" } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBeNull(); - }); - - it("denies when email_verified is absent", () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: {} as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBeNull(); - }); - - it("admits a verified (boolean true) allowlisted email with the email reason", () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: true } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBe("email_allowlist"); - }); - - it('admits a verified email when email_verified is the string "true"', () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: "true" } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBe("email_allowlist"); - }); - - it('accepts a mixed-case "True" string (case-insensitive normalization)', () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: "True" } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBe("email_allowlist"); - }); - - it("denies a verified email that is not on any allowlist", () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: true } as unknown as Profile, - emails: ["stranger@gmail.com"], - config, - }) - ).toBeNull(); - }); - }); - - describe("GitHub", () => { - it("admits an allowlisted GitHub username without an email_verified check", () => { - expect( - getStaticSignInReason({ - provider: "github", - profile: { login: "octocat" } as unknown as Profile, - emails: ["octo@company.com"], - config: cfg({ allowedUsers: ["octocat"] }), - }) - ).toBe("username_allowlist"); - }); - - it("denies a non-allowlisted GitHub user", () => { - expect( - getStaticSignInReason({ - provider: "github", - profile: { login: "stranger" } as unknown as Profile, - emails: ["stranger@other.com"], - config: cfg({ allowedDomains: ["company.com"], allowedUsers: ["octocat"] }), - }) - ).toBeNull(); - }); - - it("treats an undefined provider as the GitHub path", () => { - expect( - getStaticSignInReason({ - provider: undefined, - profile: { login: "octocat" } as unknown as Profile, - emails: ["octo@company.com"], - config: cfg({ allowedUsers: ["octocat"] }), - }) - ).toBe("username_allowlist"); - }); - }); - - describe("unrecognized provider", () => { - it("denies an unknown provider even when its email matches an allowlist", () => { - // Previously a non-google provider fell through to the GitHub branch and - // could be admitted by email/domain; an unrecognized provider now fails - // closed instead. - expect( - getStaticSignInReason({ - provider: "gitlab", - profile: { email_verified: true } as unknown as Profile, - emails: ["user@company.com"], - config: cfg({ allowedDomains: ["company.com"] }), - }) - ).toBeNull(); - }); - }); -}); - -describe("normalizeGitHubUserInfoProfile", () => { - const verifiedEmails = [ - { email: "octo@company.com", primary: true, verified: true, visibility: null }, - { email: "other@company.com", primary: false, verified: true, visibility: "private" }, - ]; - - it("parses a valid GitHub profile and attaches verified emails", () => { - expect( - normalizeGitHubUserInfoProfile( - { - id: 12345, - login: "octocat", - email: "old@example.com", - avatar_url: "https://example.com/a.png", - }, - verifiedEmails - ) - ).toEqual({ - id: 12345, - login: "octocat", - email: "octo@company.com", - avatar_url: "https://example.com/a.png", - verifiedEmails, - }); - }); - - it("accepts GitHub profiles with a null email", () => { - expect( - normalizeGitHubUserInfoProfile({ id: 12345, login: "octocat", email: null }, []) - ).toEqual({ id: 12345, login: "octocat", email: null, verifiedEmails: [] }); - }); - - it("rejects malformed or partial GitHub profiles", () => { - expect(normalizeGitHubUserInfoProfile({ id: 12345 }, verifiedEmails)).toBeNull(); - expect(normalizeGitHubUserInfoProfile({ id: {}, login: "octocat" }, verifiedEmails)).toBeNull(); - }); -}); - -describe("applyJwtClaims", () => { - it("captures SCM credentials and identity for a GitHub sign-in", () => { - const token = applyJwtClaims( - {}, - { - provider: "github", - type: "oauth", - providerAccountId: "12345", - access_token: "gho_abc", - refresh_token: "ghr_def", - expires_at: 1_700_000_000, - } as Account, - { id: 12345, login: "octocat" } as unknown as Profile - ); - - expect(token.provider).toBe("github"); - expect(token.providerUserId).toBe("12345"); - expect(token.githubUserId).toBe("12345"); - expect(token.githubLogin).toBe("octocat"); - expect(token.accessToken).toBe("gho_abc"); - expect(token.refreshToken).toBe("ghr_def"); - expect(token.accessTokenExpiresAt).toBe(1_700_000_000 * 1000); - }); - - it("does NOT capture an access token for a Google sign-in (F1 credential-leak gate)", () => { - const token = applyJwtClaims( - {}, - { - provider: "google", - type: "oauth", - providerAccountId: "google-sub-1", - access_token: "ya29.google-token", - refresh_token: "1//google-refresh", - expires_at: 1_700_000_000, - } as Account, - { sub: "google-sub-1", email: "pm@gmail.com", email_verified: true } as unknown as Profile - ); - - expect(token.accessToken).toBeUndefined(); - expect(token.refreshToken).toBeUndefined(); - expect(token.accessTokenExpiresAt).toBeUndefined(); - expect(token.provider).toBe("google"); - expect(token.providerUserId).toBe("google-sub-1"); - expect(token.githubUserId).toBeUndefined(); - expect(token.githubLogin).toBeUndefined(); - }); - - it("clears stale GitHub claims when a prior GitHub JWT is reused for a Google sign-in", () => { - const token = applyJwtClaims( - { - provider: "github", - providerUserId: "12345", - githubUserId: "12345", - githubLogin: "octocat", - accessToken: "gho_abc", - refreshToken: "ghr_def", - accessTokenExpiresAt: 1_700_000_000 * 1000, - } as JWT, - { - provider: "google", - type: "oauth", - providerAccountId: "google-sub-1", - access_token: "ya29.google-token", - refresh_token: "1//google-refresh", - expires_at: 1_700_000_000, - } as Account, - { sub: "google-sub-1", email: "pm@gmail.com", email_verified: true } as unknown as Profile - ); - - expect(token.provider).toBe("google"); - expect(token.providerUserId).toBe("google-sub-1"); - expect(token.accessToken).toBeUndefined(); - expect(token.refreshToken).toBeUndefined(); - expect(token.accessTokenExpiresAt).toBeUndefined(); - expect(token.githubUserId).toBeUndefined(); - expect(token.githubLogin).toBeUndefined(); - }); - - it("backfills provider/providerUserId for a legacy GitHub JWT with no account on the request", () => { - const token = applyJwtClaims({ githubUserId: "999" } as JWT, null, undefined); - - expect(token.provider).toBe("github"); - expect(token.providerUserId).toBe("999"); - // No account on the request, so no fresh credentials are captured. - expect(token.accessToken).toBeUndefined(); - }); - - it("leaves an anonymous token untouched", () => { - const token = applyJwtClaims({}, null, undefined); - - expect(token.provider).toBeUndefined(); - expect(token.providerUserId).toBeUndefined(); - }); - - it("stores no provider and clears GitHub claims for an unrecognized provider", () => { - // Defensive: such a session is already denied at signIn, but if a JWT for an - // unrecognized provider were ever produced it must carry no SCM/GitHub state. - const token = applyJwtClaims( - { - accessToken: "gho_old", - githubUserId: "12345", - githubLogin: "octocat", - } as JWT, - { - provider: "gitlab", - type: "oauth", - providerAccountId: "gl-1", - access_token: "glpat-xyz", - } as unknown as Account, - undefined - ); - - expect(token.provider).toBeUndefined(); - expect(token.providerUserId).toBeUndefined(); - expect(token.accessToken).toBeUndefined(); - expect(token.githubUserId).toBeUndefined(); - expect(token.githubLogin).toBeUndefined(); - }); -}); - -describe("applySessionUser", () => { - function emptySession(): Session { - return { user: {}, expires: "" }; - } - - it("maps a GitHub token onto the session user", () => { - const session = applySessionUser(emptySession(), { - provider: "github", - providerUserId: "12345", - githubUserId: "12345", - githubLogin: "octocat", - } as JWT); - - expect(session.user.id).toBe("12345"); - expect(session.user.provider).toBe("github"); - expect(session.user.login).toBe("octocat"); - }); - - it("maps a Google token onto the session user with no login", () => { - const session = applySessionUser(emptySession(), { - provider: "google", - providerUserId: "google-sub-1", - } as JWT); - - expect(session.user.id).toBe("google-sub-1"); - expect(session.user.provider).toBe("google"); - expect(session.user.login).toBeUndefined(); - }); - - it("falls back to githubUserId for a legacy token without providerUserId", () => { - const session = applySessionUser(emptySession(), { githubUserId: "999" } as JWT); - - expect(session.user.id).toBe("999"); - }); -}); - -async function importAuthModule(env: Record = {}) { - vi.resetModules(); - resetAuthEnv(); - for (const [key, value] of Object.entries(env)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - - return import("./auth"); -} - -function resetAuthEnv(): void { - for (const key of [ - "ALLOWED_EMAIL_DOMAINS", - "ALLOWED_USERS", - "ALLOWED_EMAILS", - "ALLOWED_GITHUB_ORGS", - "UNSAFE_ALLOW_ALL_USERS", - "NEXT_PUBLIC_APP_NAME", - "GOOGLE_CLIENT_ID", - "GOOGLE_CLIENT_SECRET", - ]) { - if (ORIGINAL_ENV[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = ORIGINAL_ENV[key]; - } - } -} - -function getGitHubProviderScope(authOptions: NextAuthOptions): string { - const provider = authOptions.providers[0] as { - options: { authorization: { params: { scope: string } } }; - }; - return provider.options.authorization.params.scope; -} - -function getSignIn(authOptions: NextAuthOptions) { - const signIn = authOptions.callbacks?.signIn; - if (!signIn) { - throw new Error("signIn callback is not configured"); - } - - return signIn; -} diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts deleted file mode 100644 index 8e78dca3c..000000000 --- a/packages/web/src/lib/auth.ts +++ /dev/null @@ -1,469 +0,0 @@ -import type { Account, NextAuthOptions, Profile, Session } from "next-auth"; -import type { JWT } from "next-auth/jwt"; -import GitHubProvider from "next-auth/providers/github"; -import type { GithubProfile } from "next-auth/providers/github"; -import GoogleProvider from "next-auth/providers/google"; -import { z } from "zod"; -import { DEFAULT_APP_NAME } from "@open-inspect/shared"; -import { - type AccessAllowReason, - type AccessControlConfig, - getAccessAllowReason, - parseAllowlist, - parseBooleanEnv, -} from "./access-control"; -import { - checkGitHubOrganizationAccess, - type GitHubOrganizationAccessResult, -} from "./github-org-membership"; -import { type AuthProvider, isAuthProvider } from "./build-auth-identity"; -import { applyOiSessionTokens } from "./oi-session"; -import { githubEmailListSchema, type GitHubEmail } from "./github-email-schema"; - -const GITHUB_EMAIL_FETCH_TIMEOUT_MS = 5_000; - -interface GitHubEmailFetchParams { - accessToken: string | undefined; - fetchImpl?: typeof fetch; - userAgent?: string; - timeoutMs?: number; -} - -const githubUserInfoProfileSchema = z - .object({ - id: z.union([z.number(), z.string()]), - login: z.string(), - email: z.string().nullable().optional(), - }) - .passthrough(); - -type GitHubUserInfoProfile = z.infer & { - email: string | null; - verifiedEmails: GitHubEmail[]; -}; - -type ProfileWithVerifiedEmails = Profile & { verifiedEmails?: GitHubEmail[] }; - -export function normalizeGitHubUserInfoProfile( - profile: unknown, - verifiedEmails: GitHubEmail[] -): GitHubUserInfoProfile | null { - const result = githubUserInfoProfileSchema.safeParse(profile); - if (!result.success) { - return null; - } - - return { - ...result.data, - email: verifiedEmails.find((e) => e.primary)?.email ?? null, - verifiedEmails, - }; -} - -/** - * Fetch verified email addresses from GitHub's API. - * - * Returns all verified emails for the authenticated user. If the access token - * is missing or the request fails, returns an empty array (fails closed). - * Requests are aborted after the timeout to prevent hanging. - */ -export async function getVerifiedGitHubEmails({ - accessToken, - fetchImpl = fetch, - userAgent = "Open-Inspect", - timeoutMs = GITHUB_EMAIL_FETCH_TIMEOUT_MS, -}: GitHubEmailFetchParams): Promise { - if (!accessToken) return []; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const startedAt = performance.now(); - - try { - const response = await fetchImpl("https://api.github.com/user/emails", { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": userAgent, - }, - signal: controller.signal, - }); - if (!response.ok) { - console.warn("[github-email-fetch] request failed", { - status: response.status, - elapsedMs: Math.round(performance.now() - startedAt), - // A 403 here almost always means the GitHub App is missing the "Email - // addresses" account permission (read-only), so /user/emails is forbidden - // even with a valid token. Without it, ALLOWED_EMAILS / - // ALLOWED_EMAIL_DOMAINS can never match a GitHub sign-in, which otherwise - // looks like an unexplained "no_matching_policy" denial. Surface a fix. - // (OAuth App deployments authorize this via the user:email scope instead.) - ...(response.status === 403 && { - hint: "GitHub App is likely missing the 'Email addresses: Read-only' account permission; grant it and re-approve the installation, or ALLOWED_EMAILS/ALLOWED_EMAIL_DOMAINS will not match GitHub sign-ins.", - }), - }); - return []; - } - const result = githubEmailListSchema.safeParse(await response.json()); - if (!result.success) { - console.warn("[github-email-fetch] invalid response", { - elapsedMs: Math.round(performance.now() - startedAt), - }); - return []; - } - return result.data.filter((e) => e.verified); - } catch (error) { - console.warn("[github-email-fetch] request error", { - error: error instanceof Error ? error.name : "unknown", - message: error instanceof Error ? error.message : String(error), - elapsedMs: Math.round(performance.now() - startedAt), - }); - return []; - } finally { - clearTimeout(timeout); - } -} - -// Extend NextAuth types to include provider-agnostic identity plus the -// GitHub-only SCM fields. -declare module "next-auth" { - interface Session { - user: { - id?: string; // Canonical provider user id: GitHub numeric id or Google sub - login?: string; // GitHub username (GitHub-only) - provider?: AuthProvider; // Which provider authenticated this session - name?: string | null; - email?: string | null; - image?: string | null; - }; - } -} - -declare module "next-auth/jwt" { - interface JWT { - accessToken?: string; - refreshToken?: string; - accessTokenExpiresAt?: number; // Unix timestamp in milliseconds - githubUserId?: string; - githubLogin?: string; - provider?: AuthProvider; - providerUserId?: string; // GitHub numeric id or Google sub - // CP-issued web session tokens: minted by the provider-verified - // exchange at sign-in, renewed via the rotating refresh grant through - // the /api/auth/oi-refresh route (the jwt callback never renews). - oiAccessToken?: string; - oiAccessTokenExpiresAt?: number; // Unix timestamp in milliseconds - oiRefreshToken?: string; - } -} - -export const BASE_GITHUB_OAUTH_SCOPE = "read:user user:email repo"; - -export function buildGitHubOAuthScope( - allowedOrganizations = parseAllowlist(process.env.ALLOWED_GITHUB_ORGS) -): string { - return allowedOrganizations.length > 0 - ? `${BASE_GITHUB_OAUTH_SCOPE} read:org` - : BASE_GITHUB_OAUTH_SCOPE; -} - -/** - * Normalize Google's `email_verified` claim. Google has returned it as boolean - * `true` or the string "true" (case-insensitive) depending on the flow; anything - * else is treated as unverified and fails closed. - */ -function isVerifiedGoogleEmail(profile: Profile | undefined): boolean { - const googleProfile = profile as { email_verified?: boolean | string } | undefined; - return ( - googleProfile?.email_verified === true || - String(googleProfile?.email_verified).toLowerCase() === "true" - ); -} - -/** - * Resolve the static (synchronous) allow reason for a sign-in attempt, or null - * when the static allowlists don't admit it. Pure and exported so the policy is - * unit-testable — NextAuth's inline signIn callback otherwise can't be reached. - * - * Providers are handled explicitly; an unrecognized provider is denied - * (default-closed) rather than treated as GitHub. - * - * - GitHub: the email was already resolved to the verified primary in the - * provider's userinfo override, so only the allowlist gate applies. - * - Google: the email MUST be verified (see isVerifiedGoogleEmail) before any - * allowlist match. All email-based admission (the email allowlist here, and - * cross-provider account linking downstream) trusts this, so it is the single - * most security-sensitive check in the sign-in path. - * - * GitHub organization membership is intentionally NOT resolved here: it needs an - * async call to GitHub's API, so the signIn callback applies it as a fallback - * when this returns null. - */ -export function getStaticSignInReason(args: { - provider: string | undefined; - profile: Profile | undefined; - emails: string[] | undefined; - config: AccessControlConfig; -}): AccessAllowReason | null { - const { provider, profile, emails, config } = args; - - switch (provider) { - case "google": { - // The email must be verified before any email-based allowlist match. - if (!isVerifiedGoogleEmail(profile)) { - return null; - } - return getAccessAllowReason(config, { emails }); - } - case "github": - case undefined: { - // GitHub, including legacy sessions minted before the provider field - // existed (treated as GitHub, matching resolveAuthProvider). The email was - // already resolved to the verified primary in the provider's userinfo - // override, so only the allowlist gate applies. - const githubProfile = profile as { login?: string } | undefined; - return getAccessAllowReason(config, { - githubUsername: githubProfile?.login, - emails, - }); - } - default: - // Any other provider is denied rather than treated as GitHub, so admitting - // a new provider is a deliberate case here and never relies on an email - // this app has not verified for that provider. - return null; - } -} - -/** - * Apply provider claims to the JWT. Pure and exported for testing. - * - * SCM credentials (accessToken/refreshToken/expiry) are captured ONLY for - * GitHub. A Google `access_token` must never populate `token.accessToken`: both - * the session-create and ws-token routes forward `token.accessToken` as - * `scmToken`, after which the control plane would use a Google token against - * GitHub's API and refresh it at GitHub's OAuth endpoint (credential leak). - * - * On a non-GitHub sign-in we also CLEAR any GitHub SCM/identity claims carried - * over from a prior GitHub session on the same JWT (NextAuth passes the previous - * token into this callback), so a Google token can never hold stale GitHub - * credentials. Cross-provider GitHub attribution for a linked user is resolved - * server-side from D1, not from these cookie claims. - */ -export function applyJwtClaims( - token: JWT, - account: Account | null | undefined, - profile: Profile | undefined -): JWT { - if (account) { - // Validate the provider against the supported set instead of casting. Only a - // validated provider contributes an identity: an unrecognized provider stores - // no provider/providerUserId and falls to the claim-clearing branch below, so - // it can't surface as a legacy GitHub session via resolveAuthProvider. - const provider = isAuthProvider(account.provider) ? account.provider : undefined; - token.provider = provider; - token.providerUserId = provider ? account.providerAccountId : undefined; - - if (provider === "github") { - token.accessToken = account.access_token; - token.refreshToken = account.refresh_token; - // expires_at is in seconds, convert to milliseconds (only set if provided) - token.accessTokenExpiresAt = account.expires_at ? account.expires_at * 1000 : undefined; - } else { - // Non-GitHub sign-in: drop any GitHub SCM/identity claims left on a token - // reused from a prior GitHub session, so a Google JWT carries no SCM state. - token.accessToken = undefined; - token.refreshToken = undefined; - token.accessTokenExpiresAt = undefined; - token.githubUserId = undefined; - token.githubLogin = undefined; - } - } - - if (profile) { - // GitHub profile carries id (numeric) and login (username); Google profiles - // carry neither, so these stay unset for Google sessions. - const githubProfile = profile as { id?: number; login?: string }; - if (githubProfile.id) { - token.githubUserId = githubProfile.id.toString(); - } - if (githubProfile.login) { - token.githubLogin = githubProfile.login; - } - } - - // Back-compat for the staggered deploy: GitHub JWTs minted before - // provider/providerUserId existed carry githubUserId but no provider. Backfill - // from githubUserId so session.user.id/provider stay correct without forcing a - // re-login. Never fires for Google (no githubUserId) or fresh logins - // (providerUserId is already set from the account above). - if (!token.providerUserId && token.githubUserId) { - token.provider = "github"; - token.providerUserId = token.githubUserId; - } - - return token; -} - -/** - * Map JWT claims onto the session user. Pure and exported for testing. - */ -export function applySessionUser(session: Session, token: JWT): Session { - if (session.user) { - // Canonical provider user id, falling back to githubUserId so legacy GitHub - // JWTs (minted before providerUserId existed) keep a stable session.user.id - // across the deploy. - session.user.id = token.providerUserId ?? token.githubUserId; - session.user.provider = token.provider; - // login is GitHub-only; undefined for Google sessions. - session.user.login = token.githubLogin; - } - return session; -} - -function logSignInDecision( - login: string | undefined, - decision: "allow" | "deny", - reason: string -): void { - console.info("[auth] sign-in decision", { - login: login ?? null, - decision, - reason, - }); -} - -function getOrgMembershipDecisionReason(orgMembership: GitHubOrganizationAccessResult): string { - if (orgMembership.allowed) { - return "org_membership"; - } - - return orgMembership.reason === "unavailable" - ? "org_membership_unavailable" - : "org_membership_denied"; -} - -const providers: NextAuthOptions["providers"] = [ - GitHubProvider({ - clientId: process.env.GITHUB_CLIENT_ID!, - clientSecret: process.env.GITHUB_CLIENT_SECRET!, - authorization: { - params: { - scope: buildGitHubOAuthScope(), - }, - }, - userinfo: { - url: "https://api.github.com/user", - async request({ client, tokens }) { - const verifiedEmails = await getVerifiedGitHubEmails({ accessToken: tokens.access_token! }); - const profile = normalizeGitHubUserInfoProfile( - await client.userinfo(tokens.access_token!), - verifiedEmails - ); - if (!profile) { - throw new Error("Invalid GitHub userinfo response"); - } - // SAFETY: NextAuth's Profile type is narrower than GitHub's numeric `id`, - // but this object has just been validated against the GitHub userinfo shape. - return profile as Profile; - }, - }, - }), -]; - -// Google is opt-in: enabled only when both credentials are configured, so -// GitHub-only deployments are byte-unchanged. Scopes stay within the -// non-sensitive openid/email/profile set (no SCM access, no Google review). -const googleClientId = process.env.GOOGLE_CLIENT_ID; -const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; -if (googleClientId && googleClientSecret) { - providers.push( - GoogleProvider({ - clientId: googleClientId, - clientSecret: googleClientSecret, - authorization: { - params: { - scope: "openid email profile", - }, - }, - }) - ); -} - -export const authOptions: NextAuthOptions = { - debug: process.env.NODE_ENV === "development" || process.env.NEXTAUTH_DEBUG === "true", - providers, - callbacks: { - async signIn({ account, profile, user }) { - const config: AccessControlConfig = { - allowedDomains: parseAllowlist(process.env.ALLOWED_EMAIL_DOMAINS), - allowedUsers: parseAllowlist(process.env.ALLOWED_USERS), - allowedEmails: parseAllowlist(process.env.ALLOWED_EMAILS), - allowedOrganizations: parseAllowlist(process.env.ALLOWED_GITHUB_ORGS), - unsafeAllowAllUsers: parseBooleanEnv(process.env.UNSAFE_ALLOW_ALL_USERS), - }; - - const provider = account?.provider; - const githubProfile = profile as { login?: string } | undefined; - const isGitHubProvider = provider === "github" || provider === undefined; - const hasAllowLists = config.allowedDomains.length > 0 || config.allowedEmails.length > 0; - - let emails: string[] | undefined = undefined; - if (isGitHubProvider && hasAllowLists) { - const verifiedEmails = (profile as ProfileWithVerifiedEmails | undefined)?.verifiedEmails; - emails = verifiedEmails?.map((e) => e.email); - } else { - emails = user.email ? [user.email] : undefined; - } - - // Static, synchronous allowlist gate. Provider-aware: Google requires a - // verified email before any email-based match (see getStaticSignInReason). - const staticReason = getStaticSignInReason({ - provider, - profile, - emails, - config, - }); - if (staticReason) { - logSignInDecision(githubProfile?.login, "allow", staticReason); - return true; - } - - // GitHub organization membership fallback. Org membership is a GitHub - // concept and the async check calls GitHub's API with the OAuth token, so - // it runs only for GitHub sign-ins (including legacy sessions with no - // provider) when at least one org is configured. Any other provider — - // Google or unrecognized — fails closed here without contacting GitHub, so - // a non-GitHub OAuth token is never sent to GitHub's API. - const allowedOrganizations = config.allowedOrganizations ?? []; - if (!isGitHubProvider || allowedOrganizations.length === 0) { - logSignInDecision(githubProfile?.login, "deny", "no_matching_policy"); - return false; - } - - const orgMembership = await checkGitHubOrganizationAccess({ - accessToken: account?.access_token, - allowedOrganizations, - userAgent: process.env.NEXT_PUBLIC_APP_NAME?.trim() || DEFAULT_APP_NAME, - }); - - logSignInDecision( - githubProfile?.login, - orgMembership.allowed ? "allow" : "deny", - getOrgMembershipDecisionReason(orgMembership) - ); - - return orgMembership.allowed; - }, - async jwt({ token, account, profile }) { - return applyOiSessionTokens(applyJwtClaims(token, account, profile), account); - }, - async session({ session, token }) { - return applySessionUser(session, token); - }, - }, - pages: { - error: "/access-denied", - }, -}; diff --git a/packages/web/src/lib/browser-auth-proxy.test.ts b/packages/web/src/lib/browser-auth-proxy.test.ts new file mode 100644 index 000000000..3b15c56e5 --- /dev/null +++ b/packages/web/src/lib/browser-auth-proxy.test.ts @@ -0,0 +1,250 @@ +import { sha256Hex, verifyServiceSignature } from "@open-inspect/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + dispatchControlPlaneFetch: vi.fn(), +})); + +vi.mock("./control-plane-transport", () => ({ + dispatchControlPlaneFetch: mocks.dispatchControlPlaneFetch, + getControlPlaneUrl: () => "https://control-plane.example", +})); + +import { dispatchBrowserAuthRequest, proxyBrowserAuthRequest } from "./browser-auth-proxy"; + +describe("proxyBrowserAuthRequest", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = { + ...originalEnv, + SERVICE_AUTH_SECRET: "web-service-secret", + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("forwards the auth request transparently with a fresh web signature", async () => { + const upstreamHeaders = new Headers({ + Location: "/after-sign-in", + "Cache-Control": "private", + }); + upstreamHeaders.append( + "Set-Cookie", + "__Secure-openinspect.session_token=session.signature; Path=/; Secure; HttpOnly" + ); + upstreamHeaders.append( + "Set-Cookie", + "__Secure-openinspect.state=; Path=/; Max-Age=0; Secure; HttpOnly" + ); + mocks.dispatchControlPlaneFetch.mockResolvedValue( + new Response("redirecting", { + status: 302, + headers: upstreamHeaders, + }) + ); + const body = JSON.stringify({ + provider: "github", + callbackURL: "/after-sign-in", + disableRedirect: true, + }); + + const response = await proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/sign-in/social?return=1", { + method: "POST", + headers: { + Authorization: "Bearer caller-controlled", + Connection: "keep-alive", + Cookie: "__Secure-openinspect.state=state-cookie", + "Content-Type": "application/json", + Origin: "https://web.example", + "User-Agent": "Test Browser", + "X-Forwarded-For": "203.0.113.42", + "X-OpenInspect-Client-IP": "198.51.100.99", + "X-OpenInspect-Service": "modal", + "X-OpenInspect-Service-Signature": "caller-controlled", + }, + body, + }) + ); + + const [url, init] = mocks.dispatchControlPlaneFetch.mock.calls[0] ?? []; + expect(url).toBe("https://control-plane.example/api/auth/sign-in/social?return=1"); + expect(init).toMatchObject({ + method: "POST", + redirect: "manual", + cache: "no-store", + }); + expect(new TextDecoder().decode(init?.body as Uint8Array)).toBe(body); + + const sentHeaders = new Headers(init?.headers); + expect(sentHeaders.get("Cookie")).toBe("__Secure-openinspect.state=state-cookie"); + expect(sentHeaders.get("Content-Type")).toBe("application/json"); + expect(sentHeaders.get("Origin")).toBe("https://web.example"); + expect(sentHeaders.get("User-Agent")).toBe("Test Browser"); + expect(sentHeaders.get("X-OpenInspect-Client-IP")).toBeNull(); + expect(sentHeaders.get("Authorization")).toBeNull(); + expect(sentHeaders.get("Connection")).toBeNull(); + expect(sentHeaders.get("X-OpenInspect-Service")).toBe("web"); + expect(sentHeaders.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + + const verification = await verifyServiceSignature({ + signatureHeader: sentHeaders.get("X-OpenInspect-Service-Signature") ?? "", + service: "web", + secret: "web-service-secret", + method: "POST", + url: String(url), + bodySha256Hex: await sha256Hex(body), + actor: "", + }); + expect(verification.ok).toBe(true); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/after-sign-in"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.getSetCookie()).toHaveLength(2); + expect(await response.text()).toBe("redirecting"); + }); + + it("dispatches server-side auth calls without requiring a synthetic request origin", async () => { + mocks.dispatchControlPlaneFetch.mockResolvedValue(Response.json({ user: { id: "user-1" } })); + + const response = await dispatchBrowserAuthRequest({ + method: "GET", + pathname: "/api/auth/get-session", + headers: { + Cookie: "__Secure-openinspect.session_token=session.signature", + "X-Trace-Id": "trace-1", + }, + }); + + const [url, init, metadata] = mocks.dispatchControlPlaneFetch.mock.calls[0] ?? []; + expect(url).toBe("https://control-plane.example/api/auth/get-session"); + expect(init).toMatchObject({ + method: "GET", + redirect: "manual", + cache: "no-store", + }); + expect(metadata).toEqual({}); + + const sentHeaders = new Headers(init?.headers); + expect(sentHeaders.get("Cookie")).toBe("__Secure-openinspect.session_token=session.signature"); + expect(sentHeaders.get("X-Trace-Id")).toBe("trace-1"); + expect(sentHeaders.get("X-OpenInspect-Service")).toBe("web"); + + const verification = await verifyServiceSignature({ + signatureHeader: sentHeaders.get("X-OpenInspect-Service-Signature") ?? "", + service: "web", + secret: "web-service-secret", + method: "GET", + url: String(url), + bodySha256Hex: await sha256Hex(""), + actor: "", + }); + expect(verification.ok).toBe(true); + expect(response.status).toBe(200); + }); + + it("rejects typed dispatches outside the positive proxy allowlist", async () => { + const response = await dispatchBrowserAuthRequest({ + method: "GET", + pathname: "/api/auth/list-sessions", + }); + + expect(response.status).toBe(404); + expect(mocks.dispatchControlPlaneFetch).not.toHaveBeenCalled(); + }); + + it("forwards Vercel's trusted client IP header on Vercel", async () => { + process.env.VERCEL = "1"; + mocks.dispatchControlPlaneFetch.mockResolvedValue(Response.json({ ok: true })); + + await proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/get-session", { + headers: { + "X-Vercel-Forwarded-For": "203.0.113.42", + "X-Forwarded-For": "192.0.2.55", + }, + }) + ); + + const [, init] = mocks.dispatchControlPlaneFetch.mock.calls[0] ?? []; + expect(new Headers(init?.headers).get("X-OpenInspect-Client-IP")).toBe("203.0.113.42"); + }); + + it("forwards Cloudflare's trusted client IP header on Cloudflare", async () => { + mocks.dispatchControlPlaneFetch.mockResolvedValue(Response.json({ ok: true })); + const request = new Request("https://web.example/api/auth/get-session", { + headers: { + "CF-Connecting-IP": "198.51.100.24", + "X-Forwarded-For": "192.0.2.55", + }, + }); + Object.defineProperty(request, "cf", { value: {} }); + + await proxyBrowserAuthRequest(request); + + const [, init] = mocks.dispatchControlPlaneFetch.mock.calls[0] ?? []; + expect(new Headers(init?.headers).get("X-OpenInspect-Client-IP")).toBe("198.51.100.24"); + }); + + it("rejects endpoints outside the positive proxy allowlist", async () => { + const response = await proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/list-sessions", { + method: "GET", + }) + ); + + expect(response.status).toBe(404); + expect(mocks.dispatchControlPlaneFetch).not.toHaveBeenCalled(); + }); + + it("does not advertise upstream compression after fetch decodes the response body", async () => { + mocks.dispatchControlPlaneFetch.mockResolvedValue( + new Response(JSON.stringify({ url: "https://github.example/authorize" }), { + status: 200, + headers: { + "Content-Encoding": "br", + "Content-Length": "999", + "Content-Type": "application/json", + }, + }) + ); + + const response = await proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/sign-in/social", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + url: "https://github.example/authorize", + }); + }); + + it("fails closed when the web signing secret is unavailable", async () => { + delete process.env.SERVICE_AUTH_SECRET; + + await expect( + proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/get-session", { + method: "GET", + }) + ) + ).rejects.toThrow("SERVICE_AUTH_SECRET not configured"); + expect(mocks.dispatchControlPlaneFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/lib/browser-auth-proxy.ts b/packages/web/src/lib/browser-auth-proxy.ts new file mode 100644 index 000000000..6a957cec9 --- /dev/null +++ b/packages/web/src/lib/browser-auth-proxy.ts @@ -0,0 +1,162 @@ +import { + BROWSER_AUTH_CLIENT_IP_HEADER, + buildServiceAuthHeaders, + isBrowserAuthProxyRoute, +} from "@open-inspect/shared"; +import { dispatchControlPlaneFetch, getControlPlaneUrl } from "./control-plane-transport"; + +const REQUEST_HEADERS = [ + "Accept", + "Accept-Language", + "Content-Type", + "Cookie", + "Origin", + "User-Agent", +] as const; + +const HOP_BY_HOP_RESPONSE_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +const DECODED_BODY_RESPONSE_HEADERS = new Set(["content-encoding", "content-length"]); + +/** + * A logical browser-auth request for server code that has no incoming URL. + * The dispatcher resolves the configured control-plane origin before signing. + */ +export interface BrowserAuthDispatchRequest { + readonly method: string; + readonly pathname: string; + readonly search?: string; + readonly headers?: HeadersInit; + readonly body?: Uint8Array; + readonly clientIp?: string | null; +} + +function copyRequestHeaders(source: Headers, clientIp?: string | null): Headers { + const headers = new Headers(); + for (const name of REQUEST_HEADERS) { + const value = source.get(name); + if (value !== null) headers.set(name, value); + } + if (clientIp != null) { + headers.set(BROWSER_AUTH_CLIENT_IP_HEADER, clientIp); + } + return headers; +} + +function trustedClientIp(request: Request): string | null { + if (process.env.VERCEL === "1") { + return request.headers.get("X-Vercel-Forwarded-For"); + } + return "cf" in request ? request.headers.get("CF-Connecting-IP") : null; +} + +function getSetCookieValues(headers: Headers): string[] { + const withGetSetCookie = headers as Headers & { + getSetCookie?: () => string[]; + }; + const values = withGetSetCookie.getSetCookie?.() ?? []; + if (values.length > 0) return values; + const singleValue = headers.get("Set-Cookie"); + return singleValue ? [singleValue] : []; +} + +function copyResponseHeaders(upstream: Headers): Headers { + const headers = new Headers(); + upstream.forEach((value, name) => { + const normalizedName = name.toLowerCase(); + if ( + normalizedName !== "set-cookie" && + !HOP_BY_HOP_RESPONSE_HEADERS.has(normalizedName) && + !DECODED_BODY_RESPONSE_HEADERS.has(normalizedName) + ) { + headers.append(name, value); + } + }); + for (const value of getSetCookieValues(upstream)) { + headers.append("Set-Cookie", value); + } + headers.set("Cache-Control", "no-store"); + headers.set("Pragma", "no-cache"); + return headers; +} + +async function dispatchAllowedBrowserAuthRequest( + request: BrowserAuthDispatchRequest +): Promise { + const method = request.method; + const secret = process.env.SERVICE_AUTH_SECRET; + if (!secret) { + throw new Error("SERVICE_AUTH_SECRET not configured"); + } + + const upstreamUrl = `${getControlPlaneUrl()}${request.pathname}${request.search ?? ""}`; + const sourceHeaders = new Headers(request.headers); + const headers = copyRequestHeaders(sourceHeaders, request.clientIp); + const serviceHeaders = await buildServiceAuthHeaders({ + service: "web", + secret, + method, + url: upstreamUrl, + body: request.body, + traceId: sourceHeaders.get("x-trace-id") ?? undefined, + }); + for (const [name, value] of Object.entries(serviceHeaders)) { + headers.set(name, value); + } + + const upstream = await dispatchControlPlaneFetch( + upstreamUrl, + { + method, + headers, + body: request.body, + redirect: "manual", + cache: "no-store", + }, + {} + ); + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: copyResponseHeaders(upstream.headers), + }); +} + +export async function dispatchBrowserAuthRequest( + request: BrowserAuthDispatchRequest +): Promise { + const method = request.method.toUpperCase(); + if (!isBrowserAuthProxyRoute(method, request.pathname)) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + return dispatchAllowedBrowserAuthRequest({ ...request, method }); +} + +export async function proxyBrowserAuthRequest(request: Request): Promise { + const incomingUrl = new URL(request.url); + const method = request.method.toUpperCase(); + if (!isBrowserAuthProxyRoute(method, incomingUrl.pathname)) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + const body = + method === "GET" || method === "HEAD" ? undefined : new Uint8Array(await request.arrayBuffer()); + return dispatchAllowedBrowserAuthRequest({ + method, + pathname: incomingUrl.pathname, + search: incomingUrl.search, + headers: request.headers, + body, + clientIp: trustedClientIp(request), + }); +} diff --git a/packages/web/src/lib/browser-auth-session-contract.ts b/packages/web/src/lib/browser-auth-session-contract.ts new file mode 100644 index 000000000..dde039ed7 --- /dev/null +++ b/packages/web/src/lib/browser-auth-session-contract.ts @@ -0,0 +1,27 @@ +import { isCanonicalUserId } from "@open-inspect/shared"; +import { z } from "zod"; + +export const browserAuthSessionUserSchema = z.object({ + id: z.string().refine(isCanonicalUserId, "Browser session user id is not canonical"), + name: z.string().nullable().optional(), + email: z.string().nullable().optional(), + image: z.string().nullable().optional(), +}); + +export const browserAuthSessionResponseSchema = z + .object({ + user: browserAuthSessionUserSchema, + session: z + .object({ + id: z.string().min(1), + userId: z.string().min(1), + expiresAt: z.string().min(1), + }) + .passthrough(), + }) + .refine(({ session, user }) => session.userId === user.id, { + message: "Browser session user does not match its principal", + }); + +export type BrowserAuthSessionUser = z.infer; +export type BrowserAuthSessionResponse = z.infer; diff --git a/packages/web/src/lib/browser-session-cookie.test.ts b/packages/web/src/lib/browser-session-cookie.test.ts new file mode 100644 index 000000000..64a17000c --- /dev/null +++ b/packages/web/src/lib/browser-session-cookie.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { serializeBrowserSessionCookies } from "./browser-session-cookie"; + +describe("serializeBrowserSessionCookies", () => { + it("forwards only the opaque Better Auth session cookie", () => { + expect( + serializeBrowserSessionCookies([ + { name: "__Secure-openinspect.state", value: "state" }, + { name: "__Secure-openinspect.session_token", value: "session.signature" }, + { name: "analytics", value: "tracking" }, + ]) + ).toBe("__Secure-openinspect.session_token=session.signature"); + }); + + it("supports numeric chunks without accepting lookalike cookie names", () => { + expect( + serializeBrowserSessionCookies([ + { name: "__Secure-openinspect.session_token.0", value: "first" }, + { name: "__Secure-openinspect.session_token.attacker", value: "ignored" }, + { name: "__Secure-openinspect.session_token.1", value: "second" }, + ]) + ).toBe( + "__Secure-openinspect.session_token.0=first; __Secure-openinspect.session_token.1=second" + ); + }); + + it("supports Better Auth's host-only localhost cookie without widening the name", () => { + expect( + serializeBrowserSessionCookies([ + { name: "openinspect.session_token", value: "local-session.signature" }, + { name: "openinspect.state", value: "state" }, + { name: "openinspect.session_token.attacker", value: "ignored" }, + ]) + ).toBe("openinspect.session_token=local-session.signature"); + }); + + it("returns null when the session cookie is absent", () => { + expect(serializeBrowserSessionCookies([])).toBeNull(); + }); + + it("rejects duplicate names and invalid values", () => { + expect(() => + serializeBrowserSessionCookies([ + { name: "__Secure-openinspect.session_token", value: "first" }, + { name: "__Secure-openinspect.session_token", value: "second" }, + ]) + ).toThrow("Duplicate browser session cookie"); + + expect(() => + serializeBrowserSessionCookies([ + { name: "__Secure-openinspect.session_token", value: "valid; injected=value" }, + ]) + ).toThrow("Invalid browser session cookie value"); + }); +}); diff --git a/packages/web/src/lib/browser-session-cookie.ts b/packages/web/src/lib/browser-session-cookie.ts new file mode 100644 index 000000000..ed480c82c --- /dev/null +++ b/packages/web/src/lib/browser-session-cookie.ts @@ -0,0 +1,41 @@ +const BROWSER_SESSION_COOKIE_PATTERN = + /^(?:__Secure-openinspect|openinspect)\.session_token(?:\.[0-9]+)?$/; + +export interface BrowserCookie { + readonly name: string; + readonly value: string; +} + +function hasInvalidCookieValueCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (character === ";" || codePoint === undefined || codePoint <= 0x20 || codePoint === 0x7f) { + return true; + } + } + return false; +} + +/** + * Serialize only Better Auth's opaque browser-session cookie. + * + * OAuth transaction cookies and unrelated browser state must not become + * credentials on control-plane resource requests. + */ +export function serializeBrowserSessionCookies(cookies: readonly BrowserCookie[]): string | null { + const sessionCookies = cookies.filter(({ name }) => BROWSER_SESSION_COOKIE_PATTERN.test(name)); + if (sessionCookies.length === 0) return null; + + const names = new Set(); + for (const { name, value } of sessionCookies) { + if (names.has(name)) { + throw new Error(`Duplicate browser session cookie: ${name}`); + } + if (hasInvalidCookieValueCharacter(value)) { + throw new Error("Invalid browser session cookie value"); + } + names.add(name); + } + + return sessionCookies.map(({ name, value }) => `${name}=${value}`).join("; "); +} diff --git a/packages/web/src/lib/build-auth-identity.test.ts b/packages/web/src/lib/build-auth-identity.test.ts index 0bd72b845..794cdba55 100644 --- a/packages/web/src/lib/build-auth-identity.test.ts +++ b/packages/web/src/lib/build-auth-identity.test.ts @@ -1,122 +1,35 @@ import { describe, expect, it } from "vitest"; -import { - buildAuthDisplay, - buildAuthIdentity, - buildScmAttribution, - isAuthProvider, - resolveAuthProvider, - type AuthIdentityUser, -} from "./build-auth-identity"; - -const githubUser: AuthIdentityUser = { - id: "12345", - login: "ada", - name: "Ada Lovelace", - email: "ada@example.com", - image: "https://avatars.githubusercontent.com/u/12345", - provider: "github", -}; - -const googleUser: AuthIdentityUser = { - id: "google-sub-1", - name: "Pat PM", - email: "pm@gmail.com", - image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", -}; - -describe("resolveAuthProvider", () => { - it("returns the explicit provider", () => { - expect(resolveAuthProvider(githubUser)).toBe("github"); - expect(resolveAuthProvider(googleUser)).toBe("google"); - }); - - it("defaults a missing provider to github (legacy session back-compat)", () => { - expect(resolveAuthProvider({ id: "12345" })).toBe("github"); - expect(resolveAuthProvider(null)).toBe("github"); - expect(resolveAuthProvider(undefined)).toBe("github"); - }); -}); +import { buildAuthDisplay, isAuthProvider } from "./build-auth-identity"; describe("isAuthProvider", () => { - it("accepts supported providers", () => { + it("accepts only executable sign-in providers", () => { expect(isAuthProvider("github")).toBe(true); expect(isAuthProvider("google")).toBe(true); - }); - - it("rejects unknown or missing providers", () => { expect(isAuthProvider("gitlab")).toBe(false); - expect(isAuthProvider("")).toBe(false); expect(isAuthProvider(undefined)).toBe(false); - expect(isAuthProvider(null)).toBe(false); - }); -}); - -describe("buildAuthIdentity", () => { - it("maps a GitHub user to the auth* block", () => { - expect(buildAuthIdentity(githubUser)).toEqual({ - authProvider: "github", - authUserId: "12345", - }); - }); - - it("maps a Google user to the auth* block", () => { - expect(buildAuthIdentity(googleUser)).toEqual({ - authProvider: "google", - authUserId: "google-sub-1", - }); - }); - - it("defaults the provider", () => { - expect(buildAuthIdentity({ id: "12345", name: null, email: null, image: null })).toEqual({ - authProvider: "github", - authUserId: "12345", - }); }); }); describe("buildAuthDisplay", () => { - it("returns display fields only — never authProvider/authUserId (forbidden under strict)", () => { - expect(buildAuthDisplay(githubUser)).toEqual({ + it("returns cosmetic fields without identity or SCM assertions", () => { + expect( + buildAuthDisplay({ + name: "Ada Lovelace", + email: "ada@example.com", + image: "https://avatars.example/ada", + }) + ).toEqual({ authEmail: "ada@example.com", authName: "Ada Lovelace", - authAvatarUrl: "https://avatars.githubusercontent.com/u/12345", - }); - expect(buildAuthDisplay(googleUser)).toEqual({ - authEmail: "pm@gmail.com", - authName: "Pat PM", - authAvatarUrl: "https://lh3.googleusercontent.com/a/pat", + authAvatarUrl: "https://avatars.example/ada", }); }); it("normalizes null fields to undefined", () => { - expect(buildAuthDisplay({ id: "12345", name: null, email: null, image: null })).toEqual({ + expect(buildAuthDisplay({ name: null, email: null, image: null })).toEqual({ authEmail: undefined, authName: undefined, authAvatarUrl: undefined, }); }); }); - -describe("buildScmAttribution", () => { - it("returns the GitHub attribution block — never credentials (forbidden under strict)", () => { - expect(buildScmAttribution(githubUser)).toEqual({ - scmLogin: "ada", - scmName: "Ada Lovelace", - scmEmail: "ada@example.com", - scmAvatarUrl: "https://avatars.githubusercontent.com/u/12345", - }); - }); - - it("returns an empty object for Google — no scm* fields at all", () => { - // The provider gate (F1/F2): a Google session must never carry scm* - // attribution, which the control plane would store as GitHub identity. - expect(buildScmAttribution(googleUser)).toEqual({}); - }); - - it("treats a missing provider as GitHub (legacy session back-compat)", () => { - expect(buildScmAttribution({ id: "12345", login: "ada" })).toMatchObject({ - scmLogin: "ada", - }); - }); -}); diff --git a/packages/web/src/lib/build-auth-identity.ts b/packages/web/src/lib/build-auth-identity.ts index 9afb85668..31a67fdda 100644 --- a/packages/web/src/lib/build-auth-identity.ts +++ b/packages/web/src/lib/build-auth-identity.ts @@ -1,120 +1,32 @@ -/** - * Single chokepoint for the auth-provider discriminator on the web side. - * - * Under identity enforcement the control plane derives WHO the caller is - * (userId, authProvider/authUserId, SCM credentials) from the authenticated - * Bearer principal and rejects those fields in identity-route bodies. What the - * web still sends over the wire is display-only: - * - * - `auth*` display block (`buildAuthDisplay`) — email/name/avatar for BOTH - * GitHub and Google. - * - `scm*` attribution block (`buildScmAttribution`) — GitHub-only - * login/name/email/avatar for git-commit attribution; a Google session - * carries no `scm*` at all. - * - * `buildAuthIdentity` normalizes the provider/id pair used in the - * provider-identity resolution path. The pair is carried in the URL only; the - * control plane authorizes it against the Bearer principal. - * - * Keeping the `provider === "github"` decision in this one module is the whole - * point of the 4B split — otherwise the branch sprawls across every route and - * GitHub-only fields can leak into a Google request. The `sessions`, - * `ws-token`, and `automations` routes build their bodies from these helpers - * and never branch on provider themselves. - */ - export type AuthProvider = "github" | "google"; -/** - * Validated narrowing for the auth-provider discriminator. Returns true only for - * a provider this app explicitly supports, so an unrecognized value fails closed - * at the boundary instead of being cast onto the union. - */ export function isAuthProvider(value: string | null | undefined): value is AuthProvider { return value === "github" || value === "google"; } -export interface AuthIdentityUser { - id?: string | null; - login?: string | null; - name?: string | null; - email?: string | null; - image?: string | null; - provider?: AuthProvider; -} - -export interface AuthIdentity { - authProvider: AuthProvider; - authUserId?: string; +export interface AuthDisplayUser { + readonly name?: string | null; + readonly email?: string | null; + readonly image?: string | null; } export interface AuthDisplay { - authEmail?: string; - authName?: string; - authAvatarUrl?: string; -} - -export interface ScmAttribution { - scmLogin?: string; - scmName?: string; - scmEmail?: string; - scmAvatarUrl?: string; -} - -/** - * Resolve the authentication provider for a session user. Legacy GitHub - * sessions were minted before `provider` existed, so a missing provider is - * treated as GitHub — the same back-compat default the control plane applies - * (`authProvider ?? "github"`). - */ -export function resolveAuthProvider(user: AuthIdentityUser | null | undefined): AuthProvider { - return user?.provider ?? "github"; -} - -/** - * Provider-scoped identity reference used to resolve the canonical user via - * `/provider-identities/:provider/:id`. The values select the URL; no request - * body is sent, and the control plane requires an exactly matching user token. - */ -export function buildAuthIdentity(user: AuthIdentityUser | null | undefined): AuthIdentity { - return { - authProvider: resolveAuthProvider(user), - authUserId: user?.id ?? undefined, - }; + readonly authEmail?: string; + readonly authName?: string; + readonly authAvatarUrl?: string; } /** - * Display-only auth block for identity-route bodies. The control plane keeps - * these body-carried by design; the identifying fields (`authProvider`, - * `authUserId`) come from the Bearer principal and must not be sent. + * Cosmetic user attributes allowed in session and automation bodies. + * + * The authenticated principal and provider/SCM provenance are control-plane + * state; this helper deliberately cannot express those authority-bearing + * fields. */ -export function buildAuthDisplay(user: AuthIdentityUser | null | undefined): AuthDisplay { +export function buildAuthDisplay(user: AuthDisplayUser | null | undefined): AuthDisplay { return { authEmail: user?.email ?? undefined, authName: user?.name ?? undefined, authAvatarUrl: user?.image ?? undefined, }; } - -/** - * GitHub-only git-commit attribution (display fields, no credentials). Returns - * an empty object for non-GitHub providers (e.g. Google) so their request - * bodies carry no `scm*` fields at all — the provider gate the F1/F2 findings - * call for, enforced here at the single source rather than at each call site. - * - * SCM credentials (`scmUserId`/`scmToken`/`scmRefreshToken`/expiry) are never - * sent: the control plane derives them from the authenticated principal's - * token store and strict enforcement rejects them in the body. - */ -export function buildScmAttribution(user: AuthIdentityUser | null | undefined): ScmAttribution { - if (resolveAuthProvider(user) !== "github") { - return {}; - } - - return { - scmLogin: user?.login ?? undefined, - scmName: user?.name ?? undefined, - scmEmail: user?.email ?? undefined, - scmAvatarUrl: user?.image ?? undefined, - }; -} diff --git a/packages/web/src/lib/client-auth-boundary-eslint.test.ts b/packages/web/src/lib/client-auth-boundary-eslint.test.ts index c69efa745..47620ea62 100644 --- a/packages/web/src/lib/client-auth-boundary-eslint.test.ts +++ b/packages/web/src/lib/client-auth-boundary-eslint.test.ts @@ -50,10 +50,10 @@ describe("client authentication boundaries", () => { ).resolves.toHaveLength(0); }); - it("allows the auth seam to own the NextAuth client integration", async () => { + it("keeps the app-owned auth seam framework-independent", async () => { await expect( boundaryMessages('import { useSession } from "next-auth/react";', authSessionPath) - ).resolves.toHaveLength(0); + ).resolves.toHaveLength(1); }); it("allows the browser request seam to own fetch", async () => { diff --git a/packages/web/src/lib/control-plane-transport.test.ts b/packages/web/src/lib/control-plane-transport.test.ts deleted file mode 100644 index 7253b5cbf..000000000 --- a/packages/web/src/lib/control-plane-transport.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("next/headers", () => ({ - headers: vi.fn(), -})); - -import { headers } from "next/headers"; -import { controlPlaneTokenFetch } from "./control-plane-transport"; - -describe("controlPlaneTokenFetch", () => { - const originalEnv = { ...process.env }; - const fetchMock = vi.fn(); - - beforeEach(() => { - vi.resetAllMocks(); - process.env = { - ...originalEnv, - CONTROL_PLANE_URL: "https://control-plane.example", - SERVICE_AUTH_SECRET: "web-service-secret", - NODE_ENV: "development", - }; - vi.mocked(headers).mockResolvedValue(new Headers({})); - vi.stubGlobal("fetch", fetchMock); - fetchMock.mockResolvedValue(Response.json({ ok: true })); - }); - - afterEach(() => { - process.env = originalEnv; - vi.unstubAllGlobals(); - }); - - it("signs with web's sig1 credential and never the legacy bearer", async () => { - await controlPlaneTokenFetch("/auth/tokens/exchange", { - method: "POST", - body: JSON.stringify({ subjectTokenType: "github-access-token", subjectToken: "t" }), - }); - - const [url, init] = fetchMock.mock.calls[0] ?? []; - const sentHeaders = new Headers(init?.headers); - expect(url).toBe("https://control-plane.example/auth/tokens/exchange"); - expect(sentHeaders.get("X-OpenInspect-Service")).toBe("web"); - expect(sentHeaders.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); - expect(sentHeaders.get("Authorization")).toBeNull(); - }); - - it("throws when SERVICE_AUTH_SECRET is not configured", async () => { - delete process.env.SERVICE_AUTH_SECRET; - await expect( - controlPlaneTokenFetch("/auth/tokens/refresh", { method: "POST", body: "{}" }) - ).rejects.toThrow("SERVICE_AUTH_SECRET not configured"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("rejects service-authenticated requests outside token issuance", async () => { - await expect(controlPlaneTokenFetch("/sessions", { method: "GET" })).rejects.toThrow( - "Service authentication is restricted to token endpoints" - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/web/src/lib/control-plane-transport.ts b/packages/web/src/lib/control-plane-transport.ts index 26fa99ff0..8fc2eaf40 100644 --- a/packages/web/src/lib/control-plane-transport.ts +++ b/packages/web/src/lib/control-plane-transport.ts @@ -6,17 +6,14 @@ * same-account worker-to-worker fetch restrictions (error 1042). Falls back * to URL-based fetch for Vercel / local development. * - * This module sits below both `control-plane.ts` (user-credentialed - * requests) and `oi-session.ts` (token exchange/refresh), keeping the import - * graph a DAG. + * This module sits below both the browser-auth proxy and resource-request + * transport, keeping platform-specific dispatch outside their protocol logic. */ -import { buildServiceAuthHeaders } from "@open-inspect/shared"; import { createLogger } from "@/lib/logger"; -import { getCorrelationLogFields } from "@/lib/request-correlation"; -import { getRequestCorrelation } from "@/lib/request-context"; const log = createLogger("control-plane-transport"); +export const CONTROL_PLANE_FETCH_TIMEOUT_MS = 15_000; /** * Get the control plane base URL (no trailing slash) from environment. @@ -90,75 +87,21 @@ export async function dispatchControlPlaneFetch( fetchOptions: RequestInit, correlationFields: Record ): Promise { + const timeoutSignal = AbortSignal.timeout(CONTROL_PLANE_FETCH_TIMEOUT_MS); + const signal = fetchOptions.signal + ? AbortSignal.any([fetchOptions.signal, timeoutSignal]) + : timeoutSignal; + const boundedFetchOptions = { + ...fetchOptions, + signal, + }; + // On Cloudflare Workers, use the service binding to call the control plane const binding = await getServiceBinding(correlationFields); if (binding) { - return binding.fetch(url, fetchOptions); + return binding.fetch(url, boundedFetchOptions); } // Fallback: direct fetch (works on Vercel / local dev) - return fetch(url, fetchOptions); -} - -/** - * Make a control-plane request signed with web's own sig1 service - * credential — never a user token. - * - * Reserved for the token endpoints (exchange/refresh): issuance must be - * reachable only through web's per-service identity. Throws when - * SERVICE_AUTH_SECRET is not configured; callers treat that as an exchange - * failure. - */ -/** - * Token calls sit on the sign-in path and the background refresh check — an - * unresponsive control plane must fail fast into the callers' existing - * exchange_fallback/request_failed paths, not hang until the platform's own - * timeout. - */ -const SERVICE_FETCH_TIMEOUT_MS = 10_000; - -export async function controlPlaneTokenFetch( - path: string, - init: { method: string; body?: string } -): Promise { - if ( - init.method !== "POST" || - (path !== "/auth/tokens/exchange" && path !== "/auth/tokens/refresh") - ) { - throw new Error("Service authentication is restricted to token endpoints"); - } - const secret = process.env.SERVICE_AUTH_SECRET; - if (!secret) { - throw new Error("SERVICE_AUTH_SECRET not configured"); - } - - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const correlation = await getRequestCorrelation(); - const correlationFields = getCorrelationLogFields(correlation); - // The signature covers method, path, query, and body hash — not the host — - // so signing the URL-based form stays valid across the service binding. - const url = `${getControlPlaneUrl()}${normalizedPath}`; - - const headers = { - "Content-Type": "application/json", - ...(await buildServiceAuthHeaders({ - service: "web", - secret, - method: init.method, - url, - body: init.body, - traceId: correlation.traceId, - })), - }; - - return dispatchControlPlaneFetch( - url, - { - method: init.method, - headers, - body: init.body, - signal: AbortSignal.timeout(SERVICE_FETCH_TIMEOUT_MS), - }, - correlationFields - ); + return fetch(url, boundedFetchOptions); } diff --git a/packages/web/src/lib/control-plane.test.ts b/packages/web/src/lib/control-plane.test.ts index 0b99d2383..93d1eb7c8 100644 --- a/packages/web/src/lib/control-plane.test.ts +++ b/packages/web/src/lib/control-plane.test.ts @@ -1,3 +1,4 @@ +import { sha256Hex, verifyServiceSignature } from "@open-inspect/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("next/headers", () => ({ @@ -5,15 +6,10 @@ vi.mock("next/headers", () => ({ cookies: vi.fn(), })); -vi.mock("next-auth/jwt", () => ({ - getToken: vi.fn(), -})); - -import { headers, cookies } from "next/headers"; -import { getToken } from "next-auth/jwt"; +import { cookies, headers } from "next/headers"; import { controlPlaneUserFetch } from "./control-plane"; -describe("controlPlaneUserFetch correlation", () => { +describe("controlPlaneUserFetch", () => { const originalEnv = { ...process.env }; const fetchMock = vi.fn(); @@ -27,12 +23,19 @@ describe("controlPlaneUserFetch correlation", () => { }; vi.stubGlobal("fetch", fetchMock); fetchMock.mockResolvedValue(Response.json({ ok: true })); + vi.mocked(headers).mockResolvedValue( + new Headers({ + "x-trace-id": "trace-123", + "x-request-id": "client-hop-1", + "x-open-inspect-request-id": "webhop01", + }) + ); vi.mocked(cookies).mockResolvedValue({ - getAll: () => [{ name: "next-auth.session-token", value: "cookie-value" }], - } as never); - vi.mocked(getToken).mockResolvedValue({ - oiAccessToken: "oi_at_live_token", - oiAccessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + getAll: () => [ + { name: "__Secure-openinspect.session_token", value: "session.signature" }, + { name: "__Secure-openinspect.state", value: "oauth-state" }, + { name: "unrelated", value: "do-not-forward" }, + ], } as never); }); @@ -41,51 +44,72 @@ describe("controlPlaneUserFetch correlation", () => { vi.unstubAllGlobals(); }); - it("propagates the current request trace id downstream", async () => { - vi.mocked(headers).mockResolvedValue( - new Headers({ - "x-trace-id": "trace-123", - "x-request-id": "client-hop-1", - "x-open-inspect-request-id": "webhop01", - }) - ); - - await controlPlaneUserFetch("/sessions", { + it("combines the browser session with a fresh web signature", async () => { + const body = JSON.stringify({ ok: true }); + await controlPlaneUserFetch("/sessions?archived=false", { method: "POST", - headers: { Range: "bytes=0-5" }, - body: JSON.stringify({ ok: true }), + headers: { + Authorization: "Bearer caller-controlled", + Cookie: "caller=controlled", + Range: "bytes=0-5", + "X-OpenInspect-Service": "modal", + "X-OpenInspect-Service-Signature": "caller-controlled", + }, + body, }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); - - expect(url).toBe("https://control-plane.example/sessions"); - expect(forwardedHeaders.get("x-trace-id")).toBe("trace-123"); - expect(forwardedHeaders.get("x-request-id")).toBeNull(); - expect(forwardedHeaders.get("Range")).toBe("bytes=0-5"); - expect(forwardedHeaders.get("Authorization")).toBe("Bearer oi_at_live_token"); - expect(forwardedHeaders.get("X-OpenInspect-Service")).toBeNull(); + const sentHeaders = new Headers(init?.headers); + + expect(url).toBe("https://control-plane.example/sessions?archived=false"); + expect(sentHeaders.get("Cookie")).toBe("__Secure-openinspect.session_token=session.signature"); + expect(sentHeaders.get("Authorization")).toBeNull(); + expect(sentHeaders.get("Range")).toBe("bytes=0-5"); + expect(sentHeaders.get("x-trace-id")).toBe("trace-123"); + expect(sentHeaders.get("x-request-id")).toBeNull(); + expect(sentHeaders.get("X-OpenInspect-Service")).toBe("web"); + expect(sentHeaders.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + + const verification = await verifyServiceSignature({ + signatureHeader: sentHeaders.get("X-OpenInspect-Service-Signature") ?? "", + service: "web", + secret: "web-sig1-secret", + method: "POST", + url: String(url), + bodySha256Hex: await sha256Hex(body), + actor: "", + }); + expect(verification.ok).toBe(true); }); - it("merges tuple and Headers option headers without dropping values", async () => { - vi.mocked(headers).mockResolvedValue( - new Headers({ - "x-trace-id": "trace-123", - "x-open-inspect-request-id": "webhop01", - }) - ); - + it("merges Headers options without dropping caller values", async () => { await controlPlaneUserFetch("/sessions", { headers: new Headers({ Accept: "application/json" }), }); const [, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); + const sentHeaders = new Headers(init?.headers); - expect(forwardedHeaders.get("Accept")).toBe("application/json"); - expect(forwardedHeaders.get("Content-Type")).toBe("application/json"); - expect(forwardedHeaders.get("x-trace-id")).toBe("trace-123"); + expect(sentHeaders.get("Accept")).toBe("application/json"); + expect(sentHeaders.get("Content-Type")).toBe("application/json"); + expect(sentHeaders.get("x-trace-id")).toBe("trace-123"); + }); + + it("preserves caller cancellation while enforcing the transport timeout", async () => { + const caller = new AbortController(); + + await controlPlaneUserFetch("/sessions", { signal: caller.signal }); + + const [, init] = fetchMock.mock.calls[0] ?? []; + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.signal).not.toBe(caller.signal); + expect(init?.signal?.aborted).toBe(false); + + caller.abort("caller disconnected"); + + expect(init?.signal?.aborted).toBe(true); + expect(init?.signal?.reason).toBe("caller disconnected"); }); it("generates a fresh trace id when the inbound one is invalid", async () => { @@ -101,54 +125,13 @@ describe("controlPlaneUserFetch correlation", () => { const [, init] = fetchMock.mock.calls[0] ?? []; const traceId = new Headers(init?.headers).get("x-trace-id"); - expect(traceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + expect(traceId).toMatch(/^[0-9a-f-]{36}$/i); expect(traceId).not.toBe("not a valid trace id"); }); - it("attaches the web session token as the Bearer credential when live", async () => { - vi.mocked(headers).mockResolvedValue(new Headers({})); + it("returns 401 without dispatching when the browser session cookie is absent", async () => { vi.mocked(cookies).mockResolvedValue({ - getAll: () => [{ name: "next-auth.session-token", value: "cookie-value" }], - } as never); - vi.mocked(getToken).mockResolvedValue({ - oiAccessToken: "oi_at_live_token", - oiAccessTokenExpiresAt: Date.now() + 60 * 60 * 1000, - } as never); - - await controlPlaneUserFetch("/sessions"); - - const [, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); - expect(forwardedHeaders.get("Authorization")).toBe("Bearer oi_at_live_token"); - }); - - it("never lets a caller-supplied Authorization header override the credential", async () => { - vi.mocked(headers).mockResolvedValue(new Headers({})); - vi.mocked(cookies).mockResolvedValue({ - getAll: () => [{ name: "next-auth.session-token", value: "cookie-value" }], - } as never); - vi.mocked(getToken).mockResolvedValue({ - oiAccessToken: "oi_at_live_token", - oiAccessTokenExpiresAt: Date.now() + 60 * 60 * 1000, - } as never); - - await controlPlaneUserFetch("/sessions", { - headers: { Authorization: "Bearer caller-supplied" }, - }); - - const [, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); - expect(forwardedHeaders.get("Authorization")).toBe("Bearer oi_at_live_token"); - }); - - it("returns 401 without dispatching when the web session token is expired", async () => { - vi.mocked(headers).mockResolvedValue(new Headers({})); - vi.mocked(cookies).mockResolvedValue({ - getAll: () => [{ name: "next-auth.session-token", value: "cookie-value" }], - } as never); - vi.mocked(getToken).mockResolvedValue({ - oiAccessToken: "oi_at_expired", - oiAccessTokenExpiresAt: Date.now() - 1000, + getAll: () => [{ name: "__Secure-openinspect.state", value: "oauth-state" }], } as never); const response = await controlPlaneUserFetch("/sessions"); @@ -158,44 +141,47 @@ describe("controlPlaneUserFetch correlation", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("does not require the web service credential for user-facing calls", async () => { + it("fails closed when the web signing secret is unavailable", async () => { delete process.env.SERVICE_AUTH_SECRET; - vi.mocked(headers).mockResolvedValue(new Headers({})); - const response = await controlPlaneUserFetch("/sessions"); - - expect(response.status).toBe(200); - const [, init] = fetchMock.mock.calls[0] ?? []; - expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer oi_at_live_token"); - }); - - it("never falls back to web's sig1 service credential when no web session token is live", async () => { - process.env.SERVICE_AUTH_SECRET = "web-sig1-secret"; - vi.mocked(headers).mockResolvedValue(new Headers({})); - vi.mocked(cookies).mockResolvedValue({ getAll: () => [] } as never); - vi.mocked(getToken).mockResolvedValue(null); - - const body = JSON.stringify({ title: "t" }); - const response = await controlPlaneUserFetch("/sessions/abc/title", { method: "POST", body }); - - expect(response.status).toBe(401); + await expect(controlPlaneUserFetch("/sessions")).rejects.toThrow( + "SERVICE_AUTH_SECRET not configured" + ); expect(fetchMock).not.toHaveBeenCalled(); }); - it("forwards the exact bytes of a buffered binary body and keeps the caller Content-Type", async () => { - vi.mocked(headers).mockResolvedValue(new Headers({})); - - const body = new TextEncoder().encode("--boundary\r\nfake multipart\r\n--boundary--").buffer; + it("forwards the exact bytes of a buffered binary body", async () => { + const body = new TextEncoder().encode("--boundary\r\nbinary\u0000body\r\n--boundary--").buffer; await controlPlaneUserFetch("/sessions/abc/attachments", { method: "POST", body, headers: { "Content-Type": "multipart/form-data; boundary=boundary" }, }); - const [, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); - expect(forwardedHeaders.get("Content-Type")).toBe("multipart/form-data; boundary=boundary"); - expect(forwardedHeaders.get("Authorization")).toBe("Bearer oi_at_live_token"); + const [url, init] = fetchMock.mock.calls[0] ?? []; + const sentHeaders = new Headers(init?.headers); + expect(sentHeaders.get("Content-Type")).toBe("multipart/form-data; boundary=boundary"); expect(init?.body).toBe(body); + + const verification = await verifyServiceSignature({ + signatureHeader: sentHeaders.get("X-OpenInspect-Service-Signature") ?? "", + service: "web", + secret: "web-sig1-secret", + method: "POST", + url: String(url), + bodySha256Hex: await sha256Hex(body), + actor: "", + }); + expect(verification.ok).toBe(true); + }); + + it("rejects body types whose exact dispatched bytes cannot be signed", async () => { + await expect( + controlPlaneUserFetch("/sessions", { + method: "POST", + body: new URLSearchParams({ title: "hello" }), + }) + ).rejects.toThrow("Unsupported control-plane request body"); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/lib/control-plane.ts b/packages/web/src/lib/control-plane.ts index ed0f32990..46d7dcd13 100644 --- a/packages/web/src/lib/control-plane.ts +++ b/packages/web/src/lib/control-plane.ts @@ -5,54 +5,114 @@ * vs. URL-based fetch) to `control-plane-transport.ts`. */ +import { buildServiceAuthHeaders } from "@open-inspect/shared"; +import { cookies } from "next/headers"; +import { serializeBrowserSessionCookies } from "@/lib/browser-session-cookie"; import { dispatchControlPlaneFetch, getControlPlaneUrl } from "@/lib/control-plane-transport"; import { createLogger } from "@/lib/logger"; -import { getOiAccessTokenFromCookies } from "@/lib/oi-session"; import { getCorrelationLogFields } from "@/lib/request-correlation"; import { getRequestCorrelation } from "@/lib/request-context"; const log = createLogger("control-plane-client"); /** - * Create authenticated headers for a control plane request. + * Return the exact body representation accepted by sig1. * - * Returns the signed-in user's web session token (`Authorization: Bearer - * oi_at_…`), which resolves to a verified user principal at the control - * plane. User-facing calls never fall back to web's broad service credential: - * without a live token the caller receives a local 401 and must reauthenticate. + * Fetch can serialize several higher-level BodyInit variants. Reject those + * here because signing a value before Fetch serializes it would authenticate + * different bytes than the control plane receives. + */ +function getSignableBody( + body: BodyInit | null | undefined +): ArrayBuffer | Uint8Array | string | undefined { + if (body === undefined || body === null) return undefined; + if (typeof body === "string" || body instanceof ArrayBuffer || body instanceof Uint8Array) { + return body; + } + throw new Error("Unsupported control-plane request body"); +} + +/** + * Combine web's channel identity with the opaque browser-session credential. + * Both are required by browser-authenticated control-plane routes. */ async function getControlPlaneHeaders(request: { method: string; url: string; traceId: string; -}): Promise { - const oiAccessToken = await getOiAccessTokenFromCookies(); - if (oiAccessToken) { - return { - "Content-Type": "application/json", - Authorization: `Bearer ${oiAccessToken}`, - "x-trace-id": request.traceId, - }; + body: ArrayBuffer | Uint8Array | string | undefined; +}): Promise { + const cookieHeader = serializeBrowserSessionCookies((await cookies()).getAll()); + if (!cookieHeader) { + log.warn("auth.user_session_missing", { + event: "auth.user_session_missing", + http_path: new URL(request.url).pathname, + http_method: request.method, + trace_id: request.traceId, + }); + return null; } - log.warn("auth.user_session_missing", { - event: "auth.user_session_missing", - http_path: new URL(request.url).pathname, - http_method: request.method, - trace_id: request.traceId, + + const secret = process.env.SERVICE_AUTH_SECRET; + if (!secret) { + throw new Error("SERVICE_AUTH_SECRET not configured"); + } + + const headers = new Headers({ + "Content-Type": "application/json", + Cookie: cookieHeader, + }); + const serviceHeaders = await buildServiceAuthHeaders({ + service: "web", + secret, + method: request.method, + url: request.url, + body: request.body, + traceId: request.traceId, }); - return null; + for (const [name, value] of Object.entries(serviceHeaders)) { + headers.set(name, value); + } + return headers; +} + +function unauthorizedResponse(correlation: { requestId: string; traceId: string }): Response { + return Response.json( + { error: "Unauthorized" }, + { + status: 401, + headers: { + "x-request-id": correlation.requestId, + "x-trace-id": correlation.traceId, + }, + } + ); +} + +function mergeAuthenticatedHeaders( + callerHeaders: HeadersInit | undefined, + authenticatedHeaders: Headers +): Headers { + const headers = new Headers(callerHeaders); + headers.delete("Authorization"); + headers.delete("Cookie"); + headers.delete("X-OpenInspect-Actor"); + headers.delete("X-OpenInspect-Service"); + headers.delete("X-OpenInspect-Service-Signature"); + + const callerContentType = headers.get("Content-Type"); + authenticatedHeaders.forEach((value, name) => headers.set(name, value)); + if (callerContentType !== null) { + headers.set("Content-Type", callerContentType); + } + return headers; } /** - * Make a user-authenticated request to the control plane. - * - * The credential is applied after caller-supplied headers, so an - * `Authorization` header in `options` can never override the identity - * attached here. + * Make a browser-session-authenticated request to the control plane. * - * @param path - API path (e.g., "/sessions") - * @param options - Fetch options (method, body, etc.) - * @returns Fetch Response + * Every request carries both a fresh `service:web` signature and the opaque + * Better Auth session cookie. Caller-supplied identity headers are discarded. */ export async function controlPlaneUserFetch( path: string, @@ -64,44 +124,24 @@ export async function controlPlaneUserFetch( try { const url = `${getControlPlaneUrl()}${normalizedPath}`; - const credentialHeaderValues = await getControlPlaneHeaders({ - method: options.method ?? "GET", + const method = options.method ?? "GET"; + const body = getSignableBody(options.body); + const authenticatedHeaders = await getControlPlaneHeaders({ + method, url, traceId: correlation.traceId, + body, }); - if (!credentialHeaderValues) { - return Response.json( - { error: "Unauthorized" }, - { - status: 401, - headers: { - "x-request-id": correlation.requestId, - "x-trace-id": correlation.traceId, - }, - } - ); - } - const credentialHeaders = new Headers(credentialHeaderValues); - - // Caller headers first, credential headers on top: the credential wins - // over any caller-supplied Authorization or signature header. Content-Type - // is the one caller-overridable credential header — it defaults to JSON - // and is not signature-covered (e.g. buffered multipart uploads). - const mergedHeaders = new Headers(options.headers); - const callerContentType = mergedHeaders.get("Content-Type"); - credentialHeaders.forEach((value, key) => { - mergedHeaders.set(key, value); - }); - if (callerContentType !== null) { - mergedHeaders.set("Content-Type", callerContentType); - } - - const fetchOptions: RequestInit = { - ...options, - headers: mergedHeaders, - }; + if (!authenticatedHeaders) return unauthorizedResponse(correlation); - return await dispatchControlPlaneFetch(url, fetchOptions, correlationFields); + return await dispatchControlPlaneFetch( + url, + { + ...options, + headers: mergeAuthenticatedHeaders(options.headers, authenticatedHeaders), + }, + correlationFields + ); } catch (error) { log.error("control_plane.fetch_failed", { ...correlationFields, diff --git a/packages/web/src/lib/current-user.test.ts b/packages/web/src/lib/current-user.test.ts deleted file mode 100644 index 3a3245eb1..000000000 --- a/packages/web/src/lib/current-user.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/control-plane", () => ({ - controlPlaneUserFetch: vi.fn(), -})); - -import { controlPlaneUserFetch } from "@/lib/control-plane"; -import { clearCurrentUserIdCacheForTests, resolveCurrentUserId } from "./current-user"; - -describe("resolveCurrentUserId — provider-scoped cache", () => { - beforeEach(() => { - vi.resetAllMocks(); - clearCurrentUserIdCacheForTests(); - }); - - it("does not alias a GitHub id and a numerically identical Google sub", async () => { - const githubUserId = "0123456789abcdef0123456789abcdef"; - const googleUserId = "fedcba9876543210fedcba9876543210"; - vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: githubUserId })) - .mockResolvedValueOnce(Response.json({ userId: googleUserId })); - - // Same numeric id ("123") under two providers must resolve independently. - const gh = await resolveCurrentUserId({ id: "123", provider: "github", login: "ada" }); - const google = await resolveCurrentUserId({ - id: "123", - provider: "google", - email: "pm@gmail.com", - }); - - expect(gh).toEqual({ ok: true, userId: githubUserId }); - expect(google).toEqual({ ok: true, userId: googleUserId }); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(1, "/provider-identities/github/123", { - method: "PUT", - }); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(2, "/provider-identities/google/123", { - method: "PUT", - }); - - // A second GitHub resolution must come from the GitHub-scoped cache entry, - // not the Google one, and without a third control-plane call. - const githubAgain = await resolveCurrentUserId({ id: "123", provider: "github", login: "ada" }); - expect(githubAgain).toEqual({ ok: true, userId: githubUserId }); - expect(controlPlaneUserFetch).toHaveBeenCalledTimes(2); - }); - - it("rejects malformed current-user responses from the control plane", async () => { - vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( - Response.json({ userId: "not-canonical" }) - ); - - await expect(resolveCurrentUserId({ id: "123", provider: "github" })).resolves.toEqual({ - ok: false, - status: 502, - body: { error: "Invalid current user response" }, - }); - }); - - it("rejects partial current-user responses without a userId", async () => { - vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce(Response.json({})); - - await expect(resolveCurrentUserId({ id: "123", provider: "github" })).resolves.toEqual({ - ok: false, - status: 502, - body: { error: "Invalid current user response" }, - }); - }); -}); diff --git a/packages/web/src/lib/current-user.ts b/packages/web/src/lib/current-user.ts deleted file mode 100644 index f6e50e610..000000000 --- a/packages/web/src/lib/current-user.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { isCanonicalUserId } from "@open-inspect/shared"; -import { - buildAuthIdentity, - type AuthIdentity, - type AuthIdentityUser, -} from "@/lib/build-auth-identity"; -import { controlPlaneUserFetch } from "@/lib/control-plane"; - -export type CurrentUserIdentityInput = AuthIdentityUser; - -type ResolveCurrentUserResult = - | { - ok: true; - userId: string; - } - | { - ok: false; - status: number; - body: unknown; - }; - -const CURRENT_USER_ID_CACHE_TTL_MS = 5 * 60 * 1000; -const currentUserIdCache = new Map(); -const pendingCurrentUserIdResolutions = new Map>(); - -function getResponseUserId(data: unknown): unknown { - return data && typeof data === "object" ? (data as { userId?: unknown }).userId : undefined; -} - -export function clearCurrentUserIdCacheForTests() { - currentUserIdCache.clear(); - pendingCurrentUserIdResolutions.clear(); -} - -export async function resolveCurrentUserId( - user: CurrentUserIdentityInput | null | undefined -): Promise { - const identity = buildAuthIdentity(user); - const authUserId = identity.authUserId; - if (!authUserId) { - return { - ok: false, - status: 409, - body: { error: "User id unavailable" }, - }; - } - - // Resolution is provider-scoped (the route path carries the provider), so the - // cache must be too — the same id under two providers must never alias. - const cacheKey = `${identity.authProvider}:${authUserId}`; - const cached = currentUserIdCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return { - ok: true, - userId: cached.userId, - }; - } - - const pending = pendingCurrentUserIdResolutions.get(cacheKey); - if (pending) { - return pending; - } - - const resolution = resolveCurrentUserIdUncached(identity, authUserId, cacheKey).finally(() => { - pendingCurrentUserIdResolutions.delete(cacheKey); - }); - pendingCurrentUserIdResolutions.set(cacheKey, resolution); - return resolution; -} - -async function resolveCurrentUserIdUncached( - identity: AuthIdentity, - authUserId: string, - cacheKey: string -): Promise { - const response = await controlPlaneUserFetch( - `/provider-identities/${identity.authProvider}/${encodeURIComponent(authUserId)}`, - { method: "PUT" } - ); - - const data = await response.json(); - if (!response.ok) { - return { - ok: false, - status: response.status, - body: data, - }; - } - - const userId = getResponseUserId(data); - if (!isCanonicalUserId(userId)) { - return { - ok: false, - status: 502, - body: { error: "Invalid current user response" }, - }; - } - - currentUserIdCache.set(cacheKey, { - userId, - expiresAt: Date.now() + CURRENT_USER_ID_CACHE_TTL_MS, - }); - - return { - ok: true, - userId, - }; -} diff --git a/packages/web/src/lib/github-email-schema.test.ts b/packages/web/src/lib/github-email-schema.test.ts deleted file mode 100644 index f0e2079be..000000000 --- a/packages/web/src/lib/github-email-schema.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { githubEmailListSchema } from "./github-email-schema"; - -describe("githubEmailListSchema", () => { - it("parses valid GitHub email API responses", () => { - const result = githubEmailListSchema.safeParse([ - { email: "user@example.com", primary: true, verified: true, visibility: "private" }, - ]); - - expect(result.success).toBe(true); - }); - - it("accepts nullable visibility from GitHub", () => { - const result = githubEmailListSchema.safeParse([ - { email: "user@example.com", primary: true, verified: true, visibility: null }, - ]); - - expect(result.success).toBe(true); - }); - - it("rejects malformed or partial email responses", () => { - expect(githubEmailListSchema.safeParse({ email: "user@example.com" }).success).toBe(false); - expect( - githubEmailListSchema.safeParse([ - { email: "user@example.com", primary: true, verified: true }, - ]).success - ).toBe(false); - expect( - githubEmailListSchema.safeParse([ - { email: "user@example.com", primary: true, verified: "yes", visibility: null }, - ]).success - ).toBe(false); - }); -}); diff --git a/packages/web/src/lib/github-email-schema.ts b/packages/web/src/lib/github-email-schema.ts deleted file mode 100644 index 585406e27..000000000 --- a/packages/web/src/lib/github-email-schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from "zod"; - -export const githubEmailSchema = z.object({ - email: z.string(), - primary: z.boolean(), - verified: z.boolean(), - visibility: z.string().nullable(), -}); - -export const githubEmailListSchema = z.array(githubEmailSchema); - -export type GitHubEmail = z.infer; diff --git a/packages/web/src/lib/github-org-membership.test.ts b/packages/web/src/lib/github-org-membership.test.ts deleted file mode 100644 index d784684a9..000000000 --- a/packages/web/src/lib/github-org-membership.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { afterEach, describe, it, expect, vi } from "vitest"; -import { checkGitHubOrganizationAccess } from "./github-org-membership"; - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); -}); - -describe("checkGitHubOrganizationAccess", () => { - it("returns true when any configured organization membership is active", async () => { - vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ state: "pending" }))) - .mockResolvedValueOnce( - new Response(JSON.stringify({ state: "active" })) - ) as unknown as typeof fetch; - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["pending-org", "active-org"], - fetchImpl, - userAgent: "Test App", - }) - ).resolves.toEqual({ allowed: true, reason: "active_membership", organization: "active-org" }); - - expect(fetchImpl).toHaveBeenCalledWith( - "https://api.github.com/user/memberships/orgs/active-org", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "Test App", - }) as HeadersInit, - }) - ); - }); - - it("returns early after the first active membership", async () => { - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "active" }))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["active-org", "other-org"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: true, reason: "active_membership", organization: "active-org" }); - - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it("returns false for pending membership", async () => { - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "pending" }))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "not_member" }); - - expect(info).toHaveBeenCalledWith( - "[github-org-access] membership not active", - expect.objectContaining({ - org: "acme", - state: "pending", - elapsedMs: expect.any(Number), - }) - ); - }); - - it("returns not_member for denied GitHub responses", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn( - async () => - new Response("Not Found", { - status: 404, - headers: { - "x-github-request-id": "github-request-id", - "x-ratelimit-limit": "60", - "x-ratelimit-remaining": "59", - "x-ratelimit-reset": "1710000000", - }, - }) - ); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "not_member" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership request failed", - expect.objectContaining({ - org: "acme", - status: 404, - requestId: "github-request-id", - rateLimitLimit: "60", - rateLimitRemaining: "59", - rateLimitReset: "1710000000", - elapsedMs: expect.any(Number), - hint: expect.any(String), - }) - ); - }); - - it("returns unavailable for operational GitHub responses", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn( - async () => - new Response("rate limited", { - status: 429, - headers: { - "x-github-request-id": "github-request-id", - "x-ratelimit-remaining": "0", - "retry-after": "30", - }, - }) - ); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership request failed", - expect.objectContaining({ - org: "acme", - status: 429, - requestId: "github-request-id", - rateLimitRemaining: "0", - retryAfter: "30", - elapsedMs: expect.any(Number), - }) - ); - }); - - it("returns false without an access token or org allowlist", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - - await expect( - checkGitHubOrganizationAccess({ accessToken: undefined, allowedOrganizations: ["acme"] }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - await expect( - checkGitHubOrganizationAccess({ accessToken: "token", allowedOrganizations: [] }) - ).resolves.toEqual({ allowed: false, reason: "not_member" }); - - expect(warn).toHaveBeenCalledWith("[github-org-access] membership check skipped", { - reason: "missing_access_token", - organizationCount: 1, - }); - }); - - it("URL-encodes organization names", async () => { - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "active" }))); - - await checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme labs"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - - expect(fetchImpl).toHaveBeenCalledWith( - "https://api.github.com/user/memberships/orgs/acme%20labs", - expect.any(Object) - ); - }); - - it("flags a missing membership state as unusable", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: null }))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership response unusable state", - expect.objectContaining({ - org: "acme", - state: null, - elapsedMs: expect.any(Number), - }) - ); - }); - - it("flags a non-object membership response as unusable", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response(JSON.stringify("active"))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership response unusable state", - expect.objectContaining({ - org: "acme", - state: null, - elapsedMs: expect.any(Number), - }) - ); - }); - - it("flags an unexpected membership state as unusable", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "unknown" }))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership response unusable state", - expect.objectContaining({ - org: "acme", - state: "unknown", - elapsedMs: expect.any(Number), - }) - ); - }); - - it("returns unavailable for malformed membership responses", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response("not-json")); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership request error", - expect.objectContaining({ - org: "acme", - error: expect.any(String), - message: expect.any(String), - elapsedMs: expect.any(Number), - }) - ); - }); - - it("aborts timed out membership requests", async () => { - vi.useFakeTimers(); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn( - (_url, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => { - reject(new DOMException("Aborted", "AbortError")); - }); - }) - ) as unknown as typeof fetch; - - const result = checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl, - timeoutMs: 50, - }); - - await vi.advanceTimersByTimeAsync(50); - await expect(result).resolves.toEqual({ allowed: false, reason: "unavailable" }); - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership request error", - expect.objectContaining({ - org: "acme", - error: "AbortError", - message: "Aborted", - elapsedMs: expect.any(Number), - }) - ); - }); -}); diff --git a/packages/web/src/lib/github-org-membership.ts b/packages/web/src/lib/github-org-membership.ts deleted file mode 100644 index 147d6f395..000000000 --- a/packages/web/src/lib/github-org-membership.ts +++ /dev/null @@ -1,172 +0,0 @@ -export interface GitHubOrganizationAccessParams { - accessToken?: string; - allowedOrganizations: string[]; - fetchImpl?: typeof fetch; - userAgent?: string; - timeoutMs?: number; -} - -export const GITHUB_MEMBERSHIP_CHECK_TIMEOUT_MS = 10_000; - -export type GitHubOrganizationAccessResult = - | { - allowed: true; - reason: "active_membership"; - organization: string; - } - | { - allowed: false; - reason: "not_member" | "unavailable"; - }; - -function getMembershipState(data: unknown): unknown { - return data && typeof data === "object" ? (data as { state?: unknown }).state : undefined; -} - -/** - * Check whether a GitHub user access token belongs to at least one allowed - * organization. This is the sole, asynchronous source of truth for org-based - * access — the synchronous allowlist policy in access-control.ts deliberately - * does not evaluate org membership. - * - * Fails closed: any outcome other than a confirmed active membership denies. The - * result distinguishes a definitive non-membership (`not_member`) from an - * operational failure (`unavailable` — missing/blocked token, rate limit, GitHub - * outage, or an unreadable membership response) so the caller can log why a - * sign-in was rejected. - */ -export async function checkGitHubOrganizationAccess({ - accessToken, - allowedOrganizations, - fetchImpl = fetch, - userAgent = "Open-Inspect", - timeoutMs = GITHUB_MEMBERSHIP_CHECK_TIMEOUT_MS, -}: GitHubOrganizationAccessParams): Promise { - if (allowedOrganizations.length === 0) { - return { allowed: false, reason: "not_member" }; - } - - if (!accessToken) { - console.warn("[github-org-access] membership check skipped", { - reason: "missing_access_token", - organizationCount: allowedOrganizations.length, - }); - return { allowed: false, reason: "unavailable" }; - } - - let isUnavailable = false; - - for (const org of allowedOrganizations) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const startedAt = performance.now(); - - try { - const response = await fetchImpl( - `https://api.github.com/user/memberships/orgs/${encodeURIComponent(org)}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": userAgent, - }, - signal: controller.signal, - } - ); - - if (!response.ok) { - console.warn("[github-org-access] membership request failed", { - org, - status: response.status, - ...getGitHubResponseDiagnostics(response, startedAt), - hint: getGitHubMembershipFailureHint(response.status), - }); - if (isGitHubMembershipUnavailableStatus(response.status)) { - isUnavailable = true; - } - continue; - } - - const state = getMembershipState(await response.json()); - if (state === "active") { - return { allowed: true, reason: "active_membership", organization: org }; - } - - if (state === "pending") { - // Expected non-active state (invited, not yet joined): deny, but this is - // not an outage, so leave isUnavailable untouched (reads as not_member). - console.info("[github-org-access] membership not active", { - org, - state, - ...getGitHubResponseDiagnostics(response, startedAt), - }); - } else { - // Missing or unrecognized state — fail closed AND flag unavailable, since - // an unusable response can't prove non-membership. `state` in the payload - // still distinguishes a null state from an unexpected literal. - isUnavailable = true; - console.warn("[github-org-access] membership response unusable state", { - org, - state: state ?? null, - ...getGitHubResponseDiagnostics(response, startedAt), - }); - } - } catch (error) { - isUnavailable = true; - console.warn("[github-org-access] membership request error", { - org, - error: error instanceof Error ? error.name : "unknown", - message: error instanceof Error ? error.message : String(error), - elapsedMs: getElapsedMs(startedAt), - }); - } finally { - clearTimeout(timeout); - } - } - - return { allowed: false, reason: isUnavailable ? "unavailable" : "not_member" }; -} - -function getGitHubMembershipFailureHint(status: number): string | undefined { - if (status === 401) { - return "GitHub rejected the OAuth token while checking organization membership."; - } - - if (status === 403) { - return "Verify the GitHub OAuth token has read:org access and any organization SAML requirements are satisfied. If this deployment also uses a GitHub App, make sure membership read permission changes were republished and approved."; - } - - if (status === 429) { - return "GitHub rate limited the organization membership check."; - } - - if (status === 404) { - return "GitHub returns 404 when the user is not an organization member or the token cannot read that membership."; - } - - if (status >= 500) { - return "GitHub returned a server error while checking organization membership."; - } - - return undefined; -} - -function isGitHubMembershipUnavailableStatus(status: number): boolean { - return status !== 404; -} - -function getGitHubResponseDiagnostics(response: Response, startedAt: number) { - return { - requestId: response.headers.get("x-github-request-id"), - rateLimitLimit: response.headers.get("x-ratelimit-limit"), - rateLimitRemaining: response.headers.get("x-ratelimit-remaining"), - rateLimitReset: response.headers.get("x-ratelimit-reset"), - retryAfter: response.headers.get("retry-after"), - elapsedMs: getElapsedMs(startedAt), - }; -} - -function getElapsedMs(startedAt: number): number { - return Math.round(performance.now() - startedAt); -} diff --git a/packages/web/src/lib/oi-session.test.ts b/packages/web/src/lib/oi-session.test.ts deleted file mode 100644 index f8b1c2972..000000000 --- a/packages/web/src/lib/oi-session.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { JWT } from "next-auth/jwt"; -import type { Account } from "next-auth"; - -vi.mock("@/lib/control-plane-transport", () => ({ - controlPlaneTokenFetch: vi.fn(), -})); - -import { controlPlaneTokenFetch } from "@/lib/control-plane-transport"; -import { encode } from "next-auth/jwt"; -import { - applyOiSessionTokens, - getLiveOiAccessToken, - readOiAccessTokenFromCookiePairs, - renewWebSessionTokens, - OI_ACCESS_TOKEN_RENEW_WINDOW_MS, -} from "@/lib/oi-session"; - -const tokenFetch = vi.mocked(controlPlaneTokenFetch); - -const PAIR = { - accessToken: "oi_at_fresh", - accessTokenExpiresAtEpochMs: Date.now() + 8 * 60 * 60 * 1000, - refreshToken: "oi_rt_fresh", - refreshTokenExpiresAtEpochMs: Date.now() + 30 * 24 * 60 * 60 * 1000, -}; - -function githubAccount(overrides: Partial = {}): Account { - return { - provider: "github", - providerAccountId: "583231", - type: "oauth", - access_token: "gho_subject", - refresh_token: "ghr_refresh", - expires_at: Math.floor(Date.now() / 1000) + 3600, - ...overrides, - } as Account; -} - -function pairResponse(): Response { - return new Response(JSON.stringify(PAIR), { status: 200 }); -} - -function errorResponse(status: number, error: string): Response { - return new Response(JSON.stringify({ error }), { status }); -} - -beforeEach(() => { - tokenFetch.mockReset(); -}); - -describe("applyOiSessionTokens — sign-in exchange", () => { - it("exchanges a GitHub subject with SCM capture fields", async () => { - tokenFetch.mockResolvedValue(pairResponse()); - const token = await applyOiSessionTokens({} as JWT, githubAccount()); - - expect(tokenFetch).toHaveBeenCalledWith("/auth/tokens/exchange", { - method: "POST", - body: expect.any(String), - }); - const body = JSON.parse(tokenFetch.mock.calls[0][1].body!) as Record; - expect(body).toMatchObject({ - subjectTokenType: "github-access-token", - subjectToken: "gho_subject", - scmRefreshToken: "ghr_refresh", - }); - expect(typeof body.scmTokenExpiresAt).toBe("number"); - - expect(token.oiAccessToken).toBe("oi_at_fresh"); - expect(token.oiRefreshToken).toBe("oi_rt_fresh"); - expect(token.oiAccessTokenExpiresAt).toBe(PAIR.accessTokenExpiresAtEpochMs); - }); - - it("exchanges a Google subject without SCM fields", async () => { - tokenFetch.mockResolvedValue(pairResponse()); - await applyOiSessionTokens( - {} as JWT, - githubAccount({ provider: "google", refresh_token: "google-refresh" }) - ); - const body = JSON.parse(tokenFetch.mock.calls[0][1].body!) as Record; - expect(body.subjectTokenType).toBe("google-access-token"); - expect(body.scmRefreshToken).toBeUndefined(); - }); - - it("falls back with unset fields when the exchange fails", async () => { - tokenFetch.mockResolvedValue(errorResponse(401, "subject_rejected")); - const token = await applyOiSessionTokens( - { oiAccessToken: "oi_at_stale" } as JWT, - githubAccount() - ); - expect(token.oiAccessToken).toBeUndefined(); - expect(token.oiRefreshToken).toBeUndefined(); - }); - - it("falls back when the service credential is unavailable", async () => { - tokenFetch.mockRejectedValue(new Error("SERVICE_AUTH_SECRET not configured")); - const token = await applyOiSessionTokens({} as JWT, githubAccount()); - expect(token.oiAccessToken).toBeUndefined(); - }); - - it("clears stale fields for unrecognized providers", async () => { - const token = await applyOiSessionTokens( - { oiAccessToken: "oi_at_stale" } as JWT, - githubAccount({ provider: "gitlab" }) - ); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(token.oiAccessToken).toBeUndefined(); - }); -}); - -describe("applyOiSessionTokens — jwt callback never renews", () => { - it("leaves a near-expiry token untouched without an account (renewal is the refresh route's job)", async () => { - const token = { - oiAccessToken: "oi_at_old", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS - 60_000, - oiRefreshToken: "oi_rt_old", - } as JWT; - const result = await applyOiSessionTokens(token, null); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(result.oiAccessToken).toBe("oi_at_old"); - expect(result.oiRefreshToken).toBe("oi_rt_old"); - }); -}); - -describe("renewWebSessionTokens", () => { - function nearExpiryToken(): JWT { - return { - oiAccessToken: "oi_at_old", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS - 60_000, - oiRefreshToken: "oi_rt_old", - } as JWT; - } - - it("redeems the refresh grant when the access token nears expiry", async () => { - tokenFetch.mockResolvedValue(pairResponse()); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - - expect(tokenFetch).toHaveBeenCalledWith("/auth/tokens/refresh", { - method: "POST", - body: JSON.stringify({ refreshToken: "oi_rt_old" }), - }); - expect(result).toEqual({ status: "authenticated", changed: true }); - expect(token.oiAccessToken).toBe("oi_at_fresh"); - expect(token.oiRefreshToken).toBe("oi_rt_fresh"); - }); - - it("leaves fresh tokens alone", async () => { - const token = { - oiAccessToken: "oi_at_live", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS + 60_000, - oiRefreshToken: "oi_rt_live", - } as JWT; - const result = await renewWebSessionTokens(token); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(result).toEqual({ status: "authenticated", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_live"); - }); - - it("does nothing when the token carries no oi fields", async () => { - const result = await renewWebSessionTokens({} as JWT); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(result).toEqual({ status: "unauthenticated", changed: false }); - }); - - it("keeps the fields when a concurrent renewal won the race (refresh_superseded)", async () => { - tokenFetch.mockResolvedValue(errorResponse(401, "refresh_superseded")); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "authenticated", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_old"); - expect(token.oiRefreshToken).toBe("oi_rt_old"); - }); - - it("keeps the fields on refresh_superseded even when the access token has expired", async () => { - // The 2026-07-24 prod incident: a wake-from-idle race loser carries a - // long-expired access token — it must NOT wipe the identity the race - // winner just persisted. The dead-vs-superseded call is the CP's alone. - tokenFetch.mockResolvedValue(errorResponse(401, "refresh_superseded")); - const token = { - oiAccessToken: "oi_at_idle", - oiAccessTokenExpiresAt: Date.now() - 4 * 60 * 60 * 1000, - oiRefreshToken: "oi_rt_idle", - } as JWT; - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "authenticated", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_idle"); - expect(token.oiRefreshToken).toBe("oi_rt_idle"); - }); - - it("clears the fields on refresh reuse detection and reports the change for persistence", async () => { - tokenFetch.mockResolvedValue(errorResponse(401, "refresh_reuse_detected")); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "unauthenticated", changed: true }); - expect(token.oiAccessToken).toBeUndefined(); - expect(token.oiRefreshToken).toBeUndefined(); - }); - - it("clears the fields when the grant is genuinely dead (invalid_refresh_token)", async () => { - tokenFetch.mockResolvedValue(errorResponse(401, "invalid_refresh_token")); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "unauthenticated", changed: true }); - expect(token.oiAccessToken).toBeUndefined(); - expect(token.oiRefreshToken).toBeUndefined(); - }); - - it("keeps the fields on transient request failures", async () => { - tokenFetch.mockRejectedValue(new Error("network down")); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "authenticated", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_old"); - expect(token.oiRefreshToken).toBe("oi_rt_old"); - }); - - it("reports temporary unavailability when a transient failure outlasts the access token", async () => { - tokenFetch.mockRejectedValue(new Error("network down")); - const token = { - oiAccessToken: "oi_at_expired", - oiAccessTokenExpiresAt: Date.now() - 1, - oiRefreshToken: "oi_rt_retryable", - } as JWT; - - const result = await renewWebSessionTokens(token); - - expect(result).toEqual({ status: "temporarily_unavailable", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_expired"); - expect(token.oiRefreshToken).toBe("oi_rt_retryable"); - }); -}); - -describe("getLiveOiAccessToken", () => { - it("returns every unexpired token and rejects only missing or expired tokens", () => { - expect( - getLiveOiAccessToken({ - oiAccessToken: "oi_at_x", - oiAccessTokenExpiresAt: Date.now() + 10 * 60 * 1000, - } as JWT) - ).toBe("oi_at_x"); - expect( - getLiveOiAccessToken({ - oiAccessToken: "oi_at_x", - oiAccessTokenExpiresAt: Date.now() + 30_000, - } as JWT) - ).toBe("oi_at_x"); - expect( - getLiveOiAccessToken({ - oiAccessToken: "oi_at_x", - oiAccessTokenExpiresAt: Date.now() - 1, - } as JWT) - ).toBeNull(); - expect(getLiveOiAccessToken({} as JWT)).toBeNull(); - expect(getLiveOiAccessToken(null)).toBeNull(); - }); -}); - -describe("readOiAccessTokenFromCookiePairs", () => { - const SECURE_COOKIE = "__Secure-next-auth.session-token"; - const SECRET = "test-nextauth-secret-for-round-trip"; - - beforeEach(() => { - vi.stubEnv("NEXTAUTH_SECRET", SECRET); - // https URL → getToken looks for the __Secure- cookie name, as in prod. - vi.stubEnv("NEXTAUTH_URL", "https://open-inspect.example"); - }); - - async function encodedJwtWithPair(): Promise { - // Real next-auth encode — no mocking. This pins the exact seam that - // regressed: getToken reads req.cookies, never a headers.cookie string. - return encode({ - token: { - oiAccessToken: "oi_at_round_trip", - oiAccessTokenExpiresAt: Date.now() + 8 * 60 * 60 * 1000, - oiRefreshToken: "oi_rt_round_trip", - }, - secret: SECRET, - }); - } - - it("round-trips a live token through a real encoded session cookie", async () => { - const jwt = await encodedJwtWithPair(); - await expect(readOiAccessTokenFromCookiePairs({ [SECURE_COOKIE]: jwt })).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("reassembles chunked session cookies", async () => { - const jwt = await encodedJwtWithPair(); - const half = Math.ceil(jwt.length / 2); - await expect( - readOiAccessTokenFromCookiePairs({ - [`${SECURE_COOKIE}.0`]: jwt.slice(0, half), - [`${SECURE_COOKIE}.1`]: jwt.slice(half), - }) - ).resolves.toBe("oi_at_round_trip"); - }); - - it("returns null for unrelated cookies and undecodable tokens", async () => { - await expect(readOiAccessTokenFromCookiePairs({ other: "value" })).resolves.toBeNull(); - await expect( - readOiAccessTokenFromCookiePairs({ [SECURE_COOKIE]: "not-a-jwe" }) - ).resolves.toBeNull(); - }); -}); diff --git a/packages/web/src/lib/oi-session.ts b/packages/web/src/lib/oi-session.ts deleted file mode 100644 index 09d0eb2c5..000000000 --- a/packages/web/src/lib/oi-session.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Web session tokens (`oi_at_`/`oi_rt_`): the CP-issued user credential that - * replaces asserted body identity. - * - * Sign-in exchanges the user's provider token — while it is in scope in the - * NextAuth jwt callback — for a token pair minted only after the control - * plane verifies the subject with the provider. Renewal is a rotating - * refresh grant; provider evidence is never needed again. Exchange and - * refresh calls are signed with web's sig1 service credential. - */ - -import { getToken, type JWT } from "next-auth/jwt"; -import { z } from "zod"; -import type { Account } from "next-auth"; - -import { controlPlaneTokenFetch } from "@/lib/control-plane-transport"; -import { createLogger } from "@/lib/logger"; - -const log = createLogger("oi-session"); - -/** Renew when the access token expires within this window. */ -export const OI_ACCESS_TOKEN_RENEW_WINDOW_MS = 15 * 60 * 1000; - -const tokenPairSchema = z.object({ - accessToken: z.string().min(1), - accessTokenExpiresAtEpochMs: z.number().int().positive(), - refreshToken: z.string().min(1), - refreshTokenExpiresAtEpochMs: z.number().int().positive(), -}); - -type WebSessionTokenPair = z.infer; - -async function postTokenEndpoint( - path: "/auth/tokens/exchange" | "/auth/tokens/refresh", - body: Record -): Promise<{ ok: true; pair: WebSessionTokenPair } | { ok: false; status: number; error: string }> { - const response = await controlPlaneTokenFetch(path, { - method: "POST", - body: JSON.stringify(body), - }); - const data: unknown = await response.json().catch(() => null); - if (!response.ok) { - const error = - typeof data === "object" && data !== null && "error" in data - ? String((data as { error: unknown }).error) - : `http_${response.status}`; - return { ok: false, status: response.status, error }; - } - const parsed = tokenPairSchema.safeParse(data); - if (!parsed.success) { - return { ok: false, status: response.status, error: "invalid_token_pair_response" }; - } - return { ok: true, pair: parsed.data }; -} - -/** - * Exchange the provider credential captured at sign-in for a web session - * token pair. Returns null on any failure (`auth.exchange_fallback`) — the - * caller leaves the `oi*` JWT fields unset, so the session gate requires - * a new sign-in before any user-facing control-plane request is dispatched. - */ -async function exchangeForWebSessionTokens(params: { - provider: "github" | "google"; - subjectToken: string; - scmRefreshToken?: string; - scmTokenExpiresAt?: number; -}): Promise { - try { - const result = await postTokenEndpoint("/auth/tokens/exchange", { - subjectTokenType: - params.provider === "github" ? "github-access-token" : "google-access-token", - subjectToken: params.subjectToken, - ...(params.scmRefreshToken ? { scmRefreshToken: params.scmRefreshToken } : {}), - ...(params.scmTokenExpiresAt ? { scmTokenExpiresAt: params.scmTokenExpiresAt } : {}), - }); - if (!result.ok) { - log.warn("oi_session.exchange_fallback", { - event: "auth.exchange_fallback", - provider: params.provider, - http_status: result.status, - reason: result.error, - }); - return null; - } - return result.pair; - } catch (error) { - log.warn("oi_session.exchange_fallback", { - event: "auth.exchange_fallback", - provider: params.provider, - reason: "request_failed", - error: error instanceof Error ? error : new Error(String(error)), - }); - return null; - } -} - -type RefreshOutcome = - | { ok: true; pair: WebSessionTokenPair } - | { - ok: false; - reason: - | "invalid_refresh_token" - | "refresh_superseded" - | "refresh_reuse_detected" - | "request_failed"; - }; - -/** Redeem the rotating refresh grant for a new pair. */ -async function redeemWebSessionRefresh(refreshToken: string): Promise { - try { - const result = await postTokenEndpoint("/auth/tokens/refresh", { refreshToken }); - if (result.ok) { - return { ok: true, pair: result.pair }; - } - return { - ok: false, - reason: - result.error === "invalid_refresh_token" || - result.error === "refresh_superseded" || - result.error === "refresh_reuse_detected" - ? result.error - : "request_failed", - }; - } catch { - return { ok: false, reason: "request_failed" }; - } -} - -/** - * Read the current request's NextAuth JWT and return a live web session - * token, or null when the pair is absent/expired or there is no request - * context. Read-only — renewal happens in the persistable oi-refresh route, - * never here. - */ -export async function getOiAccessTokenFromCookies(): Promise { - let cookiePairs: Record; - try { - const { cookies } = await import("next/headers"); - const cookieStore = await cookies(); - cookiePairs = Object.fromEntries( - cookieStore.getAll().map((cookie) => [cookie.name, cookie.value]) - ); - } catch { - // Not in a request context (build, background work) — no user identity. - return null; - } - if (Object.keys(cookiePairs).length === 0) return null; - return readOiAccessTokenFromCookiePairs(cookiePairs); -} - -/** - * Decode the NextAuth JWT from parsed cookie pairs and return a live web - * session token. `getToken` reads `req.cookies` only — it never parses a - * `headers.cookie` string — so the request stub must carry the parsed pairs - * (this also lets next-auth reassemble chunked session cookies). - */ -export async function readOiAccessTokenFromCookiePairs( - cookiePairs: Record -): Promise { - try { - const token = await getToken({ - req: { headers: {}, cookies: cookiePairs } as Parameters[0]["req"], - }); - return getLiveOiAccessToken(token); - } catch (error) { - log.warn("oi_session.jwt_read_failed", { - error: error instanceof Error ? error : new Error(String(error)), - }); - return null; - } -} - -/** The JWT's access token while it remains unexpired, else null. */ -export function getLiveOiAccessToken(token: JWT | null): string | null { - if (!token?.oiAccessToken || !token.oiAccessTokenExpiresAt) return null; - if (token.oiAccessTokenExpiresAt <= Date.now()) return null; - return token.oiAccessToken; -} - -/** - * Set the `oi*` JWT fields at sign-in — the only moment provider evidence - * exists — by exchanging the provider credential for a token pair. Runs in - * the NextAuth jwt callback, whose cookie the sign-in flow persists. - * - * Renewal deliberately does NOT happen here: the jwt callback also runs - * under `getServerSession`, which cannot persist a rotated cookie — a - * renewal there would consume the rotating refresh grant and orphan the - * cookie's copy. Renewal lives in the `/api/auth/oi-refresh` route handler - * (`renewWebSessionTokens`), which the client invokes and which CAN persist. - */ -export async function applyOiSessionTokens( - token: JWT, - account: Account | null | undefined -): Promise { - if (!account) { - return token; - } - if ((account.provider === "github" || account.provider === "google") && account.access_token) { - const pair = await exchangeForWebSessionTokens({ - provider: account.provider, - subjectToken: account.access_token, - scmRefreshToken: account.provider === "github" ? account.refresh_token : undefined, - scmTokenExpiresAt: account.expires_at ? account.expires_at * 1000 : undefined, - }); - setOiFields(token, pair); - } else { - setOiFields(token, null); - } - return token; -} - -/** - * Renew the `oi*` fields on a decoded JWT via the rotating refresh grant, - * mutating the token in place. The result describes whether the complete web - * session remains usable, separately from whether the cookie changed. A change - * (rotated pair, or fields cleared because the grant is dead) MUST be persisted - * by the caller, so this is only called from contexts that can write the - * session cookie (the oi-refresh route handler). - */ -export async function renewWebSessionTokens(token: JWT): Promise<{ - status: "authenticated" | "unauthenticated" | "temporarily_unavailable"; - changed: boolean; -}> { - const { oiAccessToken, oiAccessTokenExpiresAt, oiRefreshToken } = token; - if (!oiAccessToken || !oiAccessTokenExpiresAt || !oiRefreshToken) { - return { status: "unauthenticated", changed: false }; - } - if (oiAccessTokenExpiresAt - Date.now() > OI_ACCESS_TOKEN_RENEW_WINDOW_MS) { - return { status: "authenticated", changed: false }; - } - - const outcome = await redeemWebSessionRefresh(oiRefreshToken); - if (outcome.ok) { - setOiFields(token, outcome.pair); - return { status: "authenticated", changed: true }; - } - log.warn("oi_session.refresh_failed", { - event: "auth.refresh_failed", - reason: outcome.reason, - }); - switch (outcome.reason) { - case "request_failed": - // A transient refresh failure is harmless while the current access token - // is still valid. Once it expires, report temporary unavailability so the - // client can retry without conflating an outage with terminal auth loss. - return { - status: oiAccessTokenExpiresAt > Date.now() ? "authenticated" : "temporarily_unavailable", - changed: false, - }; - case "refresh_superseded": - // A concurrent renewal won (CP grace window). In the common case the - // cookie jar holds the winner's fresh pair — never persist over it. - // The CP makes this call from row state; it must NOT be inferred here - // from access-token freshness (at wake-from-idle the access token is - // always expired, and clearing on a lost race wiped a live identity — - // the 2026-07-24 prod incident). - // - // KNOWN GAP: the jar holding the winner is not guaranteed. NextAuth's - // session route re-encodes the decoded JWT on every session read, so a - // stale in-flight response can restore the consumed token over the - // winner's cookie; the next renewal of that restored token outside the - // grace window then reads as reuse and revokes the family. Not fixable - // here: the winner's pair is unrecoverable (hash-at-rest), the doomed - // jar state is unobservable in this request, and clearing is strictly - // worse (wipes a live identity in the common case). The fix is moving - // the pair out of the NextAuth JWT into a single-writer store — - // session-auth roadmap Phase B. - return { status: "authenticated", changed: false }; - case "invalid_refresh_token": - // The grant is genuinely dead (unknown, revoked, or expired) — clear - // the fields, and persist the cleared state so later checks stop - // replaying a dead grant (re-login required). - setOiFields(token, null); - return { status: "unauthenticated", changed: true }; - case "refresh_reuse_detected": - // Rotation reuse is the token-theft signal — always clear the fields - // (re-login required). - setOiFields(token, null); - return { status: "unauthenticated", changed: true }; - default: { - const exhaustive: never = outcome.reason; - throw new Error(`Unhandled refresh outcome: ${String(exhaustive)}`); - } - } -} - -function setOiFields(token: JWT, pair: WebSessionTokenPair | null): void { - token.oiAccessToken = pair?.accessToken; - token.oiAccessTokenExpiresAt = pair?.accessTokenExpiresAtEpochMs; - token.oiRefreshToken = pair?.refreshToken; -} diff --git a/packages/web/src/lib/server-auth-boundary-eslint.test.ts b/packages/web/src/lib/server-auth-boundary-eslint.test.ts index defec386c..20212532f 100644 --- a/packages/web/src/lib/server-auth-boundary-eslint.test.ts +++ b/packages/web/src/lib/server-auth-boundary-eslint.test.ts @@ -38,9 +38,9 @@ describe("server authentication import boundary", () => { ).resolves.toHaveLength(0); }); - it("allows auth endpoints to own the framework integration", async () => { + it("rejects framework imports from auth proxy endpoints", async () => { await expect( restrictedImportMessages('import NextAuth from "next-auth";', authRoutePath) - ).resolves.toHaveLength(0); + ).resolves.toHaveLength(1); }); }); diff --git a/packages/web/src/lib/server-auth-session.test.ts b/packages/web/src/lib/server-auth-session.test.ts index 3da006dad..84402ecf8 100644 --- a/packages/web/src/lib/server-auth-session.test.ts +++ b/packages/web/src/lib/server-auth-session.test.ts @@ -1,29 +1,115 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -vi.mock("next-auth", () => ({ - getServerSession: vi.fn(), +const mocks = vi.hoisted(() => ({ + cookies: vi.fn(), + dispatchBrowserAuthRequest: vi.fn(), })); -vi.mock("./auth", () => ({ - authOptions: { providers: [] }, +vi.mock("next/headers", () => ({ + cookies: mocks.cookies, +})); + +vi.mock("./browser-auth-proxy", () => ({ + dispatchBrowserAuthRequest: mocks.dispatchBrowserAuthRequest, })); -import { getServerSession } from "next-auth"; -import { authOptions } from "./auth"; import { getServerAuthSession, type ServerAuthSession } from "./server-auth-session"; describe("getServerAuthSession", () => { beforeEach(() => { vi.resetAllMocks(); + mocks.cookies.mockResolvedValue({ + getAll: () => [ + { name: "__Secure-openinspect.session_token", value: "session.signature" }, + { name: "__Secure-openinspect.state", value: "oauth-state" }, + { name: "unrelated", value: "do-not-forward" }, + ], + }); + }); + + it("resolves the app session through the signed browser-auth proxy", async () => { + const session = { + user: { + id: "0123456789abcdef0123456789abcdef", + name: "Ada", + email: "ada@example.com", + image: "https://images.example/ada", + }, + session: { + id: "session-1", + userId: "0123456789abcdef0123456789abcdef", + expiresAt: "2099-01-01T00:00:00.000Z", + }, + }; + mocks.dispatchBrowserAuthRequest.mockResolvedValue(Response.json(session)); + + await expect(getServerAuthSession()).resolves.toEqual({ user: session.user }); + + expect(mocks.dispatchBrowserAuthRequest).toHaveBeenCalledWith({ + method: "GET", + pathname: "/api/auth/get-session", + headers: { + Cookie: "__Secure-openinspect.session_token=session.signature", + }, + }); + }); + + it("returns null without dispatching when the browser session cookie is absent", async () => { + mocks.cookies.mockResolvedValue({ + getAll: () => [{ name: "__Secure-openinspect.state", value: "oauth-state" }], + }); + + await expect(getServerAuthSession()).resolves.toBeNull(); + expect(mocks.dispatchBrowserAuthRequest).not.toHaveBeenCalled(); + }); + + it("returns null when Better Auth rejects the browser session", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue( + Response.json({ error: "Unauthorized" }, { status: 401 }) + ); + + await expect(getServerAuthSession()).resolves.toBeNull(); + }); + + it("returns null when Better Auth reports no current session", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue(Response.json(null)); + + await expect(getServerAuthSession()).resolves.toBeNull(); + }); + + it("throws when the auth service fails instead of treating failure as logout", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue( + Response.json({ error: "Unavailable" }, { status: 503 }) + ); + + await expect(getServerAuthSession()).rejects.toThrow( + "Browser authentication failed with status 503" + ); + }); + + it("rejects malformed successful session responses", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue( + Response.json({ user: { id: 42 }, session: { userId: "user-1" } }) + ); + + await expect(getServerAuthSession()).rejects.toThrow(); }); - it("delegates to the current NextAuth server session implementation", async () => { - const session = { user: { id: "user-1" } }; - vi.mocked(getServerSession).mockResolvedValue(session as never); + it("rejects a noncanonical browser user id", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue( + Response.json({ + user: { id: "better-auth-default-id" }, + session: { + id: "session-1", + userId: "better-auth-default-id", + expiresAt: "2099-01-01T00:00:00.000Z", + }, + }) + ); - await expect(getServerAuthSession()).resolves.toBe(session); - expect(getServerSession).toHaveBeenCalledOnce(); - expect(getServerSession).toHaveBeenCalledWith(authOptions); + await expect(getServerAuthSession()).rejects.toThrow( + "Browser session user id is not canonical" + ); }); it("exposes an app-owned session contract", () => { diff --git a/packages/web/src/lib/server-auth-session.ts b/packages/web/src/lib/server-auth-session.ts index c81c13dbb..27ee91358 100644 --- a/packages/web/src/lib/server-auth-session.ts +++ b/packages/web/src/lib/server-auth-session.ts @@ -1,8 +1,12 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "./auth"; -import type { AuthIdentityUser } from "./build-auth-identity"; +import { cookies } from "next/headers"; +import { dispatchBrowserAuthRequest } from "./browser-auth-proxy"; +import { + browserAuthSessionResponseSchema, + type BrowserAuthSessionUser, +} from "./browser-auth-session-contract"; +import { serializeBrowserSessionCookies } from "./browser-session-cookie"; -export type ServerAuthUser = AuthIdentityUser; +export type ServerAuthUser = BrowserAuthSessionUser; /** * App-owned session contract consumed by server-side BFF routes. @@ -11,16 +15,33 @@ export type ServerAuthUser = AuthIdentityUser; * route authorization does not depend on framework-owned session types. */ export interface ServerAuthSession { - user?: ServerAuthUser | null; + user: ServerAuthUser; } /** * Server-side authentication seam for BFF routes. * - * This deliberately delegates to the existing NextAuth implementation. A - * later terminal-auth change can replace this boundary without another - * repository-wide route migration. + * The web is a framework-free BFF for browser authentication. Only the opaque + * Better Auth session cookie crosses this boundary; OAuth transaction cookies + * and unrelated browser cookies are never forwarded by server-side callers. */ -export function getServerAuthSession(): Promise { - return getServerSession(authOptions); +export async function getServerAuthSession(): Promise { + const cookieStore = await cookies(); + const cookieHeader = serializeBrowserSessionCookies(cookieStore.getAll()); + if (!cookieHeader) return null; + const response = await dispatchBrowserAuthRequest({ + method: "GET", + pathname: "/api/auth/get-session", + headers: { Cookie: cookieHeader }, + }); + + if (response.status === 401) return null; + if (!response.ok) { + throw new Error(`Browser authentication failed with status ${response.status}`); + } + + const payload: unknown = await response.json(); + if (payload === null) return null; + const session = browserAuthSessionResponseSchema.parse(payload); + return { user: session.user }; } diff --git a/packages/web/src/lib/session-cookie.test.ts b/packages/web/src/lib/session-cookie.test.ts deleted file mode 100644 index 41edf75b7..000000000 --- a/packages/web/src/lib/session-cookie.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { encode } from "next-auth/jwt"; -import { readOiAccessTokenFromCookiePairs } from "@/lib/oi-session"; -import { sessionCookieName, writeSessionCookie } from "@/lib/session-cookie"; - -vi.mock("@/lib/control-plane-transport", () => ({ - controlPlaneTokenFetch: vi.fn(), -})); - -const SECRET = "test-nextauth-secret-for-round-trip"; -const SECURE_COOKIE = "__Secure-next-auth.session-token"; - -interface SetCall { - name: string; - value: string; - options: { maxAge: number; secure: boolean; httpOnly: boolean }; -} - -function fakeStore(initial: Record = {}) { - const sets: SetCall[] = []; - return { - sets, - getAll: () => Object.entries(initial).map(([name, value]) => ({ name, value })), - set: (name: string, value: string, options: SetCall["options"]) => { - sets.push({ name, value, options }); - }, - }; -} - -/** Apply the writer's set calls the way a browser would, then read back. */ -function toCookiePairs(initial: Record, sets: SetCall[]): Record { - const pairs = { ...initial }; - for (const { name, value, options } of sets) { - if (options.maxAge === 0) delete pairs[name]; - else pairs[name] = value; - } - return pairs; -} - -async function encodeSessionJwt(extraClaims: Record = {}): Promise { - return encode({ - token: { - oiAccessToken: "oi_at_round_trip", - oiAccessTokenExpiresAt: Date.now() + 8 * 60 * 60 * 1000, - oiRefreshToken: "oi_rt_round_trip", - ...extraClaims, - }, - secret: SECRET, - }); -} - -beforeEach(() => { - vi.stubEnv("NEXTAUTH_SECRET", SECRET); - vi.stubEnv("NEXTAUTH_URL", "https://open-inspect.example"); -}); - -describe("sessionCookieName", () => { - it("uses the __Secure- prefix exactly when NEXTAUTH_URL is https", () => { - expect(sessionCookieName()).toBe(SECURE_COOKIE); - vi.stubEnv("NEXTAUTH_URL", "http://localhost:3000"); - expect(sessionCookieName()).toBe("next-auth.session-token"); - }); - - it("falls back to the VERCEL secure-cookie default when NEXTAUTH_URL is unset", () => { - // Vercel preview deployments: https, VERCEL injected, no NEXTAUTH_URL. - vi.stubEnv("NEXTAUTH_URL", undefined); - vi.stubEnv("VERCEL", "1"); - expect(sessionCookieName()).toBe(SECURE_COOKIE); - vi.stubEnv("VERCEL", undefined); - expect(sessionCookieName()).toBe("next-auth.session-token"); - }); -}); - -describe("writeSessionCookie", () => { - it("writes a single secure cookie that next-auth's reader decodes", async () => { - const jwt = await encodeSessionJwt(); - const store = fakeStore(); - - writeSessionCookie(store, jwt); - - expect(store.sets).toHaveLength(1); - expect(store.sets[0]).toMatchObject({ - name: SECURE_COOKIE, - options: { httpOnly: true, secure: true }, - }); - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs({}, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("persists to the secure cookie next-auth reads on Vercel previews", async () => { - vi.stubEnv("NEXTAUTH_URL", undefined); - vi.stubEnv("VERCEL", "1"); - const jwt = await encodeSessionJwt(); - const store = fakeStore(); - - writeSessionCookie(store, jwt); - - expect(store.sets).toHaveLength(1); - expect(store.sets[0]).toMatchObject({ - name: SECURE_COOKIE, - options: { httpOnly: true, secure: true }, - }); - // The reader is next-auth's own getToken — this fails if writer and - // reader ever disagree on the preview cookie name again. - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs({}, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("chunks oversized values the way next-auth reassembles them", async () => { - const jwt = await encodeSessionJwt({ padding: "x".repeat(6000) }); - const store = fakeStore(); - - writeSessionCookie(store, jwt); - - expect(store.sets.length).toBeGreaterThan(1); - expect(store.sets.map((s) => s.name)).toEqual( - store.sets.map((_, i) => `${SECURE_COOKIE}.${i}`) - ); - for (const set of store.sets) { - expect(set.value.length).toBeLessThanOrEqual(4096 - 163); - } - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs({}, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("expires stale chunks when a new value fits in one cookie", async () => { - const jwt = await encodeSessionJwt(); - const stale = { - [`${SECURE_COOKIE}.0`]: "stale-first-half", - [`${SECURE_COOKIE}.1`]: "stale-second-half", - }; - const store = fakeStore(stale); - - writeSessionCookie(store, jwt); - - const expired = store.sets.filter((s) => s.options.maxAge === 0).map((s) => s.name); - expect(expired).toEqual([`${SECURE_COOKIE}.0`, `${SECURE_COOKIE}.1`]); - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs(stale, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("expires the stale base cookie when the new value chunks", async () => { - const jwt = await encodeSessionJwt({ padding: "x".repeat(6000) }); - const stale = { [SECURE_COOKIE]: "stale-unchunked" }; - const store = fakeStore(stale); - - writeSessionCookie(store, jwt); - - const expired = store.sets.filter((s) => s.options.maxAge === 0).map((s) => s.name); - expect(expired).toEqual([SECURE_COOKIE]); - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs(stale, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); -}); diff --git a/packages/web/src/lib/session-cookie.ts b/packages/web/src/lib/session-cookie.ts deleted file mode 100644 index 3a0fc5723..000000000 --- a/packages/web/src/lib/session-cookie.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Chunk-aware writer for the NextAuth v4 session cookie. - * - * next-auth v4 never exposes its cookie writer, but the oi-refresh route must - * persist a re-encoded JWT exactly the way next-auth's `SessionStore` reads it - * back: a single cookie under the session name when the value fits, or - * `name.0`, `name.1`, … chunks when it does not. The constants and split rule - * mirror `next-auth/core/lib/cookie.js` byte for byte — the reader joins every - * cookie whose name starts with the session name, so any stale complementary - * form (old chunks after an unchunked write, or the old base cookie after a - * chunked write) MUST be expired in the same response or the reader would - * concatenate stale and fresh values. - */ - -// Mirrors ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE in next-auth v4. -const COOKIE_CHUNK_SIZE = 4096 - 163; - -/** next-auth v4's default session maxAge (30 days). */ -export const SESSION_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; - -/** - * Whether next-auth uses `__Secure-`-prefixed cookies. Must resolve exactly - * like the paired reader — next-auth v4 `getToken`'s `secureCookie` default: - * an https NEXTAUTH_URL when the variable is set, else the presence of - * Vercel's injected VERCEL env. Vercel preview deployments serve https - * without NEXTAUTH_URL, so dropping the fallback would write a cookie - * next-auth never reads back. - */ -function secureCookiesEnabled(): boolean { - return process.env.NEXTAUTH_URL?.startsWith("https://") ?? Boolean(process.env.VERCEL); -} - -export function sessionCookieName(): string { - return `${secureCookiesEnabled() ? "__Secure-" : ""}next-auth.session-token`; -} - -/** The subset of Next's request cookie store the writer needs. */ -export interface WritableCookieStore { - getAll(): { name: string; value: string }[]; - set( - name: string, - value: string, - options: { - httpOnly: boolean; - sameSite: "lax"; - path: string; - secure: boolean; - maxAge: number; - } - ): unknown; -} - -/** - * Persist an encoded NextAuth session JWT to the response, splitting into - * chunks when it exceeds the single-cookie budget and expiring every stale - * session cookie the new write does not replace. - */ -export function writeSessionCookie(cookieStore: WritableCookieStore, encodedJwt: string): void { - const name = sessionCookieName(); - const options = { - httpOnly: true, - sameSite: "lax" as const, - path: "/", - secure: secureCookiesEnabled(), - maxAge: SESSION_COOKIE_MAX_AGE_SECONDS, - }; - - const chunks: { name: string; value: string }[] = []; - if (encodedJwt.length <= COOKIE_CHUNK_SIZE) { - chunks.push({ name, value: encodedJwt }); - } else { - for (let i = 0; i * COOKIE_CHUNK_SIZE < encodedJwt.length; i++) { - chunks.push({ - name: `${name}.${i}`, - value: encodedJwt.slice(i * COOKIE_CHUNK_SIZE, (i + 1) * COOKIE_CHUNK_SIZE), - }); - } - } - - const written = new Set(chunks.map((chunk) => chunk.name)); - for (const existing of cookieStore.getAll()) { - const isSessionCookie = existing.name === name || existing.name.startsWith(`${name}.`); - if (isSessionCookie && !written.has(existing.name)) { - cookieStore.set(existing.name, "", { ...options, maxAge: 0 }); - } - } - for (const chunk of chunks) { - cookieStore.set(chunk.name, chunk.value, options); - } -} diff --git a/packages/web/src/lib/site-config.ts b/packages/web/src/lib/site-config.ts index 9e6627541..247b4866b 100644 --- a/packages/web/src/lib/site-config.ts +++ b/packages/web/src/lib/site-config.ts @@ -19,9 +19,8 @@ export const APP_FAVICON_URL = APP_ICON_URL || DEFAULT_FAVICON_URL; /** * Whether to show the "Sign in with Google" button. Build-time flag mirroring - * the server-side conditional GoogleProvider (enabled only when both - * GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set). The provider set is static - * per deployment, so a build-time flag avoids an async getProviders() round-trip - * in the sign-in client component. + * whether the control plane has the Google provider configured. The provider + * set is static per deployment, so a build-time flag avoids an authentication + * metadata round-trip in the sign-in client component. */ export const GOOGLE_LOGIN_ENABLED = process.env.NEXT_PUBLIC_GOOGLE_ENABLED?.trim() === "true"; diff --git a/scripts/wrangler-secrets.sh b/scripts/wrangler-secrets.sh index d5254bead..0078fcda7 100755 --- a/scripts/wrangler-secrets.sh +++ b/scripts/wrangler-secrets.sh @@ -1,27 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -# Upload secrets to a Cloudflare Worker via wrangler. +# Upload current secrets and remove retired web-auth secrets via wrangler. # Required environment variables: -# WORKER_NAME - target worker name -# GITHUB_CLIENT_SECRET - GitHub OAuth client secret -# NEXTAUTH_SECRET - NextAuth.js signing secret -# SERVICE_AUTH_SECRET - web's per-service sig1 signing secret -# Optional environment variables: -# GOOGLE_CLIENT_SECRET - Google OAuth client secret (uploaded only when set; -# empty for GitHub-only deployments) +# WORKER_NAME - target worker name +# SERVICE_AUTH_SECRET - web's per-service sig1 signing secret echo "Uploading secrets to worker: ${WORKER_NAME}" -echo "${GITHUB_CLIENT_SECRET}" | npx wrangler secret put GITHUB_CLIENT_SECRET --name "${WORKER_NAME}" -echo "${NEXTAUTH_SECRET}" | npx wrangler secret put NEXTAUTH_SECRET --name "${WORKER_NAME}" echo "${SERVICE_AUTH_SECRET}" | npx wrangler secret put SERVICE_AUTH_SECRET --name "${WORKER_NAME}" -# Google login is opt-in: only upload the secret when configured. (Disabling -# Google after enabling leaves the old secret in place; delete it manually if needed.) -if [ -n "${GOOGLE_CLIENT_SECRET:-}" ]; then - echo "${GOOGLE_CLIENT_SECRET}" | npx wrangler secret put GOOGLE_CLIENT_SECRET --name "${WORKER_NAME}" -fi - +existing_secrets="$(npx wrangler secret list --name "${WORKER_NAME}" --format json)" +for retired_secret in GITHUB_CLIENT_SECRET GOOGLE_CLIENT_SECRET NEXTAUTH_SECRET; do + if [[ "${existing_secrets}" =~ \"name\"[[:space:]]*:[[:space:]]*\"${retired_secret}\" ]]; then + printf 'y\n' | npx wrangler secret delete "${retired_secret}" --name "${WORKER_NAME}" + fi +done echo "Secrets uploaded successfully" diff --git a/terraform/README.md b/terraform/README.md index 6365af769..f59fa9cd3 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -193,7 +193,6 @@ WEB_PLATFORM # Optional; defaults to vercel VERCEL_API_TOKEN VERCEL_TEAM_ID VERCEL_PROJECT_ID -NEXTAUTH_URL # Used by the Vercel web deploy workflow # Modal MODAL_TOKEN_ID @@ -221,7 +220,7 @@ VERCEL_SANDBOX_RUNTIME # Optional; defaults to node24 VERCEL_SNAPSHOT_EXPIRATION_MS # Optional; defaults to 0 VERCEL_SANDBOX_API_BASE_URL # Optional advanced Vercel Sandbox API base URL override -# GitHub OAuth App +# GitHub App OAuth credentials GH_OAUTH_CLIENT_ID GH_OAUTH_CLIENT_SECRET @@ -252,7 +251,7 @@ ANTHROPIC_API_KEY # Security Secrets TOKEN_ENCRYPTION_KEY REPO_SECRETS_ENCRYPTION_KEY -NEXTAUTH_SECRET +NEXTAUTH_SECRET # Browser-auth secret; legacy Actions secret name # Access control ALLOWED_USERS diff --git a/terraform/d1/migrations/0048_better_auth_core.sql b/terraform/d1/migrations/0048_better_auth_core.sql index 266c6dcf9..507029f57 100644 --- a/terraform/d1/migrations/0048_better_auth_core.sql +++ b/terraform/d1/migrations/0048_better_auth_core.sql @@ -1,7 +1,7 @@ -- Better Auth browser identity, account, and session authority. -- -- This schema is generated from the exact-pinned Better Auth 1.6.25 core --- configuration in packages/control-plane/src/auth/browser-auth.ts. It is +-- configuration in packages/control-plane/src/auth/user/better-auth.ts. It is -- additive and inert until the final browser-auth routes are activated. -- -- At activation, auth_users.id is projected unchanged into canonical users.id. diff --git a/terraform/d1/migrations/0049_backfill_better_auth_accounts.sql b/terraform/d1/migrations/0049_backfill_better_auth_accounts.sql new file mode 100644 index 000000000..791c2450c --- /dev/null +++ b/terraform/d1/migrations/0049_backfill_better_auth_accounts.sql @@ -0,0 +1,104 @@ +-- Seed Better Auth users from the canonical users that predate its activation, +-- then seed accounts from immutable sign-in identities. Email reserves an +-- existing canonical user against implicit linking, but account ownership +-- comes only from user_identities' exact provider issuer and subject. + +-- Better Auth's D1 transaction fallback is non-atomic. A failed first sign-in +-- can therefore leave the new auth user, account, and session behind when the +-- canonical users projection rejects a duplicate email. Remove that entire +-- partial identity graph before inserting the canonical row below. The foreign +-- keys cascade only from a Better Auth user whose normalized email is already +-- owned by a different canonical user; unrelated Better Auth identities are +-- untouched. +DELETE FROM auth_users +WHERE EXISTS ( + SELECT 1 + FROM users + WHERE users.id <> auth_users.id + AND lower(trim(users.email)) = lower(trim(auth_users.email)) + ); + +INSERT INTO auth_users ( + id, + name, + email, + emailVerified, + image, + createdAt, + updatedAt +) +SELECT + users.id, + coalesce(nullif(trim(users.display_name), ''), lower(trim(users.email))), + lower(trim(users.email)), + 0, + users.avatar_url, + strftime('%Y-%m-%dT%H:%M:%fZ', users.created_at / 1000.0, 'unixepoch'), + strftime('%Y-%m-%dT%H:%M:%fZ', users.updated_at / 1000.0, 'unixepoch') +FROM users +WHERE users.email IS NOT NULL + AND length(trim(users.email)) > 0 + AND NOT EXISTS ( + SELECT 1 + FROM auth_users + WHERE auth_users.id = users.id + AND lower(trim(auth_users.email)) = lower(trim(users.email)) + ); + +INSERT INTO auth_accounts ( + id, + accountId, + providerId, + userId, + accessToken, + refreshToken, + idToken, + accessTokenExpiresAt, + refreshTokenExpiresAt, + scope, + password, + createdAt, + updatedAt +) +SELECT + user_identities.id, + user_identities.provider_user_id, + user_identities.provider, + user_identities.user_id, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + strftime( + '%Y-%m-%dT%H:%M:%fZ', + user_identities.created_at / 1000.0, + 'unixepoch' + ), + strftime( + '%Y-%m-%dT%H:%M:%fZ', + user_identities.created_at / 1000.0, + 'unixepoch' + ) +FROM user_identities +JOIN auth_users + ON auth_users.id = user_identities.user_id +WHERE ( + ( + user_identities.provider = 'github' + AND user_identities.provider_issuer = 'https://github.com' + ) + OR ( + user_identities.provider = 'google' + AND user_identities.provider_issuer = 'https://accounts.google.com' + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM auth_accounts + WHERE auth_accounts.providerId = user_identities.provider + AND auth_accounts.accountId = user_identities.provider_user_id + AND auth_accounts.userId = user_identities.user_id + ); diff --git a/terraform/d1/migrations/0050_purge_retired_api_tokens.sql b/terraform/d1/migrations/0050_purge_retired_api_tokens.sql new file mode 100644 index 000000000..6a565cccb --- /dev/null +++ b/terraform/d1/migrations/0050_purge_retired_api_tokens.sql @@ -0,0 +1,4 @@ +-- Browser token exchange and refresh-token rotation are retired by the Better +-- Auth cutover. Remove the historical hashed credentials once; the empty table +-- remains for additive migration compatibility and receives no new rows. +DELETE FROM api_tokens; diff --git a/terraform/environments/production/.terraform.lock.hcl b/terraform/environments/production/.terraform.lock.hcl index 6646d9da9..724ecc2f5 100644 --- a/terraform/environments/production/.terraform.lock.hcl +++ b/terraform/environments/production/.terraform.lock.hcl @@ -82,6 +82,27 @@ provider "registry.terraform.io/hashicorp/null" { ] } +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} + provider "registry.terraform.io/vercel/vercel" { version = "2.15.1" constraints = ">= 2.0.0, ~> 2.0" diff --git a/terraform/environments/production/checks.tf b/terraform/environments/production/checks.tf index 9a6ed6ed8..86cf33644 100644 --- a/terraform/environments/production/checks.tf +++ b/terraform/environments/production/checks.tf @@ -1,6 +1,6 @@ # Verify the Vercel production URL matches our hardcoded pattern. If Vercel -# assigns a different domain (e.g., due to naming conflicts), NEXTAUTH_URL and -# cross-service references will silently break. +# assigns a different domain (e.g., due to naming conflicts), browser-auth +# redirects and cross-service references will silently break. check "vercel_url_matches" { assert { condition = ( diff --git a/terraform/environments/production/locals.tf b/terraform/environments/production/locals.tf index 53a7ea2d0..44faa5fe6 100644 --- a/terraform/environments/production/locals.tf +++ b/terraform/environments/production/locals.tf @@ -8,7 +8,7 @@ locals { # Google login is enabled only when both OAuth credentials are configured. # Drives the build-time NEXT_PUBLIC_GOOGLE_ENABLED flag (sign-in button) and - # mirrors the server-side conditional GoogleProvider in packages/web/src/lib/auth.ts. + # mirrors the control plane's conditional Better Auth Google provider. google_enabled = trimspace(var.google_client_id) != "" && trimspace(var.google_client_secret) != "" # URLs for cross-service configuration diff --git a/terraform/environments/production/terraform.tfvars.example b/terraform/environments/production/terraform.tfvars.example index 758eab4e8..895920ed9 100644 --- a/terraform/environments/production/terraform.tfvars.example +++ b/terraform/environments/production/terraform.tfvars.example @@ -235,7 +235,7 @@ repo_secrets_encryption_key = "" # Generate with: openssl rand -hex 32 modal_api_secret = "" -# NextAuth.js secret +# Browser authentication secret (the Terraform input retains its legacy name) nextauth_secret = "" # ============================================================================= diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index 25b23ed33..63698f42a 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -486,9 +486,14 @@ variable "e2b_auto_pause" { } variable "nextauth_secret" { - description = "NextAuth.js secret (generate with: openssl rand -base64 32)" + description = "Browser authentication secret used by the control plane (legacy Terraform input name; generate with: openssl rand -base64 32)" type = string sensitive = true + + validation { + condition = length(regexall("\\S", var.nextauth_secret)) >= 32 + error_message = "nextauth_secret must contain at least 32 non-whitespace characters." + } } # ============================================================================= diff --git a/terraform/environments/production/web-cloudflare.tf b/terraform/environments/production/web-cloudflare.tf index d85393f80..9cbff7443 100644 --- a/terraform/environments/production/web-cloudflare.tf +++ b/terraform/environments/production/web-cloudflare.tf @@ -33,9 +33,6 @@ resource "null_resource" "web_app_cloudflare_secrets" { triggers = { secrets_hash = sha256(join(",", [ - var.github_client_secret, - var.google_client_secret, - var.nextauth_secret, random_password.service_auth_secret_web.result, ])) } @@ -48,9 +45,6 @@ resource "null_resource" "web_app_cloudflare_secrets" { CLOUDFLARE_API_TOKEN = var.cloudflare_api_token CLOUDFLARE_ACCOUNT_ID = var.cloudflare_account_id WORKER_NAME = local.web_worker_name - GITHUB_CLIENT_SECRET = var.github_client_secret - GOOGLE_CLIENT_SECRET = var.google_client_secret - NEXTAUTH_SECRET = var.nextauth_secret SERVICE_AUTH_SECRET = random_password.service_auth_secret_web.result } } @@ -69,14 +63,10 @@ resource "local_file" "web_app_wrangler_production" { compatibility_date = "2025-08-15" compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"] - # The workers.dev route is disabled when a custom domain is attached, so the - # app is only reachable on the origin NEXTAUTH_URL points at. + # A custom-domain deployment has one canonical browser origin. workers_dev = ${local.web_custom_domain_enabled ? "false" : "true"} [vars] - GITHUB_CLIENT_ID = "${var.github_client_id}" - GOOGLE_CLIENT_ID = "${var.google_client_id}" - NEXTAUTH_URL = "${local.web_app_url}" CONTROL_PLANE_URL = "${local.control_plane_url}" NEXT_PUBLIC_WS_URL = "${local.ws_url}" NEXT_PUBLIC_SANDBOX_PROVIDER = "${var.sandbox_provider}" @@ -84,11 +74,6 @@ resource "local_file" "web_app_wrangler_production" { NEXT_PUBLIC_APP_SHORT_NAME = "${var.app_short_name}" NEXT_PUBLIC_APP_ICON_URL = "${var.app_icon_url}" NEXT_PUBLIC_GOOGLE_ENABLED = "${tostring(local.google_enabled)}" - ALLOWED_USERS = "${var.allowed_users}" - ALLOWED_EMAIL_DOMAINS = "${var.allowed_email_domains}" - ALLOWED_EMAILS = "${var.allowed_emails}" - ALLOWED_GITHUB_ORGS = "${var.allowed_github_orgs}" - UNSAFE_ALLOW_ALL_USERS = "${tostring(var.unsafe_allow_all_users)}" [assets] directory = ".open-next/assets" diff --git a/terraform/environments/production/web-vercel.tf b/terraform/environments/production/web-vercel.tf index 2d6f1f6b2..732e7cde7 100644 --- a/terraform/environments/production/web-vercel.tf +++ b/terraform/environments/production/web-vercel.tf @@ -16,32 +16,6 @@ module "web_app" { build_command = "next build" environment_variables = [ - # GitHub OAuth - { - key = "GITHUB_CLIENT_ID" - value = var.github_client_id - targets = ["production", "preview"] - sensitive = false - }, - { - key = "GITHUB_CLIENT_SECRET" - value = var.github_client_secret - targets = ["production", "preview"] - sensitive = true - }, - # NextAuth - { - key = "NEXTAUTH_URL" - value = local.web_app_url - targets = ["production"] - sensitive = false - }, - { - key = "NEXTAUTH_SECRET" - value = var.nextauth_secret - targets = ["production", "preview"] - sensitive = true - }, # Control Plane { key = "CONTROL_PLANE_URL" @@ -86,50 +60,10 @@ module "web_app" { targets = ["production", "preview"] sensitive = true }, - # Access Control - { - key = "ALLOWED_USERS" - value = var.allowed_users - targets = ["production", "preview"] - sensitive = false - }, - { - key = "ALLOWED_EMAIL_DOMAINS" - value = var.allowed_email_domains - targets = ["production", "preview"] - sensitive = false - }, - { - key = "ALLOWED_GITHUB_ORGS" - value = var.allowed_github_orgs - targets = ["production", "preview"] - sensitive = false - }, - { - key = "UNSAFE_ALLOW_ALL_USERS" - value = tostring(var.unsafe_allow_all_users) - targets = ["production", "preview"] - sensitive = false - }, - # New env vars MUST be appended here. The module's env-var resource is - # count-indexed by list position (modules/vercel-project/main.tf uses count, - # because Vercel values are sensitive and can't be for_each keys), so - # inserting mid-list renumbers every downstream var and forces Vercel to - # destroy/recreate them — which races into ENV_CONFLICT. Appending keeps - # existing indices stable. - # Google OAuth (optional; both empty for GitHub-only deployments) - { - key = "GOOGLE_CLIENT_ID" - value = var.google_client_id - targets = ["production", "preview"] - sensitive = false - }, - { - key = "GOOGLE_CLIENT_SECRET" - value = var.google_client_secret - targets = ["production", "preview"] - sensitive = true - }, + # This cutover intentionally removes the legacy web-owned auth variables, + # so the first apply replaces the module's count-indexed env resources. + # After that one-time transition, append new variables to keep indices + # stable and avoid Vercel ENV_CONFLICT replacement races. # Build-time flag that reveals the "Sign in with Google" button. Inlined into # the client bundle, so it must be present at build time (not just runtime). { @@ -138,11 +72,5 @@ module "web_app" { targets = ["production", "preview"] sensitive = false }, - { - key = "ALLOWED_EMAILS" - value = var.allowed_emails - targets = ["production", "preview"] - sensitive = false - }, ] } diff --git a/terraform/environments/production/workers-control-plane.tf b/terraform/environments/production/workers-control-plane.tf index 5c6c790a7..c986b1c57 100644 --- a/terraform/environments/production/workers-control-plane.tf +++ b/terraform/environments/production/workers-control-plane.tf @@ -65,7 +65,13 @@ module "control_plane_worker" { plain_text_bindings = concat( [ { name = "GITHUB_CLIENT_ID", value = var.github_client_id }, + { name = "GOOGLE_CLIENT_ID", value = var.google_client_id }, { name = "WEB_APP_URL", value = local.web_app_url }, + { name = "ALLOWED_USERS", value = var.allowed_users }, + { name = "ALLOWED_EMAIL_DOMAINS", value = var.allowed_email_domains }, + { name = "ALLOWED_EMAILS", value = var.allowed_emails }, + { name = "ALLOWED_GITHUB_ORGS", value = var.allowed_github_orgs }, + { name = "UNSAFE_ALLOW_ALL_USERS", value = tostring(var.unsafe_allow_all_users) }, { name = "WORKER_URL", value = local.control_plane_url }, { name = "DEPLOYMENT_NAME", value = var.deployment_name }, { name = "APP_NAME", value = var.app_name }, @@ -119,6 +125,10 @@ module "control_plane_worker" { secrets = concat( [ + # The existing operator-managed auth secret now signs Better Auth state + # and cookies in the control plane. Keeping the Terraform input stable + # avoids coupling secret rotation to the browser-auth cutover. + { name = "BROWSER_AUTH_SECRET", value = var.nextauth_secret }, { name = "GITHUB_CLIENT_SECRET", value = var.github_client_secret }, { name = "TOKEN_ENCRYPTION_KEY", value = var.token_encryption_key }, { name = "REPO_SECRETS_ENCRYPTION_KEY", value = var.repo_secrets_encryption_key }, @@ -135,6 +145,9 @@ module "control_plane_worker" { { name = "GITHUB_APP_PRIVATE_KEY", value = var.github_app_private_key }, { name = "GITHUB_APP_INSTALLATION_ID", value = var.github_app_installation_id }, ], + local.google_enabled ? [ + { name = "GOOGLE_CLIENT_SECRET", value = var.google_client_secret }, + ] : [], local.use_modal_backend ? [ { name = "MODAL_TOKEN_ID", value = var.modal_token_id }, { name = "MODAL_TOKEN_SECRET", value = var.modal_token_secret }, From c3664fc9fc8f68adf48c3e1e79d319762fe2fe8e Mon Sep 17 00:00:00 2001 From: "open-inspect[bot]" <255062780+open-inspect[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:03:32 -0700 Subject: [PATCH 5/6] fix(types): validate Linear boundary responses (#1020) This is an automated nightly unsafe-cast remediation sweep. It replaces selected high-risk Linear bot boundary assertions with parse-don't-assert validation, following the TypeScript Coding Standards for unsafe casts and the Zod boundary-validation pattern established in PR #807. | Finding | Risk | Cast removed | Fix | | --- | --- | --- | --- | | `packages/linear-bot/src/webhook-handler.ts:177` | HIGH | Control-plane create-session response cast to `{ sessionId: string }`, bypassing the existing shared schema | Reused `createSessionResponseSchema.safeParse`; malformed success responses now follow the existing create-session failure path | | `packages/linear-bot/src/webhook-handler.ts:380` | HIGH | Control-plane session events response cast to `Array<{ type; data }>` feeding follow-up prompt context | Added a local Zod response schema for the consumed event fields and skip prior-context enrichment when parsing fails | | `packages/linear-bot/src/utils/integration-config.ts:58` | HIGH | Control-plane resolved Linear config response cast to `ResolvedLinearConfig` | Added a package-local Zod schema and made `z.infer` the config type source of truth; malformed responses fall back to defaults | Verification: | Command | Result | | --- | --- | | `npm run build -w @open-inspect/shared` | Passed | | `npm run build -w @open-inspect/linear-bot` | Passed | | `npm run typecheck` | Passed | | `npm test -w @open-inspect/linear-bot` | Passed, 13 files / 189 tests | | `npm run lint -w @open-inspect/linear-bot` | Passed | | `npm run lint -- --ignore-pattern '.opencode/**'` | Passed for tracked repo code; literal `npm run lint` in this workspace is blocked by a pre-existing untracked `.opencode/` directory that is not part of this PR | | `npm run format` | Passed | --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/feca6455e6cb748e1ada460d645a4c87)* --------- Co-authored-by: OpenInspect Co-authored-by: Cole Murray --- .../src/utils/integration-config.test.ts | 88 +++++++++++ .../src/utils/integration-config.ts | 33 +++-- .../linear-bot/src/webhook-handler.test.ts | 139 +++++++++++++++++- packages/linear-bot/src/webhook-handler.ts | 37 +++-- 4 files changed, 269 insertions(+), 28 deletions(-) diff --git a/packages/linear-bot/src/utils/integration-config.test.ts b/packages/linear-bot/src/utils/integration-config.test.ts index b992abe2a..f0deb3f79 100644 --- a/packages/linear-bot/src/utils/integration-config.test.ts +++ b/packages/linear-bot/src/utils/integration-config.test.ts @@ -3,6 +3,23 @@ import type { Env } from "../types"; import { getLinearConfig } from "./integration-config"; describe("getLinearConfig", () => { + function envForFetchResponse(response: Response): Env { + const fetch = vi.fn().mockResolvedValue(response); + return { + SERVICE_AUTH_SECRET: "test-secret", + CONTROL_PLANE: { fetch }, + } as unknown as Env; + } + + function envForResponse(body: unknown): Env { + return envForFetchResponse( + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + } + it("encodes nested repository owners as one route segment", async () => { const fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ config: null }), { @@ -22,4 +39,75 @@ describe("getLinearConfig", () => { expect.any(Object) ); }); + + it("returns a parsed resolved config", async () => { + await expect( + getLinearConfig( + envForResponse({ + config: { + model: "openai/gpt-5.4", + reasoningEffort: null, + allowUserPreferenceOverride: false, + allowLabelModelOverride: true, + emitToolProgressActivities: false, + issueSessionInstructions: "Use small commits.", + enabledRepos: ["acme/backend"], + }, + }), + "acme/backend" + ) + ).resolves.toEqual({ + model: "openai/gpt-5.4", + reasoningEffort: null, + allowUserPreferenceOverride: false, + allowLabelModelOverride: true, + emitToolProgressActivities: false, + issueSessionInstructions: "Use small commits.", + enabledRepos: ["acme/backend"], + }); + }); + + it("falls back when the response shape is malformed", async () => { + await expect( + getLinearConfig( + envForResponse({ + config: { + model: "openai/gpt-5.4", + allowUserPreferenceOverride: "yes", + }, + }), + "acme/backend" + ) + ).resolves.toEqual({ + model: null, + reasoningEffort: null, + allowUserPreferenceOverride: true, + allowLabelModelOverride: true, + emitToolProgressActivities: true, + issueSessionInstructions: null, + enabledRepos: null, + }); + }); + + it("falls back when the response is invalid JSON", async () => { + await expect( + getLinearConfig( + envForFetchResponse( + new Response("{not-json", { + status: 200, + headers: { "content-type": "application/json" }, + }) + ), + "acme/backend" + ) + ).resolves.toEqual({ + model: null, + reasoningEffort: null, + allowUserPreferenceOverride: true, + allowLabelModelOverride: true, + emitToolProgressActivities: true, + issueSessionInstructions: null, + enabledRepos: null, + }); + }); }); diff --git a/packages/linear-bot/src/utils/integration-config.ts b/packages/linear-bot/src/utils/integration-config.ts index 691550a71..b85b243fc 100644 --- a/packages/linear-bot/src/utils/integration-config.ts +++ b/packages/linear-bot/src/utils/integration-config.ts @@ -1,16 +1,23 @@ import { encodeRepositoryPathSegments, parseRepositoryFullName } from "@open-inspect/shared"; +import { z } from "zod"; import type { Env } from "../types"; import { signedControlPlaneFetch } from "../internal-auth"; -export interface ResolvedLinearConfig { - model: string | null; - reasoningEffort: string | null; - allowUserPreferenceOverride: boolean; - allowLabelModelOverride: boolean; - emitToolProgressActivities: boolean; - issueSessionInstructions: string | null; - enabledRepos: string[] | null; -} +const resolvedLinearConfigSchema = z.object({ + model: z.string().nullable(), + reasoningEffort: z.string().nullable(), + allowUserPreferenceOverride: z.boolean(), + allowLabelModelOverride: z.boolean(), + emitToolProgressActivities: z.boolean(), + issueSessionInstructions: z.string().nullable(), + enabledRepos: z.array(z.string()).nullable(), +}); + +const resolvedLinearConfigResponseSchema = z.object({ + config: resolvedLinearConfigSchema.nullable(), +}); + +export type ResolvedLinearConfig = z.infer; const DEFAULT_CONFIG: ResolvedLinearConfig = { model: null, @@ -45,10 +52,12 @@ export async function getLinearConfig(env: Env, repo: string): Promise null) + ); + if (!parsed.success || !parsed.data.config) { return DEFAULT_CONFIG; } - return data.config; + return parsed.data.config; } diff --git a/packages/linear-bot/src/webhook-handler.test.ts b/packages/linear-bot/src/webhook-handler.test.ts index 4ad50da6b..2e333211c 100644 --- a/packages/linear-bot/src/webhook-handler.test.ts +++ b/packages/linear-bot/src/webhook-handler.test.ts @@ -259,7 +259,10 @@ describe("handleAgentSessionEvent environment targets", () => { return { ok: true, json: () => Promise.resolve({ config: null }) }; } if (url === "https://internal/sessions") { - return { ok: true, json: () => Promise.resolve({ sessionId: "session-xyz" }) }; + return { + ok: true, + json: () => Promise.resolve({ sessionId: "session-xyz", status: "created" }), + }; } if (url === "https://internal/sessions/session-xyz/prompt") { return { ok: true, json: () => Promise.resolve({ ok: true }) }; @@ -288,6 +291,74 @@ describe("handleAgentSessionEvent environment targets", () => { return JSON.parse(String((call[1] as RequestInit).body)) as Record; } + async function runWithCreateSessionResponse(response: Response, traceId: string) { + const { kv, store } = createFakeKV({ + "oauth:client-credentials:org-1": validToken(), + "config:project-repos": JSON.stringify({ "project-1": { environmentId: "env_abc" } }), + }); + const env = makeLinearBotEnv(kv); + const fetchMock = stubControlPlane(env); + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "https://internal/environments") { + return Response.json({ environments: [environment], total: 1 }); + } + if (url.startsWith("https://internal/integration-settings/linear/resolved/")) { + return Response.json({ config: null }); + } + if (url === "https://internal/sessions") return response; + if (url === "https://internal/repos") return Response.json({ repos: [] }); + throw new Error(`Unexpected control-plane fetch to ${url}`); + }); + + await handleAgentSessionEvent(makeWebhook(), env, traceId); + + return { + issueSessionStored: store.has("issue:issue-1"), + requestedUrls: fetchMock.mock.calls.map(([input]) => String(input)), + }; + } + + async function followUpPromptForEventsResponse( + eventsResponse: Response, + traceId: string + ): Promise> { + const { kv } = createFakeKV({ + "oauth:client-credentials:org-1": validToken(), + "issue:issue-1": JSON.stringify({ + sessionId: "session-xyz", + issueId: "issue-1", + issueIdentifier: "ENG-42", + repoOwner: "acme", + repoName: "backend", + model: "anthropic/claude-haiku-4-5", + createdAt: Date.now(), + }), + }); + const env = makeLinearBotEnv(kv); + const controlPlaneFetch = (env.CONTROL_PLANE as unknown as { fetch: ReturnType }) + .fetch; + controlPlaneFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/events?type=token&limit=20")) return eventsResponse; + if (url.endsWith("/prompt")) return Response.json({ ok: true }); + throw new Error(`Unexpected control-plane fetch to ${url}`); + }); + const webhook = makeWebhook(); + webhook.action = "prompted"; + webhook.agentActivity = { + userId: "follow-up-human-user", + content: { type: "prompt", body: "Please continue." }, + }; + + await handleAgentSessionEvent(webhook, env, traceId); + + const promptCall = controlPlaneFetch.mock.calls.find(([input]) => + String(input).endsWith("/prompt") + ); + return JSON.parse(String(promptCall?.[1]?.body)) as Record; + } + it("transitions an existing installation and creates an environment session", async () => { const { kv, store } = createFakeKV({ "oauth:token:org-1": JSON.stringify({ @@ -342,6 +413,29 @@ describe("handleAgentSessionEvent environment targets", () => { expect(tokenBody.has("refresh_token")).toBe(false); }); + it("does not store or prompt when the create-session response is malformed", async () => { + const result = await runWithCreateSessionResponse( + Response.json({ id: "session-xyz" }), + "trace-malformed-session" + ); + + expect(result.issueSessionStored).toBe(false); + expect(result.requestedUrls).not.toContain("https://internal/sessions/session-xyz/prompt"); + }); + + it("does not store or prompt when the create-session response is invalid JSON", async () => { + const result = await runWithCreateSessionResponse( + new Response("{not-json", { + status: 201, + headers: { "content-type": "application/json" }, + }), + "trace-invalid-json-session" + ); + + expect(result.issueSessionStored).toBe(false); + expect(result.requestedUrls).not.toContain("https://internal/sessions/session-xyz/prompt"); + }); + it("creates an environment session from a label-matched team mapping", async () => { const { kv } = createFakeKV({ "oauth:client-credentials:org-1": validToken(), @@ -470,7 +564,7 @@ describe("handleAgentSessionEvent environment targets", () => { controlPlaneFetch.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/integration-settings/")) return Response.json({ config: null }); - if (url.endsWith("/events?limit=20")) return Response.json({ events: [] }); + if (url.endsWith("/events?type=token&limit=20")) return Response.json({ events: [] }); if (url.endsWith("/prompt")) return Response.json({ ok: true }); throw new Error(`Unexpected control-plane fetch to ${url}`); }); @@ -505,6 +599,43 @@ describe("handleAgentSessionEvent environment targets", () => { expect(body.callbackContext).not.toHaveProperty("transitionIssueOnStart"); }); + it("adds prior token context from a parsed events response", async () => { + const body = await followUpPromptForEventsResponse( + Response.json({ + events: [ + { type: "token", data: { content: "Most recent response." } }, + { type: "token", data: { content: "Older response." } }, + ], + }), + "trace-follow-up-context" + ); + + expect(body.content).toContain("Previous agent response"); + expect(body.content).toContain("Most recent response."); + expect(body.content).not.toContain("Older response."); + }); + + it("skips prior token context when the events response is malformed", async () => { + const body = await followUpPromptForEventsResponse( + Response.json({ events: [{ type: "token", data: { content: 123 } }] }), + "trace-follow-up-bad-events" + ); + + expect(body.content).not.toContain("Previous agent response"); + }); + + it("skips prior token context when the events response is invalid JSON", async () => { + const body = await followUpPromptForEventsResponse( + new Response("{not-json", { + status: 200, + headers: { "content-type": "application/json" }, + }), + "trace-follow-up-invalid-json-events" + ); + + expect(body.content).not.toContain("Previous agent response"); + }); + it("stops an existing session when Linear sends a stop signal", async () => { const { kv, store } = createFakeKV({ "issue:issue-1": JSON.stringify({ @@ -597,7 +728,7 @@ describe("handleAgentSessionEvent environment targets", () => { }, }); } - if (url.endsWith("/events?limit=20")) return Response.json({ events: [] }); + if (url.endsWith("/events?type=token&limit=20")) return Response.json({ events: [] }); if (url.endsWith("/prompt")) return Response.json({ ok: true }); throw new Error(`Unexpected control-plane fetch to ${url}`); }); @@ -638,7 +769,7 @@ describe("handleAgentSessionEvent environment targets", () => { controlPlaneFetch.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/integration-settings/")) return Response.json({ config: null }); - if (url.endsWith("/events?limit=20")) return Response.json({ events: [] }); + if (url.endsWith("/events?type=token&limit=20")) return Response.json({ events: [] }); if (url.endsWith("/prompt")) return Response.json({ ok: true }); throw new Error(`Unexpected control-plane fetch to ${url}`); }); diff --git a/packages/linear-bot/src/webhook-handler.ts b/packages/linear-bot/src/webhook-handler.ts index e216da324..6b5237c6e 100644 --- a/packages/linear-bot/src/webhook-handler.ts +++ b/packages/linear-bot/src/webhook-handler.ts @@ -3,6 +3,8 @@ * Extracted from index.ts for modularity. */ +import { createSessionResponseSchema } from "@open-inspect/shared"; +import { z } from "zod"; import type { Env, LinearCallbackContext, @@ -36,6 +38,17 @@ import { getUserPreferences, lookupIssueSession, storeIssueSession } from "./kv- const log = createLogger("handler"); +const sessionEventsSummaryResponseSchema = z.object({ + events: z.array( + z.object({ + type: z.literal("token"), + data: z.object({ + content: z.string(), + }), + }) + ), +}); + export function escapeHtml(s: string): string { return s .replace(/&/g, "&") @@ -162,8 +175,11 @@ async function createSession( return { ok: false, status: response.status, body }; } - const result = (await response.json()) as { sessionId: string }; - return { ok: true, sessionId: result.sessionId }; + const result = createSessionResponseSchema.safeParse(await response.json().catch(() => null)); + if (!result.success) { + return { ok: false, status: response.status, body: "invalid response" }; + } + return { ok: true, sessionId: result.data.sessionId }; } // ─── Sub-handlers ──────────────────────────────────────────────────────────── @@ -366,22 +382,19 @@ async function handleFollowUp( let sessionContextSummary = ""; try { - const eventsUrl = `https://internal/sessions/${existingSession.sessionId}/events?limit=20`; + const eventsUrl = `https://internal/sessions/${existingSession.sessionId}/events?type=token&limit=20`; const eventsRes = await signedControlPlaneFetch(env, { method: "GET", url: eventsUrl, traceId, }); if (eventsRes.ok) { - const eventsData = (await eventsRes.json()) as { - events: Array<{ type: string; data: Record }>; - }; - const recentTokens = eventsData.events.filter((e) => e.type === "token").slice(-1); - if (recentTokens.length > 0) { - const lastContent = String(recentTokens[0].data.content ?? ""); - if (lastContent) { - sessionContextSummary = lastContent.slice(0, 500); - } + const eventsData = sessionEventsSummaryResponseSchema.safeParse(await eventsRes.json()); + const latestContent = eventsData.success + ? eventsData.data.events[0]?.data.content + : undefined; + if (latestContent) { + sessionContextSummary = latestContent.slice(0, 500); } } } catch { From b4488ab9677a6e2a110975db30f986addf94249b Mon Sep 17 00:00:00 2001 From: "open-inspect[bot]" <255062780+open-inspect[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:04:30 -0700 Subject: [PATCH 6/6] fix(types): validate Linear classifier response (#1097) This is an automated nightly unsafe-cast remediation. It replaces unsafe assertions at an external Anthropic API boundary with package-local Zod validation, following the TypeScript Coding Standards unsafe-cast / parse-don't-assert guidance and the Zod boundary-validation pattern established in PR #807. | Finding | Risk | Cast removed | Fix | | --- | --- | --- | --- | | `packages/linear-bot/src/classifier/index.ts:141` | HIGH | `(await response.json()) as AnthropicResponse` on an external API response | Added `anthropicMessagesResponseSchema` and `safeParse` before reading `content` | | `packages/linear-bot/src/classifier/index.ts:148` | HIGH | `toolBlock.input as Record` plus `input.confidence as ConfidenceLevel` | Added `classifyToolInputSchema` with `repoId` modeled as `string | null`, confidence enum validation, and `safeParse` before returning the `z.infer` type | Verification: | Command | Result | | --- | --- | | `npm run build -w @open-inspect/shared` | Passed | | `npm run build -w @open-inspect/linear-bot` | Passed | | `npm test -w @open-inspect/linear-bot` | Passed, 200 tests | | `npm run typecheck` | Passed | | `npm run lint -w @open-inspect/linear-bot` | Passed | | `npm run format` | Passed | | `npm run lint` | Passed in a clean worktree for this commit; the active agent workspace contains untracked local `.opencode` tool files that are not part of this PR and cause root lint false positives there | --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/8c9388c89bcb8c9de06d4b25235c0809)* Co-authored-by: waclaude --- .../linear-bot/src/classifier/index.test.ts | 65 +++++++++++++++++++ packages/linear-bot/src/classifier/index.ts | 57 ++++++++-------- 2 files changed, 92 insertions(+), 30 deletions(-) create mode 100644 packages/linear-bot/src/classifier/index.test.ts diff --git a/packages/linear-bot/src/classifier/index.test.ts b/packages/linear-bot/src/classifier/index.test.ts new file mode 100644 index 000000000..24d1b337c --- /dev/null +++ b/packages/linear-bot/src/classifier/index.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { anthropicMessagesResponseSchema, classifyToolInputSchema } from "./index"; + +describe("anthropicMessagesResponseSchema", () => { + it("parses a response with the consumed tool block fields", () => { + const parsed = anthropicMessagesResponseSchema.safeParse({ + id: "msg_1", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "classify_repository", + input: { + repoId: "org/repo", + confidence: "high", + reasoning: "The issue names the repo.", + alternatives: [], + }, + }, + ], + }); + + expect(parsed.success).toBe(true); + }); + + it("rejects a response without content", () => { + const parsed = anthropicMessagesResponseSchema.safeParse({ id: "msg_1" }); + + expect(parsed.success).toBe(false); + }); +}); + +describe("classifyToolInputSchema", () => { + it("parses a valid classification tool input", () => { + const parsed = classifyToolInputSchema.safeParse({ + repoId: "org/repo", + confidence: "medium", + reasoning: "The labels match this repository.", + alternatives: ["org/other"], + }); + + expect(parsed.success).toBe(true); + }); + + it("parses a null repoId for low-confidence classifications", () => { + const parsed = classifyToolInputSchema.safeParse({ + repoId: null, + confidence: "low", + reasoning: "No repository was a clear match.", + alternatives: ["org/api", "org/web"], + }); + + expect(parsed.success).toBe(true); + }); + + it("rejects malformed or partial tool input", () => { + const parsed = classifyToolInputSchema.safeParse({ + repoId: "org/repo", + confidence: "certain", + reasoning: "Invalid confidence value.", + }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/packages/linear-bot/src/classifier/index.ts b/packages/linear-bot/src/classifier/index.ts index 3c1c4feb3..e591acf94 100644 --- a/packages/linear-bot/src/classifier/index.ts +++ b/packages/linear-bot/src/classifier/index.ts @@ -4,7 +4,7 @@ */ import type { Env, RepoConfig, ClassificationResult } from "../types"; -import type { ConfidenceLevel } from "@open-inspect/shared"; +import { z } from "zod"; import { getAvailableRepos, buildRepoDescriptions } from "./repos"; import { createLogger } from "../logger"; @@ -12,24 +12,24 @@ const log = createLogger("classifier"); const CLASSIFY_REPO_TOOL_NAME = "classify_repository"; -interface ClassifyToolInput { - repoId: string | null; - confidence: ConfidenceLevel; - reasoning: string; - alternatives: string[]; -} - -interface AnthropicContentBlock { - type: string; - id?: string; - name?: string; - input?: unknown; - text?: string; -} - -interface AnthropicResponse { - content: AnthropicContentBlock[]; -} +export const classifyToolInputSchema = z.object({ + repoId: z.string().nullable(), + confidence: z.enum(["high", "medium", "low"]), + reasoning: z.string(), + alternatives: z.array(z.string()), +}); + +export type ClassifyToolInput = z.infer; + +export const anthropicMessagesResponseSchema = z.object({ + content: z.array( + z.object({ + type: z.string(), + name: z.string().optional(), + input: z.unknown().optional(), + }) + ), +}); /** * Build classification prompt from Linear issue context. @@ -138,22 +138,19 @@ async function callAnthropic(apiKey: string, prompt: string): Promise b.type === "tool_use" && b.name === CLASSIFY_REPO_TOOL_NAME ); if (!toolBlock) throw new Error("No tool_use block in Anthropic response"); - const input = toolBlock.input as Record; - return { - repoId: input.repoId === null ? null : typeof input.repoId === "string" ? input.repoId : null, - confidence: (input.confidence as ConfidenceLevel) || "low", - reasoning: String(input.reasoning || ""), - alternatives: Array.isArray(input.alternatives) - ? input.alternatives.filter((a): a is string => typeof a === "string") - : [], - }; + const input = classifyToolInputSchema.safeParse(toolBlock.input); + if (!input.success) throw new Error("Malformed Anthropic tool input"); + + return input.data; } /**