Contracts
api-logout-endpoint.md
API Logout Endpoint
Endpoint: POST https://api.spec-kitty.com/api/v1/logout Purpose: Revoke session and invalidate all tokens Authentication: Bearer token (via Authorization header) Idempotency: Idempotent (safe to call multiple times) Content-Type: application/json
Request
Headers
POST /api/v1/logout HTTP/1.1
Host: api.spec-kitty.com
Authorization: Bearer <access_token>
Content-Type: application/json
Accept: application/json
Body
No request body required. Logout is authenticated by the bearer token alone.
Authentication
- Access token provided in
Authorization: Bearerheader - Session to revoke is identified by the token's bound session_id (server-side)
Response
Success (200 OK)
Status: 200 OK Content-Type: application/json
{
"status": "logged_out",
"session_id": "sess_01HR6CYJK...",
"message": "Session successfully revoked"
}
Response Parameters
| Parameter | Type | Description |
|---|---|---|
status | string | Always "logged_out" on success |
session_id | string | Session ID that was revoked (echo of request) |
message | string | Human-readable confirmation |
Idempotency Behavior
If session already logged out or token invalid:
- Return
401 Unauthorized(token no longer valid) - Not idempotent in strict sense (cannot call with expired/revoked token)
- Safe to call once; subsequent calls require fresh token if session is still active
Best practice:
- CLI calls logout once during cleanup
- Does not retry logout on 401 (session is already revoked)
- Always deletes local credentials after logout attempt (whether SaaS call succeeds or fails)
Error Responses
Authentication Failure (401 Unauthorized)
{
"error": "invalid_token",
"error_description": "Access token is invalid or expired"
}
When: Authorization header missing or token invalid
Server Error (500 Internal Server Error)
{
"error": "server_error",
"error_description": "Internal server error"
}
When: Transient server failure; CLI should retry with backoff
Side Effects
When logout succeeds: 1. Session marked as revoked in SaaS database 2. All API calls using tokens from this session are rejected with HTTP 401 3. Refresh token becomes invalid (cannot be used to obtain new access tokens) 4. User must re-authenticate to obtain new tokens
CLI side: 1. Delete stored session from secure storage (Keychain/file/etc.) 2. Clear in-memory TokenManager cache 3. Transition to NotAuthenticated state 4. Next API call will require re-login
CLI Integration Points
1. User runs spec-kitty auth logout: CLI reads session from storage 2. Call logout endpoint: POST to /api/v1/logout with the bearer token in the Authorization header. No request body. The session being revoked is identified server-side by the bound session_id of the token, not by a client-provided field. 3. Cleanup:
FR-014 requires that server failure does not block local credential deletion. 5. Report to user: "Successfully logged out" (or "Failed to revoke session, but local credentials removed")
- If success: delete stored session, clear TokenManager cache
- If failure (network, 4xx, 5xx): attempt local cleanup anyway; log warning.
Logout Flow States
[Authenticated] (has valid access/refresh tokens)
│
├──→ CLI calls POST /api/v1/logout
│ │
│ ├──→ 200 OK: Session revoked
│ │ └──→ Delete stored session, clear TokenManager
│ │ └──→ [NotAuthenticated]
│ │
│ ├──→ 401 Unauthorized: Access token expired
│ │ └──→ TokenManager raises session_invalid
│ │ └──→ [NotAuthenticated] (force re-login message)
│ │
│ └──→ Network error or 500: Cannot reach SaaS
│ └──→ Local cleanup anyway (delete stored session)
│ └──→ [NotAuthenticated] (with warning message)
│
↓
[NotAuthenticated]
Semantics
Logout is best-effort:
- CLI always deletes local credentials (even if SaaS call fails)
- Best case: SaaS revokes session AND CLI deletes credentials
- Fallback: CLI deletes credentials (SaaS may log session as orphaned)
- Never: Keep credentials if logout call fails (security principle: trust local deletion)
Idempotency:
- Safe to call logout multiple times (returns
200 OKevery time) - If called after session already logged out elsewhere, returns
200 OK
Token Revocation Notes
Access tokens:
- Invalidated immediately (SaaS rejects all API calls with that token)
- TTL-based caches may retain old token briefly (until expiry)
- WebSocket connections using that token are disconnected
Refresh tokens:
- Invalidated immediately (cannot be exchanged for new access token)
- Cannot resume session with revoked refresh token
Session state:
- All session data associated with
session_idis marked revoked - No further API calls can use tokens from this session
Rate Limiting
Not typically rate-limited (logout is low-frequency operation).
Notes
- Endpoint requires authentication (Bearer token from session being logged out)
- Idempotent (safe to retry on network failure)
- No client secret required (session authentication only)
- Session revocation is immediate (all API calls are rejected instantly)
- CLI cleanup is mandatory (happens whether SaaS call succeeds or fails)
api-ws-token-endpoint.md
WebSocket Token Endpoint
Endpoint: POST https://api.spec-kitty.com/api/v1/ws-token Purpose: Obtain ephemeral token for WebSocket upgrade Authentication: Bearer token (via Authorization header) Content-Type: application/json
Request
Headers
POST /api/v1/ws-token HTTP/1.1
Host: api.spec-kitty.com
Authorization: Bearer <access_token>
Content-Type: application/json
Accept: application/json
Body
{
"team_id": "tm_acme"
}
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
team_id | string | Yes | Team ID to request WebSocket access for (from /api/v1/me teams array) |
Field Constraints
team_id: non-empty team identifier (must be one of user's teams from/api/v1/me)
Response
Success (200 OK)
Status: 200 OK Content-Type: application/json
{
"ws_token": "ws_eyJ0eXAiOiJKV1QiLCJhbGc...",
"expires_in": 3600,
"session_id": "sess_01HR6CYJK...",
"ws_url": "wss://api.spec-kitty.com/ws"
}
Response Parameters
| Parameter | Type | Description |
|---|---|---|
ws_token | string | Short-lived WebSocket auth token (opaque) |
expires_in | integer | Token lifetime in seconds (typically 3600 = 1 hour) |
session_id | string | Session ID (for audit and reference) |
ws_url | string | WebSocket endpoint to connect to |
Field Constraints
ws_token: opaque string; never emptyexpires_in: positive integer; typically 3600 seconds (1 hour)session_id: session identifier from current sessionws_url: fixed HTTPS WebSocket endpoint
Error Responses
Missing Team ID (400 Bad Request)
{
"error": "invalid_request",
"error_description": "Missing required field: team_id"
}
When: Request body missing team_id field
User Not Team Member (403 Forbidden)
{
"error": "forbidden",
"error_description": "User is not a member of team tm_acme"
}
When: team_id is valid but user is not a member of that team
Authentication Failure (401 Unauthorized)
{
"error": "invalid_token",
"error_description": "Access token is invalid or expired"
}
When: Authorization header missing or token invalid
Server Error (500 Internal Server Error)
{
"error": "server_error",
"error_description": "Internal server error"
}
When: Transient server failure; CLI should retry with backoff
WebSocket Upgrade
After obtaining ws_token and ws_url, CLI upgrades to WebSocket:
WebSocket Connection Request
GET /ws HTTP/1.1
Host: api.spec-kitty.com
Authorization: Bearer <ws_token>
Upgrade: websocket
Connection: Upgrade
WebSocket Handshake Headers
GET /ws HTTP/1.1
Host: api.spec-kitty.com:443
Authorization: Bearer <ws_token>
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64-encoded-16-bytes>
Sec-WebSocket-Version: 13
Token Location
- Passed as an Authorization header:
Authorization: Bearer <ws_token> - NOT in the URL query string;
?token=is ignored by the SaaS WebSocket middleware - Single-use: token is consumed on successful handshake
Connection URL Formula
Use the ws_url from the /api/v1/ws-token response unchanged and attach the bearer header:
websockets.connect(
ws_url,
additional_headers={"Authorization": f"Bearer {ws_token}"}
)
Example:
wss://api.spec-kitty.com/ws
Authorization: Bearer ws_eyJ0eXAi...
Pre-Connect Refresh Logic
TokenManager pre-connect behavior:
1. Before any WebSocket connection, TokenManager checks access token expiry 2. If access token expires within 5 minutes:
3. If access token still valid (>5 min lifetime remaining):
4. Result: WebSocket connection uses fresh tokens (access token valid for ≥5 min)
- Call
/oauth/tokenwithrefresh_tokento obtain new access token - Update stored session with new tokens
- Proceed with WebSocket upgrade
- Skip refresh; proceed immediately to
/api/v1/ws-token
Refresh flow before WebSocket:
[WebSocket Needed]
│
├──→ Check access_token_expires_at
│ │
│ ├──→ Expires within 5 min
│ │ └──→ POST /oauth/token (refresh_token grant)
│ │ ├──→ Success: update stored session, proceed
│ │ └──→ Fail: session_invalid, force re-login
│ │
│ └──→ Valid for >5 min: skip refresh
│
├──→ POST /api/v1/ws-token
│ └──→ Receive ws_token (ephemeral)
│
└──→ WebSocket GET with ?token=ws_token
└──→ SaaS validates ws_token and establishes connection
Token Lifecycle
WebSocket token:
- Issued by
/api/v1/ws-tokenendpoint - Lifetime: typically 1 hour (
expires_in=3600) - Single-use on upgrade: consumed on WebSocket upgrade handshake
- Not stored: ephemeral, used once then discarded during upgrade
- Session binding: WebSocket session bound to
team_idrequested - After upgrade: session_id used for subsequent WebSocket message authentication
Access token (for /api/v1/ws-token call):
- Must be valid at request time
- May expire during WebSocket session (SaaS handles in-flight)
- Refresh happens pre-connect (CLI side) or on 401 (SaaS side)
Concurrent WebSocket Scenarios
Multiple WebSocket connections:
- Each requires separate
/api/v1/ws-tokencall - Each gets unique ephemeral token
- Each token is single-use
- Safe to obtain multiple tokens concurrently
Token refresh coordination:
- If multiple threads/tasks need WebSocket pre-connect refresh:
- TokenManager uses asyncio.Lock (single-flight refresh)
- First caller refreshes; others wait for result
- All proceed with refreshed token once ready
- Prevents thundering herd on token endpoint
CLI Integration Points
1. WebSocket connection needed (e.g., live tracker update) 2. TokenManager._ensure_fresh(): Check access token expiry; refresh if needed 3. POST /api/v1/ws-token: Obtain ephemeral token 4. WebSocket GET upgrade: Include token as Authorization: Bearer <ws_token> 5. On successful upgrade: WebSocket connection authenticated and ready 6. On failure (invalid token, expired, etc.): Force re-login and retry
Security Notes
- Token is ephemeral: Single-use, short-lived (5 min), consumed on upgrade
- Query parameter: WebSocket upgrade cannot use
Authorizationheader (WebSocket spec limitation) - HTTPS required: WebSocket upgrade is
wss://(WSS over TLS) - No client secret: Session authentication only (via token)
- Access token refresh pre-connect: Ensures fresh credentials before upgrade
Rate Limiting
Not typically rate-limited (low-frequency operation).
Notes
- Requires valid session: Must be authenticated (access token valid)
- Session ID must match:
session_idmust be from current session - Access token refresh automatic: Pre-connect refresh happens transparently to user
- WebSocket token is single-use: Consumed on upgrade, cannot be reused
- Ephemeral token TTL: Typically 1 hour (
expires_in=3600), matching SaaS protected-endpoints contract; CLI obtains a fresh token for each WebSocket connection or upon access-token refresh
error-responses.md
Standardized Error Responses
Purpose: Define common error formats and codes across all OAuth and API endpoints Format: JSON (OAuth 2.0 compliant, RFC 6749) HTTP Status: Varies by error type (see table)
Standard Error Response Format
JSON Structure
{
"error": "error_code",
"error_description": "Human-readable error message",
"error_uri": "https://api.spec-kitty.com/docs/errors/error_code"
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
error | string | Yes | Machine-readable error code (see codes below) |
error_description | string | No | Human-readable description of the error |
error_uri | string | No | URL to error documentation |
Error Codes by HTTP Status
400 Bad Request
Common causes: Malformed request, missing parameters, invalid values
| Error Code | Description | Retry | Example |
|---|---|---|---|
invalid_request | Missing or malformed parameters | No | Missing client_id in request |
invalid_client | Unknown or untrusted client | No | client_id not recognized by SaaS |
invalid_scope | Requested scope not available | No | Requested scope admin not available to client |
invalid_grant | Code/token invalid, expired, or reused | No | Authorization code already exchanged once |
invalid_redirect_uri | Redirect URI not registered | No | redirect_uri mismatch from authorization request |
unauthorized_client | Client not permitted to use this flow | No | Client not allowed for this grant_type |
unsupported_grant_type | Unknown grant type | No | Unknown value for grant_type parameter |
access_denied | User denied the request | No | User clicked "Deny" in browser |
authorization_pending | Device code: awaiting user approval | Yes | User has not yet approved device |
expired_token | Device code or token has expired | No | Device code exceeded expires_in window |
server_error | Transient server error | Yes | Temporary SaaS outage |
Example:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "invalid_grant",
"error_description": "Authorization code has expired or was already used"
}
401 Unauthorized
Common causes: Missing or invalid authentication
| Error Code | HTTP | Description | Retry |
|---|---|---|---|
invalid_token | 401 | Access token missing, invalid, or expired | No (refresh token and retry) |
insufficient_scope | 401 | Token valid but insufficient scope for operation | No (request new scope) |
token_revoked | 401 | Token revoked via logout or SaaS admin | No (force re-login) |
Example:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": "invalid_token",
"error_description": "Access token is invalid or expired"
}
403 Forbidden
Common causes: User lacks permission for requested operation
| Error Code | HTTP | Description | Retry |
|---|---|---|---|
access_denied | 403 | User lacks permission for this operation | No |
insufficient_scope | 403 | Token scope insufficient for operation | No |
Example:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "access_denied",
"error_description": "You do not have permission to access this resource"
}
429 Too Many Requests
Common causes: Rate limit exceeded
| Error Code | HTTP | Description | Retry |
|---|---|---|---|
rate_limited | 429 | Too many requests from this client | Yes (with backoff) |
Example:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
{
"error": "rate_limited",
"error_description": "Rate limit exceeded; retry after 60 seconds"
}
500 Internal Server Error
Common causes: Server-side failure (transient)
| Error Code | HTTP | Description | Retry |
|---|---|---|---|
server_error | 500 | Transient server error | Yes (with backoff) |
temporarily_unavailable | 500 | Service temporarily unavailable | Yes (with backoff) |
Example:
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"error": "server_error",
"error_description": "Internal server error; please try again later"
}
502 Bad Gateway
Common causes: Upstream service failure
| Error Code | HTTP | Description | Retry |
|---|---|---|---|
server_error | 502 | Upstream service error | Yes (with backoff) |
503 Service Unavailable
Common causes: Scheduled maintenance or overload
| Error Code | HTTP | Description | Retry |
|---|---|---|---|
temporarily_unavailable | 503 | Service temporarily unavailable | Yes (with backoff) |
CLI Error Handling Strategy
By Error Type
Terminal Errors (do not retry):
invalid_request,invalid_client,invalid_scope,invalid_grant,invalid_redirect_uriaccess_denied,insufficient_scope,unauthorized_client,unsupported_grant_typeinvalid_token(on API call, not token endpoint)
Action: Show error to user, require re-authentication or manual intervention
Retryable Errors (retry with backoff):
authorization_pending(device flow polling)server_error,temporarily_unavailable(transient failures)rate_limited(rate limiting)
Action: Retry with exponential backoff (1s, 2s, 4s, …, max 60s)
Refresh Errors (on 401 API response):
- If
access_token_expired: auto-refresh viarefresh_tokenand retry request (1x) - If
session_invalid: force re-login, delete stored session - If other 401: generic error handling
Retry Strategy
Exponential Backoff
# For transient errors
backoff_seconds = min(60, 2 ** attempt_count) # 1, 2, 4, 8, ..., 60
time.sleep(backoff_seconds + random(0, 1)) # Add jitter
Max Retries
- OAuth token endpoint: 5 retries (5 minutes max with backoff)
- Device flow polling: Continue until
expires_inexceeded - API calls: 1 retry after token refresh
Rate Limit Handling
- Check Retry-After header if present (prefer over backoff)
- If Retry-After present: wait that duration
- If absent: use exponential backoff
OAuth 2.0 Error Code Reference
Standard codes (RFC 6749):
invalid_request,invalid_client,invalid_scope,invalid_grant,invalid_redirect_uriunauthorized_client,unsupported_grant_type,access_denied
Device flow codes (RFC 8628):
authorization_pending,access_denied,expired_token
Custom codes (spec-kitty extensions):
insufficient_scope,token_revoked,rate_limited,temporarily_unavailable
Device Flow Polling Errors
During device code polling, specific error codes guide retry strategy:
| Error | Meaning | CLI Action |
|---|---|---|
authorization_pending | User hasn't approved yet | Wait interval seconds, retry |
access_denied | User denied authorization | Stop polling, show "Authorization denied" |
expired_token | Device code expired | Stop polling, show "Device code expired; run login again" |
server_error | Transient failure | Retry with backoff |
API Call Errors (401 Responses)
When CLI receives 401 response on API call:
{
"error": "access_token_expired",
"error_description": "Access token has expired"
}
or
{
"error": "session_invalid",
"error_description": "Session has been revoked or invalidated"
}
CLI behavior:
| Error | Action |
|---|---|
access_token_expired | Refresh via /oauth/token, retry request (1x) |
session_invalid | Delete session, show "Session expired; run login again" |
Other 401 | Show error, require re-login |
HTTP Status Summary
| Status | Use Case | Examples |
|---|---|---|
| 200 | Success (all endpoints) | Token obtained, logout complete, etc. |
| 302 | Redirect (authorization endpoint) | Redirect to callback with code |
| 400 | Client error (bad request) | Malformed request, invalid grant, access denied |
| 401 | Auth failure | Missing/invalid token, expired token |
| 403 | Permission denied | Insufficient scope |
| 429 | Rate limited | Too many requests |
| 500 | Server error (transient) | Internal failure, retry recommended |
| 502 | Bad gateway | Upstream failure, retry recommended |
| 503 | Unavailable | Maintenance, retry recommended |
Notes
- All errors are JSON (never HTML error pages)
- Field descriptions are advisory (may vary; use
errorcode as canonical) - Retry logic should be smart: Different codes have different retry strategies
- Rate limiting headers: Include
Retry-Afterif present; use for backoff timing - Device flow:
authorization_pendingis expected and normal; continue polling - 401 on API vs. auth endpoint: Different semantics; see "API Call Errors" above
oauth-authorize-endpoint.md
OAuth 2.0 Authorization Endpoint
Endpoint: GET https://api.spec-kitty.com/oauth/authorize RFC: RFC 6749 (OAuth 2.0), RFC 7636 (PKCE) Flow: Authorization Code + PKCE Client Type: Public (browser callback to localhost loopback)
Request
HTTP Method
GET with query parameters (not POST).
Query String Format
GET https://api.spec-kitty.com/oauth/authorize?
client_id=cli_<client-id>&
redirect_uri=http://localhost:8080/callback&
response_type=code&
scope=offline_access+api.read+api.write&
state=<128-bit-random-base64url>&
code_challenge=<SHA256-base64url>&
code_challenge_method=S256
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | CLI OAuth client ID (format: cli_<id>) |
redirect_uri | string | Yes | Loopback callback URL (must be http://localhost:PORT/callback) |
response_type | string | Yes | Must be "code" (authorization code flow) |
scope | string | Yes | Space-separated scopes; must include offline_access |
state | string | Yes | CSRF nonce (≥128 bits entropy, base64url-encoded) |
code_challenge | string | Yes | SHA256(code_verifier) base64url-encoded (RFC 7636) |
code_challenge_method | string | Yes | Must be "S256" (SHA256); "plain" not supported |
Scope Values
Standard scopes for CLI:
offline_access— REQUIRED; enables refresh token issuanceapi.read— Read access to API resourcesapi.write— Write access to API resources
Example: offline_access api.read api.write
Response
Success (302 Redirect)
Status: 302 Found
Location Header:
http://localhost:8080/callback?
code=<authorization-code>&
state=<echo-original-state>
Response Parameters
| Parameter | Type | Description |
|---|---|---|
code | string | Authorization code (opaque, 5–10 min lifetime) |
state | string | Echo of request state parameter (for CSRF validation) |
Example:
HTTP/1.1 302 Found
Location: http://localhost:8080/callback?code=authcode_abc123def456&state=base64url_state_value
Code Validation Rules
- Authorization code is single-use (cannot be exchanged twice)
- Lifetime: 5–10 minutes from issuance
- Must be exchanged at
/oauth/tokenwith matchingcode_verifier
Error Responses
See error-responses.md for standardized error codes. Common errors:
| Error | Description |
|---|---|
invalid_request | Missing/malformed parameters (e.g., no client_id) |
invalid_client | Unknown or untrusted client_id |
invalid_scope | Requested scope not available to client |
invalid_redirect_uri | redirect_uri not registered for this client |
unauthorized_client | Client not permitted to use this flow |
server_error | Transient server error; user should retry |
access_denied | User denied authorization in browser |
Example:
HTTP/1.1 302 Found
Location: http://localhost:8080/callback?
error=access_denied&
error_description=User+declined+authorization&
state=base64url_state_value
CLI Integration Points
1. Launch browser: CLI constructs authorization URL and opens browser with start_browser(auth_url) 2. Loopback callback: CLI listens on localhost:PORT and captures code + state 3. State validation: CLI verifies state matches original PKCE state 4. Code exchange: CLI calls /oauth/token with code + code_verifier 5. Timeout: If no callback within 5 minutes, authorization request expires (show user "Authorization timeout" message)
Security Requirements
- PKCE mandatory (no
plainmethod; must useS256) - State parameter validation required (prevents CSRF)
- Redirect URI enforcement (loopback only; must be registered)
- HTTPS for all production calls (localhost callback is exempt)
- User agent (not client credentials) — user is required to authenticate in browser
Notes
- This is the user-facing interactive flow. Machine/service auth uses separate endpoints.
- Callback must be to
http://localhost:*(port arbitrary, determined at runtime). - No client secret required (public client).
- Authorization scope includes
offline_accessto obtain refresh token.
oauth-device-endpoint.md
OAuth 2.0 Device Authorization Endpoint
Endpoint: POST https://api.spec-kitty.com/oauth/device RFC: RFC 8628 (Device Authorization Grant) Flow: Device Authorization Flow (headless fallback) Content-Type: application/x-www-form-urlencoded
Request
Headers
POST /oauth/device HTTP/1.1
Host: api.spec-kitty.com
Content-Type: application/x-www-form-urlencoded
Accept: application/json
Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | CLI OAuth client ID (format: cli_<id>) |
scope | string | Yes | Space-separated scopes (must include offline_access) |
Example Request:
POST /oauth/device HTTP/1.1
Host: api.spec-kitty.com
Content-Type: application/x-www-form-urlencoded
client_id=cli_abc123&scope=offline_access+api.read+api.write
Response
Success (200 OK)
Status: 200 OK Content-Type: application/json
{
"device_code": "DEVICE_ABC123XYZ789",
"user_code": "ABCD-1234",
"verification_uri": "https://api.spec-kitty.com/device",
"expires_in": 900,
"interval": 5
}
Response Parameters
| Parameter | Type | Description |
|---|---|---|
device_code | string | Opaque device code used by CLI for polling (never show to user) |
user_code | string | Human-readable code shown to user (format: XXXX-XXXX; 8 chars + hyphen) |
verification_uri | string | URL user visits in browser to authorize (no parameters; fixed URL) |
expires_in | integer | Device code lifetime in seconds (typically 900 = 15 minutes) |
interval | integer | Recommended polling interval in seconds (typically 5; CLI caps at 10) |
Field Constraints
device_code: opaque string, ≥32 characters, URL-safeuser_code: exactly 8 alphanumeric characters + hyphen (e.g.,ABCD-1234)verification_uri: fixed HTTPS URL with no query parametersexpires_in: positive integer, typically 900 secondsinterval: positive integer ≥1 second; CLI applies max 10-second cap
CLI Integration Points
1. Request device code: CLI calls POST /oauth/device with client_id and scope 2. Display to user: Show user_code and verification_uri ("Visit https://... and enter ABCD-1234") 3. Start polling loop: CLI polls /oauth/token with device_code every interval seconds (capped at 10s) 4. Timeout: If polling exceeds expires_in seconds without approval, stop and show "Device code expired" 5. Approval handler: When user approves in browser, /oauth/token returns tokens (see oauth-token-endpoint.md)
Error Responses
See error-responses.md for standardized error codes. Common errors:
| Error | Description |
|---|---|
invalid_request | Missing or malformed parameters |
invalid_client | Unknown or untrusted client_id |
invalid_scope | Requested scope not available to client |
server_error | Transient server error; user should retry |
Example:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "invalid_client",
"error_description": "Client not recognized"
}
User Verification Flow (Separate Browser UI)
The verification_uri is a fixed URL that the SaaS provides; CLI does not construct it.
User browser flow: 1. User opens https://api.spec-kitty.com/device in browser 2. Prompted to enter user_code (e.g., "ABCD-1234") 3. SaaS looks up device by user_code and displays what the CLI is requesting 4. User approves (authenticates if needed) 5. SaaS marks device code as approved 6. CLI's polling loop receives approval at next poll
Security Notes
- Device code never exposed to user (only
user_codeis shown) - User verification UI is SaaS responsibility (not CLI)
- No client secret required (public client)
- Scope must include
offline_accessfor refresh token issuance - Codes are single-use and time-limited (prevent reuse attacks)
Polling Behavior
See oauth-token-endpoint.md for polling request format and responses.
Polling continues until one of:
expires_inseconds elapse (device code expires)- User approves (tokens returned)
- User denies (error
access_denied) - Polling receives error
expired_token(device code revoked)
Notes
- This endpoint is called once per headless login session.
- Multiple CLI instances cannot share the same device code (polling is per-CLI instance).
- User verification UI is entirely SaaS-hosted; CLI only displays
user_codeandverification_uri.
oauth-token-endpoint.md
OAuth 2.0 Token Endpoint
Endpoint: POST https://api.spec-kitty.com/oauth/token RFC: RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 8628 (Device Flow) Content-Type: application/x-www-form-urlencoded Flow: All flows (Authorization Code, Device Code, Refresh Token)
Request
Headers
POST /oauth/token HTTP/1.1
Host: api.spec-kitty.com
Content-Type: application/x-www-form-urlencoded
Accept: application/json
Common Parameters (All Requests)
| Parameter | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | CLI OAuth client ID (format: cli_<id>) |
grant_type | string | Yes | One of: authorization_code, device_code, refresh_token |
Grant Type: authorization_code
Purpose: Exchange authorization code for tokens (final step of browser-based login)
Request Body
POST /oauth/token HTTP/1.1
Host: api.spec-kitty.com
Content-Type: application/x-www-form-urlencoded
client_id=cli_abc123&
grant_type=authorization_code&
code=authcode_xyz789&
redirect_uri=http://localhost:8080/callback&
code_verifier=<43-char-pkce-verifier>
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | CLI OAuth client ID |
grant_type | string | Yes | Must be authorization_code |
code | string | Yes | Authorization code from /oauth/authorize callback |
redirect_uri | string | Yes | Must match redirect URI used in /oauth/authorize request |
code_verifier | string | Yes | PKCE verifier (43 ASCII characters; RFC 7636) |
Field Constraints
code: opaque string, single-use, 5–10 minute lifetimeredirect_uri: must be exact match to authorization requestcode_verifier: exactly 43 characters (unreserved ASCII chars per RFC 7636)
Grant Type: device_code
Purpose: Polling request for device code approval (headless flow)
Request Body
POST /oauth/token HTTP/1.1
Host: api.spec-kitty.com
Content-Type: application/x-www-form-urlencoded
client_id=cli_abc123&
grant_type=device_code&
device_code=DEVICE_ABC123XYZ789
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | CLI OAuth client ID |
grant_type | string | Yes | Must be device_code |
device_code | string | Yes | Device code from /oauth/device response |
Polling Behavior
- CLI calls this endpoint repeatedly every
intervalseconds (from/oauth/deviceresponse) - Returns
200 OKwith tokens when user approves - Returns
400 Bad Requestwith errorauthorization_pendingwhile awaiting approval - Returns
400 Bad Requestwith erroraccess_deniedif user denies - Returns
400 Bad Requestwith errorexpired_tokenif device code has expired
Grant Type: refresh_token
Purpose: Obtain new access token using refresh token (keep session alive)
Request Body
POST /oauth/token HTTP/1.1
Host: api.spec-kitty.com
Content-Type: application/x-www-form-urlencoded
client_id=cli_abc123&
grant_type=refresh_token&
refresh_token=rf_<refresh-token>
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | CLI OAuth client ID |
grant_type | string | Yes | Must be refresh_token |
refresh_token | string | Yes | Refresh token from prior token response |
Response (All Grant Types)
Success (200 OK)
Status: 200 OK Content-Type: application/json
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"token_type": "Bearer",
"refresh_token": "rf_5C4E9...",
"expires_in": 3600,
"refresh_token_expires_in": 7776000,
"refresh_token_expires_at": "2026-07-08T13:37:14Z",
"scope": "offline_access api.read api.write",
"session_id": "sess_01HR6CYJK..."
}
Response Parameters
| Parameter | Type | Description |
|---|---|---|
access_token | string | Bearer token for API calls (opaque or JWT) |
token_type | string | Always Bearer (RFC 6750) |
refresh_token | string | Long-lived token for renewal; TTL surfaced via refresh_token_expires_in / refresh_token_expires_at (landed 2026-04-09) |
expires_in | integer | Access token lifetime in seconds (typically 3600 = 1 hour) |
refresh_token_expires_in | integer | Refresh token TTL in seconds, from the server clock at issue time. Landed 2026-04-09 per saas-amendment-refresh-ttl.md. |
refresh_token_expires_at | ISO 8601 string | Absolute refresh token expiry timestamp (UTC). Source of truth for session-end UX; CLI stores this verbatim without local clock math. Landed 2026-04-09. |
scope | string | Space-separated scopes granted; must include offline_access |
session_id | string | Server-side session identifier (ULID format, non-empty) |
Field Constraints
access_token: opaque string or JWT; never emptytoken_type: alwaysBearer(no alternatives)refresh_token: opaque string; never emptyexpires_in: positive integer; typically 3600 (1 hour) for access tokensrefresh_token_expires_in: positive integer; MUST be ≥expires_in(refresh outlives access)refresh_token_expires_at: ISO 8601 timestamp in UTC; MUST be a valid future datetime at issue timescope: must includeoffline_accessfor refresh token to be validsession_id: ULID format (non-empty), used for logout and session tracking
Error Responses
See error-responses.md for standardized error codes.
Authorization Code / Refresh Token Errors (400 Bad Request)
{
"error": "invalid_grant",
"error_description": "Authorization code has expired or was already used"
}
| Error | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Missing or malformed parameters |
invalid_client | 400 | Unknown or untrusted client_id |
invalid_grant | 400 | Code/token invalid, expired, or already used |
invalid_scope | 400 | Requested scope not available |
unauthorized_client | 400 | Client not permitted to use this grant type |
unsupported_grant_type | 400 | Unknown grant_type value |
server_error | 500 | Transient server error; CLI should retry with backoff |
Device Code Polling Errors (400 Bad Request)
In progress (awaiting approval):
{
"error": "authorization_pending",
"error_description": "User has not yet approved the device"
}
User denied:
{
"error": "access_denied",
"error_description": "User denied the authorization request"
}
Device code expired:
{
"error": "expired_token",
"error_description": "Device code has expired; request new code from /oauth/device"
}
CLI Retry Behavior
Transient errors (server_error, authorization_pending):
- Device flow: retry after
intervalseconds (from/oauth/device) - Token exchange: retry with exponential backoff (1s, 2s, 4s, …, max 60s)
Terminal errors (invalid_grant, access_denied, expired_token):
- Do not retry; notify user and require re-authentication
Security-related errors (invalid_client, unauthorized_client):
- Do not retry; likely client configuration issue
Session Management
Session lifetime:
- Access token: ~1 hour (
expires_in = 3600) - Refresh token: SaaS-managed TTL, surfaced via
refresh_token_expires_in/refresh_token_expires_aton every token response (see logout behavior) - CLI extends session indefinitely by calling refresh before access-token expiry, and stores the server-supplied refresh expiry verbatim
Logout:
- CLI calls
POST /api/v1/logoutwithsession_idto invalidate session - See
api-logout-endpoint.mdfor logout contract
Token Characteristics
Access token:
- Format: Opaque string or JWT (client must not parse JWT)
- Use:
Authorization: Bearer <access_token>header in API calls - Lifetime: ~1 hour (from
expires_in) - Validation: SaaS verifies validity on each API call
Refresh token:
- Format: Opaque string (never a JWT)
- Use: Sent to
/oauth/tokenwithgrant_type=refresh_token - Lifetime: SaaS-managed; CLI tracks via
refresh_token_expires_in/refresh_token_expires_at(landed 2026-04-09) - Storage: Secure storage backend (Keychain, file, etc.)
- Never: logged, cached in plaintext, or transmitted to API endpoints
Session ID:
- Format: ULID (unique identifier, sortable by timestamp)
- Use: Provided in token response; used for logout
- Lifetime: Tied to refresh token (valid until logout or expiry)
Rate Limiting
SaaS may apply rate limits (TBD in SaaS contract):
- Typical: 100 requests/minute per client_id
- Typical: 10 requests/minute per IP for
/oauth/device - CLI should implement adaptive backoff on 429 responses
Notes
- No client secret used (public client auth via PKCE for authorization code)
- All requests must use HTTPS (no HTTP exceptions)
- Tokens are bearer tokens (include in
Authorization: Bearerheader) - Refresh tokens are long-lived (enable indefinite session renewal)
- Session ID enables revocation (logout invalidates all API calls immediately)
saas-amendment-refresh-ttl.md
SaaS Contract Amendment: Refresh Token Lifetime Metadata
Status: LANDED on 2026-04-09 (parallel SaaS mission 032-browser-mediated-cli-auth-renewable-sessions). Target: SaaS epic #49 Affects: POST /oauth/token response schema (all grant types); GET /api/v1/me response Author: spec-kitty CLI mission 080, post-tasks review Date proposed: 2026-04-09 Date landed: 2026-04-09
Landing Summary
The SaaS team landed this amendment on 2026-04-09 (same day it was proposed). The authoritative schemas in contracts/oauth-token-endpoint.md, spec.md §7.1, §8.5, §8.6, and the WP04/WP05 _build_session() guidance have all been updated to reflect the landed fields:
refresh_token_expires_at (ISO 8601 UTC) for all three grant types (authorization_code, refresh_token, device_code)
StoredSession.refresh_token_expires_at (no client-side clock math)
POST /oauth/tokenreturnsrefresh_token_expires_in(int seconds) andGET /api/v1/mereturnsrefresh_token_expires_at- The CLI stores the server-supplied
refresh_token_expires_atverbatim in
This document is retained as a historical record only. The content below describes the original proposal; do not use it as a CLI implementation reference. The canonical contract is contracts/oauth-token-endpoint.md.
Problem (historical)
The CLI cannot reliably display refresh token expiry, warn the user before forced re-login, or compute a remaining-session countdown without an explicit server-provided refresh-token lifetime. The current SaaS token endpoint contract (see oauth-token-endpoint.md and the SaaS-side protected-endpoints.md) returns only expires_in (access token TTL) — there is no field for the refresh token's TTL.
Spec.md C-008 says "refresh token TTL is ~90 days (SaaS token policy)", but the SaaS-side merged implementation currently mixes 30-day, 90-day, and "renewable indefinitely" semantics across different code paths. A client-hardcoded 90-day default would codify drift, not resolve it.
The CLI mission therefore refuses to hardcode any refresh TTL. Until this amendment lands, the CLI sets refresh_token_expires_at = None and treats refresh expiry as server-managed: it only learns about expiry via a 400 invalid_grant response on a refresh attempt.
This blocks the following CLI behaviors:
1. spec-kitty auth status "expires in N days" display for the refresh token. Current state: auth status shows the refresh token row as Refresh token: server-managed (no client-known TTL). 2. Proactive expiry warnings in auth status when refresh expiry is near. 3. "Session ends in" countdown before forced re-login. 4. Optimistic re-login prompts when the CLI can predict a refresh failure before it happens.
These behaviors are explicitly marked as DEPENDENT on this amendment in:
notes on refresh_token_expires_at
T025 (build_session) notes
T030 (build_session) notes
T038 (duration formatter) notes
kitty-specs/080-browser-mediated-oauth-cli-auth/spec.mdconstraint C-012kitty-specs/080-browser-mediated-oauth-cli-auth/data-model.mdStoredSessionkitty-specs/080-browser-mediated-oauth-cli-auth/tasks/WP04-browser-login-flow.mdkitty-specs/080-browser-mediated-oauth-cli-auth/tasks/WP05-headless-login-flow.mdkitty-specs/080-browser-mediated-oauth-cli-auth/tasks/WP07-status-command.md
Proposed Amendment
Add the following fields to the POST /oauth/token response (all grant types: authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code).
New field: refresh_token_expires_in
{
"access_token": "at_xyz...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt_xyz...",
"refresh_token_expires_in": 7776000, ← NEW (90 days = 7776000s, example)
"issued_at": "2026-04-09T13:37:14Z", ← NEW (recommended; lets client compute exact expiry)
"scope": "offline_access",
"session_id": "sess_xyz..."
}
| Field | Type | Required | Description |
|---|---|---|---|
refresh_token_expires_in | integer | Yes | Refresh token TTL in seconds, computed from issued_at. Server is the source of truth for the lifetime; CLI MUST NOT assume a default. |
issued_at | ISO 8601 string | Recommended | When the tokens were issued, server clock. Lets the client compute the exact expiry without local-clock drift. If absent, the client uses its own now() as a fallback (small drift acceptable). |
Validation Rules
refresh_token, device_code) so the CLI can update them on every refresh
refresh_token_expires_inMUST be a positive integerrefresh_token_expires_inMUST be ≥expires_in(refresh outlives access)issued_at(if present) MUST be a valid ISO 8601 timestamp in UTC- Both fields MUST be returned by all three grant types (authorization_code,
Backwards Compatibility
This is an additive amendment. Older CLI versions ignore unknown fields. The CLI shipped from this mission tolerates the absence of these fields by setting refresh_token_expires_at = None and degrading the affected UX, so the SaaS side can roll out the amendment independently of any CLI release.
CLI-Side Implementation Plan (after amendment lands)
Once SaaS adds the fields, the CLI side does not need a new mission. The existing _build_session() helpers in WP04/WP05 already read tokens.get("refresh_token_expires_in") (returning None if absent). When the field appears, the next refresh fills in refresh_token_expires_at. WP07 status display already branches on is None to show the right message.
Specifically:
1. WP01 session.py already declares refresh_token_expires_at: Optional[datetime] 2. WP04 _build_session() reads tokens.get("refresh_token_expires_in") and sets now + timedelta(seconds=...) if present, else None 3. WP05 _build_session() does the same 4. WP07 _print_token_expiry() shows "server-managed" when None, otherwise shows the human-readable duration
No code changes are needed when the amendment lands beyond verifying that the new fields appear in real refresh responses.
Open Questions for SaaS Team
1. Renewal semantics: Does refresh token TTL extend on every refresh (sliding window) or stay fixed from initial login (absolute expiry)?
successful refresh — already supported by the existing TokenRefreshFlow._update_session() shape
and never update it during refresh
- If sliding: the CLI needs to update
refresh_token_expires_aton every - If absolute: the CLI should set
refresh_token_expires_atonce at login - Document the chosen semantics in the amendment doc on the SaaS side
2. Inactive session expiry: Is the 90-day (or whatever) TTL extended on token use, or only on refresh? If extended on use, does /api/v1/me count as use? CLI behavior depends on the answer.
3. Server clock vs client clock: If issued_at is omitted, how should the CLI handle clock skew (e.g., user's laptop is 10 minutes off NTP)? Recommendation: include issued_at as a Recommended field and document that clients should fall back to local now() if absent.
Acceptance Criteria for SaaS Side
protected-endpoints.md) updated with the new fields
documents it in the amendment
- □
POST /oauth/tokenreturnsrefresh_token_expires_infor all grant types - □
POST /oauth/tokenreturnsissued_at(recommended) for all grant types - □ Documentation in SaaS contract docs (
oauth-token-endpoint.md, - □ At least one SaaS test verifies the new fields appear in token responses
- □ SaaS team confirms the renewal semantics (sliding vs absolute) and
Acceptance Criteria for CLI Side (this mission)
The CLI mission can ship without this amendment landing. After the amendment lands, CLI behavior automatically improves with no code changes required:
and sets refresh_token_expires_at only if present
when refresh_token_expires_at is None
is_refresh_token_expired() if the value is set; otherwise lets the SaaS reject refresh attempts with invalid_grant
- □ WP01
StoredSession.refresh_token_expires_atisOptional[datetime] - □ WP04/WP05
_build_session()readstokens.get("refresh_token_expires_in") - □ WP07
_print_token_expiry()displays "server-managed (no client-known TTL)" - □ WP01
TokenManager.refresh_if_needed()only checks - □ No CLI code anywhere contains a hardcoded 90-day (or any other) refresh TTL