Document JWT token auth design in ARCHITECTURE.md.

Two-layer auth: SSH key signing to obtain a 30-day JWT, then
token-based auth for all subsequent requests. Auto-renewal is
transparent — client interceptor catches Unauthenticated, re-signs,
caches new token, retries. Server is stateless (JWT signed with
repo-local secret key). Token cached at $XDG_STATE_HOME/sgard/token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 00:36:58 -07:00
parent 92d64d5540
commit 66af104155

View File

@@ -161,11 +161,102 @@ replaces its manifest.
## Authentication ## Authentication
SSH key signing via gRPC metadata interceptors: Authentication is designed to be transparent — the user never explicitly
- Server loads an `authorized_keys` file (standard SSH format) logs in or manages credentials. It uses SSH keys they already have.
- Client signs a nonce+timestamp with SSH private key (via ssh-agent or key file)
- Signature + public key sent as gRPC metadata on every call ### Overview
- 5-minute timestamp window prevents replay
Two mechanisms, layered:
1. **SSH key signing** — used to obtain a token or when no valid token exists
2. **JWT token** — used for all subsequent requests, cached on disk
From the user's perspective, authentication is automatic. The client
handles token acquisition, caching, and renewal without prompting.
### Token-Based Auth (Primary Path)
The server issues signed JWT tokens valid for 30 days. The client caches
the token and attaches it as gRPC metadata on every call.
```
service GardenSync {
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
// ... other RPCs
}
```
**Authenticate RPC:**
- Client sends an SSH-signed challenge (nonce + timestamp + public key)
- Server verifies the signature against its `authorized_keys` file
- Server returns a JWT signed with its own secret key
- JWT claims: public key fingerprint, issued-at, 30-day expiry
**Normal request flow:**
1. Client reads cached token from `$XDG_STATE_HOME/sgard/token`
(falls back to `~/.local/state/sgard/token`)
2. Client attaches token as `x-sgard-auth-token` gRPC metadata
3. Server verifies JWT signature and expiry
4. If valid → request proceeds
5. If expired or invalid → server returns `Unauthenticated`
**Auto-renewal flow (transparent to user):**
1. Client sends request with cached token
2. Server rejects with `Unauthenticated`
3. Client interceptor catches the error
4. Client calls `Authenticate` RPC with SSH signature
5. Server issues new JWT
6. Client caches new token to disk
7. Client retries the original request with the new token
### SSH Key Signing (Fallback)
Used only during the `Authenticate` RPC. The client signs a challenge
payload to prove possession of an authorized SSH private key.
**Challenge payload:** `nonce (32 random bytes) || timestamp (big-endian int64)`
**Metadata fields on Authenticate RPC:**
- `x-sgard-auth-nonce` — base64-encoded 32-byte nonce
- `x-sgard-auth-timestamp` — Unix seconds as decimal string
- `x-sgard-auth-signature` — base64-encoded SSH signature
- `x-sgard-auth-pubkey` — SSH public key in authorized_keys format
**Server verification:**
- Parse public key, check fingerprint against `authorized_keys` file
- Verify SSH signature over the payload
- Check timestamp is within 5-minute window (prevents replay)
### Server-Side Token Management
The server does not store tokens. JWTs are stateless — the server signs
them with a secret key and verifies its own signature on each request.
**Secret key:** Generated on first startup, stored at `<repo>/jwt.key`
(32 random bytes). If the key file is deleted, all outstanding tokens
become invalid and clients re-authenticate automatically.
**No revocation mechanism.** For a single-user personal sync tool,
revocation is unnecessary. Removing a key from `authorized_keys`
prevents new token issuance. Existing tokens expire naturally within
30 days. Deleting `jwt.key` invalidates all tokens immediately.
### Client-Side Token Storage
Token cached at `$XDG_STATE_HOME/sgard/token` (per XDG Base Directory
spec, state is "data that should persist between restarts but isn't
important enough to back up"). Falls back to `~/.local/state/sgard/token`.
The token file contains the raw JWT string. File permissions are set to
`0600`.
### Key Resolution
SSH key resolution order (for initial authentication):
1. `--ssh-key` flag (explicit path to private key)
2. `SGARD_SSH_KEY` environment variable
3. ssh-agent (if `SSH_AUTH_SOCK` is set, uses first available key)
4. Default paths: `~/.ssh/id_ed25519`, `~/.ssh/id_rsa`
## Go Package Structure ## Go Package Structure