did:web Identity for robot.wtf
Context
robot.wtf authenticates users exclusively via ATProto OAuth, which requires a Bluesky account. This creates a chicken-and-egg problem for testing permission sharing: you need an external account to log in, create a wiki, and grant permissions.
did:web provides a self-sovereign identity mechanism. Each wiki gets a DID (did:web:{slug}.robot.wtf) that resolves to a DID document served by the platform. A CLI tool bootstraps a user record for that DID and mints a login token, bypassing ATProto OAuth entirely. The wiki's DID document links back to its owner via alsoKnownAs.
This enables:
- Logging into a wiki without a Bluesky account
- Testing permission sharing between wiki identities
- Platform identity assertion (
did:web:robot.wtf)
Components
1. Platform DID document (did:web:robot.wtf)
File: app/platform_server.py
Where: After existing /.well-known/jwks.json route (line 722)
New route: GET /.well-known/did.json
Returns a static DID document referencing the existing rs256_jwk:
{ "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/jwk/v1"], "id": "did:web:robot.wtf", "verificationMethod": [{ "id": "did:web:robot.wtf#platform-key", "type": "JsonWebKey", "controller": "did:web:robot.wtf", "publicKeyJwk": "<rs256_jwk>" }], "authentication": ["did:web:robot.wtf#platform-key"], "assertionMethod": ["did:web:robot.wtf#platform-key"], "service": [{ "id": "did:web:robot.wtf#oauth-as", "type": "OAuthAuthorizationServer", "serviceEndpoint": "https://robot.wtf/.well-known/oauth-authorization-server" }] }
Uses PLATFORM_DOMAIN and _SCHEME variables already in scope.
2. Per-wiki DID documents (did:web:{slug}.robot.wtf)
File: app/resolver.py
Where: Early intercept in TenantResolver.__call__(), after slug extraction (line ~651) but before auth resolution (line ~702). Pattern: similar to _serve_platform_static() (line 528).
When PATH_INFO == "/.well-known/did.json":
- Look up wiki by slug via
WikiModel.get(slug) - Return 404 if wiki not found
- Build DID document dynamically:
{ "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/jwk/v1"], "id": "did:web:dev.robot.wtf", "alsoKnownAs": ["did:plc:<owner>"], "controller": "did:web:robot.wtf", "verificationMethod": [{ "id": "did:web:dev.robot.wtf#platform-key", "type": "JsonWebKey", "controller": "did:web:robot.wtf", "publicKeyJwk": "<platform rs256_jwk>" }], "authentication": ["did:web:dev.robot.wtf#platform-key"], "assertionMethod": ["did:web:dev.robot.wtf#platform-key"] }
Key access: The resolver needs the platform's public JWK. Currently rs256_jwk is constructed inside _register_auth_routes() in platform_server.py. Extract JWK construction into a shared utility (app/auth/jwk.py) that both platform_server.py and resolver.py can import. Avoids duplicating key loading logic.
3. One-time login route
File: app/platform_server.py
Where: Inside _register_auth_routes(), after the existing routes.
New route: GET /auth/token-login
Flow:
- Extract
tokenquery parameter - Validate as platform JWT (same
PlatformJWT.validate_token()) - Look up user by
subclaim — 401 if not found - Mint a fresh 24-hour session JWT
- Set as HTTP-only cookie (same pattern as OAuth callback, lines 526-545)
- Redirect to
/app/(or to anextquery parameter if provided)
No nonce table needed — the token is a short-lived JWT signed with the platform key.
4. CLI bootstrap tool
File: app/cli.py (new)
No existing CLI module. Standalone script using argparse.
Usage: python -m app.cli did-web-login --slug dev
Flow:
- Load platform DB connection (
get_connection()fromapp/db.py) - Load signing keys (
_load_keys()fromapp/auth/jwt.py) - Look up wiki by slug → get
owner_did, validate wiki exists - Construct DID:
did:web:{slug}.{PLATFORM_DOMAIN} - Construct handle:
{slug}.{PLATFORM_DOMAIN} - Upsert user record:
INSERT OR REPLACE INTO users (did, handle, display_name, created_at) VALUES (?, ?, ?, ?) - Mint short-lived JWT (e.g., 5 minutes):
PlatformJWT.create_token(user_did=did, handle=handle, ...) - Print login URL:
https://{PLATFORM_DOMAIN}/auth/token-login?token={jwt}
Environment variables needed (same as platform server):
ROBOT_DB_PATH— platform databaseSIGNING_KEY_PATH— RSA private keyPLATFORM_DOMAIN— defaults torobot.wtf
5. Shared JWK utility
File: app/auth/jwk.py (new)
Extract from platform_server.py lines 225-249:
load_public_jwk(signing_key_path: str) -> dict— loads PEM, extracts RSA public numbers, returns JWK dict
Both platform_server.py and resolver.py import from here.
Files changed
| File | Change |
|---|---|
app/auth/jwk.py |
New. Shared JWK construction utility. |
app/platform_server.py |
Add /.well-known/did.json route. Add /auth/token-login route. Refactor rs256_jwk construction to use shared utility. |
app/resolver.py |
Add /.well-known/did.json intercept in TenantResolver.__call__(). |
app/cli.py |
New. did-web-login command. |
tests/test_did_web.py |
New. Tests for DID document serving and token login. |
Tests
File: tests/test_did_web.py
Uses existing fixtures from conftest.py (db, user_model, wiki_model, sample_user, sample_wiki).
- Platform DID document:
GET /.well-known/did.jsonon platform server → 200, valid DID document, correct key - Per-wiki DID document: Call
TenantResolverwithHost: test-wiki.robot.wtfandPATH_INFO: /.well-known/did.json→ 200, valid DID document,alsoKnownAscontains owner's DID - Per-wiki DID 404: Request DID for nonexistent wiki → 404
- Token login:
GET /auth/token-login?token=<valid-jwt>→ 302 redirect, cookie set - Token login invalid:
GET /auth/token-login?token=garbage→ 401 - Token login unknown user: Valid JWT but user not in DB → 401
Verification
- Run
pytest tests/test_did_web.py— all pass - Run full test suite
pytest— no regressions - Manual smoke test (on VPS after deploy):
curl https://robot.wtf/.well-known/did.json→ valid platform DID documentcurl https://dev.robot.wtf/.well-known/did.json→ valid wiki DID document with correctalsoKnownAs- Run CLI:
python -m app.cli did-web-login --slug dev→ prints login URL - Open login URL in browser → redirected to
/app/, logged in asdid:web:dev.robot.wtf - Navigate to wiki → has owner permissions
Future: per-wiki keys
Upgrade path when needed:
- Add
signing_keycolumn towikistable (or store Ed25519 keypair as file in wiki repo) - Generate keypair at wiki creation
- DID document adds
#wiki-keyverification method alongside#platform-key controllerfield removed once wiki self-signs- Old signatures against
#platform-keyremain verifiable during transition
