diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..63dfdc7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 100 + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8b486a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +# Every pull request, and the release before it stages anything (release.yml +# calls this workflow): format (Oxfmt), lint (Oxlint), type-check, the unit +# tests and the plugin SDK's contract test kit (checkAuthMethod), the build +# and the tarball's file list; then the end-to-end suite (e2e/run.sh), which +# runs the released QAuth server image with this plugin installed on top, +# against a mock GitHub. pnpm's version is package.json's packageManager. +name: ci + +on: + pull_request: + push: + branches: [main] + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + # The Node QAuth's server image runs, and the plugin with it. + node-version: 26 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run format:check + - run: pnpm run lint + - run: pnpm run typecheck + - name: Unit tests and the SDK's contract test kit (checkAuthMethod) + run: pnpm test + - run: pnpm run build + - name: The files npm would receive + run: | + set -euo pipefail + pnpm pack --pack-destination "$RUNNER_TEMP/pack" + tar -tzf "$RUNNER_TEMP"/pack/*.tgz + + e2e: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 26 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: The released server image plus the plugin, against a mock GitHub + run: bash e2e/run.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf0f5e3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,162 @@ +# The npm release of @qauth-labs/plugin-github, from a tag (RELEASING.md). +# +# A tag vX.Y.Z or vX.Y.Z-rc.N starts it. The tag must name the version in +# package.json. The whole CI runs first (ci.yml: the checks, the contract test +# kit, the end-to-end suite). Then the package is packed once, and that +# tarball, checked by its digest, is staged on npm through trusted publishing +# (OIDC, no token) in the `npm` environment; npm attaches provenance on its +# own. A release candidate goes to the `next` dist-tag, a release to +# `latest`. The staged version goes live only when a maintainer approves it +# on npmjs.com with 2FA. The run leaves a draft GitHub release with the +# tarball and the version's CHANGELOG section. +# +# npm's trusted publisher for @qauth-labs/plugin-github names this file: +# release.yml, environment npm. +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + ci: + uses: ./.github/workflows/ci.yml + + pack: + needs: ci + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.plan.outputs.version }} + dist-tag: ${{ steps.plan.outputs.dist-tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The whole history: the tagged commit must be on main. + fetch-depth: 0 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 26 + cache: pnpm + - name: The tag points at a commit on main + run: | + set -euo pipefail + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "The tag points at $GITHUB_SHA, which is not on main: only a reviewed, merged commit is released." >&2 + exit 1 + fi + - id: plan + name: The tag names the package's version; the dist-tag it goes to + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="$(node -p 'require("./package.json").version')" + if [ "$TAG" != "v$version" ]; then + echo "The tag $TAG does not name the package's version $version." >&2 + exit 1 + fi + case "$version" in + *-rc.*) dist_tag=next ;; + *-*) echo "Only a release candidate (-rc.N) or a release is published: $version." >&2; exit 1 ;; + *) dist_tag=latest ;; + esac + if ! grep -q "^## \[$version\]" CHANGELOG.md; then + echo "CHANGELOG.md has no section for $version." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "dist-tag=$dist_tag" >> "$GITHUB_OUTPUT" + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - name: Pack it as npm will receive it + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/pack" + pnpm pack --pack-destination "$RUNNER_TEMP/pack" + cd "$RUNNER_TEMP/pack" + tarball="$(ls ./*.tgz)" + sha256sum "$tarball" > "$tarball.sha256" + tar -tzf "$tarball" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package + path: ${{ runner.temp }}/pack/ + if-no-files-found: error + retention-days: 7 + + publish: + needs: pack + runs-on: ubuntu-24.04 + environment: npm + permissions: + contents: read + id-token: write + env: + DIST_TAG: ${{ needs.pack.outputs.dist-tag }} + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: package + path: pack + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 26 + registry-url: https://registry.npmjs.org + # pnpm builds and packs; npm stages the tarball, as QAuth's release-npm.yml does. + - name: npm with staged publishing through OIDC + run: npm install --global npm@11.21.0 + - name: The tarball is the one the pack job made + working-directory: pack + run: sha256sum --check ./*.sha256 + - name: Stage it on npm; a maintainer approves it there, with 2FA + working-directory: pack + run: npm stage publish ./*.tgz --tag "$DIST_TAG" --access public + + draft-release: + needs: [pack, publish] + runs-on: ubuntu-24.04 + permissions: + contents: write + env: + VERSION: ${{ needs.pack.outputs.version }} + DIST_TAG: ${{ needs.pack.outputs.dist-tag }} + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: package + path: pack + - name: Draft the release with the tarball and the version's changelog section + run: | + set -euo pipefail + awk -v heading="## [$VERSION]" ' + index($0, heading) == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > "$RUNNER_TEMP/notes.md" + { + echo + echo "---" + echo + echo "Staged on npm as \`$VERSION\` (dist-tag \`$DIST_TAG\`), with provenance. It goes" + echo "live when a maintainer approves the stage on npmjs.com with 2FA; publish this" + echo "draft after that." + } >> "$RUNNER_TEMP/notes.md" + prerelease=() + case "$VERSION" in *-rc.*) prerelease=(--prerelease) ;; esac + gh release create "$TAG" pack/*.tgz pack/*.sha256 \ + --draft --verify-tag --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md" "${prerelease[@]}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f24e92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +coverage/ +*.tgz +# The end-to-end suite's generated certificates, allowlist and logs. +e2e/.work/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..c853781 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,17 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + // QAuth's own formatting options (qauth-labs/qauth's .oxfmtrc.json). + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf", + "sortPackageJson": false, + // The order of imports, checked by `oxfmt --check` and fixed by `oxfmt`. + "sortImports": true, + // Gitignore-style; .gitignore applies too. + "ignorePatterns": ["node_modules", "dist", "coverage", "*.log", "pnpm-lock.yaml", "e2e/.work"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..e668d75 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,125 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + // QAuth's own rule set (qauth-labs/qauth's .oxlintrc.json): @eslint/js and + // typescript-eslint `recommended` as Nx configures them, less the Nx module + // boundaries and QAuth's key custody guard, which concern QAuth's own + // workspace. Oxlint's default category is off, so nothing else runs. + // Formatting and the order of imports are Oxfmt's job (.oxfmtrc.json): no + // ESLint plugin, so no ESLint in the tree. + "plugins": ["eslint", "typescript"], + "categories": { + "correctness": "off" + }, + "ignorePatterns": ["**/coverage/**", "**/dist/**", "**/node_modules/**", "e2e/.work/**"], + "rules": { + "for-direction": "error", + "no-async-promise-executor": "error", + "no-case-declarations": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-constant-binary-expression": "error", + "no-constant-condition": ["error", { "checkLoops": "allExceptWhileTrue" }], + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-else-if": "error", + "no-duplicate-case": "error", + "no-empty": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-empty-static-block": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-fallthrough": "error", + "no-global-assign": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-loss-of-precision": "error", + "no-misleading-character-class": "error", + "no-nonoctal-decimal-escape": "error", + "no-prototype-builtins": "error", + "no-regex-spaces": "error", + "no-self-assign": "error", + "no-shadow-restricted-names": ["error", { "reportGlobalThis": true }], + "no-sparse-arrays": "error", + "no-unassigned-vars": "error", + "no-unsafe-finally": "error", + "no-unsafe-optional-chaining": "error", + "no-unused-labels": "error", + "no-unused-private-class-members": "error", + "no-useless-assignment": "error", + "no-useless-backreference": "error", + "no-useless-catch": "error", + "no-useless-escape": "error", + "no-var": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "preserve-caught-error": "error", + "require-yield": "error", + "use-isnan": "error", + "valid-typeof": "error", + + "no-array-constructor": "error", + "no-empty-function": "error", + "no-unused-expressions": "error", + "no-unused-vars": [ + "warn", + { "vars": "all", "args": "after-used", "caughtErrors": "all", "ignoreRestSiblings": false } + ], + "typescript/adjacent-overload-signatures": "error", + "typescript/ban-ts-comment": "error", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-empty-interface": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-explicit-any": "warn", + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-inferrable-types": "error", + "typescript/no-misused-new": "error", + "typescript/no-namespace": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-non-null-assertion": "warn", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/triple-slash-reference": "error" + }, + "overrides": [ + { + // Every outbound call goes through the egress client the server hands + // the plugin (ADR-019 Decision 8), never a network API of its own. + "files": ["src/**/*.ts"], + "rules": { + "no-restricted-globals": [ + "error", + { + "name": "fetch", + "message": "Use context.egress: every call goes through the egress client." + } + ], + "no-restricted-imports": [ + "error", + { + "paths": [ + { "name": "node:http", "message": "Use context.egress." }, + { "name": "node:https", "message": "Use context.egress." }, + { "name": "node:net", "message": "Use context.egress." }, + { "name": "node:tls", "message": "Use context.egress." }, + { "name": "node:dgram", "message": "Use context.egress." }, + { "name": "http", "message": "Use context.egress." }, + { "name": "https", "message": "Use context.egress." }, + { "name": "net", "message": "Use context.egress." }, + { "name": "tls", "message": "Use context.egress." }, + { "name": "dgram", "message": "Use context.egress." }, + { "name": "undici", "message": "Use context.egress." } + ] + } + ] + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e325067 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to `@qauth-labs/plugin-github` are recorded here. The +format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The +package has its own version line, separate from QAuth's: 0.x while QAuth's +plugin API is experimental, with a `-rc.N` release candidate before each +release (RELEASING.md). + +## [Unreleased] + +## [0.1.0-rc.0] + +The first release candidate. Experimental: QAuth loads it only with +`PLUGIN_API_ENABLED=true`, and its plugin API may change before QAuth 1.0. + +### Added + +- Sign-in with GitHub as a QAuth AuthMethod plugin (contract + `qauth-auth-method/1-rc.0`), for QAuth 0.2.0-rc.1 and later on the 0.2 line. + It depends only on `@qauth-labs/plugin-sdk`. +- The OAuth 2.0 authorization code flow with `state` and PKCE (S256), on a + callback URL of this provider alone (`/upstream/github/callback`). The code + is redeemed at the token endpoint asking for JSON. +- The person is read from `GET /user` and `GET /user/emails`, with the + `user:email` scope and nothing more. The account is keyed by the numeric + user id under the GitHub instance that issued it, never by the login name. +- The email address becomes a verified attribute only when GitHub marks it + both primary and verified. The login name and the display name are stored + as unverified attributes. +- The access token is used for those two reads of one sign-in and dropped: + it is never stored, logged or handed to the server. +- A `baseUrl` for GitHub Enterprise Server. A private address is reached only + when the operator's egress allowlist names it for the realm. +- CI with the plugin SDK's contract test kit, and an end-to-end suite that + runs the released QAuth server image with the plugin installed, against a + mock GitHub. +- A release workflow that stages the package on npm through trusted + publishing, for a maintainer's approval with 2FA. + +[Unreleased]: https://github.com/qauth-labs/plugin-github/compare/v0.1.0-rc.0...HEAD +[0.1.0-rc.0]: https://github.com/qauth-labs/plugin-github/releases/tag/v0.1.0-rc.0 diff --git a/README.md b/README.md index 68a9053..b75f5b8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,259 @@ -# qauth-plugin-github -GitHub sign-in for QAuth, as an AuthMethod plugin (experimental) +# @qauth-labs/plugin-github + +> **Experimental.** This plugin runs on QAuth's experimental plugin API. QAuth +> loads it only while `PLUGIN_API_ENABLED=true`, and the API, the contract and +> this package may change before QAuth 1.0. Do not rely on it for production +> sign-in yet. + +Sign-in with GitHub for [QAuth](https://github.com/qauth-labs/qauth), as an +AuthMethod plugin. A realm that enables it offers "GitHub" as an upstream +sign-in: the person signs in on GitHub (or a GitHub Enterprise Server), and +QAuth signs them into the account that belongs to their GitHub user id. +Applications keep receiving QAuth's own OAuth 2.1 and OpenID Connect tokens. + +The plugin is made and released by the QAuth project, in its own repository, +the way a third party writes a plugin: it depends only on the published +[`@qauth-labs/plugin-sdk`](https://www.npmjs.com/package/@qauth-labs/plugin-sdk) +and has its own version line. + +## Compatibility + +| Plugin | QAuth server | AuthMethod contract | Plugin SDK | +| ------------ | ------------------------------- | -------------------------- | ------------ | +| `0.1.0-rc.0` | `0.2.0-rc.1` and later on `0.2` | `qauth-auth-method/1-rc.0` | `0.2.0-rc.1` | + +## How it signs a person in + +GitHub has no OpenID Connect sign-in: it issues no ID token and takes no +`nonce`. The plugin uses OAuth 2.0 and GitHub's REST API instead. + +1. **Authorization request.** The plugin sends the browser to + `/login/oauth/authorize` with a fresh `state` and a PKCE challenge + (S256), asking for the `user:email` scope and nothing else. The + `redirect_uri` is this provider's own callback, + `https:///upstream/github/callback`. +2. **Callback.** QAuth checks the returned `state` against the sign-in's flow + cookie before the plugin runs; the plugin checks it once more. With no ID + token, the `state`, the flow cookie and a callback URL that only this + provider uses are what tie GitHub's answer to the sign-in that asked for + it. +3. **Token exchange.** The code is redeemed at `/login/oauth/access_token` + with the PKCE verifier, the client id and secret, asking for JSON + (`Accept: application/json`). +4. **The person.** With the access token, the plugin reads `GET /user` (the + numeric id, the login, the display name) and `GET /user/emails` (the + addresses, which is what `user:email` allows). Then it drops the token. + +**The account is the numeric GitHub user id**, under the GitHub instance that +issued it (`https://github.com`, or the Enterprise Server's base URL). It is +never the login name: a login can be renamed and then taken by someone else. +A renamed login, or a changed email address, signs in the same account. + +**Attributes.** When the account is created, QAuth stores: + +| Attribute | From | Verified | +| -------------------- | ----------------------------------------- | ------------------------------------------- | +| `email` | the address GitHub marks **primary** | only when GitHub also marks it **verified** | +| `preferred_username` | the login name | never | +| `name` | the display name, when the person set one | never | + +Only the primary address is taken. An address that is verified but not +primary is not used, and a list with no primary address, or two, gives no +email at all. + +**The access token** is used for the two reads of one sign-in, and nowhere +else. It is never stored, logged or handed to QAuth. Each call that carries a +credential (the code and the client secret, or the access token) follows no +redirect. + +**Outbound calls.** Every call goes through the egress client QAuth hands the +plugin, never a network API of its own; the plugin holds no key. + +### PKCE + +The plugin always sends PKCE with S256 and the verifier at the token endpoint. + +- **github.com** accepts PKCE (S256) for OAuth apps and GitHub Apps since + GitHub's changelog of 2025-07-14, in the + [web application flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow). + It checks the verifier when the authorization request carried a challenge. +- **GitHub Enterprise Server** releases from before that change do not + support PKCE; they ignore the parameters they do not know (RFC 6749 §3.1). + There the sign-in rests on `state`, the flow cookie and the client secret, + as it did before PKCE. Check your release's documentation. + +The end-to-end suite runs against a mock that enforces PKCE the way +github.com does. It has not been run against github.com itself in CI, which +would need a real account. + +## Install it (operators) + +You need QAuth `0.2.0-rc.1` or later, and an image you build on top of +QAuth's released one. + +### 1. Register the app on GitHub + +Create an OAuth app (or a GitHub App) on github.com or on your Enterprise +Server: + +- **Callback URL**: `https:///upstream/github/callback`, one per + realm host. +- **OAuth app**: nothing else to set. The plugin asks for `user:email`. +- **GitHub App**: give it the account permission **Email addresses: read-only** + and no other permission. + +Keep the client id and generate a client secret. + +### 2. Add the package to the server image + +The image is a self-contained tree under `/app`. Install the plugin into a +separate stage, nested, so it brings its own copy of the plugin SDK, and copy +it in. `--omit=peer` makes it use the server's own `zod`; `--ignore-scripts` +runs no install script. Pin the server image by digest and the plugin by +version, and install from a lockfile you have read, so every build gets the +same dependency tree. Write the lockfile once, in a `plugins/` directory of +your build context, without installing anything: + +```bash +mkdir plugins && cd plugins +npm init --yes > /dev/null +npm install --package-lock-only --save-exact --omit=dev --omit=peer \ + --install-strategy=nested --ignore-scripts @qauth-labs/plugin-github@0.1.0-rc.0 +``` + +Read `package-lock.json` and commit both files. Then: + +```dockerfile +ARG QAUTH_IMAGE=ghcr.io/qauth-labs/qauth/auth-server:0.2.0-rc.1@sha256: + +# The build stage writes the files the server will run: pin it by digest too. +FROM node:26-alpine@sha256: AS plugin +WORKDIR /plugin +COPY plugins/package.json plugins/package-lock.json ./ +RUN npm ci --omit=dev --omit=peer --install-strategy=nested --ignore-scripts + +FROM ${QAUTH_IMAGE} +COPY --from=plugin --chown=nodejs:nodejs \ + /plugin/node_modules/@qauth-labs/plugin-github \ + /app/node_modules/@qauth-labs/plugin-github +``` + +Rebuild it at every QAuth upgrade, with the lockfile moved to the plugin +version made for that release by the same `npm install --package-lock-only`. +To know the package was built by this +repository's release workflow, read the version's provenance on npmjs.com, +or the attestation at +`npm view @qauth-labs/plugin-github@ dist.attestations.url`, and +check that it names `qauth-labs/plugin-github` and +`.github/workflows/release.yml`. `npm audit signatures` alone checks that the +signatures and attestations are valid, not who built the package. QAuth's +own docs describe the recipe +under "Operate → Images → Add a plugin to the image". + +### 3. Turn it on + +```sh +AUTH_METHOD_PLUGINS=@qauth-labs/plugin-github +PLUGIN_API_ENABLED=true +``` + +The boot logs `AuthMethod plugin @qauth-labs/plugin-github loaded: github (github)`. +With `PLUGIN_API_ENABLED` off, the server does not load it and logs why: +`The AuthMethod plugin @qauth-labs/plugin-github is not loaded: it is not a +first-party plugin, and the experimental plugin API is off. …` + +### 4. Configure it for a realm + +Through QAuth's admin API, per realm (`{realm}` is the realm's id, `github` +the plugin's name): + +1. Seal the client secret: + `POST /admin/v1-rc.0/realms/{realm}/plugins/github/secrets/seal` with + `{"path": "clientSecret", "value": ""}`. The answer's + `sealed` value is what the server stores; the secret itself never is. +2. Set the configuration: + `POST /admin/v1-rc.0/realms/{realm}/plugins/github/config` with + `{"version": , "config": {"clientId": ""}, "secrets": {"clientSecret": {"sealed": ""}}}`. + Add `"baseUrl"` to `config` for an Enterprise Server. +3. Enable it: `POST /admin/v1-rc.0/realms/{realm}/plugins/github/enable` with + `{"version": }`. + +Setting the configuration and enabling the plugin are security operations: +each needs an approval (`QAuth-Approval` header), as QAuth's admin API +documentation describes. + +| Setting | Required | What it is | +| -------------- | -------- | ---------------------------------------------------------------------------------------- | +| `clientId` | yes | The app's client id. | +| `clientSecret` | yes | The app's client secret. Write-only: sealed by the server, never shown again. | +| `baseUrl` | no | An Enterprise Server's address, `https://host[:port]`, no path. Absent means github.com. | + +Once enabled, the realm's sign-in offers GitHub, and an application can send +the browser straight to it with `qauth_upstream=github` on +`/oauth/authorize`. A first sign-in creates an account when the realm allows +sign-up. + +### GitHub Enterprise Server + +Set `baseUrl` to the server's address, for example +`https://github.corp.example`. The plugin then uses +`/login/oauth/authorize`, `/login/oauth/access_token` and +the REST API at `/api/v3`. Accounts are keyed under that address, +so the same numeric id on github.com and on your server are two different +people. + +QAuth's egress client refuses private addresses (RFC 1918, unique-local and +the like). If your server is on a private address, the operator names it, for +the realms that may reach it, in the allowlist file `EGRESS_ALLOWLIST_PATH` +points at: + +```json +{ + "entries": [{ "host": "github.corp.example", "realms": ["master"] }] +} +``` + +Without that entry, a sign-in through that server fails with "We could not +sign you in", and the server logs `egress call blocked`. + +## Develop + +The repository uses QAuth's own toolchain: Node 26, pnpm (the version in +`package.json`'s `packageManager`), TypeScript, Oxlint, Oxfmt and Vitest. +pnpm resolves a dependency version only once it has been on the registry +for 7 days (`pnpm-workspace.yaml`), as QAuth does. + +```sh +pnpm install +pnpm run lint && pnpm run format:check && pnpm run typecheck && pnpm test +``` + +`pnpm test` runs the unit tests against a fake GitHub, and the plugin SDK's +contract test kit (`checkAuthMethod`), for github.com and for an Enterprise +Server. + +The end-to-end suite needs docker, openssl, Node and pnpm: + +```sh +pnpm run e2e +``` + +It packs the plugin, builds QAuth's released server image with the plugin +added as above, starts it with PostgreSQL, Redis and a mock GitHub +Enterprise Server, configures the plugin through the admin API and signs in +through the server's pages. It checks that: + +- the first sign-in creates an account keyed by the numeric id; +- a renamed login, and a changed email address, sign in the same account; +- only a primary and verified email becomes a verified attribute; +- the access token is not in the database, the logs or the events; +- a callback with a wrong `state` is refused; +- a private base URL is refused unless the allowlist names it; +- with `PLUGIN_API_ENABLED` off, the boot skips the plugin and says why. + +CI runs both on every pull request. Releases are described in +[RELEASING.md](RELEASING.md). + +## License + +[Apache-2.0](LICENSE) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..c7d9415 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,66 @@ +# Releasing + +`@qauth-labs/plugin-github` is released to npm from a tag of this repository, +by `.github/workflows/release.yml`, through npm's trusted publishing: the +workflow proves who it is with GitHub's OIDC token, holds no npm token, and +npm attaches provenance. The workflow only **stages** the version; it goes +live when a maintainer approves the stage on npmjs.com with 2FA. This is the +way QAuth's own SDK packages are released (`release-npm.yml` in +qauth-labs/qauth). + +## Versions + +The package has its own version line, not QAuth's. + +- `0.x` while QAuth's plugin API is experimental (`PLUGIN_API_ENABLED`). +- Each release is preceded by release candidates, `X.Y.Z-rc.N`, which go to + the `next` dist-tag. A release goes to `latest`. No other pre-release form + is published. +- The README's compatibility table says which QAuth versions and which + AuthMethod contract each version is for. Update it in the same pull request + as the version. + +## Cut a release + +1. In a pull request, set `version` in `package.json` (for example + `0.1.0-rc.1`) and move the `Unreleased` entries of `CHANGELOG.md` under `## [0.1.0-rc.1]`, + update the links at the bottom and the README's compatibility table. Merge + it when CI is green. + +2. Tag the merge commit on `main` and push the tag: + + ```sh + git tag -s v0.1.0-rc.1 -m v0.1.0-rc.1 + git push origin v0.1.0-rc.1 + ``` + +3. `release.yml` runs the whole CI again (checks, contract test kit, + end-to-end suite), checks that the tag points at a commit on `main`, that + it names the version in `package.json` and that `CHANGELOG.md` has its + section, builds and packs the tarball once with pnpm and stages it on npm + (`npm stage publish`), from the `npm` environment. + +4. Approve the stage on npmjs.com with 2FA. Check the version, the dist-tag + and the provenance before approving. + +5. Publish the draft GitHub release the run left, with the tarball and the + changelog section. + +## One-time setup + +npm configures a trusted publisher only for a package that exists. Before the +first release, a maintainer: + +1. Publishes a placeholder by hand, from an empty directory, once: + `@qauth-labs/plugin-github@0.0.0` with only a `package.json` and a README + that points here. +2. On npmjs.com, under the package's settings, adds the trusted publisher: + the organisation `qauth-labs`, the repository `plugin-github`, the + workflow `release.yml`, the environment `npm`. Then sets publishing access + to require 2FA and disallow tokens. +3. In this repository's settings, creates the `npm` environment and limits + it to tags matching `v*`, and adds a tag ruleset that lets only + maintainers create or move `v*` tags. + +Nothing else holds a credential: the workflow's `id-token: write` permission +is granted only to the job that stages the package. diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 0000000..685a582 --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,21 @@ +# The released QAuth server image with this plugin added the way an operator +# adds it (QAuth's docs: operate/images, "Add a plugin to the image"). The +# plugin is the tarball `npm pack` made of this checkout, the same file npm +# would publish; run.sh puts it at .work/plugin.tgz. +# +# The plugin is installed nested, so it brings its own copy of the plugin SDK, +# without its peer dependencies, so it uses the server's zod, and without +# install scripts. An operator installs a released version from a lockfile +# with `npm ci`; this test installs the tarball it has just packed. +ARG QAUTH_IMAGE=ghcr.io/qauth-labs/qauth/auth-server:0.2.0-rc.1@sha256:a4b199c5b1e896346d09c348f0a6cb055ab4f64bf82e0654740b0aa2631e5e8c + +FROM node:26-alpine@sha256:c6ad9e9c1d80f6eb607c26b4b0a132ff7a2344fad1c78ea3baa2a7d4c8de6d94 AS plugin +WORKDIR /plugin +COPY .work/plugin.tgz /tmp/plugin.tgz +RUN npm init --yes > /dev/null \ + && npm install --omit=dev --omit=peer --install-strategy=nested --ignore-scripts /tmp/plugin.tgz + +FROM ${QAUTH_IMAGE} +COPY --from=plugin --chown=nodejs:nodejs \ + /plugin/node_modules/@qauth-labs/plugin-github \ + /app/node_modules/@qauth-labs/plugin-github diff --git a/e2e/compose.yml b/e2e/compose.yml new file mode 100644 index 0000000..9dc751f --- /dev/null +++ b/e2e/compose.yml @@ -0,0 +1,141 @@ +# The end-to-end suite's deployment (run.sh starts it): QAuth's released +# images, the server with this plugin added (Dockerfile), and a mock GitHub +# Enterprise Server on the deployment's private network. +# +# The mock answers to two names. `mock-github` is on the operator's egress +# allowlist (egress-allowlist.json) and `mock-github-unlisted` is not, so the +# suite can show that a private base URL works only where the allowlist names +# it. The server's egress client resolves names through DNS, so both are +# network aliases. +# +# The secrets (JWT keys, KEK, session cookie secret, the mock's client +# secret) come from run.sh's environment, made fresh for each run. +name: plugin-github-e2e + +x-server: &server + build: + context: . + dockerfile: Dockerfile + image: plugin-github-e2e/auth-server:local + environment: &server-env + NODE_ENV: production + HOST: 0.0.0.0 + PORT: '3000' + LOG_LEVEL: info + DATABASE_URL: postgres://qauth:qauth@postgres:5432/qauth + REDIS_URL: redis://redis:6379/0 + REDIS_HOST: redis + REDIS_PORT: '6379' + REDIS_DB: '0' + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:?run e2e/run.sh} + JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY:?run e2e/run.sh} + JWT_ISSUER: http://localhost:3000 + KEK: ${KEK:?run e2e/run.sh} + SESSION_COOKIE_SECRET: ${SESSION_COOKIE_SECRET:?run e2e/run.sh} + # The suite talks to the server over plain http on localhost. + SESSION_COOKIE_SECURE: 'false' + # Required at boot; the suite sends no mail (the mock email provider). + EMAIL_FROM_ADDRESS: noreply@qauth.test + EMAIL_BASE_URL: http://localhost:3000 + # The first boot seeds the master realm with sign-up allowed, so a first + # sign-in through GitHub creates the account. + ALLOW_SIGN_UP: 'true' + # The suite signs in a dozen times from one address. + RATE_LIMIT_MAX: '10000' + LOGIN_RATE_LIMIT: '1000' + AUTHORIZE_RATE_LIMIT: '1000' + TOKEN_RATE_LIMIT: '1000' + REGISTER_CLIENT_RATE_LIMIT: '100' + # The plugin, as an operator installs it. + AUTH_METHOD_PLUGINS: '@qauth-labs/plugin-github' + PLUGIN_API_ENABLED: 'true' + EGRESS_ALLOWLIST_PATH: /etc/qauth/egress-allowlist.json + # The mock's certificate is signed by run.sh's throwaway CA. + NODE_EXTRA_CA_CERTS: /etc/qauth/e2e-ca.crt + # The admin API configures the plugin for the realm. Its base URL is only + # what DPoP proofs name (htu); the suite reaches it on port 3002. + ADMIN_API_ENABLED: 'true' + ADMIN_API_HOST: 0.0.0.0 + ADMIN_API_PORT: '3002' + ADMIN_API_BASE_URL: https://admin.qauth.test + DPOP_ENABLED: 'true' + volumes: + - ./egress-allowlist.json:/etc/qauth/egress-allowlist.json:ro + - ./.work/certs/ca.crt:/etc/qauth/e2e-ca.crt:ro + +services: + postgres: + image: postgres:18-alpine + environment: + POSTGRES_DB: qauth + POSTGRES_USER: qauth + POSTGRES_PASSWORD: qauth + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U qauth -d qauth'] + interval: 2s + timeout: 5s + retries: 30 + + redis: + image: redis:7-alpine + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 2s + timeout: 5s + retries: 30 + + migrate: + image: ghcr.io/qauth-labs/qauth/migration-runner:0.2.0-rc.1@sha256:d4c47545e04b1fd78f16146ffd8e20f02983722d20a71ffbfde4c4c8c95599f6 + environment: + DATABASE_URL: postgres://qauth:qauth@postgres:5432/qauth + depends_on: + postgres: + condition: service_healthy + + mock-github: + image: node:26-alpine@sha256:c6ad9e9c1d80f6eb607c26b4b0a132ff7a2344fad1c78ea3baa2a7d4c8de6d94 + command: ['node', '/mock/server.mjs'] + environment: + MOCK_GITHUB_CLIENT_ID: ${MOCK_GITHUB_CLIENT_ID:?run e2e/run.sh} + MOCK_GITHUB_CLIENT_SECRET: ${MOCK_GITHUB_CLIENT_SECRET:?run e2e/run.sh} + volumes: + - ./mock-github:/mock:ro + - ./.work/certs:/certs:ro + networks: + default: + aliases: [mock-github-unlisted] + # The suite plays the browser at the mock's authorization page, and reads + # its control API, from the host. + ports: + - 127.0.0.1:8443:8443 + + auth-server: + <<: *server + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + mock-github: + condition: service_started + ports: + - 127.0.0.1:3000:3000 + - 127.0.0.1:3002:3002 + + # The same image and plugin with the experimental plugin API off: the boot + # must skip the plugin and say why. run.sh starts it after the first server + # has seeded the database. + auth-server-plugin-api-off: + <<: *server + environment: + <<: *server-env + PLUGIN_API_ENABLED: 'false' + ADMIN_API_ENABLED: 'false' + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + ports: + - 127.0.0.1:3010:3000 + profiles: [plugin-api-off] diff --git a/e2e/egress-allowlist.json b/e2e/egress-allowlist.json new file mode 100644 index 0000000..a3b486c --- /dev/null +++ b/e2e/egress-allowlist.json @@ -0,0 +1,9 @@ +{ + "entries": [ + { + "host": "mock-github", + "realms": ["master"], + "note": "The mock GitHub Enterprise Server. Its other name, mock-github-unlisted, is not here." + } + ] +} diff --git a/e2e/github.e2e.test.ts b/e2e/github.e2e.test.ts new file mode 100644 index 0000000..3840f4a --- /dev/null +++ b/e2e/github.e2e.test.ts @@ -0,0 +1,417 @@ +/** + * The plugin end to end, where an operator runs it: QAuth's released server + * image with the plugin installed on top (e2e/Dockerfile), against a mock + * GitHub Enterprise Server on the deployment's private network. The suite + * configures the plugin through the admin API, as an operator does, and signs + * in through the server's own pages, as a browser does. + * + * Run it through e2e/run.sh. + */ +import { createHash, randomBytes } from 'node:crypto'; + +import { beforeAll, describe, expect, it } from 'vitest'; + +import { + Admin, + browse, + CookieJar, + databaseDump, + formsOf, + issuedTokens, + linkWithClass, + literal, + logsOf, + MOCK, + mockCalls, + mockClient, + type MockPerson, + setPerson, + sql, +} from './harness.js'; + +/** Where the test application wants the code; nothing listens there. */ +const REDIRECT_URI = 'http://127.0.0.1:9/plugin-github-e2e/callback'; +/** The callback this provider alone uses. */ +const CALLBACK = 'http://localhost:3000/upstream/github/callback'; +/** The mock GitHub under the name the operator's allowlist names. */ +const LISTED_BASE_URL = 'https://mock-github:8443'; +/** The same mock under a name the allowlist does not name. */ +const UNLISTED_BASE_URL = 'https://mock-github-unlisted:8443'; + +const SIGN_IN_FAILED = 'urn:qauth:event:user.sign_in_failed:v1-rc.0'; +const REGISTERED = 'urn:qauth:event:user.registered:v1-rc.0'; + +let admin: Admin; +let clientId: string; +let realmId: string; + +function isRedirect(response: Response): boolean { + return response.status === 302 || response.status === 303; +} + +/** Configure the plugin for the realm and enable it, through the admin API. */ +async function configure(baseUrl: string): Promise { + const { clientId: githubClientId, clientSecret } = mockClient(); + const current = await admin.call('GET', '/plugins/github'); + expect(current.status).toBe(200); + const sealed = await admin.call('POST', '/plugins/github/secrets/seal', { + path: 'clientSecret', + value: clientSecret, + }); + expect(sealed.status).toBe(200); + const body = { + version: current.body['version'], + config: { clientId: githubClientId, baseUrl }, + secrets: { clientSecret: { sealed: sealed.body['sealed'] } }, + }; + const configured = await admin.call('POST', '/plugins/github/config', body, { + 'qauth-approval': await admin.approved('plugins.update', body), + }); + expect(configured.status, JSON.stringify(configured.body)).toBe(200); + if (configured.body['enabled'] !== true) { + const enableBody = { version: configured.body['version'] }; + const enabled = await admin.call('POST', '/plugins/github/enable', enableBody, { + 'qauth-approval': await admin.approved('plugins.enable', enableBody), + }); + expect(enabled.status, JSON.stringify(enabled.body)).toBe(200); + expect(enabled.body['enabled']).toBe(true); + } +} + +/** A sign-in started at the server and sent on to GitHub. */ +interface StartedSignIn { + readonly jar: CookieJar; + /** GitHub's authorization page, as the server sent the browser there. */ + readonly providerUrl: URL; +} + +/** An application asks the server to sign someone in through GitHub. */ +async function startSignIn(): Promise { + const jar = new CookieJar(); + const verifier = randomBytes(32).toString('base64url'); + const authorize = new URL('/oauth/authorize', 'http://localhost:3000'); + authorize.search = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope: 'openid', + state: randomBytes(16).toString('base64url'), + code_challenge: createHash('sha256').update(verifier).digest('base64url'), + code_challenge_method: 'S256', + qauth_upstream: 'github', + }).toString(); + const toStart = await browse(jar, authorize); + expect(isRedirect(toStart)).toBe(true); + const startUrl = toStart.headers.get('location') ?? ''; + expect(startUrl).toMatch(/^\/ui\/upstream\/github\/start\?/); + const start = await browse(jar, startUrl); + expect(start.status).toBe(200); + const href = linkWithClass(await start.text(), 'alt-action'); + expect(href).toBeDefined(); + return { jar, providerUrl: new URL(href ?? '') }; +} + +/** + * The person signs in at the mock's authorization page; the URL GitHub sends + * the browser back to. The suite reaches the mock from the host as localhost. + */ +async function atGitHub(providerUrl: URL): Promise { + const page = new URL(providerUrl.pathname + providerUrl.search, MOCK); + const answer = await fetch(page, { redirect: 'manual' }); + expect(isRedirect(answer)).toBe(true); + const back = new URL(answer.headers.get('location') ?? ''); + expect(`${back.origin}${back.pathname}`).toBe(CALLBACK); + return back; +} + +/** + * Follow the server from the callback to the application, granting consent + * on the way when the page asks; the code the application receives. + */ +async function toApplication(jar: CookieJar, afterCallback: Response): Promise { + let response = afterCallback; + for (let step = 0; step < 10; step += 1) { + const location = response.headers.get('location'); + if (isRedirect(response) && location !== null) { + const next = new URL(location, 'http://localhost:3000'); + if (next.href.startsWith(REDIRECT_URI)) { + const code = next.searchParams.get('code'); + expect(next.searchParams.get('error')).toBeNull(); + expect(code).not.toBeNull(); + return code ?? ''; + } + response = await browse(jar, next); + continue; + } + expect(response.status, `step ${String(step)}`).toBe(200); + const consent = formsOf(await response.text()).find( + (form) => + form.action.endsWith('/ui/consent') || + form.buttons.some((button) => button.name === 'decision' && button.value === 'allow') + ); + expect(consent, 'a consent form').toBeDefined(); + if (consent === undefined) break; + const form = new URLSearchParams(consent.fields); + form.set('decision', 'allow'); + response = await browse(jar, consent.action, { method: 'POST', form }); + } + throw new Error('the server did not send the browser back to the application'); +} + +/** A whole sign-in of `person`; the code the application receives. */ +async function signIn(person: MockPerson): Promise { + await setPerson(person); + const { jar, providerUrl } = await startSignIn(); + const back = await atGitHub(providerUrl); + const callback = await browse(jar, back); + expect(isRedirect(callback), await callback.clone().text()).toBe(true); + return toApplication(jar, callback); +} + +/** The accounts the GitHub credential of `id` belongs to. */ +function accountsOf(id: number): string[] { + return sql( + `SELECT user_id FROM user_credentials + WHERE provider_type = 'github' AND external_sub = ${literal(String(id))}` + ); +} + +function userCount(): number { + return Number(sql('SELECT count(*) FROM users')[0]); +} + +/** The attributes the plugin wrote for an account: key, value and whether verified. */ +function attributesOf(accountId: string): { key: string; value: string; verified: boolean }[] { + return sql( + `SELECT attr_key, attr_value, verified FROM user_attributes + WHERE user_id = ${literal(accountId)} AND source = 'github' ORDER BY attr_key` + ).map((row) => { + const [key = '', value = '', verified = ''] = row.split('|'); + return { key, value, verified: verified === 't' }; + }); +} + +function failedSignIns(reason: string): number { + return Number( + sql( + `SELECT count(*) FROM events WHERE type = ${literal(SIGN_IN_FAILED)} + AND data->>'method' = 'upstream' AND data->>'reason' = ${literal(reason)}` + )[0] + ); +} + +const octo: MockPerson = { + id: 58_000_001, + login: 'octo-first', + name: 'Octo First', + emails: [ + { email: 'octo@example.com', primary: true, verified: true }, + { email: '58000001+octo-first@users.noreply.example.com', primary: false, verified: true }, + ], +}; + +beforeAll(async () => { + const [master] = sql(`SELECT id FROM realms WHERE name = 'master'`); + if (master === undefined) throw new Error('no master realm'); + realmId = master; + admin = await Admin.create(realmId); + const registered = await fetch('http://localhost:3000/oauth/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'plugin-github e2e application', + redirect_uris: [REDIRECT_URI], + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: 'openid', + }), + }); + expect(registered.status).toBe(201); + clientId = ((await registered.json()) as { client_id: string }).client_id; +}); + +describe('the boot', () => { + it('loads the plugin as an operator-installed package while PLUGIN_API_ENABLED is on', () => { + expect(logsOf('auth-server')).toContain( + 'AuthMethod plugin @qauth-labs/plugin-github loaded: github (github)' + ); + }); + + it('skips the plugin with PLUGIN_API_ENABLED off, and says why', () => { + const log = logsOf('auth-server-plugin-api-off'); + expect(log).toContain( + 'The AuthMethod plugin @qauth-labs/plugin-github is not loaded: it is not a first-party plugin, and the experimental plugin API is off.' + ); + expect(log).not.toContain('AuthMethod plugin @qauth-labs/plugin-github loaded'); + }); + + it('lists the plugin in the admin API, not configured yet', async () => { + const plugin = await admin.call('GET', '/plugins/github'); + expect(plugin.status).toBe(200); + expect(plugin.body).toMatchObject({ + type: 'github', + package: '@qauth-labs/plugin-github', + firstParty: false, + contract: 'qauth-auth-method/1-rc.0', + enabled: false, + }); + }); +}); + +describe('a private base URL', () => { + it('is refused while the operator allowlist does not name it', async () => { + await configure(UNLISTED_BASE_URL); + await setPerson(octo); + const refusedBefore = failedSignIns('upstream_unavailable'); + const callsBefore = (await mockCalls()).length; + + const { jar, providerUrl } = await startSignIn(); + expect(providerUrl.origin).toBe(UNLISTED_BASE_URL); + const back = await atGitHub(providerUrl); + const callback = await browse(jar, back); + + expect(callback.status).toBe(400); + expect(await callback.text()).toContain('We could not sign you in'); + expect(failedSignIns('upstream_unavailable')).toBe(refusedBefore + 1); + // The egress client refused the address before anything was sent: the + // mock saw the browser at its authorization page, and nothing else. + const calls = (await mockCalls()).slice(callsBefore); + expect(calls.map((call) => call.path)).toEqual(['/login/oauth/authorize']); + expect(logsOf('auth-server')).toContain('egress call blocked'); + expect(accountsOf(octo.id)).toEqual([]); + }); + + it('works once it is the name the allowlist names', async () => { + await configure(LISTED_BASE_URL); + const { providerUrl } = await startSignIn(); + expect(providerUrl.origin).toBe(LISTED_BASE_URL); + }); +}); + +describe('sign-in', () => { + let account: string; + + it('the first sign-in creates an account keyed by the numeric id', async () => { + const before = userCount(); + await signIn(octo); + + const accounts = accountsOf(octo.id); + expect(accounts).toHaveLength(1); + account = accounts[0] ?? ''; + expect(userCount()).toBe(before + 1); + // The subject is the id, under the GitHub that issued it; never the login. + const [credential] = sql( + `SELECT external_sub, credential_data->>'iss' FROM user_credentials + WHERE user_id = ${literal(account)} AND provider_type = 'github'` + ); + expect(credential).toBe(`${String(octo.id)}|${LISTED_BASE_URL}`); + expect( + sql(`SELECT 1 FROM user_credentials WHERE external_sub = ${literal(octo.login)}`) + ).toEqual([]); + expect( + sql( + `SELECT data->>'method' FROM events + WHERE type = ${literal(REGISTERED)} AND subject_id = ${literal(account)}` + ) + ).toEqual(['upstream']); + }); + + it('stores the primary verified email as verified, the login and the name as unverified', () => { + expect(attributesOf(account)).toEqual([ + { key: 'email', value: 'octo@example.com', verified: true }, + { key: 'name', value: 'Octo First', verified: false }, + { key: 'preferred_username', value: 'octo-first', verified: false }, + ]); + }); + + it('a renamed login signs in the same account', async () => { + const before = userCount(); + await signIn({ ...octo, login: 'octo-renamed', name: 'Octo Renamed' }); + expect(accountsOf(octo.id)).toEqual([account]); + expect(userCount()).toBe(before); + }); + + it('a changed email address signs in the same account, and no new account is created', async () => { + const before = userCount(); + await signIn({ + ...octo, + emails: [{ email: 'octo-changed@example.com', primary: true, verified: true }], + }); + expect(accountsOf(octo.id)).toEqual([account]); + expect(userCount()).toBe(before); + }); + + it('only a primary and verified email becomes a verified attribute', async () => { + const unverified: MockPerson = { + id: 58_000_002, + login: 'octo-unverified', + name: null, + emails: [ + { email: 'primary-unverified@example.com', primary: true, verified: false }, + { email: 'other-verified@example.com', primary: false, verified: true }, + ], + }; + await signIn(unverified); + const [second] = accountsOf(unverified.id); + expect(second).toBeDefined(); + expect(second).not.toBe(account); + // The primary address, unverified; the verified address that is not + // primary is not taken at all. + expect(attributesOf(second ?? '')).toEqual([ + { key: 'email', value: 'primary-unverified@example.com', verified: false }, + { key: 'preferred_username', value: 'octo-unverified', verified: false }, + ]); + expect( + sql(`SELECT 1 FROM user_attributes WHERE attr_value = 'other-verified@example.com'`) + ).toEqual([]); + // No address of the first account is verified but the one it began with. + expect( + sql( + `SELECT attr_value FROM user_attributes + WHERE source = 'github' AND attr_key = 'email' AND verified` + ) + ).toEqual(['octo@example.com']); + }); + + it('a callback with a wrong state is refused', async () => { + await setPerson(octo); + const mismatchesBefore = failedSignIns('transaction_mismatch'); + const tokensBefore = (await issuedTokens()).length; + const { jar, providerUrl } = await startSignIn(); + const back = await atGitHub(providerUrl); + expect(back.searchParams.get('state')).toBe(providerUrl.searchParams.get('state')); + + const forged = new URL(back); + forged.searchParams.set('state', randomBytes(32).toString('base64url')); + const refused = await browse(jar, forged); + + expect(refused.status).toBe(400); + expect(await refused.text()).toContain('We could not sign you in'); + expect(failedSignIns('transaction_mismatch')).toBe(mismatchesBefore + 1); + // The code was never redeemed. + expect(await issuedTokens()).toHaveLength(tokensBefore); + }); +}); + +describe('the access token', () => { + it('is not in the database, the logs or the events', async () => { + const tokens = await issuedTokens(); + // One per completed sign-in above. + expect(tokens.length).toBeGreaterThanOrEqual(4); + + const dump = databaseDump(); + const events = sql('SELECT row_to_json(events)::text FROM events').join('\n'); + const logs = [logsOf('auth-server'), logsOf('auth-server-plugin-api-off')].join('\n'); + expect(events).toContain(REGISTERED); + for (const token of tokens) { + expect(dump.includes(token), 'a token in the database').toBe(false); + expect(events.includes(token), 'a token in the events').toBe(false); + expect(logs.includes(token), 'a token in the logs').toBe(false); + } + // Nor is the client secret, which the server keeps sealed. + const { clientSecret } = mockClient(); + expect(dump.includes(clientSecret), 'the client secret in the database').toBe(false); + expect(logs.includes(clientSecret), 'the client secret in the logs').toBe(false); + }); +}); diff --git a/e2e/harness.ts b/e2e/harness.ts new file mode 100644 index 0000000..30ca8cb --- /dev/null +++ b/e2e/harness.ts @@ -0,0 +1,431 @@ +/** + * What the end-to-end suite drives the deployment with: a browser of sorts (a + * cookie jar and the HTML forms of QAuth's pages), the admin API (an admin + * account, a DPoP-bound access token and a fresh DPoP proof for each call), + * the database and the logs (through docker compose), and the mock GitHub's + * control API. e2e/run.sh starts the deployment and sets the environment. + */ +import { execFileSync } from 'node:child_process'; +import { + createHash, + createPrivateKey, + generateKeyPairSync, + type KeyObject, + randomBytes, + randomUUID, + sign, + type webcrypto, +} from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type JsonWebKey = webcrypto.JsonWebKey; + +/** The server's public listener, as the suite reaches it. */ +export const SERVER = 'http://localhost:3000'; +/** The admin API's listener. */ +export const ADMIN = 'http://localhost:3002'; +/** What DPoP proofs for the admin API name: the server's ADMIN_API_BASE_URL. */ +const ADMIN_BASE_URL = 'https://admin.qauth.test'; +/** The mock GitHub, from the host. */ +export const MOCK = 'https://localhost:8443'; +/** The admin API's version prefix. */ +const ADMIN_PREFIX = '/admin/v1-rc.0'; + +function required(name: string): string { + const value = process.env[name]; + if (value === undefined || value === '') { + throw new Error(`${name} is not set: run the suite through e2e/run.sh`); + } + return value; +} + +/** Run `docker compose` on the suite's deployment; its standard output. */ +export function compose(...args: string[]): string { + return execFileSync('docker', ['compose', '--file', required('E2E_COMPOSE_FILE'), ...args], { + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +/** Run one SQL statement in the server's database; its rows, one line each, columns split by `|`. */ +export function sql(statement: string): string[] { + const out = compose( + 'exec', + '-T', + 'postgres', + 'psql', + '--username=qauth', + '--dbname=qauth', + '--no-align', + '--tuples-only', + '--quiet', + '--set=ON_ERROR_STOP=1', + '--command', + statement + ); + return out.split('\n').filter((line) => line !== ''); +} + +/** A SQL string literal. */ +export function literal(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +/** Everything the server's database holds, as `pg_dump` writes it. */ +export function databaseDump(): string { + return compose( + 'exec', + '-T', + 'postgres', + 'pg_dump', + '--username=qauth', + '--dbname=qauth', + '--data-only' + ); +} + +/** A service's log, as docker keeps it. */ +export function logsOf(service: string): string { + return compose('logs', '--no-color', '--no-log-prefix', service); +} + +/** The client secret the mock GitHub expects, and the client id. */ +export function mockClient(): { clientId: string; clientSecret: string } { + return { + clientId: required('MOCK_GITHUB_CLIENT_ID'), + clientSecret: required('MOCK_GITHUB_CLIENT_SECRET'), + }; +} + +// --------------------------------------------------------------------------- +// The mock GitHub's control API. + +export interface MockEmail { + email: string; + primary: boolean; + verified: boolean; +} + +export interface MockPerson { + id: number; + login: string; + name: string | null; + emails: MockEmail[]; +} + +export interface MockCall { + method: string; + host: string; + path: string; +} + +async function mockControl(path: string, init?: RequestInit): Promise { + const response = await fetch(`${MOCK}/_control${path}`, init); + if (!response.ok) throw new Error(`mock control ${path}: ${String(response.status)}`); + return response.json(); +} + +/** Set the person who signs in at the mock's authorization page next. */ +export async function setPerson(person: MockPerson): Promise { + await mockControl('/person', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(person), + }); +} + +/** Every access token the mock issued. */ +export async function issuedTokens(): Promise { + return ((await mockControl('/tokens')) as { tokens: string[] }).tokens; +} + +/** Every call the mock answered. */ +export async function mockCalls(): Promise { + return ((await mockControl('/calls')) as { calls: MockCall[] }).calls; +} + +// --------------------------------------------------------------------------- +// A browser of sorts. + +/** The cookies the server set, by name. Every request goes to one origin. */ +export class CookieJar { + private readonly cookies = new Map(); + + storeFrom(response: Response): void { + for (const header of response.headers.getSetCookie()) { + const pair = header.split(';', 1)[0] ?? ''; + const equals = pair.indexOf('='); + if (equals <= 0) continue; + const name = pair.slice(0, equals).trim(); + const value = pair.slice(equals + 1).trim(); + if (/;\s*max-age=0\b/i.test(header) || value === '') { + this.cookies.delete(name); + } else { + this.cookies.set(name, value); + } + } + } + + header(): string | undefined { + if (this.cookies.size === 0) return undefined; + return [...this.cookies].map(([name, value]) => `${name}=${value}`).join('; '); + } +} + +/** One request to the server with the jar's cookies; redirects are not followed. */ +export async function browse( + jar: CookieJar, + url: string | URL, + init: { method?: string; form?: URLSearchParams } = {} +): Promise { + const headers: Record = {}; + const cookie = jar.header(); + if (cookie !== undefined) headers['cookie'] = cookie; + let body: string | undefined; + if (init.form !== undefined) { + headers['content-type'] = 'application/x-www-form-urlencoded'; + body = init.form.toString(); + } + const response = await fetch(new URL(url, SERVER), { + method: init.method ?? 'GET', + headers, + redirect: 'manual', + ...(body === undefined ? {} : { body }), + }); + jar.storeFrom(response); + return response; +} + +const ENTITIES: Readonly> = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}; + +/** Decode the character references in an HTML attribute value. */ +export function decodeAttribute(value: string): string { + return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, reference: string) => { + if (reference.startsWith('#')) { + const code = /^#x/i.test(reference) + ? Number.parseInt(reference.slice(2), 16) + : Number.parseInt(reference.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : match; + } + return ENTITIES[reference.toLowerCase()] ?? match; + }); +} + +function attributesOf(tag: string): Map { + const attributes = new Map(); + const body = tag.replace(/^<\s*[a-z]+/i, '').replace(/\/?>$/, ''); + for (const match of body.matchAll( + /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g + )) { + const name = match[1]; + if (name === undefined) continue; + attributes.set(name.toLowerCase(), decodeAttribute(match[2] ?? match[3] ?? match[4] ?? '')); + } + return attributes; +} + +/** The URL of the first `` with the given class. */ +export function linkWithClass(html: string, className: string): string | undefined { + for (const match of html.matchAll(/]*>/gi)) { + const attributes = attributesOf(match[0]); + const classes = (attributes.get('class') ?? '').split(/\s+/); + if (classes.includes(className)) return attributes.get('href'); + } + return undefined; +} + +/** A form as the page rendered it. */ +export interface HtmlForm { + readonly action: string; + readonly fields: URLSearchParams; + readonly buttons: ReadonlyArray<{ readonly name: string; readonly value: string }>; +} + +/** Every `
` of a page, with its inputs as a browser would send them. */ +export function formsOf(html: string): HtmlForm[] { + const forms: HtmlForm[] = []; + for (const match of html.matchAll(/]*>([\s\S]*?)<\/form>/gi)) { + const formTag = /^]*>/i.exec(match[0])?.[0] ?? ''; + const fields = new URLSearchParams(); + for (const input of (match[1] ?? '').matchAll(/]*>/gi)) { + const attributes = attributesOf(input[0]); + const name = attributes.get('name'); + if (name === undefined || name === '') continue; + const type = (attributes.get('type') ?? 'text').toLowerCase(); + if ((type === 'checkbox' || type === 'radio') && !attributes.has('checked')) continue; + if (type === 'submit' || type === 'button') continue; + fields.append(name, attributes.get('value') ?? (type === 'checkbox' ? 'on' : '')); + } + const buttons: { name: string; value: string }[] = []; + for (const button of (match[1] ?? '').matchAll(/]*>/gi)) { + const attributes = attributesOf(button[0]); + const name = attributes.get('name'); + if (name !== undefined && (attributes.get('type') ?? 'submit').toLowerCase() === 'submit') { + buttons.push({ name, value: attributes.get('value') ?? '' }); + } + } + forms.push({ action: attributesOf(formTag).get('action') ?? '', fields, buttons }); + } + return forms; +} + +// --------------------------------------------------------------------------- +// JOSE, with node:crypto alone. + +function base64url(value: string | Buffer): string { + return Buffer.from(value).toString('base64url'); +} + +function jws( + header: Record, + claims: Record, + signWith: (input: Buffer) => Buffer +): string { + const input = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claims))}`; + return `${input}.${base64url(signWith(Buffer.from(input, 'ascii')))}`; +} + +/** An RFC 7638 thumbprint of a P-256 public key. */ +function thumbprint(jwk: JsonWebKey): string { + const members = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }; + return base64url(createHash('sha256').update(JSON.stringify(members)).digest()); +} + +/** The admin API, as one security admin of the master realm. */ +export class Admin { + private constructor( + readonly realmId: string, + readonly accountId: string, + private readonly token: string, + private readonly proofKey: KeyObject, + private readonly proofJwk: JsonWebKey + ) {} + + /** + * Make a security admin for the realm (an account and a grant, written + * straight into the database, as the operator's bootstrap does), and an + * access token for it, bound to a fresh DPoP key and signed with the + * server's key, as the server issues one. + */ + static async create(realmId: string): Promise { + const [accountId] = sql( + `WITH account AS (INSERT INTO users (realm_id) VALUES (${literal(realmId)}) RETURNING id) + INSERT INTO admin_grants (user_id, built_in_role, target_realm_id) + SELECT id, 'security-admin', ${literal(realmId)} FROM account RETURNING user_id` + ); + if (accountId === undefined) throw new Error('no admin account'); + + const discovery = (await ( + await fetch(`${SERVER}/.well-known/openid-configuration`) + ).json()) as { issuer: string; jwks_uri: string }; + const signingKey = createPrivateKey( + readFileSync(join(required('E2E_WORK'), 'keys', 'jwt-private.pem')) + ); + const signingJwk = signingKey.export({ format: 'jwk' }); + const jwks = (await ( + await fetch(discovery.jwks_uri.replace(/^https?:\/\/[^/]+/, SERVER)) + ).json()) as { + keys: (JsonWebKey & { kid?: string })[]; + }; + const kid = jwks.keys.find((key) => key.x === signingJwk.x)?.kid; + if (kid === undefined) throw new Error('the server does not publish its signing key'); + + const { privateKey: proofKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const proofJwk = publicKey.export({ format: 'jwk' }); + const now = Math.floor(Date.now() / 1000); + const token = jws( + { alg: 'EdDSA', typ: 'at+jwt', kid }, + { + iss: discovery.issuer, + sub: accountId, + aud: 'urn:qauth:aud:admin:v1-rc.0', + client_id: 'plugin-github-e2e', + token_use: 'access', + scope: 'qauth:admin:read qauth:admin:write qauth:admin:security', + jti: randomUUID(), + iat: now, + exp: now + 1800, + cnf: { jkt: thumbprint(proofJwk) }, + }, + (input) => sign(null, input, signingKey) + ); + return new Admin(realmId, accountId, token, proofKey, proofJwk); + } + + /** A DPoP proof for one call (RFC 9449 §4.2). */ + private proof(method: string, path: string): string { + return jws( + { typ: 'dpop+jwt', alg: 'ES256', jwk: this.proofJwk }, + { + jti: randomBytes(16).toString('base64url'), + htm: method, + htu: `${ADMIN_BASE_URL}${path}`, + iat: Math.floor(Date.now() / 1000), + ath: base64url(createHash('sha256').update(this.token, 'ascii').digest()), + }, + (input) => sign('sha256', input, { key: this.proofKey, dsaEncoding: 'ieee-p1363' }) + ); + } + + /** One admin API call, below the realm; its status and JSON body. */ + async call( + method: 'GET' | 'POST', + path: string, + body?: unknown, + headers: Record = {} + ): Promise<{ status: number; body: Record }> { + const fullPath = `${ADMIN_PREFIX}/realms/${this.realmId}${path}`; + const response = await fetch(`${ADMIN}${fullPath}`, { + method, + headers: { + authorization: `DPoP ${this.token}`, + dpop: this.proof(method, fullPath), + accept: 'application/json', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...headers, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const text = await response.text(); + return { + status: response.status, + body: text === '' ? {} : (JSON.parse(text) as Record), + }; + } + + /** + * File an approval for a security operation and approve it. The approval + * is written as approved straight into the database: the suite has no + * passkey to approve it with. The server's gate checks it as it checks + * any other. + */ + async approved(operation: string, body: unknown): Promise { + const filed = await this.call('POST', '/approvals', { + operation, + params: { plugin: 'github' }, + body, + }); + if (filed.status !== 201 || typeof filed.body['id'] !== 'string') { + throw new Error( + `approval for ${operation}: ${String(filed.status)} ${JSON.stringify(filed.body)}` + ); + } + const id = filed.body['id']; + sql( + `UPDATE admin_approvals SET status = 'approved', approvals = jsonb_build_array(jsonb_build_object( + 'approverRealmId', requester_realm_id::text, 'approverId', requester_id::text, + 'credentialId', 'plugin-github-e2e', 'factor', 'webauthn', + 'approvedAt', (extract(epoch FROM clock_timestamp()) * 1000)::bigint)) + WHERE id = ${literal(id)}` + ); + return id; + } +} diff --git a/e2e/mock-github/server.mjs b/e2e/mock-github/server.mjs new file mode 100644 index 0000000..1470b1b --- /dev/null +++ b/e2e/mock-github/server.mjs @@ -0,0 +1,199 @@ +/** + * A mock GitHub Enterprise Server for the end-to-end suite, over https: the + * authorization page, the token endpoint, `GET /api/v3/user` and + * `GET /api/v3/user/emails`, answering as GitHub does. It checks what GitHub + * checks: the client, the code (good once), the `redirect_uri`, the PKCE + * verifier, the bearer token and the `User-Agent`. + * + * The test drives it through a control API under `/_control/`: it sets the + * person who signs in next, and reads back every access token the mock + * issued and every call it answered, so it can look for those tokens in the + * server's database and logs. + * + * No dependencies: it runs on the plain node image. + */ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:https'; + +const PORT = Number(process.env.PORT ?? '8443'); +const CLIENT_ID = process.env.MOCK_GITHUB_CLIENT_ID ?? ''; +const CLIENT_SECRET = process.env.MOCK_GITHUB_CLIENT_SECRET ?? ''; +if (CLIENT_ID === '' || CLIENT_SECRET === '') { + throw new Error('MOCK_GITHUB_CLIENT_ID and MOCK_GITHUB_CLIENT_SECRET are required'); +} + +/** The person who signs in at the authorization page. The test replaces it. */ +let person = { + id: 1001, + login: 'first-person', + name: 'First Person', + emails: [{ email: 'first@example.com', primary: true, verified: true, visibility: 'private' }], +}; + +/** Issued codes: the request they answer, and the person as they were then. */ +const codes = new Map(); +/** Issued access tokens and the person each belongs to. */ +const people = new Map(); +/** Every access token issued, in order. */ +const tokens = []; +/** Every call answered, without its credentials: method, host and path. */ +const calls = []; + +function s256(verifier) { + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +} + +function same(left, right) { + const a = Buffer.from(String(left)); + const b = Buffer.from(String(right)); + return a.length === b.length && timingSafeEqual(a, b); +} + +function json(response, status, body) { + const text = JSON.stringify(body); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(text), + }); + response.end(text); +} + +async function bodyOf(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 64 * 1024) throw new Error('body too large'); + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function authorize(url, response) { + const query = url.searchParams; + const redirectUri = query.get('redirect_uri'); + const state = query.get('state'); + if (query.get('client_id') !== CLIENT_ID || redirectUri === null || state === null) { + return json(response, 400, { message: 'not a request of the registered app' }); + } + const code = randomBytes(10).toString('hex'); + codes.set(code, { + redirectUri, + challenge: query.get('code_challenge'), + challengeMethod: query.get('code_challenge_method'), + person: structuredClone(person), + }); + const back = new URL(redirectUri); + back.searchParams.set('code', code); + back.searchParams.set('state', state); + response.writeHead(302, { location: back.href }); + response.end(); +} + +function token(request, form, response) { + // GitHub answers JSON only when asked; the plugin asks. + if (!(request.headers.accept ?? '').includes('application/json')) { + response.writeHead(200, { 'content-type': 'application/x-www-form-urlencoded' }); + return response.end('error=not_asked_for_json'); + } + if ( + !same(form.get('client_id') ?? '', CLIENT_ID) || + !same(form.get('client_secret') ?? '', CLIENT_SECRET) + ) { + return json(response, 200, { error: 'incorrect_client_credentials' }); + } + const code = form.get('code') ?? ''; + const issued = codes.get(code); + codes.delete(code); + if (issued === undefined) return json(response, 200, { error: 'bad_verification_code' }); + if (form.get('redirect_uri') !== issued.redirectUri) { + return json(response, 200, { error: 'redirect_uri_mismatch' }); + } + if (issued.challenge !== null) { + const verifier = form.get('code_verifier') ?? ''; + if (issued.challengeMethod !== 'S256' || !same(s256(verifier), issued.challenge)) { + return json(response, 200, { error: 'bad_verification_code' }); + } + } + const accessToken = `gho_${randomBytes(27).toString('base64url')}`; + tokens.push(accessToken); + people.set(accessToken, issued.person); + return json(response, 200, { + access_token: accessToken, + token_type: 'bearer', + scope: 'user:email', + }); +} + +function bearer(request) { + const header = request.headers.authorization ?? ''; + return header.startsWith('Bearer ') ? people.get(header.slice('Bearer '.length)) : undefined; +} + +function api(request, url, response) { + if (request.headers['user-agent'] === undefined) { + return json(response, 403, { message: 'Request forbidden by administrative rules.' }); + } + const signedIn = bearer(request); + if (signedIn === undefined) return json(response, 401, { message: 'Bad credentials' }); + if (url.pathname === '/api/v3/user') { + return json(response, 200, { + login: signedIn.login, + id: signedIn.id, + node_id: `U_${String(signedIn.id)}`, + type: 'User', + name: signedIn.name, + email: null, + }); + } + return json(response, 200, signedIn.emails); +} + +async function control(request, url, response) { + if (request.method === 'PUT' && url.pathname === '/_control/person') { + person = JSON.parse(await bodyOf(request)); + return json(response, 200, person); + } + if (request.method === 'GET' && url.pathname === '/_control/tokens') { + return json(response, 200, { tokens }); + } + if (request.method === 'GET' && url.pathname === '/_control/calls') { + return json(response, 200, { calls }); + } + return json(response, 404, { message: 'Not Found' }); +} + +const tls = { + key: readFileSync(process.env.TLS_KEY ?? '/certs/mock.key'), + cert: readFileSync(process.env.TLS_CERT ?? '/certs/mock.crt'), +}; + +createServer(tls, (request, response) => { + const url = new URL(request.url ?? '/', 'https://mock-github'); + if (url.pathname.startsWith('/_control/')) { + control(request, url, response).catch(() => + json(response, 400, { message: 'bad control call' }) + ); + return; + } + calls.push({ method: request.method, host: request.headers.host ?? '', path: url.pathname }); + if (request.method === 'GET' && url.pathname === '/login/oauth/authorize') { + return authorize(url, response); + } + if (request.method === 'POST' && url.pathname === '/login/oauth/access_token') { + bodyOf(request) + .then((text) => token(request, new URLSearchParams(text), response)) + .catch(() => json(response, 400, { message: 'bad request' })); + return; + } + if ( + request.method === 'GET' && + (url.pathname === '/api/v3/user' || url.pathname === '/api/v3/user/emails') + ) { + return api(request, url, response); + } + return json(response, 404, { message: 'Not Found' }); +}).listen(PORT, '0.0.0.0', () => { + console.log(`mock GitHub listening on ${String(PORT)}`); +}); diff --git a/e2e/run.sh b/e2e/run.sh new file mode 100644 index 0000000..386f571 --- /dev/null +++ b/e2e/run.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# The end-to-end suite: QAuth's released server image with this plugin +# installed the way an operator installs it, against a mock GitHub Enterprise +# Server, driven over HTTP by e2e/github.e2e.test.ts. +# +# Needs docker (with compose), openssl, node and pnpm. Run it from anywhere: +# +# pnpm run e2e +# +# It packs the plugin as npm would publish it, makes throwaway secrets and a +# throwaway CA in e2e/.work/, builds the image (e2e/Dockerfile), starts the +# deployment (e2e/compose.yml), runs the suite and takes everything down. +# On a failure it prints the server's and the mock's logs. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(dirname "$here")" +work="$here/.work" +compose=(docker compose --file "$here/compose.yml") + +rm -rf "$work" +mkdir -p "$work/certs" "$work/ca" "$work/keys" "$work/pack" + +# A CA for this run only, and the mock's certificate for every name it is +# reached by: the two network names, and localhost for the suite on the host. +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=plugin-github e2e CA' \ + -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign' \ + -keyout "$work/ca/ca.key" -out "$work/certs/ca.crt" 2> /dev/null +openssl req -newkey rsa:2048 -nodes -subj '/CN=mock-github' \ + -keyout "$work/certs/mock.key" -out "$work/ca/mock.csr" 2> /dev/null +printf '%s\n' 'subjectAltName=DNS:mock-github,DNS:mock-github-unlisted,DNS:localhost' \ + 'extendedKeyUsage=serverAuth' > "$work/ca/mock.ext" +openssl x509 -req -in "$work/ca/mock.csr" -CA "$work/certs/ca.crt" -CAkey "$work/ca/ca.key" \ + -CAcreateserial -days 1 -extfile "$work/ca/mock.ext" -out "$work/certs/mock.crt" 2> /dev/null +# The mock reads its key through a read-only bind mount. +chmod 644 "$work/certs/mock.key" + +# The server's signing key and secrets, fresh for this run. +openssl genpkey -algorithm ed25519 -out "$work/keys/jwt-private.pem" 2> /dev/null +openssl pkey -in "$work/keys/jwt-private.pem" -pubout -out "$work/keys/jwt-public.pem" +JWT_PRIVATE_KEY="$(cat "$work/keys/jwt-private.pem")" +JWT_PUBLIC_KEY="$(cat "$work/keys/jwt-public.pem")" +KEK="$(openssl rand -base64 32)" +SESSION_COOKIE_SECRET="$(openssl rand -base64 48)" +MOCK_GITHUB_CLIENT_ID="Iv23e2eClient$(openssl rand -hex 4)" +MOCK_GITHUB_CLIENT_SECRET="$(openssl rand -hex 20)" +export JWT_PRIVATE_KEY JWT_PUBLIC_KEY KEK SESSION_COOKIE_SECRET +export MOCK_GITHUB_CLIENT_ID MOCK_GITHUB_CLIENT_SECRET + +# The allowlist is read through a bind mount; others must not be able to write it. +chmod 644 "$here/egress-allowlist.json" + +# The plugin as npm would receive it. +(cd "$root" && pnpm run build > /dev/null && pnpm pack --pack-destination "$work/pack" > /dev/null) +cp "$work"/pack/*.tgz "$work/plugin.tgz" + +finish() { + local status=$? + if [ "$status" -ne 0 ]; then + echo "--- the end-to-end suite failed; the deployment's logs follow ---" >&2 + "${compose[@]}" --profile plugin-api-off logs --no-color auth-server auth-server-plugin-api-off \ + mock-github migrate >&2 || true + fi + "${compose[@]}" --profile plugin-api-off down --volumes --remove-orphans > /dev/null 2>&1 || true + exit "$status" +} +trap finish EXIT + +wait_ready() { + local url="$1" service="$2" + for _ in $(seq 1 120); do + if curl --silent --fail "$url" 2> /dev/null | grep -q '"status":"ok"'; then + return 0 + fi + # A server that stopped at boot will not become ready: stop waiting. + if [ -n "$("${compose[@]}" --profile plugin-api-off ps --quiet --status exited "$service")" ]; then + echo "$service stopped during its boot" >&2 + return 1 + fi + sleep 1 + done + echo "$url did not become ready" >&2 + return 1 +} + +"${compose[@]}" build auth-server +"${compose[@]}" up --detach auth-server +wait_ready http://localhost:3000/ready auth-server +# The second server boots on the database the first one seeded. +"${compose[@]}" --profile plugin-api-off up --detach --no-build auth-server-plugin-api-off +wait_ready http://localhost:3010/ready auth-server-plugin-api-off + +cd "$root" +E2E_COMPOSE_FILE="$here/compose.yml" \ +E2E_WORK="$work" \ +NODE_EXTRA_CA_CERTS="$work/certs/ca.crt" \ + pnpm exec vitest run --config e2e/vitest.config.ts diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts new file mode 100644 index 0000000..881e7da --- /dev/null +++ b/e2e/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +// The end-to-end suite. It needs the deployment e2e/run.sh starts; run it +// through that script, not on its own. +export default defineConfig({ + test: { + include: ['e2e/**/*.e2e.test.ts'], + environment: 'node', + // One deployment, one realm: the tests run in order, in one file. + fileParallelism: false, + sequence: { concurrent: false }, + testTimeout: 60_000, + hookTimeout: 120_000, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f916537 --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "@qauth-labs/plugin-github", + "version": "0.1.0-rc.0", + "description": "GitHub sign-in for QAuth, as an AuthMethod plugin: OAuth 2.0 with PKCE and GitHub's user API, keyed by the numeric GitHub user id. Experimental.", + "keywords": [ + "qauth", + "qauth-plugin", + "auth-method", + "github", + "oauth", + "sign-in" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/qauth-labs/plugin-github#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/qauth-labs/plugin-github.git" + }, + "bugs": { + "url": "https://github.com/qauth-labs/plugin-github/issues" + }, + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=26.10.0" + }, + "packageManager": "pnpm@12.14.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "oxlint", + "format": "oxfmt", + "format:check": "oxfmt --check", + "test": "vitest run", + "e2e": "bash e2e/run.sh", + "prepack": "pnpm run build" + }, + "dependencies": { + "@qauth-labs/plugin-sdk": "0.2.0-rc.1" + }, + "peerDependencies": { + "zod": "^4.6.7" + }, + "devDependencies": { + "@types/node": "^26.6.5", + "oxfmt": "0.73.0", + "oxlint": "1.88.0", + "typescript": "~6.0.3", + "vitest": "^5.0.5", + "zod": "^4.6.7" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..57fb0ee --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1282 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.14.0 + version: 12.14.0 + +packages: + + '@pnpm/exe.android-arm64@12.14.0': + resolution: {integrity: sha512-+iSh7x21C3A2hqc+Z0ZmbKTcn6YNlzrXM142PsnQH/dLj0DYxFzoBvc6XAnL/yfQ5nnH2ebBiRj/zVxr61fZbQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.14.0': + resolution: {integrity: sha512-qT+0LqEr2wBVaxcG2T8cX/R8SSRz2Rvpp8nRR4fh8tnGUZRch87gFmsHUAoSQh7w6j1h0qTjrA384j4xi8frvw==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.14.0': + resolution: {integrity: sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.14.0': + resolution: {integrity: sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.14.0': + resolution: {integrity: sha512-Qg5rxBrGYl6Tfso+EyygPf84fY1Fzy55E/0or+iuYKtnf0vc0MlHJfVIlT7pBukMRntQHRik7hUi0Z4s7AlJsw==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.14.0': + resolution: {integrity: sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.14.0': + resolution: {integrity: sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.14.0': + resolution: {integrity: sha512-gSoryn7ySdXd//gjgf3MvvA8ZerJayaSbuMZMtAWq4vb2/wlVEgpl0Kvqt54y58TRj533Qjq2txKgnxd4JyoAQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.14.0': + resolution: {integrity: sha512-EMjew1H9WqQ7pL+tYqCPwiuB+0PeCiJRV7fyWeuFw0wwYUajFBjcaPO3IGAVu9sdmJDU+e+HYv/Zey6aKae0Vw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.14.0': + resolution: {integrity: sha512-shYOj/SFoXD35fHe0pWr2JofwSXulC0BnEIXLj2QokDi0EF//AfKsui35yKIkTEl+s2VJybc3PO4iJNhKIj5Qw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.14.0': + resolution: {integrity: sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.14.0': + resolution: {integrity: sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.14.0': + resolution: {integrity: sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.14.0': + resolution: {integrity: sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==} + cpu: [x64] + os: [win32] + + pnpm@12.14.0: + resolution: {integrity: sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.14.0': + optional: true + + '@pnpm/exe.android-x64@12.14.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.14.0': + optional: true + + '@pnpm/exe.darwin-x64@12.14.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.14.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.14.0': + optional: true + + '@pnpm/exe.linux-arm64@12.14.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.14.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.14.0': + optional: true + + '@pnpm/exe.linux-s390x@12.14.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.14.0': + optional: true + + '@pnpm/exe.linux-x64@12.14.0': + optional: true + + '@pnpm/exe.win32-arm64@12.14.0': + optional: true + + '@pnpm/exe.win32-x64@12.14.0': + optional: true + + pnpm@12.14.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.14.0 + '@pnpm/exe.android-x64': 12.14.0 + '@pnpm/exe.darwin-arm64': 12.14.0 + '@pnpm/exe.darwin-x64': 12.14.0 + '@pnpm/exe.freebsd-x64': 12.14.0 + '@pnpm/exe.linux-arm64': 12.14.0 + '@pnpm/exe.linux-arm64-musl': 12.14.0 + '@pnpm/exe.linux-ppc64': 12.14.0 + '@pnpm/exe.linux-riscv64': 12.14.0 + '@pnpm/exe.linux-s390x': 12.14.0 + '@pnpm/exe.linux-x64': 12.14.0 + '@pnpm/exe.linux-x64-musl': 12.14.0 + '@pnpm/exe.win32-arm64': 12.14.0 + '@pnpm/exe.win32-x64': 12.14.0 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@qauth-labs/plugin-sdk': + specifier: 0.2.0-rc.1 + version: 0.2.0-rc.1(zod@4.6.7) + devDependencies: + '@types/node': + specifier: ^26.6.5 + version: 26.6.6 + oxfmt: + specifier: 0.73.0 + version: 0.73.0 + oxlint: + specifier: 1.88.0 + version: 1.88.0 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@26.6.6)(vite@8.3.5(@types/node@26.6.6)) + zod: + specifier: ^4.6.7 + version: 4.6.7 + +packages: + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.73.0': + resolution: {integrity: sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.73.0': + resolution: {integrity: sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.73.0': + resolution: {integrity: sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.73.0': + resolution: {integrity: sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + resolution: {integrity: sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + resolution: {integrity: sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + resolution: {integrity: sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + resolution: {integrity: sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + resolution: {integrity: sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + resolution: {integrity: sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + resolution: {integrity: sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + resolution: {integrity: sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + resolution: {integrity: sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.73.0': + resolution: {integrity: sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.73.0': + resolution: {integrity: sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + resolution: {integrity: sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + resolution: {integrity: sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + resolution: {integrity: sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.88.0': + resolution: {integrity: sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.88.0': + resolution: {integrity: sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.88.0': + resolution: {integrity: sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.88.0': + resolution: {integrity: sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.88.0': + resolution: {integrity: sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + resolution: {integrity: sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + resolution: {integrity: sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + resolution: {integrity: sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.88.0': + resolution: {integrity: sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + resolution: {integrity: sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + resolution: {integrity: sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + resolution: {integrity: sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + resolution: {integrity: sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.88.0': + resolution: {integrity: sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.88.0': + resolution: {integrity: sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.88.0': + resolution: {integrity: sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + resolution: {integrity: sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + resolution: {integrity: sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.88.0': + resolution: {integrity: sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@qauth-labs/plugin-sdk@0.2.0-rc.1': + resolution: {integrity: sha512-HzJdOkoteT1sd8y6iOQ0HVpdBNB1kNuSXQFlhu6CwS8ZEFgRN05Hrx+TLBzQagwig7fXGAQVxj37khOJbjxS/w==} + engines: {node: '>=22'} + peerDependencies: + zod: ^4.6.7 + + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + + '@vitest/mocker@5.0.5': + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.5': + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + oxfmt@0.73.0: + resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + '@astrojs/compiler-rs': + optional: true + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.88.0: + resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.5: + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + +snapshots: + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.155.0': {} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + optional: true + + '@oxfmt/binding-android-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.73.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.73.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.73.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.88.0': + optional: true + + '@oxlint/binding-android-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-x64@1.88.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.88.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.88.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.88.0': + optional: true + + '@qauth-labs/plugin-sdk@0.2.0-rc.1(zod@4.6.7)': + dependencies: + zod: 4.6.7 + + '@rolldown/binding-android-arm-eabi@1.2.15': + optional: true + + '@rolldown/binding-android-arm64@1.2.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.15': + optional: true + + '@rolldown/binding-darwin-x64@1.2.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.15': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.15': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.15': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.10': {} + + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + + '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@26.6.6))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.5 + optionalDependencies: + vite: 8.3.5(@types/node@26.6.6) + + '@vitest/spy@5.0.5': {} + + assertion-error@2.0.1: {} + + chai@6.3.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@1.4.5: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + nanoid@3.3.20: {} + + obug@3.0.0: {} + + oxfmt@0.73.0: + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.73.0 + '@oxfmt/binding-android-arm64': 0.73.0 + '@oxfmt/binding-darwin-arm64': 0.73.0 + '@oxfmt/binding-darwin-x64': 0.73.0 + '@oxfmt/binding-freebsd-x64': 0.73.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.73.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.73.0 + '@oxfmt/binding-linux-arm64-gnu': 0.73.0 + '@oxfmt/binding-linux-arm64-musl': 0.73.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-musl': 0.73.0 + '@oxfmt/binding-linux-s390x-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-musl': 0.73.0 + '@oxfmt/binding-openharmony-arm64': 0.73.0 + '@oxfmt/binding-win32-arm64-msvc': 0.73.0 + '@oxfmt/binding-win32-ia32-msvc': 0.73.0 + '@oxfmt/binding-win32-x64-msvc': 0.73.0 + + oxlint@1.88.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.88.0 + '@oxlint/binding-android-arm64': 1.88.0 + '@oxlint/binding-darwin-arm64': 1.88.0 + '@oxlint/binding-darwin-x64': 1.88.0 + '@oxlint/binding-freebsd-x64': 1.88.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.88.0 + '@oxlint/binding-linux-arm-musleabihf': 1.88.0 + '@oxlint/binding-linux-arm64-gnu': 1.88.0 + '@oxlint/binding-linux-arm64-musl': 1.88.0 + '@oxlint/binding-linux-ppc64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-musl': 1.88.0 + '@oxlint/binding-linux-s390x-gnu': 1.88.0 + '@oxlint/binding-linux-x64-gnu': 1.88.0 + '@oxlint/binding-linux-x64-musl': 1.88.0 + '@oxlint/binding-openharmony-arm64': 1.88.0 + '@oxlint/binding-win32-arm64-msvc': 1.88.0 + '@oxlint/binding-win32-ia32-msvc': 1.88.0 + '@oxlint/binding-win32-x64-msvc': 1.88.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + rolldown@1.2.15: + dependencies: + '@oxc-project/types': 0.155.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 + + source-map-js@1.2.2: {} + + std-env@4.3.0: {} + + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.2.0: {} + + typescript@6.0.3: {} + + undici-types@8.9.0: {} + + vite@8.3.5(@types/node@26.6.6): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.32 + rolldown: 1.2.15 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.6 + fsevents: 2.3.3 + + vitest@5.0.5(@types/node@26.6.6)(vite@8.3.5(@types/node@26.6.6)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@26.6.6)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.5 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.5(@types/node@26.6.6) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.6 + transitivePeerDependencies: + - msw + + why-is-node-running@3.2.1: {} + + zod@4.6.7: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..1d95b50 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,17 @@ +# pnpm settings for this repository, which is one package (no workspace +# packages). The same rules as QAuth's own pnpm-workspace.yaml. + +# Supply-chain cooldown: a version is resolved only once it has been on the +# registry for 7 days (10080 minutes), long enough for a hijacked release to be +# reported and pulled. A version may be taken sooner only when it is listed +# below by exact version, with why and when it matures; remove each once it +# matures. +minimumReleaseAge: 10080 +minimumReleaseAgeExclude: + # QAuth's plugin SDK, provenance-signed by qauth-labs/qauth's release-npm + # workflow at tag v0.2.0-rc.1: the contract this plugin is written against. + # Published 2026-11-05T09:17:29Z: remove after 2026-11-12T09:18Z. + - '@qauth-labs/plugin-sdk@0.2.0-rc.1' + +# Never auto-install before a script runs; warn when node_modules is stale. +verifyDepsBeforeRun: warn diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..98292cb --- /dev/null +++ b/src/config.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +/** + * The plugin's config for one realm, and where GitHub answers for it. + * + * Without `baseUrl` the plugin signs people in at github.com. With it, at a + * GitHub Enterprise Server: its web pages at the base URL, its REST API under + * `/api/v3` there. A base URL that resolves to a private address works only + * when the operator's egress allowlist names it for the realm: the plugin + * makes every call through the egress client the server hands it. + */ + +/** github.com: its web origin, which is also the issuer of its user ids. */ +export const GITHUB_COM = 'https://github.com'; + +/** github.com's REST API. */ +export const GITHUB_COM_API = 'https://api.github.com'; + +/** + * A base URL: `https://` and a host name or IPv4 address, an optional port, + * an optional trailing `/`, and nothing else (no credentials, path, query or + * fragment). GitHub Enterprise Server answers at the root of its host. + */ +export const BASE_URL_PATTERN = + /^https:\/\/[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?(?::[0-9]{1,5})?\/?$/; + +/** The config an admin gives the plugin for one realm. */ +export const configSchema = z.object({ + /** The client id of the GitHub OAuth app (or GitHub App) registered for this realm. */ + clientId: z + .string() + .min(1) + .max(255) + .regex(/^[\x21-\x7e]+$/), + /** Its client secret. Write-only: the server stores it encrypted and never shows it again. */ + clientSecret: z.string().min(1).max(1024).meta({ writeOnly: true }), + /** + * The base URL of a GitHub Enterprise Server, such as + * `https://github.example.com`. Leave it out for github.com. + */ + baseUrl: z + .url({ protocol: /^https$/ }) + .max(270) + .regex(BASE_URL_PATTERN) + .optional(), +}); + +/** The parsed config. */ +export type GitHubConfig = z.output; + +/** Where one GitHub answers. */ +export interface GitHubEndpoints { + /** + * The issuer of the user ids: `https://github.com`, or the origin of the + * Enterprise Server. A numeric id is unique only within it. + */ + readonly issuer: string; + /** The page the browser is sent to. */ + readonly authorize: string; + /** Where the code is redeemed. */ + readonly token: string; + /** The REST API's root, without a trailing `/`. */ + readonly api: string; + /** + * Who set the destinations, for the egress client: `operator` for + * github.com when the config names no base URL (this package's own code + * names it), `admin` for a base URL the realm's config names. + */ + readonly source: 'operator' | 'admin'; +} + +/** The endpoints of the GitHub `config` names. */ +export function endpointsOf(config: Pick): GitHubEndpoints { + const origin = config.baseUrl === undefined ? GITHUB_COM : new URL(config.baseUrl).origin; + if (origin === GITHUB_COM) { + return { + issuer: GITHUB_COM, + authorize: `${GITHUB_COM}/login/oauth/authorize`, + token: `${GITHUB_COM}/login/oauth/access_token`, + api: GITHUB_COM_API, + source: config.baseUrl === undefined ? 'operator' : 'admin', + }; + } + return { + issuer: origin, + authorize: `${origin}/login/oauth/authorize`, + token: `${origin}/login/oauth/access_token`, + api: `${origin}/api/v3`, + source: 'admin', + }; +} diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..275b91d --- /dev/null +++ b/src/github.ts @@ -0,0 +1,259 @@ +import type { + AuthMethodContext, + AuthMethodEgressRequest, + AuthMethodEgressResponse, + UpstreamFailureReason, +} from '@qauth-labs/plugin-sdk'; +import { z } from 'zod'; + +import { endpointsOf, type GitHubConfig } from './config.js'; + +/** + * GitHub's side of a sign-in: redeem the code, then read the person with the + * access token. GitHub offers no OpenID Connect sign-in (no ID token, no + * `nonce`), so the person is whoever the REST API says the token belongs to. + * + * Every call goes through the egress client the server hands the plugin + * (`context.egress`), never `fetch`. Each call that carries a credential (the + * code and the client secret, the access token) follows no redirect, so a + * `307` cannot send the credential on. The access token is used for the two + * reads of one sign-in and then dropped: it is never stored, logged or handed + * to the server. + */ + +/** The scope the plugin asks for: the person's email addresses, read-only. Nothing else. */ +export const GITHUB_SCOPE = 'user:email'; + +/** The REST API version the plugin is written against. */ +export const GITHUB_API_VERSION = '2022-11-28'; + +/** GitHub refuses an API call without a `User-Agent`. */ +export const USER_AGENT = 'qauth-labs-plugin-github'; + +/** The most email addresses one page of `/user/emails` holds (GitHub's own maximum). */ +const EMAILS_PER_PAGE = 100; + +/** An OAuth error code as it may be logged: printable ASCII, short. */ +const LOGGABLE_ERROR_CODE = /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,64}$/; + +/** A sign-in that cannot go on, with the reason the server is told. */ +export class GitHubFailure extends Error { + override readonly name = 'GitHubFailure'; + constructor( + readonly reason: UpstreamFailureReason, + message: string + ) { + super(message); + } +} + +/** The person as GitHub's REST API describes them. */ +export interface GitHubPerson { + /** The numeric user id, as a decimal string: the subject. Never changes. */ + readonly id: string; + /** The login name. It can change, so it is never the subject. */ + readonly login: string; + /** The display name, when the person set one. */ + readonly name: string | undefined; + /** The primary email address, when GitHub lists exactly one. */ + readonly email: string | undefined; + /** Whether GitHub verified that primary address. */ + readonly emailVerified: boolean; +} + +const tokenAnswerSchema = z.object({ + access_token: z.string().min(1).max(4096), + token_type: z.string().optional(), +}); + +const userSchema = z.object({ + id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + login: z.string().min(1).max(255), + name: z.string().nullish(), +}); + +const emailsSchema = z + .array( + z.object({ + email: z.string().min(3).max(320), + primary: z.boolean(), + verified: z.boolean(), + }) + ) + .max(EMAILS_PER_PAGE); + +/** The JSON of an answer's body, or `undefined` when it is not JSON. */ +function jsonBody(answer: AuthMethodEgressResponse): unknown { + try { + return JSON.parse(Buffer.from(answer.body).toString('utf8')) as unknown; + } catch { + return undefined; + } +} + +/** The `error` member of an OAuth error answer, as it may be logged. */ +function loggableError(body: unknown): string { + const error = + typeof body === 'object' && body !== null + ? (body as Record)['error'] + : undefined; + return typeof error === 'string' && LOGGABLE_ERROR_CODE.test(error) ? error : 'no error code'; +} + +/** Send one call; a refused or failed call means GitHub cannot be reached. */ +async function send( + context: AuthMethodContext, + request: AuthMethodEgressRequest +): Promise { + try { + return await context.egress.request(request); + } catch { + // The egress client's error can quote the URL; the purpose says enough. + context.log.warn( + { purpose: request.purpose }, + 'GitHub could not be reached, or the egress client refused the call' + ); + throw new GitHubFailure('upstream_unavailable', `${request.purpose}: no answer`); + } +} + +/** Throw for a status that is not `200`: `429` and `5xx` mean GitHub is unavailable. */ +function checkStatus( + context: AuthMethodContext, + answer: AuthMethodEgressResponse, + purpose: string +): void { + if (answer.status === 200) return; + context.log.warn({ purpose, status: answer.status }, 'GitHub answered a status that is not 200'); + if (answer.status === 429 || answer.status >= 500) { + throw new GitHubFailure('upstream_unavailable', `${purpose}: ${String(answer.status)}`); + } + throw new GitHubFailure('invalid_response', `${purpose}: ${String(answer.status)}`); +} + +/** + * Redeem the code at GitHub's token endpoint, asking for JSON; the access + * token. The request carries the PKCE verifier, and the same `redirect_uri` + * the authorization request named. + */ +export async function redeemCode( + context: AuthMethodContext, + code: string, + codeVerifier: string, + redirectUri: string +): Promise { + const { config } = context; + const endpoints = endpointsOf(config); + const purpose = 'upstream-token'; + const answer = await send(context, { + method: 'POST', + url: endpoints.token, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + 'user-agent': USER_AGENT, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + client_id: config.clientId, + client_secret: config.clientSecret, + }).toString(), + purpose, + source: endpoints.source, + // The body carries the code and the secret: a 307 must not send it on. + redirect: 'error', + }); + if (answer.status === 429 || answer.status >= 500) { + checkStatus(context, answer, purpose); + } + const body = jsonBody(answer); + // GitHub answers an OAuth error with 200 and an `error` member. + if ( + (typeof body === 'object' && body !== null && 'error' in body) || + answer.status === 400 || + answer.status === 401 || + answer.status === 403 + ) { + const code = loggableError(body); + context.log.warn({ purpose, status: answer.status, error: code }, 'GitHub refused the code'); + throw new GitHubFailure('provider_error', `${purpose}: ${code}`); + } + checkStatus(context, answer, purpose); + const token = tokenAnswerSchema.safeParse(body); + if (!token.success) { + throw new GitHubFailure('invalid_response', `${purpose}: no access token`); + } + if (token.data.token_type !== undefined && token.data.token_type.toLowerCase() !== 'bearer') { + throw new GitHubFailure('invalid_response', `${purpose}: not a bearer token`); + } + return token.data.access_token; +} + +/** One read of GitHub's REST API with the access token; its JSON. */ +async function readApi( + context: AuthMethodContext, + accessToken: string, + path: string, + purpose: string +): Promise { + const endpoints = endpointsOf(context.config); + const answer = await send(context, { + method: 'GET', + url: `${endpoints.api}${path}`, + headers: { + authorization: `Bearer ${accessToken}`, + accept: 'application/vnd.github+json', + 'x-github-api-version': GITHUB_API_VERSION, + 'user-agent': USER_AGENT, + }, + purpose, + source: endpoints.source, + // The request carries the access token: follow no redirect with it. + redirect: 'error', + }); + checkStatus(context, answer, purpose); + return jsonBody(answer); +} + +/** + * Read the person the access token belongs to: `GET /user` for the id, the + * login and the display name, and `GET /user/emails` (the `user:email` + * scope) for the primary address and whether GitHub verified it. + */ +export async function readPerson( + context: AuthMethodContext, + accessToken: string +): Promise { + const [userBody, emailsBody] = await Promise.all([ + readApi(context, accessToken, '/user', 'upstream-user'), + readApi( + context, + accessToken, + `/user/emails?per_page=${String(EMAILS_PER_PAGE)}`, + 'upstream-user-emails' + ), + ]); + const user = userSchema.safeParse(userBody); + if (!user.success) { + throw new GitHubFailure('invalid_response', 'upstream-user: not a GitHub user'); + } + const emails = emailsSchema.safeParse(emailsBody); + if (!emails.success) { + throw new GitHubFailure('invalid_response', 'upstream-user-emails: not a list of addresses'); + } + // GitHub marks one address primary. Only that one is taken, and it counts + // as verified only when GitHub says so; a list with none, or with two, + // gives no address at all. + const primary = emails.data.filter((entry) => entry.primary); + const chosen = primary.length === 1 ? primary[0] : undefined; + return { + id: String(user.data.id), + login: user.data.login, + name: user.data.name ?? undefined, + email: chosen?.email, + emailVerified: chosen?.verified === true, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a427381 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,21 @@ +/** + * `@qauth-labs/plugin-github` — GitHub sign-in for QAuth, as an AuthMethod + * plugin. EXPERIMENTAL, like the plugin API it is written against. + * + * The operator installs the package with the deployment, lists it in + * `AUTH_METHOD_PLUGINS` and turns on `PLUGIN_API_ENABLED`; each realm then + * enables and configures it through the admin API. See the README. + * + * @packageDocumentation + */ +export { + BASE_URL_PATTERN, + configSchema, + endpointsOf, + GITHUB_COM, + GITHUB_COM_API, + type GitHubConfig, + type GitHubEndpoints, +} from './config.js'; +export { GITHUB_API_VERSION, GITHUB_SCOPE } from './github.js'; +export { authMethod, CALLBACK_PATH, GITHUB_TYPE, type GitHubTransaction } from './method.js'; diff --git a/src/method.ts b/src/method.ts new file mode 100644 index 0000000..c1992b5 --- /dev/null +++ b/src/method.ts @@ -0,0 +1,229 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +import { + AUTH_METHOD_CONTRACT, + type AuthMethod, + type AuthMethodParameters, + UPSTREAM_STEP_URN, + type UpstreamCallbackOutcome, + type UserAttribute, + type VerifiedIdentity, +} from '@qauth-labs/plugin-sdk'; + +import { configSchema, endpointsOf, type GitHubConfig } from './config.js'; +import { GITHUB_SCOPE, GitHubFailure, readPerson, redeemCode } from './github.js'; + +/** + * GitHub sign-in as a QAuth AuthMethod (contract `qauth-auth-method/1-rc.0`). + * + * 1. `start()` makes a fresh `state` and PKCE verifier for each sign-in, + * keeps both in the transaction the server holds, and sends the browser + * to GitHub's authorization page with the state, the verifier's S256 + * challenge and the `user:email` scope. The state is the binding: the + * server refuses a callback that does not carry it back before this code + * runs. + * 2. GitHub sends the browser back to `/upstream/github/callback`, a URL of + * this provider alone. GitHub issues no ID token and no `nonce`, so the + * state, checked against the flow cookie the start bound, and that URL are + * what tie the answer to the sign-in that asked for it. + * 3. The callback redeems the code (with the verifier), reads the person with + * the access token, and drops the token. The person is their numeric + * GitHub user id under the GitHub that issued it; the login name, which + * can change, is only an attribute. + */ + +/** The method's type: its route prefix (`/upstream/github/…`) and the source of its attributes. */ +export const GITHUB_TYPE = 'github'; + +/** The path of the callback, below the method's prefix. */ +export const CALLBACK_PATH = '/callback'; + +/** What `start()` asks the server to keep until the callback. */ +export interface GitHubTransaction { + readonly state: string; + /** The PKCE verifier (RFC 7636 §4.1): 32 random bytes, base64url. */ + readonly codeVerifier: string; +} + +/** What the callback hands `verify()`: never the access token. */ +interface GitHubResult { + readonly issuer: string; + readonly id: string; + readonly login: string; + readonly name: string | undefined; + readonly email: string | undefined; + readonly emailVerified: boolean; +} + +/** The results the callback produced and `verify()` has not used yet. */ +const issued = new WeakSet(); + +/** The longest authorization code the callback takes. */ +const MAX_CODE_LENGTH = 1024; + +/** The longest attribute value the server accepts. */ +const MAX_ATTRIBUTE_LENGTH = 8192; + +/** A control character an attribute value may not hold: C0 but tab and line breaks, and DEL. */ +// eslint-disable-next-line no-control-regex -- matching control characters is the point +const CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/; + +/** One parameter's single value; a missing or repeated one is `undefined`. */ +function single(parameters: AuthMethodParameters, name: string): string | undefined { + const value = parameters[name]; + return typeof value === 'string' ? value : undefined; +} + +/** The S256 code challenge of a PKCE verifier (RFC 7636 §4.2). */ +function s256(verifier: string): string { + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +} + +/** Constant-time string equality. */ +function sameText(left: string, right: string): boolean { + const a = Buffer.from(left, 'utf8'); + const b = Buffer.from(right, 'utf8'); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** The transaction the server handed back, as `start()` made it. */ +function transactionOf(value: unknown): GitHubTransaction | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const { state, codeVerifier } = value as Record; + return typeof state === 'string' && typeof codeVerifier === 'string' + ? { state, codeVerifier } + : undefined; +} + +/** Whether the server takes `value` as an attribute value. */ +function isAttributeValue(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_ATTRIBUTE_LENGTH && + !CONTROL_CHARACTER.test(value) && + (String.prototype as unknown as { isWellFormed(this: string): boolean }).isWellFormed.call( + value + ) + ); +} + +/** The plugin's method: what the package exports as `authMethod`. */ +export const authMethod: AuthMethod = { + contract: AUTH_METHOD_CONTRACT, + type: GITHUB_TYPE, + id: 'github', + display: { name: 'GitHub' }, + configSchema, + steps: [UPSTREAM_STEP_URN], + + async start(_request, context) { + const { config } = context; + // 32 random bytes each: 43 base64url characters. + const state = randomBytes(32).toString('base64url'); + const codeVerifier = randomBytes(32).toString('base64url'); + const url = new URL(endpointsOf(config).authorize); + // GitHub ignores `response_type`; it is sent for the OAuth 2.0 request's + // own shape (RFC 6749 §4.1.1). + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', config.clientId); + url.searchParams.set('redirect_uri', context.routeUrl(CALLBACK_PATH)); + url.searchParams.set('scope', GITHUB_SCOPE); + url.searchParams.set('state', state); + // github.com accepts PKCE with S256 for OAuth apps and GitHub Apps. A + // server that does not know the parameters ignores them (RFC 6749 §3.1). + url.searchParams.set('code_challenge', s256(codeVerifier)); + url.searchParams.set('code_challenge_method', 'S256'); + return { redirectTo: url.href, transaction: { state, codeVerifier }, binding: state }; + }, + + routes: [ + { + kind: 'callback', + method: 'GET', + path: CALLBACK_PATH, + // The server has matched `state` with this transaction's binding + // before this runs; it is compared once more here, for nothing. + async handle(request, context): Promise { + const transaction = transactionOf(request.transaction); + const state = single(request.query, 'state'); + if ( + transaction === undefined || + state === undefined || + !sameText(state, transaction.state) + ) { + return { kind: 'failed', reason: 'transaction_mismatch' }; + } + const error = single(request.query, 'error'); + if (error === 'access_denied') return { kind: 'declined' }; + if (error !== undefined) return { kind: 'failed', reason: 'provider_error' }; + const code = single(request.query, 'code'); + if (code === undefined || code.length === 0 || code.length > MAX_CODE_LENGTH) { + return { kind: 'failed', reason: 'invalid_response' }; + } + + let result: GitHubResult; + try { + const accessToken = await redeemCode( + context, + code, + transaction.codeVerifier, + context.routeUrl(CALLBACK_PATH) + ); + // The token is used for these two reads, then dropped. + const person = await readPerson(context, accessToken); + result = Object.freeze({ issuer: endpointsOf(context.config).issuer, ...person }); + } catch (failure) { + if (failure instanceof GitHubFailure) { + return { kind: 'failed', reason: failure.reason }; + } + throw failure; + } + issued.add(result); + return { kind: 'result', result }; + }, + }, + ], + + async verify(input: unknown): Promise { + if (typeof input !== 'object' || input === null || !issued.has(input)) { + throw new Error('not a result of the GitHub callback'); + } + // Single use: a result signs one person in once. + issued.delete(input); + const result = input as GitHubResult; + return { + // The numeric id, never the login: a login can change hands. + externalSub: result.id, + assuranceLevel: 'low', + rawClaims: { + // The GitHub that assigned the id: an id is unique only within it. + iss: result.issuer, + sub: result.id, + login: result.login, + ...(result.name === undefined ? {} : { name: result.name }), + ...(result.email === undefined + ? {} + : { email: result.email, email_verified: result.emailVerified }), + }, + }; + }, + + extractAttributes(identity: VerifiedIdentity): UserAttribute[] { + const claims = identity.rawClaims; + const claim = (name: string): unknown => + Object.hasOwn(claims, name) ? claims[name] : undefined; + const attributes: UserAttribute[] = []; + const add = (attrKey: string, value: unknown, verified: boolean): void => { + if (isAttributeValue(value)) { + attributes.push({ source: GITHUB_TYPE, attrKey, attrValue: value, verified }); + } + }; + // Verified only for the primary address GitHub marks verified. + add('email', claim('email'), claim('email_verified') === true); + // The login and the display name are the person's own words: never verified. + add('preferred_username', claim('login'), false); + add('name', claim('name'), false); + return attributes; + }, +}; diff --git a/test/contract.test.ts b/test/contract.test.ts new file mode 100644 index 0000000..4d7da8e --- /dev/null +++ b/test/contract.test.ts @@ -0,0 +1,29 @@ +/** + * The plugin meets the AuthMethod contract, as QAuth's server holds a plugin + * to it: the SDK's contract test kit, `checkAuthMethod`, with a fake GitHub + * playing the provider. + */ +import { AUTH_METHOD_CONTRACT, AUTH_METHOD_PLUGIN_EXPORT } from '@qauth-labs/plugin-sdk'; +import { checkAuthMethod } from '@qauth-labs/plugin-sdk/testing'; +import { describe, expect, it } from 'vitest'; + +import * as plugin from '../src/index.js'; +import { CONFIG, FakeGitHub } from './fake-github.js'; + +describe('the package', () => { + it('exports its method as authMethod, written against the SDK’s contract', () => { + expect(AUTH_METHOD_PLUGIN_EXPORT in plugin).toBe(true); + expect(plugin.authMethod.contract).toBe(AUTH_METHOD_CONTRACT); + }); + + it('meets the AuthMethod contract for github.com', async () => { + // Rejects with an AuthMethodContractError that lists every failed check. + const report = await checkAuthMethod(plugin.authMethod, new FakeGitHub().scenario()); + expect(report.checks.length).toBeGreaterThan(0); + }); + + it('meets the AuthMethod contract for a GitHub Enterprise Server', async () => { + const config = { ...CONFIG, baseUrl: 'https://github.corp.example' }; + await checkAuthMethod(plugin.authMethod, new FakeGitHub(config).scenario()); + }); +}); diff --git a/test/fake-context.ts b/test/fake-context.ts new file mode 100644 index 0000000..2fc1332 --- /dev/null +++ b/test/fake-context.ts @@ -0,0 +1,52 @@ +/** + * The context the server hands the method, faked for the unit tests: the + * config, the egress client (answered by a fake GitHub), a signer the plugin + * never needs, a logger that keeps every line, and `routeUrl` on a test + * realm. The contract test kit builds the server's own context instead. + */ +import type { + AuthMethodContext, + AuthMethodEgressRequest, + AuthMethodEgressResponse, +} from '@qauth-labs/plugin-sdk'; + +import type { GitHubConfig } from '../src/config.js'; + +/** The realm's origin in the unit tests. */ +export const REALM_ORIGIN = 'https://realm.test'; + +/** One log line the method wrote. */ +export interface LogLine { + readonly level: 'debug' | 'info' | 'warn' | 'error'; + readonly fields: Record; + readonly message: string; +} + +export interface FakeContext extends AuthMethodContext { + readonly lines: LogLine[]; +} + +export function fakeContext( + config: GitHubConfig, + egress: ( + request: AuthMethodEgressRequest + ) => AuthMethodEgressResponse | Promise +): FakeContext { + const lines: LogLine[] = []; + const logAt = + (level: LogLine['level']) => + (fields: Record, message: string): void => { + lines.push({ level, fields, message }); + }; + return { + config, + lines, + egress: { request: async (request) => egress(request) }, + signer: { + signClientAssertion: () => Promise.reject(new Error('the plugin signs nothing')), + getUpstreamJwks: () => Promise.reject(new Error('the plugin signs nothing')), + }, + log: { debug: logAt('debug'), info: logAt('info'), warn: logAt('warn'), error: logAt('error') }, + routeUrl: (path) => `${REALM_ORIGIN}/upstream/github${path}`, + }; +} diff --git a/test/fake-github.ts b/test/fake-github.ts new file mode 100644 index 0000000..adc650f --- /dev/null +++ b/test/fake-github.ts @@ -0,0 +1,206 @@ +/** + * A fake GitHub for the unit tests and the contract test kit: the + * authorization page, the token endpoint, `GET /user` and + * `GET /user/emails`, answering the plugin's egress calls as GitHub does + * (an OAuth error at the token endpoint is a `200` with an `error` member). + * It checks what GitHub checks: the client, the code, the `redirect_uri`, + * the PKCE verifier, the bearer token and the `User-Agent`. + */ +import { createHash, randomBytes } from 'node:crypto'; + +import type { AuthMethodEgressRequest, AuthMethodEgressResponse } from '@qauth-labs/plugin-sdk'; +import type { AuthMethodKitRequest, AuthMethodKitScenario } from '@qauth-labs/plugin-sdk/testing'; + +import { endpointsOf, type GitHubConfig } from '../src/config.js'; + +/** One email address as `GET /user/emails` lists it. */ +export interface FakeEmail { + email: string; + primary: boolean; + verified: boolean; + visibility?: 'public' | 'private' | null; +} + +/** The person signed in at the fake. Tests change it between sign-ins. */ +export interface FakePerson { + id: number; + login: string; + name: string | null; + emails: FakeEmail[]; +} + +/** The config the tests give the plugin, with a secret of realistic length. */ +export const CONFIG: GitHubConfig = { + clientId: 'Ov23liFakeClientId01', + clientSecret: 'fake-github-client-secret-0123456789abcdef', +}; + +/** What the fake knows about a code it issued. */ +interface IssuedCode { + readonly redirectUri: string; + readonly challenge: string | undefined; + readonly challengeMethod: string | undefined; +} + +function answer( + request: AuthMethodEgressRequest, + status: number, + body: unknown +): AuthMethodEgressResponse { + return { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: new TextEncoder().encode(JSON.stringify(body)), + url: request.url, + }; +} + +function s256(verifier: string): string { + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +} + +export class FakeGitHub { + readonly person: FakePerson; + /** Every call the plugin made, in order. */ + readonly calls: AuthMethodEgressRequest[] = []; + /** Every access token the fake issued. */ + readonly tokens: string[] = []; + /** Answers that replace the fake's own, by path, for one test. */ + readonly overrides = new Map< + string, + (request: AuthMethodEgressRequest) => AuthMethodEgressResponse + >(); + private readonly codes = new Map(); + private readonly people = new Map(); + + constructor( + readonly config: GitHubConfig = CONFIG, + person?: Partial + ) { + this.person = { + id: 1_234_567, + login: 'octo-person', + name: 'Octo Person', + emails: [ + { email: 'octo@example.com', primary: true, verified: true, visibility: 'private' }, + { email: '1234567+octo-person@users.noreply.github.com', primary: false, verified: true }, + ], + ...person, + }; + } + + /** The person signs in on GitHub's authorization page; the code it redirects with. */ + authorize(authorizationRequest: URL): string { + const endpoints = endpointsOf(this.config); + if (`${authorizationRequest.origin}${authorizationRequest.pathname}` !== endpoints.authorize) { + throw new Error(`not the authorization page: ${authorizationRequest.href}`); + } + const query = authorizationRequest.searchParams; + if (query.get('client_id') !== this.config.clientId) throw new Error('another client'); + const redirectUri = query.get('redirect_uri'); + if (redirectUri === null) throw new Error('no redirect_uri'); + const code = randomBytes(10).toString('hex'); + this.codes.set(code, { + redirectUri, + challenge: query.get('code_challenge') ?? undefined, + challengeMethod: query.get('code_challenge_method') ?? undefined, + }); + return code; + } + + /** Answer one of the plugin's calls. */ + answer = (request: AuthMethodEgressRequest): AuthMethodEgressResponse => { + this.calls.push(request); + const endpoints = endpointsOf(this.config); + const url = new URL(request.url); + const override = this.overrides.get(url.pathname); + if (override !== undefined) return override(request); + if (request.headers?.['user-agent'] === undefined) { + return answer(request, 403, { message: 'Request forbidden by administrative rules.' }); + } + if (request.method === 'POST' && request.url === endpoints.token) return this.token(request); + if (request.method === 'GET' && url.origin + url.pathname === `${endpoints.api}/user`) { + const person = this.bearer(request); + if (person === undefined) return answer(request, 401, { message: 'Bad credentials' }); + return answer(request, 200, { + login: person.login, + id: person.id, + node_id: 'U_fake', + type: 'User', + name: person.name, + email: null, + }); + } + if (request.method === 'GET' && url.origin + url.pathname === `${endpoints.api}/user/emails`) { + const person = this.bearer(request); + if (person === undefined) return answer(request, 401, { message: 'Bad credentials' }); + return answer(request, 200, person.emails); + } + return answer(request, 404, { message: 'Not Found' }); + }; + + private token(request: AuthMethodEgressRequest): AuthMethodEgressResponse { + const form = new URLSearchParams(typeof request.body === 'string' ? request.body : ''); + if ( + form.get('client_id') !== this.config.clientId || + form.get('client_secret') !== this.config.clientSecret + ) { + return answer(request, 200, { error: 'incorrect_client_credentials' }); + } + const code = form.get('code') ?? ''; + const issued = this.codes.get(code); + // A code is good once. + this.codes.delete(code); + if (issued === undefined || form.get('redirect_uri') !== issued.redirectUri) { + return answer(request, 200, { error: 'bad_verification_code' }); + } + if (issued.challenge !== undefined) { + const verifier = form.get('code_verifier') ?? ''; + if (issued.challengeMethod !== 'S256' || s256(verifier) !== issued.challenge) { + return answer(request, 200, { error: 'bad_verification_code' }); + } + } + const token = `gho_${randomBytes(18).toString('base64url')}`; + this.tokens.push(token); + // The person as they are now, so a rename between sign-ins shows. + this.people.set(token, this.person); + return answer(request, 200, { access_token: token, token_type: 'bearer', scope: 'user:email' }); + } + + private bearer(request: AuthMethodEgressRequest): FakePerson | undefined { + const header = request.headers?.['authorization'] ?? ''; + const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : ''; + return this.people.get(token); + } + + /** The browser back at the callback, signed in. */ + callback(authorizationRequest: URL): AuthMethodKitRequest { + const code = this.authorize(authorizationRequest); + const state = authorizationRequest.searchParams.get('state') ?? ''; + return { path: '/callback', method: 'GET', query: { code, state } }; + } + + /** The browser back at the callback, the person having declined. */ + declined(authorizationRequest: URL): AuthMethodKitRequest { + const state = authorizationRequest.searchParams.get('state') ?? ''; + return { + path: '/callback', + method: 'GET', + query: { + error: 'access_denied', + error_description: 'The user has denied your application access.', + state, + }, + }; + } + + /** The contract test kit's scenario, played by this fake. */ + scenario(): AuthMethodKitScenario { + return { + config: this.config, + egress: this.answer, + callback: (request) => this.callback(request), + declined: (request) => this.declined(request), + }; + } +} diff --git a/test/method.test.ts b/test/method.test.ts new file mode 100644 index 0000000..9558ee7 --- /dev/null +++ b/test/method.test.ts @@ -0,0 +1,406 @@ +import { createHash } from 'node:crypto'; + +import type { + AuthMethodCallbackRoute, + AuthMethodEgressRequest, + AuthMethodParameters, + UpstreamCallbackOutcome, + UserAttribute, + VerifiedIdentity, +} from '@qauth-labs/plugin-sdk'; +import { assertAuthMethod } from '@qauth-labs/plugin-sdk'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { configSchema, type GitHubConfig } from '../src/config.js'; +import { authMethod, type GitHubTransaction } from '../src/index.js'; +import { type FakeContext, fakeContext, REALM_ORIGIN } from './fake-context.js'; +import { CONFIG, FakeGitHub } from './fake-github.js'; + +const callback = authMethod.routes[0] as AuthMethodCallbackRoute; + +/** One sign-in, from `start()` to the callback's outcome. */ +async function callbackOutcome( + fake: FakeGitHub, + context: FakeContext, + query?: (authorizationRequest: URL, code: string) => AuthMethodParameters +): Promise { + const start = await authMethod.start({ uiLocales: [] }, context); + const authorizationRequest = new URL(start.redirectTo); + const code = fake.authorize(authorizationRequest); + return callback.handle( + { + method: 'GET', + query: query?.(authorizationRequest, code) ?? { + code, + state: authorizationRequest.searchParams.get('state') ?? '', + }, + form: {}, + transaction: start.transaction, + }, + context + ); +} + +/** One whole sign-in: the identity and the attributes the server gets. */ +async function signIn( + fake: FakeGitHub, + context: FakeContext +): Promise<{ identity: VerifiedIdentity; attributes: UserAttribute[] }> { + const outcome = await callbackOutcome(fake, context); + if (outcome.kind !== 'result') throw new Error(`no result: ${JSON.stringify(outcome)}`); + const identity = await authMethod.verify(outcome.result); + return { identity, attributes: authMethod.extractAttributes(identity) }; +} + +function attribute(attributes: UserAttribute[], key: string): UserAttribute | undefined { + return attributes.find((entry) => entry.attrKey === key); +} + +describe('the GitHub AuthMethod', () => { + let fake: FakeGitHub; + let context: FakeContext; + + beforeEach(() => { + fake = new FakeGitHub(); + context = fakeContext(CONFIG, fake.answer); + }); + + it('meets the static checks of the contract', () => { + expect(() => { + assertAuthMethod(authMethod); + }).not.toThrow(); + expect(authMethod.type).toBe('github'); + expect(authMethod.id).toBe('github'); + expect(authMethod.routes.map((route) => `${route.method} ${route.path}`)).toEqual([ + 'GET /callback', + ]); + }); + + describe('start()', () => { + it('sends the browser to GitHub with state, PKCE S256, the user:email scope and its own callback', async () => { + const start = await authMethod.start({ uiLocales: ['tr'] }, context); + const url = new URL(start.redirectTo); + expect(`${url.origin}${url.pathname}`).toBe('https://github.com/login/oauth/authorize'); + expect(url.searchParams.get('client_id')).toBe(CONFIG.clientId); + expect(url.searchParams.get('redirect_uri')).toBe(`${REALM_ORIGIN}/upstream/github/callback`); + expect(url.searchParams.get('scope')).toBe('user:email'); + expect(url.searchParams.get('state')).toBe(start.transaction.state); + expect(start.binding).toBe(start.transaction.state); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('code_challenge')).toBe( + createHash('sha256').update(start.transaction.codeVerifier, 'ascii').digest('base64url') + ); + // No call before the browser comes back. + expect(fake.calls).toEqual([]); + }); + + it('makes a fresh state and verifier for each sign-in', async () => { + const first = await authMethod.start({ uiLocales: [] }, context); + const second = await authMethod.start({ uiLocales: [] }, context); + expect(first.transaction.state).not.toBe(second.transaction.state); + expect(first.transaction.codeVerifier).not.toBe(second.transaction.codeVerifier); + expect(first.transaction.state).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(first.transaction.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('sends the browser to a GitHub Enterprise Server at its base URL', async () => { + const config = { ...CONFIG, baseUrl: 'https://github.corp.example/' }; + const start = await authMethod.start({ uiLocales: [] }, fakeContext(config, fake.answer)); + const url = new URL(start.redirectTo); + expect(`${url.origin}${url.pathname}`).toBe( + 'https://github.corp.example/login/oauth/authorize' + ); + }); + }); + + describe('the callback', () => { + it('redeems the code with the verifier, reads the person, and keys the identity by the numeric id', async () => { + const { identity, attributes } = await signIn(fake, context); + expect(identity.externalSub).toBe('1234567'); + expect(identity.assuranceLevel).toBe('low'); + expect(identity.rawClaims).toEqual({ + iss: 'https://github.com', + sub: '1234567', + login: 'octo-person', + name: 'Octo Person', + email: 'octo@example.com', + email_verified: true, + }); + expect(attributes).toEqual([ + { source: 'github', attrKey: 'email', attrValue: 'octo@example.com', verified: true }, + { + source: 'github', + attrKey: 'preferred_username', + attrValue: 'octo-person', + verified: false, + }, + { source: 'github', attrKey: 'name', attrValue: 'Octo Person', verified: false }, + ]); + }); + + it('asks the token endpoint for JSON, sends the secret only there, and follows no redirect with a credential', async () => { + await signIn(fake, context); + const [token, ...reads] = fake.calls; + expect(token).toMatchObject({ + method: 'POST', + url: 'https://github.com/login/oauth/access_token', + purpose: 'upstream-token', + source: 'operator', + redirect: 'error', + }); + expect(token?.headers?.['accept']).toBe('application/json'); + const form = new URLSearchParams(token?.body as string); + expect(form.get('grant_type')).toBe('authorization_code'); + expect(form.get('redirect_uri')).toBe(`${REALM_ORIGIN}/upstream/github/callback`); + expect(form.get('client_secret')).toBe(CONFIG.clientSecret); + expect(form.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/); + + expect(reads.map((call) => new URL(call.url).pathname).sort()).toEqual([ + '/user', + '/user/emails', + ]); + for (const read of reads) { + expect(read).toMatchObject({ method: 'GET', redirect: 'error', source: 'operator' }); + expect(read.url.startsWith('https://api.github.com/')).toBe(true); + expect(read.headers?.['authorization']).toBe(`Bearer ${fake.tokens[0] ?? ''}`); + expect(read.headers?.['user-agent']).toBe('qauth-labs-plugin-github'); + expect(JSON.stringify(read)).not.toContain(CONFIG.clientSecret); + } + }); + + it('signs a renamed login in as the same person', async () => { + const before = await signIn(fake, context); + fake.person.login = 'renamed-person'; + const after = await signIn(fake, context); + expect(after.identity.externalSub).toBe(before.identity.externalSub); + expect(attribute(after.attributes, 'preferred_username')?.attrValue).toBe('renamed-person'); + }); + + it('signs a changed email address in as the same person', async () => { + const before = await signIn(fake, context); + fake.person.emails = [{ email: 'new@example.org', primary: true, verified: true }]; + const after = await signIn(fake, context); + expect(after.identity.externalSub).toBe(before.identity.externalSub); + expect(attribute(after.attributes, 'email')?.attrValue).toBe('new@example.org'); + }); + + it('marks the email verified only for the primary address GitHub verified', async () => { + fake.person.emails = [ + { email: 'primary@example.com', primary: true, verified: false }, + { email: 'other@example.com', primary: false, verified: true }, + ]; + const unverified = await signIn(fake, context); + expect(attribute(unverified.attributes, 'email')).toEqual({ + source: 'github', + attrKey: 'email', + attrValue: 'primary@example.com', + verified: false, + }); + + fake.person.emails = [{ email: 'other@example.com', primary: false, verified: true }]; + const noPrimary = await signIn(fake, context); + expect(attribute(noPrimary.attributes, 'email')).toBeUndefined(); + expect(noPrimary.identity.rawClaims).not.toHaveProperty('email'); + }); + + it('takes no address when GitHub lists two primary ones', async () => { + fake.person.emails = [ + { email: 'a@example.com', primary: true, verified: true }, + { email: 'b@example.com', primary: true, verified: true }, + ]; + const { attributes } = await signIn(fake, context); + expect(attribute(attributes, 'email')).toBeUndefined(); + }); + + it('leaves out a display name the person never set', async () => { + fake.person.name = null; + const { identity, attributes } = await signIn(fake, context); + expect(identity.rawClaims).not.toHaveProperty('name'); + expect(attribute(attributes, 'name')).toBeUndefined(); + }); + + it('never hands the access token to the server or a log line', async () => { + const outcome = await callbackOutcome(fake, context); + expect(outcome.kind).toBe('result'); + const token = fake.tokens[0] ?? 'missing'; + expect(JSON.stringify(outcome)).not.toContain(token); + const { identity, attributes } = await signIn(fake, context); + expect(JSON.stringify({ identity, attributes })).not.toContain(fake.tokens[1] ?? 'missing'); + expect(JSON.stringify(context.lines)).not.toContain(token); + }); + + it('ends as declined when the person declines at GitHub', async () => { + const outcome = await callbackOutcome(fake, context, (request) => ({ + error: 'access_denied', + state: request.searchParams.get('state') ?? '', + })); + expect(outcome).toEqual({ kind: 'declined' }); + expect(fake.calls).toEqual([]); + }); + + it('fails with provider_error for another error GitHub sends back', async () => { + const outcome = await callbackOutcome(fake, context, (request) => ({ + error: 'redirect_uri_mismatch', + state: request.searchParams.get('state') ?? '', + })); + expect(outcome).toEqual({ kind: 'failed', reason: 'provider_error' }); + }); + + it('refuses a callback whose state is not the transaction’s', async () => { + const outcome = await callbackOutcome(fake, context, (_request, code) => ({ + code, + state: 'A'.repeat(43), + })); + expect(outcome).toEqual({ kind: 'failed', reason: 'transaction_mismatch' }); + expect(fake.calls).toEqual([]); + }); + + it('refuses a callback without a code, or with a repeated one', async () => { + const missing = await callbackOutcome(fake, context, (request) => ({ + state: request.searchParams.get('state') ?? '', + })); + expect(missing).toEqual({ kind: 'failed', reason: 'invalid_response' }); + const repeated = await callbackOutcome(fake, context, (request, code) => ({ + code: [code, code], + state: request.searchParams.get('state') ?? '', + })); + expect(repeated).toEqual({ kind: 'failed', reason: 'invalid_response' }); + }); + + it('fails with provider_error when GitHub refuses the code (200 with an error)', async () => { + const outcome = await callbackOutcome(fake, context, (request) => ({ + code: 'not-a-code-github-issued', + state: request.searchParams.get('state') ?? '', + })); + expect(outcome).toEqual({ kind: 'failed', reason: 'provider_error' }); + expect(context.lines.some((line) => line.fields['error'] === 'bad_verification_code')).toBe( + true + ); + }); + + it('fails with upstream_unavailable when GitHub cannot be reached or answers 5xx', async () => { + const refusing = fakeContext(CONFIG, (request: AuthMethodEgressRequest) => { + if (request.purpose === 'upstream-token') throw new Error('private address refused'); + return fake.answer(request); + }); + expect(await callbackOutcome(fake, refusing)).toEqual({ + kind: 'failed', + reason: 'upstream_unavailable', + }); + + fake.overrides.set('/login/oauth/access_token', (request) => ({ + status: 502, + headers: {}, + body: new Uint8Array(), + url: request.url, + })); + expect(await callbackOutcome(fake, context)).toEqual({ + kind: 'failed', + reason: 'upstream_unavailable', + }); + }); + + it('fails with invalid_response for an answer that is not GitHub’s', async () => { + fake.overrides.set('/user', (request) => ({ + status: 200, + headers: {}, + body: new TextEncoder().encode(JSON.stringify({ id: '1234567', login: 'octo-person' })), + url: request.url, + })); + expect(await callbackOutcome(fake, context)).toEqual({ + kind: 'failed', + reason: 'invalid_response', + }); + }); + + it('fails with invalid_response when the emails endpoint is refused (no user:email)', async () => { + fake.overrides.set('/user/emails', (request) => ({ + status: 403, + headers: {}, + body: new TextEncoder().encode( + JSON.stringify({ message: 'Resource not accessible by integration' }) + ), + url: request.url, + })); + expect(await callbackOutcome(fake, context)).toEqual({ + kind: 'failed', + reason: 'invalid_response', + }); + }); + }); + + describe('GitHub Enterprise Server', () => { + it('redeems at the base URL, reads its API under /api/v3, and names it as the issuer', async () => { + const config = { ...CONFIG, baseUrl: 'https://github.corp.example:8443' }; + const enterprise = new FakeGitHub(config); + const { identity } = await signIn(enterprise, fakeContext(config, enterprise.answer)); + expect(identity.rawClaims['iss']).toBe('https://github.corp.example:8443'); + expect( + enterprise.calls.map((call) => `${call.method} ${call.url} ${call.source}`).sort() + ).toEqual([ + 'GET https://github.corp.example:8443/api/v3/user admin', + 'GET https://github.corp.example:8443/api/v3/user/emails?per_page=100 admin', + 'POST https://github.corp.example:8443/login/oauth/access_token admin', + ]); + }); + + it('treats a base URL of github.com as github.com', async () => { + const config = { ...CONFIG, baseUrl: 'https://github.com' }; + const github = new FakeGitHub(config); + const { identity } = await signIn(github, fakeContext(config, github.answer)); + expect(identity.rawClaims['iss']).toBe('https://github.com'); + expect(github.calls.some((call) => call.url.startsWith('https://api.github.com/user'))).toBe( + true + ); + }); + }); + + describe('verify()', () => { + it('takes only a result of its own callback, once', async () => { + const outcome = await callbackOutcome(fake, context); + if (outcome.kind !== 'result') throw new Error('no result'); + await expect(authMethod.verify({ ...(outcome.result as object) })).rejects.toThrow(); + await expect( + authMethod.verify({ externalSub: '1', assuranceLevel: 'high', rawClaims: {} }) + ).rejects.toThrow(); + await expect(authMethod.verify(outcome.result)).resolves.toMatchObject({ + externalSub: '1234567', + }); + await expect(authMethod.verify(outcome.result)).rejects.toThrow(); + }); + }); + + describe('the config', () => { + it('takes a client id and secret, and an https base URL with nothing past the host and port', () => { + expect(configSchema.safeParse(CONFIG).success).toBe(true); + for (const baseUrl of [ + 'https://github.corp.example', + 'https://github.corp.example/', + 'https://10.1.2.3:8443', + ]) { + expect(configSchema.safeParse({ ...CONFIG, baseUrl }).success, baseUrl).toBe(true); + } + for (const baseUrl of [ + 'http://github.corp.example', + 'https://github.corp.example/api/v3', + 'https://user:pass@github.corp.example', + 'https://github.corp.example?x=1', + 'https://github.corp.example#top', + 'github.corp.example', + ]) { + expect(configSchema.safeParse({ ...CONFIG, baseUrl }).success, baseUrl).toBe(false); + } + expect(configSchema.safeParse({ ...CONFIG, clientSecret: '' }).success).toBe(false); + expect(configSchema.safeParse({ ...CONFIG, clientId: 'has space' }).success).toBe(false); + }); + + it('marks the client secret write-only', () => { + const schema = configSchema.toJSONSchema() as { + properties: Record; + }; + expect(schema.properties['clientSecret']?.writeOnly).toBe(true); + expect(schema.properties['clientId']?.writeOnly).toBeUndefined(); + expect(schema.properties['baseUrl']?.writeOnly).toBeUndefined(); + }); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..a70b780 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + // The published build: Node's ESM with declarations, from src/ into dist/. + // A published plugin ships JavaScript: Node does not strip TypeScript types + // under node_modules. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "sourceMap": false + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ad77aa3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + // Type-checks everything: the plugin, its tests and the end-to-end suite. + // The published build is tsconfig.build.json. + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "types": ["node"], + "skipLibCheck": false, + "noEmit": true + }, + "include": ["src", "test", "e2e", "*.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0d7ce17 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// The unit tests and the contract test kit. The end-to-end suite has its own +// config (e2e/vitest.config.ts): it needs the containers. +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + }, +});