Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,51 @@ AWARENESS_DELIVERY_POLL_MS=2000
# directly, so there are no AaaS-specific Neo4j vars.
# Portal -> API base URL
PUBLIC_AWARENESS_API_URL="http://localhost:4100"

# W3DS -> OIDC bridge (services/w3ds-oidc-bridge)
# Lets GitW3 accept W3DS login through Forgejo's native OAuth2 source.
# Public base URL of the bridge. This is the OIDC `issuer`, and Forgejo compares
# it byte for byte, so a trailing slash is a silent total failure - the service
# strips one if present. Must be https:// outside local development: goth never
# verifies the ID token signature, so TLS is what makes the token trustworthy.
W3DS_OIDC_PUBLIC_URL="http://localhost:4200"
W3DS_OIDC_PORT=4200
# Set only for local development, where the bridge and GitW3 share a host.
W3DS_OIDC_ALLOW_INSECURE="true"
# Credentials of the single registered client (GitW3)
W3DS_OIDC_CLIENT_ID="gitw3"
W3DS_OIDC_CLIENT_SECRET="replace-with-a-strong-secret"
# GitW3's OAuth2 callback, compared exactly - no prefix matching
W3DS_OIDC_REDIRECT_URI="http://localhost:3080/user/oauth2/W3DS/callback"
# ES256 private key in PKCS#8 PEM, and a stable key id so rotation stays possible
W3DS_OIDC_SIGNING_KEY=""
W3DS_OIDC_KEY_ID="w3ds-oidc-1"
# W3DS carries no email address and Forgejo requires one, so the bridge derives a
# synthetic address. RFC 2606 reserves .invalid, so these can never be delivered
# to a domain someone might register. They bounce by design.
W3DS_EMAIL_DOMAIN="w3ds.invalid"
# Comma-separated names added to Forgejo's reserved list, on top of its own
W3DS_EXTRA_RESERVED_USERNAMES=""
# Minimum eID Wallet version accepted. Temporary - drops out after the rollout.
W3DS_MIN_WALLET_VERSION="0.4.0"

# --- Deploying GitW3 and the bridge together -------------------------------
# Only needed for docker-compose.gitw3.yml. Local development uses the block
# above and runs the bridge with `pnpm --filter w3ds-oidc-bridge dev`.

# GitW3's public base URL, and the bare hostname behind it. The bridge derives
# its expected callback from the first, so the two can never disagree.
GITW3_PUBLIC_URL="https://gitw3.w3ds.metastate.foundation"
GITW3_DOMAIN="gitw3.w3ds.metastate.foundation"
# The name of the authentication source. It appears on the login button and in
# the callback path, so changing it changes the redirect URI on both sides.
GITW3_AUTH_SOURCE_NAME="W3DS"
# Published by the gitw3 repo's release workflow. Follows the repo's owner.
GITW3_IMAGE="ghcr.io/ensombl/gitw3"
GITW3_VERSION="latest"
# Loopback-bound; the reverse proxy in front reaches these. SSH is not.
GITW3_HOST_PORT=3000
GITW3_SSH_PORT=2222
W3DS_OIDC_HOST_PORT=4200
# Nothing publishes a bridge image yet, so the compose file builds it locally.
W3DS_OIDC_IMAGE="w3ds-oidc-bridge:local"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions docker-compose.gitw3.yml
Original file line number Diff line number Diff line change
@@ -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:
49 changes: 49 additions & 0 deletions docker/Dockerfile.w3ds-oidc-bridge
Original file line number Diff line number Diff line change
@@ -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"]
58 changes: 58 additions & 0 deletions docker/gitw3-register-auth-source.sh
Original file line number Diff line number Diff line change
@@ -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/<name>/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"
Loading
Loading