Skip to content

Latest commit

 

History

History
292 lines (213 loc) · 9.38 KB

File metadata and controls

292 lines (213 loc) · 9.38 KB

Authentication Flow Diagrams

Sequence diagrams for every authentication flow in Birdcage. Each diagram maps directly to source code in the root package.

Rate limiting: Fixed-window middleware gates public auth routes before any business logic runs. Requests exceeding the threshold receive 429 Too Many Requests with a Retry-After header.

Rendering: GitHub renders Mermaid natively. For local preview, use the Mermaid Live Editor or a VS Code extension.


1. Registration

Owner creates the single account. Requires a registration token generated by birdcage init.

sequenceDiagram
  participant U as Browser
  participant RL as Rate Limiter
  participant H as Handler
  participant PS as Password
  participant DB as SQLite

  U->>RL: POST /auth/register {email, password, registrationToken}
  Note over RL: rl:register 5/5min — IP-keyed

  alt Rate limit exceeded
    RL-->>U: 429 {error: "Too many requests"}
  end

  RL->>H: handleRegister()
  H->>H: subtle.ConstantTimeCompare(token, cfg.RegistrationToken)

  alt Invalid token
    H-->>U: 403 {code: "INVALID_TOKEN"}
  end

  H->>H: validEmail() + validPassword()
  H->>PS: hashPassword(password)
  Note over PS: 1. Generate 16-byte random salt<br/>2. PBKDF2-SHA384 (210K iterations)<br/>3. SHA-384 integrity digest<br/>4. Format: $pbkdf2-sha384$v1$210000$salt$hash$digest

  PS-->>H: password_data string
  H->>DB: INSERT INTO account ... WHERE (SELECT COUNT(*) FROM account) = 0

  alt Account already exists
    H-->>U: 201 {success: true}
    Note over H: Identical response — prevents enumeration
  end

  H->>DB: emitEvent("registration.success", ...)
  H-->>U: 201 {success: true}
Loading

Source: auth.go:11-51 | crypto.go:29-43 | respond.go:18-26


2. Login

Full authentication flow from credential verification through token issuance. Includes adaptive proof-of-work when brute-force is detected.

sequenceDiagram
  participant U as Browser
  participant RL as Rate Limiter
  participant H as Handler
  participant PoW as PoW Engine
  participant PS as Password
  participant SS as Session
  participant DB as SQLite

  U->>RL: POST /auth/login {email, password}
  Note over RL: rl:login 5/5min — IP-keyed

  alt Rate limit exceeded
    RL-->>U: 429 {error: "Too many requests"}
  end

  RL->>H: handleLogin()
  H->>PoW: computeChallenge(ip, secret)
  PoW->>DB: SELECT COUNT(*) FROM security_event WHERE type IN (...) AND ip = ?

  alt 3+ failures in 15 min
    PoW-->>H: Challenge {nonce, difficulty: 3-5}
    H-->>U: 403 {code: "CHALLENGE_REQUIRED", challenge: {...}}
    Note over U: Browser solves SHA-256 PoW
    U->>H: POST /auth/login {email, password, challengeNonce, challengeSolution}
    H->>PoW: verifySignedNonce() + verifySolution()
    Note over PoW: HMAC-signed nonce (5-min TTL, IP-bound)<br/>SHA-256 prefix match at difficulty level
  end

  H->>DB: SELECT id, password_data FROM account WHERE email = ?

  alt User not found
    H->>PS: rejectConstantTime(password)
    Note over PS: Full PBKDF2 against dummy hash<br/>— equalizes response time
    H->>DB: emitEvent("login.failure", ...)
    H-->>U: 401 {error: "Invalid email or password"}
  end

  H->>PS: verifyPassword(password, stored)
  Note over PS: 1. PBKDF2 with stored salt<br/>2. hmac.Equal(derived, storedHash)<br/>3. hmac.Equal(derivedDigest, storedDigest)

  alt Password incorrect
    H->>DB: emitEvent("login.failure", ...)
    H-->>U: 401 {error: "Invalid email or password"}
  end

  H->>SS: createSession(userId, userAgent, ip)
  SS->>DB: DELETE expired sessions
  SS->>SS: nanoid() — 21 chars, ~126 bits entropy
  SS->>DB: INSERT INTO session (..., refresh_gen=0)
  SS->>SS: enforceSessionLimit() — max 3 per user
  SS-->>H: sessionId

  H->>H: signToken(access) + signRefreshToken(refresh, gen=0)
  Note over H: Access: {uid, sid, typ:"access", exp:+15min}<br/>Refresh: {uid, sid, typ:"refresh", gen:0, exp:+7d}<br/>Signed with separate secrets (HS256)

  H->>U: Set-Cookie: access_token (HttpOnly, Secure, SameSite=Strict)
  H->>U: Set-Cookie: refresh_token (HttpOnly, Secure, SameSite=Strict)
  H->>DB: emitEvent("login.success", ...)
  H-->>U: 200 {success: true}
Loading

Source: auth.go:54-102 | events.go:89-161 | session.go:25-71 | crypto.go:46-70


3. Normal API Request

Accessing a protected endpoint with a valid access token.

