PLAN-OAUTH-21-SERVER.md 20 KB

oauth-21-server - Work Plan

TL;DR (For humans)

What you'll get: A fully compliant OAuth 2.1 authorization server with a discovery endpoint, PKCE-mandatory authorization flows, opaque tokens stored in Redis, and a login/consent UI. All endpoints follow RFC 8414 metadata and OAuth 2.1 draft-15 with the highest security posture — no legacy grants, no plain PKCE, exact redirect URI matching, and HTTPS enforcement.

Why this approach: Using ioredis for all expiring data (tokens, auth codes, sessions) and SQLite3 only for persistent records (clients, users, scopes) cleanly separates concerns and leverages Redis TTL for automatic expiration. SvelteKit's +server.js handlers provide a clean API layer on the existing Node.js adapter.

What it will NOT do: It will not support JWT tokens, implicit grants, password grants, client_credentials grants, or query-string bearer tokens. It will not persist auth codes or tokens in SQLite3. It does not include OpenID Connect.

Effort: Large Risk: Medium — Redis dependency adds operational surface; PKCE and PAR enforcement must be exact Decisions to sanity-check: Token format (opaque), grant types (authorization_code + refresh_token only), Redis as sole store for expiring data.

Your next move: Start work with /start-work.


TL;DR (machine): Large effort, Medium risk — OAuth 2.1 AS with discovery, PKCE, PAR, opaque tokens in Redis, sqlite3 for persistent data, SvelteKit +server.js handlers.

Scope

Must have

  • /.well-known/oauth-authorization-server metadata endpoint (RFC 8414)
  • OAuth 2.1 core protocol endpoints: /oauth/authorize, /oauth/token, /oauth/introspect, /oauth/revoke
  • Client registration endpoint /oauth/register (RFC 7591)
  • PKCE S256 mandatory enforcement (no plain)
  • Pushed Authorization Requests (PAR) endpoint /oauth/par (RFC 9126) with require_pushed_authorization_requests: true
  • Opaque token generation and storage in Redis with TTL
  • Authorization code generation and storage in Redis with TTL
  • Refresh token generation and storage in Redis with TTL
  • Client authentication at token endpoint: client_secret_basic, client_secret_post, private_key_jwt, tls_client_auth
  • Introspection endpoint with client auth (not bearer-only)
  • Exact redirect URI matching
  • User login page (/oauth/login)
  • User consent page (/oauth/consent)
  • config.json updates: baseUrl, redis connection options (host, port, password, database number)
  • ioredis dependency and Redis client module
  • SQLite3 schema extension: clients table only
  • scopes_supported dynamically from SQLite3 scopes table
  • ui_locales_supported: ["en-US", "es-MX"]
  • Documentation moved from .omo/ulw-research/ to docs/

Must NOT have (guardrails, anti-slop, scope boundaries)

  • JWT access tokens (opaque only)
  • Implicit grant (response_type=token)
  • Resource Owner Password Credentials grant
  • client_credentials grant
  • plain PKCE method
  • Auth codes, access tokens, or refresh tokens stored in SQLite3
  • Bearer tokens in query strings
  • Wildcard or prefix redirect URI matching
  • OpenID Connect (ID tokens, userinfo endpoint)
  • Device authorization grant (RFC 8628)
  • CORS on authorization endpoint

Verification strategy

Zero human intervention - all verification is agent-executed.

  • Test decision: tests-after + agent-executed QA via curl/httpie against running dev server
  • Evidence: .omo/evidence/task-<N>-oauth-21-server.<ext>

Execution strategy

Parallel execution waves

Target 5-8 todos per wave.

Wave 1: Foundation — Config, dependencies, Redis client, database schema Wave 2: Discovery & Client Registration — Metadata endpoint, client registration Wave 3: Core OAuth Flow — Authorization, token, introspection, revocation endpoints Wave 4: User UI & Security Hardening — Login/consent pages, PAR, JWKS Wave 5: Documentation & Final QA — Move docs, integration testing

