diff --git a/.env.example b/.env.example index b78105e49..3fffde6d1 100644 --- a/.env.example +++ b/.env.example @@ -162,3 +162,51 @@ AWARENESS_DELIVERY_POLL_MS=2000 # directly, so there are no AaaS-specific Neo4j vars. # Portal -> API base URL PUBLIC_AWARENESS_API_URL="http://localhost:4100" + +# W3DS -> OIDC bridge (services/w3ds-oidc-bridge) +# Lets GitW3 accept W3DS login through Forgejo's native OAuth2 source. +# Public base URL of the bridge. This is the OIDC `issuer`, and Forgejo compares +# it byte for byte, so a trailing slash is a silent total failure - the service +# strips one if present. Must be https:// outside local development: goth never +# verifies the ID token signature, so TLS is what makes the token trustworthy. +W3DS_OIDC_PUBLIC_URL="http://localhost:4200" +W3DS_OIDC_PORT=4200 +# Set only for local development, where the bridge and GitW3 share a host. +W3DS_OIDC_ALLOW_INSECURE="true" +# Credentials of the single registered client (GitW3) +W3DS_OIDC_CLIENT_ID="gitw3" +W3DS_OIDC_CLIENT_SECRET="replace-with-a-strong-secret" +# GitW3's OAuth2 callback, compared exactly - no prefix matching +W3DS_OIDC_REDIRECT_URI="http://localhost:3080/user/oauth2/W3DS/callback" +# ES256 private key in PKCS#8 PEM, and a stable key id so rotation stays possible +W3DS_OIDC_SIGNING_KEY="" +W3DS_OIDC_KEY_ID="w3ds-oidc-1" +# W3DS carries no email address and Forgejo requires one, so the bridge derives a +# synthetic address. RFC 2606 reserves .invalid, so these can never be delivered +# to a domain someone might register. They bounce by design. +W3DS_EMAIL_DOMAIN="w3ds.invalid" +# Comma-separated names added to Forgejo's reserved list, on top of its own +W3DS_EXTRA_RESERVED_USERNAMES="" +# Minimum eID Wallet version accepted. Temporary - drops out after the rollout. +W3DS_MIN_WALLET_VERSION="0.4.0" + +# --- Deploying GitW3 and the bridge together ------------------------------- +# Only needed for docker-compose.gitw3.yml. Local development uses the block +# above and runs the bridge with `pnpm --filter w3ds-oidc-bridge dev`. + +# GitW3's public base URL, and the bare hostname behind it. The bridge derives +# its expected callback from the first, so the two can never disagree. +GITW3_PUBLIC_URL="https://gitw3.w3ds.metastate.foundation" +GITW3_DOMAIN="gitw3.w3ds.metastate.foundation" +# The name of the authentication source. It appears on the login button and in +# the callback path, so changing it changes the redirect URI on both sides. +GITW3_AUTH_SOURCE_NAME="W3DS" +# Published by the gitw3 repo's release workflow. Follows the repo's owner. +GITW3_IMAGE="ghcr.io/ensombl/gitw3" +GITW3_VERSION="latest" +# Loopback-bound; the reverse proxy in front reaches these. SSH is not. +GITW3_HOST_PORT=3000 +GITW3_SSH_PORT=2222 +W3DS_OIDC_HOST_PORT=4200 +# Nothing publishes a bridge image yet, so the compose file builds it locally. +W3DS_OIDC_IMAGE="w3ds-oidc-bridge:local" diff --git a/.gitignore b/.gitignore index 49c0bc5b2..d17ac236e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ node_modules .env.development.local .env.test.local .env.production.local +# Backups made before an edit hold the same secrets as the original, and are the +# kind of file a `git add -A` sweeps up without anyone noticing. +.env.bak* # Testing coverage diff --git a/docker-compose.gitw3.yml b/docker-compose.gitw3.yml new file mode 100644 index 000000000..7ba46f629 --- /dev/null +++ b/docker-compose.gitw3.yml @@ -0,0 +1,133 @@ +# GitW3 and the W3DS OIDC bridge — a candidate deployment manifest. +# +# Nothing in this repository deploys services today: build.yml builds and tests, docusaurus.yml +# publishes the docs to GitHub Pages, and docker-compose.databases.yml runs local databases only. +# How the other platforms reach *.w3ds.metastate.foundation is decided outside this repo, so this +# file is a proposal rather than the house convention. It runs as written, which makes it +# something to accept, adapt, or replace — not a list of questions. +# +# docker compose -f docker-compose.gitw3.yml --env-file .env up -d +# docker compose -f docker-compose.gitw3.yml restart gitw3 # first deploy only, see below +# +# The second line is needed once, because the authentication source is created after GitW3 has +# already started and Forgejo reads its sources at boot. Later deploys don't need it. +# +# TLS terminates in front of this. Both HTTP services bind to the loopback interface, so a +# reverse proxy on the host reaches them and nothing else does. + +name: gitw3 + +services: + # Start order matters, and not only for tidiness: Forgejo fetches the discovery document + # once, while registering its authentication sources at startup. If the bridge is down at + # that moment the source is skipped entirely, the login button disappears, and the + # follow-on symptom misleads — an unregistered source also stops Forgejo sending PKCE, so + # the bridge answers `code_challenge is required`. Hence the healthcheck gate below. + w3ds-oidc-bridge: + build: + context: . + dockerfile: docker/Dockerfile.w3ds-oidc-bridge + image: ${W3DS_OIDC_IMAGE:-w3ds-oidc-bridge:local} + container_name: w3ds-oidc-bridge + restart: unless-stopped + environment: + # The OIDC issuer. Must be https:// — the service refuses to start otherwise, and + # W3DS_OIDC_ALLOW_INSECURE is deliberately not passed through here. goth never + # verifies the ID token signature, so TLS and the client secret are the only things + # separating a real token from a forged one. + W3DS_OIDC_PUBLIC_URL: ${W3DS_OIDC_PUBLIC_URL:?the bridge's public https:// base URL} + W3DS_OIDC_PORT: 4200 + W3DS_OIDC_CLIENT_ID: ${W3DS_OIDC_CLIENT_ID:?} + W3DS_OIDC_CLIENT_SECRET: ${W3DS_OIDC_CLIENT_SECRET:?} + # Derived from GITW3_PUBLIC_URL so the two cannot drift. Forgejo sends this value + # and the bridge compares it exactly — no prefix matching, no trailing-slash mercy. + W3DS_OIDC_REDIRECT_URI: ${GITW3_PUBLIC_URL:?}/user/oauth2/${GITW3_AUTH_SOURCE_NAME:-W3DS}/callback + # PKCS#8 PEM. Newlines may be written as literal \n; the service normalises them, + # so the key survives a single-line .env entry. + W3DS_OIDC_SIGNING_KEY: ${W3DS_OIDC_SIGNING_KEY:?ES256 private key in PKCS#8 PEM} + W3DS_OIDC_KEY_ID: ${W3DS_OIDC_KEY_ID:-w3ds-oidc-1} + W3DS_EMAIL_DOMAIN: ${W3DS_EMAIL_DOMAIN:-w3ds.invalid} + W3DS_EXTRA_RESERVED_USERNAMES: ${W3DS_EXTRA_RESERVED_USERNAMES:-} + W3DS_MIN_WALLET_VERSION: ${W3DS_MIN_WALLET_VERSION:-0.4.0} + # Signatures are verified against this Registry, so it must be the same one the + # wallets on people's phones were provisioned against. + PUBLIC_REGISTRY_URL: ${PUBLIC_REGISTRY_URL:?} + ports: + - "127.0.0.1:${W3DS_OIDC_HOST_PORT:-4200}:4200" + # The healthcheck is defined in docker/Dockerfile.w3ds-oidc-bridge. + + gitw3: + image: ${GITW3_IMAGE:-ghcr.io/ensombl/gitw3}:${GITW3_VERSION:-latest} + container_name: gitw3 + restart: unless-stopped + depends_on: + w3ds-oidc-bridge: + condition: service_healthy + environment: + # environment-to-ini runs on every start, so app.ini is regenerated from these on + # each deploy — the configuration below is the source of truth, not the volume. + FORGEJO__server__ROOT_URL: ${GITW3_PUBLIC_URL:?} + FORGEJO__server__DOMAIN: ${GITW3_DOMAIN:?} + FORGEJO__server__SSH_DOMAIN: ${GITW3_DOMAIN:?} + FORGEJO__server__SSH_PORT: ${GITW3_SSH_PORT:-2222} + FORGEJO__server__HTTP_PORT: 3000 + FORGEJO__security__INSTALL_LOCK: "true" + + # W3DS becomes the only way in. ALLOW_ONLY_EXTERNAL_REGISTRATION closes the password + # sign-up page while leaving the link-account page open — that page is the fallback + # when an eName cannot yield a usable username, and DISABLE_REGISTRATION would close + # it too, turning a rare edge case into a permanent lockout. + FORGEJO__service__DISABLE_REGISTRATION: "false" + FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" + + FORGEJO__oauth2_client__ENABLE_AUTO_REGISTRATION: "true" + # `login` is load-bearing. On `auto`, two eNames that sanitise to the same username + # would let the second person into the first person's account. + FORGEJO__oauth2_client__ACCOUNT_LINKING: login + FORGEJO__oauth2_client__USERNAME: nickname + # Must be set *in this section*: it otherwise inherits [service], and an activation + # mail sent to a .invalid address never arrives, leaving every account permanently + # inactive. + FORGEJO__oauth2_client__REGISTER_EMAIL_CONFIRM: "false" + volumes: + - gitw3_data:/data + ports: + - "127.0.0.1:${GITW3_HOST_PORT:-3000}:3000" + - "${GITW3_SSH_PORT:-2222}:22" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/healthz"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 60s + # GitW3 exchanges the authorization code with the bridge over its *public* hostname — + # the discovery document publishes absolute URLs, so there is no internal shortcut and + # `http://w3ds-oidc-bridge:4200` would fail the issuer comparison. The host therefore + # needs to resolve and reach its own public name from inside the container. Where the + # network cannot hairpin, point it at the reverse proxy directly: + # extra_hosts: + # - "${W3DS_OIDC_DOMAIN:-bridge.invalid}:host-gateway" + + # Authentication sources live in Forgejo's database, not in app.ini, so they cannot be + # declared above. This one-shot closes that gap and is idempotent — it updates an existing + # source rather than adding a second one, so it is safe on every deploy. + gitw3-auth-source: + image: ${GITW3_IMAGE:-ghcr.io/ensombl/gitw3}:${GITW3_VERSION:-latest} + container_name: gitw3-auth-source + restart: "no" + depends_on: + gitw3: + condition: service_healthy + user: git + entrypoint: ["/bin/sh", "/register-auth-source.sh"] + environment: + GITW3_AUTH_SOURCE_NAME: ${GITW3_AUTH_SOURCE_NAME:-W3DS} + W3DS_OIDC_PUBLIC_URL: ${W3DS_OIDC_PUBLIC_URL:?} + W3DS_OIDC_CLIENT_ID: ${W3DS_OIDC_CLIENT_ID:?} + W3DS_OIDC_CLIENT_SECRET: ${W3DS_OIDC_CLIENT_SECRET:?} + volumes: + - gitw3_data:/data + - ./docker/gitw3-register-auth-source.sh:/register-auth-source.sh:ro + +volumes: + gitw3_data: diff --git a/docker/Dockerfile.w3ds-oidc-bridge b/docker/Dockerfile.w3ds-oidc-bridge new file mode 100644 index 000000000..3173c1d29 --- /dev/null +++ b/docker/Dockerfile.w3ds-oidc-bridge @@ -0,0 +1,49 @@ +FROM node:20-alpine AS base +RUN apk add --no-cache libc6-compat python3 make g++ +WORKDIR /app + +ENV CI=true +ENV PYTHON=/usr/bin/python3 +RUN ln -sf python3 /usr/bin/python + +# --- +FROM base AS prepare +RUN npm install -g pnpm@10.25.0 turbo@^2 +COPY . . +RUN turbo prune w3ds-oidc-bridge --docker + +# --- +FROM base AS builder +RUN npm install -g pnpm@10.25.0 +# Dependencies first, since they change far less often than the source. +COPY --from=prepare /app/out/json/ . +# signature-validator builds on postinstall and reaches the bridge through +# @metastate-foundation/auth, so its source has to be present before install. +COPY --from=prepare /app/out/full/infrastructure/signature-validator infrastructure/signature-validator +COPY --from=prepare /app/out/full/packages/auth packages/auth +RUN pnpm install --frozen-lockfile +COPY --from=prepare /app/out/full/ . +RUN pnpm turbo build --filter=w3ds-oidc-bridge + +# --- +FROM base AS runner +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/pnpm-workspace.yaml ./ +COPY --from=builder /app/pnpm-lock.yaml ./ + +COPY --from=builder /app/infrastructure ./infrastructure +COPY --from=builder /app/packages ./packages + +COPY --from=builder /app/services/w3ds-oidc-bridge/dist ./services/w3ds-oidc-bridge/dist +COPY --from=builder /app/services/w3ds-oidc-bridge/package.json ./services/w3ds-oidc-bridge/ +COPY --from=builder /app/services/w3ds-oidc-bridge/node_modules ./services/w3ds-oidc-bridge/node_modules +COPY --from=builder /app/node_modules ./node_modules + +WORKDIR /app/services/w3ds-oidc-bridge + +# Keep in step with W3DS_OIDC_PORT. +EXPOSE 4200 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD node -e "require('http').get('http://localhost:4200/healthz', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)}).on('error', () => process.exit(1))" + +CMD ["node", "dist/index.js"] diff --git a/docker/gitw3-register-auth-source.sh b/docker/gitw3-register-auth-source.sh new file mode 100755 index 000000000..bcb4a62a9 --- /dev/null +++ b/docker/gitw3-register-auth-source.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Register — or update — the W3DS authentication source in GitW3. +# +# Forgejo keeps authentication sources in its database, not in app.ini, so they cannot be +# declared alongside the rest of the configuration. Without this, a fresh instance needs +# someone to click through Site Administration before anyone can log in, which makes the +# deployment only mostly reproducible. The script is idempotent, so it can run on every +# deploy: it updates the source when it already exists and creates it otherwise. +# +# Runs as the one-shot `gitw3-auth-source` service in docker-compose.gitw3.yml, sharing +# GitW3's data volume. `gitea` is the shim in /usr/local/bin, which points the CLI at +# /data/gitea — the same configuration the running instance reads. +set -eu + +: "${W3DS_OIDC_PUBLIC_URL:?}" +: "${W3DS_OIDC_CLIENT_ID:?}" +: "${W3DS_OIDC_CLIENT_SECRET:?}" + +NAME="${GITW3_AUTH_SOURCE_NAME:-W3DS}" + +# The source name is also a URL segment — Forgejo serves /user/oauth2//callback — and +# the bridge compares the redirect URI byte for byte. A name with a space in it produces a +# callback the bridge will always reject, so refuse it here rather than at the first login. +case "$NAME" in + *[!A-Za-z0-9_-]*) + echo "GITW3_AUTH_SOURCE_NAME must be URL-safe — got '$NAME'" >&2 + exit 1 + ;; +esac + +# `admin auth list` prints a tab-separated table: ID, Name, Type, Enabled. +id=$(gitea admin auth list | awk -F'\t' -v want="$NAME" '$2 == want { print $1 }') + +# Scopes are set explicitly. The CLI leaves them empty when the flag is absent, whereas the +# admin UI pre-fills these three — so an omission here would produce a source subtly unlike +# every one created by hand. +set -- \ + --provider openidConnect \ + --key "$W3DS_OIDC_CLIENT_ID" \ + --secret "$W3DS_OIDC_CLIENT_SECRET" \ + --auto-discover-url "${W3DS_OIDC_PUBLIC_URL}/.well-known/openid-configuration" \ + --icon-url "${W3DS_OIDC_PUBLIC_URL}/icon.svg" \ + --scopes openid --scopes profile --scopes email + +if [ -n "$id" ]; then + echo "updating authentication source '$NAME' (id $id)" + gitea admin auth update-oauth --id "$id" --name "$NAME" "$@" +else + echo "creating authentication source '$NAME'" + gitea admin auth add-oauth --name "$NAME" "$@" +fi + +# Forgejo resolves the discovery document once, when it registers its sources at startup, so +# a source added after boot is inert until the next restart. Say so rather than leaving the +# operator to discover it through a login button that isn't there. +echo +echo "GitW3 must be restarted for this to take effect:" +echo " docker compose -f docker-compose.gitw3.yml restart gitw3" diff --git a/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md b/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md new file mode 100644 index 000000000..0e48109f4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md @@ -0,0 +1,325 @@ +# Implementation plan — W3DS → OIDC bridge + +**Spec:** [2026-08-05-w3ds-oidc-bridge-design.md](../specs/2026-08-05-w3ds-oidc-bridge-design.md) +**Issue:** MetaState #1097 + +Rationale lives in the spec; this document is the order of work and how each step is proved. Every task states its +verification. A task is not done until its verification passes. + +Conventions taken from the repo: `vitest run` as the `test` script (as in `packages/w3ds-gateway`), Biome for format and +lint, `tsc --noEmit` as `check-types`, config read from the root `.env`. + +Phases 1 and 2 are fully testable without Forgejo or a wallet. Do not skip ahead — the pure core is where the traps are, +and it is the only part that can be tested exhaustively. + +--- + +## Phase 0 — Scaffolding + +**0.1 Create the package.** `services/w3ds-oidc-bridge/` with `package.json` (name `w3ds-oidc-bridge`, `type: module`), +`tsconfig.json`, `tsconfig.build.json`, `README.md`. Scripts: `dev` (nodemon + ts-node), `build`, `start`, `test`, +`test:watch`, `check-types`. Dependencies: `express`, `jose`, `qrcode`, `uuid`, `dotenv`, +`@metastate-foundation/auth: workspace:*`. Dev: `vitest`, `typescript`, `@types/*`, `nodemon`, `ts-node`. + +It is picked up by `services/*` in `pnpm-workspace.yaml` — no workspace change needed. + +> **Verify:** `pnpm install` resolves, `pnpm --filter w3ds-oidc-bridge check-types` passes on an empty `src/index.ts`. + +**0.2 Add env keys to `.env.example`** (all of the spec's Deployment table). Do not touch `.env`. + +> **Verify:** every key in the spec's table appears in `.env.example`. + +*Commit: `chore(w3ds-oidc-bridge): scaffold the service package`* + +--- + +## Phase 1 — The pure core + +No HTTP in this phase. Everything here is a function. + +**1.1 `src/config.ts`.** Parse and validate env with a `required()` helper that throws at startup, mirroring +[awareness-service](../../../services/awareness-service/api/src/config.ts). Load the root `.env` by relative path. + +Include the TLS guard: throw unless `W3DS_OIDC_PUBLIC_URL` starts with `https://`, or `W3DS_OIDC_ALLOW_INSECURE` is +exactly `"true"`. Normalise `W3DS_OIDC_PUBLIC_URL` by stripping any trailing slash — the issuer is compared byte for +byte by goth, and a stray slash is a silent, total failure. + +> **Verify:** unit tests — missing key throws naming that key; `http://` throws; `http://` with the escape hatch is +> accepted; trailing slash is stripped. + +**1.2 `src/claims.ts`.** The sanitiser and the claim builder. This is the highest-risk file in the project; write the +tests first. + +Implement the pipeline exactly as specified: strip `@`, replace characters outside `[0-9A-Za-z_.-]` with a hyphen, +collapse runs of `[-._]`, strip leading and trailing `[-._]`, truncate to 40, re-strip the tail, and fall back to the +**empty string** — never an absent key — when the result is empty, reserved, or matches a reserved pattern. + +Copy `reservedUsernames` and the `*.keys` / `*.gpg` / `*.rss` / `*.atom` / `*.png` patterns from GitW3 +`models/user/user.go:639` into a named constant with a comment pointing at that source, so a future upstream change is +traceable. + +Extend the reserved list with `W3DS_EXTRA_RESERVED_USERNAMES` from config, parsed case-insensitively. This is what +makes the fallback path testable against a real wallet in phase 5 — enames are assigned, not chosen. + +Email: local part from the ename minus the leading `@`, characters outside `[0-9A-Za-z._-]` replaced by a hyphen, +domain from config. + +> **Verify:** table-driven tests covering at minimum — the four rows of the spec's example table; `@Alice` and `@alice` +> both yielding `alice`-cased names that collide on lowercase; an ename of 60 characters truncating to 40 with no +> trailing separator; `@ali..ce` collapsing; `@api`, `@admin`, `@.well-known` hitting the reserved list; `@foo.keys` +> hitting the reserved *pattern*; a name that is entirely punctuation falling back to empty. +> +> **And the regression guard:** for every fallback case, assert `"nickname" in claims` and +> `"preferred_username" in claims` are `true` and the values are `""`. An assertion on falsiness alone would pass on an +> absent key, which is the crash case (spec, "The fallback must be an empty string"). + +**1.3 `src/store.ts`.** Two TTL maps with a sweeper. Sessions: 5 minutes, holding `client_id`, `redirect_uri`, `state`, +`nonce`, `code_challenge`, and later `ename`. Codes: 60 seconds, single use — reading a code must delete it in the same +operation so a concurrent second exchange cannot win a race. + +> **Verify:** unit tests — an entry past its TTL is gone; consuming a code twice returns the value then `undefined`; +> the sweeper does not evict a live entry. + +**1.4 `src/keys.ts`.** Load the ES256 private key from config, derive the public JWK, expose the JWKS document with the +configured `kid`, and expose sign/verify helpers over `jose`. + +> **Verify:** unit test — a token signed and then verified round-trips; the JWKS `kid` matches config; the JWK contains +> no private material (`d` absent). + +*Commit: `feat(w3ds-oidc-bridge): config, claims, store and signing key`* + +--- + +## Phase 2 — The OIDC surface + +**2.1 `src/clients.ts`.** Single-client lookup by `client_id`, returning the registered `redirect_uri` and secret. One +function, so a second client is a data change rather than a refactor. + +**2.2 `src/oidc/discovery.ts`.** `GET /.well-known/openid-configuration`. Advertise `issuer`, `authorization_endpoint`, +`token_endpoint`, `userinfo_endpoint`, `jwks_uri`, `scopes_supported: ["openid","profile","email"]`, +`response_types_supported: ["code"]`, `id_token_signing_alg_values_supported: ["ES256"]`, +`code_challenge_methods_supported: ["S256"]`. + +> **Verify:** test asserts `issuer` equals `W3DS_OIDC_PUBLIC_URL` exactly, and that every advertised URL is absolute and +> shares that origin. + +**2.3 `src/oidc/token.ts` before `authorize`.** Build the ID token first so its shape is settled: `iss`, `aud`, `sub`, +`exp`, `iat`, `nonce` when present, plus `nickname`, `preferred_username`, `email`, `email_verified: false`. The access +token is a separate JWT with the same `sub` and a 5-minute TTL. + +`POST /token` checks, in order: `grant_type=authorization_code`; client authentication via `client_secret` compared with +`crypto.timingSafeEqual`; the code exists and is consumed atomically; `redirect_uri` matches the one recorded at +`/authorize` exactly; `code_verifier` hashes with SHA-256/base64url to the stored `code_challenge`. + +> **Verify:** unit tests — `exp` is present and numeric; `iss` is byte-identical to the discovery document's; `aud` +> equals `client_id`; a code refused on second use; a `code_verifier` off by one character rejected; a `redirect_uri` +> off by one character rejected; a wrong secret rejected. Each rejection returns the OAuth2 error code, not a 500. + +**2.4 `src/oidc/authorize.ts`.** Validate `client_id` and `redirect_uri` first, and only after both are valid may an +error be returned by redirect. Anything wrong with those two renders an error page and never redirects. Then require +`response_type=code`, `code_challenge`, and `code_challenge_method=S256` — reject `plain`. + +On success: create the session, build the offer with `buildAuthOffer({ baseUrl, platform: "gitw3", callbackPath: +"/w3ds/callback" })` from `@metastate-foundation/auth`, render the QR as a `qrcode` data URI, and serve the page with +one inline `EventSource` script. + +> **Verify:** tests — unknown `client_id` renders a page and sends no `Location` header; unregistered `redirect_uri` +> likewise; missing `code_challenge` redirects with `error=invalid_request`; `code_challenge_method=plain` rejected; a +> valid request returns HTML containing a `data:image` QR and the session id. + +**2.5 `src/oidc/userinfo.ts` and the JWKS route.** `/userinfo` verifies the bearer JWT with `jose` and returns the same +claims as the ID token. Its `sub` must be identical to the ID token's — goth rejects the response otherwise. + +> **Verify:** test asserts `userinfo.sub === idToken.sub` for the same exchange; a missing or expired bearer returns +> 401. + +*Commit: `feat(w3ds-oidc-bridge): OIDC discovery, authorize, token, userinfo and JWKS`* + +--- + +## Phase 3 — The W3DS surface + +**3.1 `src/w3ds/events.ts`.** `GET /w3ds/events/:session` — SSE, `Content-Type: text/event-stream`, no buffering, a +heartbeat every 30 seconds to match the platform convention, and cleanup on client disconnect. Two message kinds: +`redirect` with the callback URL, and `error` with a human-readable message. + +> **Verify:** test — a subscriber receives a `redirect` event when the session is completed programmatically; the +> connection is removed from the registry on close. + +**3.2 `src/w3ds/callback.ts`.** `POST /w3ds/callback`. Read the ename from `ename`, falling back to `w3id`. Validate the +required fields, then the `appVersion` gate against `W3DS_MIN_WALLET_VERSION`, then look up the session, then call +`verifyLoginSignature` from `@metastate-foundation/auth` with the session id as the payload. + +On success, mint the authorisation code and push `redirect` into the SSE stream. On **every** failure, push `error` into +the SSE stream as well as returning the HTTP status — the browser is waiting in front of a QR code and has no other way +to learn what happened. + +Keep the `appVersion` check in one small function; the spec notes it is temporary and will be removed after the wallet +rollout. + +> **Verify:** tests with `verifySignature` stubbed — `ename` and `w3id` both accepted; `appVersion: "0.3.9"` rejected +> *and* an error pushed to SSE; unknown session rejected; expired session rejected; invalid signature rejected; a valid +> call mints exactly one code and pushes `redirect`. + +**3.3 `src/index.ts`.** Wire the routes, add a `/healthz`, log the resolved issuer at startup, and fail fast on a config +error rather than starting a half-working server. + +> **Verify:** `pnpm --filter w3ds-oidc-bridge dev` starts; `curl /.well-known/openid-configuration` returns the document; +> `curl /healthz` returns 200; starting with a missing env key exits non-zero with a message naming the key. + +*Commit: `feat(w3ds-oidc-bridge): W3DS callback and SSE session stream`* + +--- + +## Phase 4 — Packaging + +**4.1 `docker/Dockerfile.w3ds-oidc-bridge`,** following the pattern of the existing `docker/Dockerfile.*` files. + +**4.2 `services/w3ds-oidc-bridge/README.md`** — what it is, the two contracts in one paragraph each, the env table, how +to run it locally, and how to test with the Dev Sandbox. Link the spec rather than restating it. + +**4.3 The W3DS button icon.** *Done.* Served by the bridge itself at `/icon.svg`, so the browser can always reach it — +it is about to be sent to the same origin for `/authorize` — and the mark cannot fall out of step with the service. +Inlined as a string in `src/icon.ts` rather than kept in `assets/`, because the build is `tsc` alone and a non-TS file +would not reach `dist/`. + +A shield with a key, the same vocabulary as the Nextcloud W3DS login plugin so the button is recognisable to anyone who +has seen that one, restyled to the MetaState purple and the house convention: 162 viewBox, 32 radius, 9 stroke, white +on `#8968FF`. Checked at its real 28px display size, not only large. + +> **Verify:** the image builds; the container starts with env supplied and serves the discovery document; the icon URL +> returns an image over HTTPS from a host GitW3 can reach. + +**4.4 Resolve the deployment path — blocking for phase 6.** The repository contains **no service deployment manifest**. +`docker-compose.databases.yml` brings up local databases only, there is no compose file that runs any platform or +service, and no workflow in `.github/workflows/` deploys anything. So how services reach +`*.w3ds.metastate.foundation` is out of band and not knowable from this repo. + +This must be settled with whoever owns the staging environment before phase 6 starts, and the answer recorded here. + +`docker-compose.gitw3.yml` is a **candidate**, written to make that conversation concrete: something to accept, adapt +or reject rather than a list of questions. Its header says as much, so it cannot be mistaken for the house convention. +It brings up both services with the ordering constraint enforced, and registers the authentication source through +`docker/gitw3-register-auth-source.sh` — Forgejo keeps sources in its database, so without that step a fresh instance +needs someone to click through Site Administration before anyone can log in. + +Both were verified against the running local instance: the compose file interpolates and validates, and the script's +create, update and reject branches were each exercised, leaving exactly one source behind. + +What it cannot answer, and what the meeting must: + +- **Who runs it, and where.** A host with Docker? An orchestrator? The answer decides whether this file is the artifact + or just its documentation. +- **Where the images come from.** GitW3 publishes `ghcr.io//gitw3` on `gitw3-v*` tags; the owner changes with the + repository transfer, and org packages default to private. Nothing publishes a bridge image at all — the compose file + builds it locally, which is a gap, not a design. +- **Which Registry, and which wallet build.** `PUBLIC_REGISTRY_URL` must name the same Registry the testers' wallets + were provisioned against, or every signature fails verification. This decides whether criterion 5 is testable. +- **Where the two secrets live.** `W3DS_OIDC_CLIENT_SECRET` and `W3DS_OIDC_SIGNING_KEY`. Forgejo supports a `__FILE` + suffix on its own settings; the bridge reads only environment variables, so a file-backed secret store needs a small + addition on our side. +- **How the bridge's public hostname resolves from inside GitW3's container.** The discovery document publishes absolute + URLs, so the back channel goes out through the public name — there is no internal shortcut. `extra_hosts` is the + escape hatch where the network cannot hairpin. +- **Who terminates TLS.** Non-negotiable for the bridge: goth never verifies the ID token signature. + +*Commit: `chore(w3ds-oidc-bridge): dockerfile, service README and button icon`* + +--- + +## Phase 5 — GitW3 wiring and local end-to-end + +No code in this phase — it is configuration, and it is where the acceptance criteria are actually met. + +**5.1 Generate the key pair** (ES256) and a `client_secret`. Keep them out of the repo. Record the `kid` chosen. + +**5.2 Configure GitW3.** In `app.ini`: + +```ini +[oauth2_client] +ENABLE_AUTO_REGISTRATION = true +ACCOUNT_LINKING = login +USERNAME = nickname +REGISTER_EMAIL_CONFIRM = false +``` + +`REGISTER_EMAIL_CONFIRM` must be set in `[oauth2_client]`, not `[service]`, or it inherits the `[service]` value again +(`modules/setting/oauth2.go:69`) and every W3DS account is created inactive with its activation mail sent to an address +that never delivers. Also decide `REGISTER_MANUAL_CONFIRM` deliberately: it has the same effect, less fatally. + +Confirm `ALLOW_ONLY_INTERNAL_REGISTRATION` is `false`. Then add an authentication source: OAuth2 → OpenID Connect, +auto-discovery URL pointing at the bridge, client id and secret, and the `IconURL` field pointing at the icon from 4.3. +This satisfies acceptance criterion 3 with no patch to Forgejo. + +**5.3 Walk the flow with the Dev Sandbox.** Provision an eVault, open GitW3's login page, click the W3DS button, copy +the `w3ds://auth` URI from the QR page, paste it into the sandbox, and click Perform. + +> **Verify:** a GitW3 account is created with the expected username and synthetic email; the account is **active** — +> not awaiting an activation mail that will never arrive; the `sub` stored in `external_login_user.external_id` is the +> full ename; signing in a second time reuses the same account rather than creating a second. + +**5.4 Walk the two failure paths deliberately.** + +The reserved-name path cannot be reached by choosing an ename: the Dev Sandbox calls `provision()` with +`namespace: crypto.randomUUID()` and the `w3id` comes back from the Provisioner +(`infrastructure/dev-sandbox/src/routes/+page.svelte:482`). Provisioning `@admin` is not possible. + +Reach it the other way instead: provision a normal identity, then add its sanitised username to +`W3DS_EXTRA_RESERVED_USERNAMES`, restart the bridge, and sign in. The claim goes out empty and the linking page must +render rather than panic. Remove the entry afterwards. + +Second path: an outdated `appVersion` must surface as a readable error on the QR page rather than an indefinite spinner. + +> **Verify:** both render; neither produces a 500 or a panic in the GitW3 log. + +**5.5 Repeat 5.3 and 5.4 under `USERNAME = preferred_username`.** This is the configuration where an absent claim +panics; it must behave identically. Restore `nickname` afterwards. + +> **Verify:** identical outcomes under both settings. + +--- + +## Phase 6 — Staging + +Blocked on 4.4 until the deployment path is known. + +**6.1 Deploy** the bridge behind TLS, with `W3DS_OIDC_ALLOW_INSECURE` unset. + +**6.2 Run the staging checklist:** + +- the discovery URL is `https://` and `W3DS_OIDC_ALLOW_INSECURE` is unset; +- `[oauth2_client] ACCOUNT_LINKING` is `login`, not `auto`; +- `[oauth2_client] REGISTER_EMAIL_CONFIRM` is `false` — set there, not inherited from `[service]`; +- a fresh ename creates an **active** account, and signing in again reuses it rather than creating a second; +- a reserved name reaches the linking page and renders it. + +The email-confirmation check is the one most likely to be missed, because the default is `false` on both sides locally. +It only bites on an instance with the setting turned on, and the symptom — sign-in succeeds, the account exists, login +is refused — does not point at its cause. + +**6.3 End to end with a real eID Wallet** — acceptance criterion 5. + +> **Verify:** every item above passes, recorded in the issue. + +--- + +## Order dependencies + +``` +0 ──▶ 1 ──▶ 2 ──▶ 3 ──▶ 4 ──▶ 5 ──▶ 6 + │ │ │ ▲ + └──────┘ └── 4.4 ────┘ + neither Forgejo deployment path + nor a wallet must be settled first +``` + +4.4 is the only unknown in this plan that cannot be resolved from the repository. Raise it early — it does not block +phases 0 through 5, but it does gate the acceptance criterion. + +Phase 2.3 deliberately precedes 2.4: the token shape determines what `/authorize` has to capture, and building them the +other way round tends to discover a missing field late. + +## Out of scope + +Correcting `w3id` → `ename` in the protocol documentation, and removing the `appVersion` gate once the wallet rollout +completes. Both are tracked as open items in the spec. diff --git a/docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md b/docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md new file mode 100644 index 000000000..f6b82f52a --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md @@ -0,0 +1,539 @@ +# W3DS → OIDC bridge for GitW3 + +**Date:** 2026-08-05 +**Issue:** MetaState #1097 — Make GitW3 compatible with W3DS login +**Status:** sections approved in discussion; document awaiting review + +Paths given without a link refer to the GitW3 fork (`ensombl/gitw3`), a separate repository. + +## Problem + +GitW3 is a fork of Forgejo (MetaState #1096). Forgejo has no plugin API, and the fork's governing constraint is a patch +surface of zero: every line that diverges from upstream is a merge conflict on every security release. + +Forgejo does support external authentication out of the box, via OAuth2 authentication sources. So the way to add W3DS +login without touching Forgejo's source is to speak a protocol Forgejo already understands. That protocol is OpenID +Connect. + +The bridge is an OpenID Provider on one side and a W3DS platform on the other. Forgejo believes it is talking to an +ordinary OIDC provider. The wallet believes it is talking to an ordinary W3DS platform. Neither is modified. + +This works because OIDC does not specify *how* a provider authenticates the user. Password, passkey, or a QR code signed +by an eID wallet — the provider decides, and the client only sees the resulting ID token. + +## Approach + +Three options were considered. + +**A hand-rolled minimal OIDC subset** (chosen). Implement only the endpoints Forgejo actually calls, using `jose` for +token signing. Roughly 400 lines. The alternative implementations are general-purpose OIDC providers, and every feature +they carry that we do not need — dynamic client registration, consent screens, refresh token rotation, multi-tenancy — +is surface we would have to configure correctly and keep secure. + +**A full OIDC provider library** (`oidc-provider`). Rejected: the configuration surface is larger than the +implementation we are avoiding, and the failure modes are harder to reason about. + +**A native Forgejo patch.** Rejected: it violates the zero-patch-surface constraint that governs the fork. + +### On the Nextcloud prior art + +The issue points at `ensombl/nextcloud-w3ds-login` as prior art. It solves the same problem — W3DS login for a +third-party app with local accounts — but it is **not an OIDC bridge**: it is a native Nextcloud plugin implementing +`IAlternativeLogin`. Nextcloud has a plugin API; Forgejo does not. So there is no code to reuse. + +Its structure does validate ours by independent convergence: 5-minute session TTL, 1-minute handoff token, a persistent +W3ID ↔ local user mapping table, auto-provisioning on first login, and account linking that reuses the same session +machinery. We land on the same numbers. One difference in our favour: the mapping table is free for us. Forgejo's +`external_login_user` already stores exactly that relationship for any OAuth2 source. + +## The two contracts + +The bridge is wedged between two protocols it does not get to choose. Everything below is imposed; the rest of this +document is our own design. + +### Wallet side + +Twelve platform controllers in this repository emit this URI. Six were checked against the contract below — blabsy, +pictique, evoting, calendar, file-manager, awareness-service — and they agree. It was verified against those +controllers, not against the documentation; see [Documentation drift](#documentation-drift). + +``` +offer w3ds://auth?redirect=&session=&platform=gitw3 +POST body { ename, session, signature, appVersion } +signed the signed payload IS the session id, nothing else +verify verifySignature({ eName, signature, payload: session, registryBaseUrl }) +gate appVersion >= 0.4.0 +``` + +The bridge builds its offer with `buildAuthOffer()` from `@metastate-foundation/auth` +([packages/auth/src/auth-offer.ts](../../../packages/auth/src/auth-offer.ts)) rather than re-implementing the URI +format. The package currently has no consumers; the bridge will be the first. Using it means the bridge cannot drift +from the canonical format. + +The URI is built by raw interpolation, without percent-encoding the `redirect` parameter. This is what `buildAuthOffer` +does and what five of the six platform controllers do. `platforms/calendar` is the outlier — it applies +`encodeURIComponent` to both `redirect` and `platform` +([AuthController.ts:33](../../../platforms/calendar/api/src/controllers/AuthController.ts)). The documentation says to +encode; no reference implementation does. We follow the majority. + +The callback path is ours to choose — it travels inside the `redirect` parameter. We use `/w3ds/callback`. + +#### Documentation drift + +[docs/docs/W3DS Protocol/Authentication.md](../../docs/W3DS%20Protocol/Authentication.md) documents the POST body field +as `w3id`. Every platform controller reads `ename` and rejects with `"ename is required"`. Only +[awareness-service](../../../services/awareness-service/api/src/controllers/AuthController.ts) accepts both. + +**The bridge accepts both**, preferring `ename`, matching awareness-service. Fixing the documentation is out of scope +for this issue. + +### Forgejo side + +Forgejo authenticates via `markbates/goth`'s `openidConnect` provider. Every constraint below was read from that +provider's source, not inferred. + +| Claim | Constraint | Consequence if violated | +|---|---|---| +| `exp` | present, numeric | **panics the Forgejo handler** — unchecked type assertion, [openidConnect.go:341](https://github.com/markbates/goth/blob/master/providers/openidConnect/openidConnect.go#L341) | +| `iss` | byte-identical to the discovery document's `issuer` | error | +| `aud` | equals `client_id`, or an array containing it | error | +| `sub` | becomes `UserID`; must match `/userinfo`'s `sub` if that endpoint is served | error | +| `email` | non-empty | falls through to the account-linking page, `routers/web/auth/oauth.go:1115` | + +Three properties of goth that shape the design: + +**PKCE is available.** `generateCodeChallenge` only emits a challenge for a whitelist of providers, and +`*openidConnect.Provider` is on it (`routers/web/auth/oauth.go:1513`). Forgejo will send `code_challenge` with +`S256`, so the bridge can require it rather than merely accept it. + +**`/userinfo` is optional.** goth skips the request entirely when the discovery document omits `userinfo_endpoint` +(`openidConnect.go:363`). We serve it anyway for conformance — it costs about fifteen lines — but this is a choice, not +a constraint. Serving it does impose one: its `sub` must exactly match the ID token's, or goth rejects the response +(`openidConnect.go:384`). + +**goth does not verify the ID token signature.** `decodeJWT` splits the token on `.`, base64-decodes the payload, and +JSON-parses it; the signature segment is never examined (`openidConnect.go:510`). There is no JWKS fetch anywhere in the +provider, and `jwks_uri` is not even a field on the struct goth deserialises the discovery document into. See +[Trust model](#trust-model) — this is the single most important fact in this document. + +## Architecture + +A single Express + TypeScript process, seven endpoints, two façades. Dependencies: `express`, `jose` for token signing +and verification, `qrcode` for server-side QR rendering, `uuid`, and `@metastate-foundation/auth` as a workspace +dependency (which brings in `signature-validator`). + +| Forgejo-facing | Purpose | +|---|---| +| `GET /.well-known/openid-configuration` | discovery | +| `GET /authorize` | validate request, open a session, serve the QR page | +| `POST /token` | exchange code + `code_verifier` for an ID token | +| `GET /userinfo` | served for conformance; goth would skip it if absent | +| `GET /jwks` | public key — decorative, see [Trust model](#trust-model) | + +| W3DS-facing | Purpose | +|---|---| +| `POST /w3ds/callback` | wallet posts ename + signature | +| `GET /w3ds/events/:session` | SSE; tells the browser when to continue | + +### Flow + +``` + browser bridge wallet registry + │ │ │ │ + click "W3DS" │ │ │ + ├─ GET /authorize ▶ │ │ + │ client_id, redirect_uri, state, │ │ + │ nonce, code_challenge (S256) │ │ + │ ├ validate, create session │ │ + ◀─ QR page ───────┤ TTL 5 min │ │ + ├─ SSE /events ──▶│ │ │ + │ │ │ │ + │ scan ─────────────────────────────▶│ │ + │ ◀── POST /w3ds/callback ───┤ │ + │ │ ename, session, │ │ + │ │ signature, appVersion │ │ + │ ├─ verifySignature ────────────────────────▶ + │ ◀──────────────── public key ──────────────┤ + │ ├ mint code, TTL 60 s, single use │ + ◀─ SSE: redirect ─┤ │ │ + │ │ │ │ + ├─────────────▶ Forgejo /callback?code&state │ │ + │ Forgejo ─ POST /token ──────▶│ │ + │ ◀── id_token ───────┤ │ +``` + +### Modules + +Each is independently testable and has one job. + +``` +config.ts env parsing; throws at startup on anything missing +store.ts two TTL maps — sessions, authorisation codes +keys.ts signing key, JWKS document +claims.ts ename → { sub, nickname, preferred_username, email } +oidc/ discovery · authorize · token · userinfo +w3ds/ callback (signature verification) · events (SSE) +``` + +The session map holds what `/authorize` captured — `client_id`, `redirect_uri`, `state`, `nonce`, `code_challenge` — +and gains an `ename` once the wallet callback verifies. The code map holds a single-use authorisation code bound to +that same tuple. Nothing else is stored; the access token is a JWT, so it needs no third map. + +`claims.ts` is isolated because it holds every fragile rule in the system. It is a pure function with no dependencies, +so it can be tested exhaustively for nothing. + +The QR page rendered by `/authorize` is server-side HTML with one inline script: an `EventSource` on +`/w3ds/events/:session` that navigates to the callback URL when the stream says so, and displays the error otherwise. +The QR image itself is generated by `qrcode` as a data URI, so the page loads nothing external. + +### Client registry + +One client: GitW3. Lookup is isolated in a single function so that adding a second is a few lines rather than a +refactor. No dynamic registration. + +### Scopes + +The discovery document advertises `openid`, `profile` and `email`. goth appends `openid` regardless +(`openidConnect.go:446`), and all three map to claims the bridge already emits, so the administrator can set +`OPENID_CONNECT_SCOPES` to any subset without breaking the flow. Unknown scopes are ignored rather than rejected. + +## Claims + +### The `@` trap + +Forgejo's `NormalizeUserName` removes apostrophes and replaces whitespace, `~` and `+` with hyphens +(`models/user/user.go:630`). It does **not** strip `@`, and a username must start with `[0-9a-zA-Z]`. + +Worse, if an administrator sets `USERNAME = preferred_username`, Forgejo splits the claim on `@` and keeps the part +*before* it (`routers/web/auth/auth.go:405`). An ename begins with `@`, so `@alice` yields the empty string. + +No Forgejo code path saves us. The bridge sanitises the name itself, and emits the same value in both `nickname` and +`preferred_username` so the result is identical under either setting. + +### Sanitisation + +Derived from the real rules: `^[\da-zA-Z][-.\w]*$` (dots permitted — `ALLOW_DOTS_IN_USERNAMES` defaults to true, +`modules/setting/service.go:238`), the negative pattern `[-._]{2,}|[-._]$`, and the `reservedUsernames` list plus the +`*.keys`, `*.gpg`, `*.rss`, `*.atom`, `*.png` patterns (`models/user/user.go:639`). + +The 40-character cap comes from `RegisterForm`, which the linking page binds (`routers/web/web.go:696`, +`services/forms/user_form.go:94`). The auto-registration path does not enforce it — it builds the user directly +(`routers/web/auth/oauth.go:1130`). We truncate on both so the two paths cannot disagree about whether a name is +acceptable. + +``` +@alice.w3id + → drop the @ and replace any character outside [0-9A-Za-z_.-] with a hyphen + → collapse runs of [-._] to a single hyphen + → strip leading and trailing [-._] + → truncate to 40, then re-strip the tail + → if empty, or reserved, or matching a reserved pattern → emit the empty string +``` + +Case is preserved. Forgejo stores the name as given and compares on `LowerName`, so lowercasing would gain nothing and +lose legibility. + +`W3DS_EXTRA_RESERVED_USERNAMES` extends the reserved list. An instance may have names of its own that must not be +claimed — an organisation that already exists, a route added by a future upstream release. It also makes the fallback +path testable end to end: enames are assigned by the Provisioner, not chosen, so the only way to exercise a reserved +name against a real wallet is to reserve a name that was actually issued. + +| ename | `sub` | `nickname` / `preferred_username` | `email` | +|---|---|---|---| +| `@alice` | `@alice` | `alice` | `alice@w3ds.invalid` | +| `@user-a.w3id` | `@user-a.w3id` | `user-a.w3id` | `user-a.w3id@w3ds.invalid` | +| `@_bob` | `@_bob` | `bob` | `_bob@w3ds.invalid` | +| `@admin` | `@admin` | `""` → linking page | `admin@w3ds.invalid` | + +`sub` keeps the full ename. It is the identity, and it must never be ambiguous; the username is presentation only. + +Forgejo stores it as `user.login_name` alongside `user.login_source`, and looks a returning user up by that pair first +(`routers/web/auth/oauth.go:1615`). `external_login_user` is the *fallback* lookup and is only written on the linking +path, so an auto-provisioned W3DS account has no row there. Verified: after a first sign-in, `login_name` holds the +full eName, `@` included, and `external_login_user` is empty. + +### The fallback must be an empty string, never an absent claim + +When the sanitiser produces nothing usable, the bridge hands the problem to Forgejo: an absent or empty `nickname` +routes to `showLinkingLogin` (`routers/web/auth/oauth.go:1118`), the page where the person picks their own username or +links an existing account. Delegating to upstream machinery keeps the bridge stateless and the experience native. + +One caveat found by running it: with `DISABLE_REGISTRATION = true` the linking page renders, but its "Register new +account" tab has no username field (`templates/user/auth/signup_inner.tmpl:14`), so the person cannot complete the +fallback themselves. Auto-provisioning still works on such an instance — that path never consults +`DISABLE_REGISTRATION` — so only the fallback is affected. It stays theoretical in practice: the Provisioner issues +UUID-shaped eNames, which cannot collide with Forgejo's reserved list, so the fallback is only reachable through +`W3DS_EXTRA_RESERVED_USERNAMES`, which is an administrator's deliberate act. + +**But the claim must be present and empty, not omitted.** Omitting it panics Forgejo on that very page: + +1. auto-registration correctly guards the nil — `RawData["preferred_username"] == nil || + RawData["preferred_username"].(string) == ""` short-circuits (`routers/web/auth/oauth.go:1120`) — so + `missingFields` fires and the user is sent to the linking page; +2. the linking page calls `getUserName` unconditionally (`routers/web/auth/linkaccount.go:53`); +3. `getUserName` does `RawData["preferred_username"].(string)` with no guard (`routers/web/auth/auth.go:405`). + +Absent key → nil → unchecked type assertion → the handler panics. This only bites under `USERNAME = +preferred_username`, which is exactly the configuration the "identical under either setting" property is supposed to +cover. + +An empty string satisfies every step: the nil guard passes, the assertion succeeds, `missingFields` still fires, and +`NormalizeUserName("")` returns `"", nil`. Under `nickname` the behaviour is unchanged, because goth's `getClaimValue` +skips values of zero length and falls through to `""` anyway +([openidConnect.go:481](https://github.com/markbates/goth/blob/master/providers/openidConnect/openidConnect.go#L481)). + +OIDC says a claim with no value SHOULD be omitted rather than sent empty. We deviate knowingly: the only consumer is +GitW3, and for GitW3 omission is a crash. **Do not "clean this up" by dropping empty claims from the ID token.** + +### Synthetic email + +W3DS provides no email address, and Forgejo requires a non-empty one. The bridge builds the local part from the ename +with the leading `@` removed and any character outside `[0-9A-Za-z._-]` replaced by a hyphen, on a configurable domain. + +This is a *different* derivation from the username, deliberately: the username is squeezed through Forgejo's naming +rules, whereas the address only has to parse and be unique. `@_bob` gives the username `bob` but the address +`_bob@w3ds.invalid`. Staying closer to the ename means the email is never itself the cause of a false conflict between +two distinct identities — any collision that does occur is a username collision, handled below. + +Forgejo validates the address with `mail.ParseAddress` and then against the domain allow and block lists, with no DNS +lookup (`modules/validation/email.go:73`). Both lists are empty by default, so `w3ds.invalid` passes. An instance that +sets `EMAIL_DOMAIN_ALLOWLIST` must include the synthetic domain. + +The default is `w3ds.invalid`: an RFC 2606 reserved TLD, guaranteed never to resolve, so mail cannot leak to a domain +someone might register. **These addresses never deliver.** Forgejo notifications to a W3DS account go nowhere. That is +inherent to W3DS not carrying email, not a defect. The escape hatch is that users can set a real address in their +Forgejo settings afterwards. + +In staging the domain may be pointed at a real domain with a null MX (RFC 7505) so that bounces are clean rejections +rather than DNS failures piling up in the mail queue. + +## Trust model + +**goth does not verify the ID token signature.** Two consequences, both of which must be stated rather than assumed: + +`/jwks` is decorative. Nothing in Forgejo will fetch it. We serve it for conformance. + +**The transport between Forgejo and the bridge carries the entire security of the flow.** The ID token is trustworthy +because it arrives over the back channel, on a TLS connection, in response to a request authenticated with +`client_secret`. OIDC §3.1.3.7 explicitly permits skipping signature validation under exactly these conditions — but it +means TLS is the mechanism here, not defence in depth. On a shared Docker network without TLS, anyone able to intercept +that connection can forge an identity. This is a deployment requirement, not an infrastructure detail. + +### Per-step protections + +| Step | Protection | Rationale | +|---|---|---| +| `/authorize` | PKCE S256 **mandatory**, `plain` rejected | Forgejo sends it; verified | +| | `redirect_uri` compared exactly | no prefix matching | +| session ↔ wallet | uuid v4, 122 bits of CSPRNG | this is the signed value; it must be unpredictable | +| | single use, 5-minute TTL | prevents replay | +| identity | `verifySignature` against the Registry | **the actual trust anchor** | +| | `appVersion >= 0.4.0` | failure pushed into the SSE stream | +| code → token | single use, 60-second TTL | bound to client, `redirect_uri` and `code_challenge` | +| `/token` | `client_secret`, constant-time comparison | | +| ID token | ES256, stable `kid` from day one | consistent with P-256 throughout W3DS; `kid` makes rotation possible later without breakage | + +`state` is echoed verbatim — Forgejo generates and checks it itself. `nonce` is propagated into the ID token when +present; goth does not validate it, so this is defence in depth for any future client. + +### Access token + +Because `/userinfo` is served, goth will call it with a bearer token. Rather than a third in-memory map, the access +token is itself a JWT signed with the same key, 5-minute TTL, carrying the same `sub` as the ID token — `/userinfo` +verifies it with `jose`, statelessly. + +### Account linking must stay on `login` + +Two distinct enames can sanitise to the same username; `@Alice` and `@alice` suffice, since Forgejo compares on +`LowerName`. + +With `ACCOUNT_LINKING = login` — the default (`modules/setting/oauth2.go:78`) — the second arrival reaches the linking +page and must prove they own the existing account. Safe. + +With `ACCOUNT_LINKING = auto`, Forgejo looks the account up by name, links it, and signs the person straight in +(`routers/web/auth/auth.go:573-588`). **That is an account takeover.** `auto` is prohibited, and the staging checklist +verifies it. + +### Silent authentication must be refused + +Forgejo issues a long-term SSO token tied to the authentication source +(`routers/web/auth/auth.go:83`). On a later visit to the login page it redirects to +`/user/oauth2/?prompt=none`, asking to re-authenticate the person with no interaction at all +(OIDC Core §3.1.2.1). + +The bridge keeps no session of its own — every login is a fresh QR code someone has to scan — so silent authentication +can never succeed, and rendering the QR page would be exactly the interaction the parameter forbids. `prompt=none` gets +`error=login_required` immediately. + +Forgejo already implements the other half: on `login_required` it retries interactively +(`routers/web/auth/oauth.go:1012`). Answering correctly is what makes the login page work at all for someone who has +signed in before; ignoring `prompt` strands them on a QR page they did not ask for. This only shows up on the *second* +login, which is why it survived every single-pass test. + +### The W3DS half needs CORS; the OIDC half does not + +A native eID Wallet sends no `Origin` and is unaffected. A browser-based one — starting with the Dev Sandbox, which is +the documented way to test this flow — posts JSON from its own origin, which triggers a preflight. Express answers +`OPTIONS` with a bare 200 and no CORS headers, so the browser blocks the request and reports only "Failed to fetch": +the login hangs with nothing in any log to explain it. + +`/w3ds/callback` and `/w3ds/events/:session` therefore allow any origin, without credentials. They carry no cookie and +no ambient authority — the callback is authenticated by the ECDSA signature over the session id, checked against the +Registry — so refusing an origin would stop no attacker (curl has none) while breaking every wallet that happens to run +in a browser. + +The OIDC endpoints get nothing: `/token` and `/userinfo` are back-channel calls from Forgejo, and `/authorize` is a +top-level navigation. None is ever a cross-origin fetch. + +### Error responses + +An error on `/authorize` is returned to the `redirect_uri` only once that `redirect_uri` has been validated. Unknown +client or unregistered URI renders an error page and never redirects — otherwise the bridge becomes an open redirector. + +Every failure after the QR is scanned — outdated wallet, invalid signature, expired session — goes into the SSE stream. +The browser is waiting in front of a QR code and has no other way to learn that something went wrong. + +### Out of scope, deliberately + +No refresh tokens, no consent screen, no `end_session_endpoint`, no dynamic client registration. A single trusted client +and short sessions. Each of these would be code to write, test and maintain for a need that does not exist. + +## Deployment + +### Shape + +`services/w3ds-oidc-bridge/`, a workspace member, flat rather than with an `api/` subdirectory — awareness-service has +one because it also ships a Svelte portal; this is a single process. `docker/Dockerfile.w3ds-oidc-bridge` follows the +`docker/Dockerfile.` convention. Configuration is read from the root `.env` through a `required()` helper that +throws at startup, matching [awareness-service's config](../../../services/awareness-service/api/src/config.ts). + +| Variable | Default | Note | +|---|---|---| +| `W3DS_OIDC_PUBLIC_URL` | — | the `issuer`; goth compares byte for byte | +| `W3DS_OIDC_PORT` | `4200` | | +| `W3DS_OIDC_CLIENT_ID` | — | | +| `W3DS_OIDC_CLIENT_SECRET` | — | | +| `W3DS_OIDC_REDIRECT_URI` | — | GitW3's callback; compared exactly | +| `W3DS_OIDC_SIGNING_KEY` | — | ES256 private key, never committed | +| `W3DS_OIDC_KEY_ID` | — | stable `kid` | +| `W3DS_EMAIL_DOMAIN` | `w3ds.invalid` | | +| `W3DS_EXTRA_RESERVED_USERNAMES` | empty | comma-separated; added to Forgejo's reserved list | +| `W3DS_MIN_WALLET_VERSION` | `0.4.0` | | +| `W3DS_OIDC_ALLOW_INSECURE` | `false` | local development only; see below | +| `PUBLIC_REGISTRY_URL` | — | already present in the root `.env` | + +### The bridge must be up before GitW3 starts + +Forgejo fetches the discovery document **once, at startup**, when it registers the authentication source +(`services/auth/source/oauth2/init.go:92`). If the bridge is unreachable at that moment the source is not registered at +all, and it stays gone until GitW3 is restarted — the button disappears from the login page with only a line in the +log. + +The failure that follows is worse than the cause, because it does not point back at it: with the source unregistered, +`generateCodeChallenge` no longer recognises it as an `openidConnect` provider, so Forgejo stops sending PKCE, and the +bridge rejects the request with `code_challenge is required`. Observed exactly that way while testing. + +So the two services have an ordering dependency: start the bridge first, and restart GitW3 after any deployment that +takes the bridge down. This is a deployment constraint, not a runtime one — once registered, the source survives a +bridge restart. + +### The back channel must be TLS + +`W3DS_OIDC_PUBLIC_URL` is the issuer, and Forgejo derives the token endpoint from it through discovery. It **must** be +`https://` in staging and production. This is not hardening: because goth never verifies the ID token signature, TLS +plus `client_secret` is the only thing distinguishing a real ID token from a forged one — see +[Trust model](#trust-model). + +Plain HTTP is acceptable only for local development, where the bridge and Forgejo run on the same host. Anywhere else, +a bridge reachable over HTTP is an identity forgery endpoint for anyone who can intercept that connection. The bridge +refuses to start when `W3DS_OIDC_PUBLIC_URL` is not `https://`, unless `W3DS_OIDC_ALLOW_INSECURE=true` is set +explicitly — so the unsafe case has to be chosen, never inherited. + +### GitW3 configuration + +`ENABLE_AUTO_REGISTRATION` is a `MustBool()` with no default (`modules/setting/oauth2.go:71`), so it is `false`. Without +it a new W3DS user gets no account — they land on the linking page. `ALLOW_ONLY_INTERNAL_REGISTRATION` must also stay +`false` (`routers/web/auth/oauth.go:1103`). + +```ini +[oauth2_client] +ENABLE_AUTO_REGISTRATION = true +ACCOUNT_LINKING = login ; default — `auto` is an account takeover, see Trust model +USERNAME = nickname ; default — works +REGISTER_EMAIL_CONFIRM = false ; see below — must be set here, not in [service] +``` + +#### Email confirmation locks W3DS accounts out permanently + +New OAuth2 users are created with +`IsActive: !OAuth2Client.RegisterEmailConfirm && !Service.RegisterManualConfirm` (`routers/web/auth/oauth.go:1145`). +And `[oauth2_client] REGISTER_EMAIL_CONFIRM` inherits from `[service] REGISTER_EMAIL_CONFIRM` when it is not set +explicitly — `MustBool(Service.RegisterEmailConfirm)`, `modules/setting/oauth2.go:69`. + +So on any instance that turns on email confirmation — ordinary hygiene for a public forge — every W3DS account is +created inactive and its activation mail is sent to a `w3ds.invalid` address that never delivers. The account cannot be +activated through the normal path, ever. For any other OIDC provider this works fine; it is specifically the synthetic +email that breaks it. + +The override must go in `[oauth2_client]`, not `[service]`, or the inheritance takes over again. This costs nothing in +security: the W3DS signature is a stronger identity proof than an email round-trip, and the address is synthetic anyway, +so confirming it would prove nothing. + +`REGISTER_MANUAL_CONFIRM` has the same effect and is read from `[service]` directly. It is less severe — an +administrator can activate the account by hand — but it should be a deliberate choice rather than a surprise, because +the symptom is the same: sign-in succeeds, the account exists, and login is refused. + +The remainder of acceptance criterion 3 is configuration, not code: an OAuth2 authentication source of type OpenID +Connect, pointed at the bridge's discovery URL, with the source's `IconURL` field +(`services/auth/source/oauth2/source.go:20`) pointing at `/icon.svg`. Forgejo drops that URL into an +`` (`services/auth/source/oauth2/providers.go:62`), so the bridge serves its own mark: the browser can +always reach it, and the icon cannot fall out of step with the service that owns it. Nothing in Forgejo is patched, +which preserves the fork's zero patch surface. + +## Testing + +**Unit — `claims.ts`, exhaustively.** This is the function that holds every trap: the leading `@`, consecutive +separators, truncation at 40, reserved names. Pure, dependency-free, so full edge-case coverage is nearly free. + +One assertion in there is a regression guard rather than a behaviour check: for a reserved or unmappable ename, the +`nickname` and `preferred_username` keys must be **present with an empty value**. A test that only checks the value is +falsy would pass on an absent key, which is the crash case. Assert on key presence explicitly. + +**Unit — flow protections, with `verifySignature` stubbed.** A code refused on second use. A `code_verifier` that does +not match. A `redirect_uri` differing by one character. An expired session. An `appVersion` of `0.3.9`. Each must fail, +and fail in the specified way. + +**End to end — no phone required.** The [Dev Sandbox](../../../skills/w3ds/reference/dev-setup.md) is a complete wallet +substitute: provision an eVault, paste the `w3ds://auth` URI the bridge renders, and it signs the session and POSTs to +the callback. This exercises the whole chain including real signature verification against the Registry. + +**Staging.** Acceptance criterion 5 requires the flow tested end to end in staging, so the walkthrough is repeated there +with a real eID Wallet. Debugging happens locally. Four things are checked in staging that cannot be checked anywhere +else: + +- the discovery URL is `https://` and `W3DS_OIDC_ALLOW_INSECURE` is unset; +- the bridge came up **before** GitW3, and the GitW3 log has no `Unable to register source`; +- `ACCOUNT_LINKING` is `login`, not `auto`; +- `REGISTER_EMAIL_CONFIRM` is `false` in `[oauth2_client]`, not inherited from `[service]`; +- a fresh ename gets an **active** account created, and signing in again reuses it rather than creating a second; +- a reserved name reaches the linking page and renders it, rather than panicking the handler. Enames are assigned by + the Provisioner rather than chosen, so this is reached by adding an issued name to + `W3DS_EXTRA_RESERVED_USERNAMES`, not by provisioning `@admin`. + +## Acceptance criteria + +| # | Criterion | Covered by | +|---|---|---| +| 1 | W3DS accepted as an authentication method | OAuth2 source of type OpenID Connect | +| 2 | Users sign in with their W3DS identity | wallet contract + `verifySignature` | +| 3 | W3DS login on the login page beside existing options | OAuth2 source config + `IconURL` | +| 4 | Successful auth creates or maps to a GitW3 account | `ENABLE_AUTO_REGISTRATION`, `external_login_user`, `claims.ts` | +| 5 | Flow tested end to end in staging | Dev Sandbox locally, real wallet in staging | + +## Open items + +Not blocking implementation. + +- The W3DS protocol documentation says `w3id` where every implementation uses `ename`. Worth a separate fix. +- `appVersion` is documented as temporary and will be removed once the wallet rollout completes. The gate should be + removable without touching anything else, so it stays in one place. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67b478bff..d3c4eab45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3272,7 +3272,7 @@ importers: version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) draft-js: specifier: ^0.11.7 - version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@18.3.1) @@ -3293,7 +3293,7 @@ importers: version: 18.3.1(react@18.3.1) react-draft-wysiwyg: specifier: ^1.15.0 - version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.55.0 version: 7.71.2(react@18.3.1) @@ -4125,6 +4125,49 @@ importers: specifier: ^2.1.0 version: 2.1.9(@types/node@24.12.0)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0) + services/w3ds-oidc-bridge: + dependencies: + '@metastate-foundation/auth': + specifier: workspace:* + version: link:../../packages/auth + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.22.1 + jose: + specifier: ^5.2.2 + version: 5.10.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 + tsx: + specifier: ^4.7.1 + version: 4.21.0 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^3.0.9 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + services/webhook-inlet-test: dependencies: express: @@ -5898,11 +5941,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} @@ -30048,6 +30091,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/utils': 3.2.4 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + ws: 8.19.0(bufferutil@4.1.0) + optionalDependencies: + playwright: 1.58.2 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 @@ -30067,6 +30130,26 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/utils': 3.2.4 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + ws: 8.19.0(bufferutil@4.1.0) + optionalDependencies: + playwright: 1.58.2 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 @@ -30152,6 +30235,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@24.12.0)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0) + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -32639,9 +32730,9 @@ snapshots: dotenv@17.3.1: {} - draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - fbjs: 2.0.0(encoding@0.1.13) + fbjs: 2.0.0 immutable: 3.7.6 object-assign: 4.1.1 react: 18.3.1 @@ -32649,9 +32740,9 @@ snapshots: transitivePeerDependencies: - encoding - draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 drizzle-kit@0.31.9: @@ -33102,8 +33193,8 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1)) @@ -33166,21 +33257,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.4(jiti@2.6.1) - get-tsconfig: 4.13.6 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -33223,17 +33299,6 @@ snapshots: - supports-color eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -33273,35 +33338,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -33313,7 +33349,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -34053,7 +34089,7 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@2.0.0(encoding@0.1.13): + fbjs@2.0.0: dependencies: core-js: 3.48.0 cross-fetch: 3.2.0(encoding@0.1.13) @@ -34949,9 +34985,9 @@ snapshots: html-tags@3.3.1: {} - html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 html-url-attributes@3.0.1: {} @@ -39323,12 +39359,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: classnames: 2.5.1 - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) - html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) immutable: 5.1.5 linkify-it: 2.2.0 prop-types: 15.8.1 @@ -42175,6 +42211,27 @@ snapshots: - supports-color - terser + vite-node@3.2.4(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@5.5.0) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 @@ -42249,6 +42306,24 @@ snapshots: sass: 1.98.0 terser: 5.46.0 + vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.26 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + sass: 1.98.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -42444,6 +42519,50 @@ snapshots: - supports-color - terser + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@5.5.0) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.26 + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + jsdom: 19.0.0(bufferutil@4.1.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 @@ -42472,7 +42591,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.19.15 - '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) jsdom: 19.0.0(bufferutil@4.1.0) transitivePeerDependencies: - jiti diff --git a/services/w3ds-oidc-bridge/README.md b/services/w3ds-oidc-bridge/README.md new file mode 100644 index 000000000..fdba67ddd --- /dev/null +++ b/services/w3ds-oidc-bridge/README.md @@ -0,0 +1,157 @@ +# w3ds-oidc-bridge + +An OpenID Connect provider that authenticates people with their W3DS identity. + +GitW3 — the MetaState fork of Forgejo — has no plugin API, and the fork is kept at a patch surface of zero so upstream +security releases merge cleanly. So instead of teaching Forgejo about W3DS, this service speaks a protocol Forgejo +already understands. Forgejo believes it is talking to an ordinary OIDC provider. The wallet believes it is talking to +an ordinary W3DS platform. Neither is modified. + +This works because OIDC never specifies *how* a provider authenticates someone. Here it is a QR code signed by an eID +wallet. + +**Design:** [docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md](../../docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md) +**Plan:** [docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md](../../docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md) + +## The two contracts + +**Wallet side** is fixed by the platforms already in production. The bridge serves a QR encoding +`w3ds://auth?redirect=…&session=…&platform=gitw3`, built with `buildAuthOffer()` from `@metastate-foundation/auth` so it +cannot drift from the canonical format. The wallet signs **the session id itself** and POSTs +`{ ename, session, signature, appVersion }` back. The signature is checked against the Registry. + +**Forgejo side** is fixed by `markbates/goth`. The ID token must carry `exp`, an `iss` byte-identical to the discovery +document, an `aud` matching the client id, a `sub`, and a non-empty `email`. Two of goth's behaviours shape the design +and are easy to get wrong: it never verifies the ID token signature, and an *absent* `preferred_username` claim panics +its account-linking page. Both are covered in the spec. + +## Endpoints + +| Forgejo-facing | | +|---|---| +| `GET /.well-known/openid-configuration` | discovery | +| `GET /authorize` | opens a session, serves the QR page | +| `POST /token` | code + `code_verifier` → ID token | +| `GET /userinfo` | served for conformance | +| `GET /jwks` | public key | +| `GET /icon.svg` | the login button mark | + +| W3DS-facing | | +|---|---| +| `POST /w3ds/callback` | wallet posts ename and signature | +| `GET /w3ds/events/:session` | SSE; tells the browser when to continue | +| `GET /apple-touch-icon.png` | the mark again, for the wallet's approval screen | + +The wallet resolves the app icon from the **hostname** of the redirect URI — the bridge — rather than from the +`platform` parameter, and falls back through `/apple-touch-icon.png` and `/favicon.ico` to a letter on a coloured +square. Serving that path is what stops GitW3 showing up as a bare letter, and it takes effect without a wallet +release. The hostname is also where the displayed name comes from, so it is worth choosing deliberately: +`gitw3-login.example` announces itself as "gitw3-login". + +## Configuration + +Read from the repository root `.env`. Every key without a default is required, and the service refuses to start without +it rather than failing later. + +| Variable | Default | Note | +|---|---|---| +| `W3DS_OIDC_PUBLIC_URL` | — | the `issuer`; a trailing slash is stripped, because goth compares it byte for byte | +| `W3DS_OIDC_PORT` | `4200` | | +| `W3DS_OIDC_CLIENT_ID` | — | | +| `W3DS_OIDC_CLIENT_SECRET` | — | | +| `W3DS_OIDC_REDIRECT_URI` | — | GitW3's callback; compared exactly | +| `W3DS_OIDC_SIGNING_KEY` | — | ES256 private key, never committed | +| `W3DS_OIDC_KEY_ID` | — | stable `kid` | +| `W3DS_OIDC_ALLOW_INSECURE` | `false` | local development only | +| `W3DS_EMAIL_DOMAIN` | `w3ds.invalid` | synthetic addresses; they never deliver | +| `W3DS_EXTRA_RESERVED_USERNAMES` | empty | comma-separated, added to Forgejo's reserved list | +| `W3DS_MIN_WALLET_VERSION` | `0.4.0` | | +| `PUBLIC_REGISTRY_URL` | — | already in the root `.env` | + +### The back channel must be TLS + +`W3DS_OIDC_PUBLIC_URL` must be `https://` outside local development. Because goth never verifies the ID token +signature, TLS plus the client secret is the only thing separating a real ID token from a forged one. The service +refuses to start on `http://` unless `W3DS_OIDC_ALLOW_INSECURE=true` is set explicitly, so the unsafe case has to be +chosen rather than inherited. + +## Wiring it into GitW3 + +Forgejo keeps authentication sources in its database rather than in `app.ini`, so they cannot be declared with the rest +of the configuration. [`docker/gitw3-register-auth-source.sh`](../../docker/gitw3-register-auth-source.sh) closes that +gap for deployments — it is idempotent, so it can run on every deploy. By hand it is **Site Administration → +Authentication Sources → Add**, type OAuth2, provider OpenID Connect. + +| Field | Value | +|---|---| +| Auto Discovery URL | `/.well-known/openid-configuration` | +| Client ID | `W3DS_OIDC_CLIENT_ID` | +| Client Secret | `W3DS_OIDC_CLIENT_SECRET` | +| Icon URL | `/icon.svg` | + +The bridge serves its own button icon, so there is nothing to host separately and the mark can never fall out of step +with the service. Forgejo renders it inside ``. + +Then, in `app.ini`: + +```ini +[oauth2_client] +ENABLE_AUTO_REGISTRATION = true +ACCOUNT_LINKING = login +USERNAME = nickname +REGISTER_EMAIL_CONFIRM = false +``` + +Only the first and last are changes from the defaults, and both matter. Without `ENABLE_AUTO_REGISTRATION` a new W3DS +user gets no account at all. Without `REGISTER_EMAIL_CONFIRM = false` **in this section** — it inherits `[service]` +otherwise — every account is created inactive and its activation mail is sent to an address that never delivers, which +locks the person out permanently. + +`ACCOUNT_LINKING` must stay `login`. On `auto`, two eNames that sanitise to the same username let the second person +into the first person's account. + +**Start the bridge before GitW3.** Forgejo fetches the discovery document once, when it registers the authentication +source at startup. If the bridge is down at that moment the source is not registered at all and the button vanishes +from the login page until GitW3 is restarted — with a confusing follow-on symptom, because an unregistered source also +stops Forgejo sending PKCE, and the bridge then rejects the request with `code_challenge is required`. Once registered, +the source survives a bridge restart. + +## Running locally + +```bash +pnpm --filter w3ds-oidc-bridge dev +``` + +Or as the container: + +```bash +docker build -f docker/Dockerfile.w3ds-oidc-bridge -t w3ds-oidc-bridge . +``` + +## Deploying it with GitW3 + +[`docker-compose.gitw3.yml`](../../docker-compose.gitw3.yml) brings up both services with the startup order enforced by +a healthcheck, applies the four `[oauth2_client]` settings above through `FORGEJO__*` environment variables, and +registers the authentication source. It is a **candidate** manifest — nothing else in this repository deploys a +service, so it is a proposal to whoever owns the environment rather than an established convention. + +```bash +docker compose -f docker-compose.gitw3.yml --env-file .env up -d +docker compose -f docker-compose.gitw3.yml restart gitw3 # first deploy only +``` + +The restart is needed once, because the source is created after GitW3 has already read its sources at boot. + +One constraint has no workaround: GitW3 exchanges the authorization code over the bridge's **public** hostname, since +the discovery document publishes absolute URLs. `http://w3ds-oidc-bridge:4200` would fail the issuer comparison, so the +container has to resolve its own public name. + +## Testing without a phone + +The [Dev Sandbox](../../infrastructure/dev-sandbox) is a full wallet substitute. Provision an eVault, copy the +`w3ds://auth` URI from the bridge's QR page, paste it into the sandbox and click **Perform** — it signs the session and +POSTs to the callback, exercising the whole chain including real signature verification against the Registry. + +```bash +pnpm --filter w3ds-oidc-bridge test +``` diff --git a/services/w3ds-oidc-bridge/package.json b/services/w3ds-oidc-bridge/package.json new file mode 100644 index 000000000..5f3300e8c --- /dev/null +++ b/services/w3ds-oidc-bridge/package.json @@ -0,0 +1,36 @@ +{ + "name": "w3ds-oidc-bridge", + "version": "0.1.0", + "description": "OpenID Connect provider that authenticates with W3DS, so GitW3 can accept W3DS login through Forgejo's native OAuth2 source", + "type": "module", + "private": true, + "main": "./dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", + "check": "npx @biomejs/biome check ./src && tsc --noEmit", + "check-format": "npx @biomejs/biome format ./src", + "check-lint": "npx @biomejs/biome lint ./src", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@metastate-foundation/auth": "workspace:*", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "jose": "^5.2.2", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.11.24", + "@types/qrcode": "^1.5.6", + "tsx": "^4.7.1", + "typescript": "^5.3.3", + "vitest": "^3.0.9" + } +} diff --git a/services/w3ds-oidc-bridge/src/app.ts b/services/w3ds-oidc-bridge/src/app.ts new file mode 100644 index 000000000..bc6d98522 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/app.ts @@ -0,0 +1,58 @@ +import cors from "cors"; +import express, { type Express } from "express"; +import type { BridgeContext } from "./context.js"; +import { createIconHandler, createTouchIconHandler } from "./icon.js"; +import { createAuthorizeHandler } from "./oidc/authorize.js"; +import { createDiscoveryHandler, createJwksHandler } from "./oidc/discovery.js"; +import { createTokenHandler } from "./oidc/token.js"; +import { createUserinfoHandler } from "./oidc/userinfo.js"; +import { createCallbackHandler, createEventsHandler } from "./w3ds/callback.js"; + +export function createApp(ctx: BridgeContext): Express { + const app = express(); + + // Behind TLS termination in staging and production, so trust the proxy's + // headers for logging and for req.protocol. + app.set("trust proxy", true); + app.disable("x-powered-by"); + + // The token endpoint receives a form; the wallet sends JSON. + app.use(express.urlencoded({ extended: false })); + app.use(express.json()); + + app.get("/healthz", (_req, res) => { + res.json({ ok: true }); + }); + + // Pointed at by the authentication source's IconURL in GitW3. + app.get("/icon.svg", createIconHandler()); + // Not referenced by anything here — the eID Wallet fetches this path by + // convention when it renders the approval screen. See icon.ts. + app.get("/apple-touch-icon.png", createTouchIconHandler()); + + app.get("/.well-known/openid-configuration", createDiscoveryHandler(ctx)); + app.get("/jwks", createJwksHandler(ctx)); + app.get("/authorize", createAuthorizeHandler(ctx)); + app.post("/token", createTokenHandler(ctx)); + app.get("/userinfo", createUserinfoHandler(ctx)); + + // The W3DS half is cross-origin by nature, and only this half. + // + // A native eID Wallet sends no Origin and is unaffected, but a browser-based + // one — starting with the Dev Sandbox, which is the documented way to test + // this flow — posts JSON from its own origin, which triggers a preflight. + // Without an answer to that preflight the browser blocks the request and + // reports only "Failed to fetch", so the login hangs with nothing to debug. + // + // Any origin is allowed, and credentials are not. These two endpoints carry + // no cookie and no ambient authority: the callback is authenticated by an + // ECDSA signature over the session id, checked against the Registry. Refusing + // an origin would stop no attacker — curl has no origin — and would break + // every wallet that happens to run in a browser. + const w3dsCors = cors({ origin: true, credentials: false, maxAge: 600 }); + app.options("/w3ds/callback", w3dsCors); + app.post("/w3ds/callback", w3dsCors, createCallbackHandler(ctx)); + app.get("/w3ds/events/:session", w3dsCors, createEventsHandler(ctx)); + + return app; +} diff --git a/services/w3ds-oidc-bridge/src/claims.test.ts b/services/w3ds-oidc-bridge/src/claims.test.ts new file mode 100644 index 000000000..fd4632574 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/claims.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { buildClaims, sanitiseUsername } from "./claims.js"; + +const options = { emailDomain: "w3ds.invalid" }; +const claimsFor = (ename: string, extra: string[] = []) => + buildClaims(ename, { ...options, extraReservedUsernames: extra }); + +describe("sanitiseUsername", () => { + it("drops the leading @", () => { + expect(sanitiseUsername("@alice")).toBe("alice"); + }); + + it("keeps dots, hyphens and underscores inside the name", () => { + expect(sanitiseUsername("@user-a.w3id")).toBe("user-a.w3id"); + expect(sanitiseUsername("@user_a")).toBe("user_a"); + }); + + it("preserves case", () => { + // Forgejo stores the name as given and compares on LowerName, so + // lowercasing would gain nothing and lose legibility. + expect(sanitiseUsername("@Alice")).toBe("Alice"); + }); + + it("strips a leading separator, which a username may not start with", () => { + expect(sanitiseUsername("@_bob")).toBe("bob"); + expect(sanitiseUsername("@-bob")).toBe("bob"); + expect(sanitiseUsername("@.bob")).toBe("bob"); + }); + + it("strips a trailing separator", () => { + expect(sanitiseUsername("@bob-")).toBe("bob"); + expect(sanitiseUsername("@bob.")).toBe("bob"); + }); + + it("collapses runs of separators, which the negative pattern forbids", () => { + expect(sanitiseUsername("@ali..ce")).toBe("ali-ce"); + expect(sanitiseUsername("@ali--ce")).toBe("ali-ce"); + expect(sanitiseUsername("@ali._-ce")).toBe("ali-ce"); + }); + + it("replaces characters outside the allowed set", () => { + expect(sanitiseUsername("@ali ce")).toBe("ali-ce"); + expect(sanitiseUsername("@ali+ce")).toBe("ali-ce"); + expect(sanitiseUsername("@ali/ce")).toBe("ali-ce"); + }); + + it("folds diacritics rather than mangling them", () => { + // Forgejo does the same (removeDiacriticsTransform), and replacing the + // accent with a hyphen instead would silently truncate the name. + expect(sanitiseUsername("@josé")).toBe("jose"); + expect(sanitiseUsername("@ÅSA")).toBe("ASA"); + expect(sanitiseUsername("@straße")).toBe("strasse"); + }); + + it("removes an interior @, which would otherwise reach getUserName", () => { + // Under USERNAME = preferred_username, Forgejo splits on @ and keeps the + // part before it. Nothing may reach it containing one. + expect(sanitiseUsername("@alice@example.org")).toBe( + "alice-example.org", + ); + }); + + describe("length", () => { + it("truncates to 40 characters", () => { + expect(sanitiseUsername(`@${"a".repeat(60)}`)).toBe("a".repeat(40)); + }); + + it("re-strips the tail when truncation lands on a separator", () => { + const ename = `@${"a".repeat(39)}-${"b".repeat(20)}`; + expect(sanitiseUsername(ename)).toBe("a".repeat(39)); + }); + + it("leaves a name of exactly 40 alone", () => { + expect(sanitiseUsername(`@${"a".repeat(40)}`)).toBe("a".repeat(40)); + }); + }); + + describe("names Forgejo will not accept", () => { + it.each([ + "api", + "admin", + "explore", + "login", + "user", + "ghost", + "forgejo-actions", + "favicon.ico", + "swagger.v1.json", + ])("falls back for the reserved name %s", (name) => { + expect(sanitiseUsername(`@${name}`)).toBe(""); + }); + + it("matches the reserved list case-insensitively", () => { + // Forgejo lower-cases before comparing (models/db/name.go:113), so a + // case-sensitive check here would let @Admin through. + expect(sanitiseUsername("@Admin")).toBe(""); + expect(sanitiseUsername("@API")).toBe(""); + }); + + it.each(["foo.keys", "foo.gpg", "foo.rss", "foo.atom", "foo.png"])( + "falls back for the reserved pattern %s", + (name) => { + expect(sanitiseUsername(`@${name}`)).toBe(""); + }, + ); + + it("falls back when nothing usable is left", () => { + expect(sanitiseUsername("@...")).toBe(""); + expect(sanitiseUsername("@---")).toBe(""); + expect(sanitiseUsername("@")).toBe(""); + expect(sanitiseUsername("@ ")).toBe(""); + }); + + it("honours the instance's extra reserved names", () => { + expect(sanitiseUsername("@acme", ["acme"])).toBe(""); + expect(sanitiseUsername("@ACME", ["acme"])).toBe(""); + expect(sanitiseUsername("@acme", ["other"])).toBe("acme"); + }); + }); + + it("accepts an ename with no leading @", () => { + // Not the documented shape, but cheap to tolerate and expensive to get + // wrong. + expect(sanitiseUsername("alice")).toBe("alice"); + }); +}); + +describe("buildClaims", () => { + it("matches the design's example table", () => { + expect(claimsFor("@alice")).toMatchObject({ + sub: "@alice", + nickname: "alice", + preferred_username: "alice", + email: "alice@w3ds.invalid", + }); + + expect(claimsFor("@user-a.w3id")).toMatchObject({ + sub: "@user-a.w3id", + nickname: "user-a.w3id", + email: "user-a.w3id@w3ds.invalid", + }); + + expect(claimsFor("@_bob")).toMatchObject({ + sub: "@_bob", + nickname: "bob", + email: "_bob@w3ds.invalid", + }); + + expect(claimsFor("@admin")).toMatchObject({ + sub: "@admin", + nickname: "", + email: "admin@w3ds.invalid", + }); + }); + + it("keeps the full ename as sub", () => { + // sub is the identity. It lands in external_login_user.external_id and + // must never be ambiguous; the username is presentation only. + expect(claimsFor("@Admin").sub).toBe("@Admin"); + expect(claimsFor("@...").sub).toBe("@..."); + }); + + it("emits the same value in nickname and preferred_username", () => { + // So the result is identical whichever the USERNAME setting is. + for (const ename of ["@alice", "@admin", "@josé", "@..."]) { + const claims = claimsFor(ename); + expect(claims.nickname).toBe(claims.preferred_username); + } + }); + + it("marks the synthetic address unverified", () => { + expect(claimsFor("@alice").email_verified).toBe(false); + }); + + it("uses the configured email domain", () => { + expect( + buildClaims("@alice", { emailDomain: "example.test" }).email, + ).toBe("alice@example.test"); + }); + + it("derives the address from the ename, not from the username", () => { + // Staying closer to the ename keeps the address from being the cause of a + // false conflict between two distinct identities. + const claims = claimsFor("@_bob"); + expect(claims.nickname).toBe("bob"); + expect(claims.email).toBe("_bob@w3ds.invalid"); + }); + + describe("the address must survive Go's mail.ParseAddress", () => { + // Forgejo parses it with mail.ParseAddress before storing it. A dot-atom + // may not begin with, end with, or double up on dots, so the local part + // gets stricter treatment than the username needs. + const localPart = (ename: string) => + claimsFor(ename).email.split("@")[0] ?? ""; + + it.each([ + "@...", + "@alice", + "@_bob", + "@.alice.", + "@a..b", + "@josé", + "@@@", + ])("%s yields a well-formed local part", (ename) => { + const local = localPart(ename); + expect(local).not.toBe(""); + expect(local.startsWith(".")).toBe(false); + expect(local.endsWith(".")).toBe(false); + expect(local).not.toMatch(/\.\./); + expect(local).not.toMatch(/@/); + }); + + it("falls back to a deterministic local part when nothing survives", () => { + expect(localPart("@...")).toMatch(/^w3ds-[0-9a-f]{12}$/); + expect(localPart("@...")).toBe(localPart("@...")); + expect(localPart("@...")).not.toBe(localPart("@---")); + }); + }); + + describe("the fallback must be present and empty, never absent", () => { + // An absent preferred_username panics Forgejo's account-linking page: + // getUserName does RawData["preferred_username"].(string) with no guard + // (routers/web/auth/auth.go:405). This is a regression guard, not a + // behaviour check — an assertion on falsiness alone would pass on an + // absent key, which is exactly the crash case. + it.each(["@admin", "@api", "@...", "@", "@foo.keys"])( + "for %s", + (ename) => { + const claims = claimsFor(ename); + + expect(Object.hasOwn(claims, "nickname")).toBe(true); + expect(Object.hasOwn(claims, "preferred_username")).toBe(true); + expect(claims.nickname).toBe(""); + expect(claims.preferred_username).toBe(""); + }, + ); + + it("survives JSON serialisation into the ID token", () => { + // The claims are signed as JSON. A key whose value is undefined + // disappears at this point rather than at construction, so assert on + // the shape that actually reaches Forgejo. + const encoded = JSON.parse(JSON.stringify(claimsFor("@admin"))); + expect(Object.hasOwn(encoded, "nickname")).toBe(true); + expect(Object.hasOwn(encoded, "preferred_username")).toBe(true); + }); + }); +}); diff --git a/services/w3ds-oidc-bridge/src/claims.ts b/services/w3ds-oidc-bridge/src/claims.ts new file mode 100644 index 000000000..0af99f0a2 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/claims.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; + +/** + * The claims the bridge puts in the ID token, derived from an eName. + * + * This is the whole of the fragile logic in this service, deliberately kept in a + * pure function with no dependencies so it can be tested exhaustively. + */ +export interface W3dsClaims { + /** The full eName. This is the identity; it must never be ambiguous. */ + sub: string; + /** + * The same value in both, so the result is identical whichever of Forgejo's + * USERNAME settings the instance uses. + * + * Empty when the eName cannot be mapped to an acceptable username. It must be + * **present and empty, never absent** — see `sanitiseUsername`. + */ + nickname: string; + preferred_username: string; + email: string; + /** Synthetic and undeliverable, so saying otherwise would be a lie. */ + email_verified: false; +} + +export interface ClaimOptions { + emailDomain: string; + /** Lower-cased names this instance reserves on top of Forgejo's own. */ + extraReservedUsernames?: string[]; +} + +/** + * Copied from GitW3 `models/user/user.go:639`. Kept as a literal rather than + * fetched, because it changes only when upstream changes and a silent drift is + * better caught by a failing login in staging than by an unavailable service. + */ +const RESERVED_USERNAMES = [ + ".", + "..", + "-", + ".well-known", + "api", + "metrics", + "v2", + "assets", + "attachments", + "avatar", + "avatars", + "repo-avatars", + "captcha", + "login", + "org", + "repo", + "user", + "admin", + "explore", + "issues", + "pulls", + "milestones", + "notifications", + "report_abuse", + "favicon.ico", + "manifest.json", + "robots.txt", + "sitemap.xml", + "ssh_info", + "swagger.v1.json", + "ghost", + "gitea-actions", + "forgejo-actions", +]; + +/** Also from `models/user/user.go`. Forgejo matches these as suffixes. */ +const RESERVED_SUFFIXES = [".keys", ".gpg", ".rss", ".atom", ".png"]; + +/** Forgejo's `RegisterForm`, which the account-linking page binds. */ +const MAX_USERNAME_LENGTH = 40; + +/** + * Mirrors Forgejo's own normalisation (`models/user/user.go:630`): decompose, + * drop combining marks, and expand the two characters it special-cases. Without + * this, `@josé` would lose its last letter to the character filter rather than + * becoming `jose`. + */ +function foldDiacritics(value: string): string { + return value + .replace(/Æ/g, "AE") + .replace(/æ/g, "ae") + .replace(/ß/g, "ss") + .normalize("NFD") + .replace(/\p{Mn}/gu, ""); +} + +function stripLeadingAt(ename: string): string { + return ename.startsWith("@") ? ename.slice(1) : ename; +} + +/** + * An eName to a username Forgejo will accept, or the empty string when there + * isn't one. + * + * Empty is the deliberate fallback rather than an invented prefix: Forgejo + * already routes an empty `nickname` to its account-linking page, where the + * person picks their own name. Callers must still emit the claim — see + * `buildClaims`. + */ +export function sanitiseUsername( + ename: string, + extraReserved: string[] = [], +): string { + const candidate = foldDiacritics(stripLeadingAt(ename)) + // Anything outside Forgejo's character set, including any interior @, + // which would otherwise reach getUserName and be split on. + .replace(/[^0-9A-Za-z_.-]/g, "-") + // `[-._]{2,}` is forbidden outright by Forgejo's negative pattern. + .replace(/[-._]{2,}/g, "-") + // A username must start with an alphanumeric and may not end with a + // separator. + .replace(/^[-._]+/, "") + .replace(/[-._]+$/, "") + .slice(0, MAX_USERNAME_LENGTH) + // Truncation can land on a separator, which the negative pattern also + // forbids at the end. + .replace(/[-._]+$/, ""); + + if (!candidate) return ""; + + // Forgejo lower-cases before comparing (`models/db/name.go:113`), so a + // case-sensitive check here would let @Admin through. + const lowered = candidate.toLowerCase(); + if (RESERVED_USERNAMES.includes(lowered)) return ""; + if (RESERVED_SUFFIXES.some((suffix) => lowered.endsWith(suffix))) return ""; + if (extraReserved.includes(lowered)) return ""; + + return candidate; +} + +/** + * The local part of the synthetic address. + * + * Derived from the eName rather than from the username, so that two eNames which + * collapse to the same username still carry distinct addresses and the email is + * never itself the cause of a false conflict. + * + * Dots get stricter treatment than the username needs: Forgejo parses the + * address with Go's `mail.ParseAddress`, and a dot-atom may not begin, end, or + * double up on dots. + */ +function emailLocalPart(ename: string): string { + const local = foldDiacritics(stripLeadingAt(ename)) + .replace(/[^0-9A-Za-z._-]/g, "-") + .replace(/\.{2,}/g, ".") + .replace(/^\.+/, "") + .replace(/\.+$/, ""); + + if (local) return local; + + // Nothing usable survived — an eName of pure punctuation. Fall back to + // something deterministic and unique rather than emitting an address Forgejo + // will refuse. + return `w3ds-${createHash("sha256").update(ename).digest("hex").slice(0, 12)}`; +} + +export function buildClaims(ename: string, options: ClaimOptions): W3dsClaims { + const username = sanitiseUsername( + ename, + options.extraReservedUsernames ?? [], + ); + + return { + sub: ename, + // Both keys are always present. An absent `preferred_username` panics + // Forgejo's account-linking page: getUserName does an unchecked type + // assertion on it (`routers/web/auth/auth.go:405`). Do not "clean this up" + // by dropping empty claims. + nickname: username, + preferred_username: username, + email: `${emailLocalPart(ename)}@${options.emailDomain}`, + email_verified: false, + }; +} diff --git a/services/w3ds-oidc-bridge/src/clients.ts b/services/w3ds-oidc-bridge/src/clients.ts new file mode 100644 index 000000000..b85e3269a --- /dev/null +++ b/services/w3ds-oidc-bridge/src/clients.ts @@ -0,0 +1,56 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import type { BridgeConfig } from "./config.js"; + +export interface OidcClient { + clientId: string; + clientSecret: string; + /** The one registered callback. Compared exactly; never by prefix. */ + redirectUri: string; +} + +export interface ClientRegistry { + find(clientId: string | undefined): OidcClient | undefined; + /** + * Whether a presented secret matches, in time independent of how much of it + * is correct. + */ + authenticate( + client: OidcClient, + presentedSecret: string | undefined, + ): boolean; +} + +/** + * One client: GitW3. + * + * Isolated behind a lookup so a second client is a change to this file and + * nothing else. There is no dynamic registration and no plan for one — a bridge + * that anyone can register against is a bridge that anyone can obtain an + * identity assertion from. + */ +export function createClientRegistry(config: BridgeConfig): ClientRegistry { + const client: OidcClient = { + clientId: config.clientId, + clientSecret: config.clientSecret, + redirectUri: config.redirectUri, + }; + + return { + find(clientId) { + return clientId === client.clientId ? client : undefined; + }, + + authenticate(target, presentedSecret) { + if (!presentedSecret) return false; + // Hash both sides first: timingSafeEqual throws on a length mismatch, + // which would itself leak the length of the real secret. + const expected = createHash("sha256") + .update(target.clientSecret) + .digest(); + const presented = createHash("sha256") + .update(presentedSecret) + .digest(); + return timingSafeEqual(expected, presented); + }, + }; +} diff --git a/services/w3ds-oidc-bridge/src/config.test.ts b/services/w3ds-oidc-bridge/src/config.test.ts new file mode 100644 index 000000000..08d9ce2f5 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/config.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { ConfigError, loadConfig } from "./config.js"; + +const complete: NodeJS.ProcessEnv = { + W3DS_OIDC_PUBLIC_URL: "https://w3ds-oidc.example.org", + W3DS_OIDC_CLIENT_ID: "gitw3", + W3DS_OIDC_CLIENT_SECRET: "secret", + W3DS_OIDC_REDIRECT_URI: "https://git.example.org/user/oauth2/W3DS/callback", + W3DS_OIDC_SIGNING_KEY: + "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----", + W3DS_OIDC_KEY_ID: "w3ds-oidc-1", + PUBLIC_REGISTRY_URL: "https://registry.example.org", +}; + +const env = (overrides: NodeJS.ProcessEnv = {}) => ({ + ...complete, + ...overrides, +}); + +describe("loadConfig", () => { + it("accepts a complete environment", () => { + const config = loadConfig(env()); + expect(config.clientId).toBe("gitw3"); + expect(config.port).toBe(4200); + expect(config.emailDomain).toBe("w3ds.invalid"); + expect(config.minWalletVersion).toBe("0.4.0"); + }); + + describe("required keys", () => { + const keys = [ + "W3DS_OIDC_PUBLIC_URL", + "W3DS_OIDC_CLIENT_ID", + "W3DS_OIDC_CLIENT_SECRET", + "W3DS_OIDC_REDIRECT_URI", + "W3DS_OIDC_SIGNING_KEY", + "W3DS_OIDC_KEY_ID", + "PUBLIC_REGISTRY_URL", + ]; + + it.each(keys)("throws naming %s when it is missing", (key) => { + const incomplete = env(); + delete incomplete[key]; + // Naming the key matters: this error is the whole diagnostic a + // deployer gets. + expect(() => loadConfig(incomplete)).toThrowError(new RegExp(key)); + }); + + it.each(keys)("treats %s set to whitespace as missing", (key) => { + expect(() => loadConfig(env({ [key]: " " }))).toThrowError( + ConfigError, + ); + }); + }); + + describe("the issuer", () => { + it("strips a trailing slash", () => { + // goth compares `iss` byte for byte. A stray slash fails every login + // with no useful error, so it is normalised once, here. + expect( + loadConfig( + env({ W3DS_OIDC_PUBLIC_URL: "https://b.example.org/" }), + ).publicUrl, + ).toBe("https://b.example.org"); + }); + + it("strips repeated trailing slashes", () => { + expect( + loadConfig( + env({ W3DS_OIDC_PUBLIC_URL: "https://b.example.org///" }), + ).publicUrl, + ).toBe("https://b.example.org"); + }); + + it("keeps a path prefix intact", () => { + expect( + loadConfig( + env({ + W3DS_OIDC_PUBLIC_URL: "https://b.example.org/oidc/", + }), + ).publicUrl, + ).toBe("https://b.example.org/oidc"); + }); + + it("rejects a value that is not an absolute URL", () => { + expect(() => + loadConfig(env({ W3DS_OIDC_PUBLIC_URL: "b.example.org" })), + ).toThrowError(ConfigError); + }); + }); + + describe("the TLS guard", () => { + it("refuses http:// by default", () => { + expect(() => + loadConfig( + env({ W3DS_OIDC_PUBLIC_URL: "http://localhost:4200" }), + ), + ).toThrowError(/https/); + }); + + it("allows http:// only when the escape hatch is set explicitly", () => { + const config = loadConfig( + env({ + W3DS_OIDC_PUBLIC_URL: "http://localhost:4200", + W3DS_OIDC_ALLOW_INSECURE: "true", + }), + ); + expect(config.publicUrl).toBe("http://localhost:4200"); + }); + + it("does not treat a non-'true' value as consent", () => { + // "1", "yes" and friends must not disable the guard by accident. + for (const value of ["1", "yes", "TRUE", "on", ""]) { + expect(() => + loadConfig( + env({ + W3DS_OIDC_PUBLIC_URL: "http://localhost:4200", + W3DS_OIDC_ALLOW_INSECURE: value, + }), + ), + ).toThrowError(ConfigError); + } + }); + }); + + describe("the port", () => { + it("parses a value", () => { + expect(loadConfig(env({ W3DS_OIDC_PORT: "5000" })).port).toBe(5000); + }); + + it.each(["nope", "0", "70000", "4200.5"])("rejects %s", (value) => { + expect(() => + loadConfig(env({ W3DS_OIDC_PORT: value })), + ).toThrowError(ConfigError); + }); + }); + + describe("extra reserved usernames", () => { + it("defaults to empty", () => { + expect(loadConfig(env()).extraReservedUsernames).toEqual([]); + }); + + it("splits, trims, lower-cases and drops blanks", () => { + expect( + loadConfig( + env({ + W3DS_EXTRA_RESERVED_USERNAMES: + " Alice , ,bob ,, CHARLIE", + }), + ).extraReservedUsernames, + ).toEqual(["alice", "bob", "charlie"]); + }); + }); +}); diff --git a/services/w3ds-oidc-bridge/src/config.ts b/services/w3ds-oidc-bridge/src/config.ts new file mode 100644 index 000000000..6fd15fb5d --- /dev/null +++ b/services/w3ds-oidc-bridge/src/config.ts @@ -0,0 +1,120 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { config as loadEnv } from "dotenv"; + +export interface BridgeConfig { + /** + * The OIDC `issuer`. goth compares this byte for byte against the `iss` claim + * of every ID token, so it is normalised once here — a trailing slash that + * only appears on one side of that comparison fails every login with no + * useful error. + */ + publicUrl: string; + port: number; + clientId: string; + clientSecret: string; + /** GitW3's callback. Compared exactly; never by prefix. */ + redirectUri: string; + /** ES256 private key, PKCS#8 PEM. */ + signingKey: string; + keyId: string; + /** Domain for synthetic addresses. These never deliver — see the spec. */ + emailDomain: string; + /** Lower-cased, on top of the names Forgejo already reserves. */ + extraReservedUsernames: string[]; + minWalletVersion: string; + registryUrl: string; +} + +export class ConfigError extends Error {} + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]?.trim(); + if (!value) { + throw new ConfigError(`Missing required environment variable: ${name}`); + } + return value; +} + +function optional( + env: NodeJS.ProcessEnv, + name: string, + fallback: string, +): string { + const value = env[name]?.trim(); + return value ? value : fallback; +} + +/** + * Builds the configuration from an environment. Pure: it reads nothing but the + * map it is handed, so tests do not have to mutate `process.env`. + * + * Throws rather than degrading. Every value here is load-bearing, and a bridge + * that starts with a wrong issuer or a missing key fails later, at a point where + * the symptom no longer points at the cause. + */ +export function loadConfig(env: NodeJS.ProcessEnv = process.env): BridgeConfig { + const rawPublicUrl = required(env, "W3DS_OIDC_PUBLIC_URL"); + + let parsed: URL; + try { + parsed = new URL(rawPublicUrl); + } catch { + throw new ConfigError( + `W3DS_OIDC_PUBLIC_URL is not a valid absolute URL: ${rawPublicUrl}`, + ); + } + + const allowInsecure = + optional(env, "W3DS_OIDC_ALLOW_INSECURE", "false") === "true"; + if (parsed.protocol !== "https:" && !allowInsecure) { + // goth never verifies the ID token signature (see the spec's trust model), + // so TLS plus the client secret is the only thing separating a real token + // from a forged one. The unsafe case has to be chosen, never inherited. + throw new ConfigError( + `W3DS_OIDC_PUBLIC_URL must be https:// — got ${parsed.protocol}//. Set W3DS_OIDC_ALLOW_INSECURE=true only for local development.`, + ); + } + + const port = Number(optional(env, "W3DS_OIDC_PORT", "4200")); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new ConfigError( + `W3DS_OIDC_PORT must be an integer between 1 and 65535: ${env.W3DS_OIDC_PORT}`, + ); + } + + return { + publicUrl: rawPublicUrl.replace(/\/+$/, ""), + port, + clientId: required(env, "W3DS_OIDC_CLIENT_ID"), + clientSecret: required(env, "W3DS_OIDC_CLIENT_SECRET"), + redirectUri: required(env, "W3DS_OIDC_REDIRECT_URI"), + signingKey: required(env, "W3DS_OIDC_SIGNING_KEY"), + keyId: required(env, "W3DS_OIDC_KEY_ID"), + emailDomain: optional(env, "W3DS_EMAIL_DOMAIN", "w3ds.invalid"), + extraReservedUsernames: optional( + env, + "W3DS_EXTRA_RESERVED_USERNAMES", + "", + ) + .split(",") + .map((name) => name.trim().toLowerCase()) + .filter(Boolean), + minWalletVersion: optional(env, "W3DS_MIN_WALLET_VERSION", "0.4.0"), + registryUrl: required(env, "PUBLIC_REGISTRY_URL"), + }; +} + +let cached: BridgeConfig | undefined; + +/** Memoised singleton for the running service. Loads the repository root `.env`. */ +export function getConfig(): BridgeConfig { + if (!cached) { + const here = path.dirname(fileURLToPath(import.meta.url)); + // src/ during development, dist/ once built — both sit one level under + // the package, so the same relative path reaches the repository root. + loadEnv({ path: path.resolve(here, "../../../.env") }); + cached = loadConfig(); + } + return cached; +} diff --git a/services/w3ds-oidc-bridge/src/context.ts b/services/w3ds-oidc-bridge/src/context.ts new file mode 100644 index 000000000..1741bb124 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/context.ts @@ -0,0 +1,36 @@ +import type { ClientRegistry } from "./clients.js"; +import type { BridgeConfig } from "./config.js"; +import type { Keyring } from "./keys.js"; +import type { Store } from "./store.js"; +import type { SessionStreams } from "./w3ds/events.js"; + +/** + * Everything the handlers need, passed in rather than imported. + * + * The point is testability: a handler can be exercised against a fake store and + * a throwaway key pair without a server, an environment, or a running Forgejo. + */ +export interface LoginVerification { + valid: boolean; + error?: string; +} + +export interface BridgeContext { + config: BridgeConfig; + keyring: Keyring; + store: Store; + clients: ClientRegistry; + streams: SessionStreams; + /** + * Checks the wallet's signature against the Registry. + * + * Injected rather than imported so the flow's own protections — single-use + * codes, exact redirect matching, the version gate — can be tested without a + * Registry, an eVault, or a real key pair. + */ + verifyLogin(input: { + ename: string; + session: string; + signature: string; + }): Promise; +} diff --git a/services/w3ds-oidc-bridge/src/harness.test-utils.ts b/services/w3ds-oidc-bridge/src/harness.test-utils.ts new file mode 100644 index 000000000..c54c9696b --- /dev/null +++ b/services/w3ds-oidc-bridge/src/harness.test-utils.ts @@ -0,0 +1,168 @@ +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { exportPKCS8, generateKeyPair } from "jose"; +import { createApp } from "./app.js"; +import { createClientRegistry } from "./clients.js"; +import type { BridgeConfig } from "./config.js"; +import type { BridgeContext, LoginVerification } from "./context.js"; +import { createKeyring } from "./keys.js"; +import { computeS256Challenge } from "./oidc/token.js"; +import { createStore } from "./store.js"; +import { + type EventSink, + type SessionEvent, + createSessionStreams, +} from "./w3ds/events.js"; + +export const CLIENT_ID = "gitw3"; +export const CLIENT_SECRET = "a-strong-secret"; +export const REDIRECT_URI = "https://git.example.org/user/oauth2/W3DS/callback"; +export const CODE_VERIFIER = "a".repeat(64); + +/** A sink that records instead of writing, so streams can be asserted on. */ +export function recordingSink(): EventSink & { + events: SessionEvent[]; + ended: boolean; +} { + const events: SessionEvent[] = []; + const sink = { + events, + ended: false, + write(chunk: string) { + const match = chunk.match(/^data: (.*)$/m); + if (match?.[1]) events.push(JSON.parse(match[1]) as SessionEvent); + }, + end() { + sink.ended = true; + }, + on() {}, + }; + return sink; +} + +export interface Harness { + url: string; + ctx: BridgeContext; + /** Swap in per test; defaults to accepting every signature. */ + setVerifyResult(result: LoginVerification): void; + /** Attaches a recorder to a session so its events can be asserted. */ + watch(session: string): ReturnType; + close(): Promise; +} + +export async function startHarness( + configOverrides: Partial = {}, +): Promise { + const pair = await generateKeyPair("ES256", { extractable: true }); + const signingKey = await exportPKCS8(pair.privateKey); + + // Filled in once the server has a port: the issuer has to be the address the + // tests actually call, or goth's byte-for-byte comparison would be untestable. + const config: BridgeConfig = { + publicUrl: "", + port: 0, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUri: REDIRECT_URI, + signingKey, + keyId: "test-key", + emailDomain: "w3ds.invalid", + extraReservedUsernames: [], + minWalletVersion: "0.4.0", + registryUrl: "https://registry.example.org", + ...configOverrides, + }; + + let verifyResult: LoginVerification = { valid: true }; + + const streams = createSessionStreams({ heartbeatMs: 0 }); + + const ctx: BridgeContext = { + config, + keyring: await createKeyring({ + signingKey, + keyId: config.keyId, + issuer: "", + }), + store: createStore(), + clients: createClientRegistry(config), + streams, + async verifyLogin() { + return verifyResult; + }, + }; + + const app = createApp(ctx); + const server: Server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${port}`; + + // Now that the origin is known, rebuild everything that embeds it. + config.publicUrl = url; + ctx.keyring = await createKeyring({ + signingKey, + keyId: config.keyId, + issuer: url, + }); + + return { + url, + ctx, + setVerifyResult(result) { + verifyResult = result; + }, + watch(session) { + const sink = recordingSink(); + streams.subscribe(session, sink); + return sink; + }, + close() { + streams.closeAll(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +/** The `code_challenge` a client would send for {@link CODE_VERIFIER}. */ +export const CODE_CHALLENGE = computeS256Challenge(CODE_VERIFIER); + +export function authorizeUrl( + base: string, + overrides: Record = {}, +): string { + const params: Record = { + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: "openid profile email", + state: "the-state", + nonce: "the-nonce", + code_challenge: CODE_CHALLENGE, + code_challenge_method: "S256", + ...overrides, + }; + + const url = new URL("/authorize", base); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(key, value); + } + return url.toString(); +} + +/** Pulls the session id out of the `w3ds://auth` URI embedded in the QR page. */ +export function sessionFromQrPage(html: string): string { + const match = html.match( + /w3ds:\/\/auth\?redirect=[^&"]+&session=([0-9a-f-]+)/, + ); + if (!match?.[1]) throw new Error("no session id in the QR page"); + return match[1]; +} + +export function decodeJwtPayload(token: string): Record { + const part = token.split(".")[1]; + if (!part) throw new Error("not a JWT"); + return JSON.parse(Buffer.from(part, "base64url").toString()); +} diff --git a/services/w3ds-oidc-bridge/src/icon.ts b/services/w3ds-oidc-bridge/src/icon.ts new file mode 100644 index 000000000..27a9b9f67 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/icon.ts @@ -0,0 +1,107 @@ +import type { RequestHandler } from "express"; + +/** + * The mark on GitW3's "Sign in with W3DS" button. + * + * Forgejo renders whatever `IconURL` points at inside an `` + * (`services/auth/source/oauth2/providers.go:62`), so it is served from the + * bridge itself: the browser can always reach it, since it is about to be sent + * to the same origin for `/authorize`, and it can never fall out of step with + * the service that owns it. + * + * Inlined as a string rather than kept as an asset file because the build is + * `tsc` alone — a file in `assets/` would not reach `dist/`, and the icon would + * quietly 404 in the container. + * + * The shield-and-key is the same vocabulary as the Nextcloud W3DS login plugin, + * so anyone who has seen that button recognises this one. Restyled to the + * MetaState purple and to the house convention: 162 viewBox, 32 corner radius, + * 9 stroke, white on brand. + */ +export const W3DS_ICON_SVG = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ""; + +export function createIconHandler(): RequestHandler { + return (_req, res) => { + res.type("image/svg+xml") + // Immutable in practice: a change to the mark ships as a new release. + .set("Cache-Control", "public, max-age=86400") + .send(W3DS_ICON_SVG); + }; +} + +/** + * The same mark as a 180x180 PNG, served at `/apple-touch-icon.png`. + * + * This one is for the eID Wallet rather than for a browser. When the wallet + * shows its approval screen it resolves the app icon from the *hostname* of the + * redirect URI — which is the bridge — and not from the `platform` query + * parameter (`PlatformAppCard.svelte`, via `getPlatformKey`). Its cascade runs: + * an icon bundled in `@metastate-foundation/platform-icons`, then + * `/apple-touch-icon.png` on that host, then `/favicon.ico`, then a single + * letter on a coloured square. Serving the second rung is what keeps GitW3 from + * appearing as a bare letter, and unlike adding an icon to the shared package it + * needs no wallet release to take effect. + * + * Full bleed, no corner radius. The wallet clips it to its own `rounded-2xl` and + * iOS applies its own mask, so rounding it here would only show through as + * transparent notches inside a slightly different radius. + * + * Base64 for the same reason the SVG above is inlined: the build is `tsc` alone, + * so a file under `assets/` would never reach `dist/`. It is a rasterisation of + * that SVG minus the `rx` — regenerate the two together. + */ +const TOUCH_ICON_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAAJmUlEQVR4nOyda4hVVRTHl2VFJDU9rEBmondYSFkJYS8qKdIe" + + "RIbQgwiCgvrQlz73tQj8UBAR1IeQhIIemCiGaaWIpoLkW0xnkErNRjHCR9o6s/V2nLlrn30e99691v7/uAwzc865+9x7fnet" + + "/Tr7jn/7lZMEQDvOIgAEIAcQgRxABHIAEcgBRCAHEIEcQARyABHIAUQgBxCBHEAEcgARyAFEIAcQgRxABHIAEcgBRCAHEIEc" + + "QARyABHIAUQgBxCBHEAEcgARyAFEIAcQgRxABHIAEcgBRCAHEIEcQARy/M/ADQTyjKfkYSemz8x+6T8tx4pvacUCAuNSXtnH" + + "adEvBAwokqgcfi3ypKxIcnKEa5EnTUUSkqOaFnlYkexnMpYkUSGtr4XD1VsdKShiXI6mtMiTjiJm5eiEFnlSUMSgHJ3WIo9t" + + "RUxVSLupRVuMNWqMyNGIFkPbaHD7GcGgGmYUUS9HI1qMupzTZ0GRDMVydEKLPFBEpRzdvGwpK6JMjl5dqjQVUSNH/cvD9U2+" + + "PIPbqDKpKaJAjhi0aPZ8SIki8crRVOu0QS3ypKBIjHJErkUe24pEJ0dsSSQEPmeiuqcdoSJxja3UNKP7WjjO6ECrev7uwKj8" + + "iEiOOmb0Sos89RXho7Iu/J6+ijzqR2Vj0CJPTUX4EMjRhrJvZWxa5Gkk0fQclZEjZi3yOEX4Z3jG7OF8g7Eok0OLFqNgP0op" + + "Eglq5FCqRR51iqiR47O51CH6JtIll9OEPjr3vOzPo0fo8DAd2EvD+6gTZH5Ajsi56Xa6bgpddRNdcGH7Hf4+RLu30I4NtGUt" + + "pUlycpxzLt35EN12n+hEC95h8rTs8eAztH45rV5Cx49RUqQlx5TpdM/jxVqMgve/+7HMp8XzskCSDqmszzH+HJr1Ej3yXGkz" + + "WvCBT71KD8zOnioRkogcfZfREy/TFQNUnzseoEuvoCXzaXg/mcd+5GAzZr/ejBmOq2+mmS9mT2se43JwCuCYcfHl1CyTrqUZ" + + "c+znF+NyPPJ8kzEjD8ePe58k21iWg9smk++kzsH1D+4pMYzZCin3Z3CrNZCDf9Lvu+nwwez3CRfRlVfRRZcGHfjws7Rrs9n+" + + "D7NycE9XSKt16zpat4yGto/+f//1NPV+unFqweFcxLQZtHIhmcSsHNxn5Yc/7os+pU1r2m9lXfjBWYlrLf6KJxdkVQ6bdQ4e" + + "N/GHDTbj8/dEM1rwDrybP2twQVycSWzKUVhP5JgxNpW0hXfjnWsWpxSbcvBYqweuZxTGjDy8Mx9SuTi9GJSjb2JBTuEaaFn8" + + "h3BxXKg9DMpxibc/lFutgQklDx/CB1YuVCkG5ZjQ59vK/RnV8B/oL1QpBpuybrafhOvpqoD/QH+hSsH3rQARg5Hj6BHfVu4d" + + "r4b/QH+hSjEox+Fh31YeN6mG/0B/oUoxmFYO7PVt5RE1HjcpCx/iH4rzF6oUg3IM78vuKvDAI2pl8R/CxXXoJpfeYrNCunuL" + + "byuPtZaa58E7+4dn/cXpxaYchTcQ8FhrYHLh3XjnmsUpxaYcW9YWZBYehZ/9enH84B14N/+QPRdk9ZY4s/M51i/P7kTy4O5k" + + "uf7WWpN9XEFWMSvHmu+C7nnky88PHjfhDtCzRsLoiRNZl0bgNEEOG1yQVczKcewo/fhNdotbCKxCoA2jWDwvK8gqlrvPN6wo" + + "N2+jLPzktm+dNT62suhT+mOQOsGvG4tniGnHuBzHj9HXH9FfTXdf8hMumW9/RQb7o7LD+7NJwg3GD44Z/IS4kbqrDHnX+xqo" + + "scweX8h57zZT/+An+fLDWmb4X8hQTIuepbJ4C6eABR/T4NYqi7c4uNWa2uItaa3sw+2XzWtCl31qwVpwTxf3ZzTSavWvNDpY" + + "fn5r54hIDn5fPG8cb2pknUm+wCsXZg+3YNzkab6dN63GgnFJwpecH345FnxCKaOnQlp+hg6oSbqRo1f4LY+qtYLZ50BETeSI" + + "6usE6qDohUQUOQobIwP6/Sh8CVGt/B9X5OCM24XWbDhvfiBu4k7Sdd/Tz0upFP16ukdJV50jqgZL32XZasZPv0bnnV/iKP9L" + + "iKoHjGKTY8W3vq0RZutrbi6xLB1pqznFJYfGasfU+0NnkRWefGzfKxtdWvHn3Ti/xmbipKDddFU4KEI5Ysu7DaKrwkHqIgd/" + + "+CLMLPv2FO/Dp43IUZfCakdsmWXdsoIVoRz9qno4HDE2ZQuDRzzs3JjdABGC32l/M61XxChH4Ts1fRb1HO4EW/o5ffE+Hfmn" + + "eOcYTrgCMY6thGSW7rT63nmVGqEwFcbWiHVE2kNaWDtT9FksPNU4cwpFK0dxZpmpZhxOadigaOXgzDKkrdnSFr1hg2IeeCt8" + + "17jZUj+5nPi3yqZAQr62PtqwQTHLERg8aiYXT+W3ZscDn1ixGRGHDYp8yD7kvauZXDwr3ldYPz9PyInFHDYocjn4s9vp5LJj" + + "A/3wVZv/8z/r3NzGp1TYWRd52KD455DyZ4vHq/xvNH9GOQFVzgKrFtNvu2jK3TTpmuzPPTtpw0+0eytVJiShUPRhg1RMMOZP" + + "2JyiT+GcN2j+3Op+sAp1bMjDZvDJFBJ/2CAV0wRDkguN+NHzno9wM+IPG6RlDim/lSEj2r31I9AMfiEqzCBFE4wD43Cv/Ag0" + + "g5QkFIcaOQKTC/XCj1JmRDhvQ0LTrQmByYVG/OjayBwXFG6GloTiUHav7GdzQ/3gxmQX/AjpIHcoqmq0OHvGHW+RKn5ZlYXx" + + "kLsBBkYmnB46EDSNryz8zI++QLfcFbQzm8Faq0OfHFTGD94nu37jGp6+ywGDzQi8XUWpGaRUDirjB7m7iRryo1TAIM1mMOPe" + + "fuUkqcVzo3NbalYJw2sYLZqaaNgTdMsR3obMU0GRClowdXr0Y0C3HHR6lKvC/QqBilTzb2ibsi6NtqiXw1Htk00jirQd0a3s" + + "HCnsz5AwsmCcuxgV/MgOcSP+2089SR0tyJAZZCZyOGpe15rYSCV5TC01OTiSICqnmDpYChgtDK5DWjnFVC/RohlkLK2Mogsh" + + "xKoWDstyODqkiL0axljsy+FoUJEUtHCksvZ5K/jXUSQdLRwJLYzv/HDr4JZVJDUtHKmklbG4qUB+S9J0okW6crQYWx0ZOj1f" + + "NVktHJDjFPk5yYk70QJfxnMKCDEWfBkPEIEcQARyABHIAUQgBxCBHEAEcgARyAFEIAcQgRxABHIAEcgBRCAHEIEcQARyABHI" + + "AUQgBxCBHEAEcgARyAFEIAcQgRxABHIAEcgBRCAHEIEcQARyABHIAUQgBxD5DwAA//9BmmQiAAAABklEQVQDAFYua15gDZ5l" + + "AAAAAElFTkSuQmCC"; + +const W3DS_TOUCH_ICON_PNG = Buffer.from(TOUCH_ICON_BASE64, "base64"); + +export function createTouchIconHandler(): RequestHandler { + return (_req, res) => { + res.type("image/png") + .set("Cache-Control", "public, max-age=86400") + .send(W3DS_TOUCH_ICON_PNG); + }; +} diff --git a/services/w3ds-oidc-bridge/src/index.ts b/services/w3ds-oidc-bridge/src/index.ts new file mode 100644 index 000000000..361f9d31c --- /dev/null +++ b/services/w3ds-oidc-bridge/src/index.ts @@ -0,0 +1,73 @@ +import { verifyLoginSignature } from "@metastate-foundation/auth"; +import { createApp } from "./app.js"; +import { createClientRegistry } from "./clients.js"; +import { ConfigError, getConfig } from "./config.js"; +import type { BridgeContext } from "./context.js"; +import { KeyError, createKeyring } from "./keys.js"; +import { createStore } from "./store.js"; +import { createSessionStreams } from "./w3ds/events.js"; + +/** How often abandoned sessions and codes are reaped. Reads expire lazily anyway. */ +const SWEEP_INTERVAL_MS = 60_000; + +async function main(): Promise { + // Anything wrong with the environment or the key stops the process here, + // rather than surfacing as a failed login later, when the symptom no longer + // points at the cause. + const config = getConfig(); + + const ctx: BridgeContext = { + config, + keyring: await createKeyring({ + signingKey: config.signingKey, + keyId: config.keyId, + issuer: config.publicUrl, + }), + store: createStore(), + clients: createClientRegistry(config), + streams: createSessionStreams(), + verifyLogin: ({ ename, session, signature }) => + verifyLoginSignature({ + eName: ename, + signature, + session, + registryBaseUrl: config.registryUrl, + }), + }; + + const sweeper = setInterval(() => ctx.store.sweep(), SWEEP_INTERVAL_MS); + sweeper.unref(); + + const server = createApp(ctx).listen(config.port, () => { + console.log(`w3ds-oidc-bridge listening on :${config.port}`); + // The issuer is echoed because goth compares it byte for byte, and a + // trailing slash or a wrong host is the failure hardest to spot from the + // Forgejo side. + console.log(` issuer ${config.publicUrl}`); + console.log(` registry ${config.registryUrl}`); + if (!config.publicUrl.startsWith("https://")) { + console.warn( + " WARNING: serving over http. goth does not verify the ID token signature, so this is only safe on a host shared with GitW3.", + ); + } + }); + + const shutdown = () => { + clearInterval(sweeper); + ctx.streams.closeAll(); + server.close(() => process.exit(0)); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} + +main().catch((error: unknown) => { + // A configuration or key problem is the operator's to fix, so say what is + // wrong without a stack trace they cannot act on. + if (error instanceof ConfigError || error instanceof KeyError) { + console.error(`w3ds-oidc-bridge cannot start: ${error.message}`); + process.exit(1); + } + console.error(error); + process.exit(1); +}); diff --git a/services/w3ds-oidc-bridge/src/keys.test.ts b/services/w3ds-oidc-bridge/src/keys.test.ts new file mode 100644 index 000000000..8df2a410c --- /dev/null +++ b/services/w3ds-oidc-bridge/src/keys.test.ts @@ -0,0 +1,155 @@ +import { exportPKCS8, generateKeyPair } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; +import { KeyError, type Keyring, createKeyring } from "./keys.js"; + +const ISSUER = "https://w3ds-oidc.example.org"; + +let pem: string; +let otherPem: string; +let keyring: Keyring; + +beforeAll(async () => { + const pair = await generateKeyPair("ES256", { extractable: true }); + pem = await exportPKCS8(pair.privateKey); + + const other = await generateKeyPair("ES256", { extractable: true }); + otherPem = await exportPKCS8(other.privateKey); + + keyring = await createKeyring({ + signingKey: pem, + keyId: "w3ds-oidc-1", + issuer: ISSUER, + }); +}); + +describe("createKeyring", () => { + it("rejects something that is not a key", async () => { + await expect( + createKeyring({ + signingKey: "not a key", + keyId: "k", + issuer: ISSUER, + }), + ).rejects.toBeInstanceOf(KeyError); + }); + + it("accepts a PEM whose newlines were escaped to survive a .env", async () => { + const escaped = pem.replace(/\n/g, "\\n"); + const ring = await createKeyring({ + signingKey: escaped, + keyId: "k", + issuer: ISSUER, + }); + expect(ring.jwks.keys).toHaveLength(1); + }); + + it("tolerates surrounding whitespace", async () => { + const ring = await createKeyring({ + signingKey: `\n ${pem} \n`, + keyId: "k", + issuer: ISSUER, + }); + expect(ring.jwks.keys).toHaveLength(1); + }); +}); + +describe("the JWKS document", () => { + it("carries the configured key id and algorithm", () => { + const key = keyring.jwks.keys[0]; + expect(key?.kid).toBe("w3ds-oidc-1"); + expect(key?.alg).toBe("ES256"); + expect(key?.use).toBe("sig"); + }); + + it("publishes the public half only", () => { + // `d` is the private scalar. Publishing it would hand out the identity of + // every user of the bridge. + const key = keyring.jwks.keys[0]; + expect(key?.d).toBeUndefined(); + expect(key?.kty).toBe("EC"); + expect(key?.crv).toBe("P-256"); + expect(key?.x).toBeDefined(); + expect(key?.y).toBeDefined(); + }); +}); + +describe("sign and verify", () => { + it("round-trips a payload", async () => { + const token = await keyring.sign({ sub: "@alice", aud: "gitw3" }, 300); + const payload = await keyring.verify(token, { audience: "gitw3" }); + expect(payload.sub).toBe("@alice"); + }); + + it("sets iss byte-identically to the configured issuer", async () => { + // goth compares this against the discovery document with no normalisation + // whatsoever. + const token = await keyring.sign({ sub: "@alice" }, 300); + expect((await keyring.verify(token)).iss).toBe(ISSUER); + }); + + it("always sets a numeric exp", async () => { + // Forgejo reads it as claims["exp"].(float64) with no guard, so a token + // without one panics its handler rather than failing. + const payload = await keyring.verify( + await keyring.sign({ sub: "@alice" }, 300), + ); + expect(typeof payload.exp).toBe("number"); + expect(payload.exp).toBeGreaterThan(payload.iat ?? 0); + }); + + it("names the key in the header so rotation stays possible", async () => { + const token = await keyring.sign({ sub: "@alice" }, 300); + const header = JSON.parse( + Buffer.from(token.split(".")[0] ?? "", "base64url").toString(), + ); + expect(header.kid).toBe("w3ds-oidc-1"); + expect(header.alg).toBe("ES256"); + }); + + it("refuses a token signed by another key", async () => { + const impostor = await createKeyring({ + signingKey: otherPem, + keyId: "w3ds-oidc-1", + issuer: ISSUER, + }); + const token = await impostor.sign({ sub: "@mallory" }, 300); + await expect(keyring.verify(token)).rejects.toThrow(); + }); + + it("refuses a token whose payload was edited", async () => { + const token = await keyring.sign({ sub: "@alice" }, 300); + const [header, , signature] = token.split("."); + const forged = Buffer.from( + JSON.stringify({ sub: "@mallory", iss: ISSUER }), + ).toString("base64url"); + await expect( + keyring.verify(`${header}.${forged}.${signature}`), + ).rejects.toThrow(); + }); + + it("refuses an expired token", async () => { + const token = await keyring.sign({ sub: "@alice" }, -60); + await expect(keyring.verify(token)).rejects.toThrow(); + }); + + it("refuses a token issued for another audience", async () => { + const token = await keyring.sign( + { sub: "@alice", aud: "someone-else" }, + 300, + ); + await expect( + keyring.verify(token, { audience: "gitw3" }), + ).rejects.toThrow(); + }); + + it("refuses a token from another issuer", async () => { + const elsewhere = await createKeyring({ + signingKey: pem, + keyId: "w3ds-oidc-1", + issuer: "https://elsewhere.example.org", + }); + await expect( + keyring.verify(await elsewhere.sign({ sub: "@alice" }, 300)), + ).rejects.toThrow(); + }); +}); diff --git a/services/w3ds-oidc-bridge/src/keys.ts b/services/w3ds-oidc-bridge/src/keys.ts new file mode 100644 index 000000000..995d993b3 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/keys.ts @@ -0,0 +1,98 @@ +import { createPublicKey } from "node:crypto"; +import { + type JWK, + type JWTPayload, + SignJWT, + exportJWK, + importPKCS8, + jwtVerify, +} from "jose"; + +/** + * ES256 throughout, for consistency with W3DS: the wallet signs with ECDSA + * P-256, so the bridge has no reason to introduce a second curve or an RSA key. + */ +const ALG = "ES256"; + +export interface KeyringOptions { + /** PKCS#8 PEM. Newlines may be escaped, which is how they survive a .env. */ + signingKey: string; + /** Stable from day one, so a later rotation is not a breaking change. */ + keyId: string; + /** Set on every token, byte for byte as goth will compare it. */ + issuer: string; +} + +export interface Keyring { + /** + * Served at `/jwks`. Decorative for GitW3 — goth never verifies the ID token + * signature — but the bridge is a conformant provider and the bridge itself + * uses the key pair to verify its own access tokens at `/userinfo`. + */ + jwks: { keys: JWK[] }; + sign(payload: JWTPayload, expiresInSeconds: number): Promise; + verify(token: string, options?: { audience?: string }): Promise; +} + +export class KeyError extends Error {} + +/** + * A PEM carries newlines, which a .env file does not. Accept the escaped form so + * the key can be a single line, and tolerate the literal form so a file-mounted + * secret works too. + */ +function normalisePem(pem: string): string { + return pem.includes("\\n") ? pem.replace(/\\n/g, "\n") : pem; +} + +export async function createKeyring(options: KeyringOptions): Promise { + const pem = normalisePem(options.signingKey.trim()); + + let privateKey: Awaited>; + try { + privateKey = await importPKCS8(pem, ALG); + } catch (cause) { + throw new KeyError( + "W3DS_OIDC_SIGNING_KEY is not a PKCS#8 PEM holding an ES256 (P-256) private key", + { cause }, + ); + } + + // Derive the public half rather than asking for it separately: two + // configuration values that must agree are two values that can disagree. + const publicKey = createPublicKey(privateKey as never); + const publicJwk = await exportJWK(publicKey); + + return { + jwks: { + keys: [{ ...publicJwk, kid: options.keyId, alg: ALG, use: "sig" }], + }, + + async sign(payload, expiresInSeconds) { + const issuedAt = Math.floor(Date.now() / 1000); + return ( + new SignJWT(payload) + .setProtectedHeader({ + alg: ALG, + kid: options.keyId, + typ: "JWT", + }) + .setIssuer(options.issuer) + .setIssuedAt(issuedAt) + // `exp` is not optional: goth reads it with an unchecked type + // assertion and panics on a token without one. + .setExpirationTime(issuedAt + expiresInSeconds) + .sign(privateKey) + ); + }, + + async verify(token, verifyOptions) { + const { payload } = await jwtVerify(token, publicKey, { + algorithms: [ALG], + issuer: options.issuer, + audience: verifyOptions?.audience, + }); + return payload; + }, + }; +} diff --git a/services/w3ds-oidc-bridge/src/oidc/authorize.ts b/services/w3ds-oidc-bridge/src/oidc/authorize.ts new file mode 100644 index 000000000..86c3c93a3 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/oidc/authorize.ts @@ -0,0 +1,132 @@ +import { buildAuthOffer } from "@metastate-foundation/auth"; +import type { RequestHandler } from "express"; +import QRCode from "qrcode"; +import type { BridgeContext } from "../context.js"; +import { renderErrorPage, renderQrPage } from "./pages.js"; + +/** The name the wallet shows the person when it asks them to approve. */ +export const PLATFORM_NAME = "gitw3"; + +/** Where the wallet POSTs. Ours to choose — it travels inside `redirect`. */ +export const CALLBACK_PATH = "/w3ds/callback"; + +function param(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function createAuthorizeHandler(ctx: BridgeContext): RequestHandler { + return async (req, res) => { + const query = req.query as Record; + + // The client and the callback are validated before anything else, and a + // failure in either renders a page rather than redirecting. Redirecting an + // error to an unverified URI is what turns a provider into an open + // redirector. + const client = ctx.clients.find(param(query.client_id)); + if (!client) { + res.status(400) + .type("html") + .send( + renderErrorPage( + "Unknown client", + "This bridge does not recognise the client_id in the request. Check the authentication source configured in GitW3.", + ), + ); + return; + } + + const redirectUri = param(query.redirect_uri); + if (redirectUri !== client.redirectUri) { + res.status(400) + .type("html") + .send( + renderErrorPage( + "Unregistered redirect_uri", + "The redirect_uri in the request is not the one registered for this client. It is compared exactly, so a trailing slash or a different host is enough to fail.", + ), + ); + return; + } + + // From here the callback is trusted, so errors go back to it the way the + // spec expects. + const state = param(query.state); + const bounce = (error: string, description: string) => { + const url = new URL(client.redirectUri); + url.searchParams.set("error", error); + url.searchParams.set("error_description", description); + if (state) url.searchParams.set("state", state); + res.redirect(url.toString()); + }; + + // `prompt=none` asks for authentication with no interaction whatsoever + // (OIDC Core §3.1.2.1). The bridge keeps no session of its own — every + // login is a fresh QR code someone has to scan — so silent + // authentication can never succeed here, and showing the QR page would be + // exactly the interaction the parameter forbids. + // + // Forgejo sends this on its login page to try re-authenticating someone + // who has signed in before, and on `login_required` it retries + // interactively (routers/web/auth/oauth.go:1012). Answering correctly is + // what makes the login page work after a logout; ignoring it strands the + // person on a QR page they never asked for. + if (param(query.prompt)?.split(/\s+/).includes("none")) { + return bounce( + "login_required", + "This provider cannot authenticate without user interaction", + ); + } + + if (param(query.response_type) !== "code") { + return bounce( + "unsupported_response_type", + "Only the code response type is supported", + ); + } + + const codeChallenge = param(query.code_challenge); + if (!codeChallenge) { + // Forgejo sends one for openidConnect providers, so a request without + // it is either a misconfiguration or not Forgejo. + return bounce("invalid_request", "code_challenge is required"); + } + + if (param(query.code_challenge_method) !== "S256") { + return bounce( + "invalid_request", + "code_challenge_method must be S256", + ); + } + + const offer = buildAuthOffer({ + baseUrl: ctx.config.publicUrl, + platform: PLATFORM_NAME, + callbackPath: CALLBACK_PATH, + }); + + ctx.store.sessions.set(offer.session, { + clientId: client.clientId, + redirectUri: client.redirectUri, + state, + nonce: param(query.nonce), + codeChallenge, + }); + + const qrDataUri = await QRCode.toDataURL(offer.uri, { + errorCorrectionLevel: "M", + margin: 1, + width: 480, + }); + + res.type("html") + // A session id is a credential for the next five minutes. + .set("Cache-Control", "no-store") + .send( + renderQrPage({ + walletUri: offer.uri, + qrDataUri, + eventsUrl: `${ctx.config.publicUrl}/w3ds/events/${encodeURIComponent(offer.session)}`, + }), + ); + }; +} diff --git a/services/w3ds-oidc-bridge/src/oidc/discovery.ts b/services/w3ds-oidc-bridge/src/oidc/discovery.ts new file mode 100644 index 000000000..7f108f604 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/oidc/discovery.ts @@ -0,0 +1,60 @@ +import type { RequestHandler } from "express"; +import type { BridgeContext } from "../context.js"; + +/** + * The discovery document Forgejo fetches once, at the moment an administrator + * saves the authentication source. + * + * goth does no validation here at all — it unmarshals the JSON into a struct + * with five fields and moves on. Everything beyond those five is for + * conformance and for whoever reads it next. + */ +export function buildDiscoveryDocument(issuer: string) { + return { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + // goth skips the userinfo request entirely when this is absent. We serve + // it, which obliges us to keep its `sub` identical to the ID token's. + userinfo_endpoint: `${issuer}/userinfo`, + jwks_uri: `${issuer}/jwks`, + + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["ES256"], + scopes_supported: ["openid", "profile", "email"], + // golang.org/x/oauth2 tries Basic first and falls back to form fields, so + // both are advertised and both are accepted. + token_endpoint_auth_methods_supported: [ + "client_secret_basic", + "client_secret_post", + ], + // No `plain`. Forgejo sends S256, so there is no reason to accept less. + code_challenge_methods_supported: ["S256"], + claims_supported: [ + "sub", + "iss", + "aud", + "exp", + "iat", + "nonce", + "nickname", + "preferred_username", + "email", + "email_verified", + ], + }; +} + +export function createDiscoveryHandler(ctx: BridgeContext): RequestHandler { + return (_req, res) => { + res.json(buildDiscoveryDocument(ctx.config.publicUrl)); + }; +} + +export function createJwksHandler(ctx: BridgeContext): RequestHandler { + return (_req, res) => { + res.json(ctx.keyring.jwks); + }; +} diff --git a/services/w3ds-oidc-bridge/src/oidc/flow.test.ts b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts new file mode 100644 index 000000000..b0105575b --- /dev/null +++ b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts @@ -0,0 +1,759 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + CLIENT_ID, + CLIENT_SECRET, + CODE_CHALLENGE, + CODE_VERIFIER, + type Harness, + REDIRECT_URI, + authorizeUrl, + decodeJwtPayload, + sessionFromQrPage, + startHarness, +} from "../harness.test-utils.js"; + +let bridge: Harness; + +beforeAll(async () => { + bridge = await startHarness(); +}); + +afterAll(async () => { + await bridge.close(); +}); + +beforeEach(() => { + bridge.setVerifyResult({ valid: true }); +}); + +/** Drives /authorize and the wallet callback, and returns the minted code. */ +async function signIn( + ename = "@alice", +): Promise<{ code: string; state?: string }> { + const page = await fetch(authorizeUrl(bridge.url)); + const session = sessionFromQrPage(await page.text()); + const events = bridge.watch(session); + + const response = await fetch(`${bridge.url}/w3ds/callback`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ename, + session, + signature: "sig", + appVersion: "0.4.0", + }), + }); + expect(response.status).toBe(200); + + const event = events.events[0]; + if (event?.type !== "redirect") throw new Error("no redirect event"); + const url = new URL(event.url); + const code = url.searchParams.get("code"); + if (!code) throw new Error("no code in the redirect"); + return { code, state: url.searchParams.get("state") ?? undefined }; +} + +function tokenRequest( + fields: Record, + auth: "basic" | "body" = "basic", +) { + const body = new URLSearchParams({ + grant_type: "authorization_code", + ...fields, + }); + const headers: Record = { + "content-type": "application/x-www-form-urlencoded", + }; + + if (auth === "basic") { + const encoded = Buffer.from( + `${encodeURIComponent(CLIENT_ID)}:${encodeURIComponent(CLIENT_SECRET)}`, + ).toString("base64"); + headers.authorization = `Basic ${encoded}`; + } else { + body.set("client_id", CLIENT_ID); + body.set("client_secret", CLIENT_SECRET); + } + + return fetch(`${bridge.url}/token`, { method: "POST", headers, body }); +} + +describe("discovery", () => { + it("advertises the issuer exactly as configured", async () => { + const doc = await ( + await fetch(`${bridge.url}/.well-known/openid-configuration`) + ).json(); + // goth compares this against every ID token's `iss` byte for byte. + expect(doc.issuer).toBe(bridge.url); + }); + + it("gives every endpoint as an absolute URL on the issuer's origin", async () => { + const doc = await ( + await fetch(`${bridge.url}/.well-known/openid-configuration`) + ).json(); + for (const key of [ + "authorization_endpoint", + "token_endpoint", + "userinfo_endpoint", + "jwks_uri", + ]) { + expect(new URL(doc[key]).origin).toBe(new URL(bridge.url).origin); + } + }); + + it("offers S256 and nothing weaker", async () => { + const doc = await ( + await fetch(`${bridge.url}/.well-known/openid-configuration`) + ).json(); + expect(doc.code_challenge_methods_supported).toEqual(["S256"]); + }); + + it("accepts both client authentication styles Go's oauth2 may use", async () => { + const doc = await ( + await fetch(`${bridge.url}/.well-known/openid-configuration`) + ).json(); + expect(doc.token_endpoint_auth_methods_supported).toContain( + "client_secret_basic", + ); + expect(doc.token_endpoint_auth_methods_supported).toContain( + "client_secret_post", + ); + }); +}); + +describe("jwks", () => { + it("publishes the public key only", async () => { + const jwks = await (await fetch(`${bridge.url}/jwks`)).json(); + expect(jwks.keys[0].kid).toBe("test-key"); + expect(jwks.keys[0].d).toBeUndefined(); + }); +}); + +describe("cross-origin access", () => { + // A browser-based wallet posts JSON from its own origin, which triggers a + // preflight. Express answers OPTIONS with a bare 200 and no CORS headers, so + // without this the browser blocks the request and reports only "Failed to + // fetch" — the login hangs with nothing to debug. + it("answers the preflight on the wallet callback", async () => { + const response = await fetch(`${bridge.url}/w3ds/callback`, { + method: "OPTIONS", + headers: { + origin: "http://localhost:8080", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + }); + + expect(response.status).toBeLessThan(300); + expect( + response.headers.get("access-control-allow-origin"), + ).toBeTruthy(); + }); + + it("allows the SSE stream cross-origin too", async () => { + const response = await fetch(`${bridge.url}/w3ds/events/nothing`, { + headers: { origin: "http://localhost:8080" }, + }); + expect( + response.headers.get("access-control-allow-origin"), + ).toBeTruthy(); + await response.body?.cancel(); + }); + + it("never allows credentials", async () => { + // These endpoints carry no cookie. Allowing credentials with a wildcard + // origin is the combination that turns an open endpoint into a CSRF one. + const response = await fetch(`${bridge.url}/w3ds/callback`, { + method: "OPTIONS", + headers: { + origin: "http://localhost:8080", + "access-control-request-method": "POST", + }, + }); + expect( + response.headers.get("access-control-allow-credentials"), + ).toBeNull(); + }); + + it("leaves the OIDC half alone", async () => { + // /token and /userinfo are back-channel calls from Forgejo, and + // /authorize is a top-level navigation. None of them is ever a + // cross-origin fetch, so none of them needs to advertise anything. + const response = await fetch(`${bridge.url}/userinfo`, { + headers: { origin: "http://evil.example.org" }, + }); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); +}); + +describe("the login button icon", () => { + it("is served as an SVG the browser will render in an img", async () => { + // Forgejo drops IconURL straight into , so it has to be an + // image type, not a download. + const response = await fetch(`${bridge.url}/icon.svg`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("image/svg+xml"); + expect(await response.text()).toContain(" { + const response = await fetch(`${bridge.url}/apple-touch-icon.png`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("image/png"); + + const bytes = new Uint8Array(await response.arrayBuffer()); + // Real PNG bytes, not the SVG under a .png name: the wallet reads + // naturalWidth and treats anything under 8px as a failed load, so a file + // the decoder rejects would silently fall through to a letter tile. + expect(Array.from(bytes.subarray(0, 8))).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + // Width and height live in the IHDR chunk, at bytes 16-23. + const header = new DataView(bytes.buffer, bytes.byteOffset); + expect(header.getUint32(16)).toBe(180); + expect(header.getUint32(20)).toBe(180); + }); +}); + +describe("authorize", () => { + describe("before the callback is trusted, nothing redirects", () => { + it("renders a page for an unknown client", async () => { + const response = await fetch( + authorizeUrl(bridge.url, { client_id: "someone-else" }), + { + redirect: "manual", + }, + ); + expect(response.status).toBe(400); + expect(response.headers.get("location")).toBeNull(); + expect(await response.text()).toContain("Unknown client"); + }); + + it("renders a page for an unregistered redirect_uri", async () => { + // Bouncing an error to an unverified URI is what makes an open + // redirector. + const response = await fetch( + authorizeUrl(bridge.url, { + redirect_uri: "https://evil.example.org/steal", + }), + { redirect: "manual" }, + ); + expect(response.status).toBe(400); + expect(response.headers.get("location")).toBeNull(); + }); + + it("rejects a redirect_uri that differs by one character", async () => { + const response = await fetch( + authorizeUrl(bridge.url, { redirect_uri: `${REDIRECT_URI}/` }), + { redirect: "manual" }, + ); + expect(response.status).toBe(400); + expect(response.headers.get("location")).toBeNull(); + }); + }); + + describe("once it is trusted, errors go back to it", () => { + const errorFrom = async ( + overrides: Record, + ) => { + const response = await fetch(authorizeUrl(bridge.url, overrides), { + redirect: "manual", + }); + expect(response.status).toBe(302); + return new URL(response.headers.get("location") ?? ""); + }; + + it("refuses a request with no code_challenge", async () => { + const url = await errorFrom({ code_challenge: undefined }); + expect(url.searchParams.get("error")).toBe("invalid_request"); + expect(url.origin + url.pathname).toBe(REDIRECT_URI); + }); + + it("refuses the plain challenge method", async () => { + const url = await errorFrom({ code_challenge_method: "plain" }); + expect(url.searchParams.get("error")).toBe("invalid_request"); + }); + + it("refuses a response_type other than code", async () => { + const url = await errorFrom({ response_type: "token" }); + expect(url.searchParams.get("error")).toBe( + "unsupported_response_type", + ); + }); + + it("preserves state on the way back", async () => { + const url = await errorFrom({ code_challenge: undefined }); + expect(url.searchParams.get("state")).toBe("the-state"); + }); + + describe("silent authentication", () => { + // Forgejo sends prompt=none on its login page to re-authenticate + // someone who signed in before, and retries interactively when the + // provider says login_required. Rendering the QR page instead strands + // them there, and the login page never appears again after a logout. + it("refuses prompt=none, because a QR code is interaction", async () => { + const url = await errorFrom({ prompt: "none" }); + expect(url.searchParams.get("error")).toBe("login_required"); + expect(url.searchParams.get("state")).toBe("the-state"); + }); + + it("refuses it inside a space-separated list too", async () => { + const url = await errorFrom({ prompt: "none consent" }); + expect(url.searchParams.get("error")).toBe("login_required"); + }); + + it("serves the QR page for any other prompt value", async () => { + // `login` asks to re-authenticate, which is all this bridge ever + // does, so it needs no special handling. + const response = await fetch( + authorizeUrl(bridge.url, { prompt: "login" }), + ); + expect(response.status).toBe(200); + expect(await response.text()).toContain( + "w3ds://auth?redirect=", + ); + }); + }); + }); + + it("serves a self-contained QR page and opens a session", async () => { + const response = await fetch(authorizeUrl(bridge.url)); + expect(response.status).toBe(200); + const html = await response.text(); + + expect(html).toContain("data:image/png;base64,"); + expect(html).toContain("w3ds://auth?redirect="); + expect(html).toContain("platform=gitw3"); + // Nothing may be fetched from off-origin: the page must work on an + // isolated network. + expect(html).not.toMatch(/src="https?:\/\//); + + const session = sessionFromQrPage(html); + const stored = bridge.ctx.store.sessions.get(session); + expect(stored).toMatchObject({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + state: "the-state", + nonce: "the-nonce", + codeChallenge: CODE_CHALLENGE, + }); + }); + + it("never caches a page carrying a session id", async () => { + const response = await fetch(authorizeUrl(bridge.url)); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); +}); + +describe("the wallet callback", () => { + const callback = (body: Record) => + fetch(`${bridge.url}/w3ds/callback`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + async function openSession(): Promise { + return sessionFromQrPage( + await (await fetch(authorizeUrl(bridge.url))).text(), + ); + } + + it("accepts the field as `ename`", async () => { + const session = await openSession(); + const events = bridge.watch(session); + const response = await callback({ + ename: "@alice", + session, + signature: "sig", + appVersion: "0.4.0", + }); + expect(response.status).toBe(200); + expect(events.events[0]?.type).toBe("redirect"); + }); + + it("accepts the field as `w3id`, which is what the protocol docs say", async () => { + const session = await openSession(); + const events = bridge.watch(session); + const response = await callback({ + w3id: "@alice", + session, + signature: "sig", + appVersion: "0.4.0", + }); + expect(response.status).toBe(200); + expect(events.events[0]?.type).toBe("redirect"); + }); + + it("rejects a wallet older than the minimum, and says so on the page", async () => { + // Without the SSE half, the browser would spin forever in front of a QR + // code while the wallet showed nothing at all. + const session = await openSession(); + const events = bridge.watch(session); + + const response = await callback({ + ename: "@alice", + session, + signature: "sig", + appVersion: "0.3.9", + }); + + expect(response.status).toBe(400); + expect(events.events[0]).toMatchObject({ type: "error" }); + expect((events.events[0] as { message: string }).message).toContain( + "0.4.0", + ); + }); + + it("rejects a wallet that sends no version at all", async () => { + const session = await openSession(); + const response = await callback({ + ename: "@alice", + session, + signature: "sig", + }); + expect(response.status).toBe(400); + }); + + it("rejects an unknown session", async () => { + const events = bridge.watch("00000000-0000-4000-8000-000000000000"); + const response = await callback({ + ename: "@alice", + session: "00000000-0000-4000-8000-000000000000", + signature: "sig", + appVersion: "0.4.0", + }); + expect(response.status).toBe(400); + expect(events.events[0]?.type).toBe("error"); + }); + + it("rejects a session that has already been used", async () => { + // The session is consumed on first use, so a replayed signature has + // nothing to attach to. + const session = await openSession(); + bridge.watch(session); + await callback({ + ename: "@alice", + session, + signature: "sig", + appVersion: "0.4.0", + }); + + const replay = await callback({ + ename: "@mallory", + session, + signature: "sig", + appVersion: "0.4.0", + }); + expect(replay.status).toBe(400); + }); + + it("rejects a signature the Registry does not vouch for", async () => { + bridge.setVerifyResult({ valid: false, error: "no matching key" }); + const session = await openSession(); + const events = bridge.watch(session); + + const response = await callback({ + ename: "@mallory", + session, + signature: "forged", + appVersion: "0.4.0", + }); + + expect(response.status).toBe(401); + expect(events.events[0]?.type).toBe("error"); + }); + + it("requires a session id before it can report anything", async () => { + const response = await callback({ ename: "@alice", signature: "sig" }); + expect(response.status).toBe(400); + }); + + it("carries state back to Forgejo untouched", async () => { + const { state } = await signIn(); + expect(state).toBe("the-state"); + }); +}); + +describe("token", () => { + it("exchanges a code for an ID token", async () => { + const { code } = await signIn(); + const response = await tokenRequest({ + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + + const payload = await response.json(); + expect(payload.token_type).toBe("Bearer"); + expect(payload.id_token).toBeTruthy(); + expect(payload.access_token).toBeTruthy(); + }); + + describe("the ID token satisfies every constraint goth imposes", () => { + it("carries a numeric exp, an exact iss, and the client as aud", async () => { + const { code } = await signIn(); + const response = await tokenRequest({ + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }); + const claims = decodeJwtPayload((await response.json()).id_token); + + // goth reads exp with an unchecked type assertion: a token without one + // panics the Forgejo handler rather than failing. + expect(typeof claims.exp).toBe("number"); + expect(claims.iss).toBe(bridge.url); + expect(claims.aud).toBe(CLIENT_ID); + expect(claims.sub).toBe("@alice"); + }); + + it("carries the username in both claims and a non-empty email", async () => { + const { code } = await signIn(); + const claims = decodeJwtPayload( + ( + await ( + await tokenRequest({ + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }) + ).json() + ).id_token, + ); + + expect(claims.nickname).toBe("alice"); + expect(claims.preferred_username).toBe("alice"); + expect(claims.email).toBe("alice@w3ds.invalid"); + }); + + it("keeps the claim present and empty for a reserved name", async () => { + // An absent preferred_username panics Forgejo's account-linking page — + // the very page a reserved name is sent to. + const { code } = await signIn("@admin"); + const claims = decodeJwtPayload( + ( + await ( + await tokenRequest({ + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }) + ).json() + ).id_token, + ); + + expect(Object.hasOwn(claims, "nickname")).toBe(true); + expect(Object.hasOwn(claims, "preferred_username")).toBe(true); + expect(claims.nickname).toBe(""); + }); + + it("propagates the nonce", async () => { + const { code } = await signIn(); + const claims = decodeJwtPayload( + ( + await ( + await tokenRequest({ + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }) + ).json() + ).id_token, + ); + expect(claims.nonce).toBe("the-nonce"); + }); + }); + + describe("client authentication", () => { + it("accepts credentials in the Basic header", async () => { + const { code } = await signIn(); + const response = await tokenRequest( + { + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }, + "basic", + ); + expect(response.status).toBe(200); + }); + + it("accepts credentials in the form body", async () => { + // Go's oauth2 falls back to this style, so both must work. + const { code } = await signIn(); + const response = await tokenRequest( + { + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }, + "body", + ); + expect(response.status).toBe(200); + }); + + it("refuses a wrong secret", async () => { + const { code } = await signIn(); + const response = await fetch(`${bridge.url}/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${Buffer.from(`${CLIENT_ID}:wrong`).toString("base64")}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }), + }); + expect(response.status).toBe(401); + expect((await response.json()).error).toBe("invalid_client"); + }); + + it("refuses an unknown client with the same answer as a wrong secret", async () => { + const response = await fetch(`${bridge.url}/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${Buffer.from("nobody:wrong").toString("base64")}`, + }, + body: new URLSearchParams({ grant_type: "authorization_code" }), + }); + expect(response.status).toBe(401); + expect((await response.json()).error).toBe("invalid_client"); + }); + }); + + describe("the code is single use and tightly bound", () => { + it("refuses a code redeemed twice", async () => { + const { code } = await signIn(); + const fields = { + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }; + + expect((await tokenRequest(fields)).status).toBe(200); + + const replay = await tokenRequest(fields); + expect(replay.status).toBe(400); + expect((await replay.json()).error).toBe("invalid_grant"); + }); + + it("refuses a code that was never issued", async () => { + const response = await tokenRequest({ + code: "not-a-code", + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }); + expect((await response.json()).error).toBe("invalid_grant"); + }); + + it("refuses a code_verifier that is off by one character", async () => { + const { code } = await signIn(); + const response = await tokenRequest({ + code, + redirect_uri: REDIRECT_URI, + code_verifier: `${CODE_VERIFIER.slice(0, -1)}b`, + }); + expect(response.status).toBe(400); + expect((await response.json()).error).toBe("invalid_grant"); + }); + + it("refuses a request with no code_verifier at all", async () => { + const { code } = await signIn(); + const response = await tokenRequest({ + code, + redirect_uri: REDIRECT_URI, + }); + expect((await response.json()).error).toBe("invalid_request"); + }); + + it("refuses a redirect_uri that differs from the authorization request", async () => { + const { code } = await signIn(); + const response = await tokenRequest({ + code, + redirect_uri: `${REDIRECT_URI}/`, + code_verifier: CODE_VERIFIER, + }); + expect(response.status).toBe(400); + expect((await response.json()).error).toBe("invalid_grant"); + }); + }); + + it("refuses a grant type it does not implement", async () => { + const response = await fetch(`${bridge.url}/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "client_credentials" }), + }); + expect((await response.json()).error).toBe("unsupported_grant_type"); + }); +}); + +describe("userinfo", () => { + async function tokensFor(ename = "@alice") { + const { code } = await signIn(ename); + return ( + await tokenRequest({ + code, + redirect_uri: REDIRECT_URI, + code_verifier: CODE_VERIFIER, + }) + ).json(); + } + + it("returns a sub identical to the ID token's", async () => { + // goth rejects the whole response if these differ. + const tokens = await tokensFor(); + const response = await fetch(`${bridge.url}/userinfo`, { + headers: { authorization: `Bearer ${tokens.access_token}` }, + }); + + expect(response.status).toBe(200); + expect((await response.json()).sub).toBe( + decodeJwtPayload(tokens.id_token).sub, + ); + }); + + it("returns the same claims as the ID token", async () => { + const tokens = await tokensFor(); + const info = await ( + await fetch(`${bridge.url}/userinfo`, { + headers: { authorization: `Bearer ${tokens.access_token}` }, + }) + ).json(); + + expect(info.preferred_username).toBe("alice"); + expect(info.email).toBe("alice@w3ds.invalid"); + }); + + it("refuses a request with no bearer token", async () => { + expect((await fetch(`${bridge.url}/userinfo`)).status).toBe(401); + }); + + it("refuses a token that is not one of ours", async () => { + const response = await fetch(`${bridge.url}/userinfo`, { + headers: { authorization: "Bearer not.a.token" }, + }); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain( + "invalid_token", + ); + }); + + it("refuses an ID token presented as an access token", async () => { + // Different audience: the ID token is for GitW3, this endpoint is not. + const tokens = await tokensFor(); + const response = await fetch(`${bridge.url}/userinfo`, { + headers: { authorization: `Bearer ${tokens.id_token}` }, + }); + expect(response.status).toBe(401); + }); +}); diff --git a/services/w3ds-oidc-bridge/src/oidc/pages.ts b/services/w3ds-oidc-bridge/src/oidc/pages.ts new file mode 100644 index 000000000..fe758d062 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/oidc/pages.ts @@ -0,0 +1,106 @@ +/** + * The two pages the bridge renders. + * + * Server-rendered, self-contained, no external asset. The QR page carries one + * inline script — an EventSource — because the browser has no other way to learn + * that a wallet on a different device has answered. + */ + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const STYLE = ` +:root { color-scheme: light dark; --fg: #1d2636; --muted: #5b6478; --bg: #fff; --accent: #5b34d1; --line: #e5e7eb; } +@media (prefers-color-scheme: dark) { :root { --fg: #f3f0ff; --muted: #a3adc2; --bg: #12151f; --accent: #a186ff; --line: #2a2f3d; } } +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 2rem 1rem; + font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--fg); background: var(--bg); } +main { width: 100%; max-width: 26rem; text-align: center; } +h1 { font-size: 1.35rem; margin: 0 0 .35rem; } +p { margin: 0 0 1.25rem; color: var(--muted); } +img { width: 15rem; height: 15rem; max-width: 100%; border: 1px solid var(--line); border-radius: .75rem; background: #fff; padding: .75rem; } +.uri { margin-top: 1.25rem; font-size: .8rem; word-break: break-all; color: var(--muted); } +.uri a { color: var(--accent); } +#status { margin-top: 1.25rem; min-height: 1.5rem; font-size: .9rem; } +#status[data-state="error"] { color: #b3261e; font-weight: 600; } +@media (prefers-color-scheme: dark) { #status[data-state="error"] { color: #f2b8b5; } } +.error h1 { color: #b3261e; } +`.trim(); + +function layout(title: string, bodyHtml: string, bodyClass = ""): string { + return ` + + + + + +${escapeHtml(title)} + + + +
+${bodyHtml} +
+ +`; +} + +export interface QrPageOptions { + /** The `w3ds://auth` URI, exactly as the wallet expects it. */ + walletUri: string; + /** A `data:` URI, so the page loads nothing from anywhere. */ + qrDataUri: string; + eventsUrl: string; +} + +export function renderQrPage(options: QrPageOptions): string { + const body = ` +