sequenceDiagram
  participant U as Browser
  participant MW as Auth Middleware
  participant SS as Session
  participant DB as SQLite
  participant RH as Route Handler

  U->>MW: GET /account/me (Cookie: access_token=...)
  MW->>MW: tryAccessToken() — read cookie

  MW->>MW: verifyToken(token, JWT_ACCESS_SECRET, "access")
  Note over MW: Explicit HS256 check<br/>Validates typ == "access"

  MW->>SS: getSession(claims.SID)
  SS->>DB: SELECT ... FROM session WHERE id = ? AND expires_at > now
  SS->>DB: UPDATE session SET expires_at = now + 7d
  Note over SS,DB: Sliding expiration — extends on each use
  SS-->>MW: session

  alt Session revoked or expired
    MW-->>U: 403 {code: "SESSION_REVOKED"}
  end

  MW->>RH: next() with claims in context
  RH-->>U: 200 {userId, email, gateway}
Loading

Source: middleware.go:42-60 | session.go:73-95


4. Token Refresh with Reuse Detection

When the access token expires, the middleware transparently refreshes both tokens. A generation counter detects stolen token replay.

sequenceDiagram
  participant U as Browser
  participant MW as Auth Middleware
  participant SS as Session
  participant DB as SQLite

  U->>MW: GET /account/me [Cookie: access_token(expired) + refresh_token]

  MW->>MW: tryAccessToken() — fails (expired)
  MW->>MW: tryRefreshAndRotate()

  MW->>MW: verifyToken(refreshToken, JWT_REFRESH_SECRET, "refresh")
  Note over MW: Validates typ == "refresh"<br/>Extracts Gen from claims

  MW->>SS: getSession(claims.SID)
  SS->>DB: SELECT ... refresh_gen FROM session WHERE id = ? AND expires_at > now
  SS->>DB: UPDATE session SET expires_at = now + 7d
  SS-->>MW: session (with RefreshGen)

  alt Session revoked or expired
    MW-->>U: 403 {code: "SESSION_REVOKED"}
  end

  alt claims.Gen != session.RefreshGen
    MW->>SS: endSession(sid)
    MW->>DB: emitEvent("session.refresh_reuse", ...)
    Note over MW: Stolen token detected —<br/>entire session revoked
    MW-->>U: 403 {code: "SESSION_REVOKED"}
  end

  MW->>SS: bumpRefreshGen(sid)
  SS->>DB: UPDATE session SET refresh_gen = refresh_gen + 1
  SS-->>MW: newGen

  MW->>MW: signToken(access, gen=0) + signRefreshToken(refresh, gen=newGen)

  MW->>U: Set-Cookie: access_token (new)
  MW->>U: Set-Cookie: refresh_token (new, gen=newGen)

  MW->>MW: Verify new access token, continue to handler
  MW-->>U: 200 (original request succeeds)
Loading

Source: middleware.go:70-109 | crypto.go:107-117 | session.go:109-118


5. Logout

Ends the server-side session and clears both auth cookies.

sequenceDiagram
  participant U as Browser
  participant MW as Auth Middleware
  participant H as Handler
  participant SS as Session
  participant DB as SQLite

  U->>MW: POST /auth/logout [Cookie: access_token + refresh_token]
  MW-->>H: Authenticated (claims in context)

  H->>SS: endSession(claims.SID)
  SS->>DB: UPDATE session SET expires_at = datetime('now') WHERE id = ?
  Note over SS,DB: Session expires immediately<br/>but row kept for audit trail

  H->>U: Set-Cookie: access_token="" (delete)
  H->>U: Set-Cookie: refresh_token="" (delete)
  H->>DB: emitEvent("session.revoke", ...)

  H-->>U: 200 {success: true}
Loading

Source: auth.go:105-111 | session.go:97-101


6. Password Change

Requires re-verification of the current password even though the user is authenticated. All sessions are revoked afterward, forcing re-authentication on every device.

sequenceDiagram
  participant U as Browser
  participant RL as Rate Limiter
  participant MW as Auth Middleware
  participant H as Handler
  participant PS as Password
  participant SS as Session
  participant DB as SQLite

  U->>RL: POST /account/password {currentPassword, newPassword}
  Note over RL: rl:password 3/1hr — user-keyed
  RL->>MW: requireAuth validates token + session
  MW-->>H: Authenticated (claims in context)

  H->>H: validPassword(current) + validPassword(new)
  H->>H: Reject if current == new (after normalization)

  H->>DB: SELECT password_data FROM account WHERE id = ?
  H->>PS: verifyPassword(currentPassword, stored)
  Note over PS: PBKDF2 + hmac.Equal (constant-time)

  alt Current password incorrect
    H-->>U: 401 {error: "Current password is incorrect"}
  end

  H->>PS: hashPassword(newPassword)
  Note over PS: Full PBKDF2 hash with fresh salt
  H->>DB: UPDATE account SET password_data = ? WHERE id = ?

  H->>SS: endAllSessions(userId)
  SS->>DB: UPDATE session SET expires_at = datetime('now') WHERE user_id = ?
  Note over SS,DB: All sessions expired atomically —<br/>including the current one

  H->>U: Set-Cookie: access_token="" (delete)
  H->>U: Set-Cookie: refresh_token="" (delete)
  H->>DB: emitEvent("password.change", ...)
  H->>DB: emitEvent("session.revoke_all", ...)

  H-->>U: 200 {success: true}
Loading

Source: auth.go:114-153 | session.go:103-107 | crypto.go:29-43