Relationships
↑ child of #662#1016 serve-auth: mode: oauth — device grant client + collective-based admission
Opened by stack72 · 7/7/2026· Shipped 7/8/2026
Parent
Sub-issue of #662 (serve authentication & authorization). Phase 2 Track A. Depends on swamp-club #1015 (OAuth provider + device authorization plugins).
Goal
Implement `--auth-mode oauth` on swamp serve. Users authenticate via swamp-club using the OAuth device grant flow. Admission is controlled by collective membership — only users in the allowed collectives can connect.
This replaces manual token minting (`mode: token`) with a self-service login experience: users authenticate themselves through swamp-club, and their collective membership determines access.
The flow
- Admin starts: `swamp serve --auth-mode oauth --admins user:paul --allowed-collectives acme-corp`
- First start: server auto-registers as an OAuth client with swamp-club using the admin's API key
- User runs: `swamp auth login --server wss://swamp-serve.example.com`
- CLI calls swamp serve's `/auth/device` endpoint
- swamp serve starts a device authorization grant against swamp-club
- CLI shows: `Visit https://swamp-club.com/device and enter code: ABCD-1234`
- User opens browser, signs in to swamp-club (email/password, social — whatever), enters code, approves
- swamp serve polls swamp-club's token endpoint, gets access token
- swamp serve calls swamp-club's userinfo endpoint — gets sub, email, name, collectives
- swamp serve checks: is the user a member of `acme-corp`? (from `--allowed-collectives`)
- Yes → mints server token, returns to CLI. No → rejected.
- CLI stores credential in `~/.config/swamp/servers.json` — future commands auto-authenticate
What to build
1. OAuth device grant client in swamp serve
A new module (e.g., `src/serve/oauth_client.ts`) that implements the client side of RFC 8628:
Start the device grant: ```typescript async function startDeviceGrant(providerUrl: string, clientId: string): Promise<{ deviceCode: string; userCode: string; verificationUri: string; expiresIn: number; interval: number; }> ``` Calls `POST /api/auth/device/code` with the client ID.
Poll for authorization: ```typescript async function pollForToken(providerUrl: string, clientId: string, deviceCode: string, interval: number): Promise<{ accessToken: string; }> ``` Polls `POST /api/auth/device/token` every `interval` seconds. Handles `authorization_pending` (keep polling), `slow_down` (increase interval), `expired_token` (give up), `access_denied` (user denied).
Get userinfo: ```typescript async function getUserInfo(providerUrl: string, accessToken: string): Promise<{ sub: string; email: string; name: string; collectives: string[]; }> ``` Calls `GET /api/auth/oauth2/userinfo` with Bearer token.
2. Auto-registration on first start
When `--auth-mode oauth` and no client credentials are stored locally:
- Read the admin's swamp-club API key from `~/.config/swamp/auth.json` (from `swamp auth login`)
- Call swamp-club's `POST /api/auth/oauth2/register` to register this serve instance as an OAuth client
- Store the returned `client_id` and `client_secret` locally (e.g., in `.swamp/oauth-client.json` or the vault)
- Subsequent starts read the stored credentials
If the admin hasn't run `swamp auth login`, refuse to start with: "oauth mode requires a swamp-club account — run 'swamp auth login' first"
3. `/auth/device` endpoint on swamp serve
A new HTTP endpoint on the serve handler that the CLI calls to start the device grant flow:
``` POST /auth/device → swamp serve calls swamp-club's device/code endpoint → returns { userCode, verificationUri, verificationUriComplete } to the CLI ```
The CLI then displays the code and URL. swamp serve handles the polling internally — the CLI just waits for the result.
Alternatively, swamp serve could expose a WebSocket frame type (`auth.login`) that handles the entire flow. Either approach works.
4. Admission policy
After getting the userinfo response, swamp serve applies the admission policy:
```typescript function checkAdmission( collectives: string[], allowedCollectives: string[], allowedUsers: string[], userSub: string, ): boolean { if (allowedUsers.includes(userSub)) return true; return collectives.some(c => allowedCollectives.includes(c)); } ```
If rejected, return a clear error: "user not admitted — not a member of allowed collectives (acme-corp)"
5. Server token minting with collectives
On admission, mint a server token (same as `mode: token`) but include the user's collectives on the token record:
```typescript { principalId: "user:sub-uuid", principalEmail: "sarah@acme.com", displayName: "Sarah Chen", collectives: ["acme-corp", "dev-team"], // from userinfo // ... existing fields } ```
The collectives are snapshotted at login time. They become stale if the user's collective membership changes in swamp-club — refreshed at next login or via server-side refresh (later phase).
6. `swamp auth login --server` command
Update the CLI login command to support the OAuth flow when connecting to a serve instance:
```bash swamp auth login --server wss://swamp-serve.example.com ```
Flow:
- CLI calls swamp serve's `/auth/device` (or sends an `auth.login` WebSocket frame)
- Receives user code + verification URL
- Opens browser to the verification URL (or tells user to visit it)
- Displays: "Visit https://swamp-club.com/device and enter code: ABCD-1234"
- Waits for swamp serve to report success
- Receives the server token
- Stores via `ServerCredentialRepository`
- Displays: "✓ Logged in as Sarah Chen (acme-corp, dev-team) on wss://swamp-serve.example.com"
7. Wire collectives into enforcement
Update `authorizeOrReject` in `src/serve/connection.ts` to populate collectives from the server token record instead of passing empty arrays:
```typescript // Currently: { principal, collectives: [] }
// After: { principal, collectives: tokenRecord.collectives ?? [] } ```
This means `idp-group:` grants will match for OAuth-authenticated users whose collectives include the grant's subject. For `mode: token` users, collectives remain empty (no change).
Testing (local, both services running)
```bash
Terminal 1: swamp-club on localhost:8000
cd ~/code/swamp-club/swamp-club && deno task dev
Terminal 2: swamp serve on localhost:9090
cd /tmp/test-oauth && swamp repo init && swamp model create command/shell hello swamp serve --auth-mode oauth --admins user:paul --allowed-collectives test-collective
Terminal 3: test the flow
swamp auth login --server ws://127.0.0.1:9090
→ "Visit http://localhost:8000/device and enter code: ABCD-1234"
→ Sign in to swamp-club (email/password), enter code, approve
→ "✓ Logged in as Test User (test-collective)"
Verify
swamp access can-i --server ws://127.0.0.1:9090 swamp model method run hello execute --input 'run=echo "oauth works"' --server ws://127.0.0.1:9090
Negative test: user NOT in test-collective
→ Should be rejected at admission
```
Scope
- OAuth device grant client module
- Auto-registration with swamp-club on first start
- `/auth/device` endpoint (or WebSocket frame) on swamp serve
- Admission policy (collective check)
- Server token minting with collectives
- `swamp auth login --server` CLI command
- Wire collectives into `authorizeOrReject`
- Tests
Out of scope
- SSO / IdP federation — later phase
- IdP groups on userinfo — later phase (requires SSO)
- Server-side userinfo refresh — later phase
- `mode: token` changes — existing flow unchanged
Dependencies
- swamp-club #1015 must ship first (OIDC provider + device authorization plugins)
References
- Auth config: `src/domain/access/serve_auth_config.ts` (`--auth-mode oauth` already defined)
- Server token model: `src/domain/models/access/server_token_model.ts`
- Client credential storage: `src/infrastructure/persistence/server_credential_repository.ts`
- Token auth: `src/serve/token_auth.ts` (pattern to follow for OAuth token minting)
- Remote run: `src/cli/remote_run.ts` (client-side WebSocket patterns)
- Existing auth login: `src/libswamp/auth/login.ts` (existing CLI login flow for swamp-club)
Shipped
Click a lifecycle step above to view its details.