Auth Service — L2 Service-Level Design¶
Document status: Approved
Last updated: 2026-06-10
Author: Platform Architecture
Supersedes: Auth Service summary in L1 design (lines 1009-1055)
ADRs this document implements: ADR-7, ADR-11, ADR-19, ADR-24, ADR-29, ADR-36
Table of Contents¶
- Service Overview
- Database Schema
- Redis Key Inventory
- API Endpoints — Public
- API Endpoints — Internal
- Authentication Flows
- Bootstrap Flow
- RBAC Model
- Security Invariants
- Error Codes
- Observability
- Testing Strategy
- Deployment and Configuration
1. Service Overview¶
1.1 Responsibility¶
The Auth Service is the single authority for all identity and access decisions on the platform. No other service issues tokens, verifies passwords, or stores permission grants. The service:
- Registers and authenticates users (email/password and OAuth)
- Issues, validates, and revokes JWT access tokens and opaque refresh tokens
- Manages API keys for programmatic access
- Stores and evaluates RBAC roles, scopes, and ontology-aware entity/field/row permissions
- Runs the one-time bootstrap flow that creates the first tenant and admin user
- Exposes internal endpoints for validation by the App Service and other consumers
1.2 Network Placement¶
| Property | Value |
|---|---|
| Port (internal) | 3001 |
| Docker network | oneplatform-internal |
| External exposure | Via Gateway only (/api/v1/auth/**, /api/v1/roles/**) |
| Internal endpoints | Reachable by service-token-authenticated callers on oneplatform-internal |
| Outbound service calls | None — the Auth Service makes no outbound calls to other platform services |
The Auth Service sits at the root of the dependency graph. Every other service either depends on Auth (directly or indirectly) or consumes its tokens. Auth itself depends only on Postgres and Redis.
1.3 Startup Dependencies¶
auth-service:
depends_on:
op-init:
condition: service_completed_successfully # bootstrap.token + master.key must exist
postgres:
condition: service_healthy # schema migrations must run
redis:
condition: service_healthy # revocation + refresh token storage
Startup sequence (in order):
- Load
OP_MASTER_KEYfrom/data/init/master.keyvialoadMasterKey()(falls back to/run/secrets/op_master_keyfor Docker Secrets production deployments). - Read
/data/init/bootstrap.tokeninto memory asinMemoryBootstrapToken. Do not erase the file yet. - Run database migrations against the
authschema (using@oneplatform/core's migration runner — idempotent, version-tracked). - Read
auth.bootstrap_state.bootstrap_completedfrom Postgres. - Generate Ed25519 keypair if
/data/service.keyis absent; publish public key to/data/service-keys/auth-service.pub. - Begin inotify watch on
/data/service-keys/for hot-reloading peer public keys. - Mark
/readyzhealthy only after steps 1-6 complete without error.
If step 2 fails (file missing or empty), the service starts but POST /api/v1/auth/bootstrap returns 503 Service Unavailable with AUTH_BOOTSTRAP_TOKEN_MISSING. The operator must re-run op-init.
1.4 Environment Variables¶
| Variable | Required | Description |
|---|---|---|
OP_JWT_SECRET | Yes | HMAC-SHA256 signing key for access tokens (32+ bytes, base64). Generated by op-init. |
OP_JWT_EXPIRY_SECONDS | No | Access token TTL. Default 900 (15 minutes). |
OP_REFRESH_TOKEN_TTL_SECONDS | No | Refresh token TTL. Default 604800 (7 days). |
OP_BCRYPT_ROUNDS | No | bcrypt cost factor. Default 12. Never set below 10. |
OP_MASTER_KEY | No | Injected via file path; see startup sequence. Not set directly in env. |
OP_REQUIRE_EMAIL_VERIFICATION | No | Enforce email verification. Default false. |
OAUTH_GITHUB_CLIENT_ID | Conditional | Required if GitHub OAuth enabled. |
OAUTH_GITHUB_CLIENT_SECRET | Conditional | Required if GitHub OAuth enabled. Stored encrypted. |
OAUTH_GOOGLE_CLIENT_ID | Conditional | Required if Google OAuth enabled. |
OAUTH_GOOGLE_CLIENT_SECRET | Conditional | Required if Google OAuth enabled. |
OP_SMTP_HOST | No | If absent, password reset uses link-copy mode. |
OP_SMTP_PORT | No | Default 587. |
OP_SMTP_USER | No | SMTP username. |
OP_SMTP_PASS | No | SMTP password. |
OP_SMTP_FROM | No | From address. Default no-reply@oneplatform.local. |
DATABASE_URL | Yes | PgBouncer connection string (transaction pooling mode, pool size 20). |
REDIS_URL | Yes | Redis connection string with auth (prefix-ACL user auth-service). |
OP_BASE_URL | No | Used in OAuth redirect URIs and email links. Default http://localhost:3000. |
OP_CURSOR_SECRET | Yes | HMAC key for cursor signing. Generated by op-init. |
OP_LOG_LEVEL | No | Default info. |
2. Database Schema¶
All tables live in the auth schema. The Auth Service Postgres role is auth_service_role with USAGE on the auth schema and SELECT, INSERT, UPDATE, DELETE on all tables within it. The role does NOT have CREATE TABLE privileges in production — migrations run with the auth_migrator_role which is a separate elevated role used only by the migration runner.
2.1 Row-Level Security¶
All tables with tenant-scoped data have RLS enabled. The auth_service_role connects via PgBouncer in transaction mode, so the session variable app.tenant_id is set within each transaction using SET LOCAL (consistent with all other services — app.tenant_id is the platform-standard variable name per ADR-5).
2.2 Full Schema¶
-- ============================================================
-- Schema setup
-- ============================================================
CREATE SCHEMA IF NOT EXISTS auth;
-- ============================================================
-- auth.tenants
-- ============================================================
CREATE TABLE auth.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
settings JSONB NOT NULL DEFAULT '{}',
-- settings contains: { rateLimitTier: "standard"|"premium", maxUsers: int|null,
-- emailVerificationRequired: bool, allowedOAuthProviders: string[] }
CONSTRAINT tenants_slug_unique UNIQUE (slug),
CONSTRAINT tenants_name_not_empty CHECK (length(trim(name)) > 0),
CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]$')
);
CREATE INDEX idx_tenants_slug ON auth.tenants (slug);
-- ============================================================
-- auth.users
-- ============================================================
CREATE TABLE auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
-- email is NOT globally unique — the same email can exist in multiple tenants
password_hash TEXT,
-- NULL for OAuth-only accounts
email_verified BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
display_name TEXT,
roles TEXT[] NOT NULL DEFAULT '{}',
-- roles are names from auth.roles (denormalized for query performance)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ,
failed_login_count INTEGER NOT NULL DEFAULT 0,
locked_until TIMESTAMPTZ,
-- Account lockout: locked after 10 consecutive failed logins for 15 minutes
metadata JSONB NOT NULL DEFAULT '{}',
CONSTRAINT users_email_per_tenant_unique UNIQUE (tenant_id, email),
CONSTRAINT users_email_format CHECK (email ~* '^[^@\s]+@[^@\s]+\.[^@\s]+$'),
CONSTRAINT users_roles_valid CHECK (array_length(roles, 1) IS NULL OR array_length(roles, 1) <= 20)
);
CREATE INDEX idx_users_tenant_id ON auth.users (tenant_id);
CREATE INDEX idx_users_email ON auth.users (lower(email));
CREATE INDEX idx_users_tenant_email ON auth.users (tenant_id, lower(email));
-- Partial index for active users only (most queries exclude inactive):
CREATE INDEX idx_users_active ON auth.users (tenant_id) WHERE is_active = true;
ALTER TABLE auth.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_tenant_isolation ON auth.users
USING (tenant_id = current_setting('app.tenant_id', true)::uuid
OR current_setting('app.bypass_rls', true) = 'true');
-- ============================================================
-- auth.sessions
-- ============================================================
-- One row per active refresh token. Refresh tokens are opaque strings stored
-- in Redis (keyed by token value); this table is the durable record that survives
-- Redis restarts. The Redis entry is the fast-path lookup; the Postgres row is
-- the source of truth for audit and forced logout.
CREATE TABLE auth.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
refresh_token_jti UUID NOT NULL,
-- The JTI of the CURRENT refresh token in this session's rotation chain.
-- On each rotation, this value updates atomically.
family_id UUID NOT NULL DEFAULT gen_random_uuid(),
-- family_id is assigned at session creation and never changes.
-- If a refresh token is replayed (already-rotated token reused), the ENTIRE
-- family is invalidated (all sessions with matching family_id).
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
revoked_reason TEXT,
-- e.g., "logout", "password_reset", "admin_revoke", "token_replay_detected"
user_agent TEXT,
ip_address INET,
CONSTRAINT sessions_refresh_token_jti_unique UNIQUE (refresh_token_jti),
CONSTRAINT sessions_expires_after_created CHECK (expires_at > created_at)
);
CREATE INDEX idx_sessions_user_id ON auth.sessions (user_id);
CREATE INDEX idx_sessions_tenant_id ON auth.sessions (tenant_id);
CREATE INDEX idx_sessions_family_id ON auth.sessions (family_id);
CREATE INDEX idx_sessions_refresh_token_jti ON auth.sessions (refresh_token_jti);
-- Partial index for active (non-expired, non-revoked) sessions:
CREATE INDEX idx_sessions_active ON auth.sessions (user_id)
WHERE revoked_at IS NULL AND expires_at > now();
-- ============================================================
-- auth.api_keys
-- ============================================================
CREATE TABLE auth.api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
-- bcrypt hash of the full key string (op_live_{32-char-random})
key_prefix TEXT NOT NULL,
-- First 8 characters of the key after the op_live_ prefix.
-- Used for fast lookup before bcrypt comparison.
-- Full format: "op_live_" + 8-char-prefix + remaining chars
scopes TEXT[] NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ,
-- NULL means never expires. Revocation via Redis is always instant.
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
revoked_by UUID REFERENCES auth.users(id),
CONSTRAINT api_keys_name_not_empty CHECK (length(trim(name)) > 0),
CONSTRAINT api_keys_prefix_length CHECK (length(key_prefix) = 8),
CONSTRAINT api_keys_name_per_user UNIQUE (user_id, name)
);
CREATE INDEX idx_api_keys_user_id ON auth.api_keys (user_id);
CREATE INDEX idx_api_keys_tenant_id ON auth.api_keys (tenant_id);
CREATE INDEX idx_api_keys_key_prefix ON auth.api_keys (key_prefix);
-- Partial index for active (non-revoked, non-expired) keys:
CREATE INDEX idx_api_keys_active ON auth.api_keys (key_prefix)
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now());
-- ============================================================
-- auth.oauth_providers
-- ============================================================
CREATE TABLE auth.oauth_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
-- "github" | "google"
provider_user_id TEXT NOT NULL,
-- The user's ID within the provider (e.g., GitHub numeric user ID)
provider_email TEXT,
-- Email returned by provider; may differ from auth.users.email
access_token_encrypted TEXT,
-- AES-256-GCM encrypted provider access token (HKDF-derived per ADR-11)
refresh_token_encrypted TEXT,
token_expires_at TIMESTAMPTZ,
token_key_version INTEGER NOT NULL DEFAULT 1,
-- Key version for rotation; matches OP_MASTER_KEY version
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT oauth_providers_unique_per_user UNIQUE (user_id, provider),
CONSTRAINT oauth_providers_unique_provider_user UNIQUE (provider, provider_user_id, tenant_id),
CONSTRAINT oauth_providers_name_check CHECK (provider IN ('github', 'google'))
);
CREATE INDEX idx_oauth_providers_user_id ON auth.oauth_providers (user_id);
CREATE INDEX idx_oauth_providers_provider_lookup ON auth.oauth_providers (provider, provider_user_id);
-- ============================================================
-- auth.roles
-- ============================================================
CREATE TABLE auth.roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- NULL for platform-level predefined roles (platform-admin)
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
is_predefined BOOLEAN NOT NULL DEFAULT false,
-- Predefined roles cannot be deleted or renamed
permissions TEXT[] NOT NULL DEFAULT '{}',
-- Array of scope strings: e.g., {"data:read", "data:write", "pipelines:manage"}
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT roles_unique_name_per_tenant UNIQUE (tenant_id, name),
CONSTRAINT roles_name_not_empty CHECK (length(trim(name)) > 0)
);
CREATE INDEX idx_roles_tenant_id ON auth.roles (tenant_id);
CREATE INDEX idx_roles_name ON auth.roles (name);
-- Seed predefined roles (run in migration, not idempotent raw INSERT):
-- platform-admin, tenant-admin, developer, editor, viewer
-- See Section 8 for full scope assignments.
-- ============================================================
-- auth.entity_permissions
-- ============================================================
-- Ontology-aware entity/field/row-level permissions.
-- One row per (tenant, entity_type, role) combination.
CREATE TABLE auth.entity_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
-- Ontology entity name, e.g., "customer", "order". "*" for all entities.
role TEXT NOT NULL,
-- Role name this rule applies to
actions TEXT[] NOT NULL DEFAULT '{}',
-- Allowed actions: "create" | "read" | "update" | "delete" | "admin"
field_restrictions JSONB NOT NULL DEFAULT '{}',
-- { "deny_read": ["ssn", "credit_card"], "deny_write": ["created_at"] }
row_filter JSONB NOT NULL DEFAULT '{}',
-- Row-level filter injected into queries: e.g., { "ownerId": "$userId" }
-- "$userId" is substituted at query time with the requesting user's ID.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT entity_permissions_unique UNIQUE (tenant_id, entity_type, role)
);
CREATE INDEX idx_entity_permissions_tenant_id ON auth.entity_permissions (tenant_id);
CREATE INDEX idx_entity_permissions_lookup ON auth.entity_permissions (tenant_id, entity_type, role);
-- ============================================================
-- auth.oauth_clients
-- ============================================================
-- OAuth 2.0 client registrations. Apps (via App Service) register here.
CREATE TABLE auth.oauth_clients (
client_id TEXT PRIMARY KEY,
-- Deterministic format for app clients: "app:{appId}:{tenantId}"
-- User-created clients: "client_{uuid}"
client_secret_hash TEXT,
-- NULL for public clients (PKCE only, no client secret)
client_type TEXT NOT NULL DEFAULT 'public',
-- "public" | "confidential"
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
allowed_scopes TEXT[] NOT NULL DEFAULT '{}',
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
app_id UUID,
-- Non-null for app-auto-registered clients. References app.apps but
-- Auth Service does not have an FK to app schema by design.
access_mode TEXT NOT NULL DEFAULT 'platform-user',
-- "platform-user" | "public"
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by_service TEXT,
-- Service that created this client (e.g., "app-service")
CONSTRAINT oauth_clients_type_check CHECK (client_type IN ('public', 'confidential')),
CONSTRAINT oauth_clients_access_mode_check CHECK (access_mode IN ('platform-user', 'public')),
CONSTRAINT oauth_clients_secret_required CHECK (
client_type != 'confidential' OR client_secret_hash IS NOT NULL
)
);
CREATE INDEX idx_oauth_clients_tenant_id ON auth.oauth_clients (tenant_id);
CREATE INDEX idx_oauth_clients_app_id ON auth.oauth_clients (app_id) WHERE app_id IS NOT NULL;
-- ============================================================
-- auth.bootstrap_state
-- ============================================================
-- Single-row table. Prevents re-bootstrapping after initial setup.
CREATE TABLE auth.bootstrap_state (
id INTEGER PRIMARY KEY DEFAULT 1,
bootstrap_completed BOOLEAN NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ,
admin_user_id UUID REFERENCES auth.users(id),
first_tenant_id UUID REFERENCES auth.tenants(id),
CONSTRAINT bootstrap_state_single_row CHECK (id = 1)
);
-- Insert the single row at migration time:
INSERT INTO auth.bootstrap_state (id, bootstrap_completed) VALUES (1, false)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- auth.password_reset_tokens
-- ============================================================
-- Durable record of issued password reset tokens. The actual validation
-- is dual: JWT signature + this Postgres record (for revocation audit).
-- Redis is the fast-path single-use gate (reset:{jti}).
-- This table provides the audit trail and allows admin-side forced invalidation.
CREATE TABLE auth.password_reset_tokens (
jti UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
-- NULL means unused (or expired without use)
ip_address INET
CONSTRAINT password_reset_expires_after_created CHECK (expires_at > created_at)
);
CREATE INDEX idx_password_reset_user_id ON auth.password_reset_tokens (user_id);
-- Partial index for unused tokens (fast lookup during validation):
CREATE INDEX idx_password_reset_unused ON auth.password_reset_tokens (jti)
WHERE used_at IS NULL;
2.3 Migration Strategy¶
Migrations are run by the auth_migrator_role using @oneplatform/core's migration runner. Each migration file is named {YYYYMMDD_HHmmss}_{description}.sql. Migrations are idempotent (all CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS). The migration runner stores applied migrations in auth.schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ).
The Auth Service runs migrations on startup, before /readyz is set. If a migration fails, the service exits with code 1 and Docker Compose health check fails — dependent services do not start.
3. Redis Key Inventory¶
The Auth Service connects to Redis with the auth-service ACL user. This user has ACL rules ~auth:* ~revocation:* ~reset:* ~guest-session:* (key patterns) and is denied SELECT, FLUSHDB, FLUSHALL, KEYS, DEBUG. All keys below fall within this ACL.
| Key Pattern | Type | TTL | Purpose |
|---|---|---|---|
auth:refresh:{token} | String | 604800s (7 days) | Refresh token → JSON payload {userId, tenantId, sessionId, jti, familyId} |
revocation:{jti} | String | 900s (15 minutes) | Access token revocation blocklist. Value is "1". TTL matches max access token lifetime so the key auto-expires when the token would have expired anyway. |
reset:{jti} | String | 3600s (1 hour) | Password reset single-use gate. Value is "1". DEL on use. |
auth:oauth:state:{state} | String | 600s (10 minutes) | OAuth state parameter → JSON {provider, pkceVerifier, redirectUri, tenantId} |
auth:verify:{jti} | String | 86400s (24 hours) | Email verification single-use gate. Value is "1". DEL on use. |
guest-session:{token} | String | 86400s (24 hours) | Guest session → JSON {tenantId, appId, createdAt, ipAddress} |
auth:bootstrap:attempts:{ip} | String | 600s (10 minutes) | Bootstrap rate-limit counter per IP. Value is attempt count integer. |
auth:apikey:revocation:{keyId} | String | No TTL | API key revocation. Value is "1". Persists until key is deleted from DB. |
Redis outage behavior:
- New logins fail (cannot store refresh token). Existing access tokens continue to be accepted until their 15-minute expiry.
- Password reset emails can still be sent, but the reset endpoint returns
503(cannot validate the single-use gate without Redis). Link-copy mode still generates the link — the operator must wait for Redis to recover before users can use it. - The bootstrap flow uses in-memory counters (not Redis) for rate limiting, so it is not affected by Redis outage.
- Token revocation is blocked when Redis is down. Auth middleware falls back to fail-closed: refresh token flows reject all requests. Access tokens below their expiry age are still accepted (conservative fail-open for performance).
4. API Endpoints — Public¶
All public endpoints are under /api/v1/auth/ or /api/v1/roles/. All requests and responses follow the standard envelope from ADR-29:
- Success:
{ "data": <T> } - Collection:
{ "data": <T[]>, "pagination": { "nextCursor": string|null, "total": number|null } } - Error:
{ "error": { "code": string, "message": string, "details"?: unknown, "requestId": string } }
Zod schemas are defined in services/auth/src/schemas/. The OpenAPI 3.1 spec is auto-generated from routes + Zod at build time.
4.1 Bootstrap Endpoints¶
GET /api/v1/auth/bootstrap/status¶
Auth: None (public — called by frontend before any user exists)
Rate limit: 60/min per IP (standard public limit)
Response 200 OK:
Notes: Always returns 200. Used by the frontend to decide whether to render the setup wizard or the login page. This endpoint remains available after bootstrap completes, returning { completed: true }. It is not removed from the OpenAPI spec.
POST /api/v1/auth/bootstrap¶
Auth: None (uses bootstrapToken in body as proof of authorization)
Rate limit: 3 attempts per 10 minutes per IP (in-memory, not Redis — must work when Redis is unavailable)
Request:
z.object({
adminEmail: z.string().email().max(254),
adminPassword: z.string().min(12).max(128),
tenantName: z.string().min(1).max(100).trim(),
bootstrapToken: z.string().length(64), // 32 bytes hex = 64 chars
})
Response 201 Created:
z.object({
data: z.object({
tenantId: z.string().uuid(),
adminUserId: z.string().uuid(),
accessToken: z.string(),
refreshToken: z.string(),
expiresIn: z.number(),
})
})
Error responses:
| Code | HTTP | Condition |
|---|---|---|
AUTH_BOOTSTRAP_ALREADY_COMPLETED | 410 | bootstrap_completed = true in Postgres |
AUTH_BOOTSTRAP_INVALID_TOKEN | 401 | bootstrapToken does not match in-memory value (constant-time compare) |
AUTH_BOOTSTRAP_TOKEN_MISSING | 503 | Service started but bootstrap.token file was absent |
VALIDATION_ERROR | 422 | Request body fails Zod schema |
RATE_LIMIT_EXCEEDED | 429 | More than 3 attempts in 10 minutes from this IP |
Implementation notes:
- Validate
bootstrapTokenusingcrypto.timingSafeEqual()againstBuffer.from(inMemoryBootstrapToken, 'hex'). Fail withAUTH_BOOTSTRAP_INVALID_TOKENif mismatch. Important: always run the comparison regardless of prior state — do not short-circuit and return early before comparing, as that leaks timing information about whether bootstrap is complete. - In a single Postgres transaction: (a) INSERT into
auth.tenants, (b) bcrypt-hash password at cost 12, (c) INSERT intoauth.userswithroles = ['platform-admin']andemail_verified = true, (d) UPDATEauth.bootstrap_state SET bootstrap_completed = true, completed_at = now(), admin_user_id = <id>, first_tenant_id = <id>. - Issue access + refresh tokens. Store refresh token in Redis and
auth.sessions. - Zero
inMemoryBootstrapTokenin memory (inMemoryBootstrapToken = null). - Erase
/data/init/bootstrap.tokenfrom disk (best-effort — log warning if erase fails, do not fail the request). - All subsequent calls return
410 AUTH_BOOTSTRAP_ALREADY_COMPLETEDbefore any token comparison.
The flag is set in the same transaction as user creation — there is no window where a user exists without bootstrap_completed = true.
GET /internal/auth/master-key-display¶
Auth: None (bootstrap rate limiter applies: 3/10min per IP)
Availability: Only when bootstrap_completed = false. Returns 404 Not Found after bootstrap.
Network: Accessible on oneplatform-internal network. The Frontend container calls this via the Gateway's internal proxy during wizard screen 4.
Response 200 OK:
z.object({
data: z.object({
masterKey: z.string(), // The OP_MASTER_KEY value for operator confirmation
})
})
Notes: This endpoint is intentionally not included in the public OpenAPI spec. It is not registered in the API explorer. After bootstrap completes, the handler is permanently replaced with a 404 responder at startup.
4.2 Authentication Endpoints¶
POST /api/v1/auth/register¶
Auth: None
Rate limit: 10/min per IP
Request:
z.object({
email: z.string().email().max(254).toLowerCase(),
password: z.string().min(12).max(128),
displayName: z.string().min(1).max(100).trim().optional(),
tenantId: z.string().uuid(),
// tenantId is required — self-registration is tenant-scoped.
// Invites (future) will pre-fill this. For now the tenant must exist.
})
Response 201 Created:
z.object({
data: z.object({
userId: z.string().uuid(),
email: z.string().email(),
tenantId: z.string().uuid(),
roles: z.array(z.string()),
accessToken: z.string(), // absent if OP_REQUIRE_EMAIL_VERIFICATION=true
refreshToken: z.string(), // absent if OP_REQUIRE_EMAIL_VERIFICATION=true
expiresIn: z.number().optional(),
requiresEmailVerification: z.boolean(),
})
})
Error responses:
| Code | HTTP | Condition |
|---|---|---|
CONFLICT | 409 | Email already exists in this tenant |
AUTH_TENANT_NOT_FOUND | 404 | tenantId does not exist |
AUTH_REGISTRATION_DISABLED | 403 | Platform-admin has disabled self-registration |
VALIDATION_ERROR | 422 | Request body fails Zod schema |
Implementation notes:
- Check tenant exists and is active.
- Check
(tenant_id, lower(email))uniqueness inauth.users. - Hash password:
await bcrypt.hash(password, parseInt(OP_BCRYPT_ROUNDS, 10))— always cost 12 minimum. - Insert user with
roles = ['viewer'](default role),email_verified = false. - If
OP_REQUIRE_EMAIL_VERIFICATION = true: issue email-verification JWT + Redis single-use token, send email (or return link in link-copy mode). Do NOT issue access/refresh tokens yet. - If
OP_REQUIRE_EMAIL_VERIFICATION = false: issue access + refresh tokens immediately, setemail_verified = true. - Emit
auth.user.createdevent to Redis pub/sub.
POST /api/v1/auth/login¶
Auth: None
Rate limit: 20/min per IP; additionally: 10/min per (tenant_id, email) to limit credential stuffing
Timing: Always takes ~bcrypt_cost_time regardless of whether user exists (see Security Invariants §9.1)
Request:
z.object({
email: z.string().email().max(254).toLowerCase(),
password: z.string().min(1).max(128),
tenantId: z.string().uuid(),
})
Response 200 OK:
z.object({
data: z.object({
accessToken: z.string(),
refreshToken: z.string(),
expiresIn: z.number(), // seconds until access token expiry
tokenType: z.literal('Bearer'),
user: z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().nullable(),
tenantId: z.string().uuid(),
roles: z.array(z.string()),
emailVerified: z.boolean(),
}),
})
})
Error responses:
| Code | HTTP | Condition |
|---|---|---|
UNAUTHORIZED | 401 | Invalid email or password (generic — do NOT distinguish which) |
AUTH_ACCOUNT_LOCKED | 403 | Account locked due to too many failed attempts |
AUTH_EMAIL_NOT_VERIFIED | 403 | OP_REQUIRE_EMAIL_VERIFICATION=true and email unverified |
AUTH_ACCOUNT_DEACTIVATED | 403 | is_active = false |
VALIDATION_ERROR | 422 | Request body fails Zod schema |
RATE_LIMIT_EXCEEDED | 429 | Rate limit exceeded |
Implementation notes:
- Look up user by
(tenant_id, lower(email)). If not found, run a dummybcrypt.compare('dummy', DUMMY_HASH)to prevent timing oracle, then returnUNAUTHORIZED. - Check
locked_until: iflocked_until > now(), returnAUTH_ACCOUNT_LOCKED. const valid = await bcrypt.compare(password, user.password_hash).- If invalid: increment
failed_login_count. Iffailed_login_count >= 10, setlocked_until = now() + INTERVAL '15 minutes'. ReturnUNAUTHORIZED. - If valid: reset
failed_login_count = 0, updatelast_login_at. - Issue access + refresh tokens. Store session in Postgres + Redis.
- If
OP_REQUIRE_EMAIL_VERIFICATION = trueandemail_verified = false: issue tokens but includeunverified: trueclaim in JWT. Gateway downgrades role to viewer maximum.
POST /api/v1/auth/logout¶
Auth: Bearer token (access JWT) required
Rate limit: Standard per-user limit
Request:
z.object({
refreshToken: z.string().optional(),
// If provided, this specific refresh token is revoked.
// If absent, the session associated with the current access token is revoked.
all: z.boolean().optional().default(false),
// If true, revoke ALL sessions for the current user (logout everywhere).
})
Response 204 No Content: (empty body — standard for logout)
Implementation notes:
- Add current access token's
jtito Redisrevocation:{jti}with TTL equal to token's remaining lifetime (not the fullOP_JWT_EXPIRY_SECONDS). This ensures the entry expires exactly when the token would have expired, avoiding stale entries. - If
refreshTokenprovided: deleteauth:refresh:{token}from Redis, revoke matching session in Postgres (SET revoked_at = now(), revoked_reason = 'logout'). - If
all = true: delete all Redisauth:refresh:*for this user (requires scanningauth.sessionsfor active session JTIs), revoke all sessions in Postgres. - Always return 204 — even if the token was already invalid. Idempotent.
POST /api/v1/auth/refresh¶
Auth: Opaque refresh token in request body
Rate limit: 60/min per IP
Request:
Response 200 OK:
z.object({
data: z.object({
accessToken: z.string(),
refreshToken: z.string(), // new token — old token is now invalid
expiresIn: z.number(),
tokenType: z.literal('Bearer'),
})
})
Error responses:
| Code | HTTP | Condition |
|---|---|---|
UNAUTHORIZED | 401 | Token not found in Redis (expired or already rotated) |
AUTH_TOKEN_REPLAY_DETECTED | 401 | Token found in Postgres but already rotated — family compromise detected |
AUTH_SESSION_REVOKED | 401 | Session explicitly revoked |
Token rotation and family detection:
- Look up
auth:refresh:{token}in Redis. If missing: check Postgresauth.sessionsfor a session whererefresh_token_jtimatches a token whose successor was created (meaning it was already rotated). If found, this is a replay attack — revoke the entire family:UPDATE auth.sessions SET revoked_at = now(), revoked_reason = 'token_replay_detected' WHERE family_id = <familyId>. Delete all Redis refresh tokens for this family. ReturnAUTH_TOKEN_REPLAY_DETECTED. - If found in Redis: parse payload
{userId, tenantId, sessionId, jti, familyId}. - Delete old token:
DEL auth:refresh:{token}. - Generate new refresh token (32 bytes, hex). Store
auth:refresh:{newToken}in Redis with TTL = remaining session lifetime (not a fresh 7 days — sessions have a fixed total lifetime from creation). - Update
auth.sessions SET refresh_token_jti = <newJti>, last_used_at = now(). - Issue new access JWT.
- Return new access token + new refresh token. Old refresh token is now invalid.
POST /api/v1/auth/forgot-password¶
Auth: None
Rate limit: 5/min per IP; 3/hour per email address
Timing: Always returns after a fixed delay (~200ms) regardless of whether email exists
Request:
Response 200 OK: (always — prevents email enumeration)
z.object({
data: z.object({
message: z.literal('If an account with this email exists, a reset link has been sent.'),
// In link-copy mode (no SMTP), also include:
resetLink: z.string().url().optional(),
// Present only when OP_SMTP_HOST is unset. Omitted when SMTP is configured.
})
})
Implementation notes:
- Look up user by
(tenant_id, lower(email)). Continue regardless of whether user is found. - If user found and account active: generate reset JWT
{ sub: userId, purpose: 'password-reset', jti: uuid(), iat, exp: iat + 3600 }signed withOP_JWT_SECRET. Storereset:{jti}in Redis with TTL 3600. Insert intoauth.password_reset_tokens. Send email or returnresetLinkif no SMTP. - If user not found: sleep for
~200ms(same duration as the found-user path) to prevent timing oracle. - Always return the same
200response.
POST /api/v1/auth/reset-password/{token}¶
Auth: None (the {token} path parameter IS the credential)
Rate limit: 10/min per IP
Path parameter: token — the reset JWT from the email link.
Request:
z.object({
newPassword: z.string().min(12).max(128),
confirmPassword: z.string().min(12).max(128),
})
.refine((d) => d.newPassword === d.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
Response 200 OK:
z.object({
data: z.object({
message: z.literal('Password reset successfully. Please log in again.'),
})
})
Error responses:
| Code | HTTP | Condition |
|---|---|---|
AUTH_RESET_TOKEN_INVALID | 401 | JWT signature invalid or malformed |
AUTH_RESET_TOKEN_EXPIRED | 401 | JWT exp has passed |
AUTH_RESET_TOKEN_USED | 401 | reset:{jti} not found in Redis (already used) |
VALIDATION_ERROR | 422 | Password validation fails |
Implementation notes:
- Verify JWT signature using
OP_JWT_SECRET. Reject if invalid. - Check
expclaim. ReturnAUTH_RESET_TOKEN_EXPIREDif expired. - Check
purposeclaim equals'password-reset'. - Check Redis
reset:{jti}exists. If absent, returnAUTH_RESET_TOKEN_USED. - In a Redis pipeline:
DEL reset:{jti}(atomic — prevents TOCTOU). If DEL returns 0, concurrent request already used it — returnAUTH_RESET_TOKEN_USED. - Hash new password with bcrypt cost 12.
- In Postgres transaction: (a) UPDATE
auth.users SET password_hash = <new_hash>whereid = sub, (b) UPDATEauth.password_reset_tokens SET used_at = now()wherejti = <jti>, (c) UPDATEauth.sessions SET revoked_at = now(), revoked_reason = 'password_reset'whereuser_id = sub AND revoked_at IS NULL. - Delete all refresh tokens for this user from Redis (scan
auth.sessionsfor active JTIs, thenDEL auth:refresh:{token}for each). - Return
200.
GET /api/v1/auth/verify-email/{token}¶
Auth: None
Rate limit: 20/min per IP
Path parameter: token — the email verification JWT.
Response 200 OK:
z.object({
data: z.object({
message: z.literal('Email verified successfully.'),
userId: z.string().uuid(),
})
})
Error responses:
| Code | HTTP | Condition |
|---|---|---|
AUTH_VERIFY_TOKEN_INVALID | 401 | JWT signature invalid or malformed |
AUTH_VERIFY_TOKEN_EXPIRED | 401 | JWT exp has passed |
AUTH_VERIFY_TOKEN_USED | 401 | auth:verify:{jti} not found in Redis |
AUTH_EMAIL_ALREADY_VERIFIED | 409 | User already verified |
Implementation notes:
- Verify JWT signature, expiry, and
purpose === 'email-verify'. - Atomic Redis DEL
auth:verify:{jti}. If DEL returns 0, returnAUTH_VERIFY_TOKEN_USED. - UPDATE
auth.users SET email_verified = truewhereid = sub. - If the user has an active session with
unverified: trueclaim: the next access token refresh will include updated claims. The old access token continues working until expiry.
4.3 OAuth Endpoints¶
GET /api/v1/auth/oauth/{provider}/authorize¶
Auth: None
Rate limit: 20/min per IP
Path parameter: provider — "github" or "google"
Query parameters:
z.object({
tenantId: z.string().uuid(),
redirectUri: z.string().url().optional(),
// If omitted, defaults to OP_BASE_URL + /auth/callback
})
Response 302 Found: Redirect to provider authorization URL.
Implementation notes:
- Validate
provideris"github"or"google". - Check that the provider is enabled (env vars present).
- Generate PKCE:
codeVerifier = crypto.randomBytes(32).toString('base64url'),codeChallenge = sha256(codeVerifier).toString('base64url'). - Generate
state = crypto.randomBytes(16).toString('hex'). - Store in Redis:
auth:oauth:state:{state}→{ provider, codeVerifier, redirectUri, tenantId }TTL 600s. - Build provider authorization URL with
state,code_challenge,code_challenge_method=S256,scope=read:user,user:email(GitHub) orscope=openid email profile(Google). - Return 302 to provider URL.
GET /api/v1/auth/oauth/{provider}/callback¶
Auth: None
Rate limit: 20/min per IP
Path parameter: provider — "github" or "google"
Query parameters:
z.object({
code: z.string(),
state: z.string(),
error: z.string().optional(), // Set by provider on denial
})
Response 302 Found: Redirect to OP_BASE_URL + /auth/callback#access_token=... on success, or OP_BASE_URL + /auth/error?code=... on failure.
Error responses (via redirect to error page):
| Redirect code | Condition |
|---|---|
oauth_state_invalid | State missing from Redis or constant-time compare fails |
oauth_state_expired | Redis TTL expired before callback |
oauth_provider_denied | User denied permission at provider |
oauth_exchange_failed | Code exchange request to provider returned error |
oauth_email_missing | Provider did not return an email address |
Implementation notes:
- If
errorparam present, redirect to error page. - Get
auth:oauth:state:{state}from Redis. If absent, redirect withoauth_state_invalid. DEL the key atomically. - Use constant-time comparison for state validation.
- Exchange code for provider tokens using
codeVerifierfrom state for PKCE verification. - Fetch user profile from provider (email, provider user ID, display name).
- Upsert logic:
- Look for existing
auth.oauth_providersrow by(provider, provider_user_id, tenant_id). - If found: update tokens, return existing user.
- If not found: look for
auth.usersby(tenant_id, lower(email)).- If user exists but no OAuth link: create
auth.oauth_providersrow linking the existing user. - If no user: create new user (roles = ['viewer'],
email_verified = true— OAuth-verified email), then createauth.oauth_providersrow.
- If user exists but no OAuth link: create
- Store provider tokens encrypted (AES-256-GCM with HKDF-derived key per ADR-11).
- Issue platform access + refresh tokens.
- Redirect to
OP_BASE_URL/auth/callbackwith tokens in fragment (never in query string, which appears in server logs).
4.4 API Key Endpoints¶
POST /api/v1/auth/keys¶
Auth: Bearer token required; scopes: admin or users:manage (own keys only with lesser scope)
Rate limit: 10/min per user
Request:
z.object({
name: z.string().min(1).max(100).trim(),
scopes: z.array(z.enum([
'data:read', 'data:write', 'ontology:read', 'ontology:write',
'pipelines:read', 'pipelines:trigger', 'pipelines:manage',
'apps:read', 'apps:deploy', 'apps:manage',
'plugins:read', 'plugins:manage',
'users:read', 'users:manage',
'logs:read', 'webhooks:manage', 'execution:run', 'admin',
])).min(1),
expiresAt: z.string().datetime().optional(),
// If omitted, key never expires.
})
Response 201 Created:
z.object({
data: z.object({
id: z.string().uuid(),
name: z.string(),
key: z.string(),
// The full key: "op_live_" + prefix + remainder.
// This is the ONLY time the key value is returned.
// Subsequent calls return keyPrefix only.
keyPrefix: z.string().length(8),
scopes: z.array(z.string()),
expiresAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
})
})
Implementation notes:
- Generate key:
const random = crypto.randomBytes(32).toString('base64url')— this gives 43 chars of URL-safe base64. Prefix format:op_live_+ first 8 chars (for lookup) + remaining chars. Full key =op_live_${random}. keyPrefix = random.substring(0, 8).keyHash = await bcrypt.hash(fullKey, 12).- Store in
auth.api_keys. Return full key in response body. - Log to audit:
auth.key.created.
GET /api/v1/auth/keys¶
Auth: Bearer token required
Rate limit: Standard per-user
Response 200 OK:
z.object({
data: z.array(z.object({
id: z.string().uuid(),
name: z.string(),
keyPrefix: z.string(),
scopes: z.array(z.string()),
expiresAt: z.string().datetime().nullable(),
lastUsedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
revokedAt: z.string().datetime().nullable(),
})),
pagination: z.object({
nextCursor: z.string().nullable(),
total: z.number().nullable(),
}),
})
Notes: Returns keys for the requesting user only. platform-admin may pass ?userId=<id> to view another user's keys.
DELETE /api/v1/auth/keys/{id}¶
Auth: Bearer token required; user must own the key or have users:manage
Rate limit: Standard per-user
Response 204 No Content
Implementation notes:
- Verify key belongs to requesting user (or user has
users:manage). - Set
revoked_at = now(), revoked_by = requestingUserIdinauth.api_keys. - Set
auth:apikey:revocation:{id}in Redis (no TTL — persists until explicitly removed, which happens when the key row is hard-deleted). Hard deletion is a separate admin operation. - Emit
auth.key.revokedevent.
POST /api/v1/auth/keys/{id}/rotate¶
Auth: Bearer token required; user must own the key
Rate limit: 5/min per user
Response 200 OK:
z.object({
data: z.object({
id: z.string().uuid(),
key: z.string(), // New key value — returned ONCE
keyPrefix: z.string(),
scopes: z.array(z.string()),
createdAt: z.string().datetime(),
})
})
Implementation notes:
Rotation is implemented as: revoke old key → create new key with same name and scopes, returning the new key. The old key ID becomes invalid; a new id is generated. This preserves the user's intent while closing the old key. The old key is revoked in the same transaction that creates the new one — there is no window where neither key works.
4.5 Role and Permission Endpoints¶
GET /api/v1/roles¶
Auth: Bearer token required; any authenticated user may list roles
Rate limit: Standard per-user
Query parameters:
z.object({
tenantId: z.string().uuid().optional(),
// Defaults to the requesting user's tenantId.
// platform-admin may specify other tenants.
})
Response 200 OK:
z.object({
data: z.array(z.object({
id: z.string().uuid(),
tenantId: z.string().uuid().nullable(),
name: z.string(),
description: z.string(),
isPredefined: z.boolean(),
permissions: z.array(z.string()),
createdAt: z.string().datetime(),
})),
pagination: z.object({
nextCursor: z.string().nullable(),
total: z.number(),
}),
})
POST /api/v1/roles¶
Auth: Bearer token required; tenant-admin or platform-admin
Rate limit: 20/min per user
Request:
z.object({
name: z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9\-_]{0,62}$/),
description: z.string().max(500).default(''),
permissions: z.array(z.string()).max(50),
})
Response 201 Created: Role object (same schema as GET list item)
Error responses:
| Code | HTTP | Condition |
|---|---|---|
CONFLICT | 409 | Role name already exists in this tenant |
AUTH_PREDEFINED_ROLE_CONFLICT | 409 | Name matches a predefined role name |
FORBIDDEN | 403 | Insufficient role |
PATCH /api/v1/roles/{id}¶
Auth: Bearer token required; tenant-admin or platform-admin
Rate limit: 20/min per user
Request:
z.object({
name: z.string().min(1).max(64).optional(),
description: z.string().max(500).optional(),
permissions: z.array(z.string()).max(50).optional(),
})
Response 200 OK: Updated role object.
Error responses:
| Code | HTTP | Condition |
|---|---|---|
AUTH_PREDEFINED_ROLE_IMMUTABLE | 403 | Attempting to modify a predefined role |
FORBIDDEN | 403 | Insufficient role |
NOT_FOUND | 404 | Role not found in this tenant |
GET /api/v1/roles/{id}/permissions¶
Auth: Bearer token required; tenant-admin or platform-admin
Response 200 OK:
z.object({
data: z.object({
roleId: z.string().uuid(),
entityPermissions: z.array(z.object({
entityType: z.string(),
actions: z.array(z.string()),
fieldRestrictions: z.object({
denyRead: z.array(z.string()),
denyWrite: z.array(z.string()),
}),
rowFilter: z.record(z.string()),
})),
})
})
PUT /api/v1/roles/{id}/permissions¶
Auth: Bearer token required; tenant-admin or platform-admin
Request: Same structure as the response entityPermissions array.
Response 200 OK: Updated permissions object.
Notes: This is a full replace (PUT semantics). The entire entity_permissions set for this role and tenant is replaced atomically.
4.6 User Management Endpoints¶
These are the minimal set of user management endpoints owned by the Auth Service. Richer user management (beyond the Auth Service's scope) is not included here.
GET /api/v1/users¶
Auth: Bearer token required; tenant-admin or platform-admin
Response 200 OK: Paginated list of users in the requesting tenant.
z.object({
data: z.array(z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().nullable(),
roles: z.array(z.string()),
emailVerified: z.boolean(),
isActive: z.boolean(),
lastLoginAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
})),
pagination: z.object({ nextCursor: z.string().nullable(), total: z.number() }),
})
GET /api/v1/users/{id}¶
Auth: Bearer token required; own user ID always allowed; other users require users:read scope
Response 200 OK: Single user object (same as list item schema above).
PATCH /api/v1/users/{id}¶
Auth: Bearer token required; own user for display name changes; users:manage for role changes
Request:
z.object({
displayName: z.string().min(1).max(100).optional(),
roles: z.array(z.string()).optional(),
// roles: requires users:manage scope. Role names must exist in auth.roles for this tenant.
isActive: z.boolean().optional(),
// isActive: requires tenant-admin or platform-admin.
})
Response 200 OK: Updated user object.
DELETE /api/v1/users/{id}¶
Auth: Bearer token required; tenant-admin or platform-admin
Response 204 No Content
Implementation notes: Soft delete — sets is_active = false. Revokes all active sessions and API keys. Hard delete is a separate admin-only operation that requires confirmation and is not exposed via the public API.
5. API Endpoints — Internal¶
Internal endpoints are only reachable on the oneplatform-internal Docker network. They require a valid X-Service-Token header (Ed25519 JWT signed by the calling service's private key). The serviceAuth middleware in @oneplatform/core enforces both signature validity and the service RBAC matrix.
Per the RBAC matrix (ADR-19):
| Caller | Allowed internal endpoints |
|---|---|
app-service | GET /internal/auth/validate, POST /internal/auth/guest-sessions, POST /internal/oauth/clients |
gateway-service | All internal endpoints (entry point) |
GET /internal/auth/validate¶
Auth: X-Service-Token required (any permitted caller)
Purpose: Validate a user's session cookie or Bearer token and return their identity.
Query parameters:
z.object({
token: z.string(),
// The raw value from the op_session cookie or Authorization: Bearer header.
})
Response 200 OK:
z.object({
data: z.object({
valid: z.boolean(),
userId: z.string().uuid(),
tenantId: z.string().uuid(),
roles: z.array(z.string()),
scopes: z.array(z.string()),
emailVerified: z.boolean(),
isGuest: z.literal(false),
sessionId: z.string().uuid(),
})
})
Response on invalid token 200 OK: (always 200 — callers check valid field)
z.object({
data: z.object({
valid: z.literal(false),
reason: z.string(), // e.g., "TOKEN_EXPIRED", "TOKEN_REVOKED", "TOKEN_INVALID"
})
})
Notes: This endpoint never returns 4xx for invalid tokens — that is caller logic. It returns 4xx only for malformed requests or service auth failures. The App Service caches valid results for up to 15 seconds keyed by session token value — this is safe because access tokens expire in 15 minutes and the cache TTL is shorter than the minimum time an attacker would need to abuse a just-revoked token.
POST /internal/auth/guest-sessions¶
Auth: X-Service-Token required
Purpose: Create a new guest session for a public app.
Request:
z.object({
tenantId: z.string().uuid(),
appId: z.string().uuid(),
ipAddress: z.string().ip().optional(),
})
Response 201 Created:
z.object({
data: z.object({
guestToken: z.string().length(64), // 32-byte hex, set in op_guest_session cookie
expiresAt: z.string().datetime(),
})
})
Implementation notes:
- Generate
token = crypto.randomBytes(32).toString('hex'). - Store
guest-session:{token}→{ tenantId, appId, createdAt: now().toISOString(), ipAddress }in Redis with TTL 86400. - Return token to App Service, which sets the
op_guest_sessionhttpOnly cookie.
POST /internal/oauth/clients¶
Auth: X-Service-Token required
Purpose: Register or update an OAuth 2.0 client for a deployed app.
Request:
z.object({
clientId: z.string().regex(/^app:[0-9a-f-]{36}:[0-9a-f-]{36}$/).or(z.string().regex(/^client_[0-9a-f-]{36}$/)),
clientType: z.enum(['public', 'confidential']).default('public'),
redirectUris: z.array(z.string().url()).min(1).max(10),
allowedScopes: z.array(z.string()).min(1).max(20),
tenantId: z.string().uuid(),
appId: z.string().uuid().optional(),
accessMode: z.enum(['platform-user', 'public']).default('platform-user'),
})
Response 200 OK: (upsert — idempotent)
z.object({
data: z.object({
clientId: z.string(),
clientType: z.enum(['public', 'confidential']),
redirectUris: z.array(z.string()),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
})
Implementation notes:
- Enforce that
app:*:*pattern client IDs may only be created byapp-service(checkc.var.callerService). INSERT INTO auth.oauth_clients ... ON CONFLICT (client_id) DO UPDATE SET redirect_uris = EXCLUDED.redirect_uris, allowed_scopes = EXCLUDED.allowed_scopes, access_mode = EXCLUDED.access_mode, updated_at = now(), created_by_service = EXCLUDED.created_by_service.- Return full client record.
6. Authentication Flows¶
6.1 Email/Password Registration and Login¶
REGISTRATION:
Client Auth Service Postgres Redis
| | | |
| POST /auth/register | | |
| {email, password, tenantId} | | |
|------------------------------>| | |
| | 1. Validate tenant exists | |
| |----SELECT auth.tenants------>| |
| | 2. Check email uniqueness | |
| |----SELECT auth.users-------->| |
| | 3. bcrypt.hash(pw, 12) | |
| | (~200ms CPU-bound) | |
| | 4. INSERT auth.users | |
| |----------------------------->| |
| | 5a. [email verify required] | |
| | Issue verify JWT | |
| | SET auth:verify:{jti} | |
| |-------------------------------------------->|
| | Send email / return link | |
| | 5b. [no verify required] | |
| | Issue access JWT | |
| | Generate refresh token | |
| | SET auth:refresh:{tok} | |
| |-------------------------------------------->|
| | INSERT auth.sessions | |
| |----------------------------->| |
| 201 {userId, accessToken, | | |
| refreshToken, ...} | | |
|<------------------------------| | |
LOGIN:
Client Auth Service Postgres Redis
| | | |
| POST /auth/login | | |
| {email, password, tenantId} | | |
|------------------------------>| | |
| | 1. SELECT user by | |
| | (tenant_id, lower(email)) | |
| |----SELECT auth.users-------->| |
| | 2. [user not found] | |
| | bcrypt.compare(pw, | |
| | DUMMY_HASH) -- timing | |
| | then return UNAUTHORIZED | |
| | 3. [user found] | |
| | bcrypt.compare(pw, hash) | |
| | 4. [invalid] increment | |
| | failed_login_count | |
| |----UPDATE auth.users-------->| |
| | return UNAUTHORIZED | |
| | 5. [valid] reset count, | |
| | update last_login_at | |
| |----UPDATE auth.users-------->| |
| | 6. Issue access JWT | |
| | 7. Generate refresh token | |
| | SET auth:refresh:{tok} | |
| |-------------------------------------------->|
| | 8. INSERT auth.sessions | |
| |----INSERT auth.sessions----->| |
| 200 {accessToken, | | |
| refreshToken, user} | | |
|<------------------------------| | |
6.2 OAuth Flow (GitHub / Google)¶
AUTHORIZATION:
Browser Auth Service Provider Redis
| | | |
| GET /oauth/github/ | | |
| authorize | | |
| ?tenantId=... | | |
|------------------->| | |
| | 1. Generate PKCE | |
| | codeVerifier (32B) | |
| | codeChallenge= | |
| | sha256(verifier) | |
| | 2. state = random(16B) | |
| | 3. SET oauth:state:{s} | |
| | {provider, verifier, | |
| | redirectUri, tenantId}| |
| |----------------------------------SET-------->|
| | 4. Build provider URL | |
| 302 → provider URL | | |
|<-------------------| | |
| | | |
| GET /authorize | | |
| ?state=... | | |
| &code_challenge=.. | | |
|-------------------------------------------- >| |
| [user approves] | | |
| GET /oauth/github/ | | |
| callback?code= | | |
| &state= | | |
|------------------->| | |
| | 5. GET redis state | |
| |----------------------------------GET-------->|
| | 6. Constant-time | |
| | state compare | |
| | 7. DEL oauth:state:{s} | |
| |----------------------------------DEL-------->|
| | 8. Exchange code for | |
| | provider tokens | |
| | (with code_verifier) | |
| |----------------------->| |
| | 9. Fetch user profile | |
| |----------------------->| |
| | 10. Upsert user + | |
| | oauth_providers | |
| | 11. Issue platform JWT | |
| | + refresh token | |
| 302 → /auth/callback| | |
| #accessToken=... | | |
|<-------------------| | |
6.3 JWT Issuance¶
Access token claims:
interface AccessTokenPayload {
// Standard JWT claims
sub: string; // userId (UUID)
iat: number; // issued at (Unix seconds)
exp: number; // expiry: iat + OP_JWT_EXPIRY_SECONDS (default 900)
jti: string; // UUID v4 — unique per token issuance, used for revocation
// Platform claims
tid: string; // tenantId
roles: string[]; // e.g., ["developer", "editor"]
scopes: string[]; // effective scope union from all roles
ev: boolean; // emailVerified
unverified: boolean; // true only when emailVerified=false and token issued anyway
}
Algorithm: HS256 (HMAC-SHA256) using OP_JWT_SECRET. This is consistent with the existing platform JWT strategy (ADR-7).
Note on algorithm choice: HS256 is used because all token validation happens either within the Auth Service itself or via the /internal/auth/validate endpoint. No other service validates JWTs directly — they delegate to Auth. This eliminates the need for asymmetric keys (which would only be necessary if other services needed to validate tokens without calling Auth). If future requirements arise for distributed validation, migration to RS256/ES256 is straightforward: rotate the secret to a keypair, update the issuer, keep a kid claim.
Token signing code:
import { sign, verify } from 'hono/jwt'; // Hono's JWT utilities
function issueAccessToken(user: UserRow, tenant: TenantRow): string {
const now = Math.floor(Date.now() / 1000);
const payload: AccessTokenPayload = {
sub: user.id,
iat: now,
exp: now + parseInt(process.env.OP_JWT_EXPIRY_SECONDS ?? '900', 10),
jti: crypto.randomUUID(),
tid: user.tenant_id,
roles: user.roles,
scopes: resolveScopes(user.roles), // union of scopes from all roles
ev: user.email_verified,
unverified: !user.email_verified,
};
return sign(payload, process.env.OP_JWT_SECRET!, 'HS256');
}
6.4 Refresh Token Rotation¶
POST /auth/refresh { refreshToken }
|
v
1. GET auth:refresh:{token} from Redis
|-- Not found --> check Postgres for replay detection (see below)
|-- Found --> payload = { userId, tenantId, sessionId, jti, familyId }
|
v
2. DEL auth:refresh:{token} from Redis
(atomic: if DEL returns 0, concurrent rotation happened — return UNAUTHORIZED)
|
v
3. Calculate remaining session lifetime:
session_row = SELECT expires_at FROM auth.sessions WHERE id = sessionId
remaining = session_row.expires_at - now()
If remaining <= 0: session expired, return UNAUTHORIZED
|
v
4. Generate new refresh token = crypto.randomBytes(32).toString('hex')
new_jti = crypto.randomUUID()
|
v
5. SET auth:refresh:{newToken} → { userId, tenantId, sessionId, jti: new_jti, familyId }
EX = remaining seconds
|
v
6. UPDATE auth.sessions
SET refresh_token_jti = new_jti,
last_used_at = now()
WHERE id = sessionId
|
v
7. Issue new access JWT (step 6.3)
|
v
8. Return { accessToken, refreshToken: newToken, expiresIn }
REPLAY DETECTION (step 1, not found in Redis):
SELECT * FROM auth.sessions
WHERE refresh_token_jti != <incoming_jti_hash_lookup>
-- Actually: we track this differently.
-- The refresh token value is opaque and its JTI is stored in auth.sessions.
-- If a token is NOT in Redis but WAS valid (i.e., it's been rotated),
-- it won't appear in Postgres either (we store current JTI, not history).
-- We detect replay by checking if the token's familyId has ANY active sessions
-- AND the incoming token's JTI does NOT match any current refresh_token_jti.
-- This means: token was used before but is now superseded → replay.
Replay response:
UPDATE auth.sessions
SET revoked_at = now(),
revoked_reason = 'token_replay_detected'
WHERE family_id = <familyId>
AND revoked_at IS NULL
DEL all auth:refresh:{*} for this family
(scan auth.sessions for family_id → collect all jti → DEL each)
Return AUTH_TOKEN_REPLAY_DETECTED (401)
6.5 API Key Validation¶
API key validation is performed by the Gateway's auth middleware (in @oneplatform/core), not by the Auth Service directly. The Auth Service provides the storage; validation happens in-process at the Gateway.
However, the Auth Service must expose its validation logic consistently. The validation flow:
Incoming request with:
Authorization: Bearer op_live_AbCdEfGh...
OR
X-API-Key: op_live_AbCdEfGh...
|
v
1. Extract key string
Validate prefix: must start with "op_live_"
Extract keyPrefix = key[8:16] (8 chars after "op_live_")
|
v
2. SELECT * FROM auth.api_keys
WHERE key_prefix = keyPrefix
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > now())
|
v
3. If no rows: return UNAUTHORIZED
If multiple rows (hash collision on 8-char prefix): bcrypt all
|
v
4. bcrypt.compare(incomingKey, row.key_hash)
If no match: return UNAUTHORIZED
|
v
5. Check Redis auth:apikey:revocation:{keyId}
If present: return UNAUTHORIZED (key was revoked but DB cleanup pending)
|
v
6. Validate scopes: requiredScope must be in row.scopes (or 'admin' present)
|
v
7. UPDATE auth.api_keys SET last_used_at = now()
WHERE id = row.id
(non-blocking: fire-and-forget, use setImmediate to avoid blocking response)
|
v
8. Set c.var.user = { userId: row.user_id, tenantId: row.tenant_id,
scopes: row.scopes, roles: [], isApiKey: true }
6.6 Token Revocation¶
Immediate access token revocation:
// Add jti to Redis revocation set:
const remainingTTL = payload.exp - Math.floor(Date.now() / 1000);
if (remainingTTL > 0) {
await redis.set(`revocation:${payload.jti}`, '1', { EX: remainingTTL });
}
// Token will auto-expire; no cleanup needed.
Every auth middleware call (in @oneplatform/core):
// After JWT signature verification:
const isRevoked = await redis.get(`revocation:${payload.jti}`);
if (isRevoked) throw new UnauthorizedError('TOKEN_REVOKED');
Emergency re-key (Redis outage + active compromise):
op auth emergency-rotate (CLI command)
|
v
1. Call POST /api/v1/auth/emergency-rotate
Requires: platform-admin token (or direct database access with --force flag)
|
v
2. Auth Service: rotate OP_JWT_SECRET
- Generate new secret: crypto.randomBytes(64).toString('base64')
- Update in-memory and persistent configuration
- Notify all services via platform event (ops guide: manual restart may be needed
if event delivery is impaired during Redis outage)
|
v
3. Effect: ALL outstanding access tokens are immediately invalid
(they were signed with the old secret; new secret produces different HMAC)
|
v
4. All users must re-authenticate
Refresh tokens in Redis remain valid IF Redis recovers
(refresh tokens are opaque strings, not signed by JWT_SECRET)
6.7 Password Reset Flow¶
POST /auth/forgot-password { email, tenantId }
|
v
1. Always return 200 (anti-enumeration)
2. Look up user — if not found, sleep ~200ms, return
3. Generate reset JWT:
jti = crypto.randomUUID()
token = jwt.sign({ sub: userId, purpose: 'password-reset', jti }, OP_JWT_SECRET, { expiresIn: '1h' })
4. Redis SET reset:{jti} = "1" EX 3600
5. INSERT auth.password_reset_tokens (jti, userId, expires_at = now() + 1h)
6a. SMTP configured:
Send email: "Reset your password" with link:
{OP_BASE_URL}/reset-password?token={JWT}
6b. SMTP not configured (link-copy mode):
Include resetLink in response body
Log WARN "SMTP not configured; reset link returned in response body (dev/test only)"
POST /auth/reset-password/{token}
|
v
1. jwt.verify(token, OP_JWT_SECRET) -- throws on invalid/expired
2. Check purpose === 'password-reset'
3. Redis: result = await redis.set('reset:{jti}', '2', { XX: true })
-- XX means SET only if key exists. If key does not exist, SET returns null.
-- This is the single-use atomic gate.
Actually use: pipeline [GET reset:{jti}, DEL reset:{jti}]
If GET returns null: token already used
4. bcrypt.hash(newPassword, 12)
5. Postgres transaction:
UPDATE auth.users SET password_hash = $hash WHERE id = $userId
UPDATE auth.password_reset_tokens SET used_at = now() WHERE jti = $jti
UPDATE auth.sessions SET revoked_at = now(), revoked_reason = 'password_reset'
WHERE user_id = $userId AND revoked_at IS NULL
6. Delete all refresh tokens for user from Redis
7. Return 200
6.8 Email Verification Flow¶
On registration (OP_REQUIRE_EMAIL_VERIFICATION=true):
1. INSERT user with email_verified = false
2. jti = crypto.randomUUID()
3. token = jwt.sign({ sub: userId, purpose: 'email-verify', jti }, OP_JWT_SECRET, { expiresIn: '24h' })
4. Redis SET auth:verify:{jti} = "1" EX 86400
5. Send email with link: {OP_BASE_URL}/verify-email?token={JWT}
(or return verifyLink in link-copy mode)
6. Issue access token with { unverified: true } claim
(Gateway downgrades role to viewer maximum for unverified users)
GET /auth/verify-email/{token}:
1. jwt.verify(token, OP_JWT_SECRET)
2. Check purpose === 'email-verify'
3. Redis pipeline: [GET auth:verify:{jti}, DEL auth:verify:{jti}]
If GET null: already used
4. UPDATE auth.users SET email_verified = true WHERE id = $userId
5. Return 200
6. Next token refresh for this user will include ev: true, unverified: false
7. Bootstrap Flow¶
The bootstrap flow is the critical first-run path. It is fully specified here because errors in this flow leave the platform unusable. Refer also to ADR-24 for the full rationale.
7.1 State Machine¶
States:
BOOTSTRAP_PENDING -- Initial state. bootstrap_completed = false.
bootstrap.token file exists on disk or in memory.
BOOTSTRAP_IN_FLIGHT -- Between transaction start and commit.
Guarded by Postgres advisory lock (pg_advisory_lock(1)).
BOOTSTRAP_COMPLETE -- bootstrap_completed = true in Postgres.
In-memory token is null. Token file erased from disk.
Transitions:
PENDING → IN_FLIGHT: POST /api/v1/auth/bootstrap with valid token
IN_FLIGHT → COMPLETE: Transaction commits successfully
IN_FLIGHT → PENDING: Transaction rolls back (error) -- retry allowed
COMPLETE → COMPLETE: All subsequent POSTs return 410 Gone
Guards:
1. bootstrap_completed flag is checked at the START of the handler, before
any token comparison, to return 410 immediately.
IMPORTANT: The 410 check must NOT short-circuit the timing path.
The handler must ALWAYS run the constant-time comparison.
Leak-free implementation:
const alreadyCompleted = await getBootstrapCompleted(); // DB or cached
const tokenMatch = crypto.timingSafeEqual(
Buffer.from(providedToken, 'hex'),
Buffer.from(inMemoryBootstrapToken ?? DUMMY_64_CHAR_HEX, 'hex')
);
if (alreadyCompleted) throw new GoneError('AUTH_BOOTSTRAP_ALREADY_COMPLETED');
if (!tokenMatch) throw new UnauthorizedError('AUTH_BOOTSTRAP_INVALID_TOKEN');
This ensures:
- The comparison ALWAYS runs (no early return before comparison)
- A timing attack cannot determine if bootstrap is complete
(same compute path for both completed and not-completed states)
7.2 Bootstrap Endpoint Handler (Pseudocode)¶
app.post('/api/v1/auth/bootstrap', async (c) => {
// 1. Apply rate limit (in-memory, not Redis)
const ip = c.req.header('x-forwarded-for') ?? 'unknown';
const attempts = bootstrapRateLimiter.increment(ip);
if (attempts > 3) {
return c.json({ error: { code: 'RATE_LIMIT_EXCEEDED', ... } }, 429);
}
// 2. Parse and validate body
const body = BootstrapRequestSchema.parse(await c.req.json());
// 3. Constant-time token comparison (ALWAYS runs — see above)
const alreadyCompleted = await db.query<boolean>(
'SELECT bootstrap_completed FROM auth.bootstrap_state WHERE id = 1'
).then(r => r.rows[0]?.bootstrap_completed ?? false);
const tokenMatch = crypto.timingSafeEqual(
Buffer.from(body.bootstrapToken.padEnd(64, '0'), 'hex'),
Buffer.from((inMemoryBootstrapToken ?? DUMMY_64_CHAR_HEX).padEnd(64, '0'), 'hex')
);
if (alreadyCompleted) throw new GoneError('AUTH_BOOTSTRAP_ALREADY_COMPLETED');
if (!tokenMatch || inMemoryBootstrapToken === null) {
throw new UnauthorizedError('AUTH_BOOTSTRAP_INVALID_TOKEN');
}
// 4. Acquire advisory lock (prevent concurrent bootstrap attempts)
await db.query('SELECT pg_advisory_lock(1)');
try {
// 5. Re-check inside lock (double-check pattern)
const stillPending = await db.query<boolean>(
'SELECT NOT bootstrap_completed FROM auth.bootstrap_state WHERE id = 1'
).then(r => r.rows[0]);
if (!stillPending) throw new GoneError('AUTH_BOOTSTRAP_ALREADY_COMPLETED');
// 6. Transaction: create tenant, user, set flag
const { tenantId, userId } = await db.transaction(async (trx) => {
const tenant = await trx.query(
'INSERT INTO auth.tenants (name, slug) VALUES ($1, $2) RETURNING id',
[body.tenantName, slugify(body.tenantName)]
);
const passwordHash = await bcrypt.hash(body.adminPassword, Number(process.env.OP_BCRYPT_ROUNDS ?? 12));
const user = await trx.query(
`INSERT INTO auth.users
(tenant_id, email, password_hash, email_verified, roles)
VALUES ($1, $2, $3, true, ARRAY['platform-admin'])
RETURNING id`,
[tenant.rows[0].id, body.adminEmail, passwordHash]
);
await trx.query(
`UPDATE auth.bootstrap_state
SET bootstrap_completed = true,
completed_at = now(),
admin_user_id = $1,
first_tenant_id = $2
WHERE id = 1`,
[user.rows[0].id, tenant.rows[0].id]
);
return { tenantId: tenant.rows[0].id, userId: user.rows[0].id };
});
// 7. Issue session
const { accessToken, refreshToken } = await issueSession(userId, tenantId);
// 8. Zero token from memory and erase file
inMemoryBootstrapToken = null;
try { await fs.unlink('/data/init/bootstrap.token'); } catch { /* best-effort */ }
return c.json({ data: { tenantId, adminUserId: userId, accessToken, refreshToken, expiresIn: 900 } }, 201);
} finally {
await db.query('SELECT pg_advisory_unlock(1)');
}
});
7.3 Setup Wizard Frontend Integration¶
The Auth Service does not serve the wizard UI. The wizard is served by the Frontend (Nginx) container. The Auth Service provides three endpoints the wizard calls:
GET /api/v1/auth/bootstrap/status— to decide whether to show the wizard or the login page.GET /internal/auth/master-key-display— for wizard screen 4 (master key confirmation). Only callable whenbootstrap_completed = false.POST /api/v1/auth/bootstrap— to create the platform.
The wizard screens map to Auth Service state as follows:
| Screen | Auth Service interaction |
|---|---|
| 1. Welcome | GET /status → { completed: false } confirms wizard should show |
| 2. Admin account | Client-side only (form input) |
| 3. Organization name | Client-side only |
| 4. Master key confirmation | GET /internal/auth/master-key-display → display key for operator copy |
| 5. Review and create | POST /api/v1/auth/bootstrap — creates platform, returns session tokens |
| 6. Success | Use returned accessToken to redirect to dashboard |
8. RBAC Model¶
8.1 Predefined Roles¶
Predefined roles are seeded in the auth.roles table with is_predefined = true and tenant_id = NULL. They cannot be modified or deleted via the API.
| Role | Scope | Effective Scopes |
|---|---|---|
platform-admin | Global — all tenants | admin (all scopes) + cross-tenant data access |
tenant-admin | Per-tenant | All scopes within own tenant |
developer | Per-tenant | data:read, data:write, ontology:read, pipelines:manage, apps:manage, apps:deploy, execution:run, plugins:read, logs:read |
editor | Per-tenant | data:read, data:write, ontology:read, pipelines:manage, apps:manage, logs:read |
viewer | Per-tenant | data:read, ontology:read, pipelines:read, apps:read, logs:read (own user's logs only) |
Seed SQL (run in initial migration):
INSERT INTO auth.roles (id, tenant_id, name, description, is_predefined, permissions) VALUES
(gen_random_uuid(), NULL, 'platform-admin',
'Full access across all tenants', true,
ARRAY['admin']),
(gen_random_uuid(), NULL, 'tenant-admin',
'Full access within own tenant', true,
ARRAY['data:read','data:write','ontology:read','ontology:write',
'pipelines:manage','apps:manage','apps:deploy','apps:read',
'execution:run','plugins:read','plugins:manage',
'users:read','users:manage','logs:read','webhooks:manage']),
(gen_random_uuid(), NULL, 'developer',
'Build and deploy apps and pipelines', true,
ARRAY['data:read','data:write','ontology:read','pipelines:manage',
'apps:manage','apps:deploy','apps:read','execution:run',
'plugins:read','logs:read']),
(gen_random_uuid(), NULL, 'editor',
'Manage data, pipelines, and apps (no deploy)', true,
ARRAY['data:read','data:write','ontology:read','pipelines:manage',
'apps:manage','apps:read','logs:read']),
(gen_random_uuid(), NULL, 'viewer',
'Read-only access to data and apps', true,
ARRAY['data:read','ontology:read','pipelines:read','apps:read','logs:read'])
ON CONFLICT (tenant_id, name) DO NOTHING;
8.2 Scope Resolution¶
When a user has multiple roles, the effective scope set is the union of all role scope arrays:
function resolveScopes(roles: string[]): string[] {
const scopeSets: Set<string>[] = roles.map(roleName => {
const roleRow = roleCache.get(roleName); // in-memory role cache, refreshed every 5min
return new Set(roleRow?.permissions ?? []);
});
const union = new Set<string>();
for (const set of scopeSets) {
for (const scope of set) union.add(scope);
}
// 'admin' expands to all scopes
if (union.has('admin')) return ALL_SCOPES;
return Array.from(union);
}
The ALL_SCOPES constant is the complete list of defined scopes (from the API key scopes table in section 4.4).
8.3 Entity/Field/Row Permissions¶
Entity permissions are stored in auth.entity_permissions and evaluated by the App Service BFF. The Auth Service stores and serves these permissions; the App Service enforces them.
Permission check at the App Service BFF:
// Called by App Service before forwarding a request:
async function checkEntityPermission(
userId: string,
tenantId: string,
roles: string[],
entityType: string,
action: 'create' | 'read' | 'update' | 'delete' | 'admin'
): Promise<{ allowed: boolean; fieldDenyList: string[]; rowFilter: Record<string, string> }> {
// 1. Check entity_permissions for each role
// Most permissive wins (union of allowed actions)
// 2. Field restrictions: union of deny lists (most permissive: if ANY role allows, it's allowed)
// Wait — field DENY is intersection, not union (more restrictive):
// A field is denied if ALL roles with permissions for this entity deny it.
// A field is visible if ANY role allows it.
// 3. Row filter: AND all row filters together
}
Field restriction semantics:
denyReadon a field: the field is stripped from response payloads. A user whose role hasdenyRead: ['ssn']will never seessnin any response.denyWriteon a field: the field is rejected from mutation request bodies. Attempting to set it returnsPERMISSION_DENIEDwithdetails.field = 'ssn'.- If a user has two roles, one of which has
denyRead: ['ssn']and one has no restriction onssn, the user CAN readssn(less restrictive role wins).
Row filter semantics:
{ "ownerId": "$userId" }means queries automatically appendWHERE owner_id = <requesting user's ID>.$userIdis the only supported dynamic substitution. Static values are also allowed:{ "status": "active" }.- Multiple roles' row filters are ANDed. A user with two roles where one has
{ "ownerId": "$userId" }and the other has{ "status": "active" }getsWHERE owner_id = $id AND status = 'active'. platform-adminandtenant-adminhave no row filters applied.
8.4 Token Claims and Unverified User Handling¶
When OP_REQUIRE_EMAIL_VERIFICATION = true and a user registers but has not yet verified their email:
- Tokens are issued with
unverified: true, ev: falseclaims. - The Gateway's auth middleware checks
unverifiedand downgrades the user's effective roles to['viewer']at most — they cannot perform write operations until verified. - All API responses include a
Warning: 199 - "Email verification required for full access"header (RFC 7234 Warning header). - After verification, the user's next token refresh produces
unverified: false, ev: trueclaims.
9. Security Invariants¶
These invariants MUST NOT be violated. They are enforced in code and covered by dedicated security tests.
9.1 Timing-Safe Comparisons¶
All sensitive comparisons use constant-time algorithms:
| Comparison | Implementation |
|---|---|
| Bootstrap token | crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')) |
| OAuth state | crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) |
| API key validation | bcrypt.compare — inherently timing-safe (fixed compute time per cost factor) |
| Password validation | bcrypt.compare |
Dummy hash for user-not-found path: A fixed DUMMY_HASH constant (a bcrypt hash of a random string generated at startup) is stored in memory. When a login attempt is made for a non-existent user, bcrypt.compare(providedPassword, DUMMY_HASH) is called to consume the same ~200ms as a real comparison. Without this, an attacker can distinguish "user not found" (fast response) from "wrong password" (slow response) and use this to enumerate valid email addresses.
9.2 Password Security¶
- Minimum length: 12 characters.
- Maximum length: 128 characters (prevents bcrypt DoS via extremely long inputs — bcrypt truncates at 72 bytes, but validation happens before hashing).
- bcrypt cost: 12 minimum. Configurable via
OP_BCRYPT_ROUNDS. Never reduced below 10 in production. - Passwords are NEVER logged, included in error responses, or stored in audit events.
- Password hashes are NEVER returned in any API response.
9.3 Token Security¶
- Access tokens: short-lived (15 minutes). Signed with HS256. Revocation via Redis blocklist.
- Refresh tokens: opaque 32-byte random hex. Stored in Redis with TTL. Single-use with rotation. Family-based compromise detection.
- API keys:
op_live_prefix for unambiguous identification. bcrypt-hashed at cost 12 (same as passwords). Never returned after initial issuance. - Bootstrap token: 32-byte hex (64 hex chars). Single-use. Erased from disk at service startup. Zeroed from memory after use.
- Reset tokens: JWT with 1-hour TTL + Redis single-use gate. JWT signature prevents URL manipulation. Redis gate prevents replay.
- All tokens are generated with
crypto.randomBytes()(CSPRNG). NoMath.random().
9.4 Token Rotation Invariants¶
- A refresh token is NEVER valid twice. The Redis DEL is atomic — if two concurrent requests arrive with the same token, only one DEL succeeds (returns 1); the other gets DEL returning 0 and is rejected.
- When a rotated token is replayed (token exists in DB history but not Redis), the ENTIRE session family is revoked. This prevents an attacker who obtained an old token from using it after the victim has already rotated.
- Access token revocation entries in Redis expire automatically when the token's
expis reached. No cleanup job is needed.
9.5 Bootstrap Security¶
- The bootstrap endpoint always runs the constant-time comparison, regardless of whether bootstrap is already complete (prevents timing oracle on bootstrap state).
- The bootstrap token file is erased from disk AFTER the Postgres transaction commits. If the service crashes between file erase and transaction commit, the file is present on disk and the service re-reads it on restart, allowing retry.
- Rate limiting (3 attempts / 10 min per IP) is in-memory so it works without Redis.
- The master key display endpoint (
/internal/auth/master-key-display) returns 404 after bootstrap, removing the surface from the attack surface permanently.
9.6 Input Validation¶
Every endpoint validates its input with Zod before any business logic executes. Validation failures return 422 VALIDATION_ERROR with structured field-level error details. No raw user input is concatenated into SQL — all Postgres queries use parameterized statements.
Email addresses are normalized to lowercase before storage and lookup: lower(email) in all queries and z.string().email().toLowerCase() in all Zod schemas. This prevents duplicate accounts via case variation.
9.7 Secrets in Memory¶
OP_JWT_SECRETis loaded from the environment and kept in memory for the lifetime of the process. It is never logged.OP_MASTER_KEYis loaded from file and kept in memory. It is never logged.inMemoryBootstrapTokenis zeroed (= null) immediately after the bootstrap transaction commits.- Provider OAuth secrets (
OAUTH_GITHUB_CLIENT_SECRET, etc.) are kept in memory. Provider access tokens obtained during OAuth are encrypted with AES-256-GCM before storage in Postgres.
9.8 Audit Logging¶
The Auth Service emits platform events for all security-relevant operations via Redis pub/sub (events:auth:* channels). These are consumed by the Logging Service for the audit trail.
Events emitted:
| Event | Trigger |
|---|---|
auth.user.created | User registration or bootstrap |
auth.user.deactivated | User soft-deleted or deactivated |
auth.user.login_failed | Failed login attempt (after incrementing counter) |
auth.user.locked | Account locked after 10 failures |
auth.session.created | Successful login |
auth.session.revoked | Logout, password reset, admin revoke |
auth.token.replay_detected | Replay attack detected — family revoked |
auth.key.created | API key created |
auth.key.revoked | API key revoked |
auth.password.reset_requested | forgot-password called for real user |
auth.password.reset_completed | Password successfully reset |
auth.oauth.client.registered | OAuth client created/updated |
auth.bootstrap.completed | Bootstrap endpoint called successfully |
10. Error Codes¶
Auth Service error codes follow the AUTH_ prefix convention. All codes are registered in @oneplatform/core/errors.ts.
| Code | HTTP | Description |
|---|---|---|
| Standard (from @oneplatform/core) | ||
UNAUTHORIZED | 401 | Missing or invalid authentication (generic — used when specific code would leak information) |
FORBIDDEN / PERMISSION_DENIED | 403 | Authenticated but insufficient permissions |
NOT_FOUND | 404 | Resource does not exist |
CONFLICT | 409 | Unique constraint violation (email exists, role name taken) |
VALIDATION_ERROR | 422 | Zod schema validation failure |
RATE_LIMIT_EXCEEDED | 429 | Rate limit exceeded |
INTERNAL_ERROR | 500 | Unexpected error (safe message, requestId only) |
SERVICE_UNAVAILABLE | 503 | Postgres or Redis unreachable |
| Auth-specific | ||
AUTH_BOOTSTRAP_ALREADY_COMPLETED | 410 | Bootstrap has already been completed; endpoint is permanently disabled |
AUTH_BOOTSTRAP_INVALID_TOKEN | 401 | Bootstrap token does not match |
AUTH_BOOTSTRAP_TOKEN_MISSING | 503 | Service started but bootstrap.token file was absent |
AUTH_ACCOUNT_LOCKED | 403 | Account locked due to too many failed login attempts |
AUTH_ACCOUNT_DEACTIVATED | 403 | User account has been deactivated |
AUTH_EMAIL_NOT_VERIFIED | 403 | Email verification required but not completed |
AUTH_EMAIL_ALREADY_VERIFIED | 409 | Email verification attempted but already done |
AUTH_TOKEN_REPLAY_DETECTED | 401 | Refresh token replay detected; entire session family revoked |
AUTH_SESSION_REVOKED | 401 | Session was explicitly revoked (logout, password reset, admin action) |
AUTH_RESET_TOKEN_INVALID | 401 | Password reset JWT has invalid signature or is malformed |
AUTH_RESET_TOKEN_EXPIRED | 401 | Password reset JWT has expired (1-hour TTL) |
AUTH_RESET_TOKEN_USED | 401 | Password reset token has already been used |
AUTH_VERIFY_TOKEN_INVALID | 401 | Email verification JWT has invalid signature or is malformed |
AUTH_VERIFY_TOKEN_EXPIRED | 401 | Email verification JWT has expired (24-hour TTL) |
AUTH_VERIFY_TOKEN_USED | 401 | Email verification token has already been used |
AUTH_TENANT_NOT_FOUND | 404 | Specified tenantId does not exist |
AUTH_REGISTRATION_DISABLED | 403 | Platform-admin has disabled self-registration |
AUTH_PREDEFINED_ROLE_IMMUTABLE | 403 | Attempting to modify or delete a predefined role |
AUTH_PREDEFINED_ROLE_CONFLICT | 409 | Custom role name conflicts with a predefined role name |
AUTH_OAUTH_PROVIDER_DISABLED | 400 | Requested OAuth provider is not configured |
AUTH_OAUTH_STATE_INVALID | 401 | OAuth state parameter missing, expired, or comparison failed |
AUTH_OAUTH_EXCHANGE_FAILED | 502 | Provider rejected the authorization code exchange |
AUTH_OAUTH_EMAIL_MISSING | 400 | Provider did not return an email address |
AUTH_INSUFFICIENT_SCOPE | 403 | API key does not have the required scope for this operation |
AUTH_EMERGENCY_ROTATE_FAILED | 500 | Emergency JWT secret rotation failed partway; requires manual recovery |
11. Observability¶
11.1 Health Endpoints¶
Per ADR-29:
GET /healthz — Liveness probe:
GET /readyz — Readiness probe:
{
"status": "ready",
"checks": {
"postgres": "ok",
"redis": "ok",
"bootstrap_token_loaded": "ok"
}
}
bootstrap_token_loaded is false (indicating the service cannot accept the bootstrap flow). The Gateway waits for /readyz before routing traffic. Both endpoints are excluded from rate limiting and auth middleware.
11.2 OTEL Instrumentation¶
All Hono routes are auto-instrumented by @oneplatform/core's OTEL middleware. Custom spans are created for:
auth.bcrypt.hash— bcrypt hashing duration (labels:rounds)auth.bcrypt.compare— bcrypt comparison durationauth.redis.revocation_check— Redis GET for token revocationauth.oauth.code_exchange— outbound HTTP to providerauth.bootstrap.token_compare— constant-time compare (duration only, no value)
11.3 Metrics¶
Exposed on /metrics (Prometheus format). Key metrics:
| Metric | Type | Labels | Description |
|---|---|---|---|
auth_login_total | Counter | tenant_id, result (success/failure) | Total login attempts |
auth_token_issued_total | Counter | token_type (access/refresh/api_key) | Tokens issued |
auth_token_revoked_total | Counter | reason | Tokens revoked |
auth_bcrypt_duration_seconds | Histogram | operation (hash/compare) | bcrypt timing |
auth_oauth_flow_total | Counter | provider, result | OAuth flow outcomes |
auth_api_key_validation_total | Counter | result | API key validation outcomes |
auth_bootstrap_attempts_total | Counter | result | Bootstrap attempt outcomes |
auth_password_reset_total | Counter | step (requested/completed) | Password reset funnel |
11.4 Structured Logging¶
All log entries include { traceId, requestId, service: "auth-service", level, message, metadata }. Sensitive fields (passwords, tokens, key material) are NEVER logged. The log helper in @oneplatform/core sanitizes requests before logging — fields named password, token, secret, key, hash are replaced with "[REDACTED]" in log output.
12. Testing Strategy¶
12.1 Unit Tests¶
Location: services/auth/src/**/*.test.ts
Framework: Vitest
Coverage target: 90% line coverage on all business logic files
Key unit test targets:
| Module | Tests |
|---|---|
token-service.ts | JWT issuance (correct claims, correct expiry), JWT verification (invalid sig, expired, revoked), access token revocation logic |
password-service.ts | bcrypt hash and compare, dummy hash timing (verify compare always runs), password policy validation |
refresh-token-service.ts | Token rotation (old deleted, new issued), replay detection (family revocation), concurrent rotation race (atomic DEL) |
api-key-service.ts | Key generation format, bcrypt hash/compare, prefix lookup, scope validation |
bootstrap-handler.ts | Constant-time compare (both match and mismatch), 410 after completion, in-memory token zeroing, transaction atomicity |
rbac.ts | Scope resolution (union across roles), admin expansion, entity permission evaluation, field deny resolution, row filter composition |
oauth-service.ts | State generation and storage, PKCE generation, state validation (including constant-time), user upsert logic |
12.2 Integration Tests¶
Location: services/auth/src/**/*.integration.test.ts
Framework: Vitest + Supertest + test Postgres + test Redis
Setup: @oneplatform/core/test-helpers provides createTestDatabase() and createTestRedis() that spin up in-process test instances.
Key integration test scenarios:
| Scenario | Coverage |
|---|---|
| Full login flow | Register → login → access API with token → logout → token rejected |
| Refresh token rotation | Login → refresh → verify old token invalid → refresh again with new token |
| Replay detection | Login → refresh (get token B) → attempt to use token A again → family revoked |
| Bootstrap flow | Call bootstrap → verify tenant + user created → flag set → 410 on second call |
| Password reset | Forgot password → verify link returned → use link → verify old password rejected → verify sessions revoked |
| Email verification | Register with verification required → verify unverified JWT claims → verify email → verify full claims |
| OAuth upsert scenarios | New user, existing user with matching email, existing user already linked |
| Account lockout | 10 failed logins → verify locked → wait lockout period → verify unlocked |
| API key lifecycle | Create → use → verify scopes → rotate → verify old key rejected → delete → verify rejected |
| Rate limiting | Bootstrap rate limit (3 attempts), login rate limit |
| Redis outage degradation | Simulate Redis connection failure → verify auth middleware behavior |
12.3 Security Tests¶
Location: services/auth/src/security/*.security.test.ts
Framework: Vitest
These tests explicitly target security properties, not just functional correctness:
| Test | Assertion |
|---|---|
| Timing-safe bootstrap | Call bootstrap 1000 times (500 valid token, 500 invalid token); assert mean response time difference is < 1ms |
| Timing-safe login (user not found) | Call login 1000 times (500 existing user/wrong pw, 500 nonexistent user); assert response time distributions overlap within 5% |
| Token replay atomicity | Concurrent refresh with same token (10 simultaneous requests); assert exactly 1 succeeds |
| Bootstrap concurrent | 5 concurrent bootstrap calls with valid token; assert exactly 1 succeeds and others get 410 |
| SQL injection in filter fields | Pass '; DROP TABLE auth.users; -- as email; assert parameterized query protects |
| JWT claim tampering | Modify roles claim in JWT without re-signing; assert verification failure |
| Bcrypt cost minimum | Assert OP_BCRYPT_ROUNDS < 10 produces startup error |
| Password length DoS | Send 1MB password string; assert 422 before bcrypt call |
| Revocation blocklist coverage | Issue token → add JTI to Redis blocklist → assert token rejected on next request |
| Family revocation completeness | Create 3 sessions (same family) → trigger replay on one → assert all 3 revoked |
12.4 Contract Tests¶
Pact consumer-driven contract tests for:
- App Service ↔ Auth Service:
/internal/auth/validaterequest/response contract - Gateway ↔ Auth Service: access token validation behavior
Pact tests are in services/auth/src/pact/ and run as part of CI after unit and integration tests.
13. Deployment and Configuration¶
13.1 Dockerfile¶
The Auth Service uses the shared docker/Dockerfile.service multi-stage build:
FROM node:20-alpine AS base
WORKDIR /app
COPY pnpm-workspace.yaml turbo.json ./
COPY packages/core ./packages/core
COPY services/auth ./services/auth
FROM base AS build
RUN corepack enable pnpm && pnpm install --frozen-lockfile
RUN pnpm turbo run build --filter=@oneplatform/auth
FROM node:20-alpine AS runtime
WORKDIR /app
RUN addgroup -S oneplatform && adduser -S -G oneplatform oneplatform
COPY --from=build /app/services/auth/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER oneplatform
EXPOSE 3001
CMD ["node", "dist/index.js"]
The runtime image runs as a non-root user (oneplatform). The service data volume (auth-data:/data) is owned by this user.
13.2 Docker Compose Snippet¶
auth-service:
build:
context: .
dockerfile: docker/Dockerfile.service
args:
SERVICE: auth
image: oneplatform/auth-service:${OP_VERSION:-latest}
container_name: op-auth
restart: unless-stopped
ports: [] # No external port exposure — Gateway routes all external traffic
expose:
- "3001"
networks:
- oneplatform-internal
environment:
OP_JWT_SECRET: ${OP_JWT_SECRET}
OP_BCRYPT_ROUNDS: ${OP_BCRYPT_ROUNDS:-12}
OP_REQUIRE_EMAIL_VERIFICATION: ${OP_REQUIRE_EMAIL_VERIFICATION:-false}
OAUTH_GITHUB_CLIENT_ID: ${OAUTH_GITHUB_CLIENT_ID:-}
OAUTH_GITHUB_CLIENT_SECRET: ${OAUTH_GITHUB_CLIENT_SECRET:-}
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-}
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-}
OP_SMTP_HOST: ${OP_SMTP_HOST:-}
OP_SMTP_PORT: ${OP_SMTP_PORT:-587}
OP_SMTP_USER: ${OP_SMTP_USER:-}
OP_SMTP_PASS: ${OP_SMTP_PASS:-}
OP_SMTP_FROM: ${OP_SMTP_FROM:-no-reply@oneplatform.local}
DATABASE_URL: postgresql://auth_service_role:${DB_PASSWORD}@pgbouncer:5432/oneplatform
REDIS_URL: redis://:${REDIS_AUTH_PASSWORD}@redis:6379/0
OP_BASE_URL: ${OP_BASE_URL:-http://localhost:3000}
OP_CURSOR_SECRET: ${OP_CURSOR_SECRET}
OP_LOG_LEVEL: ${OP_LOG_LEVEL:-info}
NODE_ENV: ${NODE_ENV:-production}
volumes:
- init-data:/data/init:ro # Read init files (bootstrap.token, master.key)
- auth-data:/data # Service-private volume (service.key)
- service-keys:/data/service-keys # Shared public keys (inotify-watched)
depends_on:
op-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/healthz"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
13.3 PgBouncer Configuration¶
The Auth Service connects via PgBouncer in transaction pooling mode (appropriate — Auth Service does not use LISTEN/NOTIFY or advisory locks held across connections). Pool size: 20 connections.
PgBouncer pgbouncer.ini entry:
[databases]
oneplatform = host=postgres port=5432 dbname=oneplatform pool_size=20
[pgbouncer]
pool_mode = transaction
max_client_conn = 200
Auth Service Postgres role setup (run by auth_migrator_role in initial migration):
CREATE ROLE auth_service_role WITH LOGIN PASSWORD '<generated>';
GRANT USAGE ON SCHEMA auth TO auth_service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO auth_service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO auth_service_role;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA auth TO auth_service_role;
CREATE ROLE auth_migrator_role WITH LOGIN PASSWORD '<generated>';
GRANT USAGE, CREATE ON SCHEMA auth TO auth_migrator_role;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO auth_migrator_role;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO auth_migrator_role;
13.4 Redis ACL Configuration¶
The Auth Service's Redis user is configured in redis.conf (or via redis-cli CONFIG SET):
ACL SETUSER auth-service on >$AUTH_REDIS_PASSWORD ~auth:* ~revocation:* ~reset:* ~guest-session:* +@read +@write +@string +@hash +del -select -flushdb -flushall -keys -debug
This user has access to exactly the key prefixes it needs and cannot SELECT databases, FLUSHDB, or execute admin commands.
13.5 Scaling Considerations¶
The Auth Service is designed to be horizontally scalable. State is entirely in Postgres and Redis — no in-process state beyond:
inMemoryBootstrapToken— only relevant before bootstrap. After bootstrap, this is null on all replicas. In multi-replica pre-bootstrap scenarios (unusual), the same bootstrap token file is mounted read-only on all replicas, so all replicas have the same in-memory value.- Bootstrap rate-limit counters — in-memory per-process. For multi-replica deployments, move the bootstrap rate limiter to Redis. This is not the default because Redis is not available during the scenarios where bootstrap rate limiting is most critical (startup before Redis is healthy). The in-memory limiter is per-process and provides a weaker guarantee in multi-replica scenarios; the advisory lock in Postgres prevents the actual bootstrap from happening twice regardless.
- Dummy bcrypt hash — generated at startup. Consistent behavior across replicas (each generates its own, but that is fine — it just needs to be non-null).
For production horizontal scaling: add Redis-based bootstrap rate limiting, ensure the init-data volume is mounted read-only on all replicas, and use an external load balancer that applies sticky sessions for the OAuth flow (to ensure the callback hits the same replica that stored the state in Redis — though since state is in Redis, any replica handles the callback correctly; sticky sessions are not strictly required but reduce Redis round-trips).
Appendix A: Key Format Reference¶
| Token Type | Format | Example |
|---|---|---|
| Access JWT | eyJ... (Base64URL HS256 JWT) | eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ... |
| Refresh token | Hex string (64 chars) | a3f8c2d1e4b5... |
| API key | op_live_ + Base64URL (43 chars) | op_live_AbCdEfGhIjKlMnOpQrStUvWxYz012... |
| API key prefix | First 8 chars of random portion | AbCdEfGh |
| Bootstrap token | Hex string (64 chars) | 7f3a9c4e... |
| OAuth state | Hex string (32 chars) | 4a8f2c1e... |
| Guest session | Hex string (64 chars) | 9b3c7e1a... |
| Reset JWT | eyJ... (Base64URL HS256 JWT) | eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ... |
| Verify JWT | eyJ... (Base64URL HS256 JWT) | eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ... |
Appendix B: Dependency Graph¶
┌─────────────────────────────────────────┐
│ External Clients │
│ (Browser, CLI, SDK, Partner Services) │
└──────────────────┬──────────────────────┘
│ HTTPS
┌──────────────────▼──────────────────────┐
│ Gateway Service │
│ Rate limiting, routing, auth headers │
└─────────────┬───────────────────────────┘
│ Internal network
┌─────────────▼───────────────────────────┐
│ Auth Service (port 3001) │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ Postgres │ │ Redis │ │
│ │ auth.* │ │ auth:* │ │
│ │ tables │ │ revocation:* │ │
│ └─────────────┘ │ reset:* │ │
│ │ guest-session:* │ │
│ └──────────────────┘ │
└─────────────────────────────────────────┘
▲
┌─────────────┘
App Service calls:
- GET /internal/auth/validate
- POST /internal/auth/guest-sessions
- POST /internal/oauth/clients
No other service calls Auth directly.
Auth makes no outbound service calls.
Appendix C: Zod Schema Index¶
All Zod schemas are defined in services/auth/src/schemas/. This index maps endpoints to their schema files for developer orientation.
| Schema File | Schemas Defined |
|---|---|
bootstrap.schema.ts | BootstrapRequestSchema, BootstrapResponseSchema, BootstrapStatusSchema |
auth.schema.ts | RegisterRequestSchema, LoginRequestSchema, LogoutRequestSchema, RefreshRequestSchema, ForgotPasswordSchema, ResetPasswordSchema, VerifyEmailSchema |
oauth.schema.ts | OAuthAuthorizeQuerySchema, OAuthCallbackQuerySchema, OAuthStateSchema |
api-key.schema.ts | CreateApiKeySchema, ApiKeyResponseSchema, ApiKeyListSchema |
role.schema.ts | RoleSchema, CreateRoleSchema, UpdateRoleSchema, EntityPermissionSchema |
user.schema.ts | UserSchema, UpdateUserSchema |
internal.schema.ts | ValidateTokenQuerySchema, ValidateTokenResponseSchema, GuestSessionRequestSchema, GuestSessionResponseSchema, OAuthClientRequestSchema, OAuthClientResponseSchema |
token.schema.ts | AccessTokenPayloadSchema, ResetTokenPayloadSchema, VerifyTokenPayloadSchema |