Skip to content

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

  1. Service Overview
  2. Database Schema
  3. Redis Key Inventory
  4. API Endpoints — Public
  5. API Endpoints — Internal
  6. Authentication Flows
  7. Bootstrap Flow
  8. RBAC Model
  9. Security Invariants
  10. Error Codes
  11. Observability
  12. Testing Strategy
  13. 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):

  1. Load OP_MASTER_KEY from /data/init/master.key via loadMasterKey() (falls back to /run/secrets/op_master_key for Docker Secrets production deployments).
  2. Read /data/init/bootstrap.token into memory as inMemoryBootstrapToken. Do not erase the file yet.
  3. Run database migrations against the auth schema (using @oneplatform/core's migration runner — idempotent, version-tracked).
  4. Read auth.bootstrap_state.bootstrap_completed from Postgres.
  5. Generate Ed25519 keypair if /data/service.key is absent; publish public key to /data/service-keys/auth-service.pub.
  6. Begin inotify watch on /data/service-keys/ for hot-reloading peer public keys.
  7. Mark /readyz healthy 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).

-- Set at the start of every tenant-scoped transaction:
SET LOCAL app.tenant_id = '<uuid>';

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:

z.object({
  data: z.object({
    completed: z.boolean(),
  })
})

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:

  1. Validate bootstrapToken using crypto.timingSafeEqual() against Buffer.from(inMemoryBootstrapToken, 'hex'). Fail with AUTH_BOOTSTRAP_INVALID_TOKEN if 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.
  2. In a single Postgres transaction: (a) INSERT into auth.tenants, (b) bcrypt-hash password at cost 12, (c) INSERT into auth.users with roles = ['platform-admin'] and email_verified = true, (d) UPDATE auth.bootstrap_state SET bootstrap_completed = true, completed_at = now(), admin_user_id = <id>, first_tenant_id = <id>.
  3. Issue access + refresh tokens. Store refresh token in Redis and auth.sessions.
  4. Zero inMemoryBootstrapToken in memory (inMemoryBootstrapToken = null).
  5. Erase /data/init/bootstrap.token from disk (best-effort — log warning if erase fails, do not fail the request).
  6. All subsequent calls return 410 AUTH_BOOTSTRAP_ALREADY_COMPLETED before 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:

  1. Check tenant exists and is active.
  2. Check (tenant_id, lower(email)) uniqueness in auth.users.
  3. Hash password: await bcrypt.hash(password, parseInt(OP_BCRYPT_ROUNDS, 10)) — always cost 12 minimum.
  4. Insert user with roles = ['viewer'] (default role), email_verified = false.
  5. 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.
  6. If OP_REQUIRE_EMAIL_VERIFICATION = false: issue access + refresh tokens immediately, set email_verified = true.
  7. Emit auth.user.created event 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:

  1. Look up user by (tenant_id, lower(email)). If not found, run a dummy bcrypt.compare('dummy', DUMMY_HASH) to prevent timing oracle, then return UNAUTHORIZED.
  2. Check locked_until: if locked_until > now(), return AUTH_ACCOUNT_LOCKED.
  3. const valid = await bcrypt.compare(password, user.password_hash).
  4. If invalid: increment failed_login_count. If failed_login_count >= 10, set locked_until = now() + INTERVAL '15 minutes'. Return UNAUTHORIZED.
  5. If valid: reset failed_login_count = 0, update last_login_at.
  6. Issue access + refresh tokens. Store session in Postgres + Redis.
  7. If OP_REQUIRE_EMAIL_VERIFICATION = true and email_verified = false: issue tokens but include unverified: true claim 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:

  1. Add current access token's jti to Redis revocation:{jti} with TTL equal to token's remaining lifetime (not the full OP_JWT_EXPIRY_SECONDS). This ensures the entry expires exactly when the token would have expired, avoiding stale entries.
  2. If refreshToken provided: delete auth:refresh:{token} from Redis, revoke matching session in Postgres (SET revoked_at = now(), revoked_reason = 'logout').
  3. If all = true: delete all Redis auth:refresh:* for this user (requires scanning auth.sessions for active session JTIs), revoke all sessions in Postgres.
  4. 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:

