diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6760d91..6562616 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ npm ci npm run dev ``` -This starts the frontend. In a second terminal, run `cd hub && npm run dev:api` to start the API. See [Hub development](hub/docs/development.md) for database setup, addresses, and commands. A GitHub App is not required. +This starts the frontend. Before starting the API, apply migrations and [configure identity](hub/docs/identity.md#configure-credentials). Run `cd hub && npm run dev:api` in a second terminal. See [Hub development](hub/docs/development.md) for database setup, addresses, and commands. ### Replica Starter diff --git a/hub/.env.example b/hub/.env.example index a0c2ce7..fe4a5a6 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -7,11 +7,13 @@ 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. +# API startup checks the database and this selected credential provider. +HUB_ORIGIN=http://127.0.0.1:5173 HUB_CREDENTIAL_PROVIDER=database # Generate separate values outside the database. See docs/identity.md. # HUB_CREDENTIAL_KEY= +# HUB_BOOTSTRAP_SECRET= # The environment provider is read-only and requires every GitHub setting below. # GITHUB_APP_ID= diff --git a/hub/AGENTS.md b/hub/AGENTS.md index afced34..7f86635 100644 --- a/hub/AGENTS.md +++ b/hub/AGENTS.md @@ -6,10 +6,11 @@ Hub is being rebuilt in TypeScript. The app starts locally, but its product feat - [Code architecture conventions](docs/backend-architecture.md) define repositories, integrations, utilities, domains, and allowed imports. - [Coding conventions](docs/coding-conventions.md) explain frontend layers, formatting, and interface rules. - [Database conventions](docs/database.md) define Prisma files, model section order, UUID keys, timestamps, and database naming. +- [Identity](docs/identity.md) defines GitHub sign-in, credential providers, sessions, and repository permission checks. - [Development](docs/development.md) and [testing](docs/testing-and-operations.md) own local and CI commands. - [Storybook](docs/storybook.md#ui-changes-and-pull-request-evidence) owns presentation coverage, cleanup, and PR screenshots. -Run Node commands from `hub/`, locally or inside the devcontainer. PostgreSQL and Prisma are configured without product models. Agree on the design before adding models, sign-in, deployment, or product API routes. +Run Node commands from `hub/`, locally or inside the devcontainer. PostgreSQL and Prisma store the initial identity records. Agree on the design before adding product models, deployment, sign-in providers, or product API routes. `npm test` does not require PostgreSQL. Use `npm run test:database` for `.db.test.ts` and `.db.test.tsx` cases in Node.js or jsdom. Follow the database isolation and reset rules in [testing](docs/testing-and-operations.md). diff --git a/hub/README.md b/hub/README.md index fba5019..4672a18 100644 --- a/hub/README.md +++ b/hub/README.md @@ -1,10 +1,10 @@ # Replica Hub -Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. +Hub uses TypeScript, Express, React, Vite, and Vitest. -The API has one route: `GET /api/health`. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API. +The API provides `GET /api/health` and a [GitHub-first identity boundary](docs/identity.md) for setup, sign-in, sessions, and repository permission checks. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. Sign-in, saved data, live updates, background jobs, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. Identity records are persisted, but product data, live updates, background jobs, and deployment are not implemented. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/docs/README.md b/hub/docs/README.md index 857deb9..caf8de0 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -7,7 +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, sign-in rules, and session access. +- [Identity](identity.md): GitHub sign-in, credential providers, sessions, and repository authorization. - [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 1a3d7d4..8475e83 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,20 +6,18 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -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. +The API responds to a health check and provides the [identity API](identity.md). 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. 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. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. Identity models store credentials, users, sessions, one-use OAuth attempts, and security records. Deployment, background jobs, and product APIs have not been built. This app cannot yet list repositories in the frontend, 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. +API code lives under `server/`. [Routes](backend-architecture.md#routes) mirror URL paths and HTTP methods. They parse requests, call backend functions, and send responses. The [code conventions](backend-architecture.md) define the backend layers and their import boundaries. -The only route is `GET /api/health`, which returns `{ "status": "ok" }`. Every other path returns a JSON 404 response. `createApp()` builds the Express app without starting a server. `server/index.ts` starts it at a local address. +`GET /api/health` returns `{ "status": "ok" }`. The [identity guide](identity.md#api-operations) lists the other API operations. Unknown paths return a JSON 404 response. `createApp()` builds the Express app without starting a server. `server/index.ts` configures the selected credential provider and starts the API 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. -The [identity service](identity.md) implements GitHub sign-in, sessions, repository permission checks, and administration rules. Its domain contracts and errors stay separate from the GitHub integration and do not assign HTTP statuses. - ## Frontend ```mermaid diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 3941aec..25a5cb2 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -1,11 +1,12 @@ # Code architecture conventions -Organize backend functions into repositories, integrations, utilities, and domains. Each layer has a clear job and rules for which other layers it may use. +Organize backend functions into routes, domains, repositories, integrations, and utilities. | Layer | Responsibility | Directory example | | --- | --- | --- | +| Routes | Parse HTTP input, call backend functions, and send responses. | `routes/api/session/get/index.ts` | | Repositories | Functions that interact with the database. Each repository directory represents one RESTful resource. | `repositories/user/get-user/index.ts` | -| Integrations | Functions that interact with an API or SDK. Each integration directory represents one API or SDK service. | `integrations/github/get-user/index.ts` | +| Integrations | Functions tied to an API, SDK, or framework. Group them by service or framework. | `integrations/express/respond-not-found/index.ts` | | Utilities | General functions reusable elsewhere in the codebase. | `utils/format-timestamp/index.ts` | | Domains | Business logic functions that may compose repositories, integrations, and utilities. | `domains/user/get-user-profile/index.ts` | @@ -13,6 +14,10 @@ Organize backend functions into repositories, integrations, utilities, and domai ```mermaid flowchart LR + Routes --> Domains + Routes --> Repositories + Routes --> Integrations + Routes --> Utilities Domains --> Repositories Domains --> Integrations Domains --> Utilities @@ -20,7 +25,7 @@ flowchart LR Integrations --> Utilities ``` -Repositories and integrations may import only utilities from elsewhere in the backend. Utilities may import only other utilities. Domains may import any of these layers, including other domains. Each layer may also use built-in libraries and npm packages. +Routes may import domains, repositories, integrations, and utilities. Repositories and integrations may import only utilities from other layers. Utilities may import only other utilities. Domains may import repositories, integrations, utilities, and other domains. Each layer may also use built-in libraries and npm packages. These rules apply to imported types as well as functions. A repository must not call another repository or an integration. An integration must not call a repository. Keep business decisions in domains rather than moving them into utilities to get around these rules. @@ -28,13 +33,21 @@ These rules apply to imported types as well as functions. A repository must not Put each function in a directory named with lowercase words and hyphens, such as `get-user`. Keep `index.ts`, its tests, and its types together. Group repository functions by resource and integration functions by API or SDK service, as shown above. Import from each function's directory, not from a shared file that re-exports its neighbors. +Keep function implementations flat. Extract helpers into their own function directories rather than nesting them in a factory. 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`. + 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`. +## Routes + +Mirror the URL below `server/routes/`, then add a directory for the lowercase HTTP method. Use brackets for dynamic path segments. For example, `GET /api/repositories/:owner/:repository/access` lives in `routes/api/repositories/[owner]/[repository]/access/get/index.ts`. + +Export handlers as `get()`, `post()`, `put()`, or `delete`. Import them with descriptive `handle*` aliases in `routes/index.ts`. That file exports `routes()` and explicitly registers handlers and middleware. + +Keep Express middleware, identity cookies, and request readers in `integrations/express/`. Put Hub access and CSRF policy in domains. Keep only general helpers, such as secret hashing and comparison, in utilities. GitHub credential validation and encryption belong in `integrations/github/`. -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. +Keep domain contracts and errors under `domains/identity/`, GitHub types and errors with that integration, and database inputs with their repositories. Routes map error codes to HTTP statuses and pass that mapping and the Hub origin check to Express helpers. Lower layers do not import domains. -Domain and GitHub errors contain a code and a safe message, not an HTTP status. Keep Hub access and CSRF policy in domains. GitHub credential validation and encryption belong in `integrations/github/`. +Validate request bodies with schemas in each route's `constants.ts`. Rotation and revocation require `confirmed: true`; strings and other values do not count as confirmation. Domain revocation also checks confirmation for callers outside HTTP. ## Database access @@ -42,4 +55,4 @@ The [shared Prisma client](database.md#shared-client) lives in `server/integrati 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. +`staticSettingsRecordId` identifies Hub's single settings row, not a person or GitHub installation. Its fixed UUID lets every process lock the same row during setup, rotation, and revocation. Keep it with the identity-state repository. diff --git a/hub/docs/development.md b/hub/docs/development.md index b91ecf9..1a47a82 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. Configure [GitHub identity](identity.md) before starting the API. 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. Apply migrations with `npm run db:deploy`, then set `HUB_CREDENTIAL_KEY` and `HUB_BOOTSTRAP_SECRET` as described in [Identity](identity.md#configure-credentials). Do not commit credentials. The health and frontend routes do not query the database, but API startup checks the selected credential provider and database. ## Start the app @@ -36,7 +36,7 @@ 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. Use the same proxy for the [identity API](identity.md#api-operations). Both servers accept connections only from this machine. Stop each with Ctrl+C. The API reads an optional `PORT` environment variable, defaulting to 3001. If you change it, update the local Vite proxy target to match. The editor forwards ports 3001, 5173, and 6006 from the devcontainer. API startup reads `.env` when present; Compose's environment takes precedence. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index aef7d09..6881e84 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,37 +1,55 @@ # 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. +People sign in with GitHub. Hub checks repo access through one GitHub App installation. The API works without the frontend or canvas. -The identity service implements setup, sign-in, session verification, repository access, credential rotation, and revocation without depending on HTTP. +## Configure credentials -## Credential providers +1. Apply migrations with `npm run db:deploy`. Set `DATABASE_URL` and `HUB_ORIGIN` from the [environment example](../.env.example). Use HTTPS, except for local HTTP. +2. Create a private GitHub App. Set its callback to `${HUB_ORIGIN}/api/auth/github/callback`. Enable user authorization and repository Administration read permission. Install it on the repos Hub will use. +3. Copy the numeric installation ID from the end of its settings URL. Hub uses only this installation. +4. Choose a provider below. Set a separate `HUB_BOOTSTRAP_SECRET` of at least 32 random characters for initial setup or operator enrollment. Never commit secrets. -Set `HUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): - -| Provider | Credentials | +| `HUB_CREDENTIAL_PROVIDER` | Setup | | --- | --- | -| `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. +| `database` (default) | Generate `HUB_CREDENTIAL_KEY` with `openssl rand -hex 32`. Save the App details through the setup API. Hub encrypts them. | +| `environment` | Set every `GITHUB_*` value in the [example](../.env.example), including `GITHUB_INSTALLATION_ID`. To change them, edit the environment and restart Hub. | -## Saved records +Back up the encryption key apart from the database. Losing or replacing it makes saved credentials unreadable. Hub refuses to start if its chosen provider or database fails. An empty database provider needs the bootstrap secret; sign-in stays blocked until setup. -Apply the committed migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. +Keep the App private. For an organization-owned private App, all organization members can pass the read check for a connected public repo. -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. +## API operations -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. +Send JSON bodies and an `Origin` header matching `HUB_ORIGIN` for changes. Signed-in changes also need the `X-CSRF-Token` from `GET /api/session`. -## Sign-in and access - -- Sign-in attempts expire in 15 minutes and work once in the same browser. Hub discards GitHub user tokens after identification. -- Ordinary sign-in needs read access to a repo in the configured installation. Enrolled administrators may sign in without repo access, but cannot bypass repository checks. -- Repository checks use current GitHub permissions and only the configured installation. GitHub failures deny access. -- Initial setup and administrator enrollment require a deployment bootstrap secret. Setup works only once; the environment provider cannot be changed through the service. -- Rotation and revocation require an administrator, CSRF proof, and a GitHub check in that same live session less than five minutes old. Revocation also requires explicit confirmation. -- Rotation keeps the same App, client, and installation IDs. It preserves sessions but cancels pending sign-ins and fresh verification. -- Revocation ends every session and cancels pending sign-ins. Callbacks already in progress cannot restore access. -- Completed identity changes and their audit records commit together. Rejected operations create no audit records. Only administrators can read them. +| Request | Action | +| --- | --- | +| `POST /api/setup/github` | Send `{appId, installationId, clientId, clientSecret, privateKey}` and `X-Hub-Bootstrap-Secret`. IDs are positive decimal strings. Setup works only once. | +| `POST /api/auth/github` | Open the returned `url` in the same browser. | +| `POST /api/operator/enrollment` | Start sign-in to become a Hub administrator. Requires `X-Hub-Bootstrap-Secret`. | +| `GET /api/auth/github/callback` | Finish sign-in and redirect to `/api/session`. | +| `GET /api/session` | Read the user, session ID, sign-in time, and CSRF token. | +| `DELETE /api/session` | Sign out this browser. | +| `POST /api/auth/github/verify` | Verify this session's GitHub account again. | +| `GET /api/repositories/:owner/:repository/access` | Check current access: `read`, `write`, or `admin`. | +| `PUT /api/administration/github-credentials` | Rotate credentials with `{confirmed: true, credentials: {...}}`. Keep the same App, client, and installation IDs. | +| `POST /api/administration/revoke-sessions` | End all sessions with `{confirmed: true}`. | +| `GET /api/administration/security-audit` | Read the latest 100 security records. | + +The last three routes require an administrator. Rotation and revocation also need a GitHub check in that session, less than five minutes old. GitHub may reuse its login without asking for a password or a second sign-in step. + +## Access and recovery + +- Hub uses permanent GitHub user IDs, not names or emails. Ordinary sign-in needs read access to a repo in the chosen installation. Administrators may sign in without repo access, but cannot bypass repo checks. +- Repo checks use current GitHub permissions. Repos outside the chosen installation stay blocked. GitHub failures deny access. +- Sign-in links expire in 15 minutes and work once in the same browser. Cookies are host-only and hidden from JavaScript. HTTPS cookies require a secure connection. Identity responses are not cached. +- Hub discards user tokens after checking identity. It does not store installation tokens. +- Sessions last until sign-out or revocation; closing the browser may not end them. Hub does not detect account suspension on its own. Revoke sessions to cut off access right away. +- Rotation keeps sessions but cancels pending sign-ins and fresh verification. Keep old GitHub keys until requests already sent with them finish. +- Revocation ends every session, including yours, and cancels pending sign-ins. Late callbacks cannot restore them. + +## Security records + +Hub saves each completed identity change with its audit record, or saves neither. Rejected requests create no audit records. Only administrators can read them. + +Records keep user and session IDs, not secrets or request bodies. Reads cover 90 days; later writes remove older records. There is no scheduled cleanup or tamper protection. Repo activity is not audited here. diff --git a/hub/server/create-app/index.test.ts b/hub/server/create-app/index.test.ts index 28339a1..55ac2c4 100644 --- a/hub/server/create-app/index.test.ts +++ b/hub/server/create-app/index.test.ts @@ -3,7 +3,7 @@ import { createServer } from "node:http" import type { AddressInfo } from "node:net" import { describe, expect, it } from "vitest" -import { createApp } from "." +import { createApp } from "#server/create-app" describe("createApp()", () => { it("returns the health status as JSON", async () => { diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 06d3bc0..a3bca36 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -1,16 +1,15 @@ import express from "express" -export function createApp() { +import { respondNotFound } from "#server/integrations/express/respond-not-found" +import { routes } from "#server/routes" +import type { IdentityService, IdentitySettings } from "#server/domains/identity/types" + +export function createApp(identity?: { service: IdentityService; settings: IdentitySettings }) { const app = express() app.disable("x-powered-by") - app.get("/api/health", (_request, response) => { - response.json({ status: "ok" }) - }) - - app.use((_request, response) => { - response.status(404).json({ error: "Not found" }) - }) + routes(app, identity) + app.use(respondNotFound) return app } diff --git a/hub/server/domains/identity/create-runtime/index.ts b/hub/server/domains/identity/create-runtime/index.ts new file mode 100644 index 0000000..ceefe90 --- /dev/null +++ b/hub/server/domains/identity/create-runtime/index.ts @@ -0,0 +1,43 @@ +import { createCredentialProvider } from "#server/domains/identity/create-credential-provider" +import { createIdentityService } from "#server/domains/identity/create-service" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { createGitHubIdentityClient } from "#server/integrations/github/create-identity-client" +import { prisma } from "#server/integrations/prisma" + +export async function createIdentityRuntime(environment: NodeJS.ProcessEnv) { + const origin = new URL(environment.HUB_ORIGIN ?? "http://127.0.0.1:5173") + const secureCookies = origin.protocol === "https:" + if ( + !["https:", "http:"].includes(origin.protocol) || + origin.origin !== origin.href.replace(/\/$/, "") || + origin.username || + origin.password || + (!secureCookies && + (environment.NODE_ENV === "production" || + !["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname))) + ) { + throw new Error( + "Set HUB_ORIGIN to an HTTPS origin, or a loopback HTTP origin outside production.", + ) + } + const bootstrapSecret = environment.HUB_BOOTSTRAP_SECRET + if (bootstrapSecret && bootstrapSecret.length < 32) { + throw new Error("HUB_BOOTSTRAP_SECRET must contain at least 32 characters.") + } + const provider = createCredentialProvider(environment) + const store = createIdentityStore(prisma) + await store.lock(async (records) => { + const credentials = provider.read(await records.state()) + if (!credentials && !bootstrapSecret) { + throw new Error("Set HUB_BOOTSTRAP_SECRET before starting an unconfigured Hub.") + } + }) + const settings = { origin: origin.origin, bootstrapSecret, secureCookies } + const service = createIdentityService({ + store, + provider, + github: createGitHubIdentityClient(settings.origin), + settings, + }) + return { service, settings } +} diff --git a/hub/server/domains/identity/identity-error/types.ts b/hub/server/domains/identity/identity-error/types.ts index e392934..e1cd0c9 100644 --- a/hub/server/domains/identity/identity-error/types.ts +++ b/hub/server/domains/identity/identity-error/types.ts @@ -9,8 +9,11 @@ export type IdentityErrorCode = | "different_app" | "fresh_authorization_required" | "identity_mismatch" + | "invalid_callback" | "invalid_oauth_state" + | "invalid_repository" | "origin_denied" + | "repository_access_denied" | "repository_access_required" | "setup_required" | "sign_in_cancelled" diff --git a/hub/server/index.ts b/hub/server/index.ts index e6de88b..3c5f256 100644 --- a/hub/server/index.ts +++ b/hub/server/index.ts @@ -1,12 +1,15 @@ import "dotenv/config" -import { createApp } from "./create-app/index.ts" +import { createApp } from "#server/create-app" +import { createIdentityRuntime } from "#server/domains/identity/create-runtime" const port = Number(process.env.PORT ?? 3001) if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("PORT must be an integer between 1 and 65535.") } -createApp().listen(port, "127.0.0.1", () => { +const identity = await createIdentityRuntime(process.env) + +createApp(identity).listen(port, "127.0.0.1", () => { console.info(`Hub API listening at http://127.0.0.1:${port}`) }) diff --git a/hub/server/integrations/express/get-identity-cookies/index.ts b/hub/server/integrations/express/get-identity-cookies/index.ts new file mode 100644 index 0000000..f07d41e --- /dev/null +++ b/hub/server/integrations/express/get-identity-cookies/index.ts @@ -0,0 +1,13 @@ +export function getIdentityCookies(settings: { secureCookies: boolean }) { + const prefix = settings.secureCookies ? "__Host-" : "" + return { + sessionName: `${prefix}replica-session`, + oauthName: `${prefix}replica-oauth`, + options: { + httpOnly: true, + secure: settings.secureCookies, + sameSite: "lax", + path: "/", + } as const, + } +} diff --git a/hub/server/integrations/express/prepare-private-request/index.ts b/hub/server/integrations/express/prepare-private-request/index.ts new file mode 100644 index 0000000..ee032d2 --- /dev/null +++ b/hub/server/integrations/express/prepare-private-request/index.ts @@ -0,0 +1,15 @@ +import type { NextFunction, Request, Response } from "express" + +export function preparePrivateRequest( + checkOrigin: (origin: string | undefined) => void, + request: Request, + response: Response, + next: NextFunction, +): void { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + if (!["GET", "HEAD", "OPTIONS"].includes(request.method)) { + checkOrigin(request.get("origin")) + } + next() +} diff --git a/hub/server/integrations/express/read-identity-cookie/index.ts b/hub/server/integrations/express/read-identity-cookie/index.ts new file mode 100644 index 0000000..36bdc3e --- /dev/null +++ b/hub/server/integrations/express/read-identity-cookie/index.ts @@ -0,0 +1,8 @@ +export function readIdentityCookie(header: string | undefined, name: string): string { + const values = (header ?? "") + .split(";") + .map((part) => part.trim()) + .filter((part) => part.startsWith(`${name}=`)) + const value = values.length === 1 ? (values[0]?.slice(name.length + 1) ?? "") : "" + return /^[\w-]{43}$/.test(value) ? value : "" +} diff --git a/hub/server/integrations/express/read-session-token/index.ts b/hub/server/integrations/express/read-session-token/index.ts new file mode 100644 index 0000000..b7c22df --- /dev/null +++ b/hub/server/integrations/express/read-session-token/index.ts @@ -0,0 +1,8 @@ +import type { Request } from "express" + +import { getIdentityCookies } from "#server/integrations/express/get-identity-cookies" +import { readIdentityCookie } from "#server/integrations/express/read-identity-cookie" + +export function readSessionToken(request: Request, settings: { secureCookies: boolean }): string { + return readIdentityCookie(request.headers.cookie, getIdentityCookies(settings).sessionName) +} diff --git a/hub/server/integrations/express/respond-identity-error/index.ts b/hub/server/integrations/express/respond-identity-error/index.ts new file mode 100644 index 0000000..a1c7c6e --- /dev/null +++ b/hub/server/integrations/express/respond-identity-error/index.ts @@ -0,0 +1,35 @@ +import type { NextFunction, Request, Response } from "express" + +import type { ErrorResponse } from "#server/integrations/express/respond-identity-error/types" + +export function respondIdentityError( + describeError: (error: unknown) => ErrorResponse | undefined, + error: unknown, + _request: Request, + response: Response, + next: NextFunction, +): void { + if (response.headersSent) { + next(error) + return + } + const described = describeError(error) + if (described) { + response.status(described.status).json({ code: described.code, error: described.message }) + return + } + if ( + error instanceof SyntaxError || + (error instanceof Error && "type" in error && error.type === "entity.too.large") + ) { + response + .status(400) + .json({ code: "invalid_body", error: "Supply a JSON request no larger than 24 KiB." }) + return + } + console.error("Hub identity request failed. Check database and provider availability.") + response.status(503).json({ + code: "identity_unavailable", + error: "Hub could not complete this identity request. Contact the deployment operator.", + }) +} diff --git a/hub/server/integrations/express/respond-identity-error/types.ts b/hub/server/integrations/express/respond-identity-error/types.ts new file mode 100644 index 0000000..a20d780 --- /dev/null +++ b/hub/server/integrations/express/respond-identity-error/types.ts @@ -0,0 +1,5 @@ +export interface ErrorResponse { + status: number + code: string + message: string +} diff --git a/hub/server/integrations/express/respond-not-found/index.ts b/hub/server/integrations/express/respond-not-found/index.ts new file mode 100644 index 0000000..57a76e2 --- /dev/null +++ b/hub/server/integrations/express/respond-not-found/index.ts @@ -0,0 +1,5 @@ +import type { Request, Response } from "express" + +export function respondNotFound(_request: Request, response: Response): void { + response.status(404).json({ error: "Not found" }) +} diff --git a/hub/server/routes/api/administration/github-credentials/put/constants.ts b/hub/server/routes/api/administration/github-credentials/put/constants.ts new file mode 100644 index 0000000..d1c78dd --- /dev/null +++ b/hub/server/routes/api/administration/github-credentials/put/constants.ts @@ -0,0 +1,6 @@ +import { z } from "zod" + +export const rotationSchema = z.object({ + confirmed: z.literal(true), + credentials: z.unknown().optional(), +}) diff --git a/hub/server/routes/api/administration/github-credentials/put/index.ts b/hub/server/routes/api/administration/github-credentials/put/index.ts new file mode 100644 index 0000000..54d322b --- /dev/null +++ b/hub/server/routes/api/administration/github-credentials/put/index.ts @@ -0,0 +1,28 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { IdentityError } from "#server/domains/identity/identity-error" +import { rotationSchema } from "#server/routes/api/administration/github-credentials/put/constants" +import { readSessionToken } from "#server/integrations/express/read-session-token" + +export async function put( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + + const parsed = rotationSchema.safeParse(request.body) + if (!parsed.success) { + throw new IdentityError( + "confirmation_required", + "Confirm replacement of the GitHub App credentials.", + ) + } + await service.rotate( + readSessionToken(request, settings), + request.get("x-csrf-token") ?? "", + parsed.data.credentials, + ) + response.status(204).end() +} diff --git a/hub/server/routes/api/administration/revoke-sessions/post/constants.ts b/hub/server/routes/api/administration/revoke-sessions/post/constants.ts new file mode 100644 index 0000000..9662e97 --- /dev/null +++ b/hub/server/routes/api/administration/revoke-sessions/post/constants.ts @@ -0,0 +1,3 @@ +import { z } from "zod" + +export const revocationSchema = z.object({ confirmed: z.literal(true) }) diff --git a/hub/server/routes/api/administration/revoke-sessions/post/index.ts b/hub/server/routes/api/administration/revoke-sessions/post/index.ts new file mode 100644 index 0000000..16cbf73 --- /dev/null +++ b/hub/server/routes/api/administration/revoke-sessions/post/index.ts @@ -0,0 +1,31 @@ +import type { Request, Response } from "express" + +import { IdentityError } from "#server/domains/identity/identity-error" +import type { IdentityRoutes } from "#server/routes/types" +import { getIdentityCookies } from "#server/integrations/express/get-identity-cookies" +import { revocationSchema } from "#server/routes/api/administration/revoke-sessions/post/constants" +import { readSessionToken } from "#server/integrations/express/read-session-token" + +export async function post( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + const { sessionName: sessionCookie, options: cookieOptions } = getIdentityCookies(settings) + + const parsed = revocationSchema.safeParse(request.body) + if (!parsed.success) { + throw new IdentityError( + "confirmation_required", + "Confirm that all Hub sessions, including this session, will end.", + ) + } + await service.revoke( + readSessionToken(request, settings), + request.get("x-csrf-token") ?? "", + parsed.data.confirmed, + ) + response.clearCookie(sessionCookie, cookieOptions) + response.status(204).end() +} diff --git a/hub/server/routes/api/administration/security-audit/get/index.ts b/hub/server/routes/api/administration/security-audit/get/index.ts new file mode 100644 index 0000000..d6f14b8 --- /dev/null +++ b/hub/server/routes/api/administration/security-audit/get/index.ts @@ -0,0 +1,14 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { readSessionToken } from "#server/integrations/express/read-session-token" + +export async function get( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + + response.json({ records: await service.audits(readSessionToken(request, settings)) }) +} 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 0000000..61ef50c --- /dev/null +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -0,0 +1,37 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { getIdentityCookies } from "#server/integrations/express/get-identity-cookies" +import { IdentityError } from "#server/domains/identity/identity-error" +import { readIdentityCookie } from "#server/integrations/express/read-identity-cookie" + +export async function get( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + const { + sessionName: sessionCookie, + oauthName: oauthCookie, + options: cookieOptions, + } = getIdentityCookies(settings) + + const { code, state } = request.query + response.clearCookie(oauthCookie, cookieOptions) + if ( + typeof code !== "string" || + typeof state !== "string" || + code.length > 1000 || + !/^[\w-]{43}$/.test(state) + ) { + throw new IdentityError("invalid_callback", "GitHub sign-in was not completed. Start again.") + } + const result = await service.complete( + code, + state, + readIdentityCookie(request.headers.cookie, oauthCookie), + ) + if (result.token) response.cookie(sessionCookie, result.token, cookieOptions) + response.redirect(303, `${settings.origin}/api/session`) +} diff --git a/hub/server/routes/api/auth/github/post/index.ts b/hub/server/routes/api/auth/github/post/index.ts new file mode 100644 index 0000000..15d0c3a --- /dev/null +++ b/hub/server/routes/api/auth/github/post/index.ts @@ -0,0 +1,17 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { getIdentityCookies } from "#server/integrations/express/get-identity-cookies" + +export async function post( + dependencies: IdentityRoutes, + _request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + const { oauthName: oauthCookie, options: cookieOptions } = getIdentityCookies(settings) + + const attempt = await service.start({ purpose: "sign-in" }) + response.cookie(oauthCookie, attempt.browserToken, { ...cookieOptions, maxAge: 15 * 60_000 }) + response.json({ url: attempt.url }) +} diff --git a/hub/server/routes/api/auth/github/verify/post/index.ts b/hub/server/routes/api/auth/github/verify/post/index.ts new file mode 100644 index 0000000..efe6c92 --- /dev/null +++ b/hub/server/routes/api/auth/github/verify/post/index.ts @@ -0,0 +1,22 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { getIdentityCookies } from "#server/integrations/express/get-identity-cookies" +import { readSessionToken } from "#server/integrations/express/read-session-token" + +export async function post( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + const { oauthName: oauthCookie, options: cookieOptions } = getIdentityCookies(settings) + + const attempt = await service.start({ + purpose: "verify", + token: readSessionToken(request, settings), + csrfToken: request.get("x-csrf-token") ?? "", + }) + response.cookie(oauthCookie, attempt.browserToken, { ...cookieOptions, maxAge: 15 * 60_000 }) + response.json({ url: attempt.url }) +} diff --git a/hub/server/routes/api/health/get/index.ts b/hub/server/routes/api/health/get/index.ts new file mode 100644 index 0000000..a7fd1db --- /dev/null +++ b/hub/server/routes/api/health/get/index.ts @@ -0,0 +1,5 @@ +import type { Request, Response } from "express" + +export function get(_request: Request, response: Response): void { + response.json({ status: "ok" }) +} diff --git a/hub/server/routes/api/operator/enrollment/post/index.ts b/hub/server/routes/api/operator/enrollment/post/index.ts new file mode 100644 index 0000000..507bd35 --- /dev/null +++ b/hub/server/routes/api/operator/enrollment/post/index.ts @@ -0,0 +1,20 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { getIdentityCookies } from "#server/integrations/express/get-identity-cookies" + +export async function post( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + const { oauthName: oauthCookie, options: cookieOptions } = getIdentityCookies(settings) + + const attempt = await service.start({ + purpose: "enroll", + bootstrapSecret: request.get("x-hub-bootstrap-secret"), + }) + response.cookie(oauthCookie, attempt.browserToken, { ...cookieOptions, maxAge: 15 * 60_000 }) + response.json({ url: attempt.url }) +} diff --git a/hub/server/routes/api/repositories/[owner]/[repository]/access/get/index.ts b/hub/server/routes/api/repositories/[owner]/[repository]/access/get/index.ts new file mode 100644 index 0000000..343a713 --- /dev/null +++ b/hub/server/routes/api/repositories/[owner]/[repository]/access/get/index.ts @@ -0,0 +1,37 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { IdentityError } from "#server/domains/identity/identity-error" +import { readSessionToken } from "#server/integrations/express/read-session-token" + +export async function get( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + + const { owner, repository } = request.params + if ( + typeof owner !== "string" || + typeof repository !== "string" || + !/^[\w.-]{1,100}$/.test(owner) || + !/^[\w.-]{1,100}$/.test(repository) || + [".", ".."].includes(owner) || + [".", ".."].includes(repository) + ) { + throw new IdentityError("invalid_repository", "Supply a GitHub repository owner and name.") + } + const permission = await service.permission( + readSessionToken(request, settings), + owner, + repository, + ) + if (permission === "none") { + throw new IdentityError( + "repository_access_denied", + "GitHub did not grant access to this installed repository.", + ) + } + response.json({ permission }) +} diff --git a/hub/server/routes/api/session/delete/index.ts b/hub/server/routes/api/session/delete/index.ts new file mode 100644 index 0000000..f512b7a --- /dev/null +++ b/hub/server/routes/api/session/delete/index.ts @@ -0,0 +1,20 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { getIdentityCookies } from "#server/integrations/express/get-identity-cookies" +import { readSessionToken } from "#server/integrations/express/read-session-token" + +async function deleteSession( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + const { sessionName: sessionCookie, options: cookieOptions } = getIdentityCookies(settings) + + await service.signOut(readSessionToken(request, settings), request.get("x-csrf-token") ?? "") + response.clearCookie(sessionCookie, cookieOptions) + response.status(204).end() +} + +export { deleteSession as delete } diff --git a/hub/server/routes/api/session/get/index.ts b/hub/server/routes/api/session/get/index.ts new file mode 100644 index 0000000..b585143 --- /dev/null +++ b/hub/server/routes/api/session/get/index.ts @@ -0,0 +1,19 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" +import { readSessionToken } from "#server/integrations/express/read-session-token" + +export async function get( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service, settings } = dependencies + + const current = await service.current(readSessionToken(request, settings)) + response.json({ + user: current.user, + session: { id: current.session.id, signedInAt: current.session.signedInAt }, + csrfToken: current.csrfToken, + }) +} diff --git a/hub/server/routes/api/setup/github/post/index.ts b/hub/server/routes/api/setup/github/post/index.ts new file mode 100644 index 0000000..cc3db5c --- /dev/null +++ b/hub/server/routes/api/setup/github/post/index.ts @@ -0,0 +1,14 @@ +import type { Request, Response } from "express" + +import type { IdentityRoutes } from "#server/routes/types" + +export async function post( + dependencies: IdentityRoutes, + request: Request, + response: Response, +): Promise { + const { service } = dependencies + + await service.setup(request.get("x-hub-bootstrap-secret") ?? "", request.body) + response.status(201).json({ configured: true }) +} diff --git a/hub/server/routes/identity-error-response/constants.ts b/hub/server/routes/identity-error-response/constants.ts new file mode 100644 index 0000000..e1968b7 --- /dev/null +++ b/hub/server/routes/identity-error-response/constants.ts @@ -0,0 +1,28 @@ +import type { IdentityErrorCode } from "#server/domains/identity/identity-error/types" +import type { GitHubErrorCode } from "#server/integrations/github/github-error/types" + +export const errorStatuses: Record = { + administrator_required: 403, + already_configured: 409, + bootstrap_denied: 403, + confirmation_required: 400, + credentials_changed: 503, + credentials_read_only: 409, + csrf_failed: 403, + different_app: 400, + fresh_authorization_required: 403, + github_limit: 503, + github_sign_in_failed: 401, + github_unavailable: 503, + identity_mismatch: 403, + invalid_callback: 400, + invalid_credentials: 400, + invalid_oauth_state: 401, + invalid_repository: 400, + origin_denied: 403, + repository_access_denied: 403, + repository_access_required: 403, + setup_required: 503, + sign_in_cancelled: 401, + sign_in_required: 401, +} diff --git a/hub/server/routes/identity-error-response/index.ts b/hub/server/routes/identity-error-response/index.ts new file mode 100644 index 0000000..f110526 --- /dev/null +++ b/hub/server/routes/identity-error-response/index.ts @@ -0,0 +1,9 @@ +import { IdentityError } from "#server/domains/identity/identity-error" +import { GitHubError } from "#server/integrations/github/github-error" +import { errorStatuses } from "#server/routes/identity-error-response/constants" + +export function identityErrorResponse(error: unknown) { + if (error instanceof IdentityError || error instanceof GitHubError) { + return { status: errorStatuses[error.code], code: error.code, message: error.message } + } +} diff --git a/hub/server/routes/index.test.ts b/hub/server/routes/index.test.ts new file mode 100644 index 0000000..a4b645f --- /dev/null +++ b/hub/server/routes/index.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it, vi } from "vitest" + +import { createServerFixture } from "#server/routes/test/create-server-fixture" + +describe("routes()", () => { + it("keeps browser and session secrets in secure cookies and omits internal session fields from JSON", async () => { + const fixture = await createServerFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const start = await fixture.request("/api/auth/github", { + method: "POST", + headers: { origin: fixture.settings.origin }, + }) + expect(start.status).toBe(200) + expect(await start.json()).toEqual({ url: "https://github.com/login/oauth/authorize" }) + const browserCookie = start.headers.getSetCookie()[0]! + expect(browserCookie).toMatch(/^__Host-replica-oauth=[\w-]{43};/) + for (const attribute of ["HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=900"]) { + expect(browserCookie).toContain(attribute) + } + expect(browserCookie).not.toMatch(/domain=/i) + const state = fixture.github.authorize.mock.calls[0]![1] + const callback = await fixture.request( + `/api/auth/github/callback?code=synthetic-code&state=${state}`, + { headers: { cookie: browserCookie.split(";")[0]! } }, + ) + expect(callback.status).toBe(303) + expect(callback.headers.get("location")).toBe(`${fixture.settings.origin}/api/session`) + const cookies = callback.headers.getSetCookie() + expect(cookies.find((cookie) => cookie.startsWith("__Host-replica-oauth="))).toContain( + "Expires=Thu, 01 Jan 1970", + ) + const sessionCookie = cookies.find((cookie) => cookie.startsWith("__Host-replica-session="))! + expect(sessionCookie).toMatch(/^__Host-replica-session=[\w-]{43};/) + for (const attribute of ["HttpOnly", "Secure", "SameSite=Lax", "Path=/"]) { + expect(sessionCookie).toContain(attribute) + } + + const current = await fixture.request("/api/session", { + headers: { cookie: sessionCookie.split(";")[0]! }, + }) + expect(current.status).toBe(200) + expect(current.headers.get("cache-control")).toContain("no-store") + expect(current.headers.get("referrer-policy")).toBe("no-referrer") + const body = (await current.json()) as { + session: { id: string; signedInAt: string } + csrfToken: string + } + expect(body).toMatchObject({ + user: { providerId: fixture.profile.providerId }, + }) + expect(Object.keys(body.session).sort()).toEqual(["id", "signedInAt"]) + expect(typeof body.session.id).toBe("string") + expect(typeof body.session.signedInAt).toBe("string") + expect(typeof body.csrfToken).toBe("string") + const serialized = JSON.stringify(body) + expect(serialized).not.toContain("tokenDigest") + expect(serialized).not.toContain("credentialRevision") + expect(serialized).not.toContain(fixture.credentials.clientSecret) + expect(serialized).not.toContain(sessionCookie.split(";")[0]!.split("=")[1]!) + }) + + it("rejects missing or foreign origins before a bootstrap mutation reaches the service", async () => { + const fixture = await createServerFixture() + const lock = vi.spyOn(fixture.store, "lock") + const audit = vi.spyOn(fixture.store.read, "audit") + const state = vi.spyOn(fixture.store.read, "state") + for (const origin of [undefined, "https://other.example.test"]) { + const headers: Record = { + "content-type": "application/json", + "x-hub-bootstrap-secret": fixture.settings.bootstrapSecret, + } + if (origin) headers.origin = origin + const result = await fixture.request("/api/setup/github", { + method: "POST", + headers, + body: JSON.stringify(fixture.credentials), + }) + expect(result.status).toBe(403) + } + expect(lock).not.toHaveBeenCalled() + expect(audit).not.toHaveBeenCalled() + expect(state).not.toHaveBeenCalled() + expect(fixture.snapshot().state.encryptedCredentials).toBeNull() + const allowed = await fixture.request("/api/setup/github", { + method: "POST", + headers: { + origin: fixture.settings.origin, + "content-type": "application/json", + "x-hub-bootstrap-secret": fixture.settings.bootstrapSecret, + }, + body: JSON.stringify(fixture.credentials), + }) + expect(allowed.status).toBe(201) + expect(await allowed.json()).toEqual({ configured: true }) + expect(fixture.snapshot().audits.map(({ event }) => event)).toEqual(["credentials.setup"]) + }) + + it("does not accept a callback without its same-browser cookie", async () => { + const fixture = await createServerFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const attempt = await fixture.begin() + + const result = await fixture.request( + `/api/auth/github/callback?code=synthetic-code&state=${attempt.state}`, + ) + expect(result.status).toBe(401) + expect(fixture.snapshot().sessions).toEqual([]) + expect(fixture.github.identify).not.toHaveBeenCalled() + }) + + it("returns the public invalid-credentials error when setup rejects a GitHub private key", async () => { + const fixture = await createServerFixture() + const before = fixture.snapshot() + const result = await fixture.request("/api/setup/github", { + method: "POST", + headers: { + origin: fixture.settings.origin, + "content-type": "application/json", + "x-hub-bootstrap-secret": fixture.settings.bootstrapSecret, + }, + body: JSON.stringify({ ...fixture.credentials, privateKey: "invalid-synthetic-private-key" }), + }) + + expect(result.status).toBe(400) + const body: unknown = await result.json() + expect(body).toMatchObject({ code: "invalid_credentials" }) + expect(JSON.stringify(body)).not.toContain(fixture.credentials.clientSecret) + expect(JSON.stringify(body)).not.toContain("invalid-synthetic-private-key") + expect(fixture.snapshot()).toEqual(before) + }) + + it("clears the browser session cookie after sign-out", async () => { + const fixture = await createServerFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const signedIn = await fixture.signIn() + const headers = { + origin: fixture.settings.origin, + cookie: `__Host-replica-session=${signedIn.token}`, + } + const accepted = await fixture.request("/api/session", { + method: "DELETE", + headers: { ...headers, "x-csrf-token": signedIn.csrfToken }, + }) + + expect(accepted.status).toBe(204) + expect(accepted.headers.get("set-cookie")).toContain("__Host-replica-session=;") + await expect(fixture.service.current(signedIn.token)).rejects.toThrow() + }) + + it("requires explicit boolean confirmation before passing replacement credentials to rotation", async () => { + const fixture = await createServerFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const administrator = await fixture.signIn("enroll") + const credentials = { ...fixture.credentials, clientSecret: "replacement-synthetic-secret" } + const headers = { + origin: fixture.settings.origin, + cookie: `__Host-replica-session=${administrator.token}`, + "x-csrf-token": administrator.csrfToken, + "content-type": "application/json", + } + const rejected = await fixture.request("/api/administration/github-credentials", { + method: "PUT", + headers, + body: JSON.stringify({ credentials, confirmed: "true" }), + }) + expect(rejected.status).toBe(400) + expect(fixture.provider.read(fixture.snapshot().state)).toEqual(fixture.credentials) + const accepted = await fixture.request("/api/administration/github-credentials", { + method: "PUT", + headers, + body: JSON.stringify({ credentials, confirmed: true }), + }) + + expect(accepted.status).toBe(204) + expect(fixture.provider.read(fixture.snapshot().state)).toEqual(credentials) + expect(await accepted.text()).toBe("") + }) + + it("requires boolean confirmation to revoke every session and clear the administrator cookie", async () => { + const fixture = await createServerFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const administrator = await fixture.signIn("enroll") + const otherSession = await fixture.signIn() + const before = fixture.snapshot() + const headers = { + origin: fixture.settings.origin, + cookie: `__Host-replica-session=${administrator.token}`, + "x-csrf-token": administrator.csrfToken, + "content-type": "application/json", + } + const rejected = await fixture.request("/api/administration/revoke-sessions", { + method: "POST", + headers, + body: JSON.stringify({ confirmed: "true" }), + }) + + expect(rejected.status).toBe(400) + expect(await rejected.json()).toMatchObject({ code: "confirmation_required" }) + expect(fixture.snapshot()).toEqual(before) + + const accepted = await fixture.request("/api/administration/revoke-sessions", { + method: "POST", + headers, + body: JSON.stringify({ confirmed: true }), + }) + + expect(accepted.status).toBe(204) + expect(accepted.headers.get("set-cookie")).toContain("__Host-replica-session=;") + expect(accepted.headers.get("set-cookie")).toContain("Expires=Thu, 01 Jan 1970") + for (const session of [administrator, otherSession]) { + const current = await fixture.request("/api/session", { + headers: { cookie: `__Host-replica-session=${session.token}` }, + }) + expect(current.status).toBe(401) + } + }) + + it("returns a generic failure without leaking upstream credentials in API responses or logs", async () => { + const fixture = await createServerFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const signedIn = await fixture.signIn() + const before = fixture.snapshot() + const lock = vi.spyOn(fixture.store, "lock") + const audit = vi.spyOn(fixture.store.read, "audit") + const log = vi.spyOn(console, "error").mockImplementation(() => {}) + fixture.github.permission.mockRejectedValue( + new Error(`GitHub rejected ${fixture.credentials.clientSecret}`), + ) + + const result = await fixture.request("/api/repositories/synthetic/repository/access", { + headers: { cookie: `__Host-replica-session=${signedIn.token}` }, + }) + expect(result.status).toBe(503) + expect(await result.text()).not.toContain(fixture.credentials.clientSecret) + expect(JSON.stringify(log.mock.calls)).not.toContain(fixture.credentials.clientSecret) + expect(fixture.snapshot()).toEqual(before) + expect(lock).not.toHaveBeenCalled() + expect(audit).not.toHaveBeenCalled() + }) + + it("rejects malformed JSON without touching identity storage even when auditing is unavailable", async () => { + const fixture = await createServerFixture() + const before = fixture.snapshot() + fixture.failAudit() + const lock = vi.spyOn(fixture.store, "lock") + const audit = vi.spyOn(fixture.store.read, "audit") + const state = vi.spyOn(fixture.store.read, "state") + const result = await fixture.request("/api/setup/github", { + method: "POST", + headers: { + origin: fixture.settings.origin, + "x-hub-bootstrap-secret": fixture.settings.bootstrapSecret, + "content-type": "application/json", + }, + body: '{"clientSecret":"synthetic-secret",', + }) + + expect(result.status).toBe(400) + expect(await result.text()).not.toContain("synthetic-secret") + expect(lock).not.toHaveBeenCalled() + expect(audit).not.toHaveBeenCalled() + expect(state).not.toHaveBeenCalled() + expect(fixture.snapshot()).toEqual(before) + }) + + it("preserves CSRF and unauthenticated denials without audit writes when auditing is unavailable", async () => { + const fixture = await createServerFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const signedIn = await fixture.signIn() + const before = fixture.snapshot() + fixture.failAudit() + const audit = vi.spyOn(fixture.store.read, "audit") + const log = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await fixture.request("/api/session", { + method: "DELETE", + headers: { + origin: fixture.settings.origin, + cookie: `__Host-replica-session=${signedIn.token}`, + }, + }) + + expect(result.status).toBe(403) + const body = (await result.json()) as { code: string } + expect(body.code).toBe("csrf_failed") + const unauthenticated = await fixture.request("/api/session") + expect(unauthenticated.status).toBe(401) + expect(audit).not.toHaveBeenCalled() + expect(fixture.snapshot()).toEqual(before) + expect(JSON.stringify(body)).not.toContain(signedIn.token) + expect(JSON.stringify(log.mock.calls)).not.toContain(signedIn.token) + }) +}) diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts new file mode 100644 index 0000000..1223148 --- /dev/null +++ b/hub/server/routes/index.ts @@ -0,0 +1,54 @@ +import express from "express" +import type { Express } from "express" + +import { requireHubOrigin } from "#server/domains/identity/require-hub-origin" +import { preparePrivateRequest } from "#server/integrations/express/prepare-private-request" +import { respondIdentityError } from "#server/integrations/express/respond-identity-error" +import { put as handleApiAdministrationGithubCredentialsPut } from "#server/routes/api/administration/github-credentials/put" +import { post as handleApiAdministrationRevokeSessionsPost } from "#server/routes/api/administration/revoke-sessions/post" +import { get as handleApiAdministrationSecurityAuditGet } from "#server/routes/api/administration/security-audit/get" +import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" +import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" +import { post as handleApiAuthGithubVerifyPost } from "#server/routes/api/auth/github/verify/post" +import { get as handleApiHealthGet } from "#server/routes/api/health/get" +import { post as handleApiOperatorEnrollmentPost } from "#server/routes/api/operator/enrollment/post" +import { get as handleApiRepositoriesOwnerRepositoryAccessGet } from "#server/routes/api/repositories/[owner]/[repository]/access/get" +import { delete as handleApiSessionDelete } from "#server/routes/api/session/delete" +import { get as handleApiSessionGet } from "#server/routes/api/session/get" +import { post as handleApiSetupGithubPost } from "#server/routes/api/setup/github/post" +import { identityErrorResponse } from "#server/routes/identity-error-response" +import type { IdentityRoutes } from "#server/routes/types" + +export function routes(app: Express, identity?: IdentityRoutes): void { + app.get("/api/health", handleApiHealthGet) + if (!identity) return + app.use( + "/api", + preparePrivateRequest.bind(null, requireHubOrigin.bind(null, identity.settings.origin)), + express.json({ limit: "24kb", strict: true }), + ) + app.post("/api/setup/github", handleApiSetupGithubPost.bind(null, identity)) + app.post("/api/auth/github", handleApiAuthGithubPost.bind(null, identity)) + app.post("/api/auth/github/verify", handleApiAuthGithubVerifyPost.bind(null, identity)) + app.post("/api/operator/enrollment", handleApiOperatorEnrollmentPost.bind(null, identity)) + app.get("/api/auth/github/callback", handleApiAuthGithubCallbackGet.bind(null, identity)) + app.get("/api/session", handleApiSessionGet.bind(null, identity)) + app.delete("/api/session", handleApiSessionDelete.bind(null, identity)) + app.get( + "/api/repositories/:owner/:repository/access", + handleApiRepositoriesOwnerRepositoryAccessGet.bind(null, identity), + ) + app.put( + "/api/administration/github-credentials", + handleApiAdministrationGithubCredentialsPut.bind(null, identity), + ) + app.post( + "/api/administration/revoke-sessions", + handleApiAdministrationRevokeSessionsPost.bind(null, identity), + ) + app.get( + "/api/administration/security-audit", + handleApiAdministrationSecurityAuditGet.bind(null, identity), + ) + app.use(respondIdentityError.bind(null, identityErrorResponse)) +} diff --git a/hub/server/routes/test/create-server-fixture/index.ts b/hub/server/routes/test/create-server-fixture/index.ts new file mode 100644 index 0000000..eedd5fd --- /dev/null +++ b/hub/server/routes/test/create-server-fixture/index.ts @@ -0,0 +1,27 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { onTestFinished } from "vitest" + +import { createApp } from "#server/create-app" +import { createFixture } from "#server/domains/identity/create-service/test/create-fixture" + +export async function createServerFixture() { + const fixture = createFixture() + const server = createServer(createApp(fixture)) + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + server.listen(0, "127.0.0.1") + await once(server, "listening") + const { port } = server.address() as AddressInfo + + return { + ...fixture, + request: (path: string, options?: RequestInit) => + fetch(`http://127.0.0.1:${port}${path}`, { redirect: "manual", ...options }), + } +} diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts new file mode 100644 index 0000000..76e5e29 --- /dev/null +++ b/hub/server/routes/types.ts @@ -0,0 +1,6 @@ +import type { IdentityService, IdentitySettings } from "#server/domains/identity/types" + +export interface IdentityRoutes { + service: IdentityService + settings: IdentitySettings +}