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: Bearer header
  • 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

ParameterTypeDescription
statusstringAlways "logged_out" on success
session_idstringSession ID that was revoked (echo of request)
messagestringHuman-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 OK every 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_id is 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

ParameterTypeRequiredDescription
team_idstringYesTeam 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

ParameterTypeDescription
ws_tokenstringShort-lived WebSocket auth token (opaque)
expires_inintegerToken lifetime in seconds (typically 3600 = 1 hour)
session_idstringSession ID (for audit and reference)
ws_urlstringWebSocket endpoint to connect to

Field Constraints

  • ws_token: opaque string; never empty
  • expires_in: positive integer; typically 3600 seconds (1 hour)
  • session_id: session identifier from current session
  • ws_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/token with refresh_token to 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-token endpoint
  • 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_id requested
  • 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-token call
  • 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 Authorization header (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_id must 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

FieldTypeRequiredDescription
errorstringYesMachine-readable error code (see codes below)
error_descriptionstringNoHuman-readable description of the error
error_uristringNoURL to error documentation

Error Codes by HTTP Status

400 Bad Request

Common causes: Malformed request, missing parameters, invalid values

Error CodeDescriptionRetryExample
invalid_requestMissing or malformed parametersNoMissing client_id in request
invalid_clientUnknown or untrusted clientNoclient_id not recognized by SaaS
invalid_scopeRequested scope not availableNoRequested scope admin not available to client
invalid_grantCode/token invalid, expired, or reusedNoAuthorization code already exchanged once
invalid_redirect_uriRedirect URI not registeredNoredirect_uri mismatch from authorization request
unauthorized_clientClient not permitted to use this flowNoClient not allowed for this grant_type
unsupported_grant_typeUnknown grant typeNoUnknown value for grant_type parameter
access_deniedUser denied the requestNoUser clicked "Deny" in browser
authorization_pendingDevice code: awaiting user approvalYesUser has not yet approved device
expired_tokenDevice code or token has expiredNoDevice code exceeded expires_in window
server_errorTransient server errorYesTemporary 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 CodeHTTPDescriptionRetry
invalid_token401Access token missing, invalid, or expiredNo (refresh token and retry)
insufficient_scope401Token valid but insufficient scope for operationNo (request new scope)
token_revoked401Token revoked via logout or SaaS adminNo (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 CodeHTTPDescriptionRetry
access_denied403User lacks permission for this operationNo
insufficient_scope403Token scope insufficient for operationNo

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 CodeHTTPDescriptionRetry
rate_limited429Too many requests from this clientYes (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 CodeHTTPDescriptionRetry
server_error500Transient server errorYes (with backoff)
temporarily_unavailable500Service temporarily unavailableYes (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 CodeHTTPDescriptionRetry
server_error502Upstream service errorYes (with backoff)

503 Service Unavailable

Common causes: Scheduled maintenance or overload

Error CodeHTTPDescriptionRetry
temporarily_unavailable503Service temporarily unavailableYes (with backoff)

CLI Error Handling Strategy

By Error Type

Terminal Errors (do not retry):

  • invalid_request, invalid_client, invalid_scope, invalid_grant, invalid_redirect_uri
  • access_denied, insufficient_scope, unauthorized_client, unsupported_grant_type
  • invalid_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 via refresh_token and 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_in exceeded
  • 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_uri
  • unauthorized_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:

ErrorMeaningCLI Action
authorization_pendingUser hasn't approved yetWait interval seconds, retry
access_deniedUser denied authorizationStop polling, show "Authorization denied"
expired_tokenDevice code expiredStop polling, show "Device code expired; run login again"
server_errorTransient failureRetry 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:

ErrorAction
access_token_expiredRefresh via /oauth/token, retry request (1x)
session_invalidDelete session, show "Session expired; run login again"
Other 401Show error, require re-login

HTTP Status Summary

StatusUse CaseExamples
200Success (all endpoints)Token obtained, logout complete, etc.
302Redirect (authorization endpoint)Redirect to callback with code
400Client error (bad request)Malformed request, invalid grant, access denied
401Auth failureMissing/invalid token, expired token
403Permission deniedInsufficient scope
429Rate limitedToo many requests
500Server error (transient)Internal failure, retry recommended
502Bad gatewayUpstream failure, retry recommended
503UnavailableMaintenance, retry recommended

Notes

  • All errors are JSON (never HTML error pages)
  • Field descriptions are advisory (may vary; use error code as canonical)
  • Retry logic should be smart: Different codes have different retry strategies
  • Rate limiting headers: Include Retry-After if present; use for backoff timing
  • Device flow: authorization_pending is 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

ParameterTypeRequiredDescription
client_idstringYesCLI OAuth client ID (format: cli_<id>)
redirect_uristringYesLoopback callback URL (must be http://localhost:PORT/callback)
response_typestringYesMust be "code" (authorization code flow)
scopestringYesSpace-separated scopes; must include offline_access
statestringYesCSRF nonce (≥128 bits entropy, base64url-encoded)
code_challengestringYesSHA256(code_verifier) base64url-encoded (RFC 7636)
code_challenge_methodstringYesMust be "S256" (SHA256); "plain" not supported

Scope Values

Standard scopes for CLI:

  • offline_accessREQUIRED; enables refresh token issuance
  • api.read — Read access to API resources
  • api.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

ParameterTypeDescription
codestringAuthorization code (opaque, 5–10 min lifetime)
statestringEcho 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/token with matching code_verifier

Error Responses

See error-responses.md for standardized error codes. Common errors:

ErrorDescription
invalid_requestMissing/malformed parameters (e.g., no client_id)
invalid_clientUnknown or untrusted client_id
invalid_scopeRequested scope not available to client
invalid_redirect_uriredirect_uri not registered for this client
unauthorized_clientClient not permitted to use this flow
server_errorTransient server error; user should retry
access_deniedUser 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 plain method; must use S256)
  • 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_access to 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

ParameterTypeRequiredDescription
client_idstringYesCLI OAuth client ID (format: cli_<id>)
scopestringYesSpace-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

ParameterTypeDescription
device_codestringOpaque device code used by CLI for polling (never show to user)
user_codestringHuman-readable code shown to user (format: XXXX-XXXX; 8 chars + hyphen)
verification_uristringURL user visits in browser to authorize (no parameters; fixed URL)
expires_inintegerDevice code lifetime in seconds (typically 900 = 15 minutes)
intervalintegerRecommended polling interval in seconds (typically 5; CLI caps at 10)

Field Constraints

  • device_code: opaque string, ≥32 characters, URL-safe
  • user_code: exactly 8 alphanumeric characters + hyphen (e.g., ABCD-1234)
  • verification_uri: fixed HTTPS URL with no query parameters
  • expires_in: positive integer, typically 900 seconds
  • interval: 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:

ErrorDescription
invalid_requestMissing or malformed parameters
invalid_clientUnknown or untrusted client_id
invalid_scopeRequested scope not available to client
server_errorTransient 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_code is shown)
  • User verification UI is SaaS responsibility (not CLI)
  • No client secret required (public client)
  • Scope must include offline_access for 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_in seconds 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_code and verification_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)

ParameterTypeRequiredDescription
client_idstringYesCLI OAuth client ID (format: cli_<id>)
grant_typestringYesOne 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

ParameterTypeRequiredDescription
client_idstringYesCLI OAuth client ID
grant_typestringYesMust be authorization_code
codestringYesAuthorization code from /oauth/authorize callback
redirect_uristringYesMust match redirect URI used in /oauth/authorize request
code_verifierstringYesPKCE verifier (43 ASCII characters; RFC 7636)

Field Constraints

  • code: opaque string, single-use, 5–10 minute lifetime
  • redirect_uri: must be exact match to authorization request
  • code_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

ParameterTypeRequiredDescription
client_idstringYesCLI OAuth client ID
grant_typestringYesMust be device_code
device_codestringYesDevice code from /oauth/device response

Polling Behavior

  • CLI calls this endpoint repeatedly every interval seconds (from /oauth/device response)
  • Returns 200 OK with tokens when user approves
  • Returns 400 Bad Request with error authorization_pending while awaiting approval
  • Returns 400 Bad Request with error access_denied if user denies
  • Returns 400 Bad Request with error expired_token if 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

ParameterTypeRequiredDescription
client_idstringYesCLI OAuth client ID
grant_typestringYesMust be refresh_token
refresh_tokenstringYesRefresh 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

ParameterTypeDescription
access_tokenstringBearer token for API calls (opaque or JWT)
token_typestringAlways Bearer (RFC 6750)
refresh_tokenstringLong-lived token for renewal; TTL surfaced via refresh_token_expires_in / refresh_token_expires_at (landed 2026-04-09)
expires_inintegerAccess token lifetime in seconds (typically 3600 = 1 hour)
refresh_token_expires_inintegerRefresh token TTL in seconds, from the server clock at issue time. Landed 2026-04-09 per saas-amendment-refresh-ttl.md.
refresh_token_expires_atISO 8601 stringAbsolute refresh token expiry timestamp (UTC). Source of truth for session-end UX; CLI stores this verbatim without local clock math. Landed 2026-04-09.
scopestringSpace-separated scopes granted; must include offline_access
session_idstringServer-side session identifier (ULID format, non-empty)

Field Constraints

  • access_token: opaque string or JWT; never empty
  • token_type: always Bearer (no alternatives)
  • refresh_token: opaque string; never empty
  • expires_in: positive integer; typically 3600 (1 hour) for access tokens
  • refresh_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 time
  • scope: must include offline_access for refresh token to be valid
  • session_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"
}
ErrorHTTPMeaning
invalid_request400Missing or malformed parameters
invalid_client400Unknown or untrusted client_id
invalid_grant400Code/token invalid, expired, or already used
invalid_scope400Requested scope not available
unauthorized_client400Client not permitted to use this grant type
unsupported_grant_type400Unknown grant_type value
server_error500Transient 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 interval seconds (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_at on 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/logout with session_id to invalidate session
  • See api-logout-endpoint.md for 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/token with grant_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: Bearer header)
  • 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/token returns refresh_token_expires_in (int seconds) and
  • GET /api/v1/me returns refresh_token_expires_at
  • The CLI stores the server-supplied refresh_token_expires_at verbatim 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.md constraint C-012
  • kitty-specs/080-browser-mediated-oauth-cli-auth/data-model.md StoredSession
  • kitty-specs/080-browser-mediated-oauth-cli-auth/tasks/WP04-browser-login-flow.md
  • kitty-specs/080-browser-mediated-oauth-cli-auth/tasks/WP05-headless-login-flow.md
  • kitty-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..."
}
FieldTypeRequiredDescription
refresh_token_expires_inintegerYesRefresh token TTL in seconds, computed from issued_at. Server is the source of truth for the lifetime; CLI MUST NOT assume a default.
issued_atISO 8601 stringRecommendedWhen 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_in MUST be a positive integer
  • refresh_token_expires_in MUST 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_at on every
  • If absolute: the CLI should set refresh_token_expires_at once 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/token returns refresh_token_expires_in for all grant types
  • POST /oauth/token returns issued_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_at is Optional[datetime]
  • □ WP04/WP05 _build_session() reads tokens.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