OIDC bridge that lets oauth2-proxy sit in front of Moneta's mPass sign-in. Moneta issues tokens through an implicit-grant QR flow; oauth2-proxy speaks OIDC authorization code + PKCE. This service translates between the two so oauth2-proxy can own all session management.
FastAPI + uvicorn, Redis/Valkey for short-lived bridge state.
This service used to live at
mpass-auth-proxy/inside Pressingly/foss-server-bundle. That copy is deprecated — this repository is the source of truth for all new development. Commit history up to the extraction stays in foss-server-bundle.
| Method | Path | Purpose |
|---|---|---|
GET |
/authorize |
Receives the oauth2-proxy OIDC redirect, stores the request params (including the PKCE challenge), redirects the browser to the Moneta hosted UI |
GET |
/mpass-callback |
Moneta returns the id_token here; mints a one-time auth code and redirects to oauth2-proxy's /oauth2/callback |
POST |
/token |
oauth2-proxy exchanges the auth code (or a refresh token) for real IdP tokens |
GET |
/mpass/login |
Redirects to /oauth2/sign_in — re-initiates SSO after a 401 |
GET |
/mpass/logout |
Clears the mpass_bridge cookie, then 302 to /oauth2/sign_out, optionally chaining through the IdP sign-out |
GET |
/health |
Liveness check |
GET |
/nip-fa/v1/login/start |
Buzz NIP-FA browser login: validates the loopback return_to and S256 handoff challenge, then joins the Moneta login (only when BUZZ_ADAPTER_ENABLED) |
POST |
/nip-fa/v1/login/exchange |
Buzz NIP-FA code exchange: single-use code + handoff secret for an adapter session |
GET |
/nip-fa/v1/session |
Buzz NIP-FA session check (Bearer adapter session), revalidated against Cognito at most hourly |
GET |
/nip-fa/v1/identity/key |
Our extension to NIP-FA (Bearer adapter session): returns the platform-held Nostr key {nostr_pubkey, secret_key} for the session's mPass ID, the same key on every device |
POST |
/nip-fa/v1/identity/assertions |
Buzz NIP-FA relay assertion (Bearer adapter session + Nostr-Authorization NIP-98 proof): returns a short-lived NIP-FI assertion {assertion, nostr_pubkey, expires_at} for an allowlisted relay |
POST |
/nip-fa/v1/nip-fi/agent-grant |
Buzz NIP-FA agent grant (RFC 8693 form with the owner's session as subject_token, a NIP-OA owner_attestation, and a NIP-98 proof by the agent key): enrolls the agent and returns an opaque grant (only when BUZZ_AGENTS_ENABLED) |
POST |
/nip-fa/v1/nip-fi/agent-assertion |
Buzz NIP-FA agent assertion (Bearer agent grant + NIP-98 proof by the agent key): returns a nip-fi-agent+jwt carrying nostr_owner_pubkey |
POST |
/nip-fa/v1/nip-fi/agent-revoke |
Buzz NIP-FA agent revocation (Bearer owner session, form agent_pubkey): revokes the agent for good and disconnects it on every relay |
POST |
/internal/nip-fa/v1/identity/revoke |
Internal, never routed publicly (Bearer BUZZ_ADMIN_TOKEN, JSON {"mpass_id"}): revokes a user's Buzz identity for good, ends their adapter sessions and login codes, and disconnects their key on every relay (only when BUZZ_REVOCATION_ENABLED) |
GET |
/internal/nip-fa/v1/identity/pubkey?mpass_id= |
Internal, never routed publicly (Bearer BUZZ_ADMIN_TOKEN): the stored Buzz pubkey for an mPass ID, 404 until the user has signed in to Buzz once |
GET |
/.well-known/jwks.json |
Public signing key, served when LAUNCHPAD_EMAIL_CAPTURE or BUZZ_ADAPTER_ENABLED is on (404 otherwise) |
Every endpoint above must have a matching Path() entry in the mpass-bridge
Traefik router rule. A path missing from that rule falls through to the
oauth2-proxy catch-all, which answers with a 302 to the login page —
machine-to-machine callers (MCP OAuth token exchange) cannot follow a redirect,
so auth breaks silently rather than erroring.
The router rule does not live in this repository. It is the
traefik.http.routers.mpass-bridge.rule label on the mpass-auth-proxy service
in docker-compose.yml in Pressingly/foss-server-bundle (private), and it
lists the non-Buzz paths in the table above, including /.well-known/jwks.json.
Every public Buzz adapter route lives under /nip-fa/ (see nip_fa/), so the
rule needs one PathPrefix(/nip-fa/) for all of them rather than a Path()
per route. That entry is not there yet: it lands with the bundle's Buzz
work, and until then no /nip-fa/ route is reachable through Traefik.
The /internal/ routes are the exception: they must never be added to any
Traefik router. Operators reach them on the service's internal hostname, for
example http://mpass-auth-proxy:8000/internal/... from inside the Docker
network; on the public host they fall through to the oauth2-proxy catch-all.
So adding, renaming or removing a route here is a two-repo change: land the route in this repo, then open a coordinated PR against foss-server-bundle updating the rule. The route is not reachable in any deployment until both have shipped. If you do not have access to that repo, say so on your PR so someone who does can pair the change.
All configuration is read from the environment at import time; the required
variables have no defaults and the process fails fast if they are missing. See
.env.example for the full list with descriptions.
Required: OIDC_ISSUER_URL, OIDC_CLIENT_ID, MONETA_HOSTED_UI_URL,
MPASS_CALLBACK_URL, REDIS_URL, COOKIE_DOMAIN.
Optional: OIDC_CLIENT_SECRET, SESSION_COOKIE_MAX_AGE_SECONDS,
OIDC_LOGOUT_URI, LOGOUT_REDIRECT_URL, SMB_CORPORATE_ID, PORTAL_URL,
LOG_LEVEL.
The Buzz NIP-FA adapter (BUZZ_ADAPTER_ENABLED, off by default) requires
BUZZ_IDENTITY_MASTER_SECRET (64 hex characters), BUZZ_IDENTITY_SALT (hex,
32 to 128 characters) and LAUNCHPAD_DB_PASSWORD, and reads the optional
BUZZ_ADAPTER_SESSION_TTL_SECONDS (default 2592000, 30 days). The service
refuses to start with the flag on and any of the three missing or malformed.
Each user's Nostr key is
HKDF-SHA256(ikm=master secret, salt=identity salt, info="moneta-buzz/nostr-key/v1|" + uint16_be(len(mPass ID)) + mPass ID + attempt byte),
where the attempt byte is 0 unless that scalar is out of range for secp256k1,
so changing either value re-keys every Buzz user. The first issuance binds the
public key in launchpad's buzz_identities table; a later derivation that no
longer matches it is refused with binding_mismatch rather than issuing a new
identity. The master secret also seals the adapter's Redis records, so rotating
it signs every Buzz session out.
POST /nip-fa/v1/identity/assertions issues the NIP-FI assertion a Buzz client
presents to a relay. It needs:
BUZZ_NIP_FI_ISSUER(required): the exactissstring, an https URL with no trailing slash. It must differ fromMPASS_PROXY_ISSUER_URL.BUZZ_RELAY_URLS(required): comma-separated canonical relay URLs,wss://host[:port]in lowercase, default port omitted, no port 80, no trailing slash, path, query or fragment. A non-canonical entry refuses startup; requests are compared against the list exactly.BUZZ_ASSERTION_TTL_SECONDS(default 300, 30 to 3600): assertion lifetime, capped by the adapter session's expiry.BUZZ_ADAPTER_PUBLIC_ORIGIN(optional): the origin Buzz clients reach this service on. The NIP-98utag must equal<origin>/nip-fa/v1/identity/assertionsexactly. It defaults to theMPASS_CALLBACK_URLorigin; behind Traefik the request URL is the internal one, so it is never used.- A signing key: assertions are RS256 with the same key and
kidemail capture uses, so with the adapter on the service loads it (and serves the JWKS) even with capture off, from the sources.env.examplelists.
Each Buzz relay's NIP-FI issuer policy must match, or the relay rejects every assertion:
| Relay setting | Value |
|---|---|
| issuer | BUZZ_NIP_FI_ISSUER, byte for byte |
jwks_uri |
<adapter origin>/.well-known/jwks.json |
| token class | dedicated NIP-FI (typ nip-fi+jwt), RS256 |
| community audience | https:// + the relay URL's authority, e.g. wss://chat.example.com → https://chat.example.com |
maximum_assertion_age_seconds |
at least BUZZ_ASSERTION_TTL_SECONDS |
Requests are limited to 30 per adapter session per 60 seconds (429 rate_limited with Retry-After). The claims are iss, sub (the mPass ID),
aud, iat, exp, nostr_pubkey and, when the email local part meets the
relay's username rules, nostr_public_profile {display_name, username}.
BUZZ_AGENTS_ENABLED (off by default, read only with the adapter on) turns on
the NIP-FA "Agent keys" routes, following docs/nips/NIP-FA.md in block/buzz
at b14899f. With it off they answer 403 agent_unsupported. It needs
BUZZ_REVOCATION_ENABLED (below): the service refuses to start with agents on
and owner revocation off, because an agent must stop when its owner is revoked.
- An owner enrolls an agent key with their adapter session (as
subject_token, never a header), a NIP-OAauthtag signed by their platform-held key, and a NIP-98 proof signed by the agent key. Only empty attestation conditions are accepted. An agent key belongs to its first owner for good, a revoked one never enrolls again, a user's own Buzz key can never be an agent, and each user may holdBUZZ_MAX_AGENTS_PER_USERagents with a live grant (default 5). Expired grants do not count, because clients delete agents without revoking them; re-enrolling an expired agent is counted again. - The grant is opaque; launchpad's
buzz_agentsstores only its SHA-256. It lastsBUZZ_AGENT_GRANT_TTL_SECONDS(default 691200, 8 days), use does not extend it, and enrolling again replaces it. - Agent assertions have
typnip-fi-agent+jwt,subthe owner's mPass ID andnostr_owner_pubkeythe owner's key, withexpcapped by the grant. They stop the moment the agent is revoked, or its owner'sbuzz_identities.revoked_atis set. - The current buzz-app desktop (block/buzz-app 9c94152) still enrolls in its
older Builderlab form, and
BUZZ_AGENTS_ACCEPT_LEGACY_CLIENT(on by default) accepts it on the grant route as an alias of the spec form:subject_token_typeurn:builderlab:params:oauth:token-type:bb-session,requested_token_typeurn:builderlab:params:oauth:token-type:nip-fi-agent-grant(echoed back asissued_token_type), a seventh fieldruntime=local, and the NIP-98 proof inNostr-Agent-Proofinstead ofNostr-Authorization. A request takes one form whole: both proof headers, or the legacy header with any spec field (or the reverse), is400 invalid_request. Everything after parsing is the same code: proof verification, single use, owner attestation, cap, rate limit and revocation. The client's assertion request is already the spec form. Turn the flag off, and later delete the dialect, once buzz-app'ssrc-tauri/src/federated_identity/agent/mod.rssends theurn:nostr:token types andNostr-Authorization, and our desktop build has picked that up. - NIP-98 proofs on the grant and assertion routes are single-use (event ids are remembered in Redis for 95 seconds, which covers clock drift between replicas).
- Revoking signs a NIP-FI disconnect command (
typnip-fi-command+jwt, same key andkid) and sends it to every relay's internal URL. The deny lastsBUZZ_ASSERTION_TTL_SECONDS, outlasting every assertion already issued. If any relay does not confirm, the revocation stands but the route answers503so the client retries, and a repeated revoke sends the disconnect again.
A grant is not tied to the owner's adapter session: an owner whose mPass access ends loses their sessions at the next hourly revalidation, but their agents keep getting assertions until the grant expires. Offboarding therefore has to call the owner revocation below, which ends every grant at once.
/nip-fa/v1/nip-fi/agent-assertion looks a well-formed grant up in the
launchpad database before any per-grant limit applies, on the pool email capture
shares. Put a per-client rate limit on that path at the edge.
Requests are limited to 10 grants and 10 revocations per adapter session and 30 assertions per grant, per 60 seconds.
BUZZ_REVOCATION_ENABLED (off by default, read only with the adapter on)
mounts the two internal routes in the table above and starts the relay deny
re-push. It needs BUZZ_ADMIN_TOKEN (at least 32 characters, e.g.
openssl rand -hex 32), BUZZ_RELAY_INTERNAL_URLS (one per BUZZ_RELAY_URLS
entry, same order) and BUZZ_COMMAND_PRINCIPAL, and optionally
BUZZ_RELAY_COMMAND_AUDIENCES; .env.example describes each.
Revoking an mPass ID (offboarding):
curl -sS -X POST http://mpass-auth-proxy:8000/internal/nip-fa/v1/identity/revoke \
-H "Authorization: Bearer $BUZZ_ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"mpass_id": "<cognito:username>"}'- Sets
buzz_identities.revoked_atonce; it never clears, so the user can never get their Buzz key or an assertion again, even after signing in again. - Ends the user's adapter sessions and unexchanged login codes at once, through
a Redis tombstone kept for
BUZZ_ADAPTER_SESSION_TTL_SECONDS. - Ends every agent grant the user authorized: a grant is usable only while its owner is not revoked, and no new agent can enroll under a revoked owner.
- Disconnects the user's key on every relay, until the later of
revoked_at(rounded up) and this service's clock after the commit, plusBUZZ_ASSERTION_TTL_SECONDS: every assertion issued before the revocation took effect has expired by then. One disconnect covers the user's agents too: the relay closes sessions whose recorded owner is the target, and denies an agent assertion whose owner it has denied (NIP-FI "Agent keys"). - Answers
{"revoked": true, "nostr_pubkey"}, or{"revoked": false}for a user who never bound a key. Their sessions still end and no key is issued to them while the tombstone lasts (BUZZ_ADAPTER_SESSION_TTL_SECONDS), but nothing is recorded in the database, so offboarding must also end their mPass access, as it does today.503 unavailablemeans retry: every step is idempotent, and a repeat sends the disconnect again.
A relay keeps its deny set in memory and forgets it on restart, without telling
the issuer. So while revocation is on, every replica re-sends, every 30
seconds, the disconnect for each owner or agent key revoked within the last
BUZZ_ASSERTION_TTL_SECONDS, anchored on the stored revocation time (up to 8
keys at a time). A restarted relay can therefore readmit a revoked key, with an
assertion issued before the revocation, for at most 30 seconds plus the time
one re-push takes; that grows by the relay timeout (5 seconds per batch of 8
keys) while some relay is unreachable.
Each relay's issuer policy needs:
| Relay setting | Value |
|---|---|
command_audiences |
https:// + the relay URL's authority, or that relay's BUZZ_RELAY_COMMAND_AUDIENCES entry |
authorized_principals |
BUZZ_COMMAND_PRINCIPAL |
maximum_command_age_seconds |
1 to 60 (commands are sent as soon as they are signed) |
maximum_assertion_age_seconds and skew_seconds |
maximum_assertion_age_seconds + skew_seconds above BUZZ_ASSERTION_TTL_SECONDS by at least the clock drift between this service and the relay plus one second (owner denies are rounded up to the second). A disconnect's until is now + BUZZ_ASSERTION_TTL_SECONDS by this service's clock, and the relay refuses one past its own now + skew + maximum_assertion_age, so with no margin every revoke answers 503 |
| token class | the dedicated NIP-FI class above, which admits nip-fi-agent+jwt too |
Deploy order: buzz_identities and its grants come from launchpad-api's
sql/schema.sql. Apply that schema (python -m src.migrate) before enabling
the adapter here, or /nip-fa/v1/identity/key and /nip-fa/v1/identity/assertions
answer 503 issuance_unavailable. The same goes for buzz_agents before
BUZZ_AGENTS_ENABLED or BUZZ_REVOCATION_ENABLED.
Launchpad email capture (LAUNCHPAD_EMAIL_CAPTURE) adds its own required set:
the signing key (MPASS_SIGNING_KEY_B64 or the MPASS_SIGNING_KEY_GCP_* pair),
SYNTHETIC_EMAIL_DOMAIN and LAUNCHPAD_DB_PASSWORD. The other LAUNCHPAD_DB_*
values default to the bundle's postgres. .env.example describes each one and
the order the key sources are tried in.
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill it in
set -a && . ./.env && set +a
uvicorn main:app --host 0.0.0.0 --port 8000 --log-level info --no-access-log--no-access-log is deliberate and is also baked into the Dockerfile: uvicorn's
per-request access lines echoed /mpass-callback query strings in full, which
carry the id_token, access_token and refresh_token. Application-level
events are logged through the module logger and remain visible.
pip install -r requirements.txt -r requirements-test.txt
pytestpytest-dotenv loads tests/.env.test before any test module is imported —
those are fake values, and they must stay in sync with the module-level
constants asserted in tests/test_main.py.
docker build -t mpass-auth-proxy .
docker run --rm -p 8000:8000 --env-file .env mpass-auth-proxyThe container listens on port 8000 and runs as a non-root user.
GPL-3.0 — see LICENSE.