From aa35eb3e2446be5de7a25c5458776b2037f91beb Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:28:24 +0000 Subject: [PATCH] feat(hub): add GitHub identity and session policies Add browser-bound GitHub authorization, single-installation admission, current repository checks, and transactional rotation and revocation without coupling domain errors to HTTP. Co-authored-by: Ben Thomas Co-authored-by: Nishiki Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hub/docs/README.md | 2 +- hub/docs/architecture.md | 2 + hub/docs/backend-architecture.md | 2 + hub/docs/identity.md | 13 + .../complete-github-authorization/index.ts | 48 ++ .../identity/create-csrf-token/index.ts | 5 + .../identity/create-service/index.test.ts | 472 ++++++++++++++++++ .../domains/identity/create-service/index.ts | 27 + .../test/create-fixture/index.ts | 220 ++++++++ .../finish-github-authorization/index.ts | 78 +++ .../finish-github-authorization/types.ts | 9 + .../identity/get-current-session/index.ts | 9 + .../get-identity-configuration/index.ts | 17 + .../identity/get-identity-context/index.ts | 16 + .../get-repository-permission/index.ts | 39 ++ .../identity/get-security-audits/index.ts | 15 + .../domains/identity/identity-error/index.ts | 10 + .../domains/identity/identity-error/types.ts | 17 + .../require-bootstrap-secret/index.ts | 14 + .../identity/require-csrf-token/index.ts | 9 + .../identity/require-hub-origin/index.ts | 7 + .../require-identity-administrator/index.ts | 32 ++ .../domains/identity/revoke-sessions/index.ts | 29 ++ .../rotate-github-credentials/index.ts | 45 ++ .../setup-github-credentials/index.ts | 32 ++ hub/server/domains/identity/sign-out/index.ts | 22 + .../start-github-authorization/index.ts | 39 ++ hub/server/domains/identity/types.ts | 44 ++ .../github/create-app-token/index.ts | 13 + .../github/create-authorization-url/index.ts | 20 + .../create-identity-client/index.test.ts | 325 ++++++++++++ .../github/create-identity-client/index.ts | 14 + .../github/create-identity-client/types.ts | 15 + .../create-installation-token/constants.ts | 3 + .../github/create-installation-token/index.ts | 13 + .../get-collaborator-permission/constants.ts | 6 + .../get-collaborator-permission/index.ts | 33 ++ .../github/get-repository-permission/index.ts | 15 + .../github/get-repository-permission/types.ts | 1 + .../integrations/github/github-error/types.ts | 7 +- .../github/github-response-error/index.ts | 10 + .../github/identify-user/constants.ts | 9 + .../github/identify-user/index.ts | 50 ++ .../github/identify-user/types.ts | 6 + .../github/is-user-admitted/constants.ts | 5 + .../github/is-user-admitted/index.ts | 29 ++ .../github/request-github/index.ts | 21 + hub/server/utils/digest-secret/index.ts | 5 + hub/server/utils/secrets-match/index.ts | 7 + 49 files changed, 1879 insertions(+), 2 deletions(-) create mode 100644 hub/server/domains/identity/complete-github-authorization/index.ts create mode 100644 hub/server/domains/identity/create-csrf-token/index.ts create mode 100644 hub/server/domains/identity/create-service/index.test.ts create mode 100644 hub/server/domains/identity/create-service/index.ts create mode 100644 hub/server/domains/identity/create-service/test/create-fixture/index.ts create mode 100644 hub/server/domains/identity/finish-github-authorization/index.ts create mode 100644 hub/server/domains/identity/finish-github-authorization/types.ts create mode 100644 hub/server/domains/identity/get-current-session/index.ts create mode 100644 hub/server/domains/identity/get-identity-configuration/index.ts create mode 100644 hub/server/domains/identity/get-identity-context/index.ts create mode 100644 hub/server/domains/identity/get-repository-permission/index.ts create mode 100644 hub/server/domains/identity/get-security-audits/index.ts create mode 100644 hub/server/domains/identity/identity-error/index.ts create mode 100644 hub/server/domains/identity/identity-error/types.ts create mode 100644 hub/server/domains/identity/require-bootstrap-secret/index.ts create mode 100644 hub/server/domains/identity/require-csrf-token/index.ts create mode 100644 hub/server/domains/identity/require-hub-origin/index.ts create mode 100644 hub/server/domains/identity/require-identity-administrator/index.ts create mode 100644 hub/server/domains/identity/revoke-sessions/index.ts create mode 100644 hub/server/domains/identity/rotate-github-credentials/index.ts create mode 100644 hub/server/domains/identity/setup-github-credentials/index.ts create mode 100644 hub/server/domains/identity/sign-out/index.ts create mode 100644 hub/server/domains/identity/start-github-authorization/index.ts create mode 100644 hub/server/domains/identity/types.ts create mode 100644 hub/server/integrations/github/create-app-token/index.ts create mode 100644 hub/server/integrations/github/create-authorization-url/index.ts create mode 100644 hub/server/integrations/github/create-identity-client/index.test.ts create mode 100644 hub/server/integrations/github/create-identity-client/index.ts create mode 100644 hub/server/integrations/github/create-identity-client/types.ts create mode 100644 hub/server/integrations/github/create-installation-token/constants.ts create mode 100644 hub/server/integrations/github/create-installation-token/index.ts create mode 100644 hub/server/integrations/github/get-collaborator-permission/constants.ts create mode 100644 hub/server/integrations/github/get-collaborator-permission/index.ts create mode 100644 hub/server/integrations/github/get-repository-permission/index.ts create mode 100644 hub/server/integrations/github/get-repository-permission/types.ts create mode 100644 hub/server/integrations/github/github-response-error/index.ts create mode 100644 hub/server/integrations/github/identify-user/constants.ts create mode 100644 hub/server/integrations/github/identify-user/index.ts create mode 100644 hub/server/integrations/github/identify-user/types.ts create mode 100644 hub/server/integrations/github/is-user-admitted/constants.ts create mode 100644 hub/server/integrations/github/is-user-admitted/index.ts create mode 100644 hub/server/integrations/github/request-github/index.ts create mode 100644 hub/server/utils/digest-secret/index.ts create mode 100644 hub/server/utils/secrets-match/index.ts diff --git a/hub/docs/README.md b/hub/docs/README.md index 3550698..857deb9 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 and providers. +- [Identity](identity.md): credential storage, sign-in rules, and session access. - [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 052ac57..1a3d7d4 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -18,6 +18,8 @@ The only route is `GET /api/health`, which returns `{ "status": "ok" }`. Every o 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 8827775..3941aec 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -34,6 +34,8 @@ Keep function implementations flat. A composition factory may bind imported func 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. +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/`. + ## 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). diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 9e0d375..aef7d09 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -2,6 +2,8 @@ Hub stores GitHub credentials, users, sessions, one-use sign-in attempts, and security records in PostgreSQL. Credential providers use one GitHub App installation. +The identity service implements setup, sign-in, session verification, repository access, credential rotation, and revocation without depending on HTTP. + ## Credential providers Set `HUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): @@ -22,3 +24,14 @@ Apply the committed migration with `npm run db:deploy`. See [Database](database. 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. + +## 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. diff --git a/hub/server/domains/identity/complete-github-authorization/index.ts b/hub/server/domains/identity/complete-github-authorization/index.ts new file mode 100644 index 0000000..096b1d3 --- /dev/null +++ b/hub/server/domains/identity/complete-github-authorization/index.ts @@ -0,0 +1,48 @@ +import { timingSafeEqual } from "node:crypto" + +import { finishGitHubAuthorization } from "#server/domains/identity/finish-github-authorization" +import { getIdentityConfiguration } from "#server/domains/identity/get-identity-configuration" +import { digestSecret } from "#server/utils/digest-secret" +import type { IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function completeGitHubAuthorization( + dependencies: IdentityDependencies, + code: string, + state: string, + browserToken: string, +) { + const { store, github, now } = dependencies + + const attempt = await store.lock((records) => records.consumeAttempt(digestSecret(state))) + if ( + !attempt || + attempt.expiresAt <= now() || + !timingSafeEqual(Buffer.from(digestSecret(browserToken)), Buffer.from(attempt.browserDigest)) + ) { + throw new IdentityError( + "invalid_oauth_state", + "This GitHub sign-in expired or does not belong to this browser. Start again.", + ) + } + const configuration = await getIdentityConfiguration(dependencies) + if ( + attempt.credentialRevision !== configuration.revision || + attempt.generation !== configuration.state.generation + ) { + throw new IdentityError( + "sign_in_cancelled", + "Hub credentials or sessions changed. Start a new sign-in.", + ) + } + const profile = await github.identify(configuration.credentials, code, attempt.verifier) + const verifiedAt = now() + const existingUser = await store.read.githubUser(profile.providerId) + const admitted = + attempt.purpose === "sign-in" && !existingUser?.administrator + ? await github.admitted(configuration.credentials, profile) + : false + return store.lock( + finishGitHubAuthorization.bind(null, dependencies, { attempt, profile, verifiedAt, admitted }), + ) +} diff --git a/hub/server/domains/identity/create-csrf-token/index.ts b/hub/server/domains/identity/create-csrf-token/index.ts new file mode 100644 index 0000000..19ed2d1 --- /dev/null +++ b/hub/server/domains/identity/create-csrf-token/index.ts @@ -0,0 +1,5 @@ +import { digestSecret } from "#server/utils/digest-secret" + +export function createCsrfToken(token: string): string { + return digestSecret(`csrf:${token}`) +} diff --git a/hub/server/domains/identity/create-service/index.test.ts b/hub/server/domains/identity/create-service/index.test.ts new file mode 100644 index 0000000..5364d86 --- /dev/null +++ b/hub/server/domains/identity/create-service/index.test.ts @@ -0,0 +1,472 @@ +import { describe, expect, it } from "vitest" + +import { createFixture } from "#server/domains/identity/create-service/test/create-fixture" + +describe("createIdentityService()", () => { + it("allows only the bootstrap secret to configure database credentials once", async () => { + const fixture = createFixture() + const { service, settings, credentials } = fixture + + await expect(service.setup("wrong", credentials)).rejects.toThrow() + expect(fixture.snapshot().state.encryptedCredentials).toBeNull() + for (const installationId of [undefined, 7, "0"]) { + await expect( + service.setup(settings.bootstrapSecret, { ...credentials, installationId }), + ).rejects.toThrow() + expect(fixture.snapshot().state.encryptedCredentials).toBeNull() + } + await service.setup(settings.bootstrapSecret, credentials) + const saved = fixture.snapshot() + expect(saved.state.encryptedCredentials).not.toContain(credentials.clientSecret) + expect(fixture.provider.read(saved.state)).toEqual(credentials) + expect(saved.audits).toHaveLength(1) + await expect( + service.setup(settings.bootstrapSecret, { ...credentials, clientSecret: "replacement" }), + ).rejects.toThrow() + expect(fixture.snapshot()).toEqual(saved) + }) + + it("does not allow setup or rotation to replace environment credentials", async () => { + const fixture = createFixture({ environment: true }) + const administrator = await fixture.signIn("enroll") + const before = fixture.snapshot() + + await expect( + fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials), + ).rejects.toThrow() + await expect( + fixture.service.rotate(administrator.token, administrator.csrfToken, fixture.credentials), + ).rejects.toThrow() + expect(fixture.snapshot()).toEqual(before) + expect(fixture.provider.read(before.state)).toEqual(fixture.credentials) + }) + + it("binds sign-in to a one-use browser attempt and passes its PKCE verifier to GitHub", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const first = await fixture.begin() + const second = await fixture.begin() + expect(first.state).not.toBe(second.state) + expect(first.verifier).not.toBe(second.verifier) + expect(first.browserToken).not.toBe(second.browserToken) + expect(first.verifier.length).toBeGreaterThanOrEqual(43) + const savedAttempt = fixture.snapshot().attempts[0]! + expect(savedAttempt.stateDigest).not.toBe(first.state) + expect(savedAttempt.browserDigest).not.toBe(first.browserToken) + + await expect( + fixture.service.complete("code", first.state, second.browserToken), + ).rejects.toThrow() + const result = await fixture.service.complete("code", second.state, second.browserToken) + expect(result.token).toEqual(expect.any(String)) + expect(fixture.github.identify).toHaveBeenCalledWith( + fixture.credentials, + "code", + second.verifier, + ) + await expect( + fixture.service.complete("code", second.state, second.browserToken), + ).rejects.toThrow() + expect(fixture.snapshot().sessions).toHaveLength(1) + expect(fixture.snapshot().sessions[0]!.tokenDigest).not.toBe(result.token) + }) + + it.each([15 * 60_000 - 1, 15 * 60_000])( + "enforces the fifteen-minute attempt boundary after %i milliseconds", + async (elapsed) => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const attempt = await fixture.begin() + fixture.advance(elapsed) + const completion = fixture.service.complete("code", attempt.state, attempt.browserToken) + + if (elapsed < 15 * 60_000) { + expect((await completion).token).toEqual(expect.any(String)) + } else { + await expect(completion).rejects.toThrow() + expect(fixture.github.identify).not.toHaveBeenCalled() + expect(fixture.snapshot().sessions).toEqual([]) + } + }, + ) + + it("keeps immutable GitHub accounts separate when logins change or are reused", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const original = await fixture.signIn() + fixture.github.identify.mockResolvedValue({ ...fixture.profile, login: "renamed-user" }) + const renamed = await fixture.signIn() + fixture.github.identify.mockResolvedValue({ ...fixture.profile, providerId: "20002" }) + const reused = await fixture.signIn() + + expect(renamed.user.id).toBe(original.user.id) + expect(renamed.user.login).toBe("renamed-user") + expect(reused.user.id).not.toBe(original.user.id) + expect(reused.user.administrator).toBe(false) + expect(fixture.snapshot().users).toHaveLength(2) + }) + + it("requires the bootstrap secret for administrator enrollment", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + + await expect( + fixture.service.start({ purpose: "enroll", bootstrapSecret: "wrong" }), + ).rejects.toThrow() + expect(fixture.github.authorize).not.toHaveBeenCalled() + const administrator = await fixture.signIn("enroll") + expect(administrator.user.administrator).toBe(true) + }) + + it("does not let sign-in refresh an older session's authorization", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const first = await fixture.signIn("enroll") + fixture.advance(5 * 60_000) + const second = await fixture.signIn() + + expect(second.session.freshAuthorizedAt!.getTime()).toBeGreaterThan( + first.session.freshAuthorizedAt!.getTime(), + ) + expect((await fixture.service.current(first.token)).session.freshAuthorizedAt).toEqual( + first.session.freshAuthorizedAt, + ) + await expect( + fixture.service.rotate(first.token, first.csrfToken, fixture.credentials), + ).rejects.toThrow() + }) + + it("lets an enrolled operator sign in during a repository admission outage after proving identity", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const administrator = await fixture.signIn("enroll") + fixture.github.admitted.mockRejectedValue(new Error("GitHub repository API unavailable")) + + const signedIn = await fixture.signIn() + + expect(signedIn.user.id).toBe(administrator.user.id) + expect(signedIn.user.administrator).toBe(true) + expect(fixture.github.admitted).not.toHaveBeenCalled() + }) + + it("does not extend fresh identity proof by the time spent checking repository admission", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + fixture.github.admitted.mockImplementation(() => { + fixture.advance(5 * 60_000) + return Promise.resolve(true) + }) + + const signedIn = await fixture.signIn() + + expect(signedIn.session.freshAuthorizedAt!.getTime()).toBe( + signedIn.session.signedInAt.getTime() - 5 * 60_000, + ) + await fixture.store.lock((records) => records.enroll(signedIn.user.id)) + await expect( + fixture.service.rotate(signedIn.token, signedIn.csrfToken, fixture.credentials), + ).rejects.toThrow() + }) + + it("verification refreshes only its originating session and creates no new session", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const first = await fixture.signIn() + const second = await fixture.signIn() + fixture.advance(5 * 60_000) + await expect( + fixture.begin({ purpose: "verify", token: first.token, csrfToken: "wrong" }), + ).rejects.toThrow() + const attempt = await fixture.begin({ + purpose: "verify", + token: first.token, + csrfToken: first.csrfToken, + }) + + expect(await fixture.service.complete("code", attempt.state, attempt.browserToken)).toEqual({}) + const refreshed = await fixture.service.current(first.token) + expect(refreshed.session.freshAuthorizedAt!.getTime()).toBeGreaterThan( + first.session.freshAuthorizedAt!.getTime(), + ) + expect((await fixture.service.current(second.token)).session.freshAuthorizedAt).toEqual( + second.session.freshAuthorizedAt, + ) + expect(fixture.snapshot().sessions).toHaveLength(2) + }) + + it("rejects verification by a different immutable GitHub account despite a matching login", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const signedIn = await fixture.signIn() + fixture.advance(5 * 60_000) + const attempt = await fixture.begin({ + purpose: "verify", + token: signedIn.token, + csrfToken: signedIn.csrfToken, + }) + fixture.github.identify.mockResolvedValue({ + ...fixture.profile, + providerId: "different-account", + }) + + await expect( + fixture.service.complete("code", attempt.state, attempt.browserToken), + ).rejects.toThrow() + expect((await fixture.service.current(signedIn.token)).session.freshAuthorizedAt).toEqual( + signedIn.session.freshAuthorizedAt, + ) + expect(fixture.snapshot().users).toHaveLength(1) + }) + + it("does not revive a session signed out while its verification callback is exchanging", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const signedIn = await fixture.signIn() + const attempt = await fixture.begin({ + purpose: "verify", + token: signedIn.token, + csrfToken: signedIn.csrfToken, + }) + let entered!: () => void + let release!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + fixture.github.identify.mockImplementation(async () => { + entered() + await gate + return { ...fixture.profile } + }) + const completion = fixture.service.complete("code", attempt.state, attempt.browserToken) + await started + await fixture.service.signOut(signedIn.token, signedIn.csrfToken) + release() + + await expect(completion).rejects.toThrow() + expect(fixture.snapshot().sessions).toEqual([]) + }) + + it.each(["denied", "outage"] as const)("fails sign-in closed for GitHub %s", async (failure) => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const attempt = await fixture.begin() + if (failure === "denied") fixture.github.admitted.mockResolvedValue(false) + else fixture.github.identify.mockRejectedValue(new Error("GitHub unavailable")) + + await expect( + fixture.service.complete("code", attempt.state, attempt.browserToken), + ).rejects.toThrow() + expect(fixture.snapshot().sessions).toEqual([]) + }) + + it("uses the current repository role even for administrators and fails closed on GitHub outages", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const administrator = await fixture.signIn("enroll") + fixture.github.permission.mockResolvedValueOnce("none").mockResolvedValueOnce("write") + + expect(await fixture.service.permission(administrator.token, "synthetic", "repository")).toBe( + "none", + ) + expect(await fixture.service.permission(administrator.token, "synthetic", "repository")).toBe( + "write", + ) + expect(fixture.github.permission).toHaveBeenLastCalledWith( + fixture.credentials, + expect.objectContaining({ providerId: fixture.profile.providerId }), + "synthetic", + "repository", + ) + fixture.github.permission.mockRejectedValue(new Error("GitHub unavailable")) + await expect( + fixture.service.permission(administrator.token, "synthetic", "repository"), + ).rejects.toThrow() + }) + + it("rechecks session revocation after outbound repository authorization", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const signedIn = await fixture.signIn() + let entered!: () => void + let release!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + fixture.github.permission.mockImplementation(async () => { + entered() + await gate + return "admin" + }) + const permission = fixture.service.permission(signedIn.token, "synthetic", "repository") + await started + await fixture.service.signOut(signedIn.token, signedIn.csrfToken) + release() + + await expect(permission).rejects.toThrow() + await expect(fixture.service.current(signedIn.token)).rejects.toThrow() + }) + + it("requires an administrator, CSRF, confirmation, and proof strictly younger than five minutes for revocation", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const ordinary = await fixture.signIn() + await expect(fixture.service.revoke(ordinary.token, ordinary.csrfToken, true)).rejects.toThrow() + const administrator = await fixture.signIn("enroll") + await expect(fixture.service.revoke(administrator.token, "wrong", true)).rejects.toThrow() + await expect( + fixture.service.revoke(administrator.token, administrator.csrfToken, false), + ).rejects.toThrow() + fixture.advance(5 * 60_000) + await expect( + fixture.service.revoke(administrator.token, administrator.csrfToken, true), + ).rejects.toThrow() + expect(fixture.snapshot().sessions).toHaveLength(2) + }) + + it("requires a live administrator session and CSRF for rotation", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const ordinary = await fixture.signIn() + await expect( + fixture.service.rotate(ordinary.token, ordinary.csrfToken, fixture.credentials), + ).rejects.toThrow() + const administrator = await fixture.signIn("enroll") + await expect( + fixture.service.rotate(administrator.token, "wrong", fixture.credentials), + ).rejects.toThrow() + await fixture.service.signOut(administrator.token, administrator.csrfToken) + const before = fixture.snapshot() + + await expect( + fixture.service.rotate(administrator.token, administrator.csrfToken, fixture.credentials), + ).rejects.toThrow() + expect(fixture.snapshot()).toEqual(before) + }) + + it("does not let credential rotation switch the GitHub App, OAuth client, or installation", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const administrator = await fixture.signIn("enroll") + const before = fixture.snapshot() + + for (const changed of [ + { appId: "99999" }, + { clientId: "different-client" }, + { installationId: "8" }, + ]) { + await expect( + fixture.service.rotate(administrator.token, administrator.csrfToken, { + ...fixture.credentials, + ...changed, + }), + ).rejects.toThrow() + expect(fixture.snapshot()).toEqual(before) + } + }) + + it("revokes every session and prevents an already-exchanging callback from creating another", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const administrator = await fixture.signIn("enroll") + await fixture.signIn() + const attempt = await fixture.begin() + let entered!: () => void + let release!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + fixture.github.identify.mockImplementation(async () => { + entered() + await gate + return { ...fixture.profile } + }) + const completion = fixture.service.complete("code", attempt.state, attempt.browserToken) + await started + fixture.advance(5 * 60_000 - 1) + await fixture.service.revoke(administrator.token, administrator.csrfToken, true) + release() + + await expect(completion).rejects.toThrow() + expect(fixture.snapshot().sessions).toEqual([]) + expect(fixture.snapshot().attempts).toEqual([]) + await expect(fixture.service.current(administrator.token)).rejects.toThrow() + }) + + it("rotates credentials without revoking sessions but invalidates their fresh proof and old callbacks", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const administrator = await fixture.signIn("enroll") + const ordinary = await fixture.signIn() + const attempt = await fixture.begin() + let entered!: () => void + let release!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + fixture.github.identify.mockImplementation(async () => { + entered() + await gate + return { ...fixture.profile } + }) + const completion = fixture.service.complete("code", attempt.state, attempt.browserToken) + await started + const replacement = { ...fixture.credentials, clientSecret: "new-synthetic-secret" } + await fixture.service.rotate(administrator.token, administrator.csrfToken, replacement) + release() + + await expect(completion).rejects.toThrow() + expect(fixture.provider.read(fixture.snapshot().state)).toEqual(replacement) + await expect(fixture.service.current(administrator.token)).resolves.toBeDefined() + await expect(fixture.service.current(ordinary.token)).resolves.toBeDefined() + await expect( + fixture.service.rotate(administrator.token, administrator.csrfToken, fixture.credentials), + ).rejects.toThrow() + expect(fixture.snapshot().sessions).toHaveLength(2) + expect(fixture.snapshot().attempts).toEqual([]) + }) + + it("does not revoke sessions when the durable audit cannot be saved", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const administrator = await fixture.signIn("enroll") + const before = fixture.snapshot() + fixture.failAudit() + + await expect( + fixture.service.revoke(administrator.token, administrator.csrfToken, true), + ).rejects.toThrow() + expect(fixture.snapshot()).toEqual(before) + }) + + it("limits audit access to administrators and never includes credentials or raw session tokens", async () => { + const fixture = createFixture() + await fixture.service.setup(fixture.settings.bootstrapSecret, fixture.credentials) + const ordinary = await fixture.signIn() + await expect(fixture.service.audits(ordinary.token)).rejects.toThrow() + const administrator = await fixture.signIn("enroll") + const audits = await fixture.service.audits(administrator.token) + + expect(audits.length).toBeGreaterThan(1) + const output = JSON.stringify(audits) + for (const secret of [ + fixture.credentials.clientSecret, + fixture.credentials.privateKey, + fixture.settings.bootstrapSecret, + ordinary.token, + administrator.token, + ]) + expect(output).not.toContain(secret) + }) +}) diff --git a/hub/server/domains/identity/create-service/index.ts b/hub/server/domains/identity/create-service/index.ts new file mode 100644 index 0000000..5315c0b --- /dev/null +++ b/hub/server/domains/identity/create-service/index.ts @@ -0,0 +1,27 @@ +import { completeGitHubAuthorization } from "#server/domains/identity/complete-github-authorization" +import { getCurrentSession } from "#server/domains/identity/get-current-session" +import { getRepositoryPermission } from "#server/domains/identity/get-repository-permission" +import { getSecurityAudits } from "#server/domains/identity/get-security-audits" +import { revokeSessions } from "#server/domains/identity/revoke-sessions" +import { rotateGitHubCredentials } from "#server/domains/identity/rotate-github-credentials" +import { setupGitHubCredentials } from "#server/domains/identity/setup-github-credentials" +import { signOut } from "#server/domains/identity/sign-out" +import { startGitHubAuthorization } from "#server/domains/identity/start-github-authorization" +import type { IdentityDependencies, IdentityService } from "#server/domains/identity/types" + +export function createIdentityService( + options: Omit & { now?: () => Date }, +): IdentityService { + const dependencies: IdentityDependencies = { ...options, now: options.now ?? (() => new Date()) } + return { + setup: setupGitHubCredentials.bind(null, dependencies), + start: startGitHubAuthorization.bind(null, dependencies), + complete: completeGitHubAuthorization.bind(null, dependencies), + current: getCurrentSession.bind(null, dependencies), + signOut: signOut.bind(null, dependencies), + permission: getRepositoryPermission.bind(null, dependencies), + rotate: rotateGitHubCredentials.bind(null, dependencies), + revoke: revokeSessions.bind(null, dependencies), + audits: getSecurityAudits.bind(null, dependencies), + } +} diff --git a/hub/server/domains/identity/create-service/test/create-fixture/index.ts b/hub/server/domains/identity/create-service/test/create-fixture/index.ts new file mode 100644 index 0000000..39ce627 --- /dev/null +++ b/hub/server/domains/identity/create-service/test/create-fixture/index.ts @@ -0,0 +1,220 @@ +import { generateKeyPairSync, randomUUID } from "node:crypto" +import { vi } from "vitest" + +import { createCredentialProvider } from "#server/domains/identity/create-credential-provider" +import { createIdentityService } from "#server/domains/identity/create-service" +import type { AuditInput } from "#server/repositories/security-audits/types" +import type { GitHubIdentityClient } from "#server/integrations/github/create-identity-client/types" +import type { GitHubProfile } from "#server/integrations/github/identify-user/types" +import type { HubSession } from "#server/repositories/sessions/types" +import type { HubUser } from "#server/repositories/users/types" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { IdentityState } from "#server/repositories/identity-state/types" +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" + +export function createFixture({ environment = false } = {}) { + let data = { + state: { generation: 0, encryptedCredentials: null } as IdentityState, + users: [] as HubUser[], + sessions: [] as HubSession[], + attempts: [] as OAuthAttempt[], + audits: [] as (AuditInput & { id: string; createdAt: Date })[], + } + let failAudit = false + let tail = Promise.resolve() + const records: IdentityRecords = { + state: () => Promise.resolve(structuredClone(data.state)), + saveCredentials: (encryptedCredentials) => { + data.state.encryptedCredentials = encryptedCredentials + return Promise.resolve() + }, + advanceGeneration: () => { + data.state.generation++ + data.attempts = [] + return Promise.resolve() + }, + user: (id) => + Promise.resolve(structuredClone(data.users.find((user) => user.id === id) ?? null)), + githubUser: (providerId) => + Promise.resolve( + structuredClone( + data.users.find((user) => user.provider === "github" && user.providerId === providerId) ?? + null, + ), + ), + saveUser: (profile) => { + let user = data.users.find((user) => user.providerId === profile.providerId) + if (user) Object.assign(user, profile) + else { + user = { ...profile, id: randomUUID(), provider: "github", administrator: false } + data.users.push(user) + } + return Promise.resolve(structuredClone(user)) + }, + enroll: (id) => { + const user = data.users.find((user) => user.id === id)! + user.administrator = true + return Promise.resolve(structuredClone(user)) + }, + session: (digest) => { + const session = data.sessions.find((session) => session.tokenDigest === digest) + if (!session) return Promise.resolve(null) + return Promise.resolve( + structuredClone({ + ...session, + user: data.users.find((user) => user.id === session.userId)!, + }), + ) + }, + sessionById: (id) => { + const session = data.sessions.find((session) => session.id === id) + if (!session) return Promise.resolve(null) + return Promise.resolve( + structuredClone({ + ...session, + user: data.users.find((user) => user.id === session.userId)!, + }), + ) + }, + createSession: (input) => { + const session = { ...input, id: randomUUID() } + data.sessions.push(session) + return Promise.resolve(structuredClone(session)) + }, + verifySession: (id, freshAuthorizedAt, credentialRevision) => { + const session = data.sessions.find((session) => session.id === id) + if (!session) throw new Error("Session no longer exists") + Object.assign(session, { freshAuthorizedAt, credentialRevision }) + return Promise.resolve() + }, + invalidateFreshness: () => { + for (const session of data.sessions) session.freshAuthorizedAt = null + return Promise.resolve() + }, + deleteSession: (id) => { + data.sessions = data.sessions.filter((session) => session.id !== id) + data.attempts = data.attempts.filter((attempt) => attempt.sessionId !== id) + return Promise.resolve() + }, + deleteSessions: () => { + data.sessions = [] + return Promise.resolve() + }, + createAttempt: (input) => { + data.attempts.push({ ...input, id: randomUUID() }) + return Promise.resolve() + }, + consumeAttempt: (digest) => { + const attempt = data.attempts.find((attempt) => attempt.stateDigest === digest) + data.attempts = data.attempts.filter((attempt) => attempt.stateDigest !== digest) + return Promise.resolve(structuredClone(attempt ?? null)) + }, + audit: (input) => { + if (failAudit) return Promise.reject(new Error("Audit unavailable")) + data.audits.push({ ...input, id: randomUUID(), createdAt: new Date() }) + return Promise.resolve() + }, + audits: () => Promise.resolve(structuredClone(data.audits)), + } + const store: IdentityStore = { + read: records, + async lock(operation) { + const previous = tail + let release!: () => void + tail = new Promise((resolve) => { + release = resolve + }) + await previous + const snapshot = structuredClone(data) + try { + return await operation(records) + } catch (error) { + data = snapshot + throw error + } finally { + release() + } + }, + } + const credentials = { + appId: "12345", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey: generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey, + } + const profile: GitHubProfile = { + providerId: "10001", + login: "synthetic-user", + name: null, + avatarUrl: null, + } + const github = { + authorize: vi.fn( + () => "https://github.com/login/oauth/authorize", + ), + identify: vi.fn().mockResolvedValue({ ...profile }), + admitted: vi.fn().mockResolvedValue(true), + permission: vi.fn().mockResolvedValue("read"), + } + const settings = { + origin: "https://hub.example.test", + bootstrapSecret: "synthetic-bootstrap", + secureCookies: true, + } + const provider = createCredentialProvider( + environment + ? { + HUB_CREDENTIAL_PROVIDER: "environment", + GITHUB_APP_ID: credentials.appId, + GITHUB_INSTALLATION_ID: credentials.installationId, + GITHUB_CLIENT_ID: credentials.clientId, + GITHUB_CLIENT_SECRET: credentials.clientSecret, + GITHUB_PRIVATE_KEY: credentials.privateKey, + } + : { HUB_CREDENTIAL_KEY: "ab".repeat(32) }, + ) + let clock = new Date() + const service = createIdentityService({ + store, + provider, + github, + settings, + now: () => new Date(clock), + }) + + async function begin(input: Parameters[0] = { purpose: "sign-in" }) { + const result = await service.start(input) + const [, state, verifier] = github.authorize.mock.calls.at(-1)! + return { ...result, state, verifier } + } + async function signIn(purpose: "sign-in" | "enroll" = "sign-in") { + const attempt = await begin({ purpose, bootstrapSecret: settings.bootstrapSecret }) + const result = await service.complete("synthetic-code", attempt.state, attempt.browserToken) + if (!result.token) throw new Error("Expected a sign-in token") + return { token: result.token, ...(await service.current(result.token)) } + } + + return { + service, + store, + github, + provider, + credentials, + profile, + settings, + begin, + signIn, + snapshot: () => structuredClone(data), + failAudit: () => { + failAudit = true + }, + advance: (milliseconds: number) => { + clock = new Date(clock.getTime() + milliseconds) + }, + } +} diff --git a/hub/server/domains/identity/finish-github-authorization/index.ts b/hub/server/domains/identity/finish-github-authorization/index.ts new file mode 100644 index 0000000..74680dd --- /dev/null +++ b/hub/server/domains/identity/finish-github-authorization/index.ts @@ -0,0 +1,78 @@ +import { randomBytes } from "node:crypto" + +import type { VerifiedGitHubAuthorization } from "#server/domains/identity/finish-github-authorization/types" +import { getIdentityConfiguration } from "#server/domains/identity/get-identity-configuration" +import { digestSecret } from "#server/utils/digest-secret" +import type { IdentityDependencies } from "#server/domains/identity/types" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function finishGitHubAuthorization( + dependencies: IdentityDependencies, + input: VerifiedGitHubAuthorization, + records: IdentityRecords, +): Promise<{ token?: string }> { + const { now } = dependencies + const { attempt, profile, verifiedAt, admitted } = input + + const latest = await getIdentityConfiguration(dependencies, records) + if ( + latest.revision !== attempt.credentialRevision || + latest.state.generation !== attempt.generation || + attempt.expiresAt <= now() + ) { + throw new IdentityError( + "sign_in_cancelled", + "Hub credentials or sessions changed. Start a new sign-in.", + ) + } + if (attempt.purpose === "verify") { + const session = attempt.sessionId ? await records.sessionById(attempt.sessionId) : null + if (!session) { + throw new IdentityError( + "sign_in_required", + "The initiating browser session has ended. Sign in again.", + ) + } + const currentUser = await records.user(session.userId) + if ( + !currentUser || + currentUser.provider !== "github" || + currentUser.providerId !== profile.providerId + ) { + throw new IdentityError( + "identity_mismatch", + "Verify the same GitHub account that started this request.", + ) + } + await records.verifySession(session.id, verifiedAt, latest.revision) + await records.audit({ + event: "session.verify", + actorId: currentUser.id, + sessionId: session.id, + }) + return {} + } + let user = await records.saveUser(profile) + if (attempt.purpose === "enroll") user = await records.enroll(user.id) + if (!admitted && !user.administrator) { + throw new IdentityError( + "repository_access_required", + "Access to an installed repository is required to sign in.", + ) + } + const token = randomBytes(32).toString("base64url") + const session = await records.createSession({ + tokenDigest: digestSecret(token), + userId: user.id, + signedInAt: now(), + freshAuthorizedAt: verifiedAt, + credentialRevision: latest.revision, + }) + await records.audit({ + event: attempt.purpose === "enroll" ? "operator.enroll" : "session.sign_in", + actorId: user.id, + sessionId: session.id, + }) + return { token } +} diff --git a/hub/server/domains/identity/finish-github-authorization/types.ts b/hub/server/domains/identity/finish-github-authorization/types.ts new file mode 100644 index 0000000..f2dabd8 --- /dev/null +++ b/hub/server/domains/identity/finish-github-authorization/types.ts @@ -0,0 +1,9 @@ +import type { GitHubProfile } from "#server/integrations/github/identify-user/types" +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" + +export interface VerifiedGitHubAuthorization { + attempt: OAuthAttempt + profile: GitHubProfile + verifiedAt: Date + admitted: boolean +} diff --git a/hub/server/domains/identity/get-current-session/index.ts b/hub/server/domains/identity/get-current-session/index.ts new file mode 100644 index 0000000..d1ebb66 --- /dev/null +++ b/hub/server/domains/identity/get-current-session/index.ts @@ -0,0 +1,9 @@ +import { getIdentityConfiguration } from "#server/domains/identity/get-identity-configuration" +import { getIdentityContext } from "#server/domains/identity/get-identity-context" +import type { IdentityDependencies } from "#server/domains/identity/types" + +export async function getCurrentSession(dependencies: IdentityDependencies, token: string) { + const current = await getIdentityContext(dependencies, token) + await getIdentityConfiguration(dependencies) + return current +} diff --git a/hub/server/domains/identity/get-identity-configuration/index.ts b/hub/server/domains/identity/get-identity-configuration/index.ts new file mode 100644 index 0000000..e59f22e --- /dev/null +++ b/hub/server/domains/identity/get-identity-configuration/index.ts @@ -0,0 +1,17 @@ +import { digestSecret } from "#server/utils/digest-secret" +import type { IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function getIdentityConfiguration( + dependencies: IdentityDependencies, + records = dependencies.store.read, +) { + const { provider } = dependencies + + const state = await records.state() + const credentials = provider.read(state) + if (!credentials) { + throw new IdentityError("setup_required", "Configure the GitHub App before signing in.") + } + return { state, credentials, revision: digestSecret(JSON.stringify(credentials)) } +} diff --git a/hub/server/domains/identity/get-identity-context/index.ts b/hub/server/domains/identity/get-identity-context/index.ts new file mode 100644 index 0000000..29c4da0 --- /dev/null +++ b/hub/server/domains/identity/get-identity-context/index.ts @@ -0,0 +1,16 @@ +import { createCsrfToken } from "#server/domains/identity/create-csrf-token" +import { digestSecret } from "#server/utils/digest-secret" +import type { IdentityContext, IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function getIdentityContext( + dependencies: IdentityDependencies, + token: string, + records = dependencies.store.read, +): Promise { + const session = await records.session(digestSecret(token)) + if (!token || !session) { + throw new IdentityError("sign_in_required", "Sign in to Hub.") + } + return { user: session.user, session, csrfToken: createCsrfToken(token) } +} diff --git a/hub/server/domains/identity/get-repository-permission/index.ts b/hub/server/domains/identity/get-repository-permission/index.ts new file mode 100644 index 0000000..8c3757e --- /dev/null +++ b/hub/server/domains/identity/get-repository-permission/index.ts @@ -0,0 +1,39 @@ +import { getIdentityConfiguration } from "#server/domains/identity/get-identity-configuration" +import { getIdentityContext } from "#server/domains/identity/get-identity-context" +import type { IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function getRepositoryPermission( + dependencies: IdentityDependencies, + token: string, + owner: string, + repository: string, +) { + const { store, github } = dependencies + + const current = await getIdentityContext(dependencies, token) + const configuration = await getIdentityConfiguration(dependencies) + const permission = await github.permission( + configuration.credentials, + { + providerId: current.user.providerId, + login: current.user.login, + name: current.user.name, + avatarUrl: current.user.avatarUrl, + }, + owner, + repository, + ) + await store.lock(async (records) => { + await getIdentityContext(dependencies, token, records) + if ( + (await getIdentityConfiguration(dependencies, records)).revision !== configuration.revision + ) { + throw new IdentityError( + "credentials_changed", + "GitHub App credentials changed. Retry the access check.", + ) + } + }) + return permission +} diff --git a/hub/server/domains/identity/get-security-audits/index.ts b/hub/server/domains/identity/get-security-audits/index.ts new file mode 100644 index 0000000..1fd0259 --- /dev/null +++ b/hub/server/domains/identity/get-security-audits/index.ts @@ -0,0 +1,15 @@ +import { getIdentityContext } from "#server/domains/identity/get-identity-context" +import type { IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function getSecurityAudits(dependencies: IdentityDependencies, token: string) { + const { store } = dependencies + + return store.lock(async (records) => { + const current = await getIdentityContext(dependencies, token, records) + if (!current.user.administrator) { + throw new IdentityError("administrator_required", "A Hub administrator is required.") + } + return records.audits() + }) +} diff --git a/hub/server/domains/identity/identity-error/index.ts b/hub/server/domains/identity/identity-error/index.ts new file mode 100644 index 0000000..d0f1f53 --- /dev/null +++ b/hub/server/domains/identity/identity-error/index.ts @@ -0,0 +1,10 @@ +import type { IdentityErrorCode } from "#server/domains/identity/identity-error/types" + +export class IdentityError extends Error { + code: IdentityErrorCode + + constructor(code: IdentityErrorCode, message: string) { + super(message) + this.code = code + } +} diff --git a/hub/server/domains/identity/identity-error/types.ts b/hub/server/domains/identity/identity-error/types.ts new file mode 100644 index 0000000..e392934 --- /dev/null +++ b/hub/server/domains/identity/identity-error/types.ts @@ -0,0 +1,17 @@ +export type IdentityErrorCode = + | "administrator_required" + | "already_configured" + | "bootstrap_denied" + | "confirmation_required" + | "credentials_changed" + | "credentials_read_only" + | "csrf_failed" + | "different_app" + | "fresh_authorization_required" + | "identity_mismatch" + | "invalid_oauth_state" + | "origin_denied" + | "repository_access_required" + | "setup_required" + | "sign_in_cancelled" + | "sign_in_required" diff --git a/hub/server/domains/identity/require-bootstrap-secret/index.ts b/hub/server/domains/identity/require-bootstrap-secret/index.ts new file mode 100644 index 0000000..2cb20e4 --- /dev/null +++ b/hub/server/domains/identity/require-bootstrap-secret/index.ts @@ -0,0 +1,14 @@ +import type { IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" +import { secretsMatch } from "#server/utils/secrets-match" + +export function requireBootstrapSecret( + dependencies: IdentityDependencies, + secret: string | undefined, +) { + const { settings } = dependencies + + if (!secret || !settings.bootstrapSecret || !secretsMatch(secret, settings.bootstrapSecret)) { + throw new IdentityError("bootstrap_denied", "The deployment bootstrap secret is required.") + } +} diff --git a/hub/server/domains/identity/require-csrf-token/index.ts b/hub/server/domains/identity/require-csrf-token/index.ts new file mode 100644 index 0000000..8f6cc29 --- /dev/null +++ b/hub/server/domains/identity/require-csrf-token/index.ts @@ -0,0 +1,9 @@ +import { createCsrfToken } from "#server/domains/identity/create-csrf-token" +import { IdentityError } from "#server/domains/identity/identity-error" +import { secretsMatch } from "#server/utils/secrets-match" + +export function requireCsrfToken(token: string, csrfToken: string): void { + if (!secretsMatch(csrfToken, createCsrfToken(token))) { + throw new IdentityError("csrf_failed", "Reload Hub before submitting this request.") + } +} diff --git a/hub/server/domains/identity/require-hub-origin/index.ts b/hub/server/domains/identity/require-hub-origin/index.ts new file mode 100644 index 0000000..eff7fd3 --- /dev/null +++ b/hub/server/domains/identity/require-hub-origin/index.ts @@ -0,0 +1,7 @@ +import { IdentityError } from "#server/domains/identity/identity-error" + +export function requireHubOrigin(origin: string, requestOrigin: string | undefined): void { + if (requestOrigin !== origin) { + throw new IdentityError("origin_denied", "Submit this request from the configured Hub origin.") + } +} diff --git a/hub/server/domains/identity/require-identity-administrator/index.ts b/hub/server/domains/identity/require-identity-administrator/index.ts new file mode 100644 index 0000000..cb8c985 --- /dev/null +++ b/hub/server/domains/identity/require-identity-administrator/index.ts @@ -0,0 +1,32 @@ +import { getIdentityConfiguration } from "#server/domains/identity/get-identity-configuration" +import { getIdentityContext } from "#server/domains/identity/get-identity-context" +import type { IdentityDependencies } from "#server/domains/identity/types" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" +import { IdentityError } from "#server/domains/identity/identity-error" +import { requireCsrfToken } from "#server/domains/identity/require-csrf-token" + +export async function requireIdentityAdministrator( + dependencies: IdentityDependencies, + token: string, + csrfToken: string, + records: IdentityRecords, +) { + const { now } = dependencies + + requireCsrfToken(token, csrfToken) + const current = await getIdentityContext(dependencies, token, records) + if (!current.user.administrator) { + throw new IdentityError("administrator_required", "A Hub administrator is required.") + } + const { revision } = await getIdentityConfiguration(dependencies, records) + const age = current.session.freshAuthorizedAt + ? now().getTime() - current.session.freshAuthorizedAt.getTime() + : Infinity + if (age < 0 || age >= 5 * 60_000 || revision !== current.session.credentialRevision) { + throw new IdentityError( + "fresh_authorization_required", + "Verify the same GitHub account on this browser session before continuing.", + ) + } + return current +} diff --git a/hub/server/domains/identity/revoke-sessions/index.ts b/hub/server/domains/identity/revoke-sessions/index.ts new file mode 100644 index 0000000..bb1be6a --- /dev/null +++ b/hub/server/domains/identity/revoke-sessions/index.ts @@ -0,0 +1,29 @@ +import { requireIdentityAdministrator } from "#server/domains/identity/require-identity-administrator" +import type { IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function revokeSessions( + dependencies: IdentityDependencies, + token: string, + csrfToken: string, + confirmed: boolean, +) { + const { store } = dependencies + + await store.lock(async (records) => { + const current = await requireIdentityAdministrator(dependencies, token, csrfToken, records) + if (!confirmed) { + throw new IdentityError( + "confirmation_required", + "Confirm that all Hub sessions, including this session, will end.", + ) + } + await records.advanceGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + actorId: current.user.id, + sessionId: current.session.id, + }) + }) +} diff --git a/hub/server/domains/identity/rotate-github-credentials/index.ts b/hub/server/domains/identity/rotate-github-credentials/index.ts new file mode 100644 index 0000000..8350229 --- /dev/null +++ b/hub/server/domains/identity/rotate-github-credentials/index.ts @@ -0,0 +1,45 @@ +import { getIdentityConfiguration } from "#server/domains/identity/get-identity-configuration" +import { requireIdentityAdministrator } from "#server/domains/identity/require-identity-administrator" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function rotateGitHubCredentials( + dependencies: IdentityDependencies, + token: string, + csrfToken: string, + input: unknown, +) { + const { store, provider } = dependencies + + if (!provider.encrypt) { + throw new IdentityError( + "credentials_read_only", + "Configure GitHub App credentials in the selected external provider.", + ) + } + const encrypt = provider.encrypt + const credentials = validateGitHubCredentials(input) + await store.lock(async (records) => { + const current = await requireIdentityAdministrator(dependencies, token, csrfToken, records) + const existing = await getIdentityConfiguration(dependencies, records) + if ( + existing.credentials.appId !== credentials.appId || + existing.credentials.clientId !== credentials.clientId || + existing.credentials.installationId !== credentials.installationId + ) { + throw new IdentityError( + "different_app", + "Credential rotation must retain the existing GitHub App, client ID, and installation ID.", + ) + } + await records.saveCredentials(encrypt(credentials)) + await records.invalidateFreshness() + await records.advanceGeneration() + await records.audit({ + event: "credentials.rotate", + actorId: current.user.id, + sessionId: current.session.id, + }) + }) +} diff --git a/hub/server/domains/identity/setup-github-credentials/index.ts b/hub/server/domains/identity/setup-github-credentials/index.ts new file mode 100644 index 0000000..b1f1eef --- /dev/null +++ b/hub/server/domains/identity/setup-github-credentials/index.ts @@ -0,0 +1,32 @@ +import { requireBootstrapSecret } from "#server/domains/identity/require-bootstrap-secret" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { IdentityDependencies } from "#server/domains/identity/types" +import { IdentityError } from "#server/domains/identity/identity-error" + +export async function setupGitHubCredentials( + dependencies: IdentityDependencies, + secret: string, + input: unknown, +) { + const { store, provider } = dependencies + + requireBootstrapSecret(dependencies, secret) + if (!provider.encrypt) { + throw new IdentityError( + "credentials_read_only", + "Configure GitHub App credentials in the selected external provider.", + ) + } + const encrypt = provider.encrypt + const credentials = validateGitHubCredentials(input) + await store.lock(async (records) => { + if (provider.read(await records.state())) { + throw new IdentityError( + "already_configured", + "Use administrator credential rotation for an existing GitHub App.", + ) + } + await records.saveCredentials(encrypt(credentials)) + await records.audit({ event: "credentials.setup", actorId: null, sessionId: null }) + }) +} diff --git a/hub/server/domains/identity/sign-out/index.ts b/hub/server/domains/identity/sign-out/index.ts new file mode 100644 index 0000000..44cb5d6 --- /dev/null +++ b/hub/server/domains/identity/sign-out/index.ts @@ -0,0 +1,22 @@ +import { getIdentityContext } from "#server/domains/identity/get-identity-context" +import type { IdentityDependencies } from "#server/domains/identity/types" +import { requireCsrfToken } from "#server/domains/identity/require-csrf-token" + +export async function signOut( + dependencies: IdentityDependencies, + token: string, + csrfToken: string, +) { + const { store } = dependencies + + requireCsrfToken(token, csrfToken) + await store.lock(async (records) => { + const current = await getIdentityContext(dependencies, token, records) + await records.deleteSession(current.session.id) + await records.audit({ + event: "session.sign_out", + actorId: current.user.id, + sessionId: current.session.id, + }) + }) +} diff --git a/hub/server/domains/identity/start-github-authorization/index.ts b/hub/server/domains/identity/start-github-authorization/index.ts new file mode 100644 index 0000000..178e3e7 --- /dev/null +++ b/hub/server/domains/identity/start-github-authorization/index.ts @@ -0,0 +1,39 @@ +import { randomBytes } from "node:crypto" + +import { getIdentityConfiguration } from "#server/domains/identity/get-identity-configuration" +import { getIdentityContext } from "#server/domains/identity/get-identity-context" +import { requireBootstrapSecret } from "#server/domains/identity/require-bootstrap-secret" +import { digestSecret } from "#server/utils/digest-secret" +import type { IdentityDependencies, IdentityService } from "#server/domains/identity/types" +import { requireCsrfToken } from "#server/domains/identity/require-csrf-token" + +export async function startGitHubAuthorization( + dependencies: IdentityDependencies, + input: Parameters[0], +) { + const { purpose, token, csrfToken, bootstrapSecret } = input + const { store, github, now } = dependencies + + if (purpose === "enroll") requireBootstrapSecret(dependencies, bootstrapSecret) + if (purpose === "verify") requireCsrfToken(token ?? "", csrfToken ?? "") + const state = randomBytes(32).toString("base64url") + const browserToken = randomBytes(32).toString("base64url") + const verifier = randomBytes(32).toString("base64url") + const url = await store.lock(async (records) => { + const configuration = await getIdentityConfiguration(dependencies, records) + const session = + purpose === "verify" ? await getIdentityContext(dependencies, token ?? "", records) : null + await records.createAttempt({ + stateDigest: digestSecret(state), + browserDigest: digestSecret(browserToken), + verifier, + purpose, + sessionId: session?.session.id ?? null, + generation: configuration.state.generation, + credentialRevision: configuration.revision, + expiresAt: new Date(now().getTime() + 15 * 60_000), + }) + return github.authorize(configuration.credentials, state, verifier) + }) + return { url, browserToken } +} diff --git a/hub/server/domains/identity/types.ts b/hub/server/domains/identity/types.ts new file mode 100644 index 0000000..8634259 --- /dev/null +++ b/hub/server/domains/identity/types.ts @@ -0,0 +1,44 @@ +import type { CredentialProvider } from "#server/domains/identity/create-credential-provider/types" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubIdentityClient } from "#server/integrations/github/create-identity-client/types" +import type { RepositoryPermission } from "#server/integrations/github/get-repository-permission/types" +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" +import type { HubSession } from "#server/repositories/sessions/types" +import type { HubUser } from "#server/repositories/users/types" + +export interface IdentitySettings { + origin: string + bootstrapSecret: string | undefined + secureCookies: boolean +} + +export interface IdentityContext { + user: HubUser + session: HubSession + csrfToken: string +} + +export interface IdentityService { + setup(secret: string, input: unknown): Promise + start(input: { + purpose: OAuthAttempt["purpose"] + token?: string + csrfToken?: string + bootstrapSecret?: string + }): Promise<{ url: string; browserToken: string }> + complete(code: string, state: string, browserToken: string): Promise<{ token?: string }> + current(token: string): Promise + signOut(token: string, csrfToken: string): Promise + permission(token: string, owner: string, repository: string): Promise + rotate(token: string, csrfToken: string, input: unknown): Promise + revoke(token: string, csrfToken: string, confirmed: boolean): Promise + audits(token: string): ReturnType +} + +export interface IdentityDependencies { + store: IdentityStore + provider: CredentialProvider + github: GitHubIdentityClient + settings: IdentitySettings + now: () => Date +} diff --git a/hub/server/integrations/github/create-app-token/index.ts b/hub/server/integrations/github/create-app-token/index.ts new file mode 100644 index 0000000..1ec2588 --- /dev/null +++ b/hub/server/integrations/github/create-app-token/index.ts @@ -0,0 +1,13 @@ +import { sign } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function createAppToken(credentials: GitHubCredentials): string { + const now = Math.floor(Date.now() / 1000) + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url") + const payload = Buffer.from( + JSON.stringify({ iat: now - 60, exp: now + 540, iss: credentials.appId }), + ).toString("base64url") + const unsigned = `${header}.${payload}` + return `${unsigned}.${sign("RSA-SHA256", Buffer.from(unsigned), credentials.privateKey).toString("base64url")}` +} diff --git a/hub/server/integrations/github/create-authorization-url/index.ts b/hub/server/integrations/github/create-authorization-url/index.ts new file mode 100644 index 0000000..9705d12 --- /dev/null +++ b/hub/server/integrations/github/create-authorization-url/index.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function createAuthorizationUrl( + origin: string, + credentials: GitHubCredentials, + state: string, + verifier: string, +): string { + const url = new URL("https://github.com/login/oauth/authorize") + url.search = new URLSearchParams({ + client_id: credentials.clientId, + redirect_uri: `${origin}/api/auth/github/callback`, + state, + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + }).toString() + return url.href +} diff --git a/hub/server/integrations/github/create-identity-client/index.test.ts b/hub/server/integrations/github/create-identity-client/index.test.ts new file mode 100644 index 0000000..83d74d6 --- /dev/null +++ b/hub/server/integrations/github/create-identity-client/index.test.ts @@ -0,0 +1,325 @@ +import { createHash, generateKeyPairSync } from "node:crypto" +import { describe, expect, it, vi } from "vitest" + +import { createGitHubIdentityClient } from "." + +function requestUrl(input: Parameters[0]) { + return new URL(input instanceof Request ? input.url : input) +} + +describe("createGitHubIdentityClient()", () => { + it("binds GitHub authorization to the configured callback and PKCE verifier", () => { + const client = createGitHubIdentityClient("https://hub.example.test") + const url = new URL( + client.authorize( + { + appId: "1", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey: "not needed to construct an authorization URL", + }, + "synthetic-state", + "synthetic-verifier", + ), + ) + + expect(url.origin).toBe("https://github.com") + expect(url.searchParams.get("redirect_uri")).toBe( + "https://hub.example.test/api/auth/github/callback", + ) + expect(url.searchParams.get("state")).toBe("synthetic-state") + expect(url.searchParams.get("code_challenge_method")).toBe("S256") + expect(url.searchParams.get("code_challenge")).toBe( + createHash("sha256").update("synthetic-verifier").digest("base64url"), + ) + expect(url.href).not.toContain("synthetic-secret") + }) + + it("uses the user token only to identify the permanent GitHub account", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ access_token: "synthetic-user-token" })) + .mockResolvedValueOnce( + Response.json({ + id: 42, + login: "mona", + name: null, + avatar_url: "https://avatars.example.test/mona", + }), + ) + vi.stubGlobal("fetch", fetch) + const client = createGitHubIdentityClient("https://hub.example.test") + + const profile = await client.identify( + { + appId: "1", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey: "unused", + }, + "synthetic-code", + "synthetic-verifier", + ) + + expect(profile).toEqual({ + providerId: "42", + login: "mona", + name: null, + avatarUrl: "https://avatars.example.test/mona", + }) + expect(fetch.mock.calls[0]?.[1]?.body).toContain('"code_verifier":"synthetic-verifier"') + expect(fetch.mock.calls[1]?.[0]).toBe("https://api.github.com/user") + expect(fetch.mock.calls[1]?.[1]?.headers).toMatchObject({ + Authorization: "Bearer synthetic-user-token", + }) + }) + + it("rejects a collaborator response for a different immutable GitHub identity", async () => { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }) + const fetch = vi.fn((url) => { + const path = requestUrl(url).pathname + if (path.endsWith("/installation")) return Promise.resolve(Response.json({ id: 8 })) + if (path.endsWith("/access_tokens")) { + return Promise.resolve(Response.json({ token: "synthetic-installation-token" })) + } + return Promise.resolve(Response.json({ permission: "admin", user: { id: 99 } })) + }) + vi.stubGlobal("fetch", fetch) + const client = createGitHubIdentityClient("https://hub.example.test") + + await expect( + client.permission( + { + appId: "1", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey, + }, + { providerId: "42", login: "mona", name: null, avatarUrl: null }, + "team", + "prototype", + ), + ).rejects.toMatchObject({ code: "identity_mismatch" }) + expect(fetch.mock.calls.map(([url]) => requestUrl(url).pathname)).toEqual([ + "/app/installations/7/access_tokens", + "/repos/team/prototype/collaborators/mona/permission", + ]) + expect(fetch.mock.calls[1]?.[1]?.headers).toMatchObject({ + Authorization: "Bearer synthetic-installation-token", + }) + }) + + it("admits a collaborator from a later repository page of only the configured installation", async () => { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }) + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + full_name: `team/repository-${index}`, + })) + const fetch = vi.fn((url) => { + const path = requestUrl(url) + if (path.pathname === "/app/installations") { + return Promise.resolve(Response.json([{ id: 8 }])) + } + if (path.pathname.endsWith("/access_tokens")) { + return Promise.resolve(Response.json({ token: "synthetic-configured-token" })) + } + if (path.pathname === "/installation/repositories") { + return Promise.resolve( + Response.json({ + repositories: + path.searchParams.get("page") === "1" + ? firstPage + : [{ full_name: "team/accessible" }], + }), + ) + } + return Promise.resolve( + Response.json({ + permission: path.pathname.includes("/accessible/") ? "write" : "none", + user: { id: 42 }, + }), + ) + }) + vi.stubGlobal("fetch", fetch) + const client = createGitHubIdentityClient("https://hub.example.test") + + await expect( + client.admitted( + { + appId: "1", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey, + }, + { providerId: "42", login: "mona", name: null, avatarUrl: null }, + ), + ).resolves.toBe(true) + const paths = fetch.mock.calls.map(([url]) => { + const parsed = requestUrl(url) + return `${parsed.pathname}${parsed.search}` + }) + expect(paths.filter((path) => path.startsWith("/app/"))).toEqual([ + "/app/installations/7/access_tokens", + ]) + expect(paths.filter((path) => path.startsWith("/installation/"))).toEqual([ + "/installation/repositories?per_page=100&page=1", + "/installation/repositories?per_page=100&page=2", + ]) + for (const [url, options] of fetch.mock.calls) { + if (!requestUrl(url).pathname.startsWith("/app/")) { + expect(options?.headers).toMatchObject({ + Authorization: "Bearer synthetic-configured-token", + }) + } + } + }) + + it("does not admit a user through another installation when the configured installation has no repositories", async () => { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }) + const fetch = vi.fn((url) => { + const path = requestUrl(url).pathname + if (path === "/app/installations") return Promise.resolve(Response.json([{ id: 8 }])) + if (path.endsWith("/access_tokens")) { + return Promise.resolve(Response.json({ token: "synthetic-token" })) + } + if (path === "/installation/repositories") { + return Promise.resolve(Response.json({ repositories: [] })) + } + throw new Error("Unexpected GitHub request") + }) + vi.stubGlobal("fetch", fetch) + const client = createGitHubIdentityClient("https://hub.example.test") + + await expect( + client.admitted( + { + appId: "1", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey, + }, + { providerId: "42", login: "mona", name: null, avatarUrl: null }, + ), + ).resolves.toBe(false) + expect(fetch.mock.calls.map(([url]) => requestUrl(url).pathname)).toEqual([ + "/app/installations/7/access_tokens", + "/installation/repositories", + ]) + }) + + it("does not turn a GitHub outage into a repository permission", async () => { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }) + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))) + const client = createGitHubIdentityClient("https://hub.example.test") + + await expect( + client.permission( + { + appId: "1", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey, + }, + { providerId: "42", login: "mona", name: null, avatarUrl: null }, + "team", + "prototype", + ), + ).rejects.toMatchObject({ code: "github_unavailable", upstreamStatus: 0 }) + }) + + it("denies repository access missing from the configured installation without discovering another", async () => { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }) + const fetch = vi.fn((url) => { + const path = requestUrl(url).pathname + if (path.endsWith("/installation")) return Promise.resolve(Response.json({ id: 8 })) + if (path.endsWith("/access_tokens")) { + return Promise.resolve(Response.json({ token: "synthetic-configured-token" })) + } + return Promise.resolve(Response.json({ message: "Not Found" }, { status: 404 })) + }) + vi.stubGlobal("fetch", fetch) + const client = createGitHubIdentityClient("https://hub.example.test") + + await expect( + client.permission( + { + appId: "1", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey, + }, + { providerId: "42", login: "mona", name: null, avatarUrl: null }, + "outside", + "repository", + ), + ).resolves.toBe("none") + expect(fetch.mock.calls.map(([url]) => requestUrl(url).pathname)).toEqual([ + "/app/installations/7/access_tokens", + "/repos/outside/repository/collaborators/mona/permission", + ]) + expect(fetch.mock.calls[1]?.[1]?.headers).toMatchObject({ + Authorization: "Bearer synthetic-configured-token", + }) + }) + + it("fails closed for admission and repository permission when the configured installation is removed", async () => { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }) + const credentials = { + appId: "1", + installationId: "7", + clientId: "synthetic-client", + clientSecret: "synthetic-secret", + privateKey, + } + const profile = { providerId: "42", login: "mona", name: null, avatarUrl: null } + const fetch = vi.fn(() => + Promise.resolve(Response.json({ message: "Not Found" }, { status: 404 })), + ) + vi.stubGlobal("fetch", fetch) + const client = createGitHubIdentityClient("https://hub.example.test") + + for (const operation of [ + () => client.admitted(credentials, profile), + () => client.permission(credentials, profile, "team", "prototype"), + ]) { + fetch.mockClear() + await expect(operation()).rejects.toMatchObject({ + code: "github_unavailable", + upstreamStatus: 404, + }) + expect(fetch.mock.calls.map(([url]) => requestUrl(url).pathname)).toEqual([ + "/app/installations/7/access_tokens", + ]) + } + }) +}) diff --git a/hub/server/integrations/github/create-identity-client/index.ts b/hub/server/integrations/github/create-identity-client/index.ts new file mode 100644 index 0000000..ae0a52d --- /dev/null +++ b/hub/server/integrations/github/create-identity-client/index.ts @@ -0,0 +1,14 @@ +import type { GitHubIdentityClient } from "#server/integrations/github/create-identity-client/types" +import { createAuthorizationUrl } from "#server/integrations/github/create-authorization-url" +import { getRepositoryPermission } from "#server/integrations/github/get-repository-permission" +import { identifyUser } from "#server/integrations/github/identify-user" +import { isUserAdmitted } from "#server/integrations/github/is-user-admitted" + +export function createGitHubIdentityClient(origin: string): GitHubIdentityClient { + return { + authorize: createAuthorizationUrl.bind(null, origin), + identify: identifyUser.bind(null, origin), + admitted: isUserAdmitted, + permission: getRepositoryPermission, + } +} diff --git a/hub/server/integrations/github/create-identity-client/types.ts b/hub/server/integrations/github/create-identity-client/types.ts new file mode 100644 index 0000000..b7ee6ae --- /dev/null +++ b/hub/server/integrations/github/create-identity-client/types.ts @@ -0,0 +1,15 @@ +import type { RepositoryPermission } from "#server/integrations/github/get-repository-permission/types" +import type { GitHubProfile } from "#server/integrations/github/identify-user/types" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export interface GitHubIdentityClient { + authorize(credentials: GitHubCredentials, state: string, verifier: string): string + identify(credentials: GitHubCredentials, code: string, verifier: string): Promise + admitted(credentials: GitHubCredentials, profile: GitHubProfile): Promise + permission( + credentials: GitHubCredentials, + profile: GitHubProfile, + owner: string, + repository: string, + ): Promise +} diff --git a/hub/server/integrations/github/create-installation-token/constants.ts b/hub/server/integrations/github/create-installation-token/constants.ts new file mode 100644 index 0000000..1c01e5d --- /dev/null +++ b/hub/server/integrations/github/create-installation-token/constants.ts @@ -0,0 +1,3 @@ +import { z } from "zod" + +export const installationTokenSchema = z.object({ token: z.string().min(1) }) diff --git a/hub/server/integrations/github/create-installation-token/index.ts b/hub/server/integrations/github/create-installation-token/index.ts new file mode 100644 index 0000000..64c29d5 --- /dev/null +++ b/hub/server/integrations/github/create-installation-token/index.ts @@ -0,0 +1,13 @@ +import { createAppToken } from "#server/integrations/github/create-app-token" +import { installationTokenSchema } from "#server/integrations/github/create-installation-token/constants" +import { requestGitHub } from "#server/integrations/github/request-github" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export async function createInstallationToken(credentials: GitHubCredentials): Promise { + const data = await requestGitHub( + `/app/installations/${credentials.installationId}/access_tokens`, + createAppToken(credentials), + "POST", + ) + return installationTokenSchema.parse(data).token +} diff --git a/hub/server/integrations/github/get-collaborator-permission/constants.ts b/hub/server/integrations/github/get-collaborator-permission/constants.ts new file mode 100644 index 0000000..78a6618 --- /dev/null +++ b/hub/server/integrations/github/get-collaborator-permission/constants.ts @@ -0,0 +1,6 @@ +import { z } from "zod" + +export const collaboratorPermissionSchema = z.object({ + permission: z.enum(["admin", "write", "read", "maintain", "triage", "none"]), + user: z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) }), +}) diff --git a/hub/server/integrations/github/get-collaborator-permission/index.ts b/hub/server/integrations/github/get-collaborator-permission/index.ts new file mode 100644 index 0000000..1aec6f9 --- /dev/null +++ b/hub/server/integrations/github/get-collaborator-permission/index.ts @@ -0,0 +1,33 @@ +import { collaboratorPermissionSchema } from "#server/integrations/github/get-collaborator-permission/constants" +import { GitHubResponseError } from "#server/integrations/github/github-response-error" +import { requestGitHub } from "#server/integrations/github/request-github" +import type { GitHubProfile } from "#server/integrations/github/identify-user/types" +import type { RepositoryPermission } from "#server/integrations/github/get-repository-permission/types" +import { GitHubError } from "#server/integrations/github/github-error" + +export async function getCollaboratorPermission( + token: string, + profile: GitHubProfile, + fullName: string, +): Promise { + let data: unknown + try { + data = await requestGitHub( + `/repos/${fullName}/collaborators/${encodeURIComponent(profile.login)}/permission`, + token, + ) + } catch (error) { + if (error instanceof GitHubResponseError && error.upstreamStatus === 404) return "none" + throw error + } + const result = collaboratorPermissionSchema.parse(data) + if (String(result.user.id) !== profile.providerId) { + throw new GitHubError( + "identity_mismatch", + "GitHub's repository permission response did not match the signed-in account.", + ) + } + if (result.permission === "maintain") return "write" + if (result.permission === "triage") return "read" + return result.permission +} diff --git a/hub/server/integrations/github/get-repository-permission/index.ts b/hub/server/integrations/github/get-repository-permission/index.ts new file mode 100644 index 0000000..c934c6c --- /dev/null +++ b/hub/server/integrations/github/get-repository-permission/index.ts @@ -0,0 +1,15 @@ +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import type { GitHubProfile } from "#server/integrations/github/identify-user/types" +import type { RepositoryPermission } from "#server/integrations/github/get-repository-permission/types" +import { createInstallationToken } from "#server/integrations/github/create-installation-token" +import { getCollaboratorPermission } from "#server/integrations/github/get-collaborator-permission" + +export async function getRepositoryPermission( + credentials: GitHubCredentials, + profile: GitHubProfile, + owner: string, + repository: string, +): Promise { + const fullName = `${encodeURIComponent(owner)}/${encodeURIComponent(repository)}` + return getCollaboratorPermission(await createInstallationToken(credentials), profile, fullName) +} diff --git a/hub/server/integrations/github/get-repository-permission/types.ts b/hub/server/integrations/github/get-repository-permission/types.ts new file mode 100644 index 0000000..7b2807e --- /dev/null +++ b/hub/server/integrations/github/get-repository-permission/types.ts @@ -0,0 +1 @@ +export type RepositoryPermission = "none" | "read" | "write" | "admin" diff --git a/hub/server/integrations/github/github-error/types.ts b/hub/server/integrations/github/github-error/types.ts index a5fdfb9..4974ce4 100644 --- a/hub/server/integrations/github/github-error/types.ts +++ b/hub/server/integrations/github/github-error/types.ts @@ -1 +1,6 @@ -export type GitHubErrorCode = "invalid_credentials" +export type GitHubErrorCode = + | "github_limit" + | "github_sign_in_failed" + | "github_unavailable" + | "identity_mismatch" + | "invalid_credentials" diff --git a/hub/server/integrations/github/github-response-error/index.ts b/hub/server/integrations/github/github-response-error/index.ts new file mode 100644 index 0000000..4e90a81 --- /dev/null +++ b/hub/server/integrations/github/github-response-error/index.ts @@ -0,0 +1,10 @@ +import { GitHubError } from "#server/integrations/github/github-error" + +export class GitHubResponseError extends GitHubError { + upstreamStatus: number + + constructor(upstreamStatus: number) { + super("github_unavailable", "GitHub could not verify identity or repository access. Try again.") + this.upstreamStatus = upstreamStatus + } +} diff --git a/hub/server/integrations/github/identify-user/constants.ts b/hub/server/integrations/github/identify-user/constants.ts new file mode 100644 index 0000000..be952e0 --- /dev/null +++ b/hub/server/integrations/github/identify-user/constants.ts @@ -0,0 +1,9 @@ +import { z } from "zod" + +export const accessTokenSchema = z.object({ access_token: z.string().min(1) }) +export const profileSchema = z.object({ + id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + login: z.string().min(1).max(100), + name: z.string().nullable(), + avatar_url: z.url(), +}) diff --git a/hub/server/integrations/github/identify-user/index.ts b/hub/server/integrations/github/identify-user/index.ts new file mode 100644 index 0000000..4b9119d --- /dev/null +++ b/hub/server/integrations/github/identify-user/index.ts @@ -0,0 +1,50 @@ +import { GitHubResponseError } from "#server/integrations/github/github-response-error" +import { + accessTokenSchema, + profileSchema, +} from "#server/integrations/github/identify-user/constants" +import { requestGitHub } from "#server/integrations/github/request-github" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import type { GitHubProfile } from "#server/integrations/github/identify-user/types" +import { GitHubError } from "#server/integrations/github/github-error" + +export async function identifyUser( + origin: string, + credentials: GitHubCredentials, + code: string, + verifier: string, +): Promise { + let response: Response + try { + response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + code, + code_verifier: verifier, + redirect_uri: `${origin}/api/auth/github/callback`, + }), + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new GitHubResponseError(0) + } + if (!response.ok) throw new GitHubResponseError(response.status) + const result = accessTokenSchema.safeParse(await response.json()) + if (!result.success) { + throw new GitHubError( + "github_sign_in_failed", + "GitHub did not authorize this sign-in. Start again.", + ) + } + const profile = profileSchema.parse(await requestGitHub("/user", result.data.access_token)) + return { + providerId: String(profile.id), + login: profile.login, + name: profile.name, + avatarUrl: profile.avatar_url, + } +} diff --git a/hub/server/integrations/github/identify-user/types.ts b/hub/server/integrations/github/identify-user/types.ts new file mode 100644 index 0000000..d29c660 --- /dev/null +++ b/hub/server/integrations/github/identify-user/types.ts @@ -0,0 +1,6 @@ +export interface GitHubProfile { + providerId: string + login: string + name: string | null + avatarUrl: string | null +} diff --git a/hub/server/integrations/github/is-user-admitted/constants.ts b/hub/server/integrations/github/is-user-admitted/constants.ts new file mode 100644 index 0000000..b029f29 --- /dev/null +++ b/hub/server/integrations/github/is-user-admitted/constants.ts @@ -0,0 +1,5 @@ +import { z } from "zod" + +export const repositoriesSchema = z.object({ + repositories: z.array(z.object({ full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/) })), +}) diff --git a/hub/server/integrations/github/is-user-admitted/index.ts b/hub/server/integrations/github/is-user-admitted/index.ts new file mode 100644 index 0000000..df37097 --- /dev/null +++ b/hub/server/integrations/github/is-user-admitted/index.ts @@ -0,0 +1,29 @@ +import { createInstallationToken } from "#server/integrations/github/create-installation-token" +import { getCollaboratorPermission } from "#server/integrations/github/get-collaborator-permission" +import { repositoriesSchema } from "#server/integrations/github/is-user-admitted/constants" +import { requestGitHub } from "#server/integrations/github/request-github" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import type { GitHubProfile } from "#server/integrations/github/identify-user/types" +import { GitHubError } from "#server/integrations/github/github-error" + +export async function isUserAdmitted( + credentials: GitHubCredentials, + profile: GitHubProfile, +): Promise { + const token = await createInstallationToken(credentials) + for (let page = 1; page <= 100; page++) { + const data = repositoriesSchema.parse( + await requestGitHub(`/installation/repositories?per_page=100&page=${page}`, token), + ) + for (const repository of data.repositories) { + if ((await getCollaboratorPermission(token, profile, repository.full_name)) !== "none") { + return true + } + } + if (data.repositories.length < 100) return false + } + throw new GitHubError( + "github_limit", + "GitHub repository discovery exceeded the supported page limit.", + ) +} diff --git a/hub/server/integrations/github/request-github/index.ts b/hub/server/integrations/github/request-github/index.ts new file mode 100644 index 0000000..1d5bd8a --- /dev/null +++ b/hub/server/integrations/github/request-github/index.ts @@ -0,0 +1,21 @@ +import { GitHubResponseError } from "#server/integrations/github/github-response-error" + +export async function requestGitHub(path: string, token: string, method = "GET"): Promise { + let response: Response + try { + response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new GitHubResponseError(0) + } + if (!response.ok) throw new GitHubResponseError(response.status) + return response.json() +} diff --git a/hub/server/utils/digest-secret/index.ts b/hub/server/utils/digest-secret/index.ts new file mode 100644 index 0000000..1440f6a --- /dev/null +++ b/hub/server/utils/digest-secret/index.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto" + +export function digestSecret(value: string): string { + return createHash("sha256").update(value).digest("hex") +} diff --git a/hub/server/utils/secrets-match/index.ts b/hub/server/utils/secrets-match/index.ts new file mode 100644 index 0000000..256f435 --- /dev/null +++ b/hub/server/utils/secrets-match/index.ts @@ -0,0 +1,7 @@ +import { timingSafeEqual } from "node:crypto" + +import { digestSecret } from "#server/utils/digest-secret" + +export function secretsMatch(actual: string, expected: string): boolean { + return timingSafeEqual(Buffer.from(digestSecret(actual)), Buffer.from(digestSecret(expected))) +}