Sign in with W3DS

+

Scan this with your eID Wallet.

+QR code containing a W3DS authentication request +

On this device? Open your wallet

+
Waiting for your wallet…
+`; + + return layout("Sign in with W3DS", body); +} + +/** + * Shown instead of a redirect when the request cannot be trusted enough to + * redirect anywhere — an unknown client, or a `redirect_uri` that is not the + * registered one. Bouncing an error to an unverified URI would make the bridge + * an open redirector. + */ +export function renderErrorPage(title: string, detail: string): string { + return layout( + title, + `

${escapeHtml(title)}

${escapeHtml(detail)}

`, + "error", + ); +} diff --git a/services/w3ds-oidc-bridge/src/oidc/token.ts b/services/w3ds-oidc-bridge/src/oidc/token.ts new file mode 100644 index 000000000..0ec7b8dfb --- /dev/null +++ b/services/w3ds-oidc-bridge/src/oidc/token.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import type { Request, RequestHandler } from "express"; +import { buildClaims } from "../claims.js"; +import type { BridgeContext } from "../context.js"; + +/** Long enough for Forgejo to fetch userinfo, short enough not to matter if leaked. */ +export const TOKEN_TTL_SECONDS = 300; + +/** OAuth2 error codes, so a failure is diagnosable rather than a bare 400. */ +type TokenErrorCode = + | "invalid_request" + | "invalid_client" + | "invalid_grant" + | "unsupported_grant_type"; + +interface Credentials { + clientId?: string; + clientSecret?: string; +} + +/** + * Reads client credentials from either place the spec allows. + * + * golang.org/x/oauth2 — which Forgejo uses — defaults to `AuthStyleAutoDetect`: + * it tries HTTP Basic first and retries with form fields if that fails. Handling + * only one of the two would work until it silently didn't. + */ +function readCredentials(req: Request): Credentials { + const header = req.header("authorization"); + + if (header?.toLowerCase().startsWith("basic ")) { + const decoded = Buffer.from(header.slice(6).trim(), "base64").toString( + "utf8", + ); + const separator = decoded.indexOf(":"); + if (separator !== -1) { + // RFC 6749 §2.3.1 form-urlencodes both halves before base64, and Go's + // oauth2 client does exactly that. + const decode = (value: string) => { + try { + return decodeURIComponent(value.replace(/\+/g, " ")); + } catch { + return value; + } + }; + return { + clientId: decode(decoded.slice(0, separator)), + clientSecret: decode(decoded.slice(separator + 1)), + }; + } + } + + const body = req.body as Record | undefined; + return { + clientId: + typeof body?.client_id === "string" ? body.client_id : undefined, + clientSecret: + typeof body?.client_secret === "string" + ? body.client_secret + : undefined, + }; +} + +/** base64url(sha256(verifier)) — the S256 transform, and the only one accepted. */ +export function computeS256Challenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +export function createTokenHandler(ctx: BridgeContext): RequestHandler { + return async (req, res) => { + const fail = ( + status: number, + error: TokenErrorCode, + description: string, + ) => { + // The token endpoint must not be cached by anything, ever. + res.status(status) + .set("Cache-Control", "no-store") + .json({ error, error_description: description }); + }; + + const body = (req.body ?? {}) as Record; + const field = (name: string): string | undefined => + typeof body[name] === "string" ? (body[name] as string) : undefined; + + if (field("grant_type") !== "authorization_code") { + return fail( + 400, + "unsupported_grant_type", + "Only the authorization_code grant is supported", + ); + } + + const credentials = readCredentials(req); + const client = ctx.clients.find(credentials.clientId); + if ( + !client || + !ctx.clients.authenticate(client, credentials.clientSecret) + ) { + // Same answer for an unknown client and a wrong secret: distinguishing + // them tells an attacker which half to keep guessing. + return fail(401, "invalid_client", "Client authentication failed"); + } + + const code = field("code"); + if (!code) return fail(400, "invalid_request", "Missing code"); + + // Consumed as it is read, so a replay finds nothing. + const grant = ctx.store.codes.take(code); + if (!grant) { + return fail( + 400, + "invalid_grant", + "Unknown, expired or already redeemed code", + ); + } + + if (grant.clientId !== client.clientId) { + return fail( + 400, + "invalid_grant", + "Code was not issued to this client", + ); + } + + if (field("redirect_uri") !== grant.redirectUri) { + return fail( + 400, + "invalid_grant", + "redirect_uri does not match the authorization request", + ); + } + + const verifier = field("code_verifier"); + if (!verifier) + return fail(400, "invalid_request", "Missing code_verifier"); + if (computeS256Challenge(verifier) !== grant.codeChallenge) { + return fail( + 400, + "invalid_grant", + "code_verifier does not match the code_challenge", + ); + } + + const claims = buildClaims(grant.ename, { + emailDomain: ctx.config.emailDomain, + extraReservedUsernames: ctx.config.extraReservedUsernames, + }); + + const idToken = await ctx.keyring.sign( + { + ...claims, + aud: client.clientId, + // goth does not check the nonce, but a future client would, and + // dropping it here would be silent. + ...(grant.nonce ? { nonce: grant.nonce } : {}), + }, + TOKEN_TTL_SECONDS, + ); + + // A JWT rather than a random string, so /userinfo needs no third map. Its + // audience is the bridge itself, which is the resource being accessed. + const accessToken = await ctx.keyring.sign( + { sub: claims.sub, aud: ctx.config.publicUrl }, + TOKEN_TTL_SECONDS, + ); + + res.set("Cache-Control", "no-store").json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: TOKEN_TTL_SECONDS, + id_token: idToken, + scope: "openid profile email", + }); + }; +} diff --git a/services/w3ds-oidc-bridge/src/oidc/userinfo.ts b/services/w3ds-oidc-bridge/src/oidc/userinfo.ts new file mode 100644 index 000000000..a9271e042 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/oidc/userinfo.ts @@ -0,0 +1,54 @@ +import type { RequestHandler } from "express"; +import { buildClaims } from "../claims.js"; +import type { BridgeContext } from "../context.js"; + +/** + * Served for conformance. goth would skip it entirely if the discovery document + * omitted `userinfo_endpoint` — but since we advertise it, goth calls it and + * then insists the `sub` here matches the ID token's exactly. Both come from the + * same eName through the same function, so they cannot drift. + */ +export function createUserinfoHandler(ctx: BridgeContext): RequestHandler { + return async (req, res) => { + const header = req.header("authorization"); + if (!header?.toLowerCase().startsWith("bearer ")) { + res.status(401) + .set("WWW-Authenticate", 'Bearer error="invalid_request"') + .json({ + error: "invalid_request", + error_description: "Missing bearer token", + }); + return; + } + + const token = header.slice(7).trim(); + + let sub: string; + try { + // The access token is a JWT we signed, so this needs no stored state: + // signature, issuer, audience and expiry are all checked here. + const payload = await ctx.keyring.verify(token, { + audience: ctx.config.publicUrl, + }); + if (typeof payload.sub !== "string" || payload.sub.length === 0) { + throw new Error("access token carries no subject"); + } + sub = payload.sub; + } catch { + res.status(401) + .set("WWW-Authenticate", 'Bearer error="invalid_token"') + .json({ + error: "invalid_token", + error_description: "Bearer token is not valid", + }); + return; + } + + const claims = buildClaims(sub, { + emailDomain: ctx.config.emailDomain, + extraReservedUsernames: ctx.config.extraReservedUsernames, + }); + + res.set("Cache-Control", "no-store").json(claims); + }; +} diff --git a/services/w3ds-oidc-bridge/src/store.test.ts b/services/w3ds-oidc-bridge/src/store.test.ts new file mode 100644 index 000000000..15c119f15 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/store.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { CODE_TTL_MS, SESSION_TTL_MS, TtlMap, createStore } from "./store.js"; + +let clock = 0; +const now = () => clock; + +beforeEach(() => { + clock = 1_000_000; +}); + +describe("TtlMap", () => { + it("returns a value that is still live", () => { + const map = new TtlMap(1000, now); + map.set("k", "v"); + clock += 999; + expect(map.get("k")).toBe("v"); + }); + + it("drops a value the moment its TTL is reached", () => { + const map = new TtlMap(1000, now); + map.set("k", "v"); + clock += 1000; + expect(map.get("k")).toBeUndefined(); + }); + + it("forgets an expired entry rather than keeping it around", () => { + const map = new TtlMap(1000, now); + map.set("k", "v"); + clock += 1000; + map.get("k"); + expect(map.size).toBe(0); + }); + + describe("take", () => { + it("returns the value once and nothing after", () => { + // This is what makes an authorisation code single-use. + const map = new TtlMap(1000, now); + map.set("code", "payload"); + expect(map.take("code")).toBe("payload"); + expect(map.take("code")).toBeUndefined(); + }); + + it("returns nothing for an expired entry, and does not leave it behind", () => { + const map = new TtlMap(1000, now); + map.set("code", "payload"); + clock += 1000; + expect(map.take("code")).toBeUndefined(); + expect(map.size).toBe(0); + }); + + it("returns nothing for a key that was never set", () => { + expect(new TtlMap(1000, now).take("nope")).toBeUndefined(); + }); + }); + + describe("update", () => { + it("replaces a live value and keeps the original expiry", () => { + // The wallet callback attaches an eName to a session already ticking; + // answering must not buy the login more time. + const map = new TtlMap(1000, now); + map.set("s", "pending"); + clock += 900; + expect(map.update("s", "authenticated")).toBe(true); + expect(map.get("s")).toBe("authenticated"); + clock += 100; + expect(map.get("s")).toBeUndefined(); + }); + + it("refuses to revive an expired entry", () => { + const map = new TtlMap(1000, now); + map.set("s", "pending"); + clock += 1000; + expect(map.update("s", "authenticated")).toBe(false); + expect(map.get("s")).toBeUndefined(); + }); + + it("refuses a key that was never set", () => { + expect(new TtlMap(1000, now).update("nope", "v")).toBe( + false, + ); + }); + }); + + describe("sweep", () => { + it("evicts only what has expired", () => { + const map = new TtlMap(1000, now); + map.set("old", "a"); + clock += 500; + map.set("new", "b"); + clock += 500; + + expect(map.sweep()).toBe(1); + expect(map.get("old")).toBeUndefined(); + expect(map.get("new")).toBe("b"); + }); + + it("does nothing when everything is live", () => { + const map = new TtlMap(1000, now); + map.set("a", "1"); + map.set("b", "2"); + expect(map.sweep()).toBe(0); + expect(map.size).toBe(2); + }); + }); +}); + +describe("createStore", () => { + it("gives sessions five minutes and codes sixty seconds", () => { + // The asymmetry is the point: a session waits for a human to scan a QR + // code, a code only has to survive one redirect. + expect(SESSION_TTL_MS).toBe(5 * 60 * 1000); + expect(CODE_TTL_MS).toBe(60 * 1000); + + const store = createStore(now); + store.sessions.set("s", { + clientId: "gitw3", + redirectUri: "u", + codeChallenge: "c", + }); + store.codes.set("c", { + clientId: "gitw3", + redirectUri: "u", + codeChallenge: "c", + ename: "@alice", + }); + + clock += CODE_TTL_MS; + expect(store.codes.get("c")).toBeUndefined(); + expect(store.sessions.get("s")).toBeDefined(); + + clock += SESSION_TTL_MS; + expect(store.sessions.get("s")).toBeUndefined(); + }); + + it("sweeps both maps", () => { + const store = createStore(now); + store.sessions.set("s", { + clientId: "gitw3", + redirectUri: "u", + codeChallenge: "c", + }); + store.codes.set("c", { + clientId: "gitw3", + redirectUri: "u", + codeChallenge: "c", + ename: "@alice", + }); + + clock += SESSION_TTL_MS; + store.sweep(); + + expect(store.sessions.size).toBe(0); + expect(store.codes.size).toBe(0); + }); +}); diff --git a/services/w3ds-oidc-bridge/src/store.ts b/services/w3ds-oidc-bridge/src/store.ts new file mode 100644 index 000000000..97ba71f33 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/store.ts @@ -0,0 +1,133 @@ +/** + * The bridge's entire state: two short-lived maps. + * + * Nothing is persisted. A restart drops in-flight logins, which is the right + * trade for a five-minute window — and the access token is a JWT, so it needs no + * third map. + */ + +/** A login has five minutes between the QR appearing and the wallet answering. */ +export const SESSION_TTL_MS = 5 * 60 * 1000; + +/** An authorisation code only has to survive one redirect. */ +export const CODE_TTL_MS = 60 * 1000; + +/** What `/authorize` captured, plus the eName once the wallet has proved it. */ +export interface AuthSession { + clientId: string; + redirectUri: string; + state?: string; + nonce?: string; + codeChallenge: string; + ename?: string; +} + +/** Bound to the same tuple, so a code cannot be replayed against another client. */ +export interface AuthCode { + clientId: string; + redirectUri: string; + codeChallenge: string; + nonce?: string; + ename: string; +} + +interface Entry { + value: T; + expiresAt: number; +} + +/** + * A map whose entries disappear on their own. + * + * The clock is injectable so tests can advance time without waiting or reaching + * for fake timers. + */ +export class TtlMap { + private readonly entries = new Map>(); + + constructor( + private readonly ttlMs: number, + private readonly now: () => number = Date.now, + ) {} + + set(key: string, value: T): void { + this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs }); + } + + get(key: string): T | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= this.now()) { + this.entries.delete(key); + return undefined; + } + return entry.value; + } + + /** + * Reads and removes in one step, so a value can only ever be used once. + * + * This is what makes an authorisation code single-use. Node runs this to + * completion before any other request is handled, so two concurrent + * exchanges cannot both win. + */ + take(key: string): T | undefined { + const value = this.get(key); + if (value !== undefined) this.entries.delete(key); + return value; + } + + /** Replaces a live entry, keeping its original expiry. */ + update(key: string, value: T): boolean { + const entry = this.entries.get(key); + if (!entry || entry.expiresAt <= this.now()) { + this.entries.delete(key); + return false; + } + entry.value = value; + return true; + } + + delete(key: string): void { + this.entries.delete(key); + } + + /** Evicts everything expired. Returns how many, for logging. */ + sweep(): number { + const now = this.now(); + let evicted = 0; + for (const [key, entry] of this.entries) { + if (entry.expiresAt <= now) { + this.entries.delete(key); + evicted += 1; + } + } + return evicted; + } + + get size(): number { + return this.entries.size; + } +} + +export interface Store { + sessions: TtlMap; + codes: TtlMap; + sweep(): void; +} + +export function createStore(now: () => number = Date.now): Store { + const sessions = new TtlMap(SESSION_TTL_MS, now); + const codes = new TtlMap(CODE_TTL_MS, now); + + return { + sessions, + codes, + // Reading expires lazily, so this only matters for entries nobody comes + // back for — an abandoned QR page, a wallet that never answers. + sweep() { + sessions.sweep(); + codes.sweep(); + }, + }; +} diff --git a/services/w3ds-oidc-bridge/src/w3ds/callback.ts b/services/w3ds-oidc-bridge/src/w3ds/callback.ts new file mode 100644 index 000000000..8e448b362 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/w3ds/callback.ts @@ -0,0 +1,137 @@ +import { randomBytes } from "node:crypto"; +import type { RequestHandler } from "express"; +import type { BridgeContext } from "../context.js"; +import { isWalletVersionAtLeast } from "./wallet-version.js"; + +/** 256 bits of CSPRNG, url-safe. The code is a bearer credential for 60 seconds. */ +function mintCode(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Where the wallet POSTs after the person approves. + * + * Every failure past this point is also pushed into the SSE stream. The browser + * is sitting in front of a QR code on possibly another device; the wallet + * reported the problem to us, and this is the only channel back. + */ +export function createCallbackHandler(ctx: BridgeContext): RequestHandler { + return async (req, res) => { + const body = (req.body ?? {}) as Record; + const field = (name: string): string | undefined => + typeof body[name] === "string" && body[name] + ? (body[name] as string) + : undefined; + + // The protocol documentation says `w3id`; every platform controller reads + // `ename`. Accept both, the way awareness-service does. + const ename = field("ename") ?? field("w3id"); + const session = field("session"); + const signature = field("signature"); + + // Without a session id there is nobody to tell, so this one is HTTP only. + if (!session) { + res.status(400).json({ error: "session is required" }); + return; + } + + const reject = (status: number, error: string, message: string) => { + ctx.streams.publish(session, { type: "error", message }); + res.status(status).json({ error, message }); + }; + + if (!ename) { + return reject( + 400, + "ename is required", + "The wallet did not send an identity.", + ); + } + if (!signature) { + return reject( + 400, + "signature is required", + "The wallet did not send a signature.", + ); + } + + if ( + !isWalletVersionAtLeast( + field("appVersion"), + ctx.config.minWalletVersion, + ) + ) { + return reject( + 400, + "App version too old", + `Your eID Wallet is out of date. Update to ${ctx.config.minWalletVersion} or later and try again.`, + ); + } + + // Consumed here, so a replayed signature finds no session to attach to. + const pending = ctx.store.sessions.take(session); + if (!pending) { + return reject( + 400, + "unknown or expired session", + "This sign-in request has expired. Go back to GitW3 and start again.", + ); + } + + // The trust anchor: the wallet signed the session id, and the Registry + // holds the key that proves who signed it. + const verification = await ctx.verifyLogin({ + ename, + session, + signature, + }); + if (!verification.valid) { + return reject( + 401, + "Invalid signature", + "Your wallet's signature could not be verified. Please try again.", + ); + } + + const code = mintCode(); + ctx.store.codes.set(code, { + clientId: pending.clientId, + redirectUri: pending.redirectUri, + codeChallenge: pending.codeChallenge, + nonce: pending.nonce, + ename, + }); + + const redirect = new URL(pending.redirectUri); + redirect.searchParams.set("code", code); + if (pending.state) redirect.searchParams.set("state", pending.state); + + ctx.streams.publish(session, { + type: "redirect", + url: redirect.toString(), + }); + res.status(200).json({ ok: true }); + }; +} + +export function createEventsHandler(ctx: BridgeContext): RequestHandler { + return (req, res) => { + const session = req.params.session; + if (!session) { + res.status(400).end(); + return; + } + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + Connection: "keep-alive", + // nginx buffers text/event-stream by default, which turns a live + // stream into one that delivers everything at the end. + "X-Accel-Buffering": "no", + }); + res.flushHeaders?.(); + + ctx.streams.subscribe(session, res); + }; +} diff --git a/services/w3ds-oidc-bridge/src/w3ds/events.test.ts b/services/w3ds-oidc-bridge/src/w3ds/events.test.ts new file mode 100644 index 000000000..29b6873ea --- /dev/null +++ b/services/w3ds-oidc-bridge/src/w3ds/events.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import { recordingSink } from "../harness.test-utils.js"; +import { createSessionStreams } from "./events.js"; + +const streams = () => createSessionStreams({ heartbeatMs: 0 }); + +describe("createSessionStreams", () => { + it("delivers an event to a waiting subscriber and closes the stream", () => { + const s = streams(); + const sink = recordingSink(); + s.subscribe("session-1", sink); + + s.publish("session-1", { + type: "redirect", + url: "https://git.example.org/cb?code=x", + }); + + expect(sink.events).toEqual([ + { type: "redirect", url: "https://git.example.org/cb?code=x" }, + ]); + expect(sink.ended).toBe(true); + }); + + it("holds an event for a subscriber that has not arrived yet", () => { + // A fast scan can beat the browser's EventSource. Without this the login + // would hang despite having succeeded. + const s = streams(); + s.publish("session-1", { type: "error", message: "too old" }); + + const sink = recordingSink(); + s.subscribe("session-1", sink); + + expect(sink.events).toEqual([{ type: "error", message: "too old" }]); + expect(sink.ended).toBe(true); + }); + + it("delivers a held event only once", () => { + const s = streams(); + s.publish("session-1", { type: "error", message: "too old" }); + + s.subscribe("session-1", recordingSink()); + const second = recordingSink(); + s.subscribe("session-1", second); + + expect(second.events).toEqual([]); + }); + + it("keeps sessions apart", () => { + const s = streams(); + const one = recordingSink(); + const two = recordingSink(); + s.subscribe("session-1", one); + s.subscribe("session-2", two); + + s.publish("session-1", { + type: "redirect", + url: "https://example.org/", + }); + + expect(one.events).toHaveLength(1); + expect(two.events).toHaveLength(0); + }); + + it("reaches every subscriber on the same session", () => { + const s = streams(); + const first = recordingSink(); + const second = recordingSink(); + s.subscribe("session-1", first); + s.subscribe("session-1", second); + + s.publish("session-1", { + type: "redirect", + url: "https://example.org/", + }); + + expect(first.events).toHaveLength(1); + expect(second.events).toHaveLength(1); + expect(s.subscriberCount("session-1")).toBe(0); + }); + + it("counts what is attached", () => { + const s = streams(); + expect(s.subscriberCount("session-1")).toBe(0); + s.subscribe("session-1", recordingSink()); + expect(s.subscriberCount("session-1")).toBe(1); + }); + + it("greets a new subscriber so the connection is established immediately", () => { + const s = streams(); + const written: string[] = []; + s.subscribe("session-1", { + write: (chunk) => written.push(chunk), + end: () => {}, + on: () => {}, + }); + expect(written[0]).toBe(": connected\n\n"); + }); + + it("writes a well-formed SSE frame", () => { + const s = streams(); + const written: string[] = []; + s.subscribe("session-1", { + write: (chunk) => written.push(chunk), + end: () => {}, + on: () => {}, + }); + + s.publish("session-1", { + type: "redirect", + url: "https://example.org/", + }); + + expect(written[1]).toBe( + 'event: redirect\ndata: {"type":"redirect","url":"https://example.org/"}\n\n', + ); + }); + + it("drops a subscriber whose connection closed", () => { + const s = streams(); + let onClose = () => {}; + s.subscribe("session-1", { + write: () => {}, + end: () => {}, + on: (_event, listener) => { + onClose = listener; + }, + }); + + expect(s.subscriberCount("session-1")).toBe(1); + onClose(); + expect(s.subscriberCount("session-1")).toBe(0); + }); + + describe("the heartbeat", () => { + it("pings an idle connection so a proxy does not drop it", () => { + vi.useFakeTimers(); + try { + const s = createSessionStreams({ heartbeatMs: 1000 }); + const written: string[] = []; + s.subscribe("session-1", { + write: (chunk) => written.push(chunk), + end: () => {}, + on: () => {}, + }); + + vi.advanceTimersByTime(2500); + expect( + written.filter((chunk) => chunk === ": ping\n\n"), + ).toHaveLength(2); + + s.closeAll(); + } finally { + vi.useRealTimers(); + } + }); + + it("stops once the stream has served its event", () => { + vi.useFakeTimers(); + try { + const s = createSessionStreams({ heartbeatMs: 1000 }); + const written: string[] = []; + s.subscribe("session-1", { + write: (chunk) => written.push(chunk), + end: () => {}, + on: () => {}, + }); + + s.publish("session-1", { + type: "redirect", + url: "https://example.org/", + }); + const after = written.length; + vi.advanceTimersByTime(5000); + + expect(written).toHaveLength(after); + } finally { + vi.useRealTimers(); + } + }); + }); + + it("closes everything on shutdown", () => { + const s = streams(); + const sink = recordingSink(); + s.subscribe("session-1", sink); + + s.closeAll(); + + expect(sink.ended).toBe(true); + expect(s.subscriberCount("session-1")).toBe(0); + }); +}); diff --git a/services/w3ds-oidc-bridge/src/w3ds/events.ts b/services/w3ds-oidc-bridge/src/w3ds/events.ts new file mode 100644 index 000000000..6f103cabc --- /dev/null +++ b/services/w3ds-oidc-bridge/src/w3ds/events.ts @@ -0,0 +1,125 @@ +import { SESSION_TTL_MS, TtlMap } from "../store.js"; + +/** + * What the QR page is told. + * + * `redirect` carries the browser back to Forgejo with an authorisation code. + * `error` is the only way the page ever learns something went wrong — the person + * is looking at a QR code, and their wallet reported the failure to us, not to + * them. + */ +export type SessionEvent = + | { type: "redirect"; url: string } + | { type: "error"; message: string }; + +/** + * The subset of an Express `Response` an SSE stream needs. Narrow on purpose, so + * tests can drive it with a few lines instead of a server. + */ +export interface EventSink { + write(chunk: string): void; + end(): void; + on(event: "close", listener: () => void): void; +} + +export interface SessionStreams { + /** Attaches a sink and immediately replays a pending event, if one is waiting. */ + subscribe(session: string, sink: EventSink): void; + publish(session: string, event: SessionEvent): void; + /** Number of attached sinks, for tests and for logging. */ + subscriberCount(session: string): number; + /** Stops every heartbeat. Called when the process shuts down. */ + closeAll(): void; +} + +interface Attached { + sink: EventSink; + heartbeat?: ReturnType; +} + +export interface SessionStreamOptions { + /** + * Matches the 30-second heartbeat the W3DS platforms use. Proxies drop an + * idle stream, and a dropped stream looks exactly like a login that is still + * waiting. Set to 0 in tests. + */ + heartbeatMs?: number; + now?: () => number; +} + +export function createSessionStreams( + options: SessionStreamOptions = {}, +): SessionStreams { + const heartbeatMs = options.heartbeatMs ?? 30_000; + const subscribers = new Map>(); + + // A wallet can answer before the browser has opened its stream — a fast scan, + // or an EventSource reconnecting. Without this the login would hang despite + // having succeeded. Expires with the session it belongs to. + const pending = new TtlMap(SESSION_TTL_MS, options.now); + + function send(sink: EventSink, event: SessionEvent): void { + sink.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); + } + + return { + subscribe(session, sink) { + sink.write(": connected\n\n"); + + const waiting = pending.take(session); + if (waiting) { + send(sink, waiting); + sink.end(); + return; + } + + const attached: Attached = { sink }; + if (heartbeatMs > 0) { + attached.heartbeat = setInterval( + () => sink.write(": ping\n\n"), + heartbeatMs, + ); + attached.heartbeat.unref?.(); + } + + const set = subscribers.get(session) ?? new Set(); + set.add(attached); + subscribers.set(session, set); + + sink.on("close", () => { + if (attached.heartbeat) clearInterval(attached.heartbeat); + set.delete(attached); + if (set.size === 0) subscribers.delete(session); + }); + }, + + publish(session, event) { + const set = subscribers.get(session); + if (!set || set.size === 0) { + pending.set(session, event); + return; + } + + for (const attached of set) { + send(attached.sink, event); + if (attached.heartbeat) clearInterval(attached.heartbeat); + attached.sink.end(); + } + subscribers.delete(session); + }, + + subscriberCount(session) { + return subscribers.get(session)?.size ?? 0; + }, + + closeAll() { + for (const set of subscribers.values()) { + for (const attached of set) { + if (attached.heartbeat) clearInterval(attached.heartbeat); + attached.sink.end(); + } + } + subscribers.clear(); + }, + }; +} diff --git a/services/w3ds-oidc-bridge/src/w3ds/wallet-version.test.ts b/services/w3ds-oidc-bridge/src/w3ds/wallet-version.test.ts new file mode 100644 index 000000000..2ac9c03b4 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/w3ds/wallet-version.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { isWalletVersionAtLeast } from "./wallet-version.js"; + +const atLeast040 = (version: string | undefined) => + isWalletVersionAtLeast(version, "0.4.0"); + +describe("isWalletVersionAtLeast", () => { + it.each(["0.4.0", "0.4.1", "0.5.0", "1.0.0", "10.0.0", "0.10.0"])( + "accepts %s", + (version) => { + expect(atLeast040(version)).toBe(true); + }, + ); + + it.each(["0.3.9", "0.0.1", "0.3.99"])("rejects %s", (version) => { + expect(atLeast040(version)).toBe(false); + }); + + it("compares numerically, not as strings", () => { + // "0.10.0" < "0.4.0" alphabetically, which is the classic way to get this + // wrong and lock out every wallet past the ninth minor. + expect(atLeast040("0.10.0")).toBe(true); + expect(isWalletVersionAtLeast("0.4.0", "0.10.0")).toBe(false); + }); + + it("treats a missing component as zero", () => { + // The reference implementation the platforms share does the same, so "0.4" + // must not be rejected. + expect(atLeast040("0.4")).toBe(true); + expect(atLeast040("1")).toBe(true); + expect(atLeast040("0.3")).toBe(false); + }); + + it("ignores anything past the third component", () => { + expect(atLeast040("0.4.0.7")).toBe(true); + }); + + it("rejects a version it cannot parse", () => { + // Treating an unparseable component as zero would let "abc" satisfy any + // minimum of 0.x. + for (const version of ["abc", "0.four.0", "", "v0.4.0"]) { + expect(atLeast040(version)).toBe(false); + } + }); + + it("rejects a wallet that sends no version", () => { + expect(atLeast040(undefined)).toBe(false); + }); +}); diff --git a/services/w3ds-oidc-bridge/src/w3ds/wallet-version.ts b/services/w3ds-oidc-bridge/src/w3ds/wallet-version.ts new file mode 100644 index 000000000..8aa8ff7e6 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/w3ds/wallet-version.ts @@ -0,0 +1,38 @@ +/** + * The wallet version gate. + * + * Deliberately alone in its own file: the W3DS protocol documentation calls + * `appVersion` temporary, added because some wallets signed differently, and due + * to be removed once the rollout finishes. When that happens this file and its + * one call site go, and nothing else has to be untangled. + */ +export function isWalletVersionAtLeast( + appVersion: string | undefined, + minimum: string, +): boolean { + if (!appVersion) return false; + + const parse = (value: string): number[] => + value + .split(".") + .slice(0, 3) + .map((part) => Number.parseInt(part, 10)); + + const actual = parse(appVersion); + const required = parse(minimum); + + for (let i = 0; i < 3; i += 1) { + // A missing component is zero, so "0.4" means "0.4.0" — the reference + // implementation the platforms share behaves the same way, and rejecting + // a valid wallet is worse than accepting a terse version string. + const a = actual[i] ?? 0; + const r = required[i] ?? 0; + // A component that does not parse is not a version. Treating NaN as zero + // would let "abc" through against a minimum of "0.0.0". + if (Number.isNaN(a)) return false; + if (a > r) return true; + if (a < r) return false; + } + + return true; +} diff --git a/services/w3ds-oidc-bridge/tsconfig.build.json b/services/w3ds-oidc-bridge/tsconfig.build.json new file mode 100644 index 000000000..684176bcb --- /dev/null +++ b/services/w3ds-oidc-bridge/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test-utils.ts"] +} diff --git a/services/w3ds-oidc-bridge/tsconfig.json b/services/w3ds-oidc-bridge/tsconfig.json new file mode 100644 index 000000000..b76f52b76 --- /dev/null +++ b/services/w3ds-oidc-bridge/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}