Skip to content

Add device-code enrollment and multi-token bearer auth#24

Merged
andrew-jon-p7a merged 6 commits intomainfrom
feature/device-code-enrollment
Apr 29, 2026
Merged

Add device-code enrollment and multi-token bearer auth#24
andrew-jon-p7a merged 6 commits intomainfrom
feature/device-code-enrollment

Conversation

@andrew-jon-p7a
Copy link
Copy Markdown
Contributor

Summary

Replaces the "create a member, copy the token, paste it into your VM config" onboarding flow with a gh-auth-style device-code handshake (RFC 8628). The bearer plaintext now travels directly from the broker to the operator's CLI on its next poll — never through copy-paste, terminal scrollback, or chat threads.

  • ac7 connect on the device prints a short user code + verification URL; the director (signed in via TOTP) approves in the browser; the CLI's next poll resolves with the bearer and writes it to ~/.config/ac7/auth.json (mode 0o600).
  • Multi-token per member. New tokens SQLite table replaces single-token-per-member. Each device gets its own labeled row; rotating no longer kicks every peer offline.
  • RFC 8628-shaped wire. Pending/error states return 400 + {error: <code>} (authorization_pending / slow_down / expired_token / access_denied); success returns 200 with the bearer.
  • Backward compatible. --token flag and AC7_TOKEN env var still work — CLI's makeClient() resolves in order: flag → env → saved entry. Existing CI scripts keep running.
  • Wizard unchanged. First admin's bootstrap token still mints directly (no director exists yet to approve).

What's in here

Server

  • apps/server/src/tokens.tsTokenStore (SQLite tokens table, KEK-aware, debounced last_used_at updates).
  • apps/server/src/enrollments.tsEnrollmentStore (SQLite pending_enrollments, KEK-wraps issued plaintext at rest, RFC 8628 outcomes).
  • apps/server/src/app.ts — five new routes (/enroll, /enroll/poll, /enroll/pending, /enroll/approve, /enroll/reject) + two token-mgmt routes (GET/DELETE /members/:name/tokens(/:id)). Per-IP rate limit (10/hr) on /enroll.
  • apps/server/src/auth.ts — bearer resolver now reads from TokenStore; binds tokenId to the request context.
  • apps/server/src/run.ts — boot-time idempotent migration of ac7.json tokenHashtokens table.

SDK — types, schemas, paths, client methods for the eight new endpoints.

CLI — new ac7 connect command with box-banner UI, RFC 8628 poll backoff, SIGINT-safe cancellation. New auth-config.ts helper for ~/.config/ac7/auth.json (XDG / macOS / Windows aware).

Web — new /enroll SPA route (handles its own anonymous → login bounce), MemberTokenList under member admin, PendingEnrollments panel auto-refreshing under MembersPanel.

Docs — README onboarding now points at ac7 connect; new docs/enrollment.mdx covers the flow, RFC 8628 alignment, security posture, multi-token semantics, and the legacy ac7 rotate break-glass.

Security posture

  • Device code (32 bytes, ~256 bits) is a shared secret in transit; broker stores only its sha256 hash.
  • User code (8 chars Crockford base32, no I/L/O/U) is shown to humans; bounded by /enroll rate limit + 5-min single-use TTL.
  • Issued bearer plaintext is KEK-wrapped (AES-256-GCM, same KEK that wraps TOTP/VAPID) between approval and the device's next poll.
  • Single-use semantics: a successful poll deletes the row in the same transaction. Replays return expired_token.
  • Director-side gating: members.manage required to approve/reject. The operator running ac7 connect cannot approve themselves.
  • Source IP / UA captured at mint time, displayed to the director — never enforced.

Test plan

  • Server suite: 352 passing (38 new across tokens.test.ts, enrollments.test.ts, enroll-endpoints.test.ts)
  • CLI suite: 117 passing (3 new connect.test.ts covering happy path, access_denied, expired_token)
  • Web-shell suite: 161 passing (no regressions)
  • Core suite: 87 passing
  • pnpm run lint: 0 errors, 0 warnings
  • All five touched packages typecheck and build cleanly
  • Manual smoke: spin up ac7 serve, run ac7 connect on a second machine, approve via web UI, verify token works for ac7 claude-code
  • Manual smoke: rotate-token revokes every peer token; member delete revokes all tokens
  • Manual smoke: enrollment expires cleanly after 5 min if not approved
  • Manual smoke: deep link ?code=… is cleared from history bar after read

