API reference — identity service
API reference — identity service
Section titled “API reference — identity service”REST API
Section titled “REST API”Base URL: http[s]://<host>:8081
All request bodies are application/json. All error responses carry
{"code": "<error_code>"}. Success responses carry {"field": value, ...}.
Authentication
Section titled “Authentication”Most endpoints require a bearer token in the Authorization header:
Authorization: Bearer <access_token>Endpoints that accept API keys instead of (or in addition to) JWTs are noted per-endpoint. The middleware resolves the credential type automatically — callers do not need to specify which type they are using.
Mutating endpoints (POST/PATCH, excluding auth flows) require an Idempotency-Key header. The
same request replayed within the deduplication window returns the original response without
re-executing the handler.
Endpoints
Section titled “Endpoints”POST /v1/auth/login
Section titled “POST /v1/auth/login”Authenticates with email and password. Returns an RFC 6749 §5.1 token pair.
Auth required: no
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | case-insensitive (citext) |
password | string | yes | |
x_device_label | string | no | label stored on the refresh token row |
Response 200:
{ "access_token": "<RS256 JWT>", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "pb_live_rt_..."}Error codes:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Body missing or malformed |
| 401 | unauthenticated | Wrong email, password, or membership |
| 500 | internal | Server-side failure |
POST /v1/auth/refresh
Section titled “POST /v1/auth/refresh”Rotates a refresh token and returns a fresh token pair. Reuse-detection revokes the family.
Auth required: no
Request body:
| Field | Type | Required |
|---|---|---|
refresh_token | string | yes |
Response 200: same shape as /v1/auth/login.
Error codes:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | refresh_token_required | Body missing refresh_token |
| 401 | refresh_token_reused | Token already consumed; family revoked |
| 401 | refresh_token_invalid | Expired, revoked, or not found |
| 500 | internal |
On reuse detection the body includes "action": "re_authenticate".
POST /v1/auth/logout
Section titled “POST /v1/auth/logout”Revokes the refresh token family associated with the given token. Always returns 204; unknown
or empty tokens are no-ops (no existence leak).
Auth required: no
Request body:
| Field | Type | Required |
|---|---|---|
refresh_token | string | no |
Response 204: no body.
POST /v1/auth/revoke-all
Section titled “POST /v1/auth/revoke-all”Revokes every non-revoked refresh token for a target user. Owner role required.
Auth required: JWT, role owner
Request body:
| Field | Type | Required |
|---|---|---|
user_id | string | yes |
Response 204: no body.
Error codes:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | user_id_required | user_id absent |
| 400 | user_id_invalid | user_id not a valid UUID |
| 403 | insufficient_role | Caller is not an owner |
GET /v1/users/me
Section titled “GET /v1/users/me”Returns the authenticated user’s profile.
Auth required: JWT or API key
Response 200:
{ "id": "<uuid>", "email": "user@example.com", "name": "Alice", "created_at": "2026-05-24T00:00:00Z"}GET /v1/users/me/api_keys
Section titled “GET /v1/users/me/api_keys”Lists all non-deleted API keys for the authenticated user.
Auth required: JWT or API key
Response 200: array of:
[ { "id": "<uuid>", "name": "ci-key", "key_prefix": "pb_live_", "env": "live", "expires_at": null, "last_used_at": "2026-05-24T00:00:00Z" }]POST /v1/users/me/api_keys
Section titled “POST /v1/users/me/api_keys”Mints a new API key. The raw key is returned once and never stored — treat it as a secret.
Auth required: JWT only (API keys cannot mint API keys)
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | human label |
env | string | no | live (default) or test |
expires_at | string | no | RFC 3339 expiry timestamp |
Response 201:
{ "id": "<uuid>", "key_prefix": "pb_live_", "raw_key": "pb_live_<random>", "env": "live", "expires_at": null}POST /v1/users/me/api_keys/{id}/revoke
Section titled “POST /v1/users/me/api_keys/{id}/revoke”Soft-deletes an API key. The key stops authenticating immediately.
Auth required: JWT only
Response 204: no body.
GET /v1/orgs/{id}
Section titled “GET /v1/orgs/{id}”Returns the organization. Cross-tenant: returns 404 if id is not the caller’s org.
Auth required: JWT or API key
Response 200:
{ "id": "<uuid>", "name": "Acme", "slug": "acme", "created_at": "2026-05-24T00:00:00Z", "updated_at": "2026-05-24T00:00:00Z"}PATCH /v1/orgs/{id}
Section titled “PATCH /v1/orgs/{id}”Updates org name and/or slug. Owner role required.
Auth required: JWT, role owner
Request body: all fields optional; omitted fields are unchanged.
| Field | Type |
|---|---|
name | string |
slug | string |
Response 200: same shape as GET /v1/orgs/{id}.
Common error codes
Section titled “Common error codes”| Code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Malformed body or missing required field |
unauthenticated | 401 | Missing or invalid credential |
insufficient_role | 403 | Valid credential but wrong role |
resource_not_found | 404 | Entity missing or cross-tenant isolation |
internal | 500 | Unexpected server error |
gRPC API — identity.v1.AuthService
Section titled “gRPC API — identity.v1.AuthService”Proto source: paper-board/proto/identity/v1/auth.proto
Internal consumers call identity.paper-board.svc.cluster.local:50051 (Phase 1–5 plain gRPC;
Phase 5+ mTLS via cert-manager).
VerifyAPIKey
Section titled “VerifyAPIKey”// from paper-board/proto/identity/v1/auth.protorpc VerifyAPIKey(VerifyAPIKeyRequest) returns (VerifyAPIKeyResponse);Authenticates a raw API key. Returns an AuthContext on success.
Errors: UNAUTHENTICATED (invalid_credentials, wrong_environment, no_membership),
INTERNAL.
VerifyJWT
Section titled “VerifyJWT”// from paper-board/proto/identity/v1/auth.protorpc VerifyJWT(VerifyJWTRequest) returns (VerifyJWTResponse);Validates an RS256 JWT and returns an AuthContext enriched with the current membership role.
Errors: UNAUTHENTICATED (expired, key_retired, invalid_credentials, no_membership),
INTERNAL.
IssueJWT
Section titled “IssueJWT”// from paper-board/proto/identity/v1/auth.protorpc IssueJWT(IssueJWTRequest) returns (IssueJWTResponse);Issues a signed JWT for a known (user_id, org_id) pair. TTL is clamped to 24 h; zero uses the
service default (3600 s). Callers must verify membership exists before calling — the RPC asserts
it and returns PERMISSION_DENIED (not_a_member) if not.
GetPublicKey
Section titled “GetPublicKey”// from paper-board/proto/identity/v1/auth.protorpc GetPublicKey(GetPublicKeyRequest) returns (GetPublicKeyResponse);Returns the DER-encoded RSA public key for a given kid. Used by consumers that verify JWTs
locally instead of delegating to VerifyJWT. Returns NOT_FOUND (kid_unknown) for retired
or unknown key IDs.
AuthContext message
Section titled “AuthContext message”All Verify* RPCs return an AuthContext:
| Field | Type | Notes |
|---|---|---|
user_id | string | UUID |
org_id | string | UUID |
mode | AuthMode | AUTH_MODE_JWT or AUTH_MODE_API_KEY |
role | Role | ROLE_OWNER or ROLE_MEMBER |
env | Env | ENV_LIVE or ENV_TEST |
auth_key_id | string | kid (JWT mode only) |
api_key_id | string | UUID (API key mode only) |
method | string | "jwt_session" or "api_key:<prefix>" |
JWT claim schema
Section titled “JWT claim schema”Tokens are RS256-signed with a key from identity.auth_keys (state active). The kid header
field identifies the signing key.
| Claim | Value |
|---|---|
iss | identity.paper-board |
sub | user UUID |
aud | ["agents", "identity"] |
iat | Unix timestamp — issued at |
nbf | Unix timestamp — not before (= iat) |
exp | Unix timestamp — expiry |
jti | UUID — unique token ID |
org_id | org UUID |
env | "live" or "test" |
scope | "default" |
Source:
// from paper-board/identity/internal/core/jwt/issue.go:35-46claims := jwt.MapClaims{ "iss": "identity.paper-board", "sub": userID.String(), "aud": []string{"agents", "identity"}, "iat": now.Unix(), "nbf": now.Unix(), "exp": exp.Unix(), "jti": uuid.NewString(), "org_id": orgID.String(), "env": s.cfg.Env, "scope": "default",}