Dependency matrix

Todo Depends on Blocks Can parallelize with
1 2,3,4,5
2 1 6,7,8,9,10 3,4,5
3 1 6,7,8,9,10 2,4,5
4 1 6,7,8,9,10 2,3,5
5 1 2,3,4
6 2,3,4 7
7 6 8,9,10
8 7 9,10
9 7 8,10
10 7 8,9
11 1 12
12 11
13 all
14 all

Todos

  • [x] 1. Update config.json.example with baseUrl and redis sections What to do / Must NOT do: Add baseUrl (string, e.g., https://localhost:3000) and redis object with host, port, password, db (database number) to config.json.example. Must NOT remove existing database/password sections. Parallelization: Wave 1 | Blocked by: — | Blocks: 2,3,4,5 References: config.json.example:1-25, src/lib/config.js:1-19 Acceptance criteria: cat config.json.example | grep -q baseUrl && cat config.json.example | grep -q redis QA scenarios: Verify JSON is valid (node -e "JSON.parse(require('fs').readFileSync('config.json.example'))"). Happy: valid JSON. Failure: invalid JSON syntax. Commit: Y | feat(config): add baseUrl and redis connection options

  • [x] 2. Install ioredis dependency and create Redis client module What to do / Must NOT do: pnpm add ioredis. Create src/lib/redis.js exporting a configured ioredis client using config.redis. Must NOT use redis (node-redis) package. Must handle connection errors gracefully. Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7,8,9,10 References: package.json:29-32, src/lib/config.js:1-19, ioredis docs Acceptance criteria: node -e "import('./src/lib/redis.js').then(m => console.log(typeof m.default))" returns "object" or similar QA scenarios: Happy: Redis client connects and responds to PING. Failure: Invalid config throws error with helpful message. Commit: Y | feat(redis): add ioredis client module

  • [x] 3. Extend SQLite3 schema with clients table What to do / Must NOT do: Add clients table creation to seedInfo() in src/lib/sqlite3/database.js. Schema: id, client_id (TEXT UNIQUE), client_secret (TEXT), redirect_uris (TEXT, JSON array), grant_types (TEXT), response_types (TEXT), scope (TEXT), token_endpoint_auth_method (TEXT), registration_access_token (TEXT), created_at, updated_at. Must NOT create auth_codes, access_tokens, or refresh_tokens tables in SQLite3. Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7,8,9,10 References: src/lib/sqlite3/database.js:16-47 Acceptance criteria: After rm data/app.db && npm run dev, clients table exists with correct columns. QA scenarios: Happy: Fresh DB initializes with clients table. Failure: Duplicate table creation does not error (idempotent). Commit: Y | feat(db): add clients table for OAuth 2.1

  • [x] 4. Create token generation utilities What to do / Must NOT do: Create src/lib/oauth/tokens.js with functions: generateAuthorizationCode(), generateAccessToken(), generateRefreshToken() — all return cryptographically random strings (use crypto.randomBytes). Must NOT use Math.random(). Must NOT create JWTs. Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7,8,9,10 References: Node.js crypto module docs, src/lib/crypto.js:1-13 Acceptance criteria: node -e "import('./src/lib/oauth/tokens.js').then(m => console.log(m.generateAccessToken().length > 20))" prints true QA scenarios: Happy: Generated tokens are unique across 1000 calls. Failure: Tokens are predictable or too short. Commit: Y | feat(oauth): add opaque token generation utilities

  • [x] 5. Create PKCE validation utilities What to do / Must NOT do: Create src/lib/oauth/pkce.js with generateCodeChallenge(verifier) (S256 SHA256 base64url) and verifyCodeChallenge(verifier, challenge) (constant-time comparison). Must NOT support plain method. Must use crypto.createHash('sha256'). Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7 References: RFC 7636 Section 4, Node.js crypto docs Acceptance criteria: node -e "import('./src/lib/oauth/pkce.js').then(m => m.verifyCodeChallenge('test', m.generateCodeChallenge('test')))" prints true QA scenarios: Happy: Valid verifier matches challenge. Failure: Invalid verifier fails, wrong method fails, timing attack resistant. Commit: Y | feat(oauth): add PKCE S256 utilities

  • [x] 6. Create /.well-known/oauth-authorization-server endpoint What to do / Must NOT do: Create src/routes/.well-known/oauth-authorization-server/+server.js returning JSON metadata. Must query scopes_supported from SQLite3 scopes table dynamically. Must use config.baseUrl for all endpoint URLs. Must include: issuer, authorization_endpoint, token_endpoint, revocation_endpoint, introspection_endpoint, registration_endpoint, pushed_authorization_request_endpoint, require_pushed_authorization_requests (true), response_types_supported (["code"]), grant_types_supported (["authorization_code", "refresh_token"]), token_endpoint_auth_methods_supported, revocation_endpoint_auth_methods_supported, introspection_endpoint_auth_methods_supported, code_challenge_methods_supported (["S256"]), scopes_supported, service_documentation, ui_locales_supported (["en-US", "es-MX"]). Must NOT include "plain" in code_challenge_methods_supported. Must NOT include "bearer" alone in introspection auth methods. Parallelization: Wave 2 | Blocked by: 2,3 | Blocks: 7 References: src/lib/config.js:1-19, src/lib/sqlite3/database.js:1-91, RFC 8414 Section 2 Acceptance criteria: curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq '.issuer' returns non-null QA scenarios: Happy: Returns valid JSON with all required fields. Failure: Missing baseUrl config throws 500. Scopes query returns correct list from DB. Commit: Y | feat(oauth): add OAuth 2.1 discovery metadata endpoint

  • [x] 7. Create /oauth/register client registration endpoint What to do / Must NOT do: Create src/routes/oauth/register/+server.js handling POST for dynamic client registration (RFC 7591). Store clients in SQLite3 clients table. Generate client_id, client_secret (if confidential client), registration_access_token. Return client metadata with client_id_issued_at. Must validate redirect_uris (exact, HTTPS). Must NOT allow http:// except localhost. Must NOT register clients with unsupported grant types. Parallelization: Wave 2 | Blocked by: 2,3 | Blocks: 8,9,10 References: RFC 7591, src/lib/sqlite3/database.js, src/routes/.well-known/oauth-authorization-server/+server.js Acceptance criteria: curl -s -X POST http://localhost:3000/oauth/register -H "Content-Type: application/json" -d '{"redirect_uris":["https://example.com/callback"],"client_name":"Test"}' | jq '.client_id' returns non-null QA scenarios: Happy: Valid registration returns client_id. Failure: Missing redirect_uris returns 400. Invalid redirect URI returns 400. HTTP redirect URI (non-localhost) returns 400. Commit: Y | feat(oauth): add dynamic client registration endpoint

  • [x] 8. Create /oauth/authorize authorization endpoint What to do / Must NOT do: Create src/routes/oauth/authorize/+server.js handling GET. Validate response_type=code, client_id, redirect_uri (exact match against registered), scope (must be subset of registered), code_challenge (S256 only, required), code_challenge_method (must be "S256"). Store auth code in Redis with TTL (10 minutes) keyed by code. Value includes: client_id, redirect_uri, user_id, scope, code_challenge, expires_at. Redirect to redirect_uri with ?code=...&state=...&iss=... (RFC 9207). Must NOT support response_type=token. Must NOT accept missing code_challenge. Parallelization: Wave 3 | Blocked by: 5,6,7 | Blocks: 9 References: draft-ietf-oauth-v2-1-15 Section 3.1, RFC 7636, src/lib/oauth/pkce.js, src/lib/redis.js Acceptance criteria: curl -s "http://localhost:3000/oauth/authorize?response_type=code&client_id=TEST&redirect_uri=https://example.com/callback&scope=user&code_challenge=abc123&code_challenge_method=S256&state=xyz" -w "%{http_code}" returns 302 QA scenarios: Happy: Valid request redirects with code. Failure: Missing PKCE returns 400. Invalid redirect_uri returns 400. Unsupported response_type returns 400. Expired client_id returns 400. Commit: Y | feat(oauth): add authorization endpoint with mandatory PKCE

  • [x] 9. Create /oauth/token token endpoint What to do / Must NOT do: Create src/routes/oauth/token/+server.js handling POST. Support grant_type=authorization_code and refresh_token. For authorization_code: validate code (from Redis), client auth, redirect_uri (required if in auth request), verify PKCE code_verifier against stored code_challenge. Issue opaque access_token and refresh_token, store in Redis with TTL. Return JSON with access_token, token_type, expires_in, refresh_token, scope. Must NOT support password grant. Must NOT support implicit. Must NOT issue tokens for invalid PKCE. Parallelization: Wave 3 | Blocked by: 6,7,8 | Blocks: 10 References: draft-ietf-oauth-v2-1-15 Section 3.2, src/lib/oauth/tokens.js, src/lib/redis.js, src/lib/oauth/pkce.js Acceptance criteria: curl -s -X POST http://localhost:3000/oauth/token -d "grant_type=authorization_code&code=VALID_CODE&redirect_uri=https://example.com/callback&client_id=TEST&code_verifier=VERIFIER" | jq '.access_token' returns non-null QA scenarios: Happy: Valid code exchange returns tokens. Failure: Invalid code returns 400. Missing code_verifier returns 400. Wrong redirect_uri returns 400. Expired code returns 400. Invalid client auth returns 401. Commit: Y | feat(oauth): add token endpoint with PKCE verification

  • [x] 10. Create /oauth/introspect and /oauth/revoke endpoints What to do / Must NOT do: Create src/routes/oauth/introspect/+server.js (POST) and src/routes/oauth/revoke/+server.js (POST). Introspection: authenticate client, look up token in Redis, return {active: true/false, ...} with token metadata if active. Revocation: authenticate client, delete token from Redis, return 200. Both must use client auth methods (client_secret_basic, client_secret_post). Must NOT accept bearer-only auth on introspection. Parallelization: Wave 3 | Blocked by: 6,7,9 | Blocks: — References: RFC 7662, RFC 7009, src/lib/redis.js Acceptance criteria: curl -s -X POST http://localhost:3000/oauth/introspect -u "CLIENT_ID:SECRET" -d "token=VALID_TOKEN" | jq '.active' returns true/false QA scenarios: Happy: Valid token introspection returns active=true with metadata. Valid revocation deletes token. Failure: Missing client auth returns 401. Invalid token returns active=false. Revoked token introspection returns active=false. Commit: Y | feat(oauth): add token introspection and revocation endpoints

  • [x] 11. Create /oauth/par Pushed Authorization Request endpoint What to do / Must NOT do: Create src/routes/oauth/par/+server.js handling POST. Accepts same parameters as authorize endpoint. Stores request in Redis with TTL (short-lived, e.g., 60s). Returns request_uri (urn:ietf:params:oauth:request_uri:...). Authorize endpoint must accept request_uri parameter and lookup stored request. Must set require_pushed_authorization_requests: true in discovery metadata. Parallelization: Wave 4 | Blocked by: 6,8 | Blocks: 12 References: RFC 9126, src/lib/redis.js, src/routes/oauth/authorize/+server.js Acceptance criteria: curl -s -X POST http://localhost:3000/oauth/par -d "client_id=TEST&response_type=code&redirect_uri=https://example.com/callback&scope=user&code_challenge=abc123&code_challenge_method=S256" | jq '.request_uri' returns non-null QA scenarios: Happy: PAR request returns request_uri. Authorize with request_uri redirects with code. Failure: Invalid client_id returns 400. Expired request_uri returns invalid_request. Commit: Y | feat(oauth): add PAR endpoint

  • [x] 12. Create /oauth/login and /oauth/consent UI pages What to do / Must NOT do: Create src/routes/oauth/login/+page.svelte and src/routes/oauth/consent/+page.svelte. Login: email/password form, validate against SQLite3 users table using existing Argon2id. Consent: display client name, requested scopes, allow/deny buttons. On allow, redirect to authorize endpoint with user context. Use Svelte 5 runes. Must NOT use TypeScript. Must use Flowbite components where appropriate. Parallelization: Wave 4 | Blocked by: 1 | Blocks: — References: src/routes/+page.svelte:1-39, src/lib/crypto.js:1-13, src/lib/sqlite3/database.js:1-91 Acceptance criteria: curl -s http://localhost:3000/oauth/login returns HTML with login form. curl -s http://localhost:3000/oauth/consent returns HTML with consent UI. QA scenarios: Happy: Valid credentials log in. Consent allow redirects to authorize. Consent deny returns access_denied. Failure: Invalid credentials show error. Missing session redirects to login. Commit: Y | feat(ui): add OAuth login and consent pages

  • [ ] 13. Move .omo/ulw-research/ artifacts to docs/ What to do / Must NOT do: Copy or move files from .omo/ulw-research/ to docs/oauth-research/. Must NOT delete originals (they are under .omo/ which may be gitignored). Must ensure docs are readable. Parallelization: Wave 5 | Blocked by: — | Blocks: — References: .omo/ulw-research/ Acceptance criteria: ls docs/oauth-research/ shows files from .omo/ulw-research/ QA scenarios: Happy: Files present in docs. Failure: Missing files or broken paths. Commit: Y | docs: move OAuth research to docs directory

  • [ ] 14. Final integration QA and security verification What to do / Must NOT do: Run dev server. Execute full OAuth 2.1 flow: register client -> PAR -> authorize (with login/consent) -> token exchange -> introspect -> revoke. Verify discovery metadata matches implementation. Verify no unsupported grants work. Verify no plain PKCE accepted. Verify tokens are opaque (not JWT). Must NOT skip negative testing. Parallelization: Wave 5 | Blocked by: all previous | Blocks: — References: All endpoint files, src/routes/.well-known/oauth-authorization-server/+server.js Acceptance criteria: Full flow completes successfully from register to revoke. QA scenarios: Happy: Complete end-to-end OAuth 2.1 flow. Failure: Unsupported grant returns 400. Plain PKCE rejected. Missing PKCE rejected. Invalid redirect URI rejected. Token stored in Redis (not sqlite3). Commit: Y | test(qa): verify full OAuth 2.1 integration

Final verification wave

Runs in parallel after ALL todos. ALL must APPROVE.

  • F1. Plan compliance audit — Verify every must-have is implemented and every must-not-have is absent
  • F2. Code quality review — Verify Svelte 5 runes, consistent patterns, no hardcoded secrets
  • F3. Real manual QA — Execute full OAuth 2.1 flow end-to-end with curl/httpie
  • F4. Scope fidelity — Confirm no scope creep, no JWTs, no implicit grant, no sqlite3 token storage

Commit strategy

  • One commit per todo (atomic commits)
  • Prefix: feat(config):, feat(redis):, feat(db):, feat(oauth):, feat(ui):, docs:, test(qa):
  • Clear, descriptive messages referencing the component

Success criteria

  1. /.well-known/oauth-authorization-server returns valid RFC 8414 metadata with scopes_supported from DB and ui_locales_supported: ["en-US", "es-MX"]
  2. /oauth/authorize enforces PKCE S256 and exact redirect URI matching
  3. /oauth/token issues opaque tokens stored in Redis with TTL
  4. /oauth/introspect and /oauth/revoke require client authentication
  5. /oauth/par accepts pushed authorization requests and returns request_uri
  6. /oauth/register creates clients in SQLite3 with valid redirect URIs
  7. Login and consent pages render and handle auth flow correctly
  8. No auth codes, access tokens, or refresh tokens exist in SQLite3
  9. Redis is the sole store for all expiring OAuth data
  10. Full end-to-end flow executes successfully from registration to token revocation