Decisions worth flagging in review

  • Naming: new command is ac7 connect, not ac7 enroll — the latter already exists for TOTP enrollment for web UI sign-in. The two are semantically different (machine onboarding vs human web-login enrollment); keeping them distinct avoids overloading.
  • ac7 rotate semantics changed. Previously: single-token replacement. Now: revokes every active token for the member and mints one fresh — the break-glass posture for "I think a token leaked." Adding a new device should use ac7 connect, which adds a peer token without touching the others.
  • ac7.json tokenHash is no longer load-bearing after first boot. SQLite is the resolver's source of truth; the JSON hash is preserved for human-readability and hand-edit support but the resolver doesn't read it after migration.
  • Audit log: structured logger.info() calls on every state mutation (enrollment minted/approved/rejected, token revoked/rotated, etc). A dedicated audit_log SQL table is a future enhancement; the structured log is sufficient for v1.

🤖 Generated with Claude Code

andrew-jon-p7a and others added 6 commits April 24, 2026 23:50
Signed-off-by: andrew-jon-p7a <andrewprzybilla@gmail.com>
Replaces the "create a member, copy the token, paste it into your
VM config" onboarding flow with a gh-auth-style device-code
handshake (RFC 8628). The bearer plaintext now travels directly
from the broker to the operator's CLI on its next poll — never
through a copy-paste path that could leak via terminal scrollback,
clipboard history, or chat threads.

Server:
- New SQLite-backed `tokens` table — multi-token-per-member, with
  `(label, origin, created_at, last_used_at, expires_at, created_by)`.
  Boot-time bootstrap migration copies each member's `tokenHash`
  from `ac7.json` into the store as `origin = 'bootstrap'`,
  idempotent across reboots. Auth resolver in `auth.ts` reads only
  from the token store after migration.
- New SQLite-backed `pending_enrollments` table + `EnrollmentStore`
  for the device-code flow. KEK-wraps the issued plaintext at rest.
- Five new endpoints: `POST /enroll` (anonymous mint, per-IP rate
  limited 10/hr), `POST /enroll/poll` (RFC 8628 wire shape with
  authorization_pending / slow_down / expired_token / access_denied
  as 400 + JSON), `GET /enroll/pending`, `POST /enroll/approve`
  (bind-or-create modes), `POST /enroll/reject`. All gated on
  `members.manage` for director actions.
- Two new endpoints under members admin: `GET /members/:name/tokens`,
  `DELETE /members/:name/tokens/:id` — `members.manage` or self.
  `rotate-token` semantics now revoke every peer token before
  minting a fresh one.

CLI:
- New `ac7 connect` command with the box-banner UI, RFC 8628
  poll backoff, SIGINT-safe cancellation, `--no-write` debug mode.
- Saved bearer at `~/.config/ac7/auth.json` (mode 0o600 in 0o700
  dir; XDG / macOS / Windows paths). `makeClient()` resolves
  tokens in order: `--token` flag → `AC7_TOKEN` → saved entry,
  so existing CI / scripted setups keep working unchanged.

Web UI:
- New `/enroll` page (top-level, gated by session): code entry,
  source IP / UA / labelHint visibility, bind-or-create toggle.
  Deep links via `?code=…` are cleared from history with
  replaceState.
- New `MemberTokenList` under each member's Manage tab: per-row
  label / origin / created / last-used / expires + revoke.
- New `PendingEnrollments` panel under MembersPanel — auto-
  refreshing, hidden when empty, inline reject + deep-link approve.

Tests: 38 new server tests (TokenStore, EnrollmentStore, full
endpoint flow including RFC 8628 outcomes and multi-token semantics
after approval/rotation/delete) + 3 new CLI tests covering happy
path, access_denied, and expired_token.

Docs: README onboarding now points at `ac7 connect` with the
env-var fallback documented for CI. New `docs/enrollment.mdx`
covers the flow, RFC 8628 alignment, security posture, multi-token
semantics, and the legacy `ac7 rotate` break-glass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: andrew-jon-p7a <andrewprzybilla@gmail.com>
Resolves textual conflicts in the import sort order of three files
where my new enrollment-related imports landed adjacent to imports
added by the SaaS pairing PR (#18) and dusk styling PR (#23):

- apps/server/src/app.ts (3 conflict blocks, all kept-mine)
- packages/sdk/src/client.ts (4 conflict blocks, all kept-mine)
- apps/server/test/channels-endpoints.test.ts (3 blocks, kept-mine)

All resolutions are pure "keep my additions" — origin/main has
nothing in those positions, the conflicts were textual artifacts
of git's auto-merge giving up on adjacent alphabetized sorts.

Also pulled in the dependabot-merged @hono/node-server 2.0.0 bump
(#22). Verified no breaking-change impact: the serve() factory
pattern we use is stable across the major version. All 717 tests
pass and biome is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: andrew-jon-p7a <andrewprzybilla@gmail.com>
@andrew-jon-p7a andrew-jon-p7a merged commit 1e7801e into main Apr 29, 2026
1 check passed
@andrew-jon-p7a andrew-jon-p7a deleted the feature/device-code-enrollment branch April 29, 2026 05:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant