-
Notifications
You must be signed in to change notification settings - Fork 7
Feat/w3ds OIDC bridge #1102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feat/w3ds OIDC bridge #1102
Changes from all commits
f7cb596
ff4afa6
5ff9e4b
d9e0668
593b140
eb309e7
a67c071
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: |
| 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 . . | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/sh
set -eu
test -f .dockerignore
nl -ba .dockerignore
printf '\nVerify that the rules exclude at least .env, .env.* and backup variants.\n'Repository: MetaState-Prototype-Project/prototype Length of output: 215 🏁 Script executed: set -eu
printf '%s\n' '--- .dockerignore ---'
if [ -f .dockerignore ]; then
cat -n .dockerignore
else
echo 'MISSING'
fi
printf '%s\n' '--- Dockerfile.w3ds-oidc-bridge ---'
sed -n '1,80p' docker/Dockerfile.w3ds-oidc-bridge
printf '%s\n' '--- environment-file candidates ---'
find . -maxdepth 3 -type f \( -name '.env' -o -name '.env.*' -o -name '*.env' -o -name '*env*~' \) -printRepository: MetaState-Prototype-Project/prototype Length of output: 2902 🏁 Script executed: python3 - <<'PY'
from pathlib import Path
from fnmatch import fnmatch
ignore = Path(".dockerignore").read_text().splitlines()
patterns = [line.strip() for line in ignore if line.strip() and not line.lstrip().startswith("#")]
names = [".env", ".env.local", ".env.production", ".env.backup", ".env~", "config.env"]
for name in names:
matched = [pattern for pattern in patterns if fnmatch(name, pattern)]
print(f"{name}: {matched or 'NOT IGNORED'}")
dockerfile = Path("docker/Dockerfile.w3ds-oidc-bridge").read_text().splitlines()
print("Dockerfile USER instructions:", [
line for line in dockerfile
if line.strip().upper().startswith("USER ")
])
PYRepository: MetaState-Prototype-Project/prototype Length of output: 341 🌐 Web query:
💡 Result: In the official Docker Node.js images (including the alpine variants), the default user is root [1][2]. While a 'node' user is created within the image (typically with UID 1000) [3][4], the Dockerfile does not contain a USER instruction to switch to it by default [1]. This design choice is intentional, intended to provide flexibility for users who need to install OS-level dependencies or perform other administrative tasks during the build process without having to explicitly switch back to root [1]. If you wish to run your application as a non-root user, you have two primary options: 1. At runtime: You can specify the user when running the container using the -u flag [1][2]: docker run -u "node" node:... 2. In your Dockerfile: You can explicitly set the user to 'node' at the end of your Dockerfile to ensure that the image runs as that user by default [3]: USER node For further security best practices, the official documentation provides guidance on running containers in a 'rootless' manner [5][3]. Citations:
Exclude backup environment files and run the bridge as non-root
🤖 Prompt for AI Agents |
||
| 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"] | ||
|
Comment on lines
+29
to
+49
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Run the bridge as an unprivileged user. The final stage inherits the root user. The bridge processes untrusted callback input and holds the signing key. A successful bridge compromise therefore has unnecessary root privileges inside the container. Proposed fix WORKDIR /app/services/w3ds-oidc-bridge
+RUN addgroup -S bridge && adduser -S -G bridge bridge
+USER bridge
+
# Keep in step with W3DS_OIDC_PORT.
EXPOSE 4200🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| 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" \ | ||
|
Comment on lines
+41
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Normalize the bridge URL before creating endpoint URLs. The bridge strips trailing slashes from Normalize the value once after Line 17, then use the normalized value for both URLs. 🤖 Prompt for AI Agents |
||
| --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" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Use an HTTPS bridge URL for the deployment template.
Line 172 sets an HTTP issuer and Line 175 enables insecure mode.
docker-compose.gitw3.ymlintentionally does not passW3DS_OIDC_ALLOW_INSECURE. The documented Compose command therefore makesloadConfig()reject this template before the bridge becomes healthy.Use a separate local-development environment file, or set this deployment block to an HTTPS bridge URL with insecure mode disabled.
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 172-172: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 173-173: [UnorderedKey] The W3DS_OIDC_PORT key should go before the W3DS_OIDC_PUBLIC_URL key
(UnorderedKey)
[warning] 175-175: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 175-175: [UnorderedKey] The W3DS_OIDC_ALLOW_INSECURE key should go before the W3DS_OIDC_PORT key
(UnorderedKey)
🤖 Prompt for AI Agents