refactor: replace Bearer token auth with HttpOnly cookies
All checks were successful
check / check (push) Successful in 2m21s
All checks were successful
check / check (push) Successful in 2m21s
- Remove POST /api/v1/register endpoint entirely - Session creation (POST /api/v1/session) now sets neoirc_auth HttpOnly cookie instead of returning token in JSON body - Login (POST /api/v1/login) now sets neoirc_auth HttpOnly cookie instead of returning token in JSON body - Add PASS IRC command for setting session password (enables multi-client login via POST /api/v1/login) - All per-request auth reads from neoirc_auth cookie instead of Authorization: Bearer header - Cookie properties: HttpOnly, SameSite=Strict, Secure when behind TLS - Logout and QUIT clear the auth cookie - Update CORS to AllowCredentials:true with origin reflection - Remove Authorization from CORS AllowedHeaders - Update CLI client to use cookie jar (net/http/cookiejar) - Remove Token field from SessionResponse - Add SetPassword to DB layer, remove RegisterUser - Comprehensive test updates for cookie-based auth - Add tests: TestPassCommand, TestPassCommandShortPassword, TestPassCommandEmpty, TestSessionCookie - Update README extensively: auth model, API reference, curl examples, security model, design principles, roadmap closes #83
This commit is contained in:
454
README.md
454
README.md
@@ -115,7 +115,7 @@ Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
|
||||
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
||||
are unique per server. Identity starts with a key — a nick is a display name.
|
||||
Accounts are optional: you can create an anonymous session instantly, or
|
||||
register with a password for multi-client access to a single session.
|
||||
set a password via the PASS command for multi-client access to a single session.
|
||||
|
||||
### On the resemblance to JSON-RPC
|
||||
|
||||
@@ -149,63 +149,59 @@ not arbitrary choices — each one follows from the project's core thesis that
|
||||
IRC's command model is correct and only the transport and session management
|
||||
need to change.
|
||||
|
||||
### Identity & Sessions — Dual Authentication Model
|
||||
### Identity & Sessions — Cookie-Based Authentication
|
||||
|
||||
The server supports two authentication paths: **anonymous sessions** for
|
||||
instant access, and **optional account registration** for multi-client access.
|
||||
The server uses **HTTP cookies** for all authentication. There is no separate
|
||||
registration step — sessions start anonymous and can optionally set a password
|
||||
for multi-client access.
|
||||
|
||||
#### Anonymous Sessions (No Account Required)
|
||||
|
||||
The simplest entry point. No registration, no passwords.
|
||||
#### Session Creation
|
||||
|
||||
- **Session creation**: client sends `POST /api/v1/session` with a desired
|
||||
nick → server assigns an **auth token** (64 hex characters of
|
||||
cryptographically random bytes) and returns the user ID, nick, and token.
|
||||
- The auth token implicitly identifies the client. Clients present it via
|
||||
`Authorization: Bearer <token>`.
|
||||
- Anonymous sessions are ephemeral — when the session expires or the user
|
||||
QUITs, the nick is released and there is no way to reclaim it.
|
||||
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.
|
||||
- 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.
|
||||
- Sessions start anonymous — no password required. When the session expires
|
||||
or the user QUITs, the nick is released.
|
||||
|
||||
#### Registered Accounts (Optional)
|
||||
#### Setting a Password (Optional, for Multi-Client Access)
|
||||
|
||||
For users who want multi-client access (multiple devices sharing one session):
|
||||
For users who want to access the same session from multiple devices:
|
||||
|
||||
- **Registration**: client sends `POST /api/v1/register` with a nick and
|
||||
password (minimum 8 characters) → server creates a session with the
|
||||
password hashed via bcrypt, and returns the user ID, nick, and auth token.
|
||||
- **Login**: client sends `POST /api/v1/login` with nick and password →
|
||||
server verifies the password against the stored bcrypt hash and creates a
|
||||
new client token for the existing session. This enables multi-client
|
||||
access: logging in from a new device adds a client to the existing session
|
||||
rather than creating a new one, so channel memberships and message queues
|
||||
are shared. Note: login only works while the session still exists — if all
|
||||
clients have logged out or the user has sent QUIT, the session is deleted
|
||||
and the registration is lost.
|
||||
- Registered accounts cannot be logged into via `POST /api/v1/session` —
|
||||
that endpoint is for anonymous sessions only.
|
||||
- Anonymous sessions (created via `/session`) cannot be logged into via
|
||||
`/login` because they have no password set.
|
||||
- **Set password via IRC PASS command**: the authenticated client sends
|
||||
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`.
|
||||
The server hashes the password with bcrypt and stores it on the session.
|
||||
Password must be at least 8 characters.
|
||||
- **Login from another client**: `POST /api/v1/login` with nick and password →
|
||||
server verifies the password, creates a new client for the existing session,
|
||||
and sets an auth cookie. Channel memberships and message queues are shared.
|
||||
Login only works while the session still exists — if all clients have logged
|
||||
out or the user has sent QUIT, the session is deleted and the password is
|
||||
lost.
|
||||
|
||||
#### Common Properties (Both Paths)
|
||||
#### Common Properties
|
||||
|
||||
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
|
||||
the stable identity.
|
||||
- Server-assigned IDs — clients do not choose their own IDs.
|
||||
- Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded
|
||||
in the token, no client-side decode. The server is the sole authority on
|
||||
token validity.
|
||||
- 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.
|
||||
|
||||
**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. Optional registration with password enables multi-client login
|
||||
without adding friction for casual users: if you don't want an account,
|
||||
don't create one. Note: in the current implementation, both anonymous and
|
||||
registered sessions are deleted when the last client disconnects (QUIT or
|
||||
logout); registration does not make a session survive all-client
|
||||
removal. Identity verification at the message layer via cryptographic
|
||||
signatures (see [Security Model](#security-model)) remains independent
|
||||
of account registration.
|
||||
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
|
||||
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
|
||||
password status.
|
||||
|
||||
### Nick Semantics
|
||||
|
||||
@@ -232,7 +228,7 @@ A single user session can have multiple clients (phone, laptop, terminal).
|
||||
- The server fans out all S2C messages to every active client queue for that
|
||||
user session.
|
||||
- `GET /api/v1/messages` delivers from the calling client's specific queue,
|
||||
identified by the auth token.
|
||||
identified by the auth cookie.
|
||||
- Client queues have **independent expiry/pruning** — one client going offline
|
||||
doesn't affect others.
|
||||
|
||||
@@ -244,11 +240,10 @@ User Session
|
||||
```
|
||||
|
||||
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
|
||||
client to an existing registered session, enabling true multi-client support
|
||||
(multiple tokens sharing one nick/session with independent message queues).
|
||||
Anonymous sessions created via `POST /api/v1/session` always create a new
|
||||
user with a new nick. A future endpoint to "add a client to an existing
|
||||
anonymous session" is planned but not yet implemented.
|
||||
client to an existing session (one that has a password set via PASS command),
|
||||
enabling true multi-client support (multiple cookies sharing one nick/session
|
||||
with independent message queues). Sessions without a password cannot be
|
||||
logged into.
|
||||
|
||||
**Rationale:** The fundamental IRC mobile problem is that you can't have your
|
||||
phone and laptop connected simultaneously without a bouncer. Server-side
|
||||
@@ -355,19 +350,22 @@ over binary is measured in bytes per message, not meaningful bandwidth. The
|
||||
canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
|
||||
matters for signing.
|
||||
|
||||
### Why Opaque Tokens Instead of JWTs
|
||||
### Why Opaque Cookies Instead of JWTs
|
||||
|
||||
JWTs encode claims that clients can decode and potentially rely on. This
|
||||
creates a coupling between token format and client behavior. If the server
|
||||
needs to revoke a token, change the expiry model, or add/remove claims, JWT
|
||||
clients may break or behave incorrectly.
|
||||
|
||||
Opaque tokens are simpler:
|
||||
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
|
||||
- Client presents the raw token; server hashes and looks it up
|
||||
- Revocation is a database delete
|
||||
Opaque auth cookies are simpler:
|
||||
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash →
|
||||
sets raw hex as an HttpOnly cookie
|
||||
- On each request, server hashes the cookie value and looks it up
|
||||
- Revocation is a database delete (cookie becomes invalid immediately)
|
||||
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
|
||||
- Token format can change without breaking clients
|
||||
- Cookie format can change without breaking clients
|
||||
- Clients never handle tokens directly — browsers and HTTP cookie jars
|
||||
manage everything automatically
|
||||
|
||||
---
|
||||
|
||||
@@ -391,17 +389,18 @@ The entire read/write loop for a client is two endpoints. Everything else
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
#### Anonymous Session
|
||||
#### Session Creation
|
||||
|
||||
```
|
||||
┌─ Client ──────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 1. POST /api/v1/session {"nick":"alice"} │
|
||||
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
|
||||
│ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │
|
||||
│ → {"id":1, "nick":"alice"} │
|
||||
│ │
|
||||
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
|
||||
│ → {"status":"joined","channel":"#general"} │
|
||||
│ (Server fans out JOIN event to all #general members) │
|
||||
│ (Cookie sent automatically on all subsequent requests) │
|
||||
│ │
|
||||
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │
|
||||
│ "to":"#general","body":["hello"]} │
|
||||
@@ -418,31 +417,37 @@ The entire read/write loop for a client is two endpoints. Everything else
|
||||
│ 6. POST /api/v1/messages {"command":"QUIT"} │
|
||||
│ → {"status":"quit"} │
|
||||
│ (Server broadcasts QUIT, removes from channels, │
|
||||
│ deletes session, releases nick) │
|
||||
│ deletes session, releases nick, clears cookie) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Registered Account
|
||||
#### Multi-Client via Password
|
||||
|
||||
```
|
||||
┌─ Client ──────────────────────────────────────────────────┐
|
||||
┌─ Client A ────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 1. POST /api/v1/register │
|
||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
||||
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
|
||||
│ (Session created with bcrypt-hashed password) │
|
||||
│ 1. POST /api/v1/session {"nick":"alice"} │
|
||||
│ → Set-Cookie: neoirc_auth=<token_a>; HttpOnly; ... │
|
||||
│ → {"id":1, "nick":"alice"} │
|
||||
│ │
|
||||
│ 2. POST /api/v1/messages │
|
||||
│ {"command":"PASS","body":["s3cret!!"]} │
|
||||
│ → {"status":"ok"} │
|
||||
│ (Password set via IRC PASS command) │
|
||||
│ │
|
||||
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
|
||||
│ │
|
||||
│ (From another device, while session is still active) │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Client B (another device, while session is still active) ┐
|
||||
│ │
|
||||
│ 2. POST /api/v1/login │
|
||||
│ 3. POST /api/v1/login │
|
||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
||||
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │
|
||||
│ → Set-Cookie: neoirc_auth=<token_b>; HttpOnly; ... │
|
||||
│ → {"id":1, "nick":"alice"} │
|
||||
│ (New client added to existing session — channels │
|
||||
│ and message queues are preserved. If all clients │
|
||||
│ have logged out, session no longer exists.) │
|
||||
│ and message queues are preserved.) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -729,6 +734,35 @@ Change the user's nickname.
|
||||
|
||||
**IRC reference:** RFC 1459 §4.1.2
|
||||
|
||||
#### PASS — Set Session Password
|
||||
|
||||
Set a password on the current session, enabling multi-client login via
|
||||
`POST /api/v1/login`. The password is hashed with bcrypt and stored
|
||||
server-side.
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
{"command": "PASS", "body": ["mypassword"]}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- `body[0]` is the password. Must be at least 8 characters.
|
||||
- On success, the server responds with `{"status": "ok"}`.
|
||||
- If the password is too short or missing, the server sends
|
||||
ERR_NEEDMOREPARAMS (461) via the message queue.
|
||||
- Calling PASS again overwrites the previous password.
|
||||
- Once a password is set, `POST /api/v1/login` can be used with the nick
|
||||
and password to create additional clients on the same session.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for
|
||||
session password management.
|
||||
|
||||
#### TOPIC — Set Channel Topic
|
||||
|
||||
Set or change a channel's topic.
|
||||
@@ -795,7 +829,7 @@ Destroy the session and disconnect from the server.
|
||||
quitting user. The quitting user does **not** receive their own QUIT.
|
||||
- The user is removed from all channels.
|
||||
- Empty channels are deleted (ephemeral).
|
||||
- The user's session is destroyed — the auth token is invalidated, the nick
|
||||
- The user's session is destroyed — the auth cookie is invalidated, the nick
|
||||
is released.
|
||||
- Subsequent requests with the old token return HTTP 401.
|
||||
|
||||
@@ -1086,8 +1120,8 @@ to compute stamps. Higher bit counts take exponentially longer to compute.
|
||||
## API Reference
|
||||
|
||||
All endpoints accept and return `application/json`. Authenticated endpoints
|
||||
require `Authorization: Bearer <token>` header. The token is obtained from
|
||||
`POST /api/v1/session`.
|
||||
require the `neoirc_auth` cookie, which is set automatically by
|
||||
`POST /api/v1/session` and `POST /api/v1/login`.
|
||||
|
||||
All API responses include appropriate HTTP status codes. Error responses have
|
||||
the format:
|
||||
@@ -1116,11 +1150,18 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||
|
||||
**Response:** `201 Created`
|
||||
|
||||
The response sets an `neoirc_auth` HttpOnly cookie containing the auth token.
|
||||
The JSON body does **not** include the token.
|
||||
|
||||
```
|
||||
Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"nick": "alice",
|
||||
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
|
||||
"nick": "alice"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1128,7 +1169,16 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
|---------|---------|-------------|
|
||||
| `id` | integer | Server-assigned user ID |
|
||||
| `nick` | string | Confirmed nick (always matches request on success) |
|
||||
| `token` | string | 64-character hex auth token. Store this — it's the only credential. |
|
||||
|
||||
**Cookie properties:**
|
||||
|
||||
| Property | Value |
|
||||
|------------|-------|
|
||||
| `Name` | `neoirc_auth` |
|
||||
| `HttpOnly` | `true` (not accessible from JavaScript) |
|
||||
| `SameSite` | `Strict` (prevents CSRF) |
|
||||
| `Secure` | `true` when behind TLS |
|
||||
| `Path` | `/` |
|
||||
|
||||
**Errors:**
|
||||
|
||||
@@ -1141,66 +1191,18 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||
# Use -c to save cookies, -b to send them
|
||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
```
|
||||
|
||||
### POST /api/v1/register — Register Account
|
||||
|
||||
Create a new user session with a password. The password is hashed
|
||||
with bcrypt and stored server-side. The password enables login from
|
||||
additional clients via `POST /api/v1/login` while the session
|
||||
remains active.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{"nick": "alice", "password": "mypassword"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|------------|--------|----------|-------------|
|
||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||
| `password` | string | Yes | Minimum 8 characters |
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"nick": "alice",
|
||||
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|---------|-------------|
|
||||
| `id` | integer | Server-assigned user ID |
|
||||
| `nick` | string | Confirmed nick |
|
||||
| `token` | string | 64-character hex auth token |
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `invalid nick format` | Nick doesn't match allowed format |
|
||||
| 400 | `password must be at least 8 characters` | Password too short |
|
||||
| 409 | `nick already taken` | Another active session holds this nick |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}'
|
||||
```
|
||||
|
||||
### POST /api/v1/login — Login to Account
|
||||
|
||||
Authenticate with a previously registered nick and password. Creates a new
|
||||
client token for the existing session, preserving channel memberships and
|
||||
message queues. This is how multi-client access works for registered accounts:
|
||||
each login adds a new client to the session.
|
||||
Authenticate with a nick and password (set via the PASS IRC command). Creates a
|
||||
new client for the existing session, preserving channel memberships and message
|
||||
queues. This is how multi-client access works: each login adds a new client to
|
||||
the session with its own auth cookie and message delivery queue.
|
||||
|
||||
On successful login, the server enqueues MOTD messages and synthetic channel
|
||||
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
|
||||
@@ -1213,37 +1215,37 @@ new client's queue, so the client can immediately restore its UI state.
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|------------|--------|----------|-------------|
|
||||
| `nick` | string | Yes | Must match a registered account |
|
||||
| `password` | string | Yes | Must match the account's password |
|
||||
| `nick` | string | Yes | Must match an active session with a password set |
|
||||
| `password` | string | Yes | Must match the session's password |
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
The response sets an `neoirc_auth` HttpOnly cookie for the new client.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"nick": "alice",
|
||||
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
|
||||
"nick": "alice"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|---------|-------------|
|
||||
| `id` | integer | Session ID (same as when registered) |
|
||||
| `id` | integer | Session ID |
|
||||
| `nick` | string | Current nick |
|
||||
| `token` | string | New 64-character hex auth token for this client |
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `nick and password required` | Missing nick or password |
|
||||
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
|
||||
| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
-d '{"nick":"alice","password":"mypassword"}'
|
||||
```
|
||||
|
||||
### GET /api/v1/state — Get Session State
|
||||
@@ -1287,13 +1289,13 @@ Each channel object:
|
||||
**curl example:**
|
||||
```bash
|
||||
curl -s http://localhost:8080/api/v1/state \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
**Reconnect with channel state initialization:**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||
@@ -1353,14 +1355,12 @@ real-time endpoint — clients call it in a loop.
|
||||
|
||||
**curl example (immediate):**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
|
||||
```
|
||||
|
||||
**curl example (long-poll, 15s):**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
|
||||
```
|
||||
|
||||
### POST /api/v1/messages — Send Command
|
||||
@@ -1387,6 +1387,7 @@ reference with all required and optional fields.
|
||||
| `JOIN` | `to` | | 200 OK |
|
||||
| `PART` | `to` | `body` | 200 OK |
|
||||
| `NICK` | `body` | | 200 OK |
|
||||
| `PASS` | `body` | | 200 OK |
|
||||
| `TOPIC` | `to`, `body` | | 200 OK |
|
||||
| `MODE` | `to` | | 200 OK |
|
||||
| `NAMES` | `to` | | 200 OK |
|
||||
@@ -1401,14 +1402,14 @@ All IRC commands return HTTP 200 OK. IRC-level success and error responses
|
||||
are delivered as **numeric replies** through the message queue (see
|
||||
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
|
||||
reserved for transport-level problems: malformed JSON (400), missing/invalid
|
||||
auth tokens (401), and server errors (500).
|
||||
auth cookies (401), and server errors (500).
|
||||
|
||||
**HTTP errors (transport-level only):**
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `invalid request` | Malformed JSON or empty command |
|
||||
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||
| 401 | `unauthorized` | Missing or invalid auth cookie |
|
||||
| 500 | `internal error` | Server-side failure |
|
||||
|
||||
**IRC numeric error replies (delivered via message queue):**
|
||||
@@ -1499,11 +1500,11 @@ events). Event messages are delivered via the live queue only.
|
||||
```bash
|
||||
# Latest 50 messages in #general
|
||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
|
||||
# Older messages (pagination)
|
||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/channels — List Channels
|
||||
@@ -1534,18 +1535,22 @@ List members of a channel. The `{name}` parameter is the channel name
|
||||
**curl example:**
|
||||
```bash
|
||||
curl -s http://localhost:8080/api/v1/channels/general/members \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
### POST /api/v1/logout — Logout
|
||||
|
||||
Destroy the current client's auth token. If no other clients remain on the
|
||||
session, the user is fully cleaned up: parted from all channels (with QUIT
|
||||
broadcast to members), session deleted, nick released.
|
||||
Destroy the current client's session cookie and server-side client record.
|
||||
If no other clients remain on the session, the user is fully cleaned up:
|
||||
parted from all channels (with QUIT broadcast to members), session deleted,
|
||||
nick released. The auth cookie is cleared in the response.
|
||||
|
||||
**Request:** No body. Requires auth.
|
||||
**Request:** No body. Requires auth cookie.
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
The response clears the `neoirc_auth` cookie.
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
@@ -1554,12 +1559,11 @@ broadcast to members), session deleted, nick released.
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||
| 401 | `unauthorized` | Missing or invalid auth cookie |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8080/api/v1/logout \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/users/me — Current User Info
|
||||
@@ -1583,7 +1587,7 @@ Return the current user's session state. This is an alias for
|
||||
**curl example:**
|
||||
```bash
|
||||
curl -s http://localhost:8080/api/v1/users/me \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/server — Server Info
|
||||
@@ -1828,20 +1832,21 @@ authenticity.
|
||||
|
||||
### Authentication
|
||||
|
||||
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
|
||||
Tokens are hashed (SHA-256) before storage and validated on every request.
|
||||
- **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
|
||||
(CSRF protection), and Secure when behind TLS.
|
||||
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
|
||||
password, instant access. The token is the sole credential.
|
||||
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password
|
||||
(minimum 8 characters). The password is hashed with bcrypt at the default
|
||||
cost factor and stored alongside the session. `POST /api/v1/login`
|
||||
authenticates against the stored hash and issues a new client token.
|
||||
password, instant access. The auth cookie is the sole credential.
|
||||
- **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed
|
||||
password on the session. `POST /api/v1/login` authenticates against the
|
||||
stored hash and issues a new client cookie.
|
||||
- **Password security**: Passwords are never stored in plain text. bcrypt
|
||||
handles salting and key stretching automatically. Anonymous sessions have
|
||||
an empty `password_hash` and cannot be logged into via `/login`.
|
||||
- **Token security**: Tokens should be treated like session cookies. Transmit
|
||||
only over HTTPS in production. If a token is compromised, the attacker has
|
||||
full access to the session until QUIT or expiry.
|
||||
handles salting and key stretching automatically. Sessions without a
|
||||
password cannot be logged into via `/login`.
|
||||
- **Cookie security**: Auth cookies should only be transmitted over HTTPS in
|
||||
production. If a cookie is compromised, the attacker has full access to the
|
||||
session until QUIT or expiry.
|
||||
|
||||
### Message Integrity
|
||||
|
||||
@@ -1878,8 +1883,10 @@ authenticity.
|
||||
- **HTTPS is strongly recommended** for production deployments. The server
|
||||
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS
|
||||
termination.
|
||||
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
||||
Restrict this in production via reverse proxy configuration if needed.
|
||||
- **CORS**: The server allows all origins with credentials
|
||||
(`Access-Control-Allow-Credentials: true`), reflecting the request Origin.
|
||||
This enables cookie-based auth from cross-origin clients. Restrict origins
|
||||
in production via reverse proxy configuration if needed.
|
||||
- **Content-Security-Policy**: The server sets a strict CSP header on all
|
||||
responses, restricting resource loading to same-origin and disabling
|
||||
dangerous features (object embeds, framing, base tag injection). The
|
||||
@@ -2069,7 +2076,7 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
|
||||
- **Client output queue entries**: Pruned automatically when older than
|
||||
`QUEUE_MAX_AGE` (default 30 days).
|
||||
- **Channels**: Deleted when the last member leaves (ephemeral).
|
||||
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
|
||||
- **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT`
|
||||
or when the last client logs out (`POST /api/v1/logout` with no remaining
|
||||
clients triggers session cleanup). There is no distinction between session
|
||||
types in the cleanup path — `handleQuit` and `cleanupUser` both call
|
||||
@@ -2230,68 +2237,59 @@ A complete client needs only four HTTP calls:
|
||||
### Step-by-Step with curl
|
||||
|
||||
```bash
|
||||
# 1a. Create an anonymous session (no account)
|
||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||
# 1a. Create a session (cookie saved automatically with -c)
|
||||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"testuser"}' | jq -r .token)
|
||||
-d '{"nick":"testuser"}'
|
||||
|
||||
# 1b. Or register an account (multi-client support)
|
||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
||||
# 1b. Optionally set a password for multi-client access
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
|
||||
-d '{"command":"PASS","body":["mypassword"]}'
|
||||
|
||||
# 1c. Or login to an existing account
|
||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
||||
# 1c. Login from another device (saves new cookie)
|
||||
curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
|
||||
-d '{"nick":"testuser","password":"mypassword"}'
|
||||
|
||||
# 2. Join a channel
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"JOIN","to":"#general"}'
|
||||
|
||||
# 3. Send a message
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
|
||||
|
||||
# 4. Poll for messages (one-shot)
|
||||
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
|
||||
|
||||
# 5. Long-poll (blocks up to 15s waiting for messages)
|
||||
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
|
||||
|
||||
# 6. Send a DM
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
|
||||
|
||||
# 7. Change nick
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"NICK","body":["newnick"]}'
|
||||
|
||||
# 8. Set channel topic
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
|
||||
|
||||
# 9. Leave a channel
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"PART","to":"#general","body":["goodbye"]}'
|
||||
|
||||
# 10. Disconnect
|
||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"command":"QUIT","body":["leaving"]}'
|
||||
```
|
||||
@@ -2301,27 +2299,25 @@ curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||
The key to real-time messaging is the poll loop. Here's the pattern:
|
||||
|
||||
```python
|
||||
# Python example
|
||||
import requests, json
|
||||
# Python example — using requests.Session for automatic cookie handling
|
||||
import requests, json, time
|
||||
|
||||
BASE = "http://localhost:8080/api/v1"
|
||||
token = None
|
||||
session = requests.Session() # Manages cookies automatically
|
||||
last_id = 0
|
||||
|
||||
# Create session
|
||||
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
|
||||
token = resp.json()["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
# Create session (cookie set automatically via Set-Cookie header)
|
||||
resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
|
||||
print(f"Session: {resp.json()}")
|
||||
|
||||
# Join channel
|
||||
requests.post(f"{BASE}/messages", headers=headers,
|
||||
json={"command": "JOIN", "to": "#general"})
|
||||
session.post(f"{BASE}/messages",
|
||||
json={"command": "JOIN", "to": "#general"})
|
||||
|
||||
# Poll loop
|
||||
while True:
|
||||
try:
|
||||
resp = requests.get(f"{BASE}/messages",
|
||||
headers=headers,
|
||||
resp = session.get(f"{BASE}/messages",
|
||||
params={"after": last_id, "timeout": 15},
|
||||
timeout=20) # HTTP timeout > long-poll timeout
|
||||
data = resp.json()
|
||||
@@ -2338,14 +2334,14 @@ while True:
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript/browser example
|
||||
async function pollLoop(token) {
|
||||
// JavaScript/browser example — cookies sent automatically
|
||||
async function pollLoop() {
|
||||
let lastId = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/v1/messages?after=${lastId}&timeout=15`,
|
||||
{headers: {'Authorization': `Bearer ${token}`}}
|
||||
{credentials: 'same-origin'} // Include cookies
|
||||
);
|
||||
if (resp.status === 401) { /* session expired */ break; }
|
||||
const data = await resp.json();
|
||||
@@ -2377,8 +2373,8 @@ Clients should handle these message commands from the queue:
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
|
||||
re-login (registered account).
|
||||
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or
|
||||
re-login (if a password was set).
|
||||
- **HTTP 404**: Channel or user not found.
|
||||
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
||||
NICK change).
|
||||
@@ -2398,10 +2394,11 @@ Clients should handle these message commands from the queue:
|
||||
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
|
||||
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
|
||||
if `from` is you, use `to`; if `from` is someone else, use `from`.
|
||||
5. **Reconnection**: If the poll loop fails with 401, the token is invalid.
|
||||
For anonymous sessions, create a new session. For registered accounts,
|
||||
log in again via `POST /api/v1/login` to get a fresh token on the same
|
||||
session. If it fails with a network error, retry with backoff.
|
||||
5. **Reconnection**: If the poll loop fails with 401, the auth cookie is
|
||||
invalid. For sessions without a password, create a new session. For
|
||||
sessions with a password set (via PASS command), log in again via
|
||||
`POST /api/v1/login` to get a fresh cookie on the same session. If it
|
||||
fails with a network error, retry with backoff.
|
||||
|
||||
---
|
||||
|
||||
@@ -2560,8 +2557,10 @@ creating one session pays once and keeps their session.
|
||||
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
|
||||
- [ ] **Max message size enforcement** — reject oversized messages
|
||||
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
|
||||
- [ ] **Multi-client sessions** — add client to existing session
|
||||
(share nick across devices)
|
||||
- [x] **Multi-client sessions** — set a password via PASS command, then
|
||||
login from additional devices via `POST /api/v1/login`
|
||||
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
||||
all API authentication
|
||||
|
||||
### Future (1.0+)
|
||||
|
||||
@@ -2674,13 +2673,12 @@ neoirc/
|
||||
build a working IRC-style TUI client against this API in an afternoon, the
|
||||
API is too complex.
|
||||
|
||||
2. **Accounts optional** — anonymous sessions are instant: pick a nick and
|
||||
2. **Passwords optional** — anonymous sessions are instant: pick a nick and
|
||||
talk. No registration, no email verification. The cost of entry is a
|
||||
hashcash proof, not bureaucracy. For users who want multi-client access
|
||||
(multiple devices sharing one session), optional account registration
|
||||
with password is available — but never required. Identity
|
||||
verification at the message layer uses cryptographic signing,
|
||||
independent of account status.
|
||||
(multiple devices sharing one session), the PASS command sets a password
|
||||
on the session — but it's never required. Identity verification at the
|
||||
message layer uses cryptographic signing, independent of password status.
|
||||
|
||||
3. **IRC semantics over HTTP** — command names and numeric codes from
|
||||
RFC 1459/2812. If you've built an IRC client or bot, you already know the
|
||||
|
||||
Reference in New Issue
Block a user