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":

  1. Look up wiki by slug via WikiModel.get(slug)
  2. Return 404 if wiki not found
  3. 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:

  1. Extract token query parameter
  2. Validate as platform JWT (same PlatformJWT.validate_token())
  3. Look up user by sub claim — 401 if not found
  4. Mint a fresh 24-hour session JWT
  5. Set as HTTP-only cookie (same pattern as OAuth callback, lines 526-545)
  6. Redirect to /app/ (or to a next query 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:

  1. Load platform DB connection (get_connection() from app/db.py)
  2. Load signing keys (_load_keys() from app/auth/jwt.py)
  3. Look up wiki by slug → get owner_did, validate wiki exists
  4. Construct DID: did:web:{slug}.{PLATFORM_DOMAIN}
  5. Construct handle: {slug}.{PLATFORM_DOMAIN}
  6. Upsert user record: INSERT OR REPLACE INTO users (did, handle, display_name, created_at) VALUES (?, ?, ?, ?)
  7. Mint short-lived JWT (e.g., 5 minutes): PlatformJWT.create_token(user_did=did, handle=handle, ...)
  8. Print login URL: https://{PLATFORM_DOMAIN}/auth/token-login?token={jwt}

Environment variables needed (same as platform server):

  • ROBOT_DB_PATH — platform database
  • SIGNING_KEY_PATH — RSA private key
  • PLATFORM_DOMAIN — defaults to robot.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).

  1. Platform DID document: GET /.well-known/did.json on platform server → 200, valid DID document, correct key
  2. Per-wiki DID document: Call TenantResolver with Host: test-wiki.robot.wtf and PATH_INFO: /.well-known/did.json → 200, valid DID document, alsoKnownAs contains owner's DID
  3. Per-wiki DID 404: Request DID for nonexistent wiki → 404
  4. Token login: GET /auth/token-login?token=<valid-jwt> → 302 redirect, cookie set
  5. Token login invalid: GET /auth/token-login?token=garbage → 401
  6. Token login unknown user: Valid JWT but user not in DB → 401

Verification

  1. Run pytest tests/test_did_web.py — all pass
  2. Run full test suite pytest — no regressions
  3. Manual smoke test (on VPS after deploy):
    • curl https://robot.wtf/.well-known/did.json → valid platform DID document
    • curl https://dev.robot.wtf/.well-known/did.json → valid wiki DID document with correct alsoKnownAs
    • Run CLI: python -m app.cli did-web-login --slug dev → prints login URL
    • Open login URL in browser → redirected to /app/, logged in as did:web:dev.robot.wtf
    • Navigate to wiki → has owner permissions

Future: per-wiki keys

Upgrade path when needed:

  1. Add signing_key column to wikis table (or store Ed25519 keypair as file in wiki repo)
  2. Generate keypair at wiki creation
  3. DID document adds #wiki-key verification method alongside #platform-key
  4. controller field removed once wiki self-signs
  5. Old signatures against #platform-key remain verifiable during transition