init prds
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# PRD: Auth Gateway
|
||||
|
||||
**Module:** `auth-gw/` | **Namespace:** `ajet.chat.auth-gw.*`
|
||||
**Status:** v1 | **Last updated:** 2026-02-17
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The Auth Gateway is the single edge entry point for all client traffic. It terminates sessions, validates tokens, and reverse-proxies authenticated requests to internal services (API, Web SM, TUI SM). It also handles OAuth login flows and session management.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
Client → (nginx TLS, prod) → Auth Gateway → API Service
|
||||
→ Web Session Manager
|
||||
→ TUI Session Manager
|
||||
```
|
||||
|
||||
**Auth GW has direct PG access** for session/token table lookups — this avoids a round-trip to the API for every request.
|
||||
|
||||
## 3. Route Table
|
||||
|
||||
| Path Pattern | Target | Auth Required | Description |
|
||||
|--------------|--------|---------------|-------------|
|
||||
| `GET /` | Web SM | Session | Web app root |
|
||||
| `GET /app/*` | Web SM | Session | Web app pages |
|
||||
| `GET /sse/*` | Web SM | Session | SSE streams for web |
|
||||
| `POST /web/*` | Web SM | Session | Web form submissions / Datastar signals |
|
||||
| `GET,POST /api/*` | API | Session or API Token | REST API |
|
||||
| `GET /tui/sse/*` | TUI SM | Session | SSE streams for TUI clients |
|
||||
| `POST /tui/*` | TUI SM | Session | TUI client signals |
|
||||
| `POST /api/webhooks/*/incoming` | API | Webhook Token | Incoming webhooks (bypass session auth) |
|
||||
| `GET /auth/login` | Self | None | Login page |
|
||||
| `GET /auth/callback/:provider` | Self | None | OAuth callback |
|
||||
| `POST /auth/logout` | Self | Session | Logout (destroy session) |
|
||||
| `GET /invite/:code` | Self | None | Invite landing page → redirect to login if needed |
|
||||
| `GET /health` | Self | None | Health check |
|
||||
|
||||
## 4. Authentication Flows
|
||||
|
||||
### 4.1 Session Token Validation
|
||||
|
||||
Every authenticated request follows this flow:
|
||||
|
||||
```
|
||||
1. Extract token from Cookie: ajet_session=<base64url-token>
|
||||
2. bcrypt-verify token against sessions.token_hash
|
||||
3. Check sessions.expires_at > now
|
||||
4. If valid:
|
||||
a. Extend session TTL (rolling expiry) — async, don't block request
|
||||
b. Inject headers: X-User-Id, X-User-Role, X-Community-Id, X-Trace-Id
|
||||
c. Proxy to target service
|
||||
5. If invalid/expired: redirect to /auth/login (web) or 401 (API/TUI)
|
||||
```
|
||||
|
||||
**Rolling expiry:** Each valid request extends `expires_at` by the session TTL (default: 30 days). This is done asynchronously to avoid adding latency.
|
||||
|
||||
**Token format:** 32 random bytes, base64url-encoded (43 characters). Stored as bcrypt hash.
|
||||
|
||||
### 4.2 API Token Validation
|
||||
|
||||
For `Authorization: Bearer <token>` requests to `/api/*`:
|
||||
|
||||
```
|
||||
1. Extract token from Authorization header
|
||||
2. bcrypt-verify against api_tokens.token_hash
|
||||
3. Check api_tokens.expires_at > now (if set)
|
||||
4. Check scopes allow the requested operation
|
||||
5. If valid: inject X-User-Id (api_user's owner), X-Trace-Id
|
||||
6. If invalid: 401
|
||||
```
|
||||
|
||||
### 4.3 OAuth Login Flow
|
||||
|
||||
**Supported providers:** GitHub, Gitea, Generic OIDC
|
||||
|
||||
```
|
||||
1. User visits /auth/login
|
||||
2. Page shows provider buttons (GitHub, Gitea, or configured OIDC)
|
||||
3. User clicks provider → redirect to provider's authorize URL
|
||||
4. Provider redirects to /auth/callback/:provider with code
|
||||
5. Auth GW exchanges code for access token
|
||||
6. Auth GW fetches user profile from provider
|
||||
7. Look up oauth_accounts by (provider, provider_user_id):
|
||||
a. EXISTS: load user, create session
|
||||
b. NOT EXISTS: create user + oauth_account, create session
|
||||
8. Set session cookie, redirect to / (or to pending invite if present)
|
||||
```
|
||||
|
||||
**OAuth config shape:**
|
||||
```clojure
|
||||
{:oauth
|
||||
{:github {:client-id "..." :client-secret "..." :enabled true}
|
||||
:gitea {:client-id "..." :client-secret "..." :base-url "https://gitea.example.com" :enabled true}
|
||||
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}}
|
||||
```
|
||||
|
||||
**Generic OIDC:** Uses `.well-known/openid-configuration` discovery. Requires `openid`, `profile`, `email` scopes.
|
||||
|
||||
### 4.4 First-User Bootstrap
|
||||
|
||||
```
|
||||
1. User hits /auth/login
|
||||
2. Auth GW checks: any users in DB?
|
||||
- No users: show "Create your community" flow after OAuth
|
||||
- Has users: normal login
|
||||
3. After first OAuth login:
|
||||
a. Create user from OAuth profile
|
||||
b. Redirect to /setup (community creation wizard on Web SM)
|
||||
c. Web SM shows: community name input, slug auto-generated
|
||||
d. POST creates community (user becomes owner, #general created)
|
||||
e. Redirect to /app
|
||||
```
|
||||
|
||||
### 4.5 Invite Flow
|
||||
|
||||
```
|
||||
1. User visits /invite/:code
|
||||
2. Auth GW checks invite validity (exists, not expired, not exhausted)
|
||||
- Invalid: show error page
|
||||
- Valid: store invite code in cookie/session, redirect to /auth/login
|
||||
3. After OAuth login, if pending invite code:
|
||||
a. Accept invite (join community)
|
||||
b. Redirect to community
|
||||
```
|
||||
|
||||
## 5. Reverse Proxy Behavior
|
||||
|
||||
**Request forwarding:**
|
||||
- Strip auth headers from original request
|
||||
- Inject: `X-User-Id`, `X-User-Role`, `X-Community-Id`, `X-Trace-Id`, `X-Forwarded-For`
|
||||
- Forward request body, method, path, query string unchanged
|
||||
- SSE: hold connection open, stream response bytes through
|
||||
|
||||
**Response forwarding:**
|
||||
- Pass through status code, headers, body unchanged
|
||||
- For SSE responses: stream chunks as they arrive (no buffering)
|
||||
|
||||
**Service discovery (v1):** Static config — all services on localhost with configured ports.
|
||||
```clojure
|
||||
{:services
|
||||
{:api {:host "localhost" :port 3001}
|
||||
:web-sm {:host "localhost" :port 3002}
|
||||
:tui-sm {:host "localhost" :port 3003}}}
|
||||
```
|
||||
|
||||
## 6. Rate Limiting
|
||||
|
||||
| Endpoint Pattern | Limit | Window |
|
||||
|-----------------|-------|--------|
|
||||
| `POST /auth/callback/*` | 10 | 1 min per IP |
|
||||
| `POST /api/*` | 60 | 1 min per user |
|
||||
| `GET /api/*` | 120 | 1 min per user |
|
||||
| `POST /api/webhooks/*/incoming` | 30 | 1 min per webhook |
|
||||
| `GET /sse/*`, `GET /tui/sse/*` | 5 | 1 min per user (connection attempts) |
|
||||
|
||||
**Implementation:** In-memory token bucket (atom-based). No Redis needed for v1 (single instance).
|
||||
|
||||
## 7. Session Cookie
|
||||
|
||||
```
|
||||
Name: ajet_session
|
||||
Value: <base64url-encoded-token>
|
||||
Attributes:
|
||||
HttpOnly: true
|
||||
Secure: true (prod only)
|
||||
SameSite: Lax
|
||||
Path: /
|
||||
Max-Age: 2592000 (30 days)
|
||||
```
|
||||
|
||||
## 8. Test Cases
|
||||
|
||||
### 8.1 Session Validation
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T1 | Valid session cookie | Request proxied with injected headers |
|
||||
| AUTH-T2 | Expired session | Returns 401 (API) or redirect to login (web) |
|
||||
| AUTH-T3 | Invalid/tampered token | Returns 401 |
|
||||
| AUTH-T4 | Missing cookie | Returns 401 (API) or redirect to login (web) |
|
||||
| AUTH-T5 | Session TTL extension | Valid request extends expires_at |
|
||||
| AUTH-T6 | Concurrent requests | Multiple requests with same session all succeed |
|
||||
|
||||
### 8.2 API Token Validation
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T7 | Valid API token | Request proxied with X-User-Id |
|
||||
| AUTH-T8 | Expired API token | Returns 401 |
|
||||
| AUTH-T9 | Invalid scope | Returns 403 (scope mismatch) |
|
||||
| AUTH-T10 | Bearer header format | Correctly parses `Bearer <token>` |
|
||||
|
||||
### 8.3 OAuth Flow
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T11 | GitHub OAuth success | Code exchanged, user created/found, session set, redirected |
|
||||
| AUTH-T12 | Gitea OAuth success | Same as above for Gitea |
|
||||
| AUTH-T13 | OIDC OAuth success | Uses discovery document, same flow |
|
||||
| AUTH-T14 | OAuth invalid code | Returns error, redirects to login with error message |
|
||||
| AUTH-T15 | OAuth provider down | Returns 502 with friendly error |
|
||||
| AUTH-T16 | Existing user re-login | Finds existing user via oauth_accounts, creates new session |
|
||||
| AUTH-T17 | New user first login | Creates user + oauth_account + session |
|
||||
| AUTH-T18 | OAuth state parameter | CSRF protection via state param validated on callback |
|
||||
|
||||
### 8.4 First-User Bootstrap
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T19 | Empty DB shows setup flow | /auth/login with 0 users shows setup message |
|
||||
| AUTH-T20 | First user becomes owner | After OAuth + community creation, user has owner role |
|
||||
| AUTH-T21 | Subsequent users see normal login | With users in DB, normal login page shown |
|
||||
|
||||
### 8.5 Invite Flow
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T22 | Valid invite → login → join | Full invite acceptance flow works |
|
||||
| AUTH-T23 | Expired invite | Shows error page |
|
||||
| AUTH-T24 | Exhausted invite | Shows error page |
|
||||
| AUTH-T25 | Already-member invite | Accepts gracefully, redirects to community |
|
||||
|
||||
### 8.6 Reverse Proxy
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T26 | API route proxied | /api/channels → forwarded to API service |
|
||||
| AUTH-T27 | Web route proxied | / → forwarded to Web SM |
|
||||
| AUTH-T28 | TUI route proxied | /tui/sse → forwarded to TUI SM |
|
||||
| AUTH-T29 | SSE streaming | SSE response streamed without buffering |
|
||||
| AUTH-T30 | Target service down | Returns 502 |
|
||||
| AUTH-T31 | Headers injected | X-User-Id, X-Trace-Id present on proxied request |
|
||||
| AUTH-T32 | Original auth headers stripped | Client cannot forge X-User-Id |
|
||||
|
||||
### 8.7 Rate Limiting
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T33 | Under limit | Requests succeed normally |
|
||||
| AUTH-T34 | Over limit | Returns 429 with Retry-After header |
|
||||
| AUTH-T35 | Rate limit per-user | Different users have independent limits |
|
||||
| AUTH-T36 | Rate limit per-IP for auth | OAuth callback rate limited by IP |
|
||||
|
||||
### 8.8 Logout
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T37 | Logout destroys session | POST /auth/logout deletes session from DB, clears cookie |
|
||||
| AUTH-T38 | Logout with invalid session | Returns 200 (idempotent), clears cookie |
|
||||
|
||||
### 8.9 Health Check
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T39 | Health check | GET /health returns 200 with service status |
|
||||
| AUTH-T40 | Health check (DB down) | Returns 503 with degraded status |
|
||||
|
||||
---
|
||||
|
||||
## 9. Login Page UI Mock (Hiccup rendered by Auth GW)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ ajet chat │ │
|
||||
│ └──────────────┘ │
|
||||
│ │
|
||||
│ Sign in to continue │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with GitHub │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with Gitea │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with SSO │ │ ← only if OIDC configured
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ ─── or accepting invite ─── │ ← only if invite code present
|
||||
│ Joining: My Team │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
Reference in New Issue
Block a user