From f0da5c76ddb2600891ceb0dfbac16f7edf992058 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 09:46:43 -0700 Subject: [PATCH 01/17] feat(hub): add GitHub authentication proof Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/schema/admission-handoff.prisma | 26 +++ .../authentication-provider-revision.prisma | 6 +- ...uthentication-revocation-generation.prisma | 14 ++ hub/schema/authorization-attempt.prisma | 25 +++ hub/schema/external-identity.prisma | 3 +- .../migration.sql | 193 ++++++++++++++++++ hub/server/create-app/index.ts | 10 +- hub/server/create-app/types.ts | 5 +- .../complete-authentication-proof/index.ts | 102 +++++++++ .../complete-authentication-proof/types.ts | 50 +++++ .../consume-admission-handoff/index.ts | 127 ++++++++++++ .../create-admission-handoff/index.ts | 64 ++++++ .../index.ts | 127 ++++++++++++ .../types.ts | 14 ++ hub/server/domains/authentication/errors.ts | 9 + .../index.ts | 56 +++++ .../start-authentication-proof/index.ts | 78 +++++++ .../start-authentication-proof/types.ts | 42 ++++ hub/server/domains/authentication/types.ts | 23 +++ .../express/read-trusted-proxy-hops/index.ts | 8 + .../index.ts | 136 ++++++++++++ .../types.ts | 20 ++ .../create-admission-handoff/index.ts | 30 +++ .../create-admission-handoff/types.ts | 15 ++ .../index.ts | 8 + .../index.ts | 11 + .../index.ts | 10 + .../consume-authorization-attempt/index.ts | 34 +++ .../consume-authorization-attempt/types.ts | 12 ++ .../index.ts | 13 ++ .../create-authorization-attempt/index.ts | 26 +++ .../create-authorization-attempt/types.ts | 17 ++ .../index.ts | 8 + .../create-security-audit/types.ts | 29 +++ .../get-security-audit-data/index.ts | 43 ++++ .../parse-security-audit-event/index.ts | 49 +++++ .../api/auth/github/callback/get/index.ts | 53 +++++ .../api/auth/github/start/post/index.ts | 36 ++++ hub/server/routes/api/auth/github/types.ts | 18 ++ hub/server/routes/index.ts | 9 +- hub/server/routes/types.ts | 2 + hub/server/run-api/index.ts | 17 +- 42 files changed, 1569 insertions(+), 9 deletions(-) create mode 100644 hub/schema/admission-handoff.prisma create mode 100644 hub/schema/authentication-revocation-generation.prisma create mode 100644 hub/schema/authorization-attempt.prisma create mode 100644 hub/schema/migrations/20261030170000_authentication_proof/migration.sql create mode 100644 hub/server/domains/authentication/complete-authentication-proof/index.ts create mode 100644 hub/server/domains/authentication/complete-authentication-proof/types.ts create mode 100644 hub/server/domains/authentication/consume-admission-handoff/index.ts create mode 100644 hub/server/domains/authentication/create-admission-handoff/index.ts create mode 100644 hub/server/domains/authentication/create-github-authentication-services/index.ts create mode 100644 hub/server/domains/authentication/create-github-authentication-services/types.ts create mode 100644 hub/server/domains/authentication/errors.ts create mode 100644 hub/server/domains/authentication/read-github-authentication-configuration/index.ts create mode 100644 hub/server/domains/authentication/start-authentication-proof/index.ts create mode 100644 hub/server/domains/authentication/start-authentication-proof/types.ts create mode 100644 hub/server/domains/authentication/types.ts create mode 100644 hub/server/integrations/express/read-trusted-proxy-hops/index.ts create mode 100644 hub/server/integrations/github/create-user-authentication-adapter/index.ts create mode 100644 hub/server/integrations/github/create-user-authentication-adapter/types.ts create mode 100644 hub/server/repositories/admission-handoffs/create-admission-handoff/index.ts create mode 100644 hub/server/repositories/admission-handoffs/create-admission-handoff/types.ts create mode 100644 hub/server/repositories/admission-handoffs/delete-expired-admission-handoffs/index.ts create mode 100644 hub/server/repositories/authentication-revocation-generations/get-authentication-revocation-generation/index.ts create mode 100644 hub/server/repositories/authentication-revocation-generations/increment-authentication-revocation-generation/index.ts create mode 100644 hub/server/repositories/authorization-attempts/consume-authorization-attempt/index.ts create mode 100644 hub/server/repositories/authorization-attempts/consume-authorization-attempt/types.ts create mode 100644 hub/server/repositories/authorization-attempts/count-recent-authorization-attempts/index.ts create mode 100644 hub/server/repositories/authorization-attempts/create-authorization-attempt/index.ts create mode 100644 hub/server/repositories/authorization-attempts/create-authorization-attempt/types.ts create mode 100644 hub/server/repositories/authorization-attempts/delete-expired-authorization-attempts/index.ts create mode 100644 hub/server/routes/api/auth/github/callback/get/index.ts create mode 100644 hub/server/routes/api/auth/github/start/post/index.ts create mode 100644 hub/server/routes/api/auth/github/types.ts 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-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..857338db --- /dev/null +++ b/hub/schema/migrations/20261030170000_authentication_proof/migration.sql @@ -0,0 +1,193 @@ +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 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"); + +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/complete-authentication-proof/index.ts b/hub/server/domains/authentication/complete-authentication-proof/index.ts new file mode 100644 index 00000000..cc5e651f --- /dev/null +++ b/hub/server/domains/authentication/complete-authentication-proof/index.ts @@ -0,0 +1,102 @@ +import { z } from "zod" + +import { hashAuthenticationValue } from "../../../utils/authentication/hash-authentication-value/index.ts" +import { AuthenticationProofError } from "../errors.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) { + await dependencies.writeDenialAudit("invalid_or_expired") + throw new AuthenticationProofError( + "attempt_invalid", + "The authentication attempt is invalid or has expired.", + ) + } + + const selectedBeforeProof = await dependencies.getSelectedProviderRevision() + if (!selectedBeforeProof || selectedBeforeProof.id !== attempt.providerRevisionId) { + await dependencies.writeDenialAudit("configuration_changed") + throw new AuthenticationProofError( + "configuration_changed", + "The authentication provider configuration changed during this attempt.", + ) + } + + let evidence + try { + evidence = await dependencies.adapter.completeAuthenticationProof({ + callbackUri: dependencies.callbackUri, + code: parsed.code, + codeVerifier: dependencies.decryptCodeVerifier(attempt.codeVerifierCiphertext), + }) + } catch (error) { + await dependencies.writeDenialAudit("provider_failed") + throw new AuthenticationProofError( + "provider_failed", + "The authentication provider could not prove the external identity.", + { cause: error }, + ) + } + if (evidence.provider !== dependencies.adapter.providerKey) { + await dependencies.writeDenialAudit("provider_failed") + throw new AuthenticationProofError( + "provider_failed", + "The authentication provider returned mismatched identity evidence.", + ) + } + + const selectedAfterProof = await dependencies.getSelectedProviderRevision() + if (!selectedAfterProof || selectedAfterProof.id !== attempt.providerRevisionId) { + await dependencies.writeDenialAudit("configuration_changed") + throw new AuthenticationProofError( + "configuration_changed", + "The authentication provider configuration changed during this attempt.", + ) + } + 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) + await dependencies.createHandoff({ + externalIdentityId: identity.externalIdentityId, + providerRevisionId: attempt.providerRevisionId, + browserBindingDigest, + tokenDigest: hashAuthenticationValue(handoffToken), + displayAttributes: evidence.attributes, + expiresAt, + }) + + 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..d8316a05 --- /dev/null +++ b/hub/server/domains/authentication/complete-authentication-proof/types.ts @@ -0,0 +1,50 @@ +import type { AuthenticationProviderAdapter, PublicIdentityAttributes } from "../types.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 + expiresAt: Date + externalIdentityId: string + providerRevisionId: string + tokenDigest: string + }): Promise + createOpaqueValue(): string + decryptCodeVerifier(value: string): string + getSelectedProviderRevision(): 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..24f66058 --- /dev/null +++ b/hub/server/domains/authentication/consume-admission-handoff/index.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto" + +import { Prisma } from "../../../generated/prisma/client.ts" +import { z } from "zod" + +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 { 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 type { PublicIdentityAttributes } from "../types.ts" + +type DenialReason = + "browser_mismatch" | "expired" | "provider_revision_changed" | "replayed" | "revoked" + +interface Database { + $transaction( + operation: (transaction: Prisma.TransactionClient) => Promise, + options?: { isolationLevel?: Prisma.TransactionIsolationLevel }, + ): Promise +} + +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 async function consumeAuthenticationAdmissionHandoff( + database: Database, + input: { + browserBinding: string + handoffToken: string + now: Date + providerKey: string + }, + createId: () => string = randomUUID, +): Promise { + const tokenDigest = hashAuthenticationValue(input.handoffToken) + const browserBindingDigest = hashAuthenticationValue(input.browserBinding) + + return runWithBoundedRetries( + () => + database + .$transaction( + async (transaction): Promise => { + const handoff = await transaction.admissionHandoff.findUnique({ + where: { tokenDigest }, + }) + let reason: DenialReason | null = null + if (!handoff || handoff.consumedAt) reason = "replayed" + else if (handoff.expiresAt <= input.now) reason = "expired" + else if (handoff.browserBindingDigest !== browserBindingDigest) + reason = "browser_mismatch" + else if ( + handoff.revocationGeneration !== + (await getAuthenticationRevocationGeneration(transaction)) + ) { + reason = "revoked" + } else { + const selectedRevision = await getSelectedProviderRevision(input.providerKey, { + findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), + }) + if (selectedRevision?.id !== handoff.providerRevisionId) { + reason = "provider_revision_changed" + } + } + + if (reason) { + await createSecurityAudit(transaction, { + id: createId(), + type: "authentication_handoff_denied", + ...(handoff ? { handoffId: handoff.id } : {}), + reason, + }) + return { status: "denied", reason } + } + + const consumed = await transaction.admissionHandoff.updateMany({ + where: { id: handoff!.id, consumedAt: null }, + data: { consumedAt: input.now }, + }) + if (consumed.count !== 1) { + await createSecurityAudit(transaction, { + id: createId(), + type: "authentication_handoff_denied", + handoffId: handoff!.id, + reason: "replayed", + }) + return { status: "denied", reason: "replayed" } + } + await createSecurityAudit(transaction, { + id: createId(), + type: "authentication_handoff_consumed", + handoffId: handoff!.id, + externalIdentityId: handoff!.externalIdentityId, + }) + return { + status: "consumed", + externalIdentityId: handoff!.externalIdentityId, + attributes: attributesSchema.parse(handoff!.displayAttributes), + } + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + .catch((error: unknown) => { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034") { + throw new IdentityTransactionConflictError({ cause: error }) + } + throw error + }), + (error) => error instanceof IdentityTransactionConflictError, + ) +} 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..20df560e --- /dev/null +++ b/hub/server/domains/authentication/create-admission-handoff/index.ts @@ -0,0 +1,64 @@ +import { randomUUID } from "node:crypto" + +import { Prisma } from "../../../generated/prisma/client.ts" +import type { PublicIdentityAttributes } from "../types.ts" +import { createAdmissionHandoff } from "../../../repositories/admission-handoffs/create-admission-handoff/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 { getSelectedProviderRevision } from "../../identity/get-selected-provider-revision/index.ts" +import { AuthenticationProofError } from "../errors.ts" + +interface Database { + $transaction( + operation: (transaction: Prisma.TransactionClient) => Promise, + options: { isolationLevel: Prisma.TransactionIsolationLevel }, + ): Promise +} + +export async function createAuthenticationAdmissionHandoff( + database: Database, + input: { + browserBindingDigest: string + displayAttributes: PublicIdentityAttributes + expiresAt: Date + externalIdentityId: string + providerKey: string + providerRevisionId: string + tokenDigest: string + }, + createId: () => string = randomUUID, +): Promise { + await database.$transaction( + async (transaction) => { + const selectedRevision = await getSelectedProviderRevision(input.providerKey, { + findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), + }) + if (selectedRevision?.id !== input.providerRevisionId) { + throw new AuthenticationProofError( + "configuration_changed", + "The authentication provider configuration changed before handoff creation.", + ) + } + const handoffId = createId() + await createAdmissionHandoff(transaction, { + id: handoffId, + externalIdentityId: input.externalIdentityId, + providerRevisionId: input.providerRevisionId, + tokenDigest: input.tokenDigest, + browserBindingDigest: input.browserBindingDigest, + revocationGeneration: await getAuthenticationRevocationGeneration(transaction), + displayAttributes: input.displayAttributes, + expiresAt: input.expiresAt, + }) + await createSecurityAudit(transaction, { + id: createId(), + type: "authentication_handoff_created", + handoffId, + externalIdentityId: input.externalIdentityId, + providerRevisionId: input.providerRevisionId, + }) + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) +} 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..1b2c0e01 --- /dev/null +++ b/hub/server/domains/authentication/create-github-authentication-services/index.ts @@ -0,0 +1,127 @@ +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 { countRecentAuthorizationAttempts } from "../../../repositories/authorization-attempts/count-recent-authorization-attempts/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 { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" +import type { GitHubAuthenticationRoutesDependencies } from "../../../routes/api/auth/github/types.ts" +import { createBoundedRateLimiter } from "../../../utils/authentication/create-bounded-rate-limiter/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 { 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 { startAuthenticationProof } from "../start-authentication-proof/index.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, + callbackRateLimiter: createBoundedRateLimiter({ + limit: 20, + maxKeys: 10_000, + windowMs: 10 * 60 * 1_000, + }), + 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, + recentAttemptCount: (attempt) => countRecentAuthorizationAttempts(prisma, attempt), + 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(prisma, { + ...handoff, + providerKey: adapter.providerKey, + }), + createOpaqueValue, + decryptCodeVerifier: (value) => + decryptAuthenticationValue(value, configuration.encryptionKey), + getSelectedProviderRevision: getRevision, + 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.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/start-authentication-proof/index.ts b/hub/server/domains/authentication/start-authentication-proof/index.ts new file mode 100644 index 00000000..3c3dba18 --- /dev/null +++ b/hub/server/domains/authentication/start-authentication-proof/index.ts @@ -0,0 +1,78 @@ +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 type { + StartAuthenticationProofDependencies, + StartAuthenticationProofInput, + StartAuthenticationProofResult, +} from "./types.ts" + +const inputSchema = z.object({ requestIp: z.string().min(1).max(200) }).strict() +const ATTEMPT_LIFETIME_MS = 10 * 60 * 1_000 +const INITIATION_WINDOW_MS = 10 * 60 * 1_000 +const INITIATION_LIMIT = 10 + +export async function startAuthenticationProof( + input: StartAuthenticationProofInput, + dependencies: StartAuthenticationProofDependencies, +): Promise { + const parsed = inputSchema.parse(input) + const now = dependencies.now() + await dependencies.deleteExpiredArtifacts(now) + const requestIpDigest = hashAuthenticationValue(parsed.requestIp) + const recentAttemptCount = await dependencies.recentAttemptCount({ + requestIpDigest, + createdAfter: new Date(now.getTime() - INITIATION_WINDOW_MS), + }) + if (recentAttemptCount >= INITIATION_LIMIT) { + await dependencies.writeDenialAudit("rate_limited") + throw new AuthenticationProofError( + "rate_limited", + "Too many authentication attempts were started.", + ) + } + + const selectedRevision = await dependencies.getSelectedProviderRevision() + if ( + !selectedRevision || + selectedRevision.revision !== dependencies.expectedRevision || + selectedRevision.configurationDigest !== dependencies.configurationDigest + ) { + await dependencies.writeDenialAudit("configuration_changed") + throw new AuthenticationProofError( + "configuration_changed", + "The selected authentication provider configuration is unavailable.", + ) + } + + 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, + 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..52009fa6 --- /dev/null +++ b/hub/server/domains/authentication/start-authentication-proof/types.ts @@ -0,0 +1,42 @@ +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 + recentAttemptCount(input: { createdAfter: Date; requestIpDigest: string }): Promise + writeDenialAudit(reason: "configuration_changed" | "rate_limited"): Promise +} + +export interface StartAuthenticationProofInput { + requestIp: 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..435f09e0 --- /dev/null +++ b/hub/server/domains/authentication/types.ts @@ -0,0 +1,23 @@ +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 +} 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.ts b/hub/server/integrations/github/create-user-authentication-adapter/index.ts new file mode 100644 index 00000000..f5de797e --- /dev/null +++ b/hub/server/integrations/github/create-user-authentication-adapter/index.ts @@ -0,0 +1,136 @@ +import { z } from "zod" + +import type { + ImmutableIdentityEvidence, + PublicIdentityAttributes, +} from "../../../domains/authentication/types.ts" +import type { + CreateGitHubUserAuthenticationAdapterInput, + GitHubAuthenticationPublicConfiguration, + 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(), +}) + +function assertHttpsProviderUrl(value: string, field: string): void { + if (new URL(value).protocol !== "https:") { + throw new Error(`GitHub ${field} must use HTTPS.`) + } +} + +function canonicalize(configuration: GitHubAuthenticationPublicConfiguration): string { + return JSON.stringify({ + authorizationEndpoint: configuration.authorizationEndpoint, + callbackUri: configuration.callbackUri, + clientId: configuration.clientId, + tokenEndpoint: configuration.tokenEndpoint, + userEndpoint: configuration.userEndpoint, + }) +} + +function attributes(input: z.infer): PublicIdentityAttributes { + return { + avatarUrl: input.avatar_url, + email: input.email, + login: input.login, + name: input.name, + } +} + +export function createGitHubUserAuthenticationAdapter( + input: CreateGitHubUserAuthenticationAdapterInput, +): GitHubUserAuthenticationAdapter { + const configuration = publicConfigurationSchema.parse(input.publicConfiguration) + assertHttpsProviderUrl(configuration.authorizationEndpoint, "authorization endpoint") + assertHttpsProviderUrl(configuration.tokenEndpoint, "token endpoint") + assertHttpsProviderUrl(configuration.userEndpoint, "user endpoint") + 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: canonicalize(configuration), + 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, + }): Promise { + if (callbackUri !== configuration.callbackUri) { + throw new Error("GitHub callback URI does not match the configured callback URI.") + } + const 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", + }) + if (!tokenResponse.ok) throw new Error("GitHub rejected the authorization code.") + const token = tokenResponseSchema.parse(await tokenResponse.json()) + + const userResponse = await request(configuration.userEndpoint, { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token.access_token}`, + "x-github-api-version": "2022-11-28", + }, + redirect: "error", + }) + if (!userResponse.ok) throw new Error("GitHub did not return the authenticated user.") + const user = userResponseSchema.parse(await userResponse.json()) + + return { + provider: "github", + issuer: "https://github.com", + subject: String(user.id), + attributes: attributes(user), + } + }, + } +} 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..cb8a61de --- /dev/null +++ b/hub/server/integrations/github/create-user-authentication-adapter/types.ts @@ -0,0 +1,20 @@ +import type { AuthenticationProviderAdapter } from "../../../domains/authentication/types.ts" + +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 GitHubUserAuthenticationAdapter extends AuthenticationProviderAdapter { + canonicalPublicConfiguration: string + callbackUri: string +} 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..d930e87f --- /dev/null +++ b/hub/server/repositories/admission-handoffs/create-admission-handoff/types.ts @@ -0,0 +1,15 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { PublicIdentityAttributes } from "../../../domains/authentication/types.ts" + +export type CreateAdmissionHandoffDatabase = Pick + +export interface CreateAdmissionHandoffInput { + browserBindingDigest: string + displayAttributes: PublicIdentityAttributes + 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/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/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/count-recent-authorization-attempts/index.ts b/hub/server/repositories/authorization-attempts/count-recent-authorization-attempts/index.ts new file mode 100644 index 00000000..ed56eb3d --- /dev/null +++ b/hub/server/repositories/authorization-attempts/count-recent-authorization-attempts/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function countRecentAuthorizationAttempts( + database: Pick, + input: { requestIpDigest: string; createdAfter: Date }, +) { + return database.authorizationAttempt.count({ + where: { + requestIpDigest: input.requestIpDigest, + createdAt: { gt: input.createdAfter }, + }, + }) +} 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/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..f16aa4a2 --- /dev/null +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -0,0 +1,53 @@ +import type { RequestHandler } from "express" +import { z } from "zod" + +import { AuthenticationProofError } from "../../../../../../domains/authentication/errors.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 now = dependencies.now() + const requestIp = request.ip ?? request.socket.remoteAddress ?? "unavailable" + if (!dependencies.callbackRateLimiter.take(requestIp, now)) { + response.status(429).json({ error: "Too many authentication callbacks were received." }) + return + } + const parsed = querySchema.safeParse(request.query) + const browserBinding = 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/start/post/index.ts b/hub/server/routes/api/auth/github/start/post/index.ts new file mode 100644 index 00000000..4313077a --- /dev/null +++ b/hub/server/routes/api/auth/github/start/post/index.ts @@ -0,0 +1,36 @@ +import type { RequestHandler } from "express" + +import { AuthenticationProofError } from "../../../../../../domains/authentication/errors.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 + } + try { + const result = await dependencies.start({ + requestIp: request.ip ?? request.socket.remoteAddress ?? "unavailable", + }) + 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..e343f19b --- /dev/null +++ b/hub/server/routes/api/auth/github/types.ts @@ -0,0 +1,18 @@ +import type { + CompleteAuthenticationProofInput, + CompleteAuthenticationProofResult, +} from "../../../../domains/authentication/complete-authentication-proof/types.ts" +import type { + StartAuthenticationProofInput, + StartAuthenticationProofResult, +} from "../../../../domains/authentication/start-authentication-proof/types.ts" +import type { BoundedRateLimiter } from "../../../../utils/authentication/create-bounded-rate-limiter/index.ts" + +export interface GitHubAuthenticationRoutesDependencies { + appOrigin: string + callbackRateLimiter: BoundedRateLimiter + complete(input: CompleteAuthenticationProofInput): Promise + now(): Date + secureCookies: boolean + start(input: StartAuthenticationProofInput): Promise +} 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.ts b/hub/server/run-api/index.ts index c41852e6..9a802b7d 100644 --- a/hub/server/run-api/index.ts +++ b/hub/server/run-api/index.ts @@ -2,6 +2,9 @@ import { createServer } from "node:http" import type { Logger } from "pino" import { createApp } from "../create-app/index.ts" +import { createGitHubAuthenticationServices } from "../domains/authentication/create-github-authentication-services/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" @@ -22,7 +25,19 @@ export async function runApi({ } const server = createServer() const realtime = attachRealtimeServer({ server, path: "/api/realtime", logger }) - server.on("request", createApp({ logger, broadcast: realtime.broadcast })) + const authenticationConfiguration = readGitHubAuthenticationConfiguration(process.env) + const githubAuthentication = authenticationConfiguration + ? await createGitHubAuthenticationServices(authenticationConfiguration) + : undefined + server.on( + "request", + createApp({ + logger, + broadcast: realtime.broadcast, + githubAuthentication, + trustedProxyHops: readTrustedProxyHops(process.env.HUB_TRUSTED_PROXY_HOPS), + }), + ) let finishBinding!: () => void const binding = new Promise((resolve) => { finishBinding = resolve From 085a363965fdf98f97ffb3d9726c8f48c6dd8952 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 09:46:53 -0700 Subject: [PATCH 02/17] test(hub): cover authentication proof boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../authentication-proof.db.test.ts | 186 ++++++++++++++++++ .../index.test.ts | 114 +++++++++++ .../index.test.ts | 56 ++++++ .../start-authentication-proof/index.test.ts | 95 +++++++++ .../read-trusted-proxy-hops/index.test.ts | 13 ++ .../index.test.ts | 120 +++++++++++ .../create-security-audit/index.test.ts | 50 +++++ .../routes/api/auth/github/index.test.ts | 169 ++++++++++++++++ .../index.db.test.ts | 64 ++++++ .../identity-foundation/index.db.test.ts | 25 ++- hub/test/database/reset-database/index.ts | 11 ++ 11 files changed, 901 insertions(+), 2 deletions(-) create mode 100644 hub/server/domains/authentication/authentication-proof.db.test.ts create mode 100644 hub/server/domains/authentication/complete-authentication-proof/index.test.ts create mode 100644 hub/server/domains/authentication/read-github-authentication-configuration/index.test.ts create mode 100644 hub/server/domains/authentication/start-authentication-proof/index.test.ts create mode 100644 hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts create mode 100644 hub/server/integrations/github/create-user-authentication-adapter/index.test.ts create mode 100644 hub/server/routes/api/auth/github/index.test.ts create mode 100644 hub/test/database/authentication-proof-migration/index.db.test.ts 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..ef90953d --- /dev/null +++ b/hub/server/domains/authentication/authentication-proof.db.test.ts @@ -0,0 +1,186 @@ +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 { incrementAuthenticationRevocationGeneration } from "../../repositories/authentication-revocation-generations/increment-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" + +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", + now: new Date(Date.now() + 1_000), + } + const results = await Promise.all([ + consumeAuthenticationAdmissionHandoff(prisma, input), + consumeAuthenticationAdmissionHandoff(prisma, input), + ]) + 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("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(prisma, { + handoffToken: "revoked", + browserBinding: "browser", + providerKey: "github", + now, + }), + ).resolves.toEqual({ status: "denied", reason: "revoked" }) + + await addHandoff("drifted", 2n) + await createProvider(2) + await expect( + consumeAuthenticationAdmissionHandoff(prisma, { + handoffToken: "drifted", + browserBinding: "browser", + providerKey: "github", + now, + }), + ).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..38c53ab6 --- /dev/null +++ b/hub/server/domains/authentication/complete-authentication-proof/index.test.ts @@ -0,0 +1,114 @@ +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() + }) + 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), + }), + 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(JSON.stringify(createHandoff.mock.calls)).not.toMatch( + /authorization-code|pkce-verifier|handoff-secret/, + ) + 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(), + now: () => new Date(), + resolveOrCreateIdentity: vi.fn(), + writeDenialAudit: vi.fn(), + }, + ), + ).rejects.toMatchObject({ code: "attempt_invalid" }) + expect(completeProvider).not.toHaveBeenCalled() + }) +}) 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/start-authentication-proof/index.test.ts b/hub/server/domains/authentication/start-authentication-proof/index.test.ts new file mode 100644 index 00000000..2b25c8cb --- /dev/null +++ b/hub/server/domains/authentication/start-authentication-proof/index.test.ts @@ -0,0 +1,95 @@ +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( + { requestIp: "192.0.2.10" }, + { + 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"), + recentAttemptCount: () => Promise.resolve(0), + 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 rate limited or 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"), + recentAttemptCount: () => Promise.resolve(10), + writeDenialAudit: vi.fn(), + } + + await expect( + startAuthenticationProof({ requestIp: "192.0.2.10" }, dependencies), + ).rejects.toMatchObject({ code: "rate_limited" }) + dependencies.recentAttemptCount = () => Promise.resolve(0) + await expect( + startAuthenticationProof({ requestIp: "192.0.2.10" }, dependencies), + ).rejects.toMatchObject({ code: "configuration_changed" }) + expect(createAttempt).not.toHaveBeenCalled() + }) +}) 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/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..db421ff8 --- /dev/null +++ b/hub/server/integrations/github/create-user-authentication-adapter/index.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest" + +import { createGitHubUserAuthenticationAdapter } from "." + +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") + }) +}) 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/routes/api/auth/github/index.test.ts b/hub/server/routes/api/auth/github/index.test.ts new file mode 100644 index 00000000..20c36d18 --- /dev/null +++ b/hub/server/routes/api/auth/github/index.test.ts @@ -0,0 +1,169 @@ +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 { createBoundedRateLimiter } from "../../../../utils/authentication/create-bounded-rate-limiter" +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 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: "browser-binding-secret", + expiresAt: new Date("2026-10-30T16:10:00Z"), + }), + ) + return { + complete, + start, + value: { + appOrigin: "https://hub.example.test", + callbackRateLimiter: createBoundedRateLimiter({ + limit: 20, + maxKeys: 100, + windowMs: 60_000, + }), + complete, + now: () => new Date("2026-10-30T16:00:00Z"), + secureCookies: true, + start, + } satisfies GitHubAuthenticationRoutesDependencies, + } +} + +describe("GitHub authentication routes", () => { + it("starts only from the configured origin and sets a bounded browser cookie", async () => { + const { 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( + /^hub_auth_browser=browser-binding-secret; Path=\/api\/auth; Expires=.*; HttpOnly; Secure; SameSite=Lax$/, + ) + expect(start).toHaveBeenCalledWith({ requestIp: "192.0.2.10" }) + }) + + it("returns a non-admitted handoff and excludes callback secrets from HTTP logs", async () => { + const { 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=browser-binding-secret", + "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: "browser-binding-secret", + code: "authorization-code-secret", + state: "s".repeat(32), + }) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records.join("")).not.toMatch( + /authorization-code-secret|browser-binding-secret|handoff-secret/, + ) + }) + + it("rejects malformed, oversized, and rate-limited callbacks before proof completion", async () => { + const { complete, value } = dependencies() + value.callbackRateLimiter = createBoundedRateLimiter({ + limit: 1, + maxKeys: 100, + windowMs: 60_000, + }) + 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) + const limited = await fetch( + `${baseUrl}/api/auth/github/callback?code=code&state=${"s".repeat(32)}`, + { + headers: { + cookie: "hub_auth_browser=browser-binding-secret", + "x-forwarded-for": "192.0.2.10", + }, + }, + ) + expect(limited.status).toBe(429) + expect(complete).not.toHaveBeenCalled() + }) +}) 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..c9807659 --- /dev/null +++ b/hub/test/database/authentication-proof-migration/index.db.test.ts @@ -0,0 +1,64 @@ +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 handoff = await database.query<{ table_name: string }>( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'admission_handoffs'", + ) + expect(handoff.rows).toEqual([{ table_name: "admission_handoffs" }]) + } 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..0a39e7f2 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,16 @@ 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_revocation_generations" }, + { table_name: "authorization_attempts" }, { table_name: "external_identities" }, { table_name: "security_audits" }, { table_name: "users" }, @@ -70,6 +76,21 @@ 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("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() } From f5c34322711883a2e1896ebedf2def5597045feb Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 09:47:00 -0700 Subject: [PATCH 03/17] docs(hub): explain authentication proof boundary Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 16 ++++++++++++++++ hub/docs/README.md | 1 + hub/docs/architecture.md | 6 +++--- hub/docs/authentication.md | 29 +++++++++++++++++++++++++++++ hub/docs/database.md | 4 +++- hub/docs/development.md | 8 +++++--- 6 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 hub/docs/authentication.md 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..914212c4 --- /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 selects the configured GitHub provider revision, rate-limits the trusted request address, stores a one-use authorization attempt, sets an HTTP-only browser-binding cookie, and redirects to GitHub with state and PKCE. + +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. 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. From 01f26c11bd28789f1d22960cee58c55a9c3c3f19 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 09:48:16 -0700 Subject: [PATCH 04/17] feat(hub): add authentication safety utilities Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-bounded-rate-limiter/index.ts | 38 +++++++++++++++++++ .../create-opaque-value/index.ts | 5 +++ .../decrypt-authentication-value/index.ts | 18 +++++++++ .../encrypt-authentication-value/index.ts | 13 +++++++ .../hash-authentication-value/index.ts | 5 +++ hub/server/utils/http/read-cookie/index.ts | 12 ++++++ 6 files changed, 91 insertions(+) create mode 100644 hub/server/utils/authentication/create-bounded-rate-limiter/index.ts create mode 100644 hub/server/utils/authentication/create-opaque-value/index.ts create mode 100644 hub/server/utils/authentication/decrypt-authentication-value/index.ts create mode 100644 hub/server/utils/authentication/encrypt-authentication-value/index.ts create mode 100644 hub/server/utils/authentication/hash-authentication-value/index.ts create mode 100644 hub/server/utils/http/read-cookie/index.ts diff --git a/hub/server/utils/authentication/create-bounded-rate-limiter/index.ts b/hub/server/utils/authentication/create-bounded-rate-limiter/index.ts new file mode 100644 index 00000000..fb87de67 --- /dev/null +++ b/hub/server/utils/authentication/create-bounded-rate-limiter/index.ts @@ -0,0 +1,38 @@ +export interface BoundedRateLimiter { + take(key: string, now: Date): boolean +} + +export function createBoundedRateLimiter(input: { + limit: number + maxKeys: number + windowMs: number +}): BoundedRateLimiter { + if ( + !Number.isInteger(input.limit) || + input.limit < 1 || + !Number.isInteger(input.maxKeys) || + input.maxKeys < 1 || + !Number.isInteger(input.windowMs) || + input.windowMs < 1 + ) { + throw new Error("Rate limiter bounds must be positive integers.") + } + const entries = new Map() + + return { + take(key, now) { + const cutoff = now.getTime() - input.windowMs + const recent = (entries.get(key) ?? []).filter((timestamp) => timestamp > cutoff) + if (recent.length >= input.limit) { + entries.delete(key) + entries.set(key, recent) + return false + } + recent.push(now.getTime()) + entries.delete(key) + entries.set(key, recent) + while (entries.size > input.maxKeys) entries.delete(entries.keys().next().value!) + return true + }, + } +} 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/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 +} From 21f6df370d850ff35cb38b87334a3de736461e5e Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 09:51:42 -0700 Subject: [PATCH 05/17] fix(hub): serialize authentication handoff guards Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../consume-admission-handoff/index.ts | 19 ++++++++++++------- .../index.ts | 17 +++++++++++++++++ .../index.ts | 12 ++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 hub/server/repositories/authentication-providers/lock-authentication-provider-selection/index.ts create mode 100644 hub/server/repositories/authentication-revocation-generations/lock-authentication-revocation-generation/index.ts diff --git a/hub/server/domains/authentication/consume-admission-handoff/index.ts b/hub/server/domains/authentication/consume-admission-handoff/index.ts index 24f66058..db1a0f99 100644 --- a/hub/server/domains/authentication/consume-admission-handoff/index.ts +++ b/hub/server/domains/authentication/consume-admission-handoff/index.ts @@ -4,7 +4,9 @@ import { Prisma } from "../../../generated/prisma/client.ts" import { z } from "zod" 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 { hashAuthenticationValue } from "../../../utils/authentication/hash-authentication-value/index.ts" import { IdentityTransactionConflictError } from "../../identity/errors.ts" @@ -65,16 +67,19 @@ export async function consumeAuthenticationAdmissionHandoff( else if (handoff.expiresAt <= input.now) reason = "expired" else if (handoff.browserBindingDigest !== browserBindingDigest) reason = "browser_mismatch" - else if ( - handoff.revocationGeneration !== - (await getAuthenticationRevocationGeneration(transaction)) - ) { - reason = "revoked" - } else { + else { + await lockAuthenticationRevocationGeneration(transaction) + await lockAuthenticationProviderSelection(transaction, input.providerKey) + if ( + handoff.revocationGeneration !== + (await getAuthenticationRevocationGeneration(transaction)) + ) { + reason = "revoked" + } const selectedRevision = await getSelectedProviderRevision(input.providerKey, { findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), }) - if (selectedRevision?.id !== handoff.providerRevisionId) { + if (!reason && selectedRevision?.id !== handoff.providerRevisionId) { reason = "provider_revision_changed" } } 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..aada33e8 --- /dev/null +++ b/hub/server/repositories/authentication-providers/lock-authentication-provider-selection/index.ts @@ -0,0 +1,17 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import { z } from "zod" + +const providerKeySchema = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/) + +export async function lockAuthenticationProviderSelection( + database: Pick, + providerKey: string, +): Promise { + const key = providerKeySchema.parse(providerKey) + await database.$queryRaw` + SELECT "id" + FROM "authentication_providers" + WHERE "key" = ${key} + FOR SHARE + ` +} 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 + ` +} From 2c99d17b0b08498bfff709e2ff599bbb4894cc45 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 09:51:42 -0700 Subject: [PATCH 06/17] test(hub): cover authentication handoff races Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../authentication-proof-races.db.test.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 hub/server/domains/authentication/authentication-proof-races.db.test.ts 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..6935ec3b --- /dev/null +++ b/hub/server/domains/authentication/authentication-proof-races.db.test.ts @@ -0,0 +1,150 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" + +import type { Prisma } from "../../generated/prisma/client" +import { prisma } from "../../integrations/prisma" +import { createAdmissionHandoff } from "../../repositories/admission-handoffs/create-admission-handoff" +import { incrementAuthenticationRevocationGeneration } from "../../repositories/authentication-revocation-generations/increment-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" + +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 createHandoff(providerRevisionId: string, token: string) { + const identity = await resolveOrCreateExternalIdentity( + { provider: "github", issuer: "https://github.com", subject: token }, + { + createId: randomUUID, + runTransaction: (operation) => runIdentityCreationTransaction(prisma, operation), + writeAudit: (event) => createSecurityAudit(prisma, event), + }, + ) + await createAdmissionHandoff(prisma, { + id: randomUUID(), + externalIdentityId: identity.externalIdentityId, + providerRevisionId, + tokenDigest: hashAuthenticationValue(token), + browserBindingDigest: hashAuthenticationValue("browser"), + revocationGeneration: 1n, + displayAttributes: { login: "octocat", name: null, avatarUrl: null, email: null }, + expiresAt: new Date(Date.now() + 5 * 60 * 1_000), + }) +} + +function barrierDatabase(model: "authenticationProvider" | "authenticationRevocationGeneration") { + let arrivedResolve!: () => void + let releaseResolve!: () => void + const arrived = new Promise((resolve) => { + arrivedResolve = resolve + }) + const release = new Promise((resolve) => { + releaseResolve = resolve + }) + return { + arrived, + release: releaseResolve, + database: { + $transaction( + operation: (transaction: Prisma.TransactionClient) => Promise, + options?: { isolationLevel?: Prisma.TransactionIsolationLevel }, + ) { + return prisma.$transaction(async (transaction) => { + const wrapped = new Proxy(transaction, { + get(target, property, receiver) { + if (property !== model) return Reflect.get(target, property, receiver) + if (model === "authenticationProvider") { + const delegate = transaction.authenticationProvider + return { + ...delegate, + async findUnique(input: Parameters[0]) { + const result = await delegate.findUnique(input) + arrivedResolve() + await release + return result + }, + } + } + const delegate = transaction.authenticationRevocationGeneration + return { + ...delegate, + async findUnique(input: Parameters[0]) { + const result = await delegate.findUnique(input) + arrivedResolve() + await release + return result + }, + } + }, + }) + return operation(wrapped) + }, options) + }, + }, + } +} + +describe("authentication handoff races", () => { + it("serializes global revocation after an in-flight handoff consumption", async () => { + const provider = await selectProviderRevision(1) + await createHandoff(provider.revisionId, "revocation-race") + const barrier = barrierDatabase("authenticationRevocationGeneration") + const consumption = consumeAuthenticationAdmissionHandoff(barrier.database, { + handoffToken: "revocation-race", + browserBinding: "browser", + providerKey: "github", + now: new Date(Date.now() + 1_000), + }) + + await barrier.arrived + let revocationCompleted = false + const revocation = incrementAuthenticationRevocationGeneration(prisma).then(() => { + revocationCompleted = true + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(revocationCompleted).toBe(false) + barrier.release() + + await expect(consumption).resolves.toMatchObject({ status: "consumed" }) + await revocation + }) + + it("serializes provider revision selection after an in-flight handoff consumption", async () => { + const provider = await selectProviderRevision(1) + await createHandoff(provider.revisionId, "revision-race") + const barrier = barrierDatabase("authenticationProvider") + const consumption = consumeAuthenticationAdmissionHandoff(barrier.database, { + handoffToken: "revision-race", + browserBinding: "browser", + providerKey: "github", + now: new Date(Date.now() + 1_000), + }) + + await barrier.arrived + let selectionCompleted = false + const selection = selectProviderRevision(2).then(() => { + selectionCompleted = true + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(selectionCompleted).toBe(false) + barrier.release() + + await expect(consumption).resolves.toMatchObject({ status: "consumed" }) + await selection + }) +}) From cd5dd6c29799eee08abae87e8f83e9576fd6ac25 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 09:54:17 -0700 Subject: [PATCH 07/17] test(hub): keep handoff race fixture typed Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../authentication/authentication-proof-races.db.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hub/server/domains/authentication/authentication-proof-races.db.test.ts b/hub/server/domains/authentication/authentication-proof-races.db.test.ts index 6935ec3b..7ff24ac2 100644 --- a/hub/server/domains/authentication/authentication-proof-races.db.test.ts +++ b/hub/server/domains/authentication/authentication-proof-races.db.test.ts @@ -66,8 +66,10 @@ function barrierDatabase(model: "authenticationProvider" | "authenticationRevoca ) { return prisma.$transaction(async (transaction) => { const wrapped = new Proxy(transaction, { - get(target, property, receiver) { - if (property !== model) return Reflect.get(target, property, receiver) + get(target, property) { + if (property !== model) { + return target[property as keyof Prisma.TransactionClient] + } if (model === "authenticationProvider") { const delegate = transaction.authenticationProvider return { From 559ccd4f92ac0aeb273bae08b6dc312192bae932 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 10:38:27 -0700 Subject: [PATCH 08/17] fix(hub): harden admission handoff transactions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../complete-authentication-proof/index.ts | 11 +- .../complete-authentication-proof/types.ts | 5 +- .../consume-admission-handoff/index.ts | 151 ++++++++---------- .../create-admission-handoff/index.ts | 109 +++++++------ .../index.ts | 17 +- .../index.ts | 50 ++++++ .../types.ts | 42 +++++ hub/server/domains/authentication/types.ts | 20 +++ .../consume-admission-handoff/index.ts | 12 ++ .../find-admission-handoff/index.ts | 8 + hub/server/routes/api/auth/github/types.ts | 19 +-- 11 files changed, 283 insertions(+), 161 deletions(-) create mode 100644 hub/server/domains/authentication/run-admission-handoff-transaction/index.ts create mode 100644 hub/server/domains/authentication/run-admission-handoff-transaction/types.ts create mode 100644 hub/server/repositories/admission-handoffs/consume-admission-handoff/index.ts create mode 100644 hub/server/repositories/admission-handoffs/find-admission-handoff/index.ts diff --git a/hub/server/domains/authentication/complete-authentication-proof/index.ts b/hub/server/domains/authentication/complete-authentication-proof/index.ts index cc5e651f..6db5cc3a 100644 --- a/hub/server/domains/authentication/complete-authentication-proof/index.ts +++ b/hub/server/domains/authentication/complete-authentication-proof/index.ts @@ -77,6 +77,7 @@ export async function completeAuthenticationProof( "The authentication provider configuration changed during this attempt.", ) } + const expectedRevocationGeneration = await dependencies.getRevocationGeneration() const identity = await dependencies.resolveOrCreateIdentity({ provider: evidence.provider, issuer: evidence.issuer, @@ -84,14 +85,22 @@ export async function completeAuthenticationProof( }) const handoffToken = dependencies.createOpaqueValue() const expiresAt = new Date(dependencies.now().getTime() + HANDOFF_LIFETIME_MS) - await dependencies.createHandoff({ + const handoff = await dependencies.createHandoff({ externalIdentityId: identity.externalIdentityId, providerRevisionId: attempt.providerRevisionId, browserBindingDigest, tokenDigest: hashAuthenticationValue(handoffToken), displayAttributes: evidence.attributes, expiresAt, + expectedRevocationGeneration, }) + if (handoff.status === "restart") { + await dependencies.writeDenialAudit("configuration_changed") + throw new AuthenticationProofError( + "configuration_changed", + "Authentication state changed before the admission handoff was created.", + ) + } return { status: "authentication_proved", diff --git a/hub/server/domains/authentication/complete-authentication-proof/types.ts b/hub/server/domains/authentication/complete-authentication-proof/types.ts index d8316a05..4c900814 100644 --- a/hub/server/domains/authentication/complete-authentication-proof/types.ts +++ b/hub/server/domains/authentication/complete-authentication-proof/types.ts @@ -1,4 +1,5 @@ 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 { @@ -17,14 +18,16 @@ export interface CompleteAuthenticationProofDependencies { createHandoff(input: { browserBindingDigest: string displayAttributes: PublicIdentityAttributes + expectedRevocationGeneration: bigint expiresAt: Date externalIdentityId: string providerRevisionId: string tokenDigest: string - }): Promise + }): Promise createOpaqueValue(): string decryptCodeVerifier(value: string): string getSelectedProviderRevision(): Promise + getRevocationGeneration(): Promise now(): Date resolveOrCreateIdentity(input: { issuer: string diff --git a/hub/server/domains/authentication/consume-admission-handoff/index.ts b/hub/server/domains/authentication/consume-admission-handoff/index.ts index db1a0f99..5f5ae017 100644 --- a/hub/server/domains/authentication/consume-admission-handoff/index.ts +++ b/hub/server/domains/authentication/consume-admission-handoff/index.ts @@ -1,29 +1,16 @@ import { randomUUID } from "node:crypto" - -import { Prisma } from "../../../generated/prisma/client.ts" import { z } from "zod" -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 { 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 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" -interface Database { - $transaction( - operation: (transaction: Prisma.TransactionClient) => Promise, - options?: { isolationLevel?: Prisma.TransactionIsolationLevel }, - ): Promise -} - export type ConsumeAdmissionHandoffResult = | { status: "consumed" @@ -41,92 +28,84 @@ const attributesSchema = z }) .strict() -export async function consumeAuthenticationAdmissionHandoff( - database: Database, +export function consumeAuthenticationAdmissionHandoff( input: { browserBinding: string handoffToken: string now: Date providerKey: string }, - createId: () => string = randomUUID, + dependencies: { + createId?: () => string + runTransaction: AdmissionHandoffTransaction + }, ): Promise { + const createId = dependencies.createId ?? randomUUID const tokenDigest = hashAuthenticationValue(input.handoffToken) const browserBindingDigest = hashAuthenticationValue(input.browserBinding) return runWithBoundedRetries( () => - database - .$transaction( - async (transaction): Promise => { - const handoff = await transaction.admissionHandoff.findUnique({ - where: { tokenDigest }, - }) - let reason: DenialReason | null = null - if (!handoff || handoff.consumedAt) reason = "replayed" - else if (handoff.expiresAt <= input.now) reason = "expired" - else if (handoff.browserBindingDigest !== browserBindingDigest) - reason = "browser_mismatch" - else { - await lockAuthenticationRevocationGeneration(transaction) - await lockAuthenticationProviderSelection(transaction, input.providerKey) - if ( - handoff.revocationGeneration !== - (await getAuthenticationRevocationGeneration(transaction)) - ) { - reason = "revoked" - } - const selectedRevision = await getSelectedProviderRevision(input.providerKey, { - findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), - }) - if (!reason && selectedRevision?.id !== handoff.providerRevisionId) { - reason = "provider_revision_changed" - } - } + dependencies.runTransaction(async (repositories) => { + const handoff = await repositories.findAdmissionHandoff(tokenDigest) + let reason: DenialReason | null = null + if (!handoff || handoff.consumedAt) reason = "replayed" + else if (handoff.expiresAt <= input.now) reason = "expired" + else if (handoff.browserBindingDigest !== browserBindingDigest) { + reason = "browser_mismatch" + } 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) { - await createSecurityAudit(transaction, { - id: createId(), - type: "authentication_handoff_denied", - ...(handoff ? { handoffId: handoff.id } : {}), - reason, - }) - return { status: "denied", reason } - } + if (reason) { + await repositories.createSecurityAudit({ + id: createId(), + type: "authentication_handoff_denied", + ...(handoff ? { handoffId: handoff.id } : {}), + reason, + }) + return { status: "denied", reason } + } - const consumed = await transaction.admissionHandoff.updateMany({ - where: { id: handoff!.id, consumedAt: null }, - data: { consumedAt: input.now }, - }) - if (consumed.count !== 1) { - await createSecurityAudit(transaction, { - id: createId(), - type: "authentication_handoff_denied", - handoffId: handoff!.id, - reason: "replayed", - }) - return { status: "denied", reason: "replayed" } - } - await createSecurityAudit(transaction, { - id: createId(), - type: "authentication_handoff_consumed", - handoffId: handoff!.id, - externalIdentityId: handoff!.externalIdentityId, - }) - return { - status: "consumed", - externalIdentityId: handoff!.externalIdentityId, - attributes: attributesSchema.parse(handoff!.displayAttributes), - } - }, - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, - ) - .catch((error: unknown) => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034") { - throw new IdentityTransactionConflictError({ cause: error }) - } - throw error - }), + if ( + !(await repositories.consumeAdmissionHandoff({ + id: handoff!.id, + consumedAt: input.now, + })) + ) { + await repositories.createSecurityAudit({ + id: createId(), + type: "authentication_handoff_denied", + handoffId: handoff!.id, + reason: "replayed", + }) + return { status: "denied", reason: "replayed" } + } + 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.ts b/hub/server/domains/authentication/create-admission-handoff/index.ts index 20df560e..78b111ac 100644 --- a/hub/server/domains/authentication/create-admission-handoff/index.ts +++ b/hub/server/domains/authentication/create-admission-handoff/index.ts @@ -1,64 +1,75 @@ import { randomUUID } from "node:crypto" -import { Prisma } from "../../../generated/prisma/client.ts" -import type { PublicIdentityAttributes } from "../types.ts" -import { createAdmissionHandoff } from "../../../repositories/admission-handoffs/create-admission-handoff/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 { IdentityTransactionConflictError } from "../../identity/errors.ts" import { getSelectedProviderRevision } from "../../identity/get-selected-provider-revision/index.ts" -import { AuthenticationProofError } from "../errors.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" -interface Database { - $transaction( - operation: (transaction: Prisma.TransactionClient) => Promise, - options: { isolationLevel: Prisma.TransactionIsolationLevel }, - ): Promise -} +export type CreateAuthenticationAdmissionHandoffResult = + | { status: "created" } + | { + status: "restart" + reason: "conflict" | "provider_revision_changed" | "revoked" + } -export async function createAuthenticationAdmissionHandoff( - database: Database, +export function createAuthenticationAdmissionHandoff( input: { browserBindingDigest: string displayAttributes: PublicIdentityAttributes + expectedRevocationGeneration: bigint expiresAt: Date externalIdentityId: string providerKey: string providerRevisionId: string tokenDigest: string }, - createId: () => string = randomUUID, -): Promise { - await database.$transaction( - async (transaction) => { - const selectedRevision = await getSelectedProviderRevision(input.providerKey, { - findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), - }) - if (selectedRevision?.id !== input.providerRevisionId) { - throw new AuthenticationProofError( - "configuration_changed", - "The authentication provider configuration changed before handoff creation.", - ) - } - const handoffId = createId() - await createAdmissionHandoff(transaction, { - id: handoffId, - externalIdentityId: input.externalIdentityId, - providerRevisionId: input.providerRevisionId, - tokenDigest: input.tokenDigest, - browserBindingDigest: input.browserBindingDigest, - revocationGeneration: await getAuthenticationRevocationGeneration(transaction), - displayAttributes: input.displayAttributes, - expiresAt: input.expiresAt, - }) - await createSecurityAudit(transaction, { - id: createId(), - type: "authentication_handoff_created", - handoffId, - externalIdentityId: input.externalIdentityId, - providerRevisionId: input.providerRevisionId, - }) - }, - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, - ) + 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 index 1b2c0e01..3230bf9c 100644 --- a/hub/server/domains/authentication/create-github-authentication-services/index.ts +++ b/hub/server/domains/authentication/create-github-authentication-services/index.ts @@ -8,8 +8,8 @@ import { countRecentAuthorizationAttempts } from "../../../repositories/authoriz 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 type { GitHubAuthenticationRoutesDependencies } from "../../../routes/api/auth/github/types.ts" import { createBoundedRateLimiter } from "../../../utils/authentication/create-bounded-rate-limiter/index.ts" import { createOpaqueValue } from "../../../utils/authentication/create-opaque-value/index.ts" import { decryptAuthenticationValue } from "../../../utils/authentication/decrypt-authentication-value/index.ts" @@ -21,12 +21,14 @@ import { runProviderRevisionTransaction } from "../../identity/run-provider-revi 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 { 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 { +): Promise { const adapter = createGitHubUserAuthenticationAdapter({ clientSecret: configuration.clientSecret, publicConfiguration: configuration.publicConfiguration, @@ -100,14 +102,17 @@ export async function createGitHubAuthenticationServices( callbackUri: adapter.callbackUri, consumeAttempt: (attempt) => consumeAuthorizationAttempt(prisma, attempt), createHandoff: (handoff) => - createAuthenticationAdmissionHandoff(prisma, { - ...handoff, - providerKey: adapter.providerKey, - }), + 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, { 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..3ad11755 --- /dev/null +++ b/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts @@ -0,0 +1,50 @@ +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 { 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 ( + error instanceof Prisma.PrismaClientKnownRequestError && + ["P2002", "P2034"].includes(error.code) + ) { + 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..ea9a4cd1 --- /dev/null +++ b/hub/server/domains/authentication/run-admission-handoff-transaction/types.ts @@ -0,0 +1,42 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { CreateAdmissionHandoffInput } from "../../../repositories/admission-handoffs/create-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: { id: string; consumedAt: Date }): 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/types.ts b/hub/server/domains/authentication/types.ts index 435f09e0..8ec83ac9 100644 --- a/hub/server/domains/authentication/types.ts +++ b/hub/server/domains/authentication/types.ts @@ -21,3 +21,23 @@ export interface AuthenticationProviderAdapter { codeVerifier: string }): Promise } + +export interface GitHubAuthenticationServices { + appOrigin: string + callbackRateLimiter: { + take(key: string, now: Date): boolean + } + complete(input: { browserBinding: string; code: string; state: string }): Promise<{ + attributes: PublicIdentityAttributes + expiresAt: Date + handoffToken: string + status: "authentication_proved" + }> + now(): Date + secureCookies: boolean + start(input: { requestIp: string }): Promise<{ + authorizationUrl: string + browserBinding: string + expiresAt: Date + }> +} 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..a94cb0cd --- /dev/null +++ b/hub/server/repositories/admission-handoffs/consume-admission-handoff/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function consumeAdmissionHandoff( + database: Pick, + input: { id: string; consumedAt: Date }, +): Promise { + const consumed = await database.admissionHandoff.updateMany({ + where: { id: input.id, consumedAt: null }, + data: { consumedAt: input.consumedAt }, + }) + return consumed.count === 1 +} 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/routes/api/auth/github/types.ts b/hub/server/routes/api/auth/github/types.ts index e343f19b..c6b22a75 100644 --- a/hub/server/routes/api/auth/github/types.ts +++ b/hub/server/routes/api/auth/github/types.ts @@ -1,18 +1 @@ -import type { - CompleteAuthenticationProofInput, - CompleteAuthenticationProofResult, -} from "../../../../domains/authentication/complete-authentication-proof/types.ts" -import type { - StartAuthenticationProofInput, - StartAuthenticationProofResult, -} from "../../../../domains/authentication/start-authentication-proof/types.ts" -import type { BoundedRateLimiter } from "../../../../utils/authentication/create-bounded-rate-limiter/index.ts" - -export interface GitHubAuthenticationRoutesDependencies { - appOrigin: string - callbackRateLimiter: BoundedRateLimiter - complete(input: CompleteAuthenticationProofInput): Promise - now(): Date - secureCookies: boolean - start(input: StartAuthenticationProofInput): Promise -} +export type { GitHubAuthenticationServices as GitHubAuthenticationRoutesDependencies } from "../../../../domains/authentication/types.ts" From ef5ae7e4c6943315d4d5eb8ba6fbb775ab1f7216 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 10:38:30 -0700 Subject: [PATCH 09/17] fix(hub): bound GitHub authentication callbacks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../index.ts | 124 +++++++++--------- hub/server/integrations/github/errors.ts | 6 + .../api/auth/github/callback/get/index.ts | 5 +- .../parse-browser-binding/index.ts | 8 ++ 4 files changed, 80 insertions(+), 63 deletions(-) create mode 100644 hub/server/integrations/github/errors.ts create mode 100644 hub/server/utils/authentication/parse-browser-binding/index.ts diff --git a/hub/server/integrations/github/create-user-authentication-adapter/index.ts b/hub/server/integrations/github/create-user-authentication-adapter/index.ts index f5de797e..94d34965 100644 --- a/hub/server/integrations/github/create-user-authentication-adapter/index.ts +++ b/hub/server/integrations/github/create-user-authentication-adapter/index.ts @@ -1,12 +1,9 @@ import { z } from "zod" -import type { - ImmutableIdentityEvidence, - PublicIdentityAttributes, -} from "../../../domains/authentication/types.ts" +import type { ImmutableIdentityEvidence } from "../../../domains/authentication/types.ts" +import { GitHubProviderRequestError } from "../errors.ts" import type { CreateGitHubUserAuthenticationAdapterInput, - GitHubAuthenticationPublicConfiguration, GitHubUserAuthenticationAdapter, } from "./types.ts" @@ -19,12 +16,10 @@ const publicConfigurationSchema = z 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(), @@ -32,39 +27,21 @@ const userResponseSchema = z.looseObject({ login: z.string().min(1).max(100), name: z.string().min(1).max(200).nullable(), }) - -function assertHttpsProviderUrl(value: string, field: string): void { - if (new URL(value).protocol !== "https:") { - throw new Error(`GitHub ${field} must use HTTPS.`) - } -} - -function canonicalize(configuration: GitHubAuthenticationPublicConfiguration): string { - return JSON.stringify({ - authorizationEndpoint: configuration.authorizationEndpoint, - callbackUri: configuration.callbackUri, - clientId: configuration.clientId, - tokenEndpoint: configuration.tokenEndpoint, - userEndpoint: configuration.userEndpoint, - }) -} - -function attributes(input: z.infer): PublicIdentityAttributes { - return { - avatarUrl: input.avatar_url, - email: input.email, - login: input.login, - name: input.name, - } -} +const providerRequestTimeoutMs = 10_000 export function createGitHubUserAuthenticationAdapter( input: CreateGitHubUserAuthenticationAdapterInput, ): GitHubUserAuthenticationAdapter { const configuration = publicConfigurationSchema.parse(input.publicConfiguration) - assertHttpsProviderUrl(configuration.authorizationEndpoint, "authorization endpoint") - assertHttpsProviderUrl(configuration.tokenEndpoint, "token endpoint") - assertHttpsProviderUrl(configuration.userEndpoint, "user endpoint") + 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.") } @@ -73,7 +50,13 @@ export function createGitHubUserAuthenticationAdapter( return { providerKey: "github", callbackUri: configuration.callbackUri, - canonicalPublicConfiguration: canonicalize(configuration), + 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.") @@ -96,40 +79,57 @@ export function createGitHubUserAuthenticationAdapter( if (callbackUri !== configuration.callbackUri) { throw new Error("GitHub callback URI does not match the configured callback URI.") } - const 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", - }) - if (!tokenResponse.ok) throw new Error("GitHub rejected the authorization code.") + 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()) - const userResponse = await request(configuration.userEndpoint, { - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token.access_token}`, - "x-github-api-version": "2022-11-28", - }, - redirect: "error", - }) - if (!userResponse.ok) throw new Error("GitHub did not return the authenticated user.") + 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: attributes(user), + attributes: { + avatarUrl: user.avatar_url, + email: user.email, + login: user.login, + name: user.name, + }, } }, } 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/routes/api/auth/github/callback/get/index.ts b/hub/server/routes/api/auth/github/callback/get/index.ts index f16aa4a2..a1737769 100644 --- a/hub/server/routes/api/auth/github/callback/get/index.ts +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -2,6 +2,7 @@ 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" @@ -23,7 +24,9 @@ export function get(dependencies: GitHubAuthenticationRoutesDependencies): Reque return } const parsed = querySchema.safeParse(request.query) - const browserBinding = readCookie(request.get("cookie"), "hub_auth_browser") + 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 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 +} From 614dd66bc9b52088741b590d1ac7f260c34ea84d Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 10:38:36 -0700 Subject: [PATCH 10/17] test(hub): prove authentication proof safety boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../authentication-proof-races.db.test.ts | 261 +++++++++++------- .../authentication-proof.db.test.ts | 39 ++- .../index.test.ts | 52 +++- .../create-admission-handoff/index.test.ts | 82 ++++++ .../index.test.ts | 36 +++ .../routes/api/auth/github/index.test.ts | 48 +++- .../index.db.test.ts | 92 ++++++ .../identity-foundation/index.db.test.ts | 155 +++++++++++ 8 files changed, 641 insertions(+), 124 deletions(-) create mode 100644 hub/server/domains/authentication/create-admission-handoff/index.test.ts diff --git a/hub/server/domains/authentication/authentication-proof-races.db.test.ts b/hub/server/domains/authentication/authentication-proof-races.db.test.ts index 7ff24ac2..49044fb7 100644 --- a/hub/server/domains/authentication/authentication-proof-races.db.test.ts +++ b/hub/server/domains/authentication/authentication-proof-races.db.test.ts @@ -1,17 +1,27 @@ import { randomUUID } from "node:crypto" import { describe, expect, it } from "vitest" -import type { Prisma } from "../../generated/prisma/client" import { prisma } from "../../integrations/prisma" -import { createAdmissionHandoff } from "../../repositories/admission-handoffs/create-admission-handoff" +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( @@ -26,127 +36,188 @@ async function selectProviderRevision(revision: number) { ) } -async function createHandoff(providerRevisionId: string, token: string) { - const identity = await resolveOrCreateExternalIdentity( - { provider: "github", issuer: "https://github.com", subject: token }, +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), }, ) - await createAdmissionHandoff(prisma, { - id: randomUUID(), - externalIdentityId: identity.externalIdentityId, - providerRevisionId, - tokenDigest: hashAuthenticationValue(token), +} + +function handoffInput(externalIdentityId: string, providerRevisionId: string, token: string) { + return { browserBindingDigest: hashAuthenticationValue("browser"), - revocationGeneration: 1n, 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 barrierDatabase(model: "authenticationProvider" | "authenticationRevocationGeneration") { - let arrivedResolve!: () => void - let releaseResolve!: () => void - const arrived = new Promise((resolve) => { - arrivedResolve = resolve - }) - const release = new Promise((resolve) => { - releaseResolve = resolve - }) +function barrierTransaction(target: "provider" | "revocation") { + const arrived = deferred() + const release = deferred() return { - arrived, - release: releaseResolve, - database: { - $transaction( - operation: (transaction: Prisma.TransactionClient) => Promise, - options?: { isolationLevel?: Prisma.TransactionIsolationLevel }, - ) { - return prisma.$transaction(async (transaction) => { - const wrapped = new Proxy(transaction, { - get(target, property) { - if (property !== model) { - return target[property as keyof Prisma.TransactionClient] - } - if (model === "authenticationProvider") { - const delegate = transaction.authenticationProvider - return { - ...delegate, - async findUnique(input: Parameters[0]) { - const result = await delegate.findUnique(input) - arrivedResolve() - await release - return result - }, - } - } - const delegate = transaction.authenticationRevocationGeneration - return { - ...delegate, - async findUnique(input: Parameters[0]) { - const result = await delegate.findUnique(input) - arrivedResolve() - await release - return result - }, - } - }, - }) - return operation(wrapped) - }, options) - }, - }, + 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 races", () => { - it("serializes global revocation after an in-flight handoff consumption", async () => { +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, + now: new Date(Date.now() + 1_000), + 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) - await createHandoff(provider.revisionId, "revocation-race") - const barrier = barrierDatabase("authenticationRevocationGeneration") - const consumption = consumeAuthenticationAdmissionHandoff(barrier.database, { - handoffToken: "revocation-race", - browserBinding: "browser", - providerKey: "github", - now: new Date(Date.now() + 1_000), - }) + 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 revocation = incrementAuthenticationRevocationGeneration(prisma).then(() => { + const revocationStarted = deferred() + const revocation = (async () => { + revocationStarted.resolve() + await incrementAuthenticationRevocationGeneration(prisma) revocationCompleted = true - }) - await new Promise((resolve) => setTimeout(resolve, 20)) + })() + await revocationStarted.promise expect(revocationCompleted).toBe(false) barrier.release() - await expect(consumption).resolves.toMatchObject({ status: "consumed" }) + await expect(creation).resolves.toEqual({ status: "created" }) await revocation + await expect(prisma.admissionHandoff.count()).resolves.toBe(1) }) - it("serializes provider revision selection after an in-flight handoff consumption", async () => { + it("returns restart without a handoff or success audit after observed drift", async () => { const provider = await selectProviderRevision(1) - await createHandoff(provider.revisionId, "revision-race") - const barrier = barrierDatabase("authenticationProvider") - const consumption = consumeAuthenticationAdmissionHandoff(barrier.database, { - handoffToken: "revision-race", - browserBinding: "browser", - providerKey: "github", - now: new Date(Date.now() + 1_000), - }) + const identity = await createIdentity("observed-drift") + await selectProviderRevision(2) - await barrier.arrived - let selectionCompleted = false - const selection = selectProviderRevision(2).then(() => { - selectionCompleted = true - }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(selectionCompleted).toBe(false) - barrier.release() + 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) - await expect(consumption).resolves.toMatchObject({ status: "consumed" }) - await selection + 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 index ef90953d..b7ee6759 100644 --- a/hub/server/domains/authentication/authentication-proof.db.test.ts +++ b/hub/server/domains/authentication/authentication-proof.db.test.ts @@ -13,6 +13,7 @@ import { runIdentityCreationTransaction } from "../identity/run-identity-creatio 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( @@ -125,8 +126,12 @@ describe("authentication proof database boundaries", () => { now: new Date(Date.now() + 1_000), } const results = await Promise.all([ - consumeAuthenticationAdmissionHandoff(prisma, input), - consumeAuthenticationAdmissionHandoff(prisma, input), + 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) @@ -164,23 +169,29 @@ describe("authentication proof database boundaries", () => { await addHandoff("revoked", 1n) await incrementAuthenticationRevocationGeneration(prisma) await expect( - consumeAuthenticationAdmissionHandoff(prisma, { - handoffToken: "revoked", - browserBinding: "browser", - providerKey: "github", - now, - }), + consumeAuthenticationAdmissionHandoff( + { + handoffToken: "revoked", + browserBinding: "browser", + providerKey: "github", + now, + }, + { runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation) }, + ), ).resolves.toEqual({ status: "denied", reason: "revoked" }) await addHandoff("drifted", 2n) await createProvider(2) await expect( - consumeAuthenticationAdmissionHandoff(prisma, { - handoffToken: "drifted", - browserBinding: "browser", - providerKey: "github", - now, - }), + consumeAuthenticationAdmissionHandoff( + { + handoffToken: "drifted", + browserBinding: "browser", + providerKey: "github", + now, + }, + { 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 index 38c53ab6..d7fad03b 100644 --- a/hub/server/domains/authentication/complete-authentication-proof/index.test.ts +++ b/hub/server/domains/authentication/complete-authentication-proof/index.test.ts @@ -8,7 +8,7 @@ describe("completeAuthenticationProof()", () => { const order: string[] = [] const createHandoff = vi.fn(() => { order.push("handoff") - return Promise.resolve() + return Promise.resolve({ status: "created" as const }) }) const result = await completeAuthenticationProof( { @@ -53,6 +53,7 @@ describe("completeAuthenticationProof()", () => { revision: 1, configurationDigest: "a".repeat(64), }), + getRevocationGeneration: () => Promise.resolve(1n), now: () => new Date("2026-10-30T16:00:00Z"), resolveOrCreateIdentity: () => { order.push("identity") @@ -75,9 +76,6 @@ describe("completeAuthenticationProof()", () => { tokenDigest: createHash("sha256").update("handoff-secret").digest("hex"), }), ) - expect(JSON.stringify(createHandoff.mock.calls)).not.toMatch( - /authorization-code|pkce-verifier|handoff-secret/, - ) expect(result).not.toHaveProperty("userId") expect(result).not.toHaveProperty("session") }) @@ -103,6 +101,7 @@ describe("completeAuthenticationProof()", () => { createOpaqueValue: vi.fn(), decryptCodeVerifier: vi.fn(), getSelectedProviderRevision: vi.fn(), + getRevocationGeneration: vi.fn(), now: () => new Date(), resolveOrCreateIdentity: vi.fn(), writeDenialAudit: vi.fn(), @@ -111,4 +110,49 @@ describe("completeAuthenticationProof()", () => { ).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/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/integrations/github/create-user-authentication-adapter/index.test.ts b/hub/server/integrations/github/create-user-authentication-adapter/index.test.ts index db421ff8..9c2f57f4 100644 --- a/hub/server/integrations/github/create-user-authentication-adapter/index.test.ts +++ b/hub/server/integrations/github/create-user-authentication-adapter/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest" import { createGitHubUserAuthenticationAdapter } from "." +import { GitHubProviderRequestError } from "../errors" const publicConfiguration = { authorizationEndpoint: "https://github.com/login/oauth/authorize", @@ -117,4 +118,39 @@ describe("createGitHubUserAuthenticationAdapter()", () => { }), ).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/routes/api/auth/github/index.test.ts b/hub/server/routes/api/auth/github/index.test.ts index 20c36d18..886fb7fa 100644 --- a/hub/server/routes/api/auth/github/index.test.ts +++ b/hub/server/routes/api/auth/github/index.test.ts @@ -32,6 +32,7 @@ async function openServer(dependencies: GitHubAuthenticationRoutesDependencies) } function dependencies() { + const browserBinding = "b".repeat(43) const complete = vi.fn(() => Promise.resolve({ status: "authentication_proved" as const, @@ -49,13 +50,14 @@ function dependencies() { Promise.resolve({ authorizationUrl: "https://github.com/login/oauth/authorize?state=state-secret&code_challenge=challenge", - browserBinding: "browser-binding-secret", + browserBinding, expiresAt: new Date("2026-10-30T16:10:00Z"), }), ) return { complete, start, + browserBinding, value: { appOrigin: "https://hub.example.test", callbackRateLimiter: createBoundedRateLimiter({ @@ -73,7 +75,7 @@ function dependencies() { describe("GitHub authentication routes", () => { it("starts only from the configured origin and sets a bounded browser cookie", async () => { - const { start, value } = dependencies() + const { browserBinding, start, value } = dependencies() const { baseUrl } = await openServer(value) const denied = await fetch(`${baseUrl}/api/auth/github/start`, { @@ -97,19 +99,21 @@ describe("GitHub authentication routes", () => { 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( - /^hub_auth_browser=browser-binding-secret; Path=\/api\/auth; Expires=.*; HttpOnly; Secure; SameSite=Lax$/, + new RegExp( + `^hub_auth_browser=${browserBinding}; Path=/api/auth; Expires=.*; HttpOnly; Secure; SameSite=Lax$`, + ), ) expect(start).toHaveBeenCalledWith({ requestIp: "192.0.2.10" }) }) it("returns a non-admitted handoff and excludes callback secrets from HTTP logs", async () => { - const { complete, value } = dependencies() + 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=browser-binding-secret", + cookie: `hub_auth_browser=${browserBinding}`, "x-forwarded-for": "192.0.2.10", }, }, @@ -130,18 +134,16 @@ describe("GitHub authentication routes", () => { }, }) expect(complete).toHaveBeenCalledWith({ - browserBinding: "browser-binding-secret", + browserBinding, code: "authorization-code-secret", state: "s".repeat(32), }) await vi.waitFor(() => expect(records).toHaveLength(1)) - expect(records.join("")).not.toMatch( - /authorization-code-secret|browser-binding-secret|handoff-secret/, - ) + expect(records.join("")).not.toMatch(/authorization-code-secret|handoff-secret/) }) it("rejects malformed, oversized, and rate-limited callbacks before proof completion", async () => { - const { complete, value } = dependencies() + const { browserBinding, complete, value } = dependencies() value.callbackRateLimiter = createBoundedRateLimiter({ limit: 1, maxKeys: 100, @@ -158,7 +160,7 @@ describe("GitHub authentication routes", () => { `${baseUrl}/api/auth/github/callback?code=code&state=${"s".repeat(32)}`, { headers: { - cookie: "hub_auth_browser=browser-binding-secret", + cookie: `hub_auth_browser=${browserBinding}`, "x-forwarded-for": "192.0.2.10", }, }, @@ -166,4 +168,28 @@ describe("GitHub authentication routes", () => { 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) + }) }) diff --git a/hub/test/database/authentication-proof-migration/index.db.test.ts b/hub/test/database/authentication-proof-migration/index.db.test.ts index c9807659..0b23304b 100644 --- a/hub/test/database/authentication-proof-migration/index.db.test.ts +++ b/hub/test/database/authentication-proof-migration/index.db.test.ts @@ -53,6 +53,98 @@ describe("authentication proof migration", () => { "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'admission_handoffs'", ) expect(handoff.rows).toEqual([{ table_name: "admission_handoffs" }]) + + 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() } diff --git a/hub/test/database/identity-foundation/index.db.test.ts b/hub/test/database/identity-foundation/index.db.test.ts index 0a39e7f2..589b361c 100644 --- a/hub/test/database/identity-foundation/index.db.test.ts +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -91,6 +91,161 @@ describe("identity foundation migration", () => { ).rejects.toThrow("Authentication revocation generation may only increase") }) + 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() From 5d92e6d837d09a235cb8fd8b297cfcec5a8a4aa1 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 11:34:32 -0700 Subject: [PATCH 11/17] fix(hub): enforce authoritative authentication boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/schema/authentication-rate-limit.prisma | 18 +++++ .../migration.sql | 23 +++++++ .../complete-authentication-proof/index.ts | 41 +++++++---- .../consume-admission-handoff/index.ts | 57 ++++++++------- .../index.ts | 28 +++++--- .../record-authentication-denial/index.ts | 14 ++++ .../index.ts | 4 +- .../types.ts | 6 +- .../start-authentication-proof/index.ts | 27 +++----- .../start-authentication-proof/types.ts | 3 +- hub/server/domains/authentication/types.ts | 7 +- .../index.ts | 7 +- .../types.ts | 23 ++++++- .../consume-admission-handoff/index.ts | 38 +++++++--- .../consume-admission-handoff/types.ts | 5 ++ .../create-admission-handoff/types.ts | 8 ++- .../index.ts | 69 +++++++++++++++++++ .../types.ts | 16 +++++ .../index.ts | 13 ---- .../api/auth/github/callback/get/index.ts | 10 ++- .../api/auth/github/start/post/index.ts | 13 +++- .../create-bounded-rate-limiter/index.ts | 38 ---------- 22 files changed, 321 insertions(+), 147 deletions(-) create mode 100644 hub/schema/authentication-rate-limit.prisma create mode 100644 hub/server/domains/authentication/record-authentication-denial/index.ts create mode 100644 hub/server/repositories/admission-handoffs/consume-admission-handoff/types.ts create mode 100644 hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.ts create mode 100644 hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/types.ts delete mode 100644 hub/server/repositories/authorization-attempts/count-recent-authorization-attempts/index.ts delete mode 100644 hub/server/utils/authentication/create-bounded-rate-limiter/index.ts 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/migrations/20261030170000_authentication_proof/migration.sql b/hub/schema/migrations/20261030170000_authentication_proof/migration.sql index 857338db..48840ec6 100644 --- a/hub/schema/migrations/20261030170000_authentication_proof/migration.sql +++ b/hub/schema/migrations/20261030170000_authentication_proof/migration.sql @@ -70,6 +70,25 @@ CREATE TABLE "authentication_revocation_generations" ( 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"); @@ -82,6 +101,10 @@ 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" diff --git a/hub/server/domains/authentication/complete-authentication-proof/index.ts b/hub/server/domains/authentication/complete-authentication-proof/index.ts index 6db5cc3a..a818d992 100644 --- a/hub/server/domains/authentication/complete-authentication-proof/index.ts +++ b/hub/server/domains/authentication/complete-authentication-proof/index.ts @@ -2,6 +2,7 @@ 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, @@ -30,20 +31,26 @@ export async function completeAuthenticationProof( consumedAt: now, }) if (!attempt) { - await dependencies.writeDenialAudit("invalid_or_expired") - throw new AuthenticationProofError( + 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) { - await dependencies.writeDenialAudit("configuration_changed") - throw new AuthenticationProofError( + 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 @@ -54,28 +61,35 @@ export async function completeAuthenticationProof( codeVerifier: dependencies.decryptCodeVerifier(attempt.codeVerifierCiphertext), }) } catch (error) { - await dependencies.writeDenialAudit("provider_failed") - throw new AuthenticationProofError( + 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) { - await dependencies.writeDenialAudit("provider_failed") - throw new AuthenticationProofError( + 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) { - await dependencies.writeDenialAudit("configuration_changed") - throw new AuthenticationProofError( + 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({ @@ -95,11 +109,14 @@ export async function completeAuthenticationProof( expectedRevocationGeneration, }) if (handoff.status === "restart") { - await dependencies.writeDenialAudit("configuration_changed") - throw new AuthenticationProofError( + 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 { diff --git a/hub/server/domains/authentication/consume-admission-handoff/index.ts b/hub/server/domains/authentication/consume-admission-handoff/index.ts index 5f5ae017..a944a3be 100644 --- a/hub/server/domains/authentication/consume-admission-handoff/index.ts +++ b/hub/server/domains/authentication/consume-admission-handoff/index.ts @@ -5,6 +5,8 @@ import { hashAuthenticationValue } from "../../../utils/authentication/hash-auth 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" @@ -32,7 +34,6 @@ export function consumeAuthenticationAdmissionHandoff( input: { browserBinding: string handoffToken: string - now: Date providerKey: string }, dependencies: { @@ -50,10 +51,7 @@ export function consumeAuthenticationAdmissionHandoff( const handoff = await repositories.findAdmissionHandoff(tokenDigest) let reason: DenialReason | null = null if (!handoff || handoff.consumedAt) reason = "replayed" - else if (handoff.expiresAt <= input.now) reason = "expired" - else if (handoff.browserBindingDigest !== browserBindingDigest) { - reason = "browser_mismatch" - } else { + else { await repositories.lockAuthenticationProviderSelection(input.providerKey) await repositories.lockAuthenticationRevocationGeneration() const selectedRevision = await getSelectedProviderRevision( @@ -71,28 +69,37 @@ export function consumeAuthenticationAdmissionHandoff( } if (reason) { - await repositories.createSecurityAudit({ - id: createId(), - type: "authentication_handoff_denied", - ...(handoff ? { handoffId: handoff.id } : {}), - reason, - }) - return { status: "denied", 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 } } - if ( - !(await repositories.consumeAdmissionHandoff({ - id: handoff!.id, - consumedAt: input.now, - })) - ) { - await repositories.createSecurityAudit({ - id: createId(), - type: "authentication_handoff_denied", - handoffId: handoff!.id, - reason: "replayed", - }) - return { status: "denied", reason: "replayed" } + 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(), diff --git a/hub/server/domains/authentication/create-github-authentication-services/index.ts b/hub/server/domains/authentication/create-github-authentication-services/index.ts index 3230bf9c..fd35d922 100644 --- a/hub/server/domains/authentication/create-github-authentication-services/index.ts +++ b/hub/server/domains/authentication/create-github-authentication-services/index.ts @@ -4,16 +4,16 @@ import { createGitHubUserAuthenticationAdapter } from "../../../integrations/git 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 { countRecentAuthorizationAttempts } from "../../../repositories/authorization-attempts/count-recent-authorization-attempts/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 { createBoundedRateLimiter } from "../../../utils/authentication/create-bounded-rate-limiter/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" @@ -22,6 +22,7 @@ import { validateAndPersistProviderRevision } from "../../identity/validate-and- 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" @@ -65,11 +66,23 @@ export async function createGitHubAuthenticationServices( appOrigin: configuration.appOrigin, secureCookies: configuration.secureCookies, now, - callbackRateLimiter: createBoundedRateLimiter({ - limit: 20, - maxKeys: 10_000, - windowMs: 10 * 60 * 1_000, - }), + 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, @@ -87,7 +100,6 @@ export async function createGitHubAuthenticationServices( encryptAuthenticationValue(value, configuration.encryptionKey), getSelectedProviderRevision: getRevision, now, - recentAttemptCount: (attempt) => countRecentAuthorizationAttempts(prisma, attempt), writeDenialAudit: (reason) => createSecurityAudit(prisma, { id: randomUUID(), 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 index 3ad11755..2c57e3aa 100644 --- a/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts +++ b/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts @@ -41,7 +41,9 @@ export function runAdmissionHandoffTransaction( .catch((error: unknown) => { if ( error instanceof Prisma.PrismaClientKnownRequestError && - ["P2002", "P2034"].includes(error.code) + (["P2002", "P2034"].includes(error.code) || + (error.code === "P2010" && + (String(error.meta?.code) === "40001" || error.message.includes("40001")))) ) { throw new IdentityTransactionConflictError({ cause: 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 index ea9a4cd1..77864bfc 100644 --- a/hub/server/domains/authentication/run-admission-handoff-transaction/types.ts +++ b/hub/server/domains/authentication/run-admission-handoff-transaction/types.ts @@ -1,5 +1,6 @@ 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" @@ -15,7 +16,10 @@ export interface AdmissionHandoffRecord { } export interface AdmissionHandoffRepositories { - consumeAdmissionHandoff(input: { id: string; consumedAt: Date }): Promise + consumeAdmissionHandoff(input: { + browserBindingDigest: string + id: string + }): Promise createAdmissionHandoff(input: CreateAdmissionHandoffInput): Promise createSecurityAudit(input: CreateSecurityAuditEvent): Promise findAdmissionHandoff(tokenDigest: string): Promise diff --git a/hub/server/domains/authentication/start-authentication-proof/index.ts b/hub/server/domains/authentication/start-authentication-proof/index.ts index 3c3dba18..09b609a6 100644 --- a/hub/server/domains/authentication/start-authentication-proof/index.ts +++ b/hub/server/domains/authentication/start-authentication-proof/index.ts @@ -3,16 +3,15 @@ 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({ requestIp: z.string().min(1).max(200) }).strict() +const inputSchema = z.object({ requestIpDigest: z.string().regex(/^[0-9a-f]{64}$/) }).strict() const ATTEMPT_LIFETIME_MS = 10 * 60 * 1_000 -const INITIATION_WINDOW_MS = 10 * 60 * 1_000 -const INITIATION_LIMIT = 10 export async function startAuthenticationProof( input: StartAuthenticationProofInput, @@ -21,30 +20,20 @@ export async function startAuthenticationProof( const parsed = inputSchema.parse(input) const now = dependencies.now() await dependencies.deleteExpiredArtifacts(now) - const requestIpDigest = hashAuthenticationValue(parsed.requestIp) - const recentAttemptCount = await dependencies.recentAttemptCount({ - requestIpDigest, - createdAfter: new Date(now.getTime() - INITIATION_WINDOW_MS), - }) - if (recentAttemptCount >= INITIATION_LIMIT) { - await dependencies.writeDenialAudit("rate_limited") - throw new AuthenticationProofError( - "rate_limited", - "Too many authentication attempts were started.", - ) - } - const selectedRevision = await dependencies.getSelectedProviderRevision() if ( !selectedRevision || selectedRevision.revision !== dependencies.expectedRevision || selectedRevision.configurationDigest !== dependencies.configurationDigest ) { - await dependencies.writeDenialAudit("configuration_changed") - throw new AuthenticationProofError( + 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() @@ -60,7 +49,7 @@ export async function startAuthenticationProof( browserBindingDigest: hashAuthenticationValue(browserBinding), codeChallenge, codeVerifierCiphertext: dependencies.encryptCodeVerifier(codeVerifier), - requestIpDigest, + requestIpDigest: parsed.requestIpDigest, expiresAt, }) diff --git a/hub/server/domains/authentication/start-authentication-proof/types.ts b/hub/server/domains/authentication/start-authentication-proof/types.ts index 52009fa6..c50831e1 100644 --- a/hub/server/domains/authentication/start-authentication-proof/types.ts +++ b/hub/server/domains/authentication/start-authentication-proof/types.ts @@ -27,12 +27,11 @@ export interface StartAuthenticationProofDependencies { encryptCodeVerifier(value: string): string getSelectedProviderRevision(): Promise now(): Date - recentAttemptCount(input: { createdAfter: Date; requestIpDigest: string }): Promise writeDenialAudit(reason: "configuration_changed" | "rate_limited"): Promise } export interface StartAuthenticationProofInput { - requestIp: string + requestIpDigest: string } export interface StartAuthenticationProofResult { diff --git a/hub/server/domains/authentication/types.ts b/hub/server/domains/authentication/types.ts index 8ec83ac9..d0fda112 100644 --- a/hub/server/domains/authentication/types.ts +++ b/hub/server/domains/authentication/types.ts @@ -24,9 +24,6 @@ export interface AuthenticationProviderAdapter { export interface GitHubAuthenticationServices { appOrigin: string - callbackRateLimiter: { - take(key: string, now: Date): boolean - } complete(input: { browserBinding: string; code: string; state: string }): Promise<{ attributes: PublicIdentityAttributes expiresAt: Date @@ -34,8 +31,10 @@ export interface GitHubAuthenticationServices { status: "authentication_proved" }> now(): Date + recordRateLimitDenial(operation: "callback" | "start", primaryError: Error): Promise + reserveRateLimit(input: { operation: "callback" | "start"; requestIp: string }): Promise secureCookies: boolean - start(input: { requestIp: string }): Promise<{ + start(input: { requestIpDigest: string }): Promise<{ authorizationUrl: string browserBinding: string expiresAt: Date diff --git a/hub/server/integrations/github/create-user-authentication-adapter/index.ts b/hub/server/integrations/github/create-user-authentication-adapter/index.ts index 94d34965..469d2892 100644 --- a/hub/server/integrations/github/create-user-authentication-adapter/index.ts +++ b/hub/server/integrations/github/create-user-authentication-adapter/index.ts @@ -1,6 +1,5 @@ import { z } from "zod" -import type { ImmutableIdentityEvidence } from "../../../domains/authentication/types.ts" import { GitHubProviderRequestError } from "../errors.ts" import type { CreateGitHubUserAuthenticationAdapterInput, @@ -71,11 +70,7 @@ export function createGitHubUserAuthenticationAdapter( }).toString() return url }, - async completeAuthenticationProof({ - callbackUri, - code, - codeVerifier, - }): Promise { + async completeAuthenticationProof({ callbackUri, code, codeVerifier }) { if (callbackUri !== configuration.callbackUri) { throw new Error("GitHub callback URI does not match the configured callback URI.") } diff --git a/hub/server/integrations/github/create-user-authentication-adapter/types.ts b/hub/server/integrations/github/create-user-authentication-adapter/types.ts index cb8a61de..f8f63a25 100644 --- a/hub/server/integrations/github/create-user-authentication-adapter/types.ts +++ b/hub/server/integrations/github/create-user-authentication-adapter/types.ts @@ -1,5 +1,3 @@ -import type { AuthenticationProviderAdapter } from "../../../domains/authentication/types.ts" - export interface GitHubAuthenticationPublicConfiguration { authorizationEndpoint: string callbackUri: string @@ -14,7 +12,26 @@ export interface CreateGitHubUserAuthenticationAdapterInput { publicConfiguration: unknown } -export interface GitHubUserAuthenticationAdapter extends AuthenticationProviderAdapter { +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/repositories/admission-handoffs/consume-admission-handoff/index.ts b/hub/server/repositories/admission-handoffs/consume-admission-handoff/index.ts index a94cb0cd..63d983b7 100644 --- a/hub/server/repositories/admission-handoffs/consume-admission-handoff/index.ts +++ b/hub/server/repositories/admission-handoffs/consume-admission-handoff/index.ts @@ -1,12 +1,32 @@ -import type { Prisma } from "../../../generated/prisma/client.ts" +import type { ConsumeAdmissionHandoffDatabase, ConsumeAdmissionHandoffResult } from "./types.ts" export async function consumeAdmissionHandoff( - database: Pick, - input: { id: string; consumedAt: Date }, -): Promise { - const consumed = await database.admissionHandoff.updateMany({ - where: { id: input.id, consumedAt: null }, - data: { consumedAt: input.consumedAt }, - }) - return consumed.count === 1 + 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/types.ts b/hub/server/repositories/admission-handoffs/create-admission-handoff/types.ts index d930e87f..45e9124c 100644 --- a/hub/server/repositories/admission-handoffs/create-admission-handoff/types.ts +++ b/hub/server/repositories/admission-handoffs/create-admission-handoff/types.ts @@ -1,11 +1,15 @@ import type { Prisma } from "../../../generated/prisma/client.ts" -import type { PublicIdentityAttributes } from "../../../domains/authentication/types.ts" export type CreateAdmissionHandoffDatabase = Pick export interface CreateAdmissionHandoffInput { browserBindingDigest: string - displayAttributes: PublicIdentityAttributes + displayAttributes: { + avatarUrl: string | null + email: string | null + login: string + name: string | null + } expiresAt: Date externalIdentityId: string id: string 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/authorization-attempts/count-recent-authorization-attempts/index.ts b/hub/server/repositories/authorization-attempts/count-recent-authorization-attempts/index.ts deleted file mode 100644 index ed56eb3d..00000000 --- a/hub/server/repositories/authorization-attempts/count-recent-authorization-attempts/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Prisma } from "../../../generated/prisma/client.ts" - -export function countRecentAuthorizationAttempts( - database: Pick, - input: { requestIpDigest: string; createdAfter: Date }, -) { - return database.authorizationAttempt.count({ - where: { - requestIpDigest: input.requestIpDigest, - createdAt: { gt: input.createdAfter }, - }, - }) -} diff --git a/hub/server/routes/api/auth/github/callback/get/index.ts b/hub/server/routes/api/auth/github/callback/get/index.ts index a1737769..b614b6b5 100644 --- a/hub/server/routes/api/auth/github/callback/get/index.ts +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -17,10 +17,14 @@ export function get(dependencies: GitHubAuthenticationRoutesDependencies): Reque return async (request, response) => { response.set("Cache-Control", "no-store") response.locals.omitLogPayload = true - const now = dependencies.now() const requestIp = request.ip ?? request.socket.remoteAddress ?? "unavailable" - if (!dependencies.callbackRateLimiter.take(requestIp, now)) { - response.status(429).json({ error: "Too many authentication callbacks were received." }) + 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) diff --git a/hub/server/routes/api/auth/github/start/post/index.ts b/hub/server/routes/api/auth/github/start/post/index.ts index 4313077a..dabe0f2e 100644 --- a/hub/server/routes/api/auth/github/start/post/index.ts +++ b/hub/server/routes/api/auth/github/start/post/index.ts @@ -1,6 +1,7 @@ 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" @@ -13,9 +14,19 @@ export function post(dependencies: GitHubAuthenticationRoutesDependencies): Requ 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({ - requestIp: request.ip ?? request.socket.remoteAddress ?? "unavailable", + requestIpDigest: hashAuthenticationValue(requestIp), }) response.cookie(BROWSER_COOKIE, result.browserBinding, { httpOnly: true, diff --git a/hub/server/utils/authentication/create-bounded-rate-limiter/index.ts b/hub/server/utils/authentication/create-bounded-rate-limiter/index.ts deleted file mode 100644 index fb87de67..00000000 --- a/hub/server/utils/authentication/create-bounded-rate-limiter/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -export interface BoundedRateLimiter { - take(key: string, now: Date): boolean -} - -export function createBoundedRateLimiter(input: { - limit: number - maxKeys: number - windowMs: number -}): BoundedRateLimiter { - if ( - !Number.isInteger(input.limit) || - input.limit < 1 || - !Number.isInteger(input.maxKeys) || - input.maxKeys < 1 || - !Number.isInteger(input.windowMs) || - input.windowMs < 1 - ) { - throw new Error("Rate limiter bounds must be positive integers.") - } - const entries = new Map() - - return { - take(key, now) { - const cutoff = now.getTime() - input.windowMs - const recent = (entries.get(key) ?? []).filter((timestamp) => timestamp > cutoff) - if (recent.length >= input.limit) { - entries.delete(key) - entries.set(key, recent) - return false - } - recent.push(now.getTime()) - entries.delete(key) - entries.set(key, recent) - while (entries.size > input.maxKeys) entries.delete(entries.keys().next().value!) - return true - }, - } -} From 55e74bec5fed08e38919c68167092531a54cd00e Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 11:34:46 -0700 Subject: [PATCH 12/17] test(hub): cover authoritative authentication controls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../authentication-proof-races.db.test.ts | 1 - .../authentication-proof.db.test.ts | 69 ++++++++++++++++++- .../index.test.ts | 31 +++++++++ .../start-authentication-proof/index.test.ts | 54 ++++++++++++--- .../index.db.test.ts | 69 +++++++++++++++++++ .../routes/api/auth/github/index.test.ts | 50 ++++++++++---- .../index.db.test.ts | 13 +++- .../identity-foundation/index.db.test.ts | 38 ++++++++++ 8 files changed, 296 insertions(+), 29 deletions(-) create mode 100644 hub/server/domains/authentication/record-authentication-denial/index.test.ts create mode 100644 hub/server/repositories/authentication-rate-limits/reserve-authentication-rate-limit/index.db.test.ts diff --git a/hub/server/domains/authentication/authentication-proof-races.db.test.ts b/hub/server/domains/authentication/authentication-proof-races.db.test.ts index 49044fb7..c8a856db 100644 --- a/hub/server/domains/authentication/authentication-proof-races.db.test.ts +++ b/hub/server/domains/authentication/authentication-proof-races.db.test.ts @@ -136,7 +136,6 @@ describe("authentication handoff creation races", () => { { browserBinding: "browser", handoffToken: token, - now: new Date(Date.now() + 1_000), providerKey: "github", }, { runTransaction: barrier.runTransaction }, diff --git a/hub/server/domains/authentication/authentication-proof.db.test.ts b/hub/server/domains/authentication/authentication-proof.db.test.ts index b7ee6759..dad106ef 100644 --- a/hub/server/domains/authentication/authentication-proof.db.test.ts +++ b/hub/server/domains/authentication/authentication-proof.db.test.ts @@ -5,7 +5,9 @@ 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" @@ -123,7 +125,6 @@ describe("authentication proof database boundaries", () => { handoffToken: "handoff-token", browserBinding: "browser-binding", providerKey: "github", - now: new Date(Date.now() + 1_000), } const results = await Promise.all([ consumeAuthenticationAdmissionHandoff(input, { @@ -144,6 +145,70 @@ describe("authentication proof database boundaries", () => { 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() @@ -174,7 +239,6 @@ describe("authentication proof database boundaries", () => { handoffToken: "revoked", browserBinding: "browser", providerKey: "github", - now, }, { runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation) }, ), @@ -188,7 +252,6 @@ describe("authentication proof database boundaries", () => { handoffToken: "drifted", browserBinding: "browser", providerKey: "github", - now, }, { runTransaction: (operation) => runAdmissionHandoffTransaction(prisma, operation) }, ), 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/start-authentication-proof/index.test.ts b/hub/server/domains/authentication/start-authentication-proof/index.test.ts index 2b25c8cb..ea6e8696 100644 --- a/hub/server/domains/authentication/start-authentication-proof/index.test.ts +++ b/hub/server/domains/authentication/start-authentication-proof/index.test.ts @@ -13,7 +13,7 @@ describe("startAuthenticationProof()", () => { const opaqueValues = ["state-secret", "browser-secret", "verifier-secret"][Symbol.iterator]() const result = await startAuthenticationProof( - { requestIp: "192.0.2.10" }, + { requestIpDigest: createHash("sha256").update("192.0.2.10").digest("hex") }, { adapter: { providerKey: "github", @@ -35,7 +35,6 @@ describe("startAuthenticationProof()", () => { configurationDigest: "a".repeat(64), }), now: () => new Date("2026-10-30T16:00:00Z"), - recentAttemptCount: () => Promise.resolve(0), writeDenialAudit: vi.fn(), }, ) @@ -56,7 +55,7 @@ describe("startAuthenticationProof()", () => { ) }) - it("fails closed before creating an attempt when rate limited or revision drifted", async () => { + it("fails closed before creating an attempt when the provider revision drifted", async () => { const createAttempt = vi.fn() const dependencies = { adapter: { @@ -79,17 +78,54 @@ describe("startAuthenticationProof()", () => { configurationDigest: "a".repeat(64), }), now: () => new Date("2026-10-30T16:00:00Z"), - recentAttemptCount: () => Promise.resolve(10), writeDenialAudit: vi.fn(), } await expect( - startAuthenticationProof({ requestIp: "192.0.2.10" }, dependencies), - ).rejects.toMatchObject({ code: "rate_limited" }) - dependencies.recentAttemptCount = () => Promise.resolve(0) - await expect( - startAuthenticationProof({ requestIp: "192.0.2.10" }, dependencies), + 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/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/routes/api/auth/github/index.test.ts b/hub/server/routes/api/auth/github/index.test.ts index 886fb7fa..2b26f5a8 100644 --- a/hub/server/routes/api/auth/github/index.test.ts +++ b/hub/server/routes/api/auth/github/index.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createApp } from "../../../../create-app" import { createLogger } from "../../../../integrations/pino/create-logger" -import { createBoundedRateLimiter } from "../../../../utils/authentication/create-bounded-rate-limiter" +import { hashAuthenticationValue } from "../../../../utils/authentication/hash-authentication-value" import type { GitHubAuthenticationRoutesDependencies } from "./types" async function openServer(dependencies: GitHubAuthenticationRoutesDependencies) { @@ -54,19 +54,20 @@ function dependencies() { 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", - callbackRateLimiter: createBoundedRateLimiter({ - limit: 20, - maxKeys: 100, - windowMs: 60_000, - }), complete, now: () => new Date("2026-10-30T16:00:00Z"), + recordRateLimitDenial, + reserveRateLimit, secureCookies: true, start, } satisfies GitHubAuthenticationRoutesDependencies, @@ -103,7 +104,9 @@ describe("GitHub authentication routes", () => { `^hub_auth_browser=${browserBinding}; Path=/api/auth; Expires=.*; HttpOnly; Secure; SameSite=Lax$`, ), ) - expect(start).toHaveBeenCalledWith({ requestIp: "192.0.2.10" }) + expect(start).toHaveBeenCalledWith({ + requestIpDigest: hashAuthenticationValue("192.0.2.10"), + }) }) it("returns a non-admitted handoff and excludes callback secrets from HTTP logs", async () => { @@ -143,12 +146,8 @@ describe("GitHub authentication routes", () => { }) it("rejects malformed, oversized, and rate-limited callbacks before proof completion", async () => { - const { browserBinding, complete, value } = dependencies() - value.callbackRateLimiter = createBoundedRateLimiter({ - limit: 1, - maxKeys: 100, - windowMs: 60_000, - }) + const { browserBinding, complete, reserveRateLimit, value } = dependencies() + reserveRateLimit.mockResolvedValueOnce(true).mockResolvedValueOnce(false) const { baseUrl } = await openServer(value) const malformed = await fetch( @@ -156,6 +155,10 @@ describe("GitHub authentication routes", () => { { 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)}`, { @@ -192,4 +195,25 @@ describe("GitHub authentication routes", () => { 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/test/database/authentication-proof-migration/index.db.test.ts b/hub/test/database/authentication-proof-migration/index.db.test.ts index 0b23304b..75c2484b 100644 --- a/hub/test/database/authentication-proof-migration/index.db.test.ts +++ b/hub/test/database/authentication-proof-migration/index.db.test.ts @@ -49,10 +49,17 @@ describe("authentication proof migration", () => { { migration_name: "20261029203000_identity_foundation" }, { migration_name: "20261030170000_authentication_proof" }, ]) - const handoff = await database.query<{ table_name: string }>( - "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'admission_handoffs'", + 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(handoff.rows).toEqual([{ table_name: "admission_handoffs" }]) + 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" diff --git a/hub/test/database/identity-foundation/index.db.test.ts b/hub/test/database/identity-foundation/index.db.test.ts index 589b361c..0825406f 100644 --- a/hub/test/database/identity-foundation/index.db.test.ts +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -53,6 +53,7 @@ describe("identity foundation migration", () => { { 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" }, @@ -80,6 +81,7 @@ describe("identity foundation migration", () => { const generation = await prisma.authenticationRevocationGeneration.findUniqueOrThrow({ where: { key: "global" }, }) + await expect( prisma.authenticationRevocationGeneration.update({ where: { key: "global" }, @@ -91,6 +93,42 @@ describe("identity foundation migration", () => { ).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() From 75f09ef2abfa574e38cf89167d7a519b1525951b Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 11:34:46 -0700 Subject: [PATCH 13/17] docs(hub): document shared authentication limits Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/authentication.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hub/docs/authentication.md b/hub/docs/authentication.md index 914212c4..56f2281b 100644 --- a/hub/docs/authentication.md +++ b/hub/docs/authentication.md @@ -4,7 +4,7 @@ Hub can prove a GitHub user identity without admitting that user to Hub. The pro ## Current flow -`POST /api/auth/github/start` accepts requests only from the configured Hub origin. Hub selects the configured GitHub provider revision, rate-limits the trusted request address, stores a one-use authorization attempt, sets an HTTP-only browser-binding cookie, and redirects to GitHub with state and PKCE. +`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. @@ -14,7 +14,7 @@ A successful callback returns `authentication_proved` with `admission: "pending" 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. The global revocation generation can only increase. +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. From 0433a9c2edef84fda66b7f9f5156c805a037f223 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 12:32:56 -0700 Subject: [PATCH 14/17] refactor(hub): consume aligned identity boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../run-admission-handoff-transaction/index.ts | 9 +++++---- .../lock-authentication-provider-selection/index.ts | 6 ++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts b/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts index 2c57e3aa..7e466101 100644 --- a/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts +++ b/hub/server/domains/authentication/run-admission-handoff-transaction/index.ts @@ -7,6 +7,7 @@ import { lockAuthenticationProviderSelection } from "../../../repositories/authe 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, @@ -40,10 +41,10 @@ export function runAdmissionHandoffTransaction( ) .catch((error: unknown) => { if ( - error instanceof Prisma.PrismaClientKnownRequestError && - (["P2002", "P2034"].includes(error.code) || - (error.code === "P2010" && - (String(error.meta?.code) === "40001" || error.message.includes("40001")))) + classifyPrismaTransactionError(error) === "retryable" || + (error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2010" && + (String(error.meta?.code) === "40001" || error.message.includes("40001"))) ) { throw new IdentityTransactionConflictError({ cause: error }) } 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 index aada33e8..46e807ce 100644 --- a/hub/server/repositories/authentication-providers/lock-authentication-provider-selection/index.ts +++ b/hub/server/repositories/authentication-providers/lock-authentication-provider-selection/index.ts @@ -1,13 +1,11 @@ import type { Prisma } from "../../../generated/prisma/client.ts" -import { z } from "zod" - -const providerKeySchema = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/) +import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" export async function lockAuthenticationProviderSelection( database: Pick, providerKey: string, ): Promise { - const key = providerKeySchema.parse(providerKey) + const key = parseProviderKey(providerKey) await database.$queryRaw` SELECT "id" FROM "authentication_providers" From 6bfd6f4c89d5cd8fab05b4d5d285ef8e8e0bfb03 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 14:09:33 -0700 Subject: [PATCH 15/17] fix(hub): preserve telemetry-aware API startup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/server/run-api/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/hub/server/run-api/index.ts b/hub/server/run-api/index.ts index 9a802b7d..0a3ec860 100644 --- a/hub/server/run-api/index.ts +++ b/hub/server/run-api/index.ts @@ -2,7 +2,6 @@ import { createServer } from "node:http" import type { Logger } from "pino" import { createApp } from "../create-app/index.ts" -import { createGitHubAuthenticationServices } from "../domains/authentication/create-github-authentication-services/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" @@ -27,7 +26,11 @@ export async function runApi({ const realtime = attachRealtimeServer({ server, path: "/api/realtime", logger }) const authenticationConfiguration = readGitHubAuthenticationConfiguration(process.env) const githubAuthentication = authenticationConfiguration - ? await createGitHubAuthenticationServices(authenticationConfiguration) + ? await import( + "../domains/authentication/create-github-authentication-services/index.ts" + ).then(({ createGitHubAuthenticationServices }) => + createGitHubAuthenticationServices(authenticationConfiguration), + ) : undefined server.on( "request", From 58668309b76a5e1dbc2bc0cfcaa42b3ede48e7e8 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 14:10:25 -0700 Subject: [PATCH 16/17] style(hub): format API authentication composition Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/server/run-api/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/hub/server/run-api/index.ts b/hub/server/run-api/index.ts index 0a3ec860..da39e270 100644 --- a/hub/server/run-api/index.ts +++ b/hub/server/run-api/index.ts @@ -26,10 +26,9 @@ export async function runApi({ const realtime = attachRealtimeServer({ server, path: "/api/realtime", logger }) const authenticationConfiguration = readGitHubAuthenticationConfiguration(process.env) const githubAuthentication = authenticationConfiguration - ? await import( - "../domains/authentication/create-github-authentication-services/index.ts" - ).then(({ createGitHubAuthenticationServices }) => - createGitHubAuthenticationServices(authenticationConfiguration), + ? await import("../domains/authentication/create-github-authentication-services/index.ts").then( + ({ createGitHubAuthenticationServices }) => + createGitHubAuthenticationServices(authenticationConfiguration), ) : undefined server.on( From b291bc07daa8b196c4e1837852d236036bc41faa Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 14:38:23 -0700 Subject: [PATCH 17/17] fix(hub): initialize authentication before realtime Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/server/run-api/index.test.ts | 115 +++++++++++++++++++++++++++++++ hub/server/run-api/index.ts | 7 +- 2 files changed, 119 insertions(+), 3 deletions(-) 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 da39e270..845aa034 100644 --- a/hub/server/run-api/index.ts +++ b/hub/server/run-api/index.ts @@ -22,8 +22,7 @@ export async function runApi({ if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1) { throw new Error("API shutdown timeout must be a positive integer.") } - const server = createServer() - const realtime = attachRealtimeServer({ server, path: "/api/realtime", logger }) + 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( @@ -31,13 +30,15 @@ export async function runApi({ createGitHubAuthenticationServices(authenticationConfiguration), ) : undefined + const server = createServer() + const realtime = attachRealtimeServer({ server, path: "/api/realtime", logger }) server.on( "request", createApp({ logger, broadcast: realtime.broadcast, githubAuthentication, - trustedProxyHops: readTrustedProxyHops(process.env.HUB_TRUSTED_PROXY_HOPS), + trustedProxyHops, }), ) let finishBinding!: () => void