From f7cb5969c7a295024a29b60e9cd75f45adf3a0fa Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 5 Aug 2026 09:05:51 +0300 Subject: [PATCH 1/7] docs(w3ds-oidc-bridge): design and implementation plan for W3DS login --- .../plans/2026-08-05-w3ds-oidc-bridge-plan.md | 296 +++++++++++ .../2026-08-05-w3ds-oidc-bridge-design.md | 479 ++++++++++++++++++ 2 files changed, 775 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md create mode 100644 docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md 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..4c283ce82 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md @@ -0,0 +1,296 @@ +# 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.** Acceptance criterion 3 depends on it and nothing else in this plan produces it. It is a +deliverable, not a config detail. Produce an SVG, serve it from a stable URL, and record that URL for `IconURL` in 5.2. +Reuse the MetaState mark rather than drawing something new — the same rounded-square convention as the GitW3 logo. + +> **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. +Until then phase 6 has no starting point. Do not invent a compose entry — nothing would consume it, and it would read +as a deployment path that works. + +*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..51eee1c65 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md @@ -0,0 +1,479 @@ +# 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 — it lands in `external_login_user.external_id` and must never be +ambiguous. The username is presentation only. + +### 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. + +**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. + +### 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 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`) carrying the W3DS icon on the login button. 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; +- `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. From ff4afa65a3b6c92ed28e67d03b4fc374ae42a5e6 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 5 Aug 2026 09:31:54 +0300 Subject: [PATCH 2/7] feat(w3ds-oidc-bridge): service scaffold and the pure core --- .env.example | 27 ++ pnpm-lock.yaml | 261 +++++++++++++----- services/w3ds-oidc-bridge/README.md | 84 ++++++ services/w3ds-oidc-bridge/package.json | 31 +++ services/w3ds-oidc-bridge/src/claims.test.ts | 247 +++++++++++++++++ services/w3ds-oidc-bridge/src/claims.ts | 181 ++++++++++++ services/w3ds-oidc-bridge/src/config.test.ts | 153 ++++++++++ services/w3ds-oidc-bridge/src/config.ts | 120 ++++++++ services/w3ds-oidc-bridge/src/index.ts | 3 + services/w3ds-oidc-bridge/src/keys.test.ts | 155 +++++++++++ services/w3ds-oidc-bridge/src/keys.ts | 98 +++++++ services/w3ds-oidc-bridge/src/store.test.ts | 155 +++++++++++ services/w3ds-oidc-bridge/src/store.ts | 133 +++++++++ services/w3ds-oidc-bridge/tsconfig.build.json | 12 + services/w3ds-oidc-bridge/tsconfig.json | 17 ++ 15 files changed, 1603 insertions(+), 74 deletions(-) create mode 100644 services/w3ds-oidc-bridge/README.md create mode 100644 services/w3ds-oidc-bridge/package.json create mode 100644 services/w3ds-oidc-bridge/src/claims.test.ts create mode 100644 services/w3ds-oidc-bridge/src/claims.ts create mode 100644 services/w3ds-oidc-bridge/src/config.test.ts create mode 100644 services/w3ds-oidc-bridge/src/config.ts create mode 100644 services/w3ds-oidc-bridge/src/index.ts create mode 100644 services/w3ds-oidc-bridge/src/keys.test.ts create mode 100644 services/w3ds-oidc-bridge/src/keys.ts create mode 100644 services/w3ds-oidc-bridge/src/store.test.ts create mode 100644 services/w3ds-oidc-bridge/src/store.ts create mode 100644 services/w3ds-oidc-bridge/tsconfig.build.json create mode 100644 services/w3ds-oidc-bridge/tsconfig.json diff --git a/.env.example b/.env.example index b78105e49..dfbf38f9e 100644 --- a/.env.example +++ b/.env.example @@ -162,3 +162,30 @@ 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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67b478bff..3f68d6313 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,43 @@ 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 + 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/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 +5935,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 +30085,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 +30124,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 +30229,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 +32724,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 +32734,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 +33187,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 +33251,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 +33293,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 +33332,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 +33343,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 +34083,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 +34979,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 +39353,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 +42205,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 +42300,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 +42513,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 +42585,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..6df9ed081 --- /dev/null +++ b/services/w3ds-oidc-bridge/README.md @@ -0,0 +1,84 @@ +# 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 | + +| W3DS-facing | | +|---|---| +| `POST /w3ds/callback` | wallet posts ename and signature | +| `GET /w3ds/events/:session` | SSE; tells the browser when to continue | + +## 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. + +## Running locally + +```bash +pnpm --filter w3ds-oidc-bridge dev +``` + +## 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..df0ba21d2 --- /dev/null +++ b/services/w3ds-oidc-bridge/package.json @@ -0,0 +1,31 @@ +{ + "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-types": "tsc --noEmit" + }, + "dependencies": { + "@metastate-foundation/auth": "workspace:*", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "jose": "^5.2.2", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@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/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/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/index.ts b/services/w3ds-oidc-bridge/src/index.ts new file mode 100644 index 000000000..e2f250f22 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/index.ts @@ -0,0 +1,3 @@ +// Wired up in phase 3.3. The scaffold exists so `check-types` and `test` have +// something to run against while phases 1 and 2 are built bottom-up. +export {}; 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/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/tsconfig.build.json b/services/w3ds-oidc-bridge/tsconfig.build.json new file mode 100644 index 000000000..888768184 --- /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"] +} 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"] +} From 5ff9e4bf49fb94e9ab45fc0f8ef35f3d645d4673 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 5 Aug 2026 09:53:52 +0300 Subject: [PATCH 3/7] feat(w3ds-oidc-bridge): the OIDC and W3DS endpoints --- services/w3ds-oidc-bridge/src/app.ts | 35 + services/w3ds-oidc-bridge/src/clients.ts | 56 ++ services/w3ds-oidc-bridge/src/context.ts | 36 + .../src/harness.test-utils.ts | 168 +++++ services/w3ds-oidc-bridge/src/index.ts | 76 ++- .../w3ds-oidc-bridge/src/oidc/authorize.ts | 114 ++++ .../w3ds-oidc-bridge/src/oidc/discovery.ts | 60 ++ .../w3ds-oidc-bridge/src/oidc/flow.test.ts | 644 ++++++++++++++++++ services/w3ds-oidc-bridge/src/oidc/pages.ts | 106 +++ services/w3ds-oidc-bridge/src/oidc/token.ts | 176 +++++ .../w3ds-oidc-bridge/src/oidc/userinfo.ts | 54 ++ .../w3ds-oidc-bridge/src/w3ds/callback.ts | 137 ++++ .../w3ds-oidc-bridge/src/w3ds/events.test.ts | 192 ++++++ services/w3ds-oidc-bridge/src/w3ds/events.ts | 125 ++++ .../src/w3ds/wallet-version.test.ts | 49 ++ .../src/w3ds/wallet-version.ts | 38 ++ services/w3ds-oidc-bridge/tsconfig.build.json | 2 +- 17 files changed, 2064 insertions(+), 4 deletions(-) create mode 100644 services/w3ds-oidc-bridge/src/app.ts create mode 100644 services/w3ds-oidc-bridge/src/clients.ts create mode 100644 services/w3ds-oidc-bridge/src/context.ts create mode 100644 services/w3ds-oidc-bridge/src/harness.test-utils.ts create mode 100644 services/w3ds-oidc-bridge/src/oidc/authorize.ts create mode 100644 services/w3ds-oidc-bridge/src/oidc/discovery.ts create mode 100644 services/w3ds-oidc-bridge/src/oidc/flow.test.ts create mode 100644 services/w3ds-oidc-bridge/src/oidc/pages.ts create mode 100644 services/w3ds-oidc-bridge/src/oidc/token.ts create mode 100644 services/w3ds-oidc-bridge/src/oidc/userinfo.ts create mode 100644 services/w3ds-oidc-bridge/src/w3ds/callback.ts create mode 100644 services/w3ds-oidc-bridge/src/w3ds/events.test.ts create mode 100644 services/w3ds-oidc-bridge/src/w3ds/events.ts create mode 100644 services/w3ds-oidc-bridge/src/w3ds/wallet-version.test.ts create mode 100644 services/w3ds-oidc-bridge/src/w3ds/wallet-version.ts diff --git a/services/w3ds-oidc-bridge/src/app.ts b/services/w3ds-oidc-bridge/src/app.ts new file mode 100644 index 000000000..904b8455e --- /dev/null +++ b/services/w3ds-oidc-bridge/src/app.ts @@ -0,0 +1,35 @@ +import express, { type Express } from "express"; +import type { BridgeContext } from "./context.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 }); + }); + + 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)); + + app.post("/w3ds/callback", createCallbackHandler(ctx)); + app.get("/w3ds/events/:session", createEventsHandler(ctx)); + + return app; +} 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/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/index.ts b/services/w3ds-oidc-bridge/src/index.ts index e2f250f22..361f9d31c 100644 --- a/services/w3ds-oidc-bridge/src/index.ts +++ b/services/w3ds-oidc-bridge/src/index.ts @@ -1,3 +1,73 @@ -// Wired up in phase 3.3. The scaffold exists so `check-types` and `test` have -// something to run against while phases 1 and 2 are built bottom-up. -export {}; +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/oidc/authorize.ts b/services/w3ds-oidc-bridge/src/oidc/authorize.ts new file mode 100644 index 000000000..18d0cd881 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/oidc/authorize.ts @@ -0,0 +1,114 @@ +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()); + }; + + 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..ab484aeb0 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts @@ -0,0 +1,644 @@ +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("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"); + }); + }); + + 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/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 index 888768184..684176bcb 100644 --- a/services/w3ds-oidc-bridge/tsconfig.build.json +++ b/services/w3ds-oidc-bridge/tsconfig.build.json @@ -8,5 +8,5 @@ "sourceMap": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test-utils.ts"] } From d9e066802d58e084a63f53cfe5e83fa704b68857 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 5 Aug 2026 10:10:27 +0300 Subject: [PATCH 4/7] feat(w3ds-oidc-bridge): container image and the login button icon --- docker/Dockerfile.w3ds-oidc-bridge | 49 +++++++++++++++++++ .../plans/2026-08-05-w3ds-oidc-bridge-plan.md | 11 +++-- .../2026-08-05-w3ds-oidc-bridge-design.md | 4 +- services/w3ds-oidc-bridge/README.md | 39 +++++++++++++++ services/w3ds-oidc-bridge/src/app.ts | 4 ++ services/w3ds-oidc-bridge/src/icon.ts | 38 ++++++++++++++ .../w3ds-oidc-bridge/src/oidc/flow.test.ts | 11 +++++ 7 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 docker/Dockerfile.w3ds-oidc-bridge create mode 100644 services/w3ds-oidc-bridge/src/icon.ts 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/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md b/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md index 4c283ce82..d556f989b 100644 --- a/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md +++ b/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md @@ -178,9 +178,14 @@ error rather than starting a half-working server. **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.** Acceptance criterion 3 depends on it and nothing else in this plan produces it. It is a -deliverable, not a config detail. Produce an SVG, serve it from a stable URL, and record that URL for `IconURL` in 5.2. -Reuse the MetaState mark rather than drawing something new — the same rounded-square convention as the GitW3 logo. +**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. 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 index 51eee1c65..294e751f5 100644 --- a/docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md +++ b/docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md @@ -428,7 +428,9 @@ the symptom is the same: sign-in succeeds, the account exists, and login is refu 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`) carrying the W3DS icon on the login button. Nothing in Forgejo is patched, +(`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 diff --git a/services/w3ds-oidc-bridge/README.md b/services/w3ds-oidc-bridge/README.md index 6df9ed081..f19ea690b 100644 --- a/services/w3ds-oidc-bridge/README.md +++ b/services/w3ds-oidc-bridge/README.md @@ -67,12 +67,51 @@ signature, TLS plus the client secret is the only thing separating a real ID tok 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 + +Add an authentication source: **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. + ## 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 . +``` + ## Testing without a phone The [Dev Sandbox](../../infrastructure/dev-sandbox) is a full wallet substitute. Provision an eVault, copy the diff --git a/services/w3ds-oidc-bridge/src/app.ts b/services/w3ds-oidc-bridge/src/app.ts index 904b8455e..322b295dd 100644 --- a/services/w3ds-oidc-bridge/src/app.ts +++ b/services/w3ds-oidc-bridge/src/app.ts @@ -1,5 +1,6 @@ import express, { type Express } from "express"; import type { BridgeContext } from "./context.js"; +import { createIconHandler } from "./icon.js"; import { createAuthorizeHandler } from "./oidc/authorize.js"; import { createDiscoveryHandler, createJwksHandler } from "./oidc/discovery.js"; import { createTokenHandler } from "./oidc/token.js"; @@ -22,6 +23,9 @@ export function createApp(ctx: BridgeContext): Express { res.json({ ok: true }); }); + // Pointed at by the authentication source's IconURL in GitW3. + app.get("/icon.svg", createIconHandler()); + app.get("/.well-known/openid-configuration", createDiscoveryHandler(ctx)); app.get("/jwks", createJwksHandler(ctx)); app.get("/authorize", createAuthorizeHandler(ctx)); diff --git a/services/w3ds-oidc-bridge/src/icon.ts b/services/w3ds-oidc-bridge/src/icon.ts new file mode 100644 index 000000000..4a0853bf3 --- /dev/null +++ b/services/w3ds-oidc-bridge/src/icon.ts @@ -0,0 +1,38 @@ +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); + }; +} diff --git a/services/w3ds-oidc-bridge/src/oidc/flow.test.ts b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts index ab484aeb0..14db2ebbb 100644 --- a/services/w3ds-oidc-bridge/src/oidc/flow.test.ts +++ b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts @@ -130,6 +130,17 @@ describe("jwks", () => { }); }); +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(" { describe("before the callback is trusted, nothing redirects", () => { it("renders a page for an unknown client", async () => { From 593b140fa562daa6900e82c5b093f5239b6d86e2 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 5 Aug 2026 14:10:16 +0300 Subject: [PATCH 5/7] chore(w3ds-oidc-bridge): candidate deployment manifest for GitW3 and the bridge --- .env.example | 21 +++ docker-compose.gitw3.yml | 133 ++++++++++++++++++ docker/gitw3-register-auth-source.sh | 58 ++++++++ .../plans/2026-08-05-w3ds-oidc-bridge-plan.md | 28 +++- .../2026-08-05-w3ds-oidc-bridge-design.md | 62 +++++++- pnpm-lock.yaml | 6 + services/w3ds-oidc-bridge/README.md | 30 +++- services/w3ds-oidc-bridge/package.json | 2 + services/w3ds-oidc-bridge/src/app.ts | 20 ++- .../w3ds-oidc-bridge/src/oidc/authorize.ts | 18 +++ .../w3ds-oidc-bridge/src/oidc/flow.test.ts | 84 +++++++++++ 11 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 docker-compose.gitw3.yml create mode 100755 docker/gitw3-register-auth-source.sh diff --git a/.env.example b/.env.example index dfbf38f9e..3fffde6d1 100644 --- a/.env.example +++ b/.env.example @@ -189,3 +189,24 @@ W3DS_EMAIL_DOMAIN="w3ds.invalid" 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/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/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 index d556f989b..0e48109f4 100644 --- a/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md +++ b/docs/superpowers/plans/2026-08-05-w3ds-oidc-bridge-plan.md @@ -196,8 +196,32 @@ service, and no workflow in `.github/workflows/` deploys anything. So how servic `*.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. -Until then phase 6 has no starting point. Do not invent a compose entry — nothing would consume it, and it would read -as a deployment path that works. + +`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`* 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 index 294e751f5..f6b82f52a 100644 --- a/docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md +++ b/docs/superpowers/specs/2026-08-05-w3ds-oidc-bridge-design.md @@ -245,8 +245,12 @@ name against a real wallet is to reserve a name that was actually issued. | `@_bob` | `@_bob` | `bob` | `_bob@w3ds.invalid` | | `@admin` | `@admin` | `""` → linking page | `admin@w3ds.invalid` | -`sub` keeps the full ename. It is the identity — it lands in `external_login_user.external_id` and must never be -ambiguous. The username is presentation only. +`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 @@ -254,6 +258,13 @@ When the sanitiser produces nothing usable, the bridge hands the problem to Forg 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 || @@ -343,6 +354,37 @@ With `ACCOUNT_LINKING = auto`, Forgejo looks the account up by name, links it, a (`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 @@ -380,6 +422,21 @@ throws at startup, matching [awareness-service's config](../../../services/aware | `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 @@ -455,6 +512,7 @@ with a real eID Wallet. Debugging happens locally. Four things are checked in st 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f68d6313..d3c4eab45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4130,6 +4130,9 @@ importers: '@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 @@ -4143,6 +4146,9 @@ importers: 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 diff --git a/services/w3ds-oidc-bridge/README.md b/services/w3ds-oidc-bridge/README.md index f19ea690b..944277eb1 100644 --- a/services/w3ds-oidc-bridge/README.md +++ b/services/w3ds-oidc-bridge/README.md @@ -69,8 +69,10 @@ chosen rather than inherited. ## Wiring it into GitW3 -Add an authentication source: **Site Administration → Authentication Sources → Add**, type OAuth2, provider OpenID -Connect. +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 | |---|---| @@ -100,6 +102,12 @@ 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 @@ -112,6 +120,24 @@ Or as the container: 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 diff --git a/services/w3ds-oidc-bridge/package.json b/services/w3ds-oidc-bridge/package.json index df0ba21d2..9cf53fe7a 100644 --- a/services/w3ds-oidc-bridge/package.json +++ b/services/w3ds-oidc-bridge/package.json @@ -15,12 +15,14 @@ }, "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", diff --git a/services/w3ds-oidc-bridge/src/app.ts b/services/w3ds-oidc-bridge/src/app.ts index 322b295dd..fda68da11 100644 --- a/services/w3ds-oidc-bridge/src/app.ts +++ b/services/w3ds-oidc-bridge/src/app.ts @@ -1,3 +1,4 @@ +import cors from "cors"; import express, { type Express } from "express"; import type { BridgeContext } from "./context.js"; import { createIconHandler } from "./icon.js"; @@ -32,8 +33,23 @@ export function createApp(ctx: BridgeContext): Express { app.post("/token", createTokenHandler(ctx)); app.get("/userinfo", createUserinfoHandler(ctx)); - app.post("/w3ds/callback", createCallbackHandler(ctx)); - app.get("/w3ds/events/:session", createEventsHandler(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/oidc/authorize.ts b/services/w3ds-oidc-bridge/src/oidc/authorize.ts index 18d0cd881..86c3c93a3 100644 --- a/services/w3ds-oidc-bridge/src/oidc/authorize.ts +++ b/services/w3ds-oidc-bridge/src/oidc/authorize.ts @@ -59,6 +59,24 @@ export function createAuthorizeHandler(ctx: BridgeContext): RequestHandler { 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", diff --git a/services/w3ds-oidc-bridge/src/oidc/flow.test.ts b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts index 14db2ebbb..c8ed212c5 100644 --- a/services/w3ds-oidc-bridge/src/oidc/flow.test.ts +++ b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts @@ -130,6 +130,63 @@ describe("jwks", () => { }); }); +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 @@ -211,6 +268,33 @@ describe("authorize", () => { 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 () => { From eb309e77526ac708fed85063e41b18e1345a3f37 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 5 Aug 2026 16:58:22 +0300 Subject: [PATCH 6/7] feat(w3ds-oidc-bridge): serve the mark where the wallet looks for it --- services/w3ds-oidc-bridge/README.md | 8 +++ services/w3ds-oidc-bridge/src/app.ts | 5 +- services/w3ds-oidc-bridge/src/icon.ts | 69 +++++++++++++++++++ .../w3ds-oidc-bridge/src/oidc/flow.test.ts | 22 +++++- 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/services/w3ds-oidc-bridge/README.md b/services/w3ds-oidc-bridge/README.md index 944277eb1..fdba67ddd 100644 --- a/services/w3ds-oidc-bridge/README.md +++ b/services/w3ds-oidc-bridge/README.md @@ -34,11 +34,19 @@ its account-linking page. Both are covered in the spec. | `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 diff --git a/services/w3ds-oidc-bridge/src/app.ts b/services/w3ds-oidc-bridge/src/app.ts index fda68da11..bc6d98522 100644 --- a/services/w3ds-oidc-bridge/src/app.ts +++ b/services/w3ds-oidc-bridge/src/app.ts @@ -1,7 +1,7 @@ import cors from "cors"; import express, { type Express } from "express"; import type { BridgeContext } from "./context.js"; -import { createIconHandler } from "./icon.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"; @@ -26,6 +26,9 @@ export function createApp(ctx: BridgeContext): Express { // 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)); diff --git a/services/w3ds-oidc-bridge/src/icon.ts b/services/w3ds-oidc-bridge/src/icon.ts index 4a0853bf3..27a9b9f67 100644 --- a/services/w3ds-oidc-bridge/src/icon.ts +++ b/services/w3ds-oidc-bridge/src/icon.ts @@ -36,3 +36,72 @@ export function createIconHandler(): RequestHandler { .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/oidc/flow.test.ts b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts index c8ed212c5..b0105575b 100644 --- a/services/w3ds-oidc-bridge/src/oidc/flow.test.ts +++ b/services/w3ds-oidc-bridge/src/oidc/flow.test.ts @@ -196,6 +196,24 @@ describe("the login button icon", () => { 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", () => { @@ -292,7 +310,9 @@ describe("authorize", () => { authorizeUrl(bridge.url, { prompt: "login" }), ); expect(response.status).toBe(200); - expect(await response.text()).toContain("w3ds://auth?redirect="); + expect(await response.text()).toContain( + "w3ds://auth?redirect=", + ); }); }); }); From a67c0717f4f7c60816e1a94b7550a12043a11b0d Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 5 Aug 2026 17:22:55 +0300 Subject: [PATCH 7/7] chore(w3ds-oidc-bridge): run the repo checks on the new package --- .gitignore | 3 +++ services/w3ds-oidc-bridge/package.json | 3 +++ 2 files changed, 6 insertions(+) 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/services/w3ds-oidc-bridge/package.json b/services/w3ds-oidc-bridge/package.json index 9cf53fe7a..5f3300e8c 100644 --- a/services/w3ds-oidc-bridge/package.json +++ b/services/w3ds-oidc-bridge/package.json @@ -11,6 +11,9 @@ "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": {