Skip to content

Feat/w3ds OIDC bridge - #1102

Draft
Bekiboo wants to merge 7 commits into
mainfrom
feat/w3ds-oidc-bridge
Draft

Feat/w3ds OIDC bridge#1102
Bekiboo wants to merge 7 commits into
mainfrom
feat/w3ds-oidc-bridge

Conversation

@Bekiboo

@Bekiboo Bekiboo commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Description of change

BREAKING: this adds a new service that must be deployedw3ds-oidc-bridge — and
changes required on the GitW3 instance. Nothing works without both.

  • Nine environment variables, listed in .env.example. The service refuses to start if any
    required one is missing, naming the key rather than failing later.
  • Two secrets to generate: an ES256 private key (PKCS#8 PEM) and a client secret.
  • HTTPS is mandatory. The bridge refuses to start on http:// unless
    W3DS_OIDC_ALLOW_INSECURE=true is set explicitly. Do not set it outside local development —
    see Trust model below.
  • Two DNS names (GitW3 and the bridge) with TLS in front.
  • app.ini on the GitW3 instance needs a [oauth2_client] block. One of the four settings
    locks users out permanently if it is wrong — details at the bottom.
  • Start order matters: the bridge must be up before GitW3 starts, or the login button
    silently does not appear.

docker-compose.gitw3.yml encodes all of the above and is the recommended path.

GitW3 accepts W3DS login through a new OIDC bridge service. Forgejo believes it is talking to an
ordinary OpenID Connect provider; the wallet believes it is talking to an ordinary W3DS platform.
Neither is modified — which is what keeps the fork's patch surface at zero, as agreed in #1096.

Issue Number

Closes #1097

Type of change

  • New (a change which implements a new feature)

How the change has been tested

Automated — 184 tests across 7 files (pnpm --filter w3ds-oidc-bridge test), including a full
harness that drives /authorize → wallet callback → /token/userinfo against a running
instance. Also run locally: build, check, check-format, check-lint, check-types. All green.

End to end, locally, with real cryptography — a real eVault provisioned against a local
Registry, a real P-256 signature verified against it, and a GitW3 account created and reused:

  • a fresh eName creates an active account and lands on the dashboard;
  • signing in again reuses that account rather than creating a second;
  • a wallet below W3DS_MIN_WALLET_VERSION reports the reason on the page instead of hanging;
  • an eName whose username is reserved reaches Forgejo's link-account page and renders it, rather
    than panicking — this is the path that breaks if the username claim is omitted instead of
    emitted empty.

Acceptance criteria 1–4 are met. Criterion 5 — staging — is not. There is no service
deployment path in this repository to build on: build.yml builds and tests, docusaurus.yml
publishes docs to Pages, and docker-compose.databases.yml runs local databases only. @coodos is
taking the staging environment; the manifest in this PR is a candidate for it, clearly labelled as
a proposal rather than the house convention. I'd suggest keeping #1097 open until the flow has been
run in staging with a real wallet.

Change checklist

  • I have ensured that the CI Checks pass locally
  • I have removed any unnecessary logic
  • My code is well documented
  • I have signed my commits
  • My code follows the pattern of the application
  • I have self reviewed my code

Design decisions

A bridge, not a patch. Forgejo has no plugin API, and #1096 committed us to a fork whose diff
against upstream stays empty so security releases merge cleanly. Teaching Forgejo about W3DS would
have cost both. Instead the bridge speaks a protocol Forgejo already implements. This works because
OIDC never specifies how a provider authenticates someone — here it is a QR code signed by an eID
wallet.

A minimal OIDC subset, hand-rolled on jose. Only what markbates/goth actually exercises:
authorization code + PKCE S256, one client, one signing key. A general-purpose provider library
would have brought a data store, client registration and a dozen grant types we would never use.

ACCOUNT_LINKING = login, never auto. On auto, two eNames that sanitise to the same
username would let the second person into the first person's account.

The username claim is always present, sometimes empty. Never absent. Forgejo's auto-registration
path guards the nil (oauth.go:1120) but its link-account page does not (linkaccount.go:53, and an
unguarded type assertion at auth.go:405), so omitting the claim panics the fallback path — the one
reached exactly when something has already gone wrong. There is a regression test asserting key
presence
, not truthiness.

CORS on the W3DS half only. A native wallet sends no Origin, but a browser-based one — the Dev
Sandbox, which is the documented way to test this — triggers a preflight, and Express answers
OPTIONS with a bare 200 and no headers. The browser then reports only "Failed to fetch". The OIDC
half never needs it: those are back-channel calls and top-level navigations.

Silent authentication is refused. After a logout Forgejo re-enters with prompt=none via its
long-term SSO token. The bridge answers login_required rather than rendering a QR page nobody is
looking at. Only reproducible on the second login, which is why it is worth stating.

Synthetic email on a .invalid domain. W3DS carries no email address and Forgejo requires one.
RFC 2606 reserves .invalid, so these can never be delivered to a domain someone might register.
They bounce by design — which is exactly why REGISTER_EMAIL_CONFIRM matters below.

Trust model

goth never verifies the ID token signature (openidConnect.go). TLS on the back channel plus
the client secret are therefore the only things separating a real ID token from a forged one. This
is why HTTPS is enforced by the service rather than left to the deployment, and why the unsafe case
has to be opted into with a named variable instead of inherited from a missing one.

The signature the bridge does verify is the wallet's: an ECDSA P-256 signature over the session
id, checked against the Registry through @metastate-foundation/auth.

What's in the PR

services/w3ds-oidc-bridge/ — the service. Forgejo-facing: /.well-known/openid-configuration,
/authorize (opens a session, serves the QR page), /token, /userinfo, /jwks, /icon.svg.
Wallet-facing: POST /w3ds/callback and GET /w3ds/events/:session (SSE, so the browser knows when
to continue), plus /apple-touch-icon.png — the wallet resolves the app icon from the hostname of
the redirect URI, so serving that path is what stops GitW3 appearing as a bare letter on the
approval screen.

docker/Dockerfile.w3ds-oidc-bridge — multi-stage, turbo prune, healthcheck on /healthz.

docker-compose.gitw3.yml + docker/gitw3-register-auth-source.sh — a candidate deployment.
The script exists because Forgejo keeps authentication sources in its database, not in
app.ini, so they cannot be declared with the rest of the configuration; without it a fresh
instance needs someone to click through Site Administration before anyone can log in. It is
idempotent and safe to run on every deploy.

docs/superpowers/specs/ and docs/superpowers/plans/ — the design and the implementation plan,
including the open questions the staging owner needs to answer.

The [oauth2_client] block, and the one that locks people out

[oauth2_client]
ENABLE_AUTO_REGISTRATION = true
ACCOUNT_LINKING = login
USERNAME = nickname
REGISTER_EMAIL_CONFIRM = false

REGISTER_EMAIL_CONFIRM must be set in this section. It otherwise inherits [service]
(modules/setting/oauth2.go:69), and combined with a .invalid address every W3DS account is
created inactive with its activation mail sent somewhere that never delivers — permanently
unactivatable, with no error anywhere. The local default is false on both sides, so this is the
one that will not surface until staging.

docker-compose.gitw3.yml additionally sets ALLOW_ONLY_EXTERNAL_REGISTRATION = true with
DISABLE_REGISTRATION = false, which makes W3DS the only way in while leaving the link-account page
open. DISABLE_REGISTRATION = true would close both and turn a rare edge case into a lockout.

Why the bridge must start first

Forgejo fetches the discovery document once, while registering its authentication sources 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 — with a misleading follow-on symptom, because an unregistered source
also stops Forgejo sending PKCE, so the bridge then rejects the request with
code_challenge is required. Once registered, the source survives a bridge restart. The compose
file gates GitW3 on the bridge's healthcheck.

@Bekiboo Bekiboo self-assigned this Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e8bc367-1bdf-4bf4-9b09-5361df8e3aaf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make GitW3 compatible with W3DS login

1 participant