diff --git a/hub/.env.example b/hub/.env.example index 98392530..026075eb 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -8,6 +8,22 @@ LOG_LEVEL=info OTEL_TRACES_EXPORTER=none # OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://127.0.0.1:4318/v1/traces +# Trust forwarded client addresses only behind this exact protected proxy-chain length. +HUB_TRUSTED_PROXY_HOPS=0 + +# GitHub authentication proof is disabled until every setting below is uncommented. +# AUTHENTICATION_PROVIDER=github +# HUB_APP_ORIGIN=http://127.0.0.1:5173 +# AUTHENTICATION_SECURE_COOKIES=false +# AUTHENTICATION_ENCRYPTION_KEY=replace-with-base64url-encoded-32-byte-key +# GITHUB_AUTHENTICATION_REVISION=1 +# GITHUB_CLIENT_ID=replace-with-oauth-app-client-id +# GITHUB_CLIENT_SECRET=replace-with-oauth-app-client-secret +# GITHUB_CALLBACK_URI=http://127.0.0.1:3001/api/auth/github/callback +# GITHUB_AUTHORIZATION_ENDPOINT=https://github.com/login/oauth/authorize +# GITHUB_TOKEN_ENDPOINT=https://github.com/login/oauth/access_token +# GITHUB_USER_ENDPOINT=https://api.github.com/user + # Independent worker concurrency. Both default to one (supported range: 1-100). ON_DEMAND_JOB_CONCURRENCY=1 SCHEDULED_JOB_CONCURRENCY=1 diff --git a/hub/docs/README.md b/hub/docs/README.md index 5ef13e81..823bab13 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -7,6 +7,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Coding conventions](coding-conventions.md): frontend layers and interface rules. - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. +- [Authentication proof](authentication.md): GitHub identity proof, one-use attempts, and the non-admitted handoff boundary. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Telemetry](telemetry.md): explicit OpenTelemetry spans, safe attributes, and process lifetime. - [Background jobs](background-jobs.md): PostgreSQL queues, workers, schedules, setup, and test isolation. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 123835c6..f9e4f569 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -8,13 +8,13 @@ The team has chosen TypeScript, Express for the API, React and Vite for the fron The API responds to a health check, and the frontend displays the logo with light and dark themes. Storybook keeps the existing components and pages available for reuse. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has a provider-neutral identity transaction foundation, but it does not issue sessions or admit users to Hub. Sign-in, deployment, and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has a provider-neutral identity transaction foundation and a GitHub authentication proof flow. The proof produces a short-lived non-admitted handoff; it does not issue a usable session or admit users to Hub. Deployment and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -The only route is `GET /api/health`, which returns `{ "status": "ok" }`. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` starts it at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Explicit GitHub configuration enables `POST /api/auth/github/start` and `GET /api/auth/github/callback` for authentication proof only. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` starts it at a local address. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. @@ -30,7 +30,7 @@ The identity foundation stores Hub users, immutable external identity mappings, Domain operations receive only the transaction-scoped repositories they need. A successful state change and its success audit commit together. The kernel applies one fixed bounded retry policy to expected serializable and aggregate-creation races so consumers cannot disable convergence. A rejected database operation rolls back before its final denial or failure audit is written. External provider-validation failures write one typed audit after validation returns and before the operation fails. Audit callers select a typed kernel event; the repository builds the stored action and allowlisted context. -This foundation does not prove a provider assertion, create a usable Hub session, assign membership, or authorize product access. +The [authentication proof layer](authentication.md) uses this foundation to validate explicit GitHub configuration, complete OAuth user authentication, resolve the immutable external identity, and create a browser-bound handoff. It performs provider network work outside identity transactions. It does not create a usable Hub session, assign membership, or authorize product access. ## Frontend diff --git a/hub/docs/authentication.md b/hub/docs/authentication.md new file mode 100644 index 00000000..56f2281b --- /dev/null +++ b/hub/docs/authentication.md @@ -0,0 +1,29 @@ +# Authentication proof + +Hub can prove a GitHub user identity without admitting that user to Hub. The proof flow creates a short-lived admission handoff for the next identity layer. It does not create a usable session, membership, role, invitation, or product authority. + +## Current flow + +`POST /api/auth/github/start` accepts requests only from the configured Hub origin. Hub reserves capacity in a database-backed rate-limit window for the trusted request address, selects the configured GitHub provider revision, stores a one-use authorization attempt, sets an HTTP-only browser-binding cookie, and redirects to GitHub with state and PKCE. The callback reserves its separate database-backed budget before parsing callback values, so every Hub replica enforces the same limits. + +GitHub returns to `GET /api/auth/github/callback`. Hub consumes the attempt before exchanging the bounded authorization code. It uses GitHub's current-user endpoint and binds the immutable external identity to the numeric GitHub user ID. Login, name, avatar URL, and email remain display attributes. + +A successful callback returns `authentication_proved` with `admission: "pending"` and a short-lived opaque handoff token. The handoff is bound to the browser, selected provider revision, and global revocation generation. It is not a Hub session and cannot access product routes. + +## Stored data + +Hub stores SHA-256 digests of state, browser binding, request address, and handoff token. The PKCE verifier is encrypted with the configured authentication encryption key. Provider access tokens are used only for the current-user lookup and are never saved, logged, or audited. + +Authorization attempts and admission handoffs can transition from unconsumed to consumed once. Their identity, provider revision, expiry, and evidence cannot change. Admission-handoff consumption uses PostgreSQL's current clock after acquiring its transaction locks, so callers cannot choose the expiry-check time. The global revocation generation can only increase. + +The C1 identity kernel continues to own users, immutable external identity mappings, provider revision digests, and security audits. Provider requests and validation run outside C1 database transactions. + +## Configuration + +Authentication routes are disabled unless `AUTHENTICATION_PROVIDER=github` is set. When enabled, startup requires the GitHub client, endpoint, callback, revision, cookie, origin, and encryption settings listed in [.env.example](../.env.example). Hub validates the strict public configuration allowlist and selects its explicit revision through the identity kernel. Unknown fields and revision drift fail closed; Hub never falls back to another provider. + +Set `HUB_TRUSTED_PROXY_HOPS` only to the exact number of protected proxies in front of Hub. The default is `0`, so forwarded client addresses are ignored. + +## Not implemented + +This layer does not consume a handoff through an HTTP route or issue a usable Hub session. It does not evaluate installation membership, invitations, bootstrap or recovery state, roles, repository permissions, or product authorization. Entra OIDC, account linking, synchronized access, SCIM, publishing, and migration from old Hub instances are also not implemented. diff --git a/hub/docs/database.md b/hub/docs/database.md index c64e948e..36fb9a5c 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,8 @@ # Hub database -Hub uses PostgreSQL and Prisma. +Hub uses PostgreSQL and Prisma. The schema contains the provider-neutral identity transaction foundation, one-use authorization attempts, short-lived admission handoffs, and a global authentication revocation generation. Authentication provider revisions store a validated-configuration digest, not provider configuration, and every committed provider must select one of its own revisions. Hub sessions, memberships, invitations, source integrations, and product records are not part of this foundation. + +An external identity's provider, issuer, and subject share a 2,000-byte UTF-8 limit, including two separator bytes. Runtime parsing and the committed database constraint enforce the same conservative bound so the composite unique index remains below PostgreSQL's B-tree item limit. [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. diff --git a/hub/docs/development.md b/hub/docs/development.md index 2e6efa8c..65b6b4ac 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -1,6 +1,6 @@ # Hub development -Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. Sign-in and production deployment are not configured. +Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. GitHub authentication proof can be configured locally, but it does not admit users or create Hub sessions. Production deployment is not configured. ## Devcontainer @@ -20,7 +20,7 @@ cp .env.example .env npm ci --engine-strict ``` -The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. The existing health and frontend routes do not query the database. +The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. The health and frontend routes do not query the database. Authentication routes require the database and remain disabled unless an authentication provider is selected. ## Start the app @@ -36,7 +36,9 @@ In a second terminal, also from `hub/`, start Vite: npm run dev ``` -Open . Vite serves the frontend and forwards `/api` requests to Express at . `GET /api/health` returns JSON; other API routes have not been built. Both servers accept connections only from this machine. Stop each with Ctrl+C. +Open . Vite serves the frontend and forwards `/api` requests to Express at . `GET /api/health` returns JSON. When GitHub authentication is configured, the API also provides the proof-only start and callback routes described in [Authentication proof](authentication.md). + +To enable the GitHub proof flow, copy the commented authentication settings from `.env.example`, register the exact callback URI in a GitHub OAuth App, and generate the local encryption key with `node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`. Keep the client secret and encryption key out of source control. Use insecure cookies only for loopback HTTP development. Both servers accept connections only from this machine. Stop each with Ctrl+C. Vite also forwards WebSocket upgrades under `/api`. The `/api/realtime` endpoint rejects connections until application access checks are connected; see [WebSocket updates](websockets.md). Stopping the API closes its WebSocket connections as well. diff --git a/hub/schema/admission-handoff.prisma b/hub/schema/admission-handoff.prisma new file mode 100644 index 00000000..4e7fef01 --- /dev/null +++ b/hub/schema/admission-handoff.prisma @@ -0,0 +1,26 @@ +model AdmissionHandoff { + // ID columns + id String @id @default(uuid()) @db.Uuid + externalIdentityId String @map("external_identity_id") @db.Uuid + providerRevisionId String @map("provider_revision_id") @db.Uuid + + // Relations + externalIdentity ExternalIdentity @relation(fields: [externalIdentityId], references: [id], onDelete: Restrict) + providerRevision AuthenticationProviderRevision @relation(fields: [providerRevisionId], references: [id], onDelete: Restrict) + + // Attribute columns + tokenDigest String @unique @map("token_digest") @db.VarChar(64) + browserBindingDigest String @map("browser_binding_digest") @db.VarChar(64) + revocationGeneration BigInt @map("revocation_generation") + displayAttributes Json @map("display_attributes") @db.JsonB + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([expiresAt]) + @@index([externalIdentityId]) + @@map("admission_handoffs") +} diff --git a/hub/schema/authentication-provider-revision.prisma b/hub/schema/authentication-provider-revision.prisma index 7095b0c4..935a5aaf 100644 --- a/hub/schema/authentication-provider-revision.prisma +++ b/hub/schema/authentication-provider-revision.prisma @@ -4,8 +4,10 @@ model AuthenticationProviderRevision { providerId String @map("provider_id") @db.Uuid // Relations - provider AuthenticationProvider @relation("AuthenticationProviderRevisions", fields: [providerId], references: [id], onDelete: Restrict) - selectedBy AuthenticationProvider? @relation("SelectedAuthenticationProviderRevision") + provider AuthenticationProvider @relation("AuthenticationProviderRevisions", fields: [providerId], references: [id], onDelete: Restrict) + selectedBy AuthenticationProvider? @relation("SelectedAuthenticationProviderRevision") + authorizationAttempts AuthorizationAttempt[] + admissionHandoffs AdmissionHandoff[] // Attribute columns revision Int diff --git a/hub/schema/authentication-rate-limit.prisma b/hub/schema/authentication-rate-limit.prisma new file mode 100644 index 00000000..bd7cd6ea --- /dev/null +++ b/hub/schema/authentication-rate-limit.prisma @@ -0,0 +1,18 @@ +model AuthenticationRateLimit { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Attribute columns + scope String @db.VarChar(32) + keyDigest String @map("key_digest") @db.VarChar(64) + windowStartedAt DateTime @map("window_started_at") @db.Timestamptz(3) + reservationCount Int @map("reservation_count") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([scope, keyDigest]) + @@index([windowStartedAt]) + @@map("authentication_rate_limits") +} diff --git a/hub/schema/authentication-revocation-generation.prisma b/hub/schema/authentication-revocation-generation.prisma new file mode 100644 index 00000000..7a953129 --- /dev/null +++ b/hub/schema/authentication-revocation-generation.prisma @@ -0,0 +1,14 @@ +model AuthenticationRevocationGeneration { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Attribute columns + key String @unique @db.VarChar(32) + generation BigInt @default(1) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("authentication_revocation_generations") +} diff --git a/hub/schema/authorization-attempt.prisma b/hub/schema/authorization-attempt.prisma new file mode 100644 index 00000000..b0c4458c --- /dev/null +++ b/hub/schema/authorization-attempt.prisma @@ -0,0 +1,25 @@ +model AuthorizationAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + providerRevisionId String @map("provider_revision_id") @db.Uuid + + // Relations + providerRevision AuthenticationProviderRevision @relation(fields: [providerRevisionId], references: [id], onDelete: Restrict) + + // Attribute columns + stateDigest String @unique @map("state_digest") @db.VarChar(64) + browserBindingDigest String @map("browser_binding_digest") @db.VarChar(64) + codeChallenge String @map("code_challenge") @db.VarChar(43) + codeVerifierCiphertext String @map("code_verifier_ciphertext") @db.VarChar(500) + requestIpDigest String @map("request_ip_digest") @db.VarChar(64) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([expiresAt]) + @@index([requestIpDigest, createdAt]) + @@map("authorization_attempts") +} diff --git a/hub/schema/external-identity.prisma b/hub/schema/external-identity.prisma index 6b5fa3a7..f4d25804 100644 --- a/hub/schema/external-identity.prisma +++ b/hub/schema/external-identity.prisma @@ -4,7 +4,8 @@ model ExternalIdentity { userId String @map("user_id") @db.Uuid // Relations - user User @relation(fields: [userId], references: [id], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + admissionHandoffs AdmissionHandoff[] // Attribute columns provider String @db.VarChar(100) diff --git a/hub/schema/migrations/20261030170000_authentication_proof/migration.sql b/hub/schema/migrations/20261030170000_authentication_proof/migration.sql new file mode 100644 index 00000000..48840ec6 --- /dev/null +++ b/hub/schema/migrations/20261030170000_authentication_proof/migration.sql @@ -0,0 +1,216 @@ +CREATE TABLE "authorization_attempts" ( + "id" UUID NOT NULL, + "provider_revision_id" UUID NOT NULL, + "state_digest" VARCHAR(64) NOT NULL, + "browser_binding_digest" VARCHAR(64) NOT NULL, + "code_challenge" VARCHAR(43) NOT NULL, + "code_verifier_ciphertext" VARCHAR(500) NOT NULL, + "request_ip_digest" VARCHAR(64) NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "consumed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authorization_attempts_pkey" PRIMARY KEY ("id"), + CONSTRAINT "authorization_attempts_digests" CHECK ( + "state_digest" ~ '^[0-9a-f]{64}$' + AND "browser_binding_digest" ~ '^[0-9a-f]{64}$' + AND "request_ip_digest" ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT "authorization_attempts_code_challenge" CHECK ( + "code_challenge" ~ '^[A-Za-z0-9_-]{43}$' + ), + CONSTRAINT "authorization_attempts_expiry" CHECK ("expires_at" > "created_at"), + CONSTRAINT "authorization_attempts_consumption" CHECK ( + "consumed_at" IS NULL OR "consumed_at" >= "created_at" + ) +); + +CREATE TABLE "admission_handoffs" ( + "id" UUID NOT NULL, + "external_identity_id" UUID NOT NULL, + "provider_revision_id" UUID NOT NULL, + "token_digest" VARCHAR(64) NOT NULL, + "browser_binding_digest" VARCHAR(64) NOT NULL, + "revocation_generation" BIGINT NOT NULL, + "display_attributes" JSONB NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "consumed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "admission_handoffs_pkey" PRIMARY KEY ("id"), + CONSTRAINT "admission_handoffs_digests" CHECK ( + "token_digest" ~ '^[0-9a-f]{64}$' + AND "browser_binding_digest" ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT "admission_handoffs_generation" CHECK ("revocation_generation" > 0), + CONSTRAINT "admission_handoffs_display_object" CHECK ( + jsonb_typeof("display_attributes") = 'object' + ), + CONSTRAINT "admission_handoffs_display_bounded" CHECK ( + pg_column_size("display_attributes") <= 4096 + ), + CONSTRAINT "admission_handoffs_expiry" CHECK ("expires_at" > "created_at"), + CONSTRAINT "admission_handoffs_consumption" CHECK ( + "consumed_at" IS NULL OR "consumed_at" >= "created_at" + ) +); + +CREATE TABLE "authentication_revocation_generations" ( + "id" UUID NOT NULL, + "key" VARCHAR(32) NOT NULL, + "generation" BIGINT NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_revocation_generations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "authentication_revocation_generations_key_present" CHECK (length("key") > 0), + CONSTRAINT "authentication_revocation_generations_global_key" CHECK ("key" = 'global'), + CONSTRAINT "authentication_revocation_generations_positive" CHECK ("generation" > 0) +); + +CREATE TABLE "authentication_rate_limits" ( + "id" UUID NOT NULL, + "scope" VARCHAR(32) NOT NULL, + "key_digest" VARCHAR(64) NOT NULL, + "window_started_at" TIMESTAMPTZ(3) NOT NULL, + "reservation_count" INTEGER NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_rate_limits_pkey" PRIMARY KEY ("id"), + CONSTRAINT "authentication_rate_limits_scope" CHECK ( + "scope" IN ('github_callback', 'github_start') + ), + CONSTRAINT "authentication_rate_limits_digest" CHECK ( + "key_digest" ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT "authentication_rate_limits_count" CHECK ("reservation_count" > 0) +); + +CREATE UNIQUE INDEX "authorization_attempts_state_digest_key" +ON "authorization_attempts"("state_digest"); +CREATE INDEX "authorization_attempts_expires_at_idx" ON "authorization_attempts"("expires_at"); +CREATE INDEX "authorization_attempts_request_ip_digest_created_at_idx" +ON "authorization_attempts"("request_ip_digest", "created_at"); +CREATE UNIQUE INDEX "admission_handoffs_token_digest_key" +ON "admission_handoffs"("token_digest"); +CREATE INDEX "admission_handoffs_expires_at_idx" ON "admission_handoffs"("expires_at"); +CREATE INDEX "admission_handoffs_external_identity_id_idx" +ON "admission_handoffs"("external_identity_id"); +CREATE UNIQUE INDEX "authentication_revocation_generations_key_key" +ON "authentication_revocation_generations"("key"); +CREATE UNIQUE INDEX "authentication_rate_limits_scope_key_digest_key" +ON "authentication_rate_limits"("scope", "key_digest"); +CREATE INDEX "authentication_rate_limits_window_started_at_idx" +ON "authentication_rate_limits"("window_started_at"); + +ALTER TABLE "authorization_attempts" +ADD CONSTRAINT "authorization_attempts_provider_revision_id_fkey" +FOREIGN KEY ("provider_revision_id") REFERENCES "authentication_provider_revisions"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "admission_handoffs" +ADD CONSTRAINT "admission_handoffs_external_identity_id_fkey" +FOREIGN KEY ("external_identity_id") REFERENCES "external_identities"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "admission_handoffs" +ADD CONSTRAINT "admission_handoffs_provider_revision_id_fkey" +FOREIGN KEY ("provider_revision_id") REFERENCES "authentication_provider_revisions"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE FUNCTION protect_authorization_attempt() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF OLD."expires_at" > CURRENT_TIMESTAMP THEN + RAISE EXCEPTION 'Unexpired authorization attempts cannot be deleted.'; + END IF; + RETURN OLD; + END IF; + IF NEW."id" <> OLD."id" + OR NEW."provider_revision_id" <> OLD."provider_revision_id" + OR NEW."state_digest" <> OLD."state_digest" + OR NEW."browser_binding_digest" <> OLD."browser_binding_digest" + OR NEW."code_challenge" <> OLD."code_challenge" + OR NEW."code_verifier_ciphertext" <> OLD."code_verifier_ciphertext" + OR NEW."request_ip_digest" <> OLD."request_ip_digest" + OR NEW."expires_at" <> OLD."expires_at" + OR NEW."created_at" <> OLD."created_at" + OR OLD."consumed_at" IS NOT NULL + OR NEW."consumed_at" IS NULL THEN + RAISE EXCEPTION 'Authorization attempt history is immutable except for first consumption.'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "authorization_attempts_protected" +BEFORE UPDATE OR DELETE ON "authorization_attempts" +FOR EACH ROW EXECUTE FUNCTION protect_authorization_attempt(); + +CREATE FUNCTION protect_admission_handoff() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF OLD."expires_at" > CURRENT_TIMESTAMP THEN + RAISE EXCEPTION 'Unexpired admission handoffs cannot be deleted.'; + END IF; + RETURN OLD; + END IF; + IF NEW."id" <> OLD."id" + OR NEW."external_identity_id" <> OLD."external_identity_id" + OR NEW."provider_revision_id" <> OLD."provider_revision_id" + OR NEW."token_digest" <> OLD."token_digest" + OR NEW."browser_binding_digest" <> OLD."browser_binding_digest" + OR NEW."revocation_generation" <> OLD."revocation_generation" + OR NEW."display_attributes" <> OLD."display_attributes" + OR NEW."expires_at" <> OLD."expires_at" + OR NEW."created_at" <> OLD."created_at" + OR OLD."consumed_at" IS NOT NULL + OR NEW."consumed_at" IS NULL THEN + RAISE EXCEPTION 'Admission handoff history is immutable except for first consumption.'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "admission_handoffs_protected" +BEFORE UPDATE OR DELETE ON "admission_handoffs" +FOR EACH ROW EXECUTE FUNCTION protect_admission_handoff(); + +CREATE FUNCTION protect_authentication_revocation_generation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' + OR NEW."id" <> OLD."id" + OR NEW."key" <> OLD."key" + OR NEW."generation" <= OLD."generation" + OR NEW."created_at" <> OLD."created_at" THEN + RAISE EXCEPTION 'Authentication revocation generation may only increase.'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "authentication_revocation_generations_protected" +BEFORE UPDATE OR DELETE ON "authentication_revocation_generations" +FOR EACH ROW EXECUTE FUNCTION protect_authentication_revocation_generation(); + +INSERT INTO "authentication_revocation_generations" ( + "id", + "key", + "generation" +) VALUES ( + '00000000-0000-4000-8000-000000000001', + 'global', + 1 +); diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 6ea4dbb4..fe852102 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -8,13 +8,19 @@ import { routes } from "../routes/index.ts" import type { CreateAppOptions } from "./types.ts" /** Give every endpoint the same request logging and safe error responses. */ -export function createApp({ logger, broadcast }: CreateAppOptions = {}) { +export function createApp({ + broadcast, + githubAuthentication, + logger, + trustedProxyHops = 0, +}: CreateAppOptions = {}) { const app = express() app.disable("x-powered-by") + app.set("trust proxy", trustedProxyHops) app.use(createRequestLogger(logger)) app.use(express.json()) - routes(app, { broadcast }) + routes(app, { broadcast, githubAuthentication }) app.use(respondNotFound) app.use(handleError) diff --git a/hub/server/create-app/types.ts b/hub/server/create-app/types.ts index 0d07eda1..25259a2c 100644 --- a/hub/server/create-app/types.ts +++ b/hub/server/create-app/types.ts @@ -1,8 +1,11 @@ import type { Logger } from "pino" import type { Broadcast } from "../integrations/ws/types.ts" +import type { GitHubAuthenticationRoutesDependencies } from "../routes/api/auth/github/types.ts" export interface CreateAppOptions { - logger?: Logger broadcast?: Broadcast + githubAuthentication?: GitHubAuthenticationRoutesDependencies + logger?: Logger + trustedProxyHops?: number } diff --git a/hub/server/domains/authentication/authentication-proof-races.db.test.ts b/hub/server/domains/authentication/authentication-proof-races.db.test.ts new file mode 100644 index 00000000..c8a856db --- /dev/null +++ b/hub/server/domains/authentication/authentication-proof-races.db.test.ts @@ -0,0 +1,222 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { prisma } from "../../integrations/prisma" +import { lockAuthenticationProviderSelection } from "../../repositories/authentication-providers/lock-authentication-provider-selection" +import { incrementAuthenticationRevocationGeneration } from "../../repositories/authentication-revocation-generations/increment-authentication-revocation-generation" +import { lockAuthenticationRevocationGeneration } from "../../repositories/authentication-revocation-generations/lock-authentication-revocation-generation" +import { createSecurityAudit } from "../../repositories/security-audits/create-security-audit" +import { hashAuthenticationValue } from "../../utils/authentication/hash-authentication-value" +import { resolveOrCreateExternalIdentity } from "../identity/resolve-or-create-external-identity" +import { runIdentityCreationTransaction } from "../identity/run-identity-creation-transaction" +import { runProviderRevisionTransaction } from "../identity/run-provider-revision-transaction" +import { validateAndPersistProviderRevision } from "../identity/validate-and-persist-provider-revision" +import { createAuthenticationAdmissionHandoff } from "./create-admission-handoff" +import { consumeAuthenticationAdmissionHandoff } from "./consume-admission-handoff" +import { runAdmissionHandoffTransaction } from "./run-admission-handoff-transaction" + +function deferred() { + let resolve!: () => void + const promise = new Promise((complete) => { + resolve = complete + }) + return { promise, resolve } +} + +async function selectProviderRevision(revision: number) { + return validateAndPersistProviderRevision( + { providerKey: "github", revision, configuration: { revision } }, + { + createId: randomUUID, + runTransaction: (operation) => runProviderRevisionTransaction(prisma, operation), + validateConfiguration: () => + Promise.resolve({ canonicalPublicConfiguration: JSON.stringify({ revision }) }), + writeAudit: (event) => createSecurityAudit(prisma, event), + }, + ) +} + +async function createIdentity(subject: string) { + return resolveOrCreateExternalIdentity( + { provider: "github", issuer: "https://github.com", subject }, + { + createId: randomUUID, + runTransaction: (operation) => runIdentityCreationTransaction(prisma, operation), + writeAudit: (event) => createSecurityAudit(prisma, event), + }, + ) +} + +function handoffInput(externalIdentityId: string, providerRevisionId: string, token: string) { + return { + browserBindingDigest: hashAuthenticationValue("browser"), + displayAttributes: { login: "octocat", name: null, avatarUrl: null, email: null }, + expectedRevocationGeneration: 1n, + expiresAt: new Date(Date.now() + 5 * 60 * 1_000), + externalIdentityId, + providerKey: "github", + providerRevisionId, + tokenDigest: hashAuthenticationValue(token), + } +} + +function barrierTransaction(target: "provider" | "revocation") { + const arrived = deferred() + const release = deferred() + return { + arrived: arrived.promise, + release: release.resolve, + runTransaction: ( + operation: Parameters>[1], + ) => + runAdmissionHandoffTransaction(prisma, operation, { + lockProvider: async (database, providerKey) => { + await lockAuthenticationProviderSelection(database, providerKey) + if (target === "provider") { + arrived.resolve() + await release.promise + } + }, + lockRevocation: async (database) => { + await lockAuthenticationRevocationGeneration(database) + if (target === "revocation") { + arrived.resolve() + await release.promise + } + }, + }), + } +} + +describe("authentication handoff creation races", () => { + it("serializes provider revision selection after the locked creation decision", async () => { + const provider = await selectProviderRevision(1) + const identity = await createIdentity("provider-creation-race") + const barrier = barrierTransaction("provider") + const creation = createAuthenticationAdmissionHandoff( + handoffInput(identity.externalIdentityId, provider.revisionId, "provider-race"), + { runTransaction: barrier.runTransaction }, + ) + + await barrier.arrived + let selectionCompleted = false + const selectionStarted = deferred() + const selection = (async () => { + selectionStarted.resolve() + await selectProviderRevision(2) + selectionCompleted = true + })() + await selectionStarted.promise + expect(selectionCompleted).toBe(false) + barrier.release() + + await expect(creation).resolves.toEqual({ status: "created" }) + await selection + await expect(prisma.admissionHandoff.count()).resolves.toBe(1) + await expect( + prisma.securityAudit.count({ where: { action: "authentication.handoff.created" } }), + ).resolves.toBe(1) + }) + + describe("authentication handoff consumption races", () => { + it.each(["provider", "revocation"] as const)( + "serializes %s changes after the locked consumption decision", + async (target) => { + const provider = await selectProviderRevision(1) + const identity = await createIdentity(`${target}-consumption-race`) + const token = `${target}-consumption-token` + await createAuthenticationAdmissionHandoff( + handoffInput(identity.externalIdentityId, provider.revisionId, token), + { + runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation), + }, + ) + const barrier = barrierTransaction(target) + const consumption = consumeAuthenticationAdmissionHandoff( + { + browserBinding: "browser", + handoffToken: token, + providerKey: "github", + }, + { runTransaction: barrier.runTransaction }, + ) + + await barrier.arrived + let changeCompleted = false + const changeStarted = deferred() + const change = (async () => { + changeStarted.resolve() + if (target === "provider") await selectProviderRevision(2) + else await incrementAuthenticationRevocationGeneration(prisma) + changeCompleted = true + })() + await changeStarted.promise + expect(changeCompleted).toBe(false) + barrier.release() + + await expect(consumption).resolves.toMatchObject({ status: "consumed" }) + await change + await expect( + prisma.securityAudit.count({ where: { action: "authentication.handoff.consumed" } }), + ).resolves.toBe(1) + }, + ) + }) + + it("serializes global revocation after the locked creation decision", async () => { + const provider = await selectProviderRevision(1) + const identity = await createIdentity("revocation-creation-race") + const barrier = barrierTransaction("revocation") + const creation = createAuthenticationAdmissionHandoff( + handoffInput(identity.externalIdentityId, provider.revisionId, "revocation-race"), + { runTransaction: barrier.runTransaction }, + ) + + await barrier.arrived + let revocationCompleted = false + const revocationStarted = deferred() + const revocation = (async () => { + revocationStarted.resolve() + await incrementAuthenticationRevocationGeneration(prisma) + revocationCompleted = true + })() + await revocationStarted.promise + expect(revocationCompleted).toBe(false) + barrier.release() + + await expect(creation).resolves.toEqual({ status: "created" }) + await revocation + await expect(prisma.admissionHandoff.count()).resolves.toBe(1) + }) + + it("returns restart without a handoff or success audit after observed drift", async () => { + const provider = await selectProviderRevision(1) + const identity = await createIdentity("observed-drift") + await selectProviderRevision(2) + + await expect( + createAuthenticationAdmissionHandoff( + handoffInput(identity.externalIdentityId, provider.revisionId, "revision-drift"), + { + runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation), + }, + ), + ).resolves.toEqual({ status: "restart", reason: "provider_revision_changed" }) + await expect(prisma.admissionHandoff.count()).resolves.toBe(0) + await expect( + prisma.securityAudit.count({ where: { action: "authentication.handoff.created" } }), + ).resolves.toBe(0) + + const selected = await selectProviderRevision(2) + await incrementAuthenticationRevocationGeneration(prisma) + await expect( + createAuthenticationAdmissionHandoff( + handoffInput(identity.externalIdentityId, selected.revisionId, "revocation-drift"), + { + runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation), + }, + ), + ).resolves.toEqual({ status: "restart", reason: "revoked" }) + await expect(prisma.admissionHandoff.count()).resolves.toBe(0) + }) +}) diff --git a/hub/server/domains/authentication/authentication-proof.db.test.ts b/hub/server/domains/authentication/authentication-proof.db.test.ts new file mode 100644 index 00000000..dad106ef --- /dev/null +++ b/hub/server/domains/authentication/authentication-proof.db.test.ts @@ -0,0 +1,260 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { prisma } from "../../integrations/prisma" +import { createAdmissionHandoff } from "../../repositories/admission-handoffs/create-admission-handoff" +import { createAuthorizationAttempt } from "../../repositories/authorization-attempts/create-authorization-attempt" +import { consumeAuthorizationAttempt } from "../../repositories/authorization-attempts/consume-authorization-attempt" +import { lockAuthenticationProviderSelection } from "../../repositories/authentication-providers/lock-authentication-provider-selection" +import { incrementAuthenticationRevocationGeneration } from "../../repositories/authentication-revocation-generations/increment-authentication-revocation-generation" +import { lockAuthenticationRevocationGeneration } from "../../repositories/authentication-revocation-generations/lock-authentication-revocation-generation" +import { createSecurityAudit } from "../../repositories/security-audits/create-security-audit" +import { hashAuthenticationValue } from "../../utils/authentication/hash-authentication-value" +import { resolveOrCreateExternalIdentity } from "../identity/resolve-or-create-external-identity" +import { runIdentityCreationTransaction } from "../identity/run-identity-creation-transaction" +import { runProviderRevisionTransaction } from "../identity/run-provider-revision-transaction" +import { validateAndPersistProviderRevision } from "../identity/validate-and-persist-provider-revision" +import { consumeAuthenticationAdmissionHandoff } from "./consume-admission-handoff" +import { runAdmissionHandoffTransaction } from "./run-admission-handoff-transaction" + +async function createProvider(revision = 1) { + const result = await validateAndPersistProviderRevision( + { + providerKey: "github", + revision, + configuration: { revision }, + }, + { + createId: randomUUID, + runTransaction: (operation) => runProviderRevisionTransaction(prisma, operation), + validateConfiguration: () => + Promise.resolve({ + canonicalPublicConfiguration: JSON.stringify({ revision }), + }), + writeAudit: (event) => createSecurityAudit(prisma, event), + }, + ) + return result +} + +async function createIdentity() { + return resolveOrCreateExternalIdentity( + { provider: "github", issuer: "https://github.com", subject: "123" }, + { + createId: randomUUID, + runTransaction: (operation) => runIdentityCreationTransaction(prisma, operation), + writeAudit: (event) => createSecurityAudit(prisma, event), + }, + ) +} + +describe("authentication proof database boundaries", () => { + it("atomically consumes an authorization attempt once and rejects replay, expiry, and mismatch", async () => { + const provider = await createProvider() + const now = new Date() + const base = { + id: randomUUID(), + providerRevisionId: provider.revisionId, + stateDigest: "a".repeat(64), + browserBindingDigest: "b".repeat(64), + codeChallenge: "c".repeat(43), + codeVerifierCiphertext: "encrypted-verifier", + requestIpDigest: "d".repeat(64), + expiresAt: new Date(now.getTime() + 10 * 60 * 1_000), + } + await createAuthorizationAttempt(prisma, base) + const consumedAt = new Date(Date.now() + 1_000) + + const [first, second] = await Promise.all([ + consumeAuthorizationAttempt(prisma, { + stateDigest: base.stateDigest, + browserBindingDigest: base.browserBindingDigest, + consumedAt, + }), + consumeAuthorizationAttempt(prisma, { + stateDigest: base.stateDigest, + browserBindingDigest: base.browserBindingDigest, + consumedAt, + }), + ]) + expect([first, second].filter(Boolean)).toHaveLength(1) + await expect( + consumeAuthorizationAttempt(prisma, { + stateDigest: base.stateDigest, + browserBindingDigest: "e".repeat(64), + consumedAt, + }), + ).resolves.toBeNull() + + await createAuthorizationAttempt(prisma, { + ...base, + id: randomUUID(), + stateDigest: "f".repeat(64), + expiresAt: new Date(now.getTime() + 60_000), + }) + await expect( + consumeAuthorizationAttempt(prisma, { + stateDigest: "f".repeat(64), + browserBindingDigest: base.browserBindingDigest, + consumedAt: new Date(now.getTime() + 2 * 60_000), + }), + ).resolves.toBeNull() + }) + + it("allows exactly one browser-bound handoff consumption and creates no session or membership", async () => { + const provider = await createProvider() + const identity = await createIdentity() + const now = new Date() + await createAdmissionHandoff(prisma, { + id: randomUUID(), + externalIdentityId: identity.externalIdentityId, + providerRevisionId: provider.revisionId, + tokenDigest: hashAuthenticationValue("handoff-token"), + browserBindingDigest: hashAuthenticationValue("browser-binding"), + revocationGeneration: 1n, + displayAttributes: { + login: "octocat", + name: null, + avatarUrl: null, + email: null, + }, + expiresAt: new Date(now.getTime() + 5 * 60 * 1_000), + }) + + const input = { + handoffToken: "handoff-token", + browserBinding: "browser-binding", + providerKey: "github", + } + const results = await Promise.all([ + consumeAuthenticationAdmissionHandoff(input, { + runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation), + }), + consumeAuthenticationAdmissionHandoff(input, { + runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation), + }), + ]) + expect(results.filter(({ status }) => status === "consumed")).toHaveLength(1) + expect(results.filter(({ status }) => status === "denied")).toHaveLength(1) + const applicationTables = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND (table_name LIKE '%session%' OR table_name LIKE '%membership%') + ` + expect(applicationTables).toEqual([]) + }) + + it("uses database time for expired, boundary, and lock-wait consumption", async () => { + const provider = await createProvider() + const identity = await createIdentity() + async function insertHandoff(token: string, expiryExpression: string) { + await prisma.$executeRawUnsafe( + `INSERT INTO "admission_handoffs" ( + "id", "external_identity_id", "provider_revision_id", "token_digest", + "browser_binding_digest", "revocation_generation", "display_attributes", + "expires_at", "created_at", "updated_at" + ) VALUES ( + $1::uuid, $2::uuid, $3::uuid, $4, $5, 1, '{"login":"octocat"}'::jsonb, + ${expiryExpression}, clock_timestamp() - interval '1 second', clock_timestamp() + )`, + randomUUID(), + identity.externalIdentityId, + provider.revisionId, + hashAuthenticationValue(token), + hashAuthenticationValue("browser"), + ) + } + async function consume(token: string, runTransaction = runAdmissionHandoffTransaction) { + return consumeAuthenticationAdmissionHandoff( + { handoffToken: token, browserBinding: "browser", providerKey: "github" }, + { runTransaction: (operation) => runTransaction(prisma, operation) }, + ) + } + + await insertHandoff("already-expired", "clock_timestamp() - interval '1 millisecond'") + await expect(consume("already-expired")).resolves.toEqual({ + status: "denied", + reason: "expired", + }) + + await insertHandoff("boundary", "clock_timestamp()") + await expect(consume("boundary")).resolves.toEqual({ + status: "denied", + reason: "expired", + }) + + await insertHandoff("expires-during-wait", "clock_timestamp() + interval '5 milliseconds'") + let release!: () => void + const waiting = new Promise((resolve) => { + release = resolve + }) + let arrived!: () => void + const arrival = new Promise((resolve) => { + arrived = resolve + }) + const consumption = consume("expires-during-wait", (database, operation) => + runAdmissionHandoffTransaction(database, operation, { + lockProvider: async (transaction, providerKey) => { + await lockAuthenticationProviderSelection(transaction, providerKey) + arrived() + await waiting + }, + lockRevocation: lockAuthenticationRevocationGeneration, + }), + ) + await arrival + await prisma.$executeRaw`SELECT pg_sleep(0.02)` + release() + await expect(consumption).resolves.toEqual({ status: "denied", reason: "expired" }) + }) + + it("denies handoffs after global revocation or provider revision drift", async () => { + const provider = await createProvider() + const identity = await createIdentity() + const now = new Date() + async function addHandoff(token: string, revocationGeneration: bigint) { + await createAdmissionHandoff(prisma, { + id: randomUUID(), + externalIdentityId: identity.externalIdentityId, + providerRevisionId: provider.revisionId, + tokenDigest: hashAuthenticationValue(token), + browserBindingDigest: hashAuthenticationValue("browser"), + revocationGeneration, + displayAttributes: { + login: "octocat", + name: null, + avatarUrl: null, + email: null, + }, + expiresAt: new Date(now.getTime() + 5 * 60 * 1_000), + }) + } + + await addHandoff("revoked", 1n) + await incrementAuthenticationRevocationGeneration(prisma) + await expect( + consumeAuthenticationAdmissionHandoff( + { + handoffToken: "revoked", + browserBinding: "browser", + providerKey: "github", + }, + { runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation) }, + ), + ).resolves.toEqual({ status: "denied", reason: "revoked" }) + + await addHandoff("drifted", 2n) + await createProvider(2) + await expect( + consumeAuthenticationAdmissionHandoff( + { + handoffToken: "drifted", + browserBinding: "browser", + providerKey: "github", + }, + { runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation) }, + ), + ).resolves.toEqual({ status: "denied", reason: "provider_revision_changed" }) + }) +}) diff --git a/hub/server/domains/authentication/complete-authentication-proof/index.test.ts b/hub/server/domains/authentication/complete-authentication-proof/index.test.ts new file mode 100644 index 00000000..d7fad03b --- /dev/null +++ b/hub/server/domains/authentication/complete-authentication-proof/index.test.ts @@ -0,0 +1,158 @@ +import { createHash } from "node:crypto" +import { describe, expect, it, vi } from "vitest" + +import { completeAuthenticationProof } from "." + +describe("completeAuthenticationProof()", () => { + it("consumes the attempt before provider work and returns only a pending handoff", async () => { + const order: string[] = [] + const createHandoff = vi.fn(() => { + order.push("handoff") + return Promise.resolve({ status: "created" as const }) + }) + const result = await completeAuthenticationProof( + { + browserBinding: "browser-binding-value-with-required-length", + code: "authorization-code", + state: "state-value-with-the-required-minimum-length", + }, + { + adapter: { + providerKey: "github", + createAuthorizationUrl: vi.fn(), + completeAuthenticationProof: (input) => { + order.push("provider") + expect(input.codeVerifier).toBe("pkce-verifier") + return Promise.resolve({ + provider: "github", + issuer: "https://github.com", + subject: "123", + attributes: { + login: "octocat", + name: null, + avatarUrl: null, + email: null, + }, + }) + }, + }, + callbackUri: "https://hub.test/callback", + consumeAttempt: () => { + order.push("consume") + return Promise.resolve({ + providerRevisionId: "00000000-0000-4000-8000-000000000011", + codeVerifierCiphertext: "encrypted-verifier", + }) + }, + createHandoff, + createOpaqueValue: () => "handoff-secret", + decryptCodeVerifier: () => "pkce-verifier", + getSelectedProviderRevision: () => + Promise.resolve({ + id: "00000000-0000-4000-8000-000000000011", + revision: 1, + configurationDigest: "a".repeat(64), + }), + getRevocationGeneration: () => Promise.resolve(1n), + now: () => new Date("2026-10-30T16:00:00Z"), + resolveOrCreateIdentity: () => { + order.push("identity") + return Promise.resolve({ + externalIdentityId: "00000000-0000-4000-8000-000000000012", + userId: "00000000-0000-4000-8000-000000000013", + }) + }, + writeDenialAudit: vi.fn(), + }, + ) + + expect(order).toEqual(["consume", "provider", "identity", "handoff"]) + expect(result).toMatchObject({ + status: "authentication_proved", + handoffToken: "handoff-secret", + }) + expect(createHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + tokenDigest: createHash("sha256").update("handoff-secret").digest("hex"), + }), + ) + expect(result).not.toHaveProperty("userId") + expect(result).not.toHaveProperty("session") + }) + + it("does not call the provider for replayed or browser-mismatched attempts", async () => { + const completeProvider = vi.fn() + await expect( + completeAuthenticationProof( + { + browserBinding: "browser-binding-value-with-required-length", + code: "authorization-code", + state: "state-value-with-the-required-minimum-length", + }, + { + adapter: { + providerKey: "github", + createAuthorizationUrl: vi.fn(), + completeAuthenticationProof: completeProvider, + }, + callbackUri: "https://hub.test/callback", + consumeAttempt: () => Promise.resolve(null), + createHandoff: vi.fn(), + createOpaqueValue: vi.fn(), + decryptCodeVerifier: vi.fn(), + getSelectedProviderRevision: vi.fn(), + getRevocationGeneration: vi.fn(), + now: () => new Date(), + resolveOrCreateIdentity: vi.fn(), + writeDenialAudit: vi.fn(), + }, + ), + ).rejects.toMatchObject({ code: "attempt_invalid" }) + expect(completeProvider).not.toHaveBeenCalled() + }) + + it("keeps a consumed attempt consumed when the bounded provider request fails", async () => { + const consumeAttempt = vi.fn(() => + Promise.resolve({ + providerRevisionId: "00000000-0000-4000-8000-000000000011", + codeVerifierCiphertext: "encrypted-verifier", + }), + ) + const createHandoff = vi.fn() + + await expect( + completeAuthenticationProof( + { + browserBinding: "browser-binding-value-with-required-length", + code: "authorization-code", + state: "state-value-with-the-required-minimum-length", + }, + { + adapter: { + providerKey: "github", + createAuthorizationUrl: vi.fn(), + completeAuthenticationProof: () => + Promise.reject(new Error("bounded provider failure")), + }, + callbackUri: "https://hub.test/callback", + consumeAttempt, + createHandoff, + createOpaqueValue: vi.fn(), + decryptCodeVerifier: () => "pkce-verifier", + getSelectedProviderRevision: () => + Promise.resolve({ + id: "00000000-0000-4000-8000-000000000011", + revision: 1, + configurationDigest: "a".repeat(64), + }), + getRevocationGeneration: vi.fn(), + now: () => new Date(), + resolveOrCreateIdentity: vi.fn(), + writeDenialAudit: vi.fn(), + }, + ), + ).rejects.toMatchObject({ code: "provider_failed" }) + expect(consumeAttempt).toHaveBeenCalledOnce() + expect(createHandoff).not.toHaveBeenCalled() + }) +}) diff --git a/hub/server/domains/authentication/complete-authentication-proof/index.ts b/hub/server/domains/authentication/complete-authentication-proof/index.ts new file mode 100644 index 00000000..a818d992 --- /dev/null +++ b/hub/server/domains/authentication/complete-authentication-proof/index.ts @@ -0,0 +1,128 @@ +import { z } from "zod" + +import { hashAuthenticationValue } from "../../../utils/authentication/hash-authentication-value/index.ts" +import { AuthenticationProofError } from "../errors.ts" +import { recordAuthenticationDenial } from "../record-authentication-denial/index.ts" +import type { + CompleteAuthenticationProofDependencies, + CompleteAuthenticationProofInput, + CompleteAuthenticationProofResult, +} from "./types.ts" + +const inputSchema = z + .object({ + browserBinding: z.string().min(32).max(200), + code: z.string().min(1).max(1_000), + state: z.string().min(32).max(200), + }) + .strict() +const HANDOFF_LIFETIME_MS = 5 * 60 * 1_000 + +export async function completeAuthenticationProof( + input: CompleteAuthenticationProofInput, + dependencies: CompleteAuthenticationProofDependencies, +): Promise { + const parsed = inputSchema.parse(input) + const now = dependencies.now() + const browserBindingDigest = hashAuthenticationValue(parsed.browserBinding) + const attempt = await dependencies.consumeAttempt({ + stateDigest: hashAuthenticationValue(parsed.state), + browserBindingDigest, + consumedAt: now, + }) + if (!attempt) { + const error = new AuthenticationProofError( + "attempt_invalid", + "The authentication attempt is invalid or has expired.", + ) + await recordAuthenticationDenial(error, () => + dependencies.writeDenialAudit("invalid_or_expired"), + ) + throw error + } + + const selectedBeforeProof = await dependencies.getSelectedProviderRevision() + if (!selectedBeforeProof || selectedBeforeProof.id !== attempt.providerRevisionId) { + const error = new AuthenticationProofError( + "configuration_changed", + "The authentication provider configuration changed during this attempt.", + ) + await recordAuthenticationDenial(error, () => + dependencies.writeDenialAudit("configuration_changed"), + ) + throw error + } + + let evidence + try { + evidence = await dependencies.adapter.completeAuthenticationProof({ + callbackUri: dependencies.callbackUri, + code: parsed.code, + codeVerifier: dependencies.decryptCodeVerifier(attempt.codeVerifierCiphertext), + }) + } catch (error) { + const primaryError = new AuthenticationProofError( + "provider_failed", + "The authentication provider could not prove the external identity.", + { cause: error }, + ) + await recordAuthenticationDenial(primaryError, () => + dependencies.writeDenialAudit("provider_failed"), + ) + throw primaryError + } + if (evidence.provider !== dependencies.adapter.providerKey) { + const error = new AuthenticationProofError( + "provider_failed", + "The authentication provider returned mismatched identity evidence.", + ) + await recordAuthenticationDenial(error, () => dependencies.writeDenialAudit("provider_failed")) + throw error + } + + const selectedAfterProof = await dependencies.getSelectedProviderRevision() + if (!selectedAfterProof || selectedAfterProof.id !== attempt.providerRevisionId) { + const error = new AuthenticationProofError( + "configuration_changed", + "The authentication provider configuration changed during this attempt.", + ) + await recordAuthenticationDenial(error, () => + dependencies.writeDenialAudit("configuration_changed"), + ) + throw error + } + const expectedRevocationGeneration = await dependencies.getRevocationGeneration() + const identity = await dependencies.resolveOrCreateIdentity({ + provider: evidence.provider, + issuer: evidence.issuer, + subject: evidence.subject, + }) + const handoffToken = dependencies.createOpaqueValue() + const expiresAt = new Date(dependencies.now().getTime() + HANDOFF_LIFETIME_MS) + const handoff = await dependencies.createHandoff({ + externalIdentityId: identity.externalIdentityId, + providerRevisionId: attempt.providerRevisionId, + browserBindingDigest, + tokenDigest: hashAuthenticationValue(handoffToken), + displayAttributes: evidence.attributes, + expiresAt, + expectedRevocationGeneration, + }) + if (handoff.status === "restart") { + const error = new AuthenticationProofError( + "configuration_changed", + "Authentication state changed before the admission handoff was created.", + ) + await recordAuthenticationDenial(error, () => + dependencies.writeDenialAudit("configuration_changed"), + ) + throw error + } + + return { + status: "authentication_proved", + handoffToken, + attributes: evidence.attributes, + expiresAt, + } +} diff --git a/hub/server/domains/authentication/complete-authentication-proof/types.ts b/hub/server/domains/authentication/complete-authentication-proof/types.ts new file mode 100644 index 00000000..4c900814 --- /dev/null +++ b/hub/server/domains/authentication/complete-authentication-proof/types.ts @@ -0,0 +1,53 @@ +import type { AuthenticationProviderAdapter, PublicIdentityAttributes } from "../types.ts" +import type { CreateAuthenticationAdmissionHandoffResult } from "../create-admission-handoff/index.ts" +import type { SelectedProviderRevision } from "../start-authentication-proof/types.ts" + +export interface ConsumedAuthorizationAttempt { + codeVerifierCiphertext: string + providerRevisionId: string +} + +export interface CompleteAuthenticationProofDependencies { + adapter: AuthenticationProviderAdapter + callbackUri: string + consumeAttempt(input: { + browserBindingDigest: string + consumedAt: Date + stateDigest: string + }): Promise + createHandoff(input: { + browserBindingDigest: string + displayAttributes: PublicIdentityAttributes + expectedRevocationGeneration: bigint + expiresAt: Date + externalIdentityId: string + providerRevisionId: string + tokenDigest: string + }): Promise + createOpaqueValue(): string + decryptCodeVerifier(value: string): string + getSelectedProviderRevision(): Promise + getRevocationGeneration(): Promise + now(): Date + resolveOrCreateIdentity(input: { + issuer: string + provider: string + subject: string + }): Promise<{ externalIdentityId: string; userId: string }> + writeDenialAudit( + reason: "configuration_changed" | "invalid_or_expired" | "provider_failed", + ): Promise +} + +export interface CompleteAuthenticationProofInput { + browserBinding: string + code: string + state: string +} + +export interface CompleteAuthenticationProofResult { + attributes: PublicIdentityAttributes + expiresAt: Date + handoffToken: string + status: "authentication_proved" +} diff --git a/hub/server/domains/authentication/consume-admission-handoff/index.ts b/hub/server/domains/authentication/consume-admission-handoff/index.ts new file mode 100644 index 00000000..a944a3be --- /dev/null +++ b/hub/server/domains/authentication/consume-admission-handoff/index.ts @@ -0,0 +1,118 @@ +import { randomUUID } from "node:crypto" +import { z } from "zod" + +import { hashAuthenticationValue } from "../../../utils/authentication/hash-authentication-value/index.ts" +import { IdentityTransactionConflictError } from "../../identity/errors.ts" +import { getSelectedProviderRevision } from "../../identity/get-selected-provider-revision/index.ts" +import { runWithBoundedRetries } from "../../identity/run-with-bounded-retries/index.ts" +import { AuthenticationProofError } from "../errors.ts" +import { recordAuthenticationDenial } from "../record-authentication-denial/index.ts" +import type { AdmissionHandoffTransaction } from "../run-admission-handoff-transaction/types.ts" +import type { PublicIdentityAttributes } from "../types.ts" + +type DenialReason = + "browser_mismatch" | "expired" | "provider_revision_changed" | "replayed" | "revoked" + +export type ConsumeAdmissionHandoffResult = + | { + status: "consumed" + externalIdentityId: string + attributes: PublicIdentityAttributes + } + | { status: "denied"; reason: DenialReason } + +const attributesSchema = z + .object({ + avatarUrl: z.url().max(2_000).nullable(), + email: z.email().max(320).nullable(), + login: z.string().min(1).max(100), + name: z.string().min(1).max(200).nullable(), + }) + .strict() + +export function consumeAuthenticationAdmissionHandoff( + input: { + browserBinding: string + handoffToken: string + providerKey: string + }, + dependencies: { + createId?: () => string + runTransaction: AdmissionHandoffTransaction + }, +): Promise { + const createId = dependencies.createId ?? randomUUID + const tokenDigest = hashAuthenticationValue(input.handoffToken) + const browserBindingDigest = hashAuthenticationValue(input.browserBinding) + + return runWithBoundedRetries( + () => + dependencies.runTransaction(async (repositories) => { + const handoff = await repositories.findAdmissionHandoff(tokenDigest) + let reason: DenialReason | null = null + if (!handoff || handoff.consumedAt) reason = "replayed" + else { + await repositories.lockAuthenticationProviderSelection(input.providerKey) + await repositories.lockAuthenticationRevocationGeneration() + const selectedRevision = await getSelectedProviderRevision( + input.providerKey, + repositories, + ) + if (selectedRevision?.id !== handoff.providerRevisionId) { + reason = "provider_revision_changed" + } else if ( + handoff.revocationGeneration !== + (await repositories.getAuthenticationRevocationGeneration()) + ) { + reason = "revoked" + } + } + + if (reason) { + const deniedReason = reason + await recordAuthenticationDenial( + new AuthenticationProofError("attempt_invalid", "The admission handoff was denied."), + () => + repositories.createSecurityAudit({ + id: createId(), + type: "authentication_handoff_denied", + ...(handoff ? { handoffId: handoff.id } : {}), + reason: deniedReason, + }), + ) + return { status: "denied", reason: deniedReason } + } + + const consumption = await repositories.consumeAdmissionHandoff({ + id: handoff!.id, + browserBindingDigest, + }) + if (consumption !== "consumed") { + const deniedReason = consumption + await recordAuthenticationDenial( + new AuthenticationProofError("attempt_invalid", "The admission handoff was denied."), + () => + repositories.createSecurityAudit({ + id: createId(), + type: "authentication_handoff_denied", + handoffId: handoff!.id, + reason: deniedReason, + }), + ) + return { status: "denied", reason: deniedReason } + } + await repositories.createSecurityAudit({ + id: createId(), + type: "authentication_handoff_consumed", + handoffId: handoff!.id, + externalIdentityId: handoff!.externalIdentityId, + }) + return { + status: "consumed", + externalIdentityId: handoff!.externalIdentityId, + attributes: attributesSchema.parse(handoff!.displayAttributes), + } + }), + (error) => error instanceof IdentityTransactionConflictError, + ) +} diff --git a/hub/server/domains/authentication/create-admission-handoff/index.test.ts b/hub/server/domains/authentication/create-admission-handoff/index.test.ts new file mode 100644 index 00000000..ef626c8e --- /dev/null +++ b/hub/server/domains/authentication/create-admission-handoff/index.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest" + +import { IdentityTransactionConflictError } from "../../identity/errors" +import type { AdmissionHandoffRepositories } from "../run-admission-handoff-transaction/types" +import { createAuthenticationAdmissionHandoff } from "." + +function repositories( + overrides: Partial = {}, +): AdmissionHandoffRepositories { + return { + consumeAdmissionHandoff: vi.fn(), + createAdmissionHandoff: vi.fn(), + createSecurityAudit: vi.fn(), + findAdmissionHandoff: vi.fn(), + findAuthenticationProvider: () => + Promise.resolve({ + id: "00000000-0000-4000-8000-000000000001", + key: "github", + selectedRevision: { + id: "00000000-0000-4000-8000-000000000002", + providerId: "00000000-0000-4000-8000-000000000001", + revision: 1, + configurationDigest: "a".repeat(64), + }, + }), + getAuthenticationRevocationGeneration: () => Promise.resolve(1n), + lockAuthenticationProviderSelection: vi.fn(), + lockAuthenticationRevocationGeneration: vi.fn(), + ...overrides, + } +} + +const input = { + browserBindingDigest: "b".repeat(64), + displayAttributes: { login: "octocat", name: null, avatarUrl: null, email: null }, + expectedRevocationGeneration: 1n, + expiresAt: new Date("2026-10-30T17:00:00Z"), + externalIdentityId: "00000000-0000-4000-8000-000000000003", + providerKey: "github", + providerRevisionId: "00000000-0000-4000-8000-000000000002", + tokenDigest: "c".repeat(64), +} + +describe("createAuthenticationAdmissionHandoff()", () => { + it("returns restart without creating or auditing after revision drift", async () => { + const createAdmissionHandoff = vi.fn() + const createSecurityAudit = vi.fn() + const scoped = repositories({ + createAdmissionHandoff, + createSecurityAudit, + findAuthenticationProvider: () => + Promise.resolve({ + id: "00000000-0000-4000-8000-000000000001", + key: "github", + selectedRevision: { + id: "00000000-0000-4000-8000-000000000004", + providerId: "00000000-0000-4000-8000-000000000001", + revision: 2, + configurationDigest: "d".repeat(64), + }, + }), + }) + + await expect( + createAuthenticationAdmissionHandoff(input, { + runTransaction: (operation) => operation(scoped), + }), + ).resolves.toEqual({ status: "restart", reason: "provider_revision_changed" }) + expect(createAdmissionHandoff).not.toHaveBeenCalled() + expect(createSecurityAudit).not.toHaveBeenCalled() + }) + + it("uses the kernel retry boundary and returns restart after conflicts are exhausted", async () => { + vi.useFakeTimers() + const runTransaction = vi.fn(() => Promise.reject(new IdentityTransactionConflictError())) + const result = createAuthenticationAdmissionHandoff(input, { runTransaction }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toEqual({ status: "restart", reason: "conflict" }) + expect(runTransaction).toHaveBeenCalledTimes(3) + }) +}) diff --git a/hub/server/domains/authentication/create-admission-handoff/index.ts b/hub/server/domains/authentication/create-admission-handoff/index.ts new file mode 100644 index 00000000..78b111ac --- /dev/null +++ b/hub/server/domains/authentication/create-admission-handoff/index.ts @@ -0,0 +1,75 @@ +import { randomUUID } from "node:crypto" + +import { IdentityTransactionConflictError } from "../../identity/errors.ts" +import { getSelectedProviderRevision } from "../../identity/get-selected-provider-revision/index.ts" +import { runWithBoundedRetries } from "../../identity/run-with-bounded-retries/index.ts" +import type { PublicIdentityAttributes } from "../types.ts" +import type { AdmissionHandoffTransaction } from "../run-admission-handoff-transaction/types.ts" + +export type CreateAuthenticationAdmissionHandoffResult = + | { status: "created" } + | { + status: "restart" + reason: "conflict" | "provider_revision_changed" | "revoked" + } + +export function createAuthenticationAdmissionHandoff( + input: { + browserBindingDigest: string + displayAttributes: PublicIdentityAttributes + expectedRevocationGeneration: bigint + expiresAt: Date + externalIdentityId: string + providerKey: string + providerRevisionId: string + tokenDigest: string + }, + dependencies: { + createId?: () => string + runTransaction: AdmissionHandoffTransaction + }, +): Promise { + const createId = dependencies.createId ?? randomUUID + return runWithBoundedRetries( + () => + dependencies.runTransaction(async (repositories) => { + // Every handoff operation locks provider selection before global revocation. + await repositories.lockAuthenticationProviderSelection(input.providerKey) + await repositories.lockAuthenticationRevocationGeneration() + const selectedRevision = await getSelectedProviderRevision(input.providerKey, repositories) + if (selectedRevision?.id !== input.providerRevisionId) { + return { status: "restart", reason: "provider_revision_changed" } as const + } + const revocationGeneration = await repositories.getAuthenticationRevocationGeneration() + if (revocationGeneration !== input.expectedRevocationGeneration) { + return { status: "restart", reason: "revoked" } as const + } + + const handoffId = createId() + await repositories.createAdmissionHandoff({ + id: handoffId, + externalIdentityId: input.externalIdentityId, + providerRevisionId: input.providerRevisionId, + tokenDigest: input.tokenDigest, + browserBindingDigest: input.browserBindingDigest, + revocationGeneration, + displayAttributes: input.displayAttributes, + expiresAt: input.expiresAt, + }) + await repositories.createSecurityAudit({ + id: createId(), + type: "authentication_handoff_created", + handoffId, + externalIdentityId: input.externalIdentityId, + providerRevisionId: input.providerRevisionId, + }) + return { status: "created" } as const + }), + (error) => error instanceof IdentityTransactionConflictError, + ).catch((error: unknown) => { + if (error instanceof IdentityTransactionConflictError) { + return { status: "restart", reason: "conflict" } + } + throw error + }) +} diff --git a/hub/server/domains/authentication/create-github-authentication-services/index.ts b/hub/server/domains/authentication/create-github-authentication-services/index.ts new file mode 100644 index 00000000..fd35d922 --- /dev/null +++ b/hub/server/domains/authentication/create-github-authentication-services/index.ts @@ -0,0 +1,144 @@ +import { createHash, randomUUID } from "node:crypto" + +import { createGitHubUserAuthenticationAdapter } from "../../../integrations/github/create-user-authentication-adapter/index.ts" +import { prisma } from "../../../integrations/prisma/index.ts" +import { createAuthorizationAttempt } from "../../../repositories/authorization-attempts/create-authorization-attempt/index.ts" +import { consumeAuthorizationAttempt } from "../../../repositories/authorization-attempts/consume-authorization-attempt/index.ts" +import { deleteExpiredAuthorizationAttempts } from "../../../repositories/authorization-attempts/delete-expired-authorization-attempts/index.ts" +import { deleteExpiredAdmissionHandoffs } from "../../../repositories/admission-handoffs/delete-expired-admission-handoffs/index.ts" +import { findAuthenticationProvider } from "../../../repositories/authentication-providers/find-authentication-provider/index.ts" +import { getAuthenticationRevocationGeneration } from "../../../repositories/authentication-revocation-generations/get-authentication-revocation-generation/index.ts" +import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" +import { reserveAuthenticationRateLimit } from "../../../repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.ts" +import { createOpaqueValue } from "../../../utils/authentication/create-opaque-value/index.ts" +import { decryptAuthenticationValue } from "../../../utils/authentication/decrypt-authentication-value/index.ts" +import { encryptAuthenticationValue } from "../../../utils/authentication/encrypt-authentication-value/index.ts" +import { hashAuthenticationValue } from "../../../utils/authentication/hash-authentication-value/index.ts" +import { getSelectedProviderRevision } from "../../identity/get-selected-provider-revision/index.ts" +import { resolveOrCreateExternalIdentity } from "../../identity/resolve-or-create-external-identity/index.ts" +import { runIdentityCreationTransaction } from "../../identity/run-identity-creation-transaction/index.ts" +import { runProviderRevisionTransaction } from "../../identity/run-provider-revision-transaction/index.ts" +import { validateAndPersistProviderRevision } from "../../identity/validate-and-persist-provider-revision/index.ts" +import { completeAuthenticationProof } from "../complete-authentication-proof/index.ts" +import { createAuthenticationAdmissionHandoff } from "../create-admission-handoff/index.ts" +import { runAdmissionHandoffTransaction } from "../run-admission-handoff-transaction/index.ts" +import { recordAuthenticationDenial } from "../record-authentication-denial/index.ts" +import { startAuthenticationProof } from "../start-authentication-proof/index.ts" +import type { GitHubAuthenticationServices } from "../types.ts" +import type { GitHubAuthenticationConfiguration } from "./types.ts" + +export async function createGitHubAuthenticationServices( + configuration: GitHubAuthenticationConfiguration, +): Promise { + const adapter = createGitHubUserAuthenticationAdapter({ + clientSecret: configuration.clientSecret, + publicConfiguration: configuration.publicConfiguration, + }) + await validateAndPersistProviderRevision( + { + providerKey: adapter.providerKey, + revision: configuration.revision, + configuration: configuration.publicConfiguration, + }, + { + createId: randomUUID, + runTransaction: (operation) => runProviderRevisionTransaction(prisma, operation), + validateConfiguration: () => + Promise.resolve({ + canonicalPublicConfiguration: adapter.canonicalPublicConfiguration, + }), + writeAudit: (event) => createSecurityAudit(prisma, event), + }, + ) + const configurationDigest = createHash("sha256") + .update(adapter.canonicalPublicConfiguration) + .digest("hex") + function getRevision() { + return getSelectedProviderRevision(adapter.providerKey, { + findAuthenticationProvider: (key) => findAuthenticationProvider(prisma, key), + }) + } + function now() { + return new Date() + } + + return { + appOrigin: configuration.appOrigin, + secureCookies: configuration.secureCookies, + now, + reserveRateLimit: ({ operation, requestIp }) => + reserveAuthenticationRateLimit(prisma, { + id: randomUUID(), + keyDigest: hashAuthenticationValue(requestIp), + limit: operation === "start" ? 10 : 20, + scope: operation === "start" ? "github_start" : "github_callback", + windowSeconds: 10 * 60, + }), + recordRateLimitDenial: (_operation, primaryError) => + recordAuthenticationDenial(primaryError, () => + createSecurityAudit(prisma, { + id: randomUUID(), + type: "authentication_attempt_denied", + provider: adapter.providerKey, + reason: "rate_limited", + }), + ), + start: (input) => + startAuthenticationProof(input, { + adapter, + callbackUri: adapter.callbackUri, + configurationDigest, + expectedRevision: configuration.revision, + createAttempt: (attempt) => createAuthorizationAttempt(prisma, attempt), + createId: randomUUID, + createOpaqueValue, + deleteExpiredArtifacts: async (date) => { + await deleteExpiredAuthorizationAttempts(prisma, date) + await deleteExpiredAdmissionHandoffs(prisma, date) + }, + encryptCodeVerifier: (value) => + encryptAuthenticationValue(value, configuration.encryptionKey), + getSelectedProviderRevision: getRevision, + now, + writeDenialAudit: (reason) => + createSecurityAudit(prisma, { + id: randomUUID(), + type: "authentication_attempt_denied", + provider: adapter.providerKey, + reason, + }), + }), + complete: (input) => + completeAuthenticationProof(input, { + adapter, + callbackUri: adapter.callbackUri, + consumeAttempt: (attempt) => consumeAuthorizationAttempt(prisma, attempt), + createHandoff: (handoff) => + createAuthenticationAdmissionHandoff( + { ...handoff, providerKey: adapter.providerKey }, + { + runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation), + }, + ), + createOpaqueValue, + decryptCodeVerifier: (value) => + decryptAuthenticationValue(value, configuration.encryptionKey), + getSelectedProviderRevision: getRevision, + getRevocationGeneration: () => getAuthenticationRevocationGeneration(prisma), + now, + resolveOrCreateIdentity: (identity) => + resolveOrCreateExternalIdentity(identity, { + createId: randomUUID, + runTransaction: (operation) => runIdentityCreationTransaction(prisma, operation), + writeAudit: (event) => createSecurityAudit(prisma, event), + }), + writeDenialAudit: (reason) => + createSecurityAudit(prisma, { + id: randomUUID(), + type: "authentication_attempt_denied", + provider: adapter.providerKey, + reason, + }), + }), + } +} diff --git a/hub/server/domains/authentication/create-github-authentication-services/types.ts b/hub/server/domains/authentication/create-github-authentication-services/types.ts new file mode 100644 index 00000000..c2d1d916 --- /dev/null +++ b/hub/server/domains/authentication/create-github-authentication-services/types.ts @@ -0,0 +1,14 @@ +export interface GitHubAuthenticationConfiguration { + appOrigin: string + clientSecret: string + encryptionKey: Buffer + publicConfiguration: { + authorizationEndpoint: string + callbackUri: string + clientId: string + tokenEndpoint: string + userEndpoint: string + } + revision: number + secureCookies: boolean +} diff --git a/hub/server/domains/authentication/errors.ts b/hub/server/domains/authentication/errors.ts new file mode 100644 index 00000000..bc95de7d --- /dev/null +++ b/hub/server/domains/authentication/errors.ts @@ -0,0 +1,9 @@ +export class AuthenticationProofError extends Error { + readonly code: "attempt_invalid" | "configuration_changed" | "provider_failed" | "rate_limited" + + constructor(code: AuthenticationProofError["code"], message: string, options?: ErrorOptions) { + super(message, options) + this.name = "AuthenticationProofError" + this.code = code + } +} diff --git a/hub/server/domains/authentication/read-github-authentication-configuration/index.test.ts b/hub/server/domains/authentication/read-github-authentication-configuration/index.test.ts new file mode 100644 index 00000000..34136954 --- /dev/null +++ b/hub/server/domains/authentication/read-github-authentication-configuration/index.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest" + +import { readGitHubAuthenticationConfiguration } from "." + +const configured = { + AUTHENTICATION_PROVIDER: "github", + HUB_APP_ORIGIN: "https://hub.example.test", + AUTHENTICATION_SECURE_COOKIES: "true", + AUTHENTICATION_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64url"), + GITHUB_AUTHENTICATION_REVISION: "2", + GITHUB_CLIENT_ID: "client-id", + GITHUB_CLIENT_SECRET: "client-secret", + GITHUB_CALLBACK_URI: "https://hub.example.test/api/auth/github/callback", + GITHUB_AUTHORIZATION_ENDPOINT: "https://github.com/login/oauth/authorize", + GITHUB_TOKEN_ENDPOINT: "https://github.com/login/oauth/access_token", + GITHUB_USER_ENDPOINT: "https://api.github.com/user", +} + +describe("readGitHubAuthenticationConfiguration()", () => { + it("keeps authentication disabled until a provider is explicitly selected", () => { + expect(readGitHubAuthenticationConfiguration({})).toBeNull() + }) + + it("returns the complete explicit GitHub revision without fallback", () => { + expect(readGitHubAuthenticationConfiguration(configured)).toMatchObject({ + appOrigin: "https://hub.example.test", + revision: 2, + secureCookies: true, + publicConfiguration: { + clientId: "client-id", + callbackUri: "https://hub.example.test/api/auth/github/callback", + }, + }) + }) + + it("rejects partial, unsupported, or invalid-key configuration", () => { + expect(() => + readGitHubAuthenticationConfiguration({ + ...configured, + AUTHENTICATION_PROVIDER: "entra", + }), + ).toThrow() + expect(() => + readGitHubAuthenticationConfiguration({ + ...configured, + GITHUB_CLIENT_SECRET: undefined, + }), + ).toThrow() + expect(() => + readGitHubAuthenticationConfiguration({ + ...configured, + AUTHENTICATION_ENCRYPTION_KEY: "too-short", + }), + ).toThrow("32 bytes") + }) +}) diff --git a/hub/server/domains/authentication/read-github-authentication-configuration/index.ts b/hub/server/domains/authentication/read-github-authentication-configuration/index.ts new file mode 100644 index 00000000..97e2bb46 --- /dev/null +++ b/hub/server/domains/authentication/read-github-authentication-configuration/index.ts @@ -0,0 +1,56 @@ +import { z } from "zod" + +import type { GitHubAuthenticationConfiguration } from "../create-github-authentication-services/types.ts" + +const configurationSchema = z + .object({ + appOrigin: z.url(), + authorizationEndpoint: z.url(), + callbackUri: z.url(), + clientId: z.string().min(1).max(200), + clientSecret: z.string().min(1).max(2_000), + encryptionKey: z.string().min(1).max(100), + provider: z.literal("github"), + revision: z.coerce.number().int().positive(), + secureCookies: z.enum(["true", "false"]), + tokenEndpoint: z.url(), + userEndpoint: z.url(), + }) + .strict() + +export function readGitHubAuthenticationConfiguration( + environment: NodeJS.ProcessEnv, +): GitHubAuthenticationConfiguration | null { + if (!environment.AUTHENTICATION_PROVIDER) return null + const parsed = configurationSchema.parse({ + appOrigin: environment.HUB_APP_ORIGIN, + authorizationEndpoint: environment.GITHUB_AUTHORIZATION_ENDPOINT, + callbackUri: environment.GITHUB_CALLBACK_URI, + clientId: environment.GITHUB_CLIENT_ID, + clientSecret: environment.GITHUB_CLIENT_SECRET, + encryptionKey: environment.AUTHENTICATION_ENCRYPTION_KEY, + provider: environment.AUTHENTICATION_PROVIDER, + revision: environment.GITHUB_AUTHENTICATION_REVISION, + secureCookies: environment.AUTHENTICATION_SECURE_COOKIES, + tokenEndpoint: environment.GITHUB_TOKEN_ENDPOINT, + userEndpoint: environment.GITHUB_USER_ENDPOINT, + }) + const encryptionKey = Buffer.from(parsed.encryptionKey, "base64url") + if (encryptionKey.length !== 32) { + throw new Error("AUTHENTICATION_ENCRYPTION_KEY must decode to 32 bytes.") + } + return { + appOrigin: parsed.appOrigin, + clientSecret: parsed.clientSecret, + encryptionKey, + revision: parsed.revision, + secureCookies: parsed.secureCookies === "true", + publicConfiguration: { + authorizationEndpoint: parsed.authorizationEndpoint, + callbackUri: parsed.callbackUri, + clientId: parsed.clientId, + tokenEndpoint: parsed.tokenEndpoint, + userEndpoint: parsed.userEndpoint, + }, + } +} diff --git a/hub/server/domains/authentication/record-authentication-denial/index.test.ts b/hub/server/domains/authentication/record-authentication-denial/index.test.ts new file mode 100644 index 00000000..2cd9cf3d --- /dev/null +++ b/hub/server/domains/authentication/record-authentication-denial/index.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest" + +import { AuthenticationProofError } from "../errors" +import { recordAuthenticationDenial } from "." + +describe("recordAuthenticationDenial()", () => { + it("records one audit and preserves the primary typed failure", async () => { + const primary = new AuthenticationProofError("provider_failed", "Safe failure.") + const writeAudit = vi.fn(() => Promise.resolve()) + + await expect( + recordAuthenticationDenial(primary, writeAudit).then(() => { + throw primary + }), + ).rejects.toBe(primary) + expect(writeAudit).toHaveBeenCalledOnce() + }) + + it("retains both the typed primary failure and audit failure", async () => { + const primary = new AuthenticationProofError("provider_failed", "Safe failure.") + const audit = new Error("audit unavailable") + const writeAudit = vi.fn(() => Promise.reject(audit)) + + const error = await recordAuthenticationDenial(primary, writeAudit).catch( + (caught: unknown) => caught, + ) + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([primary, audit]) + expect(writeAudit).toHaveBeenCalledOnce() + }) +}) diff --git a/hub/server/domains/authentication/record-authentication-denial/index.ts b/hub/server/domains/authentication/record-authentication-denial/index.ts new file mode 100644 index 00000000..7b9b6537 --- /dev/null +++ b/hub/server/domains/authentication/record-authentication-denial/index.ts @@ -0,0 +1,14 @@ +export async function recordAuthenticationDenial( + primaryError: Error, + writeAudit: () => Promise, +): Promise { + try { + await writeAudit() + } catch (auditError) { + throw new AggregateError( + [primaryError, auditError], + "The authentication denial and its audit could not both be recorded.", + { cause: auditError }, + ) + } +} diff --git a/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts b/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts new file mode 100644 index 00000000..7e466101 --- /dev/null +++ b/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts @@ -0,0 +1,53 @@ +import { Prisma } from "../../../generated/prisma/client.ts" +import { consumeAdmissionHandoff } from "../../../repositories/admission-handoffs/consume-admission-handoff/index.ts" +import { createAdmissionHandoff } from "../../../repositories/admission-handoffs/create-admission-handoff/index.ts" +import { findAdmissionHandoff } from "../../../repositories/admission-handoffs/find-admission-handoff/index.ts" +import { findAuthenticationProvider } from "../../../repositories/authentication-providers/find-authentication-provider/index.ts" +import { lockAuthenticationProviderSelection } from "../../../repositories/authentication-providers/lock-authentication-provider-selection/index.ts" +import { getAuthenticationRevocationGeneration } from "../../../repositories/authentication-revocation-generations/get-authentication-revocation-generation/index.ts" +import { lockAuthenticationRevocationGeneration } from "../../../repositories/authentication-revocation-generations/lock-authentication-revocation-generation/index.ts" +import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" +import { classifyPrismaTransactionError } from "../../../utils/classify-prisma-transaction-error/index.ts" +import { IdentityTransactionConflictError } from "../../identity/errors.ts" +import type { + AdmissionHandoffDatabase, + AdmissionHandoffRepositories, + AdmissionHandoffTransactionFunctions, +} from "./types.ts" + +export function runAdmissionHandoffTransaction( + database: AdmissionHandoffDatabase, + operation: (repositories: AdmissionHandoffRepositories) => Promise, + functions: AdmissionHandoffTransactionFunctions = { + lockProvider: lockAuthenticationProviderSelection, + lockRevocation: lockAuthenticationRevocationGeneration, + }, +): Promise { + return database + .$transaction( + (transaction) => + operation({ + consumeAdmissionHandoff: (input) => consumeAdmissionHandoff(transaction, input), + createAdmissionHandoff: (input) => createAdmissionHandoff(transaction, input), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + findAdmissionHandoff: (tokenDigest) => findAdmissionHandoff(transaction, tokenDigest), + findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), + getAuthenticationRevocationGeneration: () => + getAuthenticationRevocationGeneration(transaction), + lockAuthenticationProviderSelection: (key) => functions.lockProvider(transaction, key), + lockAuthenticationRevocationGeneration: () => functions.lockRevocation(transaction), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + .catch((error: unknown) => { + if ( + classifyPrismaTransactionError(error) === "retryable" || + (error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2010" && + (String(error.meta?.code) === "40001" || error.message.includes("40001"))) + ) { + throw new IdentityTransactionConflictError({ cause: error }) + } + throw error + }) +} diff --git a/hub/server/domains/authentication/run-admission-handoff-transaction/types.ts b/hub/server/domains/authentication/run-admission-handoff-transaction/types.ts new file mode 100644 index 00000000..77864bfc --- /dev/null +++ b/hub/server/domains/authentication/run-admission-handoff-transaction/types.ts @@ -0,0 +1,46 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { CreateAdmissionHandoffInput } from "../../../repositories/admission-handoffs/create-admission-handoff/types.ts" +import type { ConsumeAdmissionHandoffResult } from "../../../repositories/admission-handoffs/consume-admission-handoff/types.ts" +import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { AuthenticationProviderRecord } from "../../identity/run-provider-revision-transaction/types.ts" + +export interface AdmissionHandoffRecord { + browserBindingDigest: string + consumedAt: Date | null + displayAttributes: Prisma.JsonValue + expiresAt: Date + externalIdentityId: string + id: string + providerRevisionId: string + revocationGeneration: bigint +} + +export interface AdmissionHandoffRepositories { + consumeAdmissionHandoff(input: { + browserBindingDigest: string + id: string + }): Promise + createAdmissionHandoff(input: CreateAdmissionHandoffInput): Promise + createSecurityAudit(input: CreateSecurityAuditEvent): Promise + findAdmissionHandoff(tokenDigest: string): Promise + findAuthenticationProvider(key: string): Promise + getAuthenticationRevocationGeneration(): Promise + lockAuthenticationProviderSelection(providerKey: string): Promise + lockAuthenticationRevocationGeneration(): Promise +} + +export type AdmissionHandoffTransaction = ( + operation: (repositories: AdmissionHandoffRepositories) => Promise, +) => Promise + +export interface AdmissionHandoffDatabase { + $transaction( + operation: (transaction: Prisma.TransactionClient) => Promise, + options: { isolationLevel: Prisma.TransactionIsolationLevel }, + ): Promise +} + +export interface AdmissionHandoffTransactionFunctions { + lockProvider(database: Prisma.TransactionClient, providerKey: string): Promise + lockRevocation(database: Prisma.TransactionClient): Promise +} diff --git a/hub/server/domains/authentication/start-authentication-proof/index.test.ts b/hub/server/domains/authentication/start-authentication-proof/index.test.ts new file mode 100644 index 00000000..ea6e8696 --- /dev/null +++ b/hub/server/domains/authentication/start-authentication-proof/index.test.ts @@ -0,0 +1,131 @@ +import { createHash } from "node:crypto" +import { describe, expect, it, vi } from "vitest" + +import { startAuthenticationProof } from "." + +describe("startAuthenticationProof()", () => { + it("persists only digests and encrypted PKCE material before returning the provider URL", async () => { + const createAttempt = vi.fn(() => Promise.resolve()) + const createAuthorizationUrl = vi.fn( + ({ codeChallenge, state }: { codeChallenge: string; state: string }) => + new URL(`https://provider.test/authorize?state=${state}&challenge=${codeChallenge}`), + ) + const opaqueValues = ["state-secret", "browser-secret", "verifier-secret"][Symbol.iterator]() + + const result = await startAuthenticationProof( + { requestIpDigest: createHash("sha256").update("192.0.2.10").digest("hex") }, + { + adapter: { + providerKey: "github", + createAuthorizationUrl, + completeAuthenticationProof: vi.fn(), + }, + callbackUri: "https://hub.test/callback", + configurationDigest: "a".repeat(64), + expectedRevision: 1, + createAttempt, + createId: () => "00000000-0000-4000-8000-000000000010", + createOpaqueValue: () => opaqueValues.next().value!, + deleteExpiredArtifacts: vi.fn(), + encryptCodeVerifier: (value) => `encrypted:${value.length}`, + getSelectedProviderRevision: () => + Promise.resolve({ + id: "00000000-0000-4000-8000-000000000011", + revision: 1, + configurationDigest: "a".repeat(64), + }), + now: () => new Date("2026-10-30T16:00:00Z"), + writeDenialAudit: vi.fn(), + }, + ) + + expect(result.browserBinding).toBe("browser-secret") + expect(createAttempt).toHaveBeenCalledWith({ + id: "00000000-0000-4000-8000-000000000010", + providerRevisionId: "00000000-0000-4000-8000-000000000011", + stateDigest: createHash("sha256").update("state-secret").digest("hex"), + browserBindingDigest: createHash("sha256").update("browser-secret").digest("hex"), + requestIpDigest: createHash("sha256").update("192.0.2.10").digest("hex"), + codeChallenge: createHash("sha256").update("verifier-secret").digest("base64url"), + codeVerifierCiphertext: "encrypted:15", + expiresAt: new Date("2026-10-30T16:10:00Z"), + }) + expect(JSON.stringify(createAttempt.mock.calls)).not.toMatch( + /state-secret|browser-secret|verifier-secret/, + ) + }) + + it("fails closed before creating an attempt when the provider revision drifted", async () => { + const createAttempt = vi.fn() + const dependencies = { + adapter: { + providerKey: "github", + createAuthorizationUrl: vi.fn(), + completeAuthenticationProof: vi.fn(), + }, + callbackUri: "https://hub.test/callback", + configurationDigest: "a".repeat(64), + expectedRevision: 2, + createAttempt, + createId: vi.fn(), + createOpaqueValue: vi.fn(), + deleteExpiredArtifacts: vi.fn(), + encryptCodeVerifier: vi.fn(), + getSelectedProviderRevision: () => + Promise.resolve({ + id: "00000000-0000-4000-8000-000000000011", + revision: 1, + configurationDigest: "a".repeat(64), + }), + now: () => new Date("2026-10-30T16:00:00Z"), + writeDenialAudit: vi.fn(), + } + + await expect( + startAuthenticationProof( + { requestIpDigest: createHash("sha256").update("192.0.2.10").digest("hex") }, + dependencies, + ), + ).rejects.toMatchObject({ code: "configuration_changed" }) + expect(createAttempt).not.toHaveBeenCalled() + }) + + it("preserves revision denial and audit failure without creating state", async () => { + const createAttempt = vi.fn() + const auditFailure = new Error("audit unavailable") + + const error = await startAuthenticationProof( + { requestIpDigest: "a".repeat(64) }, + { + adapter: { + providerKey: "github", + createAuthorizationUrl: vi.fn(), + completeAuthenticationProof: vi.fn(), + }, + callbackUri: "https://hub.test/callback", + configurationDigest: "a".repeat(64), + expectedRevision: 2, + createAttempt, + createId: vi.fn(), + createOpaqueValue: vi.fn(), + deleteExpiredArtifacts: vi.fn(), + encryptCodeVerifier: vi.fn(), + getSelectedProviderRevision: () => + Promise.resolve({ + id: "00000000-0000-4000-8000-000000000011", + revision: 1, + configurationDigest: "a".repeat(64), + }), + now: () => new Date(), + writeDenialAudit: () => Promise.reject(auditFailure), + }, + ).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ code: "configuration_changed" }), + auditFailure, + ]) + expect(createAttempt).not.toHaveBeenCalled() + }) +}) diff --git a/hub/server/domains/authentication/start-authentication-proof/index.ts b/hub/server/domains/authentication/start-authentication-proof/index.ts new file mode 100644 index 00000000..09b609a6 --- /dev/null +++ b/hub/server/domains/authentication/start-authentication-proof/index.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto" +import { z } from "zod" + +import { hashAuthenticationValue } from "../../../utils/authentication/hash-authentication-value/index.ts" +import { AuthenticationProofError } from "../errors.ts" +import { recordAuthenticationDenial } from "../record-authentication-denial/index.ts" +import type { + StartAuthenticationProofDependencies, + StartAuthenticationProofInput, + StartAuthenticationProofResult, +} from "./types.ts" + +const inputSchema = z.object({ requestIpDigest: z.string().regex(/^[0-9a-f]{64}$/) }).strict() +const ATTEMPT_LIFETIME_MS = 10 * 60 * 1_000 + +export async function startAuthenticationProof( + input: StartAuthenticationProofInput, + dependencies: StartAuthenticationProofDependencies, +): Promise { + const parsed = inputSchema.parse(input) + const now = dependencies.now() + await dependencies.deleteExpiredArtifacts(now) + const selectedRevision = await dependencies.getSelectedProviderRevision() + if ( + !selectedRevision || + selectedRevision.revision !== dependencies.expectedRevision || + selectedRevision.configurationDigest !== dependencies.configurationDigest + ) { + const error = new AuthenticationProofError( + "configuration_changed", + "The selected authentication provider configuration is unavailable.", + ) + await recordAuthenticationDenial(error, () => + dependencies.writeDenialAudit("configuration_changed"), + ) + throw error + } + + const state = dependencies.createOpaqueValue() + const browserBinding = dependencies.createOpaqueValue() + const codeVerifier = dependencies.createOpaqueValue() + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url") + const expiresAt = new Date(now.getTime() + ATTEMPT_LIFETIME_MS) + + await dependencies.createAttempt({ + id: dependencies.createId(), + providerRevisionId: selectedRevision.id, + stateDigest: hashAuthenticationValue(state), + browserBindingDigest: hashAuthenticationValue(browserBinding), + codeChallenge, + codeVerifierCiphertext: dependencies.encryptCodeVerifier(codeVerifier), + requestIpDigest: parsed.requestIpDigest, + expiresAt, + }) + + return { + authorizationUrl: dependencies.adapter + .createAuthorizationUrl({ + callbackUri: dependencies.callbackUri, + codeChallenge, + state, + }) + .toString(), + browserBinding, + expiresAt, + } +} diff --git a/hub/server/domains/authentication/start-authentication-proof/types.ts b/hub/server/domains/authentication/start-authentication-proof/types.ts new file mode 100644 index 00000000..c50831e1 --- /dev/null +++ b/hub/server/domains/authentication/start-authentication-proof/types.ts @@ -0,0 +1,41 @@ +import type { AuthenticationProviderAdapter } from "../types.ts" + +export interface SelectedProviderRevision { + configurationDigest: string + id: string + revision: number +} + +export interface StartAuthenticationProofDependencies { + adapter: AuthenticationProviderAdapter + callbackUri: string + configurationDigest: string + expectedRevision: number + createAttempt(input: { + browserBindingDigest: string + codeChallenge: string + codeVerifierCiphertext: string + expiresAt: Date + id: string + providerRevisionId: string + requestIpDigest: string + stateDigest: string + }): Promise + createId(): string + createOpaqueValue(): string + deleteExpiredArtifacts(now: Date): Promise + encryptCodeVerifier(value: string): string + getSelectedProviderRevision(): Promise + now(): Date + writeDenialAudit(reason: "configuration_changed" | "rate_limited"): Promise +} + +export interface StartAuthenticationProofInput { + requestIpDigest: string +} + +export interface StartAuthenticationProofResult { + authorizationUrl: string + browserBinding: string + expiresAt: Date +} diff --git a/hub/server/domains/authentication/types.ts b/hub/server/domains/authentication/types.ts new file mode 100644 index 00000000..d0fda112 --- /dev/null +++ b/hub/server/domains/authentication/types.ts @@ -0,0 +1,42 @@ +export interface PublicIdentityAttributes { + avatarUrl: string | null + email: string | null + login: string + name: string | null +} + +export interface ImmutableIdentityEvidence { + attributes: PublicIdentityAttributes + issuer: string + provider: string + subject: string +} + +export interface AuthenticationProviderAdapter { + readonly providerKey: string + createAuthorizationUrl(input: { callbackUri: string; codeChallenge: string; state: string }): URL + completeAuthenticationProof(input: { + callbackUri: string + code: string + codeVerifier: string + }): Promise +} + +export interface GitHubAuthenticationServices { + appOrigin: string + complete(input: { browserBinding: string; code: string; state: string }): Promise<{ + attributes: PublicIdentityAttributes + expiresAt: Date + handoffToken: string + status: "authentication_proved" + }> + now(): Date + recordRateLimitDenial(operation: "callback" | "start", primaryError: Error): Promise + reserveRateLimit(input: { operation: "callback" | "start"; requestIp: string }): Promise + secureCookies: boolean + start(input: { requestIpDigest: string }): Promise<{ + authorizationUrl: string + browserBinding: string + expiresAt: Date + }> +} diff --git a/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts b/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts new file mode 100644 index 00000000..b3501e2f --- /dev/null +++ b/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest" + +import { readTrustedProxyHops } from "." + +describe("readTrustedProxyHops()", () => { + it("ignores forwarded addresses by default and accepts only a bounded exact hop count", () => { + expect(readTrustedProxyHops(undefined)).toBe(0) + expect(readTrustedProxyHops("2")).toBe(2) + expect(() => readTrustedProxyHops("-1")).toThrow() + expect(() => readTrustedProxyHops("11")).toThrow() + expect(() => readTrustedProxyHops("all")).toThrow() + }) +}) diff --git a/hub/server/integrations/express/read-trusted-proxy-hops/index.ts b/hub/server/integrations/express/read-trusted-proxy-hops/index.ts new file mode 100644 index 00000000..0f999eb1 --- /dev/null +++ b/hub/server/integrations/express/read-trusted-proxy-hops/index.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +const trustedProxyHopsSchema = z.coerce.number().int().min(0).max(10) + +export function readTrustedProxyHops(value: string | undefined): number { + if (value === undefined || value === "") return 0 + return trustedProxyHopsSchema.parse(value) +} diff --git a/hub/server/integrations/github/create-user-authentication-adapter/index.test.ts b/hub/server/integrations/github/create-user-authentication-adapter/index.test.ts new file mode 100644 index 00000000..9c2f57f4 --- /dev/null +++ b/hub/server/integrations/github/create-user-authentication-adapter/index.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest" + +import { createGitHubUserAuthenticationAdapter } from "." +import { GitHubProviderRequestError } from "../errors" + +const publicConfiguration = { + authorizationEndpoint: "https://github.com/login/oauth/authorize", + callbackUri: "https://hub.example.test/api/auth/github/callback", + clientId: "client-id", + tokenEndpoint: "https://github.com/login/oauth/access_token", + userEndpoint: "https://api.github.com/user", +} + +describe("createGitHubUserAuthenticationAdapter()", () => { + it("uses exact OAuth arguments and binds immutable identity to the numeric GitHub user ID", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "provider-token", token_type: "bearer" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 123456, + login: "octocat", + name: "The Octocat", + avatar_url: "https://avatars.example.test/octocat", + email: "octocat@example.test", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + const adapter = createGitHubUserAuthenticationAdapter({ + clientSecret: "client-secret", + publicConfiguration, + fetch: request, + }) + + const authorizationUrl = adapter.createAuthorizationUrl({ + callbackUri: publicConfiguration.callbackUri, + codeChallenge: "c".repeat(43), + state: "state-value", + }) + expect(Object.fromEntries(authorizationUrl.searchParams)).toEqual({ + client_id: "client-id", + redirect_uri: publicConfiguration.callbackUri, + state: "state-value", + code_challenge: "c".repeat(43), + code_challenge_method: "S256", + }) + + const evidence = await adapter.completeAuthenticationProof({ + callbackUri: publicConfiguration.callbackUri, + code: "authorization-code", + codeVerifier: "verifier-value", + }) + + expect(evidence).toEqual({ + provider: "github", + issuer: "https://github.com", + subject: "123456", + attributes: { + login: "octocat", + name: "The Octocat", + avatarUrl: "https://avatars.example.test/octocat", + email: "octocat@example.test", + }, + }) + const tokenRequest = request.mock.calls[0]! + expect(tokenRequest[0]).toBe(publicConfiguration.tokenEndpoint) + expect(tokenRequest[1]).toMatchObject({ + method: "POST", + redirect: "error", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + }) + expect(Object.fromEntries(tokenRequest[1]!.body as URLSearchParams)).toEqual({ + client_id: "client-id", + client_secret: "client-secret", + code: "authorization-code", + code_verifier: "verifier-value", + redirect_uri: publicConfiguration.callbackUri, + }) + expect(request.mock.calls[1]?.[1]).toMatchObject({ + redirect: "error", + headers: { + accept: "application/vnd.github+json", + authorization: "Bearer provider-token", + "x-github-api-version": "2022-11-28", + }, + }) + expect(JSON.stringify(evidence)).not.toMatch( + /provider-token|client-secret|authorization-code|verifier-value/, + ) + }) + + it("rejects unknown public configuration and callback drift", () => { + expect(() => + createGitHubUserAuthenticationAdapter({ + clientSecret: "secret", + publicConfiguration: { ...publicConfiguration, scope: "user:email" }, + }), + ).toThrow() + const adapter = createGitHubUserAuthenticationAdapter({ + clientSecret: "secret", + publicConfiguration, + }) + expect(() => + adapter.createAuthorizationUrl({ + callbackUri: "https://attacker.example.test/callback", + codeChallenge: "c".repeat(43), + state: "state", + }), + ).toThrow("callback URI") + }) + + it.each(["token exchange", "current user"] as const)( + "bounds a stalled %s request with a typed provider failure", + async (stage) => { + const aborted = AbortSignal.abort(new DOMException("timed out", "TimeoutError")) + vi.spyOn(AbortSignal, "timeout").mockReturnValue(aborted) + const request = vi.fn() + if (stage === "current user") { + request.mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "provider-token", token_type: "bearer" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + } + request.mockImplementationOnce((_url, init) => { + expect(init?.signal).toBe(aborted) + return Promise.reject(new Error("request aborted", { cause: init?.signal?.reason })) + }) + const adapter = createGitHubUserAuthenticationAdapter({ + clientSecret: "client-secret", + publicConfiguration, + fetch: request, + }) + + await expect( + adapter.completeAuthenticationProof({ + callbackUri: publicConfiguration.callbackUri, + code: "authorization-code", + codeVerifier: "verifier-value", + }), + ).rejects.toBeInstanceOf(GitHubProviderRequestError) + expect(request).toHaveBeenCalledTimes(stage === "token exchange" ? 1 : 2) + }, + ) +}) diff --git a/hub/server/integrations/github/create-user-authentication-adapter/index.ts b/hub/server/integrations/github/create-user-authentication-adapter/index.ts new file mode 100644 index 00000000..469d2892 --- /dev/null +++ b/hub/server/integrations/github/create-user-authentication-adapter/index.ts @@ -0,0 +1,131 @@ +import { z } from "zod" + +import { GitHubProviderRequestError } from "../errors.ts" +import type { + CreateGitHubUserAuthenticationAdapterInput, + GitHubUserAuthenticationAdapter, +} from "./types.ts" + +const publicConfigurationSchema = z + .object({ + authorizationEndpoint: z.url().max(500), + callbackUri: z.url().max(500), + clientId: z.string().min(1).max(200), + tokenEndpoint: z.url().max(500), + userEndpoint: z.url().max(500), + }) + .strict() +const tokenResponseSchema = z.looseObject({ + access_token: z.string().min(1).max(2_000), + token_type: z.literal("bearer"), +}) +const userResponseSchema = z.looseObject({ + avatar_url: z.url().max(2_000).nullable(), + email: z.email().max(320).nullable(), + id: z.number().int().positive(), + login: z.string().min(1).max(100), + name: z.string().min(1).max(200).nullable(), +}) +const providerRequestTimeoutMs = 10_000 + +export function createGitHubUserAuthenticationAdapter( + input: CreateGitHubUserAuthenticationAdapterInput, +): GitHubUserAuthenticationAdapter { + const configuration = publicConfigurationSchema.parse(input.publicConfiguration) + for (const [field, value] of [ + ["authorization endpoint", configuration.authorizationEndpoint], + ["token endpoint", configuration.tokenEndpoint], + ["user endpoint", configuration.userEndpoint], + ] as const) { + if (new URL(value).protocol !== "https:") { + throw new Error(`GitHub ${field} must use HTTPS.`) + } + } + if (!input.clientSecret || input.clientSecret.length > 2_000) { + throw new Error("GitHub client secret must be present and bounded.") + } + const request = input.fetch ?? globalThis.fetch + + return { + providerKey: "github", + callbackUri: configuration.callbackUri, + canonicalPublicConfiguration: JSON.stringify({ + authorizationEndpoint: configuration.authorizationEndpoint, + callbackUri: configuration.callbackUri, + clientId: configuration.clientId, + tokenEndpoint: configuration.tokenEndpoint, + userEndpoint: configuration.userEndpoint, + }), + createAuthorizationUrl({ callbackUri, codeChallenge, state }) { + if (callbackUri !== configuration.callbackUri) { + throw new Error("GitHub callback URI does not match the configured callback URI.") + } + const url = new URL(configuration.authorizationEndpoint) + url.search = new URLSearchParams({ + client_id: configuration.clientId, + redirect_uri: callbackUri, + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }).toString() + return url + }, + async completeAuthenticationProof({ callbackUri, code, codeVerifier }) { + if (callbackUri !== configuration.callbackUri) { + throw new Error("GitHub callback URI does not match the configured callback URI.") + } + let tokenResponse: Response + try { + tokenResponse = await request(configuration.tokenEndpoint, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: configuration.clientId, + client_secret: input.clientSecret, + code, + code_verifier: codeVerifier, + redirect_uri: callbackUri, + }), + redirect: "error", + signal: AbortSignal.timeout(providerRequestTimeoutMs), + }) + } catch (error) { + throw new GitHubProviderRequestError({ cause: error }) + } + if (!tokenResponse.ok) throw new GitHubProviderRequestError() + const token = tokenResponseSchema.parse(await tokenResponse.json()) + + let userResponse: Response + try { + userResponse = await request(configuration.userEndpoint, { + headers: { + accept: "application/vnd.github+json", + authorization: ["Bearer", token.access_token].join(" "), + "x-github-api-version": "2022-11-28", + }, + redirect: "error", + signal: AbortSignal.timeout(providerRequestTimeoutMs), + }) + } catch (error) { + throw new GitHubProviderRequestError({ cause: error }) + } + if (!userResponse.ok) throw new GitHubProviderRequestError() + const user = userResponseSchema.parse(await userResponse.json()) + + return { + provider: "github", + issuer: "https://github.com", + subject: String(user.id), + attributes: { + avatarUrl: user.avatar_url, + email: user.email, + login: user.login, + name: user.name, + }, + } + }, + } +} diff --git a/hub/server/integrations/github/create-user-authentication-adapter/types.ts b/hub/server/integrations/github/create-user-authentication-adapter/types.ts new file mode 100644 index 00000000..f8f63a25 --- /dev/null +++ b/hub/server/integrations/github/create-user-authentication-adapter/types.ts @@ -0,0 +1,37 @@ +export interface GitHubAuthenticationPublicConfiguration { + authorizationEndpoint: string + callbackUri: string + clientId: string + tokenEndpoint: string + userEndpoint: string +} + +export interface CreateGitHubUserAuthenticationAdapterInput { + clientSecret: string + fetch?: typeof globalThis.fetch + publicConfiguration: unknown +} + +export interface GitHubUserIdentityEvidence { + attributes: { + avatarUrl: string | null + email: string | null + login: string + name: string | null + } + issuer: string + provider: string + subject: string +} + +export interface GitHubUserAuthenticationAdapter { + canonicalPublicConfiguration: string + callbackUri: string + providerKey: string + createAuthorizationUrl(input: { callbackUri: string; codeChallenge: string; state: string }): URL + completeAuthenticationProof(input: { + callbackUri: string + code: string + codeVerifier: string + }): Promise +} diff --git a/hub/server/integrations/github/errors.ts b/hub/server/integrations/github/errors.ts new file mode 100644 index 00000000..d1086fbc --- /dev/null +++ b/hub/server/integrations/github/errors.ts @@ -0,0 +1,6 @@ +export class GitHubProviderRequestError extends Error { + constructor(options?: ErrorOptions) { + super("GitHub authentication request failed.", options) + this.name = "GitHubProviderRequestError" + } +} diff --git a/hub/server/repositories/admission-handoffs/consume-admission-handoff/index.ts b/hub/server/repositories/admission-handoffs/consume-admission-handoff/index.ts new file mode 100644 index 00000000..63d983b7 --- /dev/null +++ b/hub/server/repositories/admission-handoffs/consume-admission-handoff/index.ts @@ -0,0 +1,32 @@ +import type { ConsumeAdmissionHandoffDatabase, ConsumeAdmissionHandoffResult } from "./types.ts" + +export async function consumeAdmissionHandoff( + database: ConsumeAdmissionHandoffDatabase, + input: { browserBindingDigest: string; id: string }, +): Promise { + const rows = await database.$queryRaw>` + WITH current_handoff AS ( + SELECT "id", "browser_binding_digest", "consumed_at", "expires_at" + FROM "admission_handoffs" + WHERE "id" = ${input.id}::uuid + FOR UPDATE + ), + consumed AS ( + UPDATE "admission_handoffs" + SET "consumed_at" = clock_timestamp(), "updated_at" = clock_timestamp() + WHERE "id" = ${input.id}::uuid + AND "consumed_at" IS NULL + AND "browser_binding_digest" = ${input.browserBindingDigest} + AND "expires_at" > clock_timestamp() + RETURNING "id" + ) + SELECT CASE + WHEN EXISTS (SELECT 1 FROM consumed) THEN 'consumed' + WHEN (SELECT "consumed_at" FROM current_handoff) IS NOT NULL THEN 'replayed' + WHEN (SELECT "browser_binding_digest" FROM current_handoff) + <> ${input.browserBindingDigest} THEN 'browser_mismatch' + ELSE 'expired' + END AS "status" + ` + return rows[0]?.status ?? "replayed" +} diff --git a/hub/server/repositories/admission-handoffs/consume-admission-handoff/types.ts b/hub/server/repositories/admission-handoffs/consume-admission-handoff/types.ts new file mode 100644 index 00000000..2d0be026 --- /dev/null +++ b/hub/server/repositories/admission-handoffs/consume-admission-handoff/types.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type ConsumeAdmissionHandoffDatabase = Pick + +export type ConsumeAdmissionHandoffResult = "browser_mismatch" | "consumed" | "expired" | "replayed" diff --git a/hub/server/repositories/admission-handoffs/create-admission-handoff/index.ts b/hub/server/repositories/admission-handoffs/create-admission-handoff/index.ts new file mode 100644 index 00000000..df815478 --- /dev/null +++ b/hub/server/repositories/admission-handoffs/create-admission-handoff/index.ts @@ -0,0 +1,30 @@ +import { z } from "zod" + +import type { CreateAdmissionHandoffDatabase, CreateAdmissionHandoffInput } from "./types.ts" + +const inputSchema = z + .object({ + browserBindingDigest: z.string().regex(/^[0-9a-f]{64}$/), + displayAttributes: z + .object({ + avatarUrl: z.url().max(2_000).nullable(), + email: z.email().max(320).nullable(), + login: z.string().min(1).max(100), + name: z.string().min(1).max(200).nullable(), + }) + .strict(), + expiresAt: z.date(), + externalIdentityId: z.uuid(), + id: z.uuid(), + providerRevisionId: z.uuid(), + revocationGeneration: z.bigint().positive(), + tokenDigest: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export function createAdmissionHandoff( + database: CreateAdmissionHandoffDatabase, + input: CreateAdmissionHandoffInput, +) { + return database.admissionHandoff.create({ data: inputSchema.parse(input) }) +} diff --git a/hub/server/repositories/admission-handoffs/create-admission-handoff/types.ts b/hub/server/repositories/admission-handoffs/create-admission-handoff/types.ts new file mode 100644 index 00000000..45e9124c --- /dev/null +++ b/hub/server/repositories/admission-handoffs/create-admission-handoff/types.ts @@ -0,0 +1,19 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type CreateAdmissionHandoffDatabase = Pick + +export interface CreateAdmissionHandoffInput { + browserBindingDigest: string + displayAttributes: { + avatarUrl: string | null + email: string | null + login: string + name: string | null + } + expiresAt: Date + externalIdentityId: string + id: string + providerRevisionId: string + revocationGeneration: bigint + tokenDigest: string +} diff --git a/hub/server/repositories/admission-handoffs/delete-expired-admission-handoffs/index.ts b/hub/server/repositories/admission-handoffs/delete-expired-admission-handoffs/index.ts new file mode 100644 index 00000000..583c656e --- /dev/null +++ b/hub/server/repositories/admission-handoffs/delete-expired-admission-handoffs/index.ts @@ -0,0 +1,8 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteExpiredAdmissionHandoffs( + database: Pick, + now: Date, +): Promise { + await database.admissionHandoff.deleteMany({ where: { expiresAt: { lte: now } } }) +} diff --git a/hub/server/repositories/admission-handoffs/find-admission-handoff/index.ts b/hub/server/repositories/admission-handoffs/find-admission-handoff/index.ts new file mode 100644 index 00000000..5c3338f9 --- /dev/null +++ b/hub/server/repositories/admission-handoffs/find-admission-handoff/index.ts @@ -0,0 +1,8 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function findAdmissionHandoff( + database: Pick, + tokenDigest: string, +) { + return database.admissionHandoff.findUnique({ where: { tokenDigest } }) +} diff --git a/hub/server/repositories/authentication-providers/lock-authentication-provider-selection/index.ts b/hub/server/repositories/authentication-providers/lock-authentication-provider-selection/index.ts new file mode 100644 index 00000000..46e807ce --- /dev/null +++ b/hub/server/repositories/authentication-providers/lock-authentication-provider-selection/index.ts @@ -0,0 +1,15 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" + +export async function lockAuthenticationProviderSelection( + database: Pick, + providerKey: string, +): Promise { + const key = parseProviderKey(providerKey) + await database.$queryRaw` + SELECT "id" + FROM "authentication_providers" + WHERE "key" = ${key} + FOR SHARE + ` +} diff --git a/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.db.test.ts b/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.db.test.ts new file mode 100644 index 00000000..36fb7fcf --- /dev/null +++ b/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.db.test.ts @@ -0,0 +1,69 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { prisma } from "../../../integrations/prisma" +import type { AuthenticationRateLimitDatabase } from "./types" +import { reserveAuthenticationRateLimit } from "." + +function reserve( + keyDigest: string, + scope: "github_callback" | "github_start" = "github_start", + database: AuthenticationRateLimitDatabase = prisma, +) { + return reserveAuthenticationRateLimit(database, { + id: randomUUID(), + keyDigest, + limit: 10, + scope, + windowSeconds: 600, + }) +} + +describe("reserveAuthenticationRateLimit()", () => { + it("atomically admits exactly the configured budget under concurrency", async () => { + const results = await Promise.all(Array.from({ length: 11 }, () => reserve("a".repeat(64)))) + + expect(results.filter(Boolean)).toHaveLength(10) + expect(results.filter((reserved) => !reserved)).toHaveLength(1) + }) + + it("isolates addresses and operation scopes", async () => { + await Promise.all(Array.from({ length: 10 }, () => reserve("a".repeat(64)))) + + await expect(reserve("b".repeat(64))).resolves.toBe(true) + await expect(reserve("a".repeat(64), "github_callback")).resolves.toBe(true) + }) + + it("resets an expired window and deletes stale buckets", async () => { + await reserve("a".repeat(64)) + await prisma.authenticationRateLimit.updateMany({ + data: { windowStartedAt: new Date("2000-01-01T00:00:00Z") }, + }) + + await expect(reserve("a".repeat(64))).resolves.toBe(true) + await expect(prisma.authenticationRateLimit.count()).resolves.toBe(1) + await expect(prisma.authenticationRateLimit.findFirstOrThrow()).resolves.toMatchObject({ + reservationCount: 1, + }) + }) + + it("does not reserve capacity when its transaction rolls back", async () => { + const rollbackDatabase: AuthenticationRateLimitDatabase = { + $transaction( + operation: Parameters[0], + options: Parameters[1], + ) { + return prisma.$transaction(async (transaction) => { + await operation(transaction) + throw new Error("forced rollback") + }, options) + }, + } + + await expect(reserve("a".repeat(64), "github_start", rollbackDatabase)).rejects.toThrow( + "forced rollback", + ) + const results = await Promise.all(Array.from({ length: 10 }, () => reserve("a".repeat(64)))) + expect(results.every(Boolean)).toBe(true) + }) +}) diff --git a/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.ts b/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.ts new file mode 100644 index 00000000..524fabbf --- /dev/null +++ b/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.ts @@ -0,0 +1,69 @@ +import { Prisma } from "../../../generated/prisma/client.ts" +import { z } from "zod" + +import type { + AuthenticationRateLimitDatabase, + ReserveAuthenticationRateLimitInput, +} from "./types.ts" + +const inputSchema = z + .object({ + id: z.uuid(), + keyDigest: z.string().regex(/^[0-9a-f]{64}$/), + limit: z.number().int().min(1).max(1_000), + scope: z.enum(["github_callback", "github_start"]), + windowSeconds: z.number().int().min(1).max(86_400), + }) + .strict() + +export function reserveAuthenticationRateLimit( + database: AuthenticationRateLimitDatabase, + input: ReserveAuthenticationRateLimitInput, +): Promise { + const parsed = inputSchema.parse(input) + return database.$transaction( + async (transaction) => { + await transaction.$queryRaw>` + SELECT pg_advisory_xact_lock( + hashtextextended(${`${parsed.scope}:${parsed.keyDigest}`}, 0) + ) IS NULL AS "locked" + ` + await transaction.$executeRaw` + DELETE FROM "authentication_rate_limits" + WHERE "window_started_at" + make_interval(secs => ${parsed.windowSeconds * 2}) + <= clock_timestamp() + ` + const reserved = await transaction.$queryRaw>` + INSERT INTO "authentication_rate_limits" ( + "id", "scope", "key_digest", "window_started_at", "reservation_count" + ) VALUES ( + ${parsed.id}::uuid, + ${parsed.scope}, + ${parsed.keyDigest}, + clock_timestamp(), + 1 + ) + ON CONFLICT ("scope", "key_digest") DO UPDATE SET + "window_started_at" = CASE + WHEN "authentication_rate_limits"."window_started_at" + + make_interval(secs => ${parsed.windowSeconds}) <= clock_timestamp() + THEN clock_timestamp() + ELSE "authentication_rate_limits"."window_started_at" + END, + "reservation_count" = CASE + WHEN "authentication_rate_limits"."window_started_at" + + make_interval(secs => ${parsed.windowSeconds}) <= clock_timestamp() + THEN 1 + ELSE "authentication_rate_limits"."reservation_count" + 1 + END, + "updated_at" = clock_timestamp() + WHERE "authentication_rate_limits"."window_started_at" + + make_interval(secs => ${parsed.windowSeconds}) <= clock_timestamp() + OR "authentication_rate_limits"."reservation_count" < ${parsed.limit} + RETURNING "reservation_count" + ` + return reserved.length === 1 + }, + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }, + ) +} diff --git a/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/types.ts b/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/types.ts new file mode 100644 index 00000000..c98738b3 --- /dev/null +++ b/hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/types.ts @@ -0,0 +1,16 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export interface ReserveAuthenticationRateLimitInput { + id: string + keyDigest: string + limit: number + scope: "github_callback" | "github_start" + windowSeconds: number +} + +export interface AuthenticationRateLimitDatabase { + $transaction( + operation: (transaction: Prisma.TransactionClient) => Promise, + options: { isolationLevel: Prisma.TransactionIsolationLevel }, + ): Promise +} diff --git a/hub/server/repositories/authentication-revocation-generations/get-authentication-revocation-generation/index.ts b/hub/server/repositories/authentication-revocation-generations/get-authentication-revocation-generation/index.ts new file mode 100644 index 00000000..cb542936 --- /dev/null +++ b/hub/server/repositories/authentication-revocation-generations/get-authentication-revocation-generation/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getAuthenticationRevocationGeneration( + database: Pick, +): Promise { + const record = await database.authenticationRevocationGeneration.findUnique({ + where: { key: "global" }, + }) + if (!record) throw new Error("The global authentication revocation generation is missing.") + return record.generation +} diff --git a/hub/server/repositories/authentication-revocation-generations/increment-authentication-revocation-generation/index.ts b/hub/server/repositories/authentication-revocation-generations/increment-authentication-revocation-generation/index.ts new file mode 100644 index 00000000..de5f74e8 --- /dev/null +++ b/hub/server/repositories/authentication-revocation-generations/increment-authentication-revocation-generation/index.ts @@ -0,0 +1,10 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function incrementAuthenticationRevocationGeneration( + database: Pick, +) { + return database.authenticationRevocationGeneration.update({ + where: { key: "global" }, + data: { generation: { increment: 1 } }, + }) +} diff --git a/hub/server/repositories/authentication-revocation-generations/lock-authentication-revocation-generation/index.ts b/hub/server/repositories/authentication-revocation-generations/lock-authentication-revocation-generation/index.ts new file mode 100644 index 00000000..e9f69102 --- /dev/null +++ b/hub/server/repositories/authentication-revocation-generations/lock-authentication-revocation-generation/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function lockAuthenticationRevocationGeneration( + database: Pick, +): Promise { + await database.$queryRaw` + SELECT "id" + FROM "authentication_revocation_generations" + WHERE "key" = 'global' + FOR SHARE + ` +} diff --git a/hub/server/repositories/authorization-attempts/consume-authorization-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-authorization-attempt/index.ts new file mode 100644 index 00000000..35f9e1a2 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/consume-authorization-attempt/index.ts @@ -0,0 +1,34 @@ +import { z } from "zod" + +import type { + ConsumeAuthorizationAttemptDatabase, + ConsumeAuthorizationAttemptInput, +} from "./types.ts" + +const inputSchema = z + .object({ + browserBindingDigest: z.string().regex(/^[0-9a-f]{64}$/), + consumedAt: z.date(), + stateDigest: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export async function consumeAuthorizationAttempt( + database: ConsumeAuthorizationAttemptDatabase, + input: ConsumeAuthorizationAttemptInput, +) { + const parsed = inputSchema.parse(input) + const consumed = await database.authorizationAttempt.updateMany({ + where: { + stateDigest: parsed.stateDigest, + browserBindingDigest: parsed.browserBindingDigest, + consumedAt: null, + expiresAt: { gt: parsed.consumedAt }, + }, + data: { consumedAt: parsed.consumedAt }, + }) + if (consumed.count !== 1) return null + return database.authorizationAttempt.findUniqueOrThrow({ + where: { stateDigest: parsed.stateDigest }, + }) +} diff --git a/hub/server/repositories/authorization-attempts/consume-authorization-attempt/types.ts b/hub/server/repositories/authorization-attempts/consume-authorization-attempt/types.ts new file mode 100644 index 00000000..2273c9f3 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/consume-authorization-attempt/types.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type ConsumeAuthorizationAttemptDatabase = Pick< + Prisma.TransactionClient, + "authorizationAttempt" +> + +export interface ConsumeAuthorizationAttemptInput { + browserBindingDigest: string + consumedAt: Date + stateDigest: string +} diff --git a/hub/server/repositories/authorization-attempts/create-authorization-attempt/index.ts b/hub/server/repositories/authorization-attempts/create-authorization-attempt/index.ts new file mode 100644 index 00000000..e45c8999 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/create-authorization-attempt/index.ts @@ -0,0 +1,26 @@ +import { z } from "zod" + +import type { + CreateAuthorizationAttemptDatabase, + CreateAuthorizationAttemptInput, +} from "./types.ts" + +const inputSchema = z + .object({ + browserBindingDigest: z.string().regex(/^[0-9a-f]{64}$/), + codeChallenge: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + codeVerifierCiphertext: z.string().min(1).max(500), + expiresAt: z.date(), + id: z.uuid(), + providerRevisionId: z.uuid(), + requestIpDigest: z.string().regex(/^[0-9a-f]{64}$/), + stateDigest: z.string().regex(/^[0-9a-f]{64}$/), + }) + .strict() + +export function createAuthorizationAttempt( + database: CreateAuthorizationAttemptDatabase, + input: CreateAuthorizationAttemptInput, +) { + return database.authorizationAttempt.create({ data: inputSchema.parse(input) }) +} diff --git a/hub/server/repositories/authorization-attempts/create-authorization-attempt/types.ts b/hub/server/repositories/authorization-attempts/create-authorization-attempt/types.ts new file mode 100644 index 00000000..f9b8d8ce --- /dev/null +++ b/hub/server/repositories/authorization-attempts/create-authorization-attempt/types.ts @@ -0,0 +1,17 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type CreateAuthorizationAttemptDatabase = Pick< + Prisma.TransactionClient, + "authorizationAttempt" +> + +export interface CreateAuthorizationAttemptInput { + browserBindingDigest: string + codeChallenge: string + codeVerifierCiphertext: string + expiresAt: Date + id: string + providerRevisionId: string + requestIpDigest: string + stateDigest: string +} diff --git a/hub/server/repositories/authorization-attempts/delete-expired-authorization-attempts/index.ts b/hub/server/repositories/authorization-attempts/delete-expired-authorization-attempts/index.ts new file mode 100644 index 00000000..87b2e1d6 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/delete-expired-authorization-attempts/index.ts @@ -0,0 +1,8 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteExpiredAuthorizationAttempts( + database: Pick, + now: Date, +): Promise { + await database.authorizationAttempt.deleteMany({ where: { expiresAt: { lte: now } } }) +} diff --git a/hub/server/repositories/security-audits/create-security-audit/index.test.ts b/hub/server/repositories/security-audits/create-security-audit/index.test.ts index 07d74ba4..52b5e1c7 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.test.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.test.ts @@ -10,6 +10,31 @@ describe("createSecurityAudit()", () => { const database = { securityAudit: { create } } const id = "5dd31031-6f1c-4c16-87c2-aee5342145cc" + await createSecurityAudit(database, { + id, + type: "authentication_attempt_denied", + provider: "github", + reason: "invalid_or_expired", + }) + await createSecurityAudit(database, { + id, + type: "authentication_handoff_created", + handoffId: "4f58d4a0-dd53-4c9f-b68b-87fb85af8941", + externalIdentityId: "e8714e71-52a7-45e8-a639-99ec37f2d85f", + providerRevisionId: "df75faa4-ed4c-44ed-8db0-443cc3b426ed", + }) + await createSecurityAudit(database, { + id, + type: "authentication_handoff_consumed", + handoffId: "4f58d4a0-dd53-4c9f-b68b-87fb85af8941", + externalIdentityId: "e8714e71-52a7-45e8-a639-99ec37f2d85f", + }) + await createSecurityAudit(database, { + id, + type: "authentication_handoff_denied", + handoffId: "4f58d4a0-dd53-4c9f-b68b-87fb85af8941", + reason: "revoked", + }) await createSecurityAudit(database, { id, type: "external_identity_created", @@ -53,6 +78,31 @@ describe("createSecurityAudit()", () => { }) expect(create.mock.calls.map(([{ data }]) => data)).toEqual([ + expect.objectContaining({ + action: "authentication.attempt.denied", + outcome: "DENIED", + targetType: "authorization_attempt", + context: { provider: "github", reason: "invalid_or_expired" }, + }), + expect.objectContaining({ + action: "authentication.handoff.created", + outcome: "SUCCESS", + targetType: "admission_handoff", + context: { + externalIdentityId: "e8714e71-52a7-45e8-a639-99ec37f2d85f", + providerRevisionId: "df75faa4-ed4c-44ed-8db0-443cc3b426ed", + }, + }), + expect.objectContaining({ + action: "authentication.handoff.consumed", + outcome: "SUCCESS", + context: { externalIdentityId: "e8714e71-52a7-45e8-a639-99ec37f2d85f" }, + }), + expect.objectContaining({ + action: "authentication.handoff.denied", + outcome: "DENIED", + context: { reason: "revoked" }, + }), expect.objectContaining({ action: "identity.external.created", outcome: "SUCCESS", diff --git a/hub/server/repositories/security-audits/create-security-audit/types.ts b/hub/server/repositories/security-audits/create-security-audit/types.ts index ed56b7b2..1d277a5e 100644 --- a/hub/server/repositories/security-audits/create-security-audit/types.ts +++ b/hub/server/repositories/security-audits/create-security-audit/types.ts @@ -10,6 +10,12 @@ interface SecurityAuditEvent { id: string } +export interface AuthenticationAttemptDeniedAudit extends SecurityAuditEvent { + type: "authentication_attempt_denied" + provider: string + reason: "configuration_changed" | "invalid_or_expired" | "provider_failed" | "rate_limited" +} + export interface ExternalIdentityCreatedAudit extends SecurityAuditEvent { type: "external_identity_created" externalIdentityId: string @@ -51,7 +57,30 @@ export interface ProviderRevisionPersistFailedAudit extends SecurityAuditEvent { revision: number } +export interface AuthenticationHandoffCreatedAudit extends SecurityAuditEvent { + type: "authentication_handoff_created" + externalIdentityId: string + handoffId: string + providerRevisionId: string +} + +export interface AuthenticationHandoffConsumedAudit extends SecurityAuditEvent { + type: "authentication_handoff_consumed" + externalIdentityId: string + handoffId: string +} + +export interface AuthenticationHandoffDeniedAudit extends SecurityAuditEvent { + type: "authentication_handoff_denied" + reason: "browser_mismatch" | "expired" | "provider_revision_changed" | "replayed" | "revoked" + handoffId?: string +} + export type CreateSecurityAuditEvent = + | AuthenticationAttemptDeniedAudit + | AuthenticationHandoffConsumedAudit + | AuthenticationHandoffCreatedAudit + | AuthenticationHandoffDeniedAudit | ExternalIdentityCreatedAudit | ExternalIdentityCreateFailedAudit | ProviderRevisionValidationFailedAudit diff --git a/hub/server/repositories/security-audits/get-security-audit-data/index.ts b/hub/server/repositories/security-audits/get-security-audit-data/index.ts index 4e9a8391..0a18006e 100644 --- a/hub/server/repositories/security-audits/get-security-audit-data/index.ts +++ b/hub/server/repositories/security-audits/get-security-audit-data/index.ts @@ -7,6 +7,49 @@ export function getSecurityAuditData( event: CreateSecurityAuditEvent, ): Prisma.SecurityAuditCreateInput { switch (event.type) { + case "authentication_attempt_denied": + return { + id: event.id, + action: "authentication.attempt.denied", + outcome: SecurityAuditOutcome.DENIED, + targetType: "authorization_attempt", + context: createSecurityAuditContext({ + provider: event.provider, + reason: event.reason, + }), + } + case "authentication_handoff_created": + return { + id: event.id, + action: "authentication.handoff.created", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "admission_handoff", + targetId: event.handoffId, + context: createSecurityAuditContext({ + externalIdentityId: event.externalIdentityId, + providerRevisionId: event.providerRevisionId, + }), + } + case "authentication_handoff_consumed": + return { + id: event.id, + action: "authentication.handoff.consumed", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "admission_handoff", + targetId: event.handoffId, + context: createSecurityAuditContext({ + externalIdentityId: event.externalIdentityId, + }), + } + case "authentication_handoff_denied": + return { + id: event.id, + action: "authentication.handoff.denied", + outcome: SecurityAuditOutcome.DENIED, + targetType: "admission_handoff", + targetId: event.handoffId, + context: createSecurityAuditContext({ reason: event.reason }), + } case "external_identity_created": return { id: event.id, diff --git a/hub/server/repositories/security-audits/parse-security-audit-event/index.ts b/hub/server/repositories/security-audits/parse-security-audit-event/index.ts index 3a346fd0..81975c8f 100644 --- a/hub/server/repositories/security-audits/parse-security-audit-event/index.ts +++ b/hub/server/repositories/security-audits/parse-security-audit-event/index.ts @@ -4,6 +4,50 @@ import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" import type { CreateSecurityAuditEvent } from "../create-security-audit/types.ts" const eventSchema = z.discriminatedUnion("type", [ + z + .object({ + id: z.uuid(), + type: z.literal("authentication_attempt_denied"), + provider: z.unknown(), + reason: z.enum([ + "configuration_changed", + "invalid_or_expired", + "provider_failed", + "rate_limited", + ]), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("authentication_handoff_created"), + externalIdentityId: z.uuid(), + handoffId: z.uuid(), + providerRevisionId: z.uuid(), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("authentication_handoff_consumed"), + externalIdentityId: z.uuid(), + handoffId: z.uuid(), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("authentication_handoff_denied"), + handoffId: z.uuid().optional(), + reason: z.enum([ + "browser_mismatch", + "expired", + "provider_revision_changed", + "replayed", + "revoked", + ]), + }) + .strict(), z .object({ id: z.uuid(), @@ -62,9 +106,14 @@ const eventSchema = z.discriminatedUnion("type", [ export function parseSecurityAuditEvent(input: unknown): CreateSecurityAuditEvent { const event = eventSchema.parse(input) switch (event.type) { + case "authentication_attempt_denied": case "external_identity_created": case "external_identity_create_failed": return { ...event, provider: parseProviderKey(event.provider) } + case "authentication_handoff_created": + case "authentication_handoff_consumed": + case "authentication_handoff_denied": + return event default: return { ...event, providerKey: parseProviderKey(event.providerKey) } } diff --git a/hub/server/routes/api/auth/github/callback/get/index.ts b/hub/server/routes/api/auth/github/callback/get/index.ts new file mode 100644 index 00000000..b614b6b5 --- /dev/null +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -0,0 +1,60 @@ +import type { RequestHandler } from "express" +import { z } from "zod" + +import { AuthenticationProofError } from "../../../../../../domains/authentication/errors.ts" +import { parseBrowserBinding } from "../../../../../../utils/authentication/parse-browser-binding/index.ts" +import { readCookie } from "../../../../../../utils/http/read-cookie/index.ts" +import type { GitHubAuthenticationRoutesDependencies } from "../../types.ts" + +const querySchema = z + .object({ + code: z.string().min(1).max(1_000), + state: z.string().min(32).max(200), + }) + .strict() + +export function get(dependencies: GitHubAuthenticationRoutesDependencies): RequestHandler { + return async (request, response) => { + response.set("Cache-Control", "no-store") + response.locals.omitLogPayload = true + const requestIp = request.ip ?? request.socket.remoteAddress ?? "unavailable" + if (!(await dependencies.reserveRateLimit({ operation: "callback", requestIp }))) { + const error = new AuthenticationProofError( + "rate_limited", + "Too many authentication callbacks were received.", + ) + await dependencies.recordRateLimitDenial("callback", error) + response.status(429).json({ error: error.message }) + return + } + const parsed = querySchema.safeParse(request.query) + const browserBinding = parseBrowserBinding( + readCookie(request.get("cookie"), "hub_auth_browser"), + ) + if (!parsed.success || !browserBinding) { + response.status(400).json({ error: "The authentication callback is invalid." }) + return + } + try { + const result = await dependencies.complete({ + browserBinding, + code: parsed.data.code, + state: parsed.data.state, + }) + response.status(200).json({ + status: result.status, + admission: "pending", + handoffToken: result.handoffToken, + expiresAt: result.expiresAt.toISOString(), + identity: result.attributes, + }) + } catch (error) { + if (error instanceof AuthenticationProofError) { + const status = error.code === "attempt_invalid" ? 400 : 502 + response.status(status).json({ error: error.message }) + return + } + throw error + } + } +} diff --git a/hub/server/routes/api/auth/github/index.test.ts b/hub/server/routes/api/auth/github/index.test.ts new file mode 100644 index 00000000..2b26f5a8 --- /dev/null +++ b/hub/server/routes/api/auth/github/index.test.ts @@ -0,0 +1,219 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createApp } from "../../../../create-app" +import { createLogger } from "../../../../integrations/pino/create-logger" +import { hashAuthenticationValue } from "../../../../utils/authentication/hash-authentication-value" +import type { GitHubAuthenticationRoutesDependencies } from "./types" + +async function openServer(dependencies: GitHubAuthenticationRoutesDependencies) { + const records: string[] = [] + const server = createServer( + createApp({ + githubAuthentication: dependencies, + logger: createLogger({ destination: { write: (line) => records.push(line) } }), + trustedProxyHops: 1, + }), + ) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + return { + records, + baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + } +} + +function dependencies() { + const browserBinding = "b".repeat(43) + const complete = vi.fn(() => + Promise.resolve({ + status: "authentication_proved" as const, + handoffToken: "handoff-secret", + expiresAt: new Date("2026-10-30T16:05:00Z"), + attributes: { + login: "octocat", + name: null, + avatarUrl: null, + email: null, + }, + }), + ) + const start = vi.fn(() => + Promise.resolve({ + authorizationUrl: + "https://github.com/login/oauth/authorize?state=state-secret&code_challenge=challenge", + browserBinding, + expiresAt: new Date("2026-10-30T16:10:00Z"), + }), + ) + const reserveRateLimit = vi.fn(() => Promise.resolve(true)) + const recordRateLimitDenial = vi.fn(() => Promise.resolve()) + return { + complete, + reserveRateLimit, + recordRateLimitDenial, + start, + browserBinding, + value: { + appOrigin: "https://hub.example.test", + complete, + now: () => new Date("2026-10-30T16:00:00Z"), + recordRateLimitDenial, + reserveRateLimit, + secureCookies: true, + start, + } satisfies GitHubAuthenticationRoutesDependencies, + } +} + +describe("GitHub authentication routes", () => { + it("starts only from the configured origin and sets a bounded browser cookie", async () => { + const { browserBinding, start, value } = dependencies() + const { baseUrl } = await openServer(value) + + const denied = await fetch(`${baseUrl}/api/auth/github/start`, { + method: "POST", + headers: { origin: "https://attacker.example.test" }, + redirect: "manual", + }) + expect(denied.status).toBe(403) + expect(denied.headers.get("cache-control")).toBe("no-store") + expect(start).not.toHaveBeenCalled() + + const response = await fetch(`${baseUrl}/api/auth/github/start`, { + method: "POST", + headers: { + origin: "https://hub.example.test", + "x-forwarded-for": "192.0.2.10", + }, + redirect: "manual", + }) + expect(response.status).toBe(303) + expect(response.headers.get("location")).toContain("github.com/login/oauth/authorize") + expect(response.headers.get("cache-control")).toBe("no-store") + expect(response.headers.get("set-cookie")).toMatch( + new RegExp( + `^hub_auth_browser=${browserBinding}; Path=/api/auth; Expires=.*; HttpOnly; Secure; SameSite=Lax$`, + ), + ) + expect(start).toHaveBeenCalledWith({ + requestIpDigest: hashAuthenticationValue("192.0.2.10"), + }) + }) + + it("returns a non-admitted handoff and excludes callback secrets from HTTP logs", async () => { + const { browserBinding, complete, value } = dependencies() + const { baseUrl, records } = await openServer(value) + const response = await fetch( + `${baseUrl}/api/auth/github/callback?code=authorization-code-secret&state=${"s".repeat(32)}`, + { + headers: { + cookie: `hub_auth_browser=${browserBinding}`, + "x-forwarded-for": "192.0.2.10", + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.headers.get("cache-control")).toBe("no-store") + expect(await response.json()).toEqual({ + status: "authentication_proved", + admission: "pending", + handoffToken: "handoff-secret", + expiresAt: "2026-10-30T16:05:00.000Z", + identity: { + login: "octocat", + name: null, + avatarUrl: null, + email: null, + }, + }) + expect(complete).toHaveBeenCalledWith({ + browserBinding, + code: "authorization-code-secret", + state: "s".repeat(32), + }) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records.join("")).not.toMatch(/authorization-code-secret|handoff-secret/) + }) + + it("rejects malformed, oversized, and rate-limited callbacks before proof completion", async () => { + const { browserBinding, complete, reserveRateLimit, value } = dependencies() + reserveRateLimit.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + const { baseUrl } = await openServer(value) + + const malformed = await fetch( + `${baseUrl}/api/auth/github/callback?code=${"x".repeat(1_001)}&state=short`, + { headers: { cookie: "hub_auth_browser=binding", "x-forwarded-for": "192.0.2.10" } }, + ) + expect(malformed.status).toBe(400) + expect(reserveRateLimit).toHaveBeenNthCalledWith(1, { + operation: "callback", + requestIp: "192.0.2.10", + }) + const limited = await fetch( + `${baseUrl}/api/auth/github/callback?code=code&state=${"s".repeat(32)}`, + { + headers: { + cookie: `hub_auth_browser=${browserBinding}`, + "x-forwarded-for": "192.0.2.10", + }, + }, + ) + expect(limited.status).toBe(429) + expect(complete).not.toHaveBeenCalled() + }) + + it.each([ + ["short", "short"], + ["long", "b".repeat(44)], + ["invalid alphabet", `${"b".repeat(42)}=`], + ])("rejects a %s browser binding before domain completion", async (_name, binding) => { + const { complete, value } = dependencies() + const { baseUrl, records } = await openServer(value) + + const response = await fetch( + `${baseUrl}/api/auth/github/callback?code=code&state=${"s".repeat(32)}`, + { headers: { cookie: `hub_auth_browser=${binding}` } }, + ) + + expect(response.status).toBe(400) + expect(response.headers.get("cache-control")).toBe("no-store") + expect(await response.json()).toEqual({ + error: "The authentication callback is invalid.", + }) + expect(complete).not.toHaveBeenCalled() + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.parse(records[0]!)).toMatchObject({ level: 40, statusCode: 400 }) + expect(records[0]).not.toContain(binding) + }) + + it("returns a sanitized error when denial and audit both fail", async () => { + const { complete, value } = dependencies() + complete.mockRejectedValueOnce( + new AggregateError( + [new Error("safe primary"), new Error("raw audit failure with provider-token-secret")], + "dual failure", + ), + ) + const { baseUrl, records } = await openServer(value) + + const response = await fetch( + `${baseUrl}/api/auth/github/callback?code=code&state=${"s".repeat(32)}`, + { headers: { cookie: `hub_auth_browser=${"b".repeat(43)}` } }, + ) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ error: "Internal server error" }) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toMatch(/provider-token-secret|raw audit failure|safe primary/) + }) +}) diff --git a/hub/server/routes/api/auth/github/start/post/index.ts b/hub/server/routes/api/auth/github/start/post/index.ts new file mode 100644 index 00000000..dabe0f2e --- /dev/null +++ b/hub/server/routes/api/auth/github/start/post/index.ts @@ -0,0 +1,47 @@ +import type { RequestHandler } from "express" + +import { AuthenticationProofError } from "../../../../../../domains/authentication/errors.ts" +import { hashAuthenticationValue } from "../../../../../../utils/authentication/hash-authentication-value/index.ts" +import type { GitHubAuthenticationRoutesDependencies } from "../../types.ts" + +const BROWSER_COOKIE = "hub_auth_browser" + +export function post(dependencies: GitHubAuthenticationRoutesDependencies): RequestHandler { + return async (request, response) => { + response.set("Cache-Control", "no-store") + response.locals.omitLogPayload = true + if (request.get("origin") !== dependencies.appOrigin) { + response.status(403).json({ error: "Authentication request origin is not allowed." }) + return + } + const requestIp = request.ip ?? request.socket.remoteAddress ?? "unavailable" + if (!(await dependencies.reserveRateLimit({ operation: "start", requestIp }))) { + const error = new AuthenticationProofError( + "rate_limited", + "Too many authentication attempts were started.", + ) + await dependencies.recordRateLimitDenial("start", error) + response.status(429).json({ error: error.message }) + return + } + try { + const result = await dependencies.start({ + requestIpDigest: hashAuthenticationValue(requestIp), + }) + response.cookie(BROWSER_COOKIE, result.browserBinding, { + httpOnly: true, + sameSite: "lax", + secure: dependencies.secureCookies, + path: "/api/auth", + expires: result.expiresAt, + }) + response.redirect(303, result.authorizationUrl) + } catch (error) { + if (error instanceof AuthenticationProofError) { + response.status(error.code === "rate_limited" ? 429 : 503).json({ error: error.message }) + return + } + throw error + } + } +} diff --git a/hub/server/routes/api/auth/github/types.ts b/hub/server/routes/api/auth/github/types.ts new file mode 100644 index 00000000..c6b22a75 --- /dev/null +++ b/hub/server/routes/api/auth/github/types.ts @@ -0,0 +1 @@ +export type { GitHubAuthenticationServices as GitHubAuthenticationRoutesDependencies } from "../../../../domains/authentication/types.ts" diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index d7b4f2f8..5301a09f 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -1,10 +1,15 @@ import type { Express } from "express" import { get as handleApiHealthGet } from "./api/health/get/index.ts" +import { get as createGitHubCallbackGet } from "./api/auth/github/callback/get/index.ts" +import { post as createGitHubStartPost } from "./api/auth/github/start/post/index.ts" import type { RouteDependencies } from "./types.ts" /** Keep endpoint registration separate from the app's shared middleware. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Product routes will consume these dependencies when ported. -export function routes(app: Express, _dependencies: RouteDependencies = {}): void { +export function routes(app: Express, dependencies: RouteDependencies = {}): void { app.get("/api/health", handleApiHealthGet) + if (dependencies?.githubAuthentication) { + app.post("/api/auth/github/start", createGitHubStartPost(dependencies.githubAuthentication)) + app.get("/api/auth/github/callback", createGitHubCallbackGet(dependencies.githubAuthentication)) + } } diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index bab536ec..bdcaf7e1 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -1,5 +1,7 @@ import type { Broadcast } from "../integrations/ws/types.ts" +import type { GitHubAuthenticationRoutesDependencies } from "./api/auth/github/types.ts" export interface RouteDependencies { broadcast?: Broadcast + githubAuthentication?: GitHubAuthenticationRoutesDependencies } diff --git a/hub/server/run-api/index.test.ts b/hub/server/run-api/index.test.ts index b564915e..fd9387e9 100644 --- a/hub/server/run-api/index.test.ts +++ b/hub/server/run-api/index.test.ts @@ -9,12 +9,20 @@ import WebSocket from "ws" import { deferred } from "../../test/ws/deferred" import { rejected } from "../../test/ws/rejected" import { createApp } from "../create-app" +import { readGitHubAuthenticationConfiguration } from "../domains/authentication/read-github-authentication-configuration" +import type { GitHubAuthenticationServices } from "../domains/authentication/types" import { createLogger } from "../integrations/pino/create-logger" +import { readTrustedProxyHops } from "../integrations/express/read-trusted-proxy-hops" import { attachRealtimeServer } from "../integrations/ws/attach-realtime-server" import type * as RealtimeModule from "../integrations/ws/attach-realtime-server" import type { RealtimeServer } from "../integrations/ws/types" vi.mock("../create-app", { spy: true }) +vi.mock("../domains/authentication/read-github-authentication-configuration", { spy: true }) +vi.mock("../domains/authentication/create-github-authentication-services", () => ({ + createGitHubAuthenticationServices: vi.fn(), +})) +vi.mock("../integrations/express/read-trusted-proxy-hops", { spy: true }) vi.mock("../integrations/ws/attach-realtime-server", { spy: true }) vi.mock("node:http", async (importOriginal) => { @@ -54,6 +62,113 @@ async function startApi(options: { port: number; shutdownTimeoutMs?: number }) { } describe("runApi()", () => { + it("creates no server or realtime resources when authentication configuration parsing fails", async () => { + const { runApi } = await import(".") + const failure = new Error("invalid authentication configuration") + vi.mocked(readGitHubAuthenticationConfiguration).mockImplementationOnce(() => { + throw failure + }) + const signals = { + SIGINT: process.listeners("SIGINT"), + SIGTERM: process.listeners("SIGTERM"), + } + + await expect(runApi({ port: 0, logger: createLogger({ level: "silent" }) })).rejects.toBe( + failure, + ) + expect(createServer).not.toHaveBeenCalled() + expect(attachRealtimeServer).not.toHaveBeenCalled() + expect(process.listeners("SIGINT")).toEqual(signals.SIGINT) + expect(process.listeners("SIGTERM")).toEqual(signals.SIGTERM) + }) + + it("creates no server or realtime resources when authentication service initialization fails", async () => { + const { runApi } = await import(".") + const configuration = { + appOrigin: "https://hub.example.test", + clientSecret: "client-secret", + encryptionKey: Buffer.alloc(32), + publicConfiguration: { + authorizationEndpoint: "https://github.com/login/oauth/authorize", + callbackUri: "https://hub.example.test/api/auth/github/callback", + clientId: "client-id", + tokenEndpoint: "https://github.com/login/oauth/access_token", + userEndpoint: "https://api.github.com/user", + }, + revision: 1, + secureCookies: true, + } + const failure = new Error("database unavailable") + vi.mocked(readGitHubAuthenticationConfiguration).mockReturnValueOnce(configuration) + const { createGitHubAuthenticationServices } = + await import("../domains/authentication/create-github-authentication-services") + vi.mocked(createGitHubAuthenticationServices).mockRejectedValueOnce(failure) + const signals = { + SIGINT: process.listeners("SIGINT"), + SIGTERM: process.listeners("SIGTERM"), + } + + await expect(runApi({ port: 0, logger: createLogger({ level: "silent" }) })).rejects.toBe( + failure, + ) + expect(readTrustedProxyHops).toHaveBeenCalled() + expect(createServer).not.toHaveBeenCalled() + expect(attachRealtimeServer).not.toHaveBeenCalled() + expect(process.listeners("SIGINT")).toEqual(signals.SIGINT) + expect(process.listeners("SIGTERM")).toEqual(signals.SIGTERM) + }) + + it("completes authentication initialization before attaching realtime and listening", async () => { + const configuration = { + appOrigin: "https://hub.example.test", + clientSecret: "client-secret", + encryptionKey: Buffer.alloc(32), + publicConfiguration: { + authorizationEndpoint: "https://github.com/login/oauth/authorize", + callbackUri: "https://hub.example.test/api/auth/github/callback", + clientId: "client-id", + tokenEndpoint: "https://github.com/login/oauth/access_token", + userEndpoint: "https://api.github.com/user", + }, + revision: 1, + secureCookies: true, + } + const initialized = deferred() + vi.mocked(readGitHubAuthenticationConfiguration).mockReturnValueOnce(configuration) + const { createGitHubAuthenticationServices } = + await import("../domains/authentication/create-github-authentication-services") + vi.mocked(createGitHubAuthenticationServices).mockReturnValueOnce(initialized.promise) + const { runApi } = await import(".") + const logger = createLogger({ level: "silent" }) + const previousSignals = process.listeners("SIGTERM") + const running = runApi({ port: 0, logger }) + void running.catch(() => {}) + + await vi.waitFor(() => expect(createGitHubAuthenticationServices).toHaveBeenCalledOnce()) + expect(createServer).not.toHaveBeenCalled() + expect(attachRealtimeServer).not.toHaveBeenCalled() + const services: GitHubAuthenticationServices = { + appOrigin: configuration.appOrigin, + complete: vi.fn(), + now: () => new Date(), + recordRateLimitDenial: vi.fn(), + reserveRateLimit: vi.fn(), + secureCookies: true, + start: vi.fn(), + } + initialized.resolve(services) + await vi.waitFor(() => expect(attachRealtimeServer).toHaveBeenCalledOnce()) + const server = vi.mocked(createServer).mock.results.at(-1)?.value as Server + await vi.waitFor(() => expect(server.listening).toBe(true)) + const signal = process + .listeners("SIGTERM") + .find((listener) => !previousSignals.includes(listener)) + if (signal) Reflect.apply(signal, process, ["SIGTERM"]) + await running + + expect(vi.mocked(createApp).mock.calls.at(-1)?.[0]?.githubAuthentication).toBe(services) + }) + it("attaches realtime to the HTTP server, injects broadcast, and forbids upgrades without access policies", async () => { const { server, running, signal } = await startApi({ port: 0 }) await once(server, "listening") diff --git a/hub/server/run-api/index.ts b/hub/server/run-api/index.ts index c41852e6..845aa034 100644 --- a/hub/server/run-api/index.ts +++ b/hub/server/run-api/index.ts @@ -2,6 +2,8 @@ import { createServer } from "node:http" import type { Logger } from "pino" import { createApp } from "../create-app/index.ts" +import { readGitHubAuthenticationConfiguration } from "../domains/authentication/read-github-authentication-configuration/index.ts" +import { readTrustedProxyHops } from "../integrations/express/read-trusted-proxy-hops/index.ts" import { attachRealtimeServer } from "../integrations/ws/attach-realtime-server/index.ts" import { shutdownServer } from "../shutdown-server/index.ts" @@ -20,9 +22,25 @@ export async function runApi({ if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1) { throw new Error("API shutdown timeout must be a positive integer.") } + const trustedProxyHops = readTrustedProxyHops(process.env.HUB_TRUSTED_PROXY_HOPS) + const authenticationConfiguration = readGitHubAuthenticationConfiguration(process.env) + const githubAuthentication = authenticationConfiguration + ? await import("../domains/authentication/create-github-authentication-services/index.ts").then( + ({ createGitHubAuthenticationServices }) => + createGitHubAuthenticationServices(authenticationConfiguration), + ) + : undefined const server = createServer() const realtime = attachRealtimeServer({ server, path: "/api/realtime", logger }) - server.on("request", createApp({ logger, broadcast: realtime.broadcast })) + server.on( + "request", + createApp({ + logger, + broadcast: realtime.broadcast, + githubAuthentication, + trustedProxyHops, + }), + ) let finishBinding!: () => void const binding = new Promise((resolve) => { finishBinding = resolve diff --git a/hub/server/utils/authentication/create-opaque-value/index.ts b/hub/server/utils/authentication/create-opaque-value/index.ts new file mode 100644 index 00000000..7b9b06f9 --- /dev/null +++ b/hub/server/utils/authentication/create-opaque-value/index.ts @@ -0,0 +1,5 @@ +import { randomBytes } from "node:crypto" + +export function createOpaqueValue(bytes = 32): string { + return randomBytes(bytes).toString("base64url") +} diff --git a/hub/server/utils/authentication/decrypt-authentication-value/index.ts b/hub/server/utils/authentication/decrypt-authentication-value/index.ts new file mode 100644 index 00000000..630fcfd0 --- /dev/null +++ b/hub/server/utils/authentication/decrypt-authentication-value/index.ts @@ -0,0 +1,18 @@ +import { createDecipheriv } from "node:crypto" + +export function decryptAuthenticationValue(value: string, key: Buffer): string { + if (key.length !== 32) throw new Error("Authentication encryption key must contain 32 bytes.") + const parts = value.split(".") + if (parts.length !== 3) throw new Error("Authentication ciphertext is invalid.") + const [initializationVector, tag, ciphertext] = parts + const decipher = createDecipheriv( + "aes-256-gcm", + key, + Buffer.from(initializationVector!, "base64url"), + ) + decipher.setAuthTag(Buffer.from(tag!, "base64url")) + return Buffer.concat([ + decipher.update(Buffer.from(ciphertext!, "base64url")), + decipher.final(), + ]).toString("utf8") +} diff --git a/hub/server/utils/authentication/encrypt-authentication-value/index.ts b/hub/server/utils/authentication/encrypt-authentication-value/index.ts new file mode 100644 index 00000000..5b369a51 --- /dev/null +++ b/hub/server/utils/authentication/encrypt-authentication-value/index.ts @@ -0,0 +1,13 @@ +import { createCipheriv, randomBytes } from "node:crypto" + +export function encryptAuthenticationValue(value: string, key: Buffer): string { + if (key.length !== 32) throw new Error("Authentication encryption key must contain 32 bytes.") + const initializationVector = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", key, initializationVector) + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]) + return [ + initializationVector.toString("base64url"), + cipher.getAuthTag().toString("base64url"), + ciphertext.toString("base64url"), + ].join(".") +} diff --git a/hub/server/utils/authentication/hash-authentication-value/index.ts b/hub/server/utils/authentication/hash-authentication-value/index.ts new file mode 100644 index 00000000..939d0c8a --- /dev/null +++ b/hub/server/utils/authentication/hash-authentication-value/index.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto" + +export function hashAuthenticationValue(value: string): string { + return createHash("sha256").update(value).digest("hex") +} diff --git a/hub/server/utils/authentication/parse-browser-binding/index.ts b/hub/server/utils/authentication/parse-browser-binding/index.ts new file mode 100644 index 00000000..30b6f89a --- /dev/null +++ b/hub/server/utils/authentication/parse-browser-binding/index.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +const browserBindingSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) + +export function parseBrowserBinding(value: string | null): string | null { + const parsed = browserBindingSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/hub/server/utils/http/read-cookie/index.ts b/hub/server/utils/http/read-cookie/index.ts new file mode 100644 index 00000000..fb50110a --- /dev/null +++ b/hub/server/utils/http/read-cookie/index.ts @@ -0,0 +1,12 @@ +export function readCookie(header: string | undefined, name: string): string | null { + if (!header || header.length > 8_192) return null + for (const part of header.split(";")) { + const separator = part.indexOf("=") + if (separator < 1) continue + if (part.slice(0, separator).trim() === name) { + const value = part.slice(separator + 1).trim() + return value.length <= 1_000 ? value : null + } + } + return null +} diff --git a/hub/test/database/authentication-proof-migration/index.db.test.ts b/hub/test/database/authentication-proof-migration/index.db.test.ts new file mode 100644 index 00000000..75c2484b --- /dev/null +++ b/hub/test/database/authentication-proof-migration/index.db.test.ts @@ -0,0 +1,163 @@ +import { execFile } from "node:child_process" +import { randomBytes } from "node:crypto" +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import pg from "pg" +import { describe, expect, it } from "vitest" + +describe("authentication proof migration", () => { + it("upgrades the exact C1 schema before deploying C2", async () => { + const adminUrl = process.env.TEST_DATABASE_URL + if (!adminUrl) throw new Error("TEST_DATABASE_URL is required.") + const name = `replica_upgrade_${randomBytes(8).toString("hex")}` + const admin = new pg.Client({ connectionString: adminUrl }) + await admin.connect() + try { + await admin.query(`CREATE DATABASE ${pg.escapeIdentifier(name)}`) + const databaseUrl = new URL(adminUrl) + databaseUrl.pathname = name + const database = new pg.Client({ connectionString: databaseUrl.toString() }) + await database.connect() + try { + const hubRoot = fileURLToPath(new URL("../../../", import.meta.url)) + const migrationName = "20261029203000_identity_foundation" + const migrationPath = join(hubRoot, "schema/migrations", migrationName, "migration.sql") + await database.query(await readFile(migrationPath, "utf8")) + const prisma = join(hubRoot, "node_modules/prisma/build/index.js") + const options = { + cwd: hubRoot, + env: { ...process.env, DATABASE_URL: databaseUrl.toString() }, + timeout: 60_000, + } + await promisify(execFile)( + process.execPath, + [prisma, "migrate", "resolve", "--applied", migrationName], + options, + ) + await promisify(execFile)( + process.execPath, + [prisma, "migrate", "deploy", "--config", join(hubRoot, "prisma7.config.ts")], + options, + ) + + const migrations = await database.query<{ migration_name: string }>( + "SELECT migration_name FROM public._prisma_migrations WHERE finished_at IS NOT NULL ORDER BY started_at", + ) + expect(migrations.rows).toEqual([ + { migration_name: "20261029203000_identity_foundation" }, + { migration_name: "20261030170000_authentication_proof" }, + ]) + const c2Tables = await database.query<{ table_name: string }>( + `SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('admission_handoffs', 'authentication_rate_limits') + ORDER BY table_name`, + ) + expect(c2Tables.rows).toEqual([ + { table_name: "admission_handoffs" }, + { table_name: "authentication_rate_limits" }, + ]) + + const providerId = "00000000-0000-4000-8000-000000000010" + const revisionId = "00000000-0000-4000-8000-000000000011" + const userId = "00000000-0000-4000-8000-000000000012" + const identityId = "00000000-0000-4000-8000-000000000013" + await database.query("BEGIN") + await database.query( + `INSERT INTO authentication_providers (id, key) VALUES ($1, 'github')`, + [providerId], + ) + await database.query( + `INSERT INTO authentication_provider_revisions + (id, provider_id, revision, configuration_digest) + VALUES ($1, $2, 1, $3)`, + [revisionId, providerId, "a".repeat(64)], + ) + await database.query( + `UPDATE authentication_providers SET selected_revision_id = $1 WHERE id = $2`, + [revisionId, providerId], + ) + await database.query(`INSERT INTO users (id) VALUES ($1)`, [userId]) + await database.query( + `INSERT INTO external_identities + (id, user_id, provider, issuer, subject) + VALUES ($1, $2, 'github', 'https://github.com', '123')`, + [identityId, userId], + ) + await database.query("COMMIT") + + const attemptId = "00000000-0000-4000-8000-000000000014" + await expect( + database.query( + `INSERT INTO authorization_attempts + (id, provider_revision_id, state_digest, browser_binding_digest, + code_challenge, code_verifier_ciphertext, request_ip_digest, expires_at) + VALUES ($1, $2, 'bad', $3, $4, 'encrypted', $5, CURRENT_TIMESTAMP + interval '1 minute')`, + [attemptId, revisionId, "b".repeat(64), "c".repeat(43), "d".repeat(64)], + ), + ).rejects.toThrow() + await database.query( + `INSERT INTO authorization_attempts + (id, provider_revision_id, state_digest, browser_binding_digest, + code_challenge, code_verifier_ciphertext, request_ip_digest, expires_at) + VALUES ($1, $2, $3, $4, $5, 'encrypted', $6, CURRENT_TIMESTAMP + interval '1 minute')`, + [attemptId, revisionId, "a".repeat(64), "b".repeat(64), "c".repeat(43), "d".repeat(64)], + ) + await expect( + database.query(`DELETE FROM authorization_attempts WHERE id = $1`, [attemptId]), + ).rejects.toThrow("Unexpired authorization attempts cannot be deleted") + await database.query( + `UPDATE authorization_attempts SET consumed_at = CURRENT_TIMESTAMP WHERE id = $1`, + [attemptId], + ) + await expect( + database.query( + `UPDATE authorization_attempts SET consumed_at = CURRENT_TIMESTAMP WHERE id = $1`, + [attemptId], + ), + ).rejects.toThrow("first consumption") + + const handoffId = "00000000-0000-4000-8000-000000000015" + await expect( + database.query( + `INSERT INTO admission_handoffs + (id, external_identity_id, provider_revision_id, token_digest, + browser_binding_digest, revocation_generation, display_attributes, expires_at) + VALUES ($1, $2, $3, $4, $5, 1, '[]'::jsonb, CURRENT_TIMESTAMP + interval '1 minute')`, + [handoffId, identityId, revisionId, "e".repeat(64), "f".repeat(64)], + ), + ).rejects.toThrow() + await database.query( + `INSERT INTO admission_handoffs + (id, external_identity_id, provider_revision_id, token_digest, + browser_binding_digest, revocation_generation, display_attributes, expires_at) + VALUES ($1, $2, $3, $4, $5, 1, '{"login":"octocat"}'::jsonb, + CURRENT_TIMESTAMP + interval '1 minute')`, + [handoffId, identityId, revisionId, "e".repeat(64), "f".repeat(64)], + ) + await expect( + database.query(`DELETE FROM admission_handoffs WHERE id = $1`, [handoffId]), + ).rejects.toThrow("Unexpired admission handoffs cannot be deleted") + await database.query( + `UPDATE admission_handoffs SET consumed_at = CURRENT_TIMESTAMP WHERE id = $1`, + [handoffId], + ) + await expect( + database.query( + `UPDATE admission_handoffs SET display_attributes = '{"login":"changed"}'::jsonb + WHERE id = $1`, + [handoffId], + ), + ).rejects.toThrow("first consumption") + } finally { + await database.end() + } + } finally { + await admin.query(`DROP DATABASE IF EXISTS ${pg.escapeIdentifier(name)} WITH (FORCE)`) + await admin.end() + } + }, 30_000) +}) diff --git a/hub/test/database/identity-foundation/index.db.test.ts b/hub/test/database/identity-foundation/index.db.test.ts index 17631e37..0825406f 100644 --- a/hub/test/database/identity-foundation/index.db.test.ts +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -29,7 +29,7 @@ async function createProviderAggregate(key: string, digest = "a".repeat(64)) { } describe("identity foundation migration", () => { - it("deploys the committed baseline with only C1 application tables", async () => { + it("deploys C1 followed by the C2 authentication proof migration", async () => { const migrations = await prisma.$queryRaw>` SELECT migration_name FROM public._prisma_migrations @@ -45,10 +45,17 @@ describe("identity foundation migration", () => { ORDER BY table_name ` - expect(migrations).toEqual([{ migration_name: "20261029203000_identity_foundation" }]) + expect(migrations).toEqual([ + { migration_name: "20261029203000_identity_foundation" }, + { migration_name: "20261030170000_authentication_proof" }, + ]) expect(tables).toEqual([ + { table_name: "admission_handoffs" }, { table_name: "authentication_provider_revisions" }, { table_name: "authentication_providers" }, + { table_name: "authentication_rate_limits" }, + { table_name: "authentication_revocation_generations" }, + { table_name: "authorization_attempts" }, { table_name: "external_identities" }, { table_name: "security_audits" }, { table_name: "users" }, @@ -70,6 +77,213 @@ describe("identity foundation migration", () => { ]) }) + it("enforces one-use authentication history and monotonic global revocation", async () => { + const generation = await prisma.authenticationRevocationGeneration.findUniqueOrThrow({ + where: { key: "global" }, + }) + + await expect( + prisma.authenticationRevocationGeneration.update({ + where: { key: "global" }, + data: { generation: generation.generation }, + }), + ).rejects.toThrow("Authentication revocation generation may only increase") + await expect( + prisma.authenticationRevocationGeneration.delete({ where: { key: "global" } }), + ).rejects.toThrow("Authentication revocation generation may only increase") + }) + + it("enforces bounded authentication rate-limit bucket state", async () => { + await expect( + prisma.authenticationRateLimit.create({ + data: { + id: randomUUID(), + scope: "unknown", + keyDigest: "a".repeat(64), + windowStartedAt: new Date(), + reservationCount: 1, + }, + }), + ).rejects.toThrow() + await expect( + prisma.authenticationRateLimit.create({ + data: { + id: randomUUID(), + scope: "github_start", + keyDigest: "invalid", + windowStartedAt: new Date(), + reservationCount: 1, + }, + }), + ).rejects.toThrow() + await expect( + prisma.authenticationRateLimit.create({ + data: { + id: randomUUID(), + scope: "github_start", + keyDigest: "a".repeat(64), + windowStartedAt: new Date(), + reservationCount: 0, + }, + }), + ).rejects.toThrow() + }) + + it("enforces authorization attempt constraints and first-consumption-only history", async () => { + const { revisionId } = await createProviderAggregate("github") + const createdAt = new Date() + const attempt = { + id: randomUUID(), + providerRevisionId: revisionId, + stateDigest: "a".repeat(64), + browserBindingDigest: "b".repeat(64), + codeChallenge: "c".repeat(43), + codeVerifierCiphertext: "encrypted", + requestIpDigest: "d".repeat(64), + expiresAt: new Date(createdAt.getTime() + 60_000), + } + await prisma.authorizationAttempt.create({ data: attempt }) + + await expect( + prisma.authorizationAttempt.create({ + data: { ...attempt, id: randomUUID(), stateDigest: "invalid" }, + }), + ).rejects.toThrow() + await expect( + prisma.authorizationAttempt.create({ + data: { + ...attempt, + id: randomUUID(), + stateDigest: "e".repeat(64), + expiresAt: new Date(0), + }, + }), + ).rejects.toThrow() + await expect( + prisma.authorizationAttempt.create({ + data: { + ...attempt, + id: randomUUID(), + stateDigest: "f".repeat(64), + providerRevisionId: randomUUID(), + }, + }), + ).rejects.toMatchObject({ code: "P2003" }) + await expect(prisma.authorizationAttempt.delete({ where: { id: attempt.id } })).rejects.toThrow( + "Unexpired authorization attempts cannot be deleted", + ) + + const consumedAt = new Date(Date.now() + 1_000) + await prisma.authorizationAttempt.update({ + where: { id: attempt.id }, + data: { consumedAt }, + }) + await expect( + prisma.authorizationAttempt.update({ + where: { id: attempt.id }, + data: { consumedAt: new Date(consumedAt.getTime() + 1_000) }, + }), + ).rejects.toThrow("first consumption") + await expect( + prisma.authorizationAttempt.update({ + where: { id: attempt.id }, + data: { stateDigest: "f".repeat(64) }, + }), + ).rejects.toThrow("first consumption") + }) + + it("enforces admission handoff evidence, ownership, and one-use history", async () => { + const { revisionId } = await createProviderAggregate("github") + const userId = randomUUID() + const externalIdentityId = randomUUID() + await createUser(prisma, { id: userId }) + await createExternalIdentity(prisma, { + id: externalIdentityId, + userId, + provider: "github", + issuer: "https://github.com", + subject: "123", + }) + const handoff = { + id: randomUUID(), + externalIdentityId, + providerRevisionId: revisionId, + tokenDigest: "a".repeat(64), + browserBindingDigest: "b".repeat(64), + revocationGeneration: 1n, + displayAttributes: { login: "octocat" }, + expiresAt: new Date(Date.now() + 60_000), + } + await prisma.admissionHandoff.create({ data: handoff }) + + await expect( + prisma.admissionHandoff.create({ + data: { ...handoff, id: randomUUID(), tokenDigest: "invalid" }, + }), + ).rejects.toThrow() + await expect( + prisma.admissionHandoff.create({ + data: { + ...handoff, + id: randomUUID(), + tokenDigest: "f".repeat(64), + expiresAt: new Date(0), + }, + }), + ).rejects.toThrow() + await expect( + prisma.admissionHandoff.create({ + data: { + ...handoff, + id: randomUUID(), + tokenDigest: "c".repeat(64), + displayAttributes: ["not", "an", "object"], + }, + }), + ).rejects.toThrow() + await expect( + prisma.admissionHandoff.create({ + data: { + ...handoff, + id: randomUUID(), + tokenDigest: "d".repeat(64), + displayAttributes: { detail: "x".repeat(5_000) }, + }, + }), + ).rejects.toThrow() + await expect( + prisma.admissionHandoff.create({ + data: { + ...handoff, + id: randomUUID(), + tokenDigest: "e".repeat(64), + providerRevisionId: randomUUID(), + }, + }), + ).rejects.toMatchObject({ code: "P2003" }) + await expect(prisma.admissionHandoff.delete({ where: { id: handoff.id } })).rejects.toThrow( + "Unexpired admission handoffs cannot be deleted", + ) + + const consumedAt = new Date(Date.now() + 1_000) + await prisma.admissionHandoff.update({ + where: { id: handoff.id }, + data: { consumedAt }, + }) + await expect( + prisma.admissionHandoff.update({ + where: { id: handoff.id }, + data: { consumedAt: new Date(consumedAt.getTime() + 1_000) }, + }), + ).rejects.toThrow("first consumption") + await expect( + prisma.admissionHandoff.update({ + where: { id: handoff.id }, + data: { displayAttributes: { login: "changed" } }, + }), + ).rejects.toThrow("first consumption") + }) + it("keeps the identity tuple unique and rejects every identity history change", async () => { const firstUserId = randomUUID() const secondUserId = randomUUID() diff --git a/hub/test/database/reset-database/index.ts b/hub/test/database/reset-database/index.ts index 9860f176..81d749f0 100644 --- a/hub/test/database/reset-database/index.ts +++ b/hub/test/database/reset-database/index.ts @@ -34,6 +34,17 @@ export async function resetDatabase(databaseUrl: string) { ) await client.query(`TRUNCATE ${names.join(", ")} RESTART IDENTITY CASCADE`) } + if ( + tables.rows.some( + ({ schemaname, tablename }) => + schemaname === "public" && tablename === "authentication_revocation_generations", + ) + ) { + await client.query( + `INSERT INTO public.authentication_revocation_generations (id, key, generation) + VALUES ('00000000-0000-4000-8000-000000000001', 'global', 1)`, + ) + } } finally { await client.end() }