diff --git a/hub/.env.example b/hub/.env.example index 35e842d8..a0c2ce72 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -6,3 +6,16 @@ DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica # Only database-enabled tests require this URL and CREATEDB permission. TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres + +# Select the credential provider. See docs/identity.md. +HUB_CREDENTIAL_PROVIDER=database + +# Generate separate values outside the database. See docs/identity.md. +# HUB_CREDENTIAL_KEY= + +# The environment provider is read-only and requires every GitHub setting below. +# GITHUB_APP_ID= +# GITHUB_INSTALLATION_ID= +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= +# GITHUB_PRIVATE_KEY= diff --git a/hub/docs/README.md b/hub/docs/README.md index a844c507..35506984 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. +- [Identity](identity.md): credential storage and providers. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. - [Prototype canvas](prototype-canvas.md): the separately retained extension. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index de76ecfe..052ac579 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -8,7 +8,7 @@ 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 no product models yet. Sign-in, deployment, background jobs, 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. Identity models store credentials, users, sessions, sign-in attempts, and security records. [Credential providers](identity.md) use encrypted database values or read-only environment settings. Sign-in routes, deployment, background jobs, and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. ## Backend diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 46cc8f0b..88277750 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -30,6 +30,14 @@ Put each function in a directory named with lowercase words and hyphens, such as Add directories when their functions are needed. Do not add empty implementations just to fill out the structure. +Keep function implementations flat. A composition factory may bind imported functions to dependencies, but must not contain their implementations. Put schemas and constants in a colocated `constants.ts`, and types in `types.ts`. + +Keep domain contracts with their domains, GitHub types and errors with that integration, and database inputs with their repositories. Keep only general helpers in utilities. + ## Database access The [shared Prisma client](database.md#shared-client) lives in `server/integrations/prisma/index.ts`. Domains may pass it to repositories for database queries without letting repositories import integrations. Keep model definitions, naming, and migrations consistent with the [database conventions](database.md#schema-conventions). + +Compose repository functions in domains. The identity store binds standalone queries to a database client or transaction there; a repository must not import its neighboring queries to build that store. + +`staticSettingsRecordId` identifies Hub's single settings row, not a person or GitHub installation. Its fixed UUID lets every process lock the same row. Keep it with the identity-state repository. diff --git a/hub/docs/coding-conventions.md b/hub/docs/coding-conventions.md index 31360d5d..cd990769 100644 --- a/hub/docs/coding-conventions.md +++ b/hub/docs/coding-conventions.md @@ -9,8 +9,12 @@ Follow the [database conventions](database.md#schema-conventions) for Prisma mod - Use TypeScript for new application code and tests. - Put each standalone function or component in a kebab-case directory with `index.ts` or `index.tsx`, colocated tests, types, and styles. - Export at most one function per file and import exact directories rather than sibling-export barrels. +- Keep helpers in their own function directories. Put schemas and constants in `constants.ts` and types in `types.ts`. - Group package, alias, and relative imports separately. -- Omit `/index.ts` and `/index.tsx` from imports in frontend code and Vitest tests. Keep explicit `.ts` file paths in server runtime and Node-loaded tooling, including Storybook configuration. Native Node does not resolve directory imports; the server compiler rewrites its paths for production. +- Use `@/` for browser code and `#server/` for API code. Import function directories without `/index.ts`, such as `#server/utils/digest-secret`. Import colocated constants and types from their named files. +- [Package imports](../package.json) resolve `#server/` to source with Node's `development` condition and to compiled JavaScript otherwise. `npm run dev:api` selects that condition; `npm start` uses compiled files. TypeScript and Vitest resolve source through the same mappings. Native Node package aliases must start with `#`; `@/` remains browser-only. +- Keep explicit `.ts` paths for relative imports in native Node modules and tooling, including Storybook configuration. The server compiler rewrites relative extensions for the build. +- Prefer named alias imports such as `#server/integrations/github/identify-user/constants` over relative paths when a mapping exists. Vite resolves extensionless relative paths, but native Node does not. TypeScript `paths` alone cannot make `@/server/...` work in Node or rewrite it in emitted JavaScript. - Keep HTTP parsing and responses outside domains. Validate input and return explicit errors when introducing real API behavior. ## Frontend layers diff --git a/hub/docs/database.md b/hub/docs/database.md index 6cb52918..5604851b 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. Product models and migrations have not been added. +Hub uses PostgreSQL and Prisma. The initial migration stores the [identity boundary](identity.md); product models have not been added. [Compose](../.devcontainer/compose.yaml) pins the development database image. [package.json](../package.json) pins the Prisma packages to the same version. Dependency updates must follow the [seven-day dependency policy](../../docs/testing-and-review.md#update-dependencies). diff --git a/hub/docs/identity.md b/hub/docs/identity.md new file mode 100644 index 00000000..9e0d375f --- /dev/null +++ b/hub/docs/identity.md @@ -0,0 +1,24 @@ +# Hub identity + +Hub stores GitHub credentials, users, sessions, one-use sign-in attempts, and security records in PostgreSQL. Credential providers use one GitHub App installation. + +## Credential providers + +Set `HUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): + +| Provider | Credentials | +| --- | --- | +| `database` (default) | Generate `HUB_CREDENTIAL_KEY` with `openssl rand -hex 32`. The provider encrypts credentials before storage and checks them when reading. | +| `environment` | Set every `GITHUB_*` value, including `GITHUB_INSTALLATION_ID`. This provider is read-only and ignores database credentials. | + +Both providers require an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. + +Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors; Hub never falls back to another provider. Never commit credentials. + +## Saved records + +Apply the committed migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. + +Domain functions bind repository queries to a client or transaction. A fixed settings row lets overlapping changes lock the same record. Credential updates, session changes, and their audit records can commit together or roll back together. + +User records use permanent GitHub IDs, not names or emails. Repositories consume each sign-in attempt once. Audit reads return the latest 100 records within 90 days; later writes remove older records. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 4730dc20..e690baf8 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -34,6 +34,8 @@ Arrange each case's inputs and records inside that case. Act through its public Tests must release resources they create, such as servers, files, sockets, and event listeners. Use `try/finally` or register disposal with Vitest's `onTestFinished` when creating the resource. Clearing the module cache only affects subsequent imports. Reset any deliberately shared fixture state before each case. +Keep shared server test helpers under the owning function's `test/` directory. TypeScript checks them, but the API build excludes them. + ## Parallel database tests Each test run uses separate databases so parallel tests cannot change another worker's records or clear the development database. Vitest runs files in parallel in [forked worker processes](https://vitest.dev/config/pool). Cases within a worker run sequentially, including cases marked concurrent, so database resets cannot erase another case's records. Use `--maxWorkers` to set file-level parallelism. @@ -51,7 +53,7 @@ Use `.test.ts` or `.test.tsx` for ordinary cases. Use `.db.test.ts` or `.db.test Start the PostgreSQL service as described in [Development](development.md) and set `TEST_DATABASE_URL` using `.env.example`. The devcontainer supplies this variable. Use a local database user with permission to create and drop databases, never a production connection. -The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. There are no database test cases yet; an empty run does not prepare or exercise the databases. +The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise persistence, one-use attempts, transaction rollback, and concurrent revocation locks. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. diff --git a/hub/package.json b/hub/package.json index ba09fc60..3e9bbbef 100644 --- a/hub/package.json +++ b/hub/package.json @@ -3,6 +3,20 @@ "version": "0.1.0", "private": true, "type": "module", + "imports": { + "#server/*/constants": { + "development": "./server/*/constants.ts", + "default": "./dist/server/*/constants.js" + }, + "#server/*/types": { + "development": "./server/*/types.ts", + "default": "./dist/server/*/types.js" + }, + "#server/*": { + "development": "./server/*/index.ts", + "default": "./dist/server/*/index.js" + } + }, "engines": { "node": "^24.15.0 || ^26.0.0" }, @@ -22,7 +36,7 @@ "build": "npm run typecheck && vite build && tsc --project tsconfig.server.json", "dev": "npm run dev:client", "dev:client": "vite --host 127.0.0.1", - "dev:api": "node --watch server/index.ts", + "dev:api": "node --conditions=development --watch server/index.ts", "start": "node dist/server/index.js", "preview": "vite preview --host 127.0.0.1", "extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.mjs", diff --git a/hub/schema/identity-state.prisma b/hub/schema/identity-state.prisma new file mode 100644 index 00000000..77757ab5 --- /dev/null +++ b/hub/schema/identity-state.prisma @@ -0,0 +1,14 @@ +model IdentityState { + // ID columns + id String @id @db.Uuid + + // Attribute columns + generation Int @default(0) + encryptedCredentials String? @map("encrypted_credentials") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("identity_states") +} diff --git a/hub/schema/migrations/20261026195151_identity/migration.sql b/hub/schema/migrations/20261026195151_identity/migration.sql new file mode 100644 index 00000000..ec013404 --- /dev/null +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -0,0 +1,89 @@ +-- CreateTable +CREATE TABLE "identity_states" ( + "id" UUID NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "encrypted_credentials" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "identity_states_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "oauth_attempts" ( + "id" UUID NOT NULL, + "session_id" UUID, + "state_digest" TEXT NOT NULL, + "browser_digest" TEXT NOT NULL, + "verifier" TEXT NOT NULL, + "generation" INTEGER NOT NULL, + "credential_revision" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "oauth_attempts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "security_audits" ( + "id" UUID NOT NULL, + "actor_id" UUID, + "session_id" UUID, + "event" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "security_audits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "token_digest" TEXT NOT NULL, + "signed_in_at" TIMESTAMPTZ(3) NOT NULL, + "fresh_authorized_at" TIMESTAMPTZ(3), + "credential_revision" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "provider" TEXT NOT NULL DEFAULT 'github', + "provider_id" TEXT NOT NULL, + "login" TEXT NOT NULL, + "name" TEXT, + "avatar_url" TEXT, + "administrator" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_attempts_state_digest_key" ON "oauth_attempts"("state_digest"); + +-- CreateIndex +CREATE INDEX "oauth_attempts_expires_at_idx" ON "oauth_attempts"("expires_at"); + +-- CreateIndex +CREATE INDEX "security_audits_created_at_idx" ON "security_audits"("created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_digest_key" ON "sessions"("token_digest"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_provider_provider_id_key" ON "users"("provider", "provider_id"); + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/schema/migrations/migration_lock.toml b/hub/schema/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/hub/schema/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/hub/schema/oauth-attempt.prisma b/hub/schema/oauth-attempt.prisma new file mode 100644 index 00000000..6495ce32 --- /dev/null +++ b/hub/schema/oauth-attempt.prisma @@ -0,0 +1,21 @@ +model OAuthAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + sessionId String? @map("session_id") @db.Uuid + + // Attribute columns + stateDigest String @unique @map("state_digest") + browserDigest String @map("browser_digest") + verifier String + generation Int + credentialRevision String @map("credential_revision") + purpose String + expiresAt DateTime @map("expires_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]) + @@map("oauth_attempts") +} diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma new file mode 100644 index 00000000..1496c4bf --- /dev/null +++ b/hub/schema/security-audit.prisma @@ -0,0 +1,16 @@ +model SecurityAudit { + // ID columns + id String @id @default(uuid()) @db.Uuid + actorId String? @map("actor_id") @db.Uuid + sessionId String? @map("session_id") @db.Uuid + + // Attribute columns + event String + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([createdAt]) + @@map("security_audits") +} diff --git a/hub/schema/session.prisma b/hub/schema/session.prisma new file mode 100644 index 00000000..60738974 --- /dev/null +++ b/hub/schema/session.prisma @@ -0,0 +1,21 @@ +model Session { + // ID columns + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Attribute columns + tokenDigest String @unique @map("token_digest") + signedInAt DateTime @map("signed_in_at") @db.Timestamptz(3) + freshAuthorizedAt DateTime? @map("fresh_authorized_at") @db.Timestamptz(3) + credentialRevision String @map("credential_revision") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([userId]) + @@map("sessions") +} diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma new file mode 100644 index 00000000..39a567d5 --- /dev/null +++ b/hub/schema/user.prisma @@ -0,0 +1,22 @@ +model User { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + sessions Session[] + + // Attribute columns + provider String @default("github") + providerId String @map("provider_id") + login String + name String? + avatarUrl String? @map("avatar_url") + administrator Boolean @default(false) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([provider, providerId]) + @@map("users") +} diff --git a/hub/server/domains/identity/create-credential-provider/index.test.ts b/hub/server/domains/identity/create-credential-provider/index.test.ts new file mode 100644 index 00000000..05f34d2e --- /dev/null +++ b/hub/server/domains/identity/create-credential-provider/index.test.ts @@ -0,0 +1,93 @@ +import { generateKeyPairSync } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { createCredentialProvider } from "#server/domains/identity/create-credential-provider" + +function credentials() { + return { + appId: "12345", + installationId: "7", + clientId: "test-client", + clientSecret: "synthetic-client-secret", + privateKey: generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey, + } +} + +describe("createCredentialProvider()", () => { + it("starts unconfigured and encrypts database credentials with fresh authenticated ciphertext", () => { + const provider = createCredentialProvider({ HUB_CREDENTIAL_KEY: "ab".repeat(32) }) + const input = credentials() + + expect(provider.name).toBe("database") + expect(provider.read({ generation: 0, encryptedCredentials: null })).toBeNull() + const encrypted = provider.encrypt!(input) + expect(provider.encrypt!(input)).not.toBe(encrypted) + expect(provider.read({ generation: 0, encryptedCredentials: encrypted })).toEqual(input) + expect(encrypted).not.toContain(input.clientSecret) + expect(Buffer.from(encrypted, "base64").toString()).not.toContain(input.privateKey) + + const tampered = Buffer.from(encrypted, "base64") + tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 1 + expect(() => + provider.read({ generation: 0, encryptedCredentials: tampered.toString("base64") }), + ).toThrow() + const otherKey = createCredentialProvider({ HUB_CREDENTIAL_KEY: "cd".repeat(32) }) + expect(() => otherKey.read({ generation: 0, encryptedCredentials: encrypted })).toThrow() + }) + + it("requires a valid encryption key outside the database", () => { + for (const key of [undefined, "ab".repeat(31), "zz".repeat(32)]) { + expect(() => createCredentialProvider({ HUB_CREDENTIAL_KEY: key })).toThrow() + } + }) + + it("uses complete environment credentials read-only without consulting database ciphertext", () => { + const input = credentials() + const provider = createCredentialProvider({ + HUB_CREDENTIAL_PROVIDER: "environment", + GITHUB_APP_ID: input.appId, + GITHUB_INSTALLATION_ID: input.installationId, + GITHUB_CLIENT_ID: input.clientId, + GITHUB_CLIENT_SECRET: input.clientSecret, + GITHUB_PRIVATE_KEY: input.privateKey, + }) + + expect(provider.name).toBe("environment") + expect(provider.encrypt).toBeUndefined() + expect( + provider.read({ generation: 9, encryptedCredentials: "not-database-credentials" }), + ).toEqual(input) + }) + + it("rejects incomplete environment configuration and unknown providers instead of falling back", () => { + const input = credentials() + const environment = { + HUB_CREDENTIAL_PROVIDER: "environment", + HUB_CREDENTIAL_KEY: "ab".repeat(32), + GITHUB_APP_ID: input.appId, + GITHUB_INSTALLATION_ID: input.installationId, + GITHUB_CLIENT_ID: input.clientId, + GITHUB_CLIENT_SECRET: input.clientSecret, + GITHUB_PRIVATE_KEY: input.privateKey, + } + for (const field of [ + "GITHUB_APP_ID", + "GITHUB_INSTALLATION_ID", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GITHUB_PRIVATE_KEY", + ] as const) { + expect(() => createCredentialProvider({ ...environment, [field]: undefined })).toThrow() + } + expect(() => + createCredentialProvider({ ...environment, GITHUB_PRIVATE_KEY: "invalid" }), + ).toThrow() + expect(() => + createCredentialProvider({ ...environment, HUB_CREDENTIAL_PROVIDER: "other" }), + ).toThrow() + }) +}) diff --git a/hub/server/domains/identity/create-credential-provider/index.ts b/hub/server/domains/identity/create-credential-provider/index.ts new file mode 100644 index 00000000..3b6f661e --- /dev/null +++ b/hub/server/domains/identity/create-credential-provider/index.ts @@ -0,0 +1,34 @@ +import { decryptGitHubCredentials } from "#server/integrations/github/decrypt-github-credentials" +import { encryptGitHubCredentials } from "#server/integrations/github/encrypt-github-credentials" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { CredentialProvider } from "#server/domains/identity/create-credential-provider/types" + +export function createCredentialProvider(environment: NodeJS.ProcessEnv): CredentialProvider { + const selected = environment.HUB_CREDENTIAL_PROVIDER ?? "database" + if (selected === "environment") { + const credentials = validateGitHubCredentials({ + appId: environment.GITHUB_APP_ID, + installationId: environment.GITHUB_INSTALLATION_ID, + clientId: environment.GITHUB_CLIENT_ID, + clientSecret: environment.GITHUB_CLIENT_SECRET, + privateKey: environment.GITHUB_PRIVATE_KEY, + }) + return { name: "environment", read: () => credentials } + } + if (selected !== "database") { + throw new Error("HUB_CREDENTIAL_PROVIDER must be database or environment.") + } + + const encodedKey = environment.HUB_CREDENTIAL_KEY + if (!encodedKey || !/^[a-fA-F0-9]{64}$/.test(encodedKey)) { + throw new Error( + "Set HUB_CREDENTIAL_KEY to a 32-byte hexadecimal encryption key outside the database.", + ) + } + const key = Buffer.from(encodedKey, "hex") + return { + name: "database", + read: (state) => decryptGitHubCredentials(key, state.encryptedCredentials), + encrypt: encryptGitHubCredentials.bind(null, key), + } +} diff --git a/hub/server/domains/identity/create-credential-provider/types.ts b/hub/server/domains/identity/create-credential-provider/types.ts new file mode 100644 index 00000000..db294218 --- /dev/null +++ b/hub/server/domains/identity/create-credential-provider/types.ts @@ -0,0 +1,8 @@ +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import type { IdentityState } from "#server/repositories/identity-state/types" + +export interface CredentialProvider { + name: "database" | "environment" + read(state: IdentityState): GitHubCredentials | null + encrypt?: (credentials: GitHubCredentials) => string +} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts new file mode 100644 index 00000000..81e7074c --- /dev/null +++ b/hub/server/domains/identity/create-records/index.ts @@ -0,0 +1,44 @@ +import { advanceIdentityGeneration } from "#server/repositories/identity-state/advance-generation" +import { getIdentityState } from "#server/repositories/identity-state/get-state" +import { saveIdentityCredentials } from "#server/repositories/identity-state/save-credentials" +import { consumeOAuthAttempt } from "#server/repositories/oauth-attempts/consume-attempt" +import { createOAuthAttempt } from "#server/repositories/oauth-attempts/create-attempt" +import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { createSession } from "#server/repositories/sessions/create-session" +import { deleteSession } from "#server/repositories/sessions/delete-session" +import { deleteSessions } from "#server/repositories/sessions/delete-sessions" +import { getSession } from "#server/repositories/sessions/get-session" +import { getSessionById } from "#server/repositories/sessions/get-session-by-id" +import { invalidateFreshness } from "#server/repositories/sessions/invalidate-freshness" +import { verifySession } from "#server/repositories/sessions/verify-session" +import { enrollAdministrator } from "#server/repositories/users/enroll-administrator" +import { getGitHubUser } from "#server/repositories/users/get-github-user" +import { getUser } from "#server/repositories/users/get-user" +import { saveGitHubUser } from "#server/repositories/users/save-github-user" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function createIdentityRecords(database: Prisma.TransactionClient): IdentityRecords { + return { + state: getIdentityState.bind(null, database), + saveCredentials: saveIdentityCredentials.bind(null, database), + advanceGeneration: advanceIdentityGeneration.bind(null, database), + user: getUser.bind(null, database), + githubUser: getGitHubUser.bind(null, database), + saveUser: saveGitHubUser.bind(null, database), + enroll: enrollAdministrator.bind(null, database), + session: getSession.bind(null, database), + sessionById: getSessionById.bind(null, database), + createSession: createSession.bind(null, database), + verifySession: verifySession.bind(null, database), + invalidateFreshness: invalidateFreshness.bind(null, database), + deleteSession: deleteSession.bind(null, database), + deleteSessions: deleteSessions.bind(null, database), + createAttempt: createOAuthAttempt.bind(null, database), + consumeAttempt: consumeOAuthAttempt.bind(null, database), + audit: saveSecurityAudit.bind(null, database), + audits: getSecurityAudits.bind(null, database), + } +} diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts new file mode 100644 index 00000000..07b66171 --- /dev/null +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createIdentityStore } from "#server/domains/identity/create-store" + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { prisma, store: createIdentityStore(prisma) } +} + +describe("createIdentityStore()", () => { + it("rolls back credential, session, generation, and audit changes together", async () => { + const { store } = await database() + await store.lock(async (records) => { + const user = await records.saveUser({ + providerId: "10001", + login: "synthetic-user", + name: null, + avatarUrl: null, + }) + await records.saveCredentials("initial-encrypted-fixture") + await records.createSession({ + userId: user.id, + tokenDigest: "synthetic-token-digest", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + credentialRevision: "initial-revision", + }) + }) + + await expect( + store.lock(async (records) => { + await records.saveCredentials("replacement-encrypted-fixture") + await records.advanceGeneration() + await records.deleteSessions() + await records.audit({ event: "session.revoke_all", actorId: null, sessionId: null }) + throw new Error("Transaction failed") + }), + ).rejects.toThrow("Transaction failed") + + expect(await store.read.state()).toMatchObject({ + generation: 0, + encryptedCredentials: "initial-encrypted-fixture", + }) + expect(await store.read.session("synthetic-token-digest")).not.toBeNull() + expect(await store.read.audits()).toEqual([]) + }) + + it("serializes locks so an overlapping mutation reads the committed generation", async () => { + const { store } = await database() + await store.lock(() => Promise.resolve()) + let entered!: () => void + let release!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const first = store.lock(async (records) => { + await records.advanceGeneration() + entered() + await gate + }) + await started + const second = store.lock(async (records) => { + const state = await records.state() + await records.advanceGeneration() + return state.generation + }) + release() + + await first + expect(await second).toBe(1) + expect((await store.read.state()).generation).toBe(2) + }) + + it("upserts by immutable GitHub account rather than a reused login", async () => { + const { store } = await database() + const profile = { providerId: "10001", login: "synthetic-user", name: null, avatarUrl: null } + const original = await store.lock((records) => records.saveUser(profile)) + await store.lock((records) => records.enroll(original.id)) + const renamed = await store.lock((records) => + records.saveUser({ ...profile, login: "renamed-user" }), + ) + const reused = await store.lock((records) => + records.saveUser({ ...profile, providerId: "20002" }), + ) + + expect(renamed.id).toBe(original.id) + expect(renamed.administrator).toBe(true) + expect(reused.id).not.toBe(original.id) + expect(reused.administrator).toBe(false) + }) + + it("consumes an OAuth attempt once across concurrent transactions and clears pending attempts on a cutoff", async () => { + const { store } = await database() + const input = { + stateDigest: "synthetic-state-digest", + browserDigest: "synthetic-browser-digest", + verifier: "synthetic-verifier", + generation: 0, + credentialRevision: "synthetic-revision", + purpose: "sign-in" as const, + sessionId: null, + expiresAt: new Date(Date.now() + 15 * 60_000), + } + await store.lock((records) => records.createAttempt(input)) + const results = await Promise.all([ + store.lock((records) => records.consumeAttempt(input.stateDigest)), + store.lock((records) => records.consumeAttempt(input.stateDigest)), + ]) + expect(results.filter(Boolean)).toHaveLength(1) + expect(results.find(Boolean)).toMatchObject(input) + + await store.lock((records) => records.createAttempt({ ...input, stateDigest: "pending-state" })) + await store.lock((records) => records.advanceGeneration()) + expect(await store.lock((records) => records.consumeAttempt("pending-state"))).toBeNull() + }) + + it("invalidates fresh proof without deleting sessions and removes originating attempts on sign-out", async () => { + const { store } = await database() + const session = await store.lock(async (records) => { + const user = await records.saveUser({ + providerId: "10001", + login: "synthetic-user", + name: null, + avatarUrl: null, + }) + const session = await records.createSession({ + userId: user.id, + tokenDigest: "synthetic-token-digest", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + credentialRevision: "synthetic-revision", + }) + await records.createAttempt({ + stateDigest: "synthetic-state-digest", + browserDigest: "synthetic-browser-digest", + verifier: "synthetic-verifier", + generation: 0, + credentialRevision: "synthetic-revision", + purpose: "verify", + sessionId: session.id, + expiresAt: new Date(Date.now() + 15 * 60_000), + }) + return session + }) + + await store.lock((records) => records.invalidateFreshness()) + expect(await store.read.sessionById(session.id)).toMatchObject({ freshAuthorizedAt: null }) + await store.lock((records) => records.deleteSession(session.id)) + expect(await store.read.session("synthetic-token-digest")).toBeNull() + expect( + await store.lock((records) => records.consumeAttempt("synthetic-state-digest")), + ).toBeNull() + }) + + it("hides audits older than ninety days and prunes them on writes while retaining the cutoff", async () => { + const { prisma, store } = await database() + const now = Date.now() + vi.spyOn(Date, "now").mockReturnValue(now) + const cutoff = now - 90 * 24 * 60 * 60_000 + const old = await prisma.securityAudit.create({ + data: { + event: "identity.denied.request_failed", + createdAt: new Date(cutoff - 1), + }, + }) + const retained = await prisma.securityAudit.create({ + data: { + event: "session.sign_in", + createdAt: new Date(cutoff), + }, + }) + + expect((await store.read.audits()).map(({ id }) => id)).toEqual([retained.id]) + expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).not.toBeNull() + await store.lock((records) => + records.audit({ event: "credentials.setup", actorId: null, sessionId: null }), + ) + + expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).toBeNull() + expect(await prisma.securityAudit.findUnique({ where: { id: retained.id } })).not.toBeNull() + expect((await store.read.audits()).map(({ event }) => event)).toEqual([ + "credentials.setup", + "session.sign_in", + ]) + expect(await prisma.securityAudit.count()).toBe(2) + }) +}) diff --git a/hub/server/domains/identity/create-store/index.ts b/hub/server/domains/identity/create-store/index.ts new file mode 100644 index 00000000..73da355b --- /dev/null +++ b/hub/server/domains/identity/create-store/index.ts @@ -0,0 +1,12 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { withIdentityStoreLock } from "#server/domains/identity/with-store-lock" +import type { IdentityStore } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function createIdentityStore(client: PrismaClient): IdentityStore { + return { + read: createIdentityRecords(client), + lock: (operation) => withIdentityStoreLock(client, operation), + } +} diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts new file mode 100644 index 00000000..39d3a962 --- /dev/null +++ b/hub/server/domains/identity/create-store/types.ts @@ -0,0 +1,31 @@ +import type { IdentityState } from "#server/repositories/identity-state/types" +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" +import type { AuditInput } from "#server/repositories/security-audits/types" +import type { HubSession } from "#server/repositories/sessions/types" +import type { GitHubUserInput, HubUser } from "#server/repositories/users/types" + +export interface IdentityRecords { + state(): Promise + saveCredentials(encryptedCredentials: string): Promise + advanceGeneration(): Promise + user(id: string): Promise + githubUser(providerId: string): Promise + saveUser(profile: GitHubUserInput): Promise + enroll(userId: string): Promise + session(tokenDigest: string): Promise<(HubSession & { user: HubUser }) | null> + sessionById(id: string): Promise<(HubSession & { user: HubUser }) | null> + createSession(input: Omit): Promise + verifySession(id: string, at: Date, credentialRevision: string): Promise + invalidateFreshness(): Promise + deleteSession(id: string): Promise + deleteSessions(): Promise + createAttempt(input: Omit): Promise + consumeAttempt(stateDigest: string): Promise + audit(input: AuditInput): Promise + audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> +} + +export interface IdentityStore { + read: IdentityRecords + lock(operation: (records: IdentityRecords) => Promise): Promise +} diff --git a/hub/server/domains/identity/with-store-lock/index.ts b/hub/server/domains/identity/with-store-lock/index.ts new file mode 100644 index 00000000..7613f7fa --- /dev/null +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -0,0 +1,12 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { withIdentityLock } from "#server/repositories/identity-state/with-identity-lock" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityStoreLock( + client: PrismaClient, + operation: (records: IdentityRecords) => Promise, +): Promise { + return withIdentityLock(client, (database) => operation(createIdentityRecords(database))) +} diff --git a/hub/server/integrations/github/decrypt-github-credentials/index.ts b/hub/server/integrations/github/decrypt-github-credentials/index.ts new file mode 100644 index 00000000..d25b1c18 --- /dev/null +++ b/hub/server/integrations/github/decrypt-github-credentials/index.ts @@ -0,0 +1,22 @@ +import { createDecipheriv } from "node:crypto" + +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function decryptGitHubCredentials( + key: Buffer, + encryptedCredentials: string | null, +): GitHubCredentials | null { + if (encryptedCredentials === null) return null + try { + const data = Buffer.from(encryptedCredentials, "base64") + const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12)) + decipher.setAAD(credentialContext) + decipher.setAuthTag(data.subarray(12, 28)) + const plaintext = Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]) + return validateGitHubCredentials(JSON.parse(plaintext.toString("utf8"))) + } catch { + throw new Error("Cannot decrypt GitHub App credentials. Restore the configured encryption key.") + } +} diff --git a/hub/server/integrations/github/encrypt-github-credentials/constants.ts b/hub/server/integrations/github/encrypt-github-credentials/constants.ts new file mode 100644 index 00000000..45c9388c --- /dev/null +++ b/hub/server/integrations/github/encrypt-github-credentials/constants.ts @@ -0,0 +1 @@ +export const credentialContext = Buffer.from("replica:github-app:v1") diff --git a/hub/server/integrations/github/encrypt-github-credentials/index.ts b/hub/server/integrations/github/encrypt-github-credentials/index.ts new file mode 100644 index 00000000..56876cdc --- /dev/null +++ b/hub/server/integrations/github/encrypt-github-credentials/index.ts @@ -0,0 +1,12 @@ +import { createCipheriv, randomBytes } from "node:crypto" + +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function encryptGitHubCredentials(key: Buffer, credentials: GitHubCredentials): string { + const nonce = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", key, nonce) + cipher.setAAD(credentialContext) + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(credentials)), cipher.final()]) + return Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString("base64") +} diff --git a/hub/server/integrations/github/github-error/index.ts b/hub/server/integrations/github/github-error/index.ts new file mode 100644 index 00000000..9be9cfe6 --- /dev/null +++ b/hub/server/integrations/github/github-error/index.ts @@ -0,0 +1,10 @@ +import type { GitHubErrorCode } from "#server/integrations/github/github-error/types" + +export class GitHubError extends Error { + code: GitHubErrorCode + + constructor(code: GitHubErrorCode, message: string) { + super(message) + this.code = code + } +} diff --git a/hub/server/integrations/github/github-error/types.ts b/hub/server/integrations/github/github-error/types.ts new file mode 100644 index 00000000..a5fdfb96 --- /dev/null +++ b/hub/server/integrations/github/github-error/types.ts @@ -0,0 +1 @@ +export type GitHubErrorCode = "invalid_credentials" diff --git a/hub/server/integrations/github/validate-github-credentials/constants.ts b/hub/server/integrations/github/validate-github-credentials/constants.ts new file mode 100644 index 00000000..3a497c7e --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/constants.ts @@ -0,0 +1,16 @@ +import { z } from "zod" + +const githubIdSchema = z + .string() + .regex(/^[1-9]\d*$/) + .max(30) + +export const credentialsSchema = z + .object({ + appId: githubIdSchema, + installationId: githubIdSchema, + clientId: z.string().min(1).max(200), + clientSecret: z.string().min(1).max(1000), + privateKey: z.string().min(1).max(16384), + }) + .strict() diff --git a/hub/server/integrations/github/validate-github-credentials/index.ts b/hub/server/integrations/github/validate-github-credentials/index.ts new file mode 100644 index 00000000..744553d5 --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/index.ts @@ -0,0 +1,30 @@ +import { createPrivateKey } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { GitHubError } from "#server/integrations/github/github-error" + +import { credentialsSchema } from "#server/integrations/github/validate-github-credentials/constants" + +export function validateGitHubCredentials(input: unknown): GitHubCredentials { + const parsed = credentialsSchema.safeParse(input) + if (!parsed.success) { + throw new GitHubError( + "invalid_credentials", + "Supply the GitHub App ID, installation ID, client ID, client secret, and RSA private key.", + ) + } + + try { + const key = createPrivateKey(parsed.data.privateKey) + if (key.asymmetricKeyType !== "rsa" || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) { + throw new Error("Invalid key") + } + } catch { + throw new GitHubError( + "invalid_credentials", + "The GitHub App private key must be an RSA key of at least 2048 bits.", + ) + } + + return parsed.data +} diff --git a/hub/server/integrations/github/validate-github-credentials/types.ts b/hub/server/integrations/github/validate-github-credentials/types.ts new file mode 100644 index 00000000..d45f9941 --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/types.ts @@ -0,0 +1,7 @@ +export interface GitHubCredentials { + appId: string + installationId: string + clientId: string + clientSecret: string + privateKey: string +} diff --git a/hub/server/integrations/prisma/index.ts b/hub/server/integrations/prisma/index.ts index f9c0d9b0..78f43a6d 100644 --- a/hub/server/integrations/prisma/index.ts +++ b/hub/server/integrations/prisma/index.ts @@ -1,5 +1,5 @@ -import "dotenv/config" import { PrismaPg } from "@prisma/adapter-pg" +import "dotenv/config" import { PrismaClient } from "../../generated/prisma/client.ts" diff --git a/hub/server/repositories/identity-state/advance-generation/index.ts b/hub/server/repositories/identity-state/advance-generation/index.ts new file mode 100644 index 00000000..899e0c96 --- /dev/null +++ b/hub/server/repositories/identity-state/advance-generation/index.ts @@ -0,0 +1,11 @@ +import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function advanceIdentityGeneration(database: Prisma.TransactionClient) { + await database.identityState.update({ + where: { id: staticSettingsRecordId }, + data: { generation: { increment: 1 } }, + }) + await database.oAuthAttempt.deleteMany() +} diff --git a/hub/server/repositories/identity-state/constants.ts b/hub/server/repositories/identity-state/constants.ts new file mode 100644 index 00000000..19030771 --- /dev/null +++ b/hub/server/repositories/identity-state/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process must lock the same settings row before changing identity state. +export const staticSettingsRecordId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/identity-state/get-state/index.ts b/hub/server/repositories/identity-state/get-state/index.ts new file mode 100644 index 00000000..401138ae --- /dev/null +++ b/hub/server/repositories/identity-state/get-state/index.ts @@ -0,0 +1,7 @@ +import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getIdentityState(database: Prisma.TransactionClient) { + return database.identityState.findUniqueOrThrow({ where: { id: staticSettingsRecordId } }) +} diff --git a/hub/server/repositories/identity-state/save-credentials/index.ts b/hub/server/repositories/identity-state/save-credentials/index.ts new file mode 100644 index 00000000..264b0ebb --- /dev/null +++ b/hub/server/repositories/identity-state/save-credentials/index.ts @@ -0,0 +1,13 @@ +import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveIdentityCredentials( + database: Prisma.TransactionClient, + encryptedCredentials: string, +) { + await database.identityState.update({ + where: { id: staticSettingsRecordId }, + data: { encryptedCredentials }, + }) +} diff --git a/hub/server/repositories/identity-state/types.ts b/hub/server/repositories/identity-state/types.ts new file mode 100644 index 00000000..19042699 --- /dev/null +++ b/hub/server/repositories/identity-state/types.ts @@ -0,0 +1,4 @@ +export interface IdentityState { + generation: number + encryptedCredentials: string | null +} diff --git a/hub/server/repositories/identity-state/with-identity-lock/index.ts b/hub/server/repositories/identity-state/with-identity-lock/index.ts new file mode 100644 index 00000000..4f964f4b --- /dev/null +++ b/hub/server/repositories/identity-state/with-identity-lock/index.ts @@ -0,0 +1,18 @@ +import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityLock( + client: PrismaClient, + operation: (database: Prisma.TransactionClient) => Promise, +): Promise { + return client.$transaction(async (transaction) => { + await transaction.identityState.upsert({ + where: { id: staticSettingsRecordId }, + create: { id: staticSettingsRecordId }, + update: {}, + }) + await transaction.$queryRaw`SELECT id FROM identity_states WHERE id = ${staticSettingsRecordId}::uuid FOR UPDATE` + return operation(transaction) + }) +} diff --git a/hub/server/repositories/oauth-attempts/consume-attempt/index.ts b/hub/server/repositories/oauth-attempts/consume-attempt/index.ts new file mode 100644 index 00000000..fee04b3e --- /dev/null +++ b/hub/server/repositories/oauth-attempts/consume-attempt/index.ts @@ -0,0 +1,20 @@ +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function consumeOAuthAttempt( + database: Prisma.TransactionClient, + stateDigest: string, +): Promise { + const attempt = await database.oAuthAttempt.findUnique({ where: { stateDigest } }) + if (!attempt) return null + await database.oAuthAttempt.delete({ where: { id: attempt.id } }) + if ( + attempt.purpose !== "sign-in" && + attempt.purpose !== "verify" && + attempt.purpose !== "enroll" + ) { + throw new Error("Stored OAuth purpose is invalid.") + } + return { ...attempt, purpose: attempt.purpose } +} diff --git a/hub/server/repositories/oauth-attempts/create-attempt/index.ts b/hub/server/repositories/oauth-attempts/create-attempt/index.ts new file mode 100644 index 00000000..fad43ef1 --- /dev/null +++ b/hub/server/repositories/oauth-attempts/create-attempt/index.ts @@ -0,0 +1,11 @@ +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createOAuthAttempt( + database: Prisma.TransactionClient, + data: Omit, +) { + await database.oAuthAttempt.deleteMany({ where: { expiresAt: { lte: new Date() } } }) + await database.oAuthAttempt.create({ data }) +} diff --git a/hub/server/repositories/oauth-attempts/types.ts b/hub/server/repositories/oauth-attempts/types.ts new file mode 100644 index 00000000..91d56160 --- /dev/null +++ b/hub/server/repositories/oauth-attempts/types.ts @@ -0,0 +1,11 @@ +export interface OAuthAttempt { + id: string + stateDigest: string + browserDigest: string + verifier: string + generation: number + credentialRevision: string + purpose: "sign-in" | "verify" | "enroll" + sessionId: string | null + expiresAt: Date +} diff --git a/hub/server/repositories/security-audits/constants.ts b/hub/server/repositories/security-audits/constants.ts new file mode 100644 index 00000000..2f80cb2e --- /dev/null +++ b/hub/server/repositories/security-audits/constants.ts @@ -0,0 +1,2 @@ +export const auditRetentionMs = 90 * 24 * 60 * 60_000 +export const auditResultLimit = 100 diff --git a/hub/server/repositories/security-audits/get-audits/index.ts b/hub/server/repositories/security-audits/get-audits/index.ts new file mode 100644 index 00000000..1b4a2e2c --- /dev/null +++ b/hub/server/repositories/security-audits/get-audits/index.ts @@ -0,0 +1,11 @@ +import { auditResultLimit, auditRetentionMs } from "#server/repositories/security-audits/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSecurityAudits(database: Prisma.TransactionClient) { + return database.securityAudit.findMany({ + where: { createdAt: { gte: new Date(Date.now() - auditRetentionMs) } }, + orderBy: { createdAt: "desc" }, + take: auditResultLimit, + }) +} diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts new file mode 100644 index 00000000..df32fdd1 --- /dev/null +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -0,0 +1,11 @@ +import { auditRetentionMs } from "#server/repositories/security-audits/constants" +import type { AuditInput } from "#server/repositories/security-audits/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveSecurityAudit(database: Prisma.TransactionClient, data: AuditInput) { + await database.securityAudit.deleteMany({ + where: { createdAt: { lt: new Date(Date.now() - auditRetentionMs) } }, + }) + await database.securityAudit.create({ data }) +} diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts new file mode 100644 index 00000000..ce9f5c5f --- /dev/null +++ b/hub/server/repositories/security-audits/types.ts @@ -0,0 +1,5 @@ +export interface AuditInput { + event: string + actorId: string | null + sessionId: string | null +} diff --git a/hub/server/repositories/sessions/create-session/index.ts b/hub/server/repositories/sessions/create-session/index.ts new file mode 100644 index 00000000..a5afcd10 --- /dev/null +++ b/hub/server/repositories/sessions/create-session/index.ts @@ -0,0 +1,7 @@ +import type { HubSession } from "#server/repositories/sessions/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function createSession(database: Prisma.TransactionClient, data: Omit) { + return database.session.create({ data }) +} diff --git a/hub/server/repositories/sessions/delete-session/index.ts b/hub/server/repositories/sessions/delete-session/index.ts new file mode 100644 index 00000000..5c1b0857 --- /dev/null +++ b/hub/server/repositories/sessions/delete-session/index.ts @@ -0,0 +1,6 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSession(database: Prisma.TransactionClient, id: string) { + await database.session.deleteMany({ where: { id } }) + await database.oAuthAttempt.deleteMany({ where: { sessionId: id } }) +} diff --git a/hub/server/repositories/sessions/delete-sessions/index.ts b/hub/server/repositories/sessions/delete-sessions/index.ts new file mode 100644 index 00000000..2d8f5fd1 --- /dev/null +++ b/hub/server/repositories/sessions/delete-sessions/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSessions(database: Prisma.TransactionClient) { + await database.session.deleteMany() +} diff --git a/hub/server/repositories/sessions/get-session-by-id/index.ts b/hub/server/repositories/sessions/get-session-by-id/index.ts new file mode 100644 index 00000000..c99dd229 --- /dev/null +++ b/hub/server/repositories/sessions/get-session-by-id/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSessionById(database: Prisma.TransactionClient, id: string) { + return database.session.findUnique({ where: { id }, include: { user: true } }) +} diff --git a/hub/server/repositories/sessions/get-session/index.ts b/hub/server/repositories/sessions/get-session/index.ts new file mode 100644 index 00000000..731c2e24 --- /dev/null +++ b/hub/server/repositories/sessions/get-session/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSession(database: Prisma.TransactionClient, tokenDigest: string) { + return database.session.findUnique({ where: { tokenDigest }, include: { user: true } }) +} diff --git a/hub/server/repositories/sessions/invalidate-freshness/index.ts b/hub/server/repositories/sessions/invalidate-freshness/index.ts new file mode 100644 index 00000000..9b106204 --- /dev/null +++ b/hub/server/repositories/sessions/invalidate-freshness/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function invalidateFreshness(database: Prisma.TransactionClient) { + await database.session.updateMany({ data: { freshAuthorizedAt: null } }) +} diff --git a/hub/server/repositories/sessions/types.ts b/hub/server/repositories/sessions/types.ts new file mode 100644 index 00000000..eb971187 --- /dev/null +++ b/hub/server/repositories/sessions/types.ts @@ -0,0 +1,8 @@ +export interface HubSession { + id: string + userId: string + tokenDigest: string + signedInAt: Date + freshAuthorizedAt: Date | null + credentialRevision: string +} diff --git a/hub/server/repositories/sessions/verify-session/index.ts b/hub/server/repositories/sessions/verify-session/index.ts new file mode 100644 index 00000000..a2b744f9 --- /dev/null +++ b/hub/server/repositories/sessions/verify-session/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function verifySession( + database: Prisma.TransactionClient, + id: string, + freshAuthorizedAt: Date, + credentialRevision: string, +) { + await database.session.update({ + where: { id }, + data: { freshAuthorizedAt, credentialRevision }, + }) +} diff --git a/hub/server/repositories/users/enroll-administrator/index.ts b/hub/server/repositories/users/enroll-administrator/index.ts new file mode 100644 index 00000000..8f7c84ed --- /dev/null +++ b/hub/server/repositories/users/enroll-administrator/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function enrollAdministrator(database: Prisma.TransactionClient, id: string) { + return database.user.update({ where: { id }, data: { administrator: true } }) +} diff --git a/hub/server/repositories/users/get-github-user/index.ts b/hub/server/repositories/users/get-github-user/index.ts new file mode 100644 index 00000000..033275b6 --- /dev/null +++ b/hub/server/repositories/users/get-github-user/index.ts @@ -0,0 +1,7 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getGitHubUser(database: Prisma.TransactionClient, providerId: string) { + return database.user.findUnique({ + where: { provider_providerId: { provider: "github", providerId } }, + }) +} diff --git a/hub/server/repositories/users/get-user/index.ts b/hub/server/repositories/users/get-user/index.ts new file mode 100644 index 00000000..956bc0e6 --- /dev/null +++ b/hub/server/repositories/users/get-user/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getUser(database: Prisma.TransactionClient, id: string) { + return database.user.findUnique({ where: { id } }) +} diff --git a/hub/server/repositories/users/save-github-user/index.ts b/hub/server/repositories/users/save-github-user/index.ts new file mode 100644 index 00000000..aee7135a --- /dev/null +++ b/hub/server/repositories/users/save-github-user/index.ts @@ -0,0 +1,11 @@ +import type { GitHubUserInput } from "#server/repositories/users/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function saveGitHubUser(database: Prisma.TransactionClient, profile: GitHubUserInput) { + return database.user.upsert({ + where: { provider_providerId: { provider: "github", providerId: profile.providerId } }, + create: { ...profile, provider: "github" }, + update: profile, + }) +} diff --git a/hub/server/repositories/users/types.ts b/hub/server/repositories/users/types.ts new file mode 100644 index 00000000..6f82d79e --- /dev/null +++ b/hub/server/repositories/users/types.ts @@ -0,0 +1,11 @@ +export interface HubUser { + id: string + provider: string + providerId: string + login: string + name: string | null + avatarUrl: string | null + administrator: boolean +} + +export type GitHubUserInput = Pick diff --git a/hub/tsconfig.json b/hub/tsconfig.json index 516108e7..fe11015c 100644 --- a/hub/tsconfig.json +++ b/hub/tsconfig.json @@ -8,6 +8,7 @@ "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "customConditions": ["development"], "noEmit": true, "noUncheckedIndexedAccess": true, "paths": { diff --git a/hub/tsconfig.server.json b/hub/tsconfig.server.json index ebd6babc..6c5d0765 100644 --- a/hub/tsconfig.server.json +++ b/hub/tsconfig.server.json @@ -12,5 +12,5 @@ "lib": ["ES2023"] }, "include": ["server/**/*.ts"], - "exclude": ["server/**/*.test.ts"] + "exclude": ["server/**/*.test.ts", "server/**/test/**"] }