z.object({
  refreshToken: z.string(),
})

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:

  1. Look up auth:refresh:{token} in Redis. If missing: check Postgres auth.sessions for a session where refresh_token_jti matches 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. Return AUTH_TOKEN_REPLAY_DETECTED.
  2. If found in Redis: parse payload {userId, tenantId, sessionId, jti, familyId}.
  3. Delete old token: DEL auth:refresh:{token}.
  4. 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).
  5. Update auth.sessions SET refresh_token_jti = <newJti>, last_used_at = now().
  6. Issue new access JWT.
  7. 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:

z.object({
  email:    z.string().email().max(254).toLowerCase(),
  tenantId: z.string().uuid(),
})

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:

  1. Look up user by (tenant_id, lower(email)). Continue regardless of whether user is found.
  2. If user found and account active: generate reset JWT { sub: userId, purpose: 'password-reset', jti: uuid(), iat, exp: iat + 3600 } signed with OP_JWT_SECRET. Store reset:{jti} in Redis with TTL 3600. Insert into auth.password_reset_tokens. Send email or return resetLink if no SMTP.
  3. If user not found: sleep for ~200ms (same duration as the found-user path) to prevent timing oracle.
  4. Always return the same 200 response.

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:

  1. Verify JWT signature using OP_JWT_SECRET. Reject if invalid.
  2. Check exp claim. Return AUTH_RESET_TOKEN_EXPIRED if expired.
  3. Check purpose claim equals 'password-reset'.
  4. Check Redis reset:{jti} exists. If absent, return AUTH_RESET_TOKEN_USED.
  5. In a Redis pipeline: DEL reset:{jti} (atomic — prevents TOCTOU). If DEL returns 0, concurrent request already used it — return AUTH_RESET_TOKEN_USED.
  6. Hash new password with bcrypt cost 12.
  7. In Postgres transaction: (a) UPDATE auth.users SET password_hash = <new_hash> where id = sub, (b) UPDATE auth.password_reset_tokens SET used_at = now() where jti = <jti>, (c) UPDATE auth.sessions SET revoked_at = now(), revoked_reason = 'password_reset' where user_id = sub AND revoked_at IS NULL.
  8. Delete all refresh tokens for this user from Redis (scan auth.sessions for active JTIs, then DEL auth:refresh:{token} for each).
  9. 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:

  1. Verify JWT signature, expiry, and purpose === 'email-verify'.
  2. Atomic Redis DEL auth:verify:{jti}. If DEL returns 0, return AUTH_VERIFY_TOKEN_USED.
  3. UPDATE auth.users SET email_verified = true where id = sub.
  4. If the user has an active session with unverified: true claim: 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:

  1. Validate provider is "github" or "google".
  2. Check that the provider is enabled (env vars present).
  3. Generate PKCE: codeVerifier = crypto.randomBytes(32).toString('base64url'), codeChallenge = sha256(codeVerifier).toString('base64url').
  4. Generate state = crypto.randomBytes(16).toString('hex').
  5. Store in Redis: auth:oauth:state:{state}{ provider, codeVerifier, redirectUri, tenantId } TTL 600s.
  6. Build provider authorization URL with state, code_challenge, code_challenge_method=S256, scope=read:user,user:email (GitHub) or scope=openid email profile (Google).
  7. 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:

  1. If error param present, redirect to error page.
  2. Get auth:oauth:state:{state} from Redis. If absent, redirect with oauth_state_invalid. DEL the key atomically.
  3. Use constant-time comparison for state validation.
  4. Exchange code for provider tokens using codeVerifier from state for PKCE verification.
  5. Fetch user profile from provider (email, provider user ID, display name).
  6. Upsert logic:
  7. Look for existing auth.oauth_providers row by (provider, provider_user_id, tenant_id).
  8. If found: update tokens, return existing user.
  9. If not found: look for auth.users by (tenant_id, lower(email)).
    • If user exists but no OAuth link: create auth.oauth_providers row linking the existing user.
    • If no user: create new user (roles = ['viewer'], email_verified = true — OAuth-verified email), then create auth.oauth_providers row.
  10. Store provider tokens encrypted (AES-256-GCM with HKDF-derived key per ADR-11).
  11. Issue platform access + refresh tokens.
  12. Redirect to OP_BASE_URL/auth/callback with 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:

  1. 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}.
  2. keyPrefix = random.substring(0, 8).
  3. keyHash = await bcrypt.hash(fullKey, 12).
  4. Store in auth.api_keys. Return full key in response body.
  5. 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:

  1. Verify key belongs to requesting user (or user has users:manage).
  2. Set revoked_at = now(), revoked_by = requestingUserId in auth.api_keys.
  3. 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.
  4. Emit auth.key.revoked event.

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:

  1. Generate token = crypto.randomBytes(32).toString('hex').
  2. Store guest-session:{token}{ tenantId, appId, createdAt: now().toISOString(), ipAddress } in Redis with TTL 86400.
  3. Return token to App Service, which sets the op_guest_session httpOnly 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:

  1. Enforce that app:*:* pattern client IDs may only be created by app-service (check c.var.callerService).
  2. 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.
  3. 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:

  1. GET /api/v1/auth/bootstrap/status — to decide whether to show the wizard or the login page.
  2. GET /internal/auth/master-key-display — for wizard screen 4 (master key confirmation). Only callable when bootstrap_completed = false.
  3. 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:

  • denyRead on a field: the field is stripped from response payloads. A user whose role has denyRead: ['ssn'] will never see ssn in any response.
  • denyWrite on a field: the field is rejected from mutation request bodies. Attempting to set it returns PERMISSION_DENIED with details.field = 'ssn'.
  • If a user has two roles, one of which has denyRead: ['ssn'] and one has no restriction on ssn, the user CAN read ssn (less restrictive role wins).

