diff --git a/README.md b/README.md index 5704a3f..97f648a 100644 --- a/README.md +++ b/README.md @@ -159,11 +159,13 @@ for multi-client access. - **Session creation**: client sends `POST /api/v1/session` with a desired nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing - a cryptographically random token (64 hex characters) and returns the user - ID and nick in the JSON response body. No token appears in the JSON body. + a cryptographically random value (64 hex characters) and returns the user + ID and nick in the JSON response body. No auth credential appears in the + JSON body. - The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS. - Clients never need to handle the token directly — the browser/HTTP client - manages cookies automatically. + Browsers handle cookies automatically. **CLI clients (curl, custom HTTP + clients) must explicitly save and send cookies** — e.g., using curl's + `-c`/`-b` flags or an HTTP cookie jar in their language's HTTP library. - Sessions start anonymous — no password required. When the session expires or the user QUITs, the nick is released. @@ -188,16 +190,17 @@ For users who want to access the same session from multiple devices: the stable identity. - Server-assigned IDs — clients do not choose their own IDs. - Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry - encoded in the token, no client-side decode. The server is the sole authority - on cookie validity. + encoded in the cookie value, no client-side decode. The server is the sole + authority on cookie validity. **Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Anonymous sessions preserve that simplicity — instant access, zero friction. But some users want to access the same session from multiple devices without a bouncer. The PASS command enables multi-client login without adding friction for casual users: if you don't need multi-client, just create a session and -go. Cookie-based auth eliminates token management from client code entirely — -browsers and HTTP cookie jars handle it automatically. Note: both anonymous +go. Cookie-based auth simplifies credential management — browsers handle +cookies automatically, and CLI clients just need a cookie jar (e.g., curl's +`-c`/`-b` flags). Note: both anonymous and password-protected sessions are deleted when the last client disconnects (QUIT or logout). Identity verification at the message layer via cryptographic signatures (see [Security Model](#security-model)) remains independent of @@ -265,9 +268,9 @@ A single user session can have multiple clients (phone, laptop, terminal). ``` User Session -├── Client A (token_a, queue_a) -├── Client B (token_b, queue_b) -└── Client C (token_c, queue_c) +├── Client A (cookie_a, queue_a) +├── Client B (cookie_b, queue_b) +└── Client C (cookie_c, queue_c) ``` **Multi-client via login:** The `POST /api/v1/login` endpoint adds a new @@ -395,8 +398,8 @@ Opaque auth cookies are simpler: - Revocation is a database delete (cookie becomes invalid immediately) - No clock skew issues, no algorithm confusion, no "none" algorithm attacks - Cookie format can change without breaking clients -- Clients never handle tokens directly — browsers and HTTP cookie jars - manage everything automatically +- Browsers and HTTP cookie jars manage cookies automatically; CLI clients + must explicitly save and resend cookies (e.g., curl `-c`/`-b` flags) --- @@ -426,12 +429,12 @@ The entire read/write loop for a client is two endpoints. Everything else ┌─ Client ──────────────────────────────────────────────────┐ │ │ │ 1. POST /api/v1/session {"nick":"alice"} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ +│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ │ → {"id":1, "nick":"alice"} │ │ │ │ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │ │ → {"status":"joined","channel":"#general"} │ -│ (Cookie sent automatically on all subsequent requests) │ +│ (Cookie must be sent on all subsequent requests) │ │ │ │ 3. POST /api/v1/messages {"command":"PRIVMSG", │ │ "to":"#general","body":["hello"]} │ @@ -459,7 +462,7 @@ The entire read/write loop for a client is two endpoints. Everything else ┌─ Client A ────────────────────────────────────────────────┐ │ │ │ 1. POST /api/v1/session {"nick":"alice"} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ +│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ │ → {"id":1, "nick":"alice"} │ │ │ │ 2. POST /api/v1/messages │ @@ -475,7 +478,7 @@ The entire read/write loop for a client is two endpoints. Everything else │ │ │ 3. POST /api/v1/login │ │ {"nick":"alice", "password":"s3cret!!"} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ +│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ │ → {"id":1, "nick":"alice"} │ │ (New client added to existing session — channels │ │ and message queues are preserved.) │ @@ -862,7 +865,7 @@ Destroy the session and disconnect from the server. - Empty channels are deleted (ephemeral). - The user's session is destroyed — the auth cookie is invalidated, the nick is released. -- Subsequent requests with the old token return HTTP 401. +- Subsequent requests with the old auth cookie return HTTP 401. **Response:** `200 OK` ```json @@ -1226,8 +1229,8 @@ the hostmask used in WHOIS, WHO, and future ban matching (`+b`). **Response:** `201 Created` -The response sets an `neoirc_auth` HttpOnly cookie containing the auth token. -The JSON body does **not** include the token. +The response sets an `neoirc_auth` HttpOnly cookie containing an opaque auth +value. The JSON body does **not** include the auth credential. ``` Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict @@ -1914,8 +1917,8 @@ authenticity. ### Authentication - **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of - entropy). Tokens are hashed (SHA-256) before storage and validated on every - request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict + entropy). Cookie values are hashed (SHA-256) before storage and validated on + every request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict (CSRF protection), and Secure when behind TLS. - **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No password, instant access. The auth cookie is the sole credential. @@ -2093,16 +2096,16 @@ The database schema is managed via embedded SQL migration files in Index on `(uuid)`. #### `clients` -| Column | Type | Description | -|-------------|----------|-------------| -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique client UUID | -| `session_id`| INTEGER | FK → sessions.id (cascade delete) | -| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) | -| `ip` | TEXT | Real IP address of this client connection | -| `hostname` | TEXT | Reverse DNS hostname of this client connection | -| `created_at`| DATETIME | Client creation time | -| `last_seen` | DATETIME | Last API request time | +| Column | Type | Description | +|--------------|----------|-------------| +| `id` | INTEGER | Primary key (auto-increment) | +| `uuid` | TEXT | Unique client UUID | +| `session_id` | INTEGER | FK → sessions.id (cascade delete) | +| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) | +| `ip` | TEXT | Real IP address of this client connection | +| `hostname` | TEXT | Reverse DNS hostname of this client connection | +| `created_at` | DATETIME | Client creation time | +| `last_seen` | DATETIME | Last API request time | Indexes on `(token)` and `(session_id)`. @@ -2171,7 +2174,7 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison). after `SESSION_IDLE_TIMEOUT` (default 30 days) — the server runs a background cleanup loop that parts idle users from all channels, broadcasts QUIT, and releases their nicks. -- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`. +- **Clients**: Individual client auth cookies are invalidated on `POST /api/v1/logout`. A session can have multiple clients; removing one doesn't affect others. However, when the last client is removed (via logout), the entire session is deleted — the user is parted from all channels, QUIT is broadcast, and @@ -2317,7 +2320,7 @@ with an HTTP client library. A complete client needs only four HTTP calls: ``` -1. POST /api/v1/session → get token +1. POST /api/v1/session → get auth cookie 2. POST /api/v1/messages (JOIN) → join channels 3. GET /api/v1/messages (loop) → receive messages 4. POST /api/v1/messages → send messages diff --git a/internal/handlers/api.go b/internal/handlers/api.go index abb1718..93e4194 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -335,7 +335,7 @@ func (hdlr *Handlers) executeCreateSession( hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, - "nick": payload.Nick, + "nick": nick, }, http.StatusCreated) } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 3221591..478cee6 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -2230,7 +2230,7 @@ func TestWhoisShowsHostInfo(t *testing.T) { } // createSessionWithUsername creates a session with a -// specific username and returns the token. +// specific username and returns the auth cookie value. func (tserver *testServer) createSessionWithUsername( nick, username string, ) string { @@ -2264,13 +2264,19 @@ func (tserver *testServer) createSessionWithUsername( ) } - var result struct { - Token string `json:"token"` + // Drain the body. + _, _ = io.ReadAll(resp.Body) + + // Extract auth cookie from response. + for _, cookie := range resp.Cookies() { + if cookie.Name == authCookieName { + return cookie.Value + } } - _ = json.NewDecoder(resp.Body).Decode(&result) + tserver.t.Fatal("no auth cookie in response") - return result.Token + return "" } func TestWhoShowsHostInfo(t *testing.T) { diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 0a4bc59..f25ca5d 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -10,7 +10,6 @@ import ( const minPasswordLength = 8 - // HandleLogin authenticates a user with nick and password. func (hdlr *Handlers) HandleLogin() http.HandlerFunc { return func(