Row filter semantics:

  • { "ownerId": "$userId" } means queries automatically append WHERE owner_id = <requesting user's ID>.
  • $userId is 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" } gets WHERE owner_id = $id AND status = 'active'.
  • platform-admin and tenant-admin have 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:

  1. Tokens are issued with unverified: true, ev: false claims.
  2. The Gateway's auth middleware checks unverified and downgrades the user's effective roles to ['viewer'] at most — they cannot perform write operations until verified.
  3. All API responses include a Warning: 199 - "Email verification required for full access" header (RFC 7234 Warning header).
  4. After verification, the user's next token refresh produces unverified: false, ev: true claims.

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). No Math.random().

9.4 Token Rotation Invariants

  1. 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.
  2. 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.
  3. Access token revocation entries in Redis expire automatically when the token's exp is reached. No cleanup job is needed.

9.5 Bootstrap Security

  1. The bootstrap endpoint always runs the constant-time comparison, regardless of whether bootstrap is already complete (prevents timing oracle on bootstrap state).
  2. 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.
  3. Rate limiting (3 attempts / 10 min per IP) is in-memory so it works without Redis.
  4. 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_SECRET is loaded from the environment and kept in memory for the lifetime of the process. It is never logged.
  • OP_MASTER_KEY is loaded from file and kept in memory. It is never logged.
  • inMemoryBootstrapToken is 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:

{ "status": "ok", "service": "auth-service", "version": "1.0.0" }
Returns 503 only if the process is in a fatal unrecoverable state.

GET /readyz — Readiness probe:

{
  "status": "ready",
  "checks": {
    "postgres": "ok",
    "redis": "ok",
    "bootstrap_token_loaded": "ok"
  }
}
Returns 503 when Postgres or Redis is unreachable, or when 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 duration
  • auth.redis.revocation_check — Redis GET for token revocation
  • auth.oauth.code_exchange — outbound HTTP to provider
  • auth.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/validate request/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:

  1. 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.
  2. 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.
  3. 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