Skip to content

AuthAPI

The auth slice of PlayTelly's platform: fronts Zitadel (the actual identity provider — credentials, orgs, users, password policy) and layers on everything Zitadel doesn't do — multi-device session tracking, magic-link invite/password-reset activation, org self-signup with approval gating, company-code-based hosting discovery, and on-prem licensing. It issues RS256 access tokens and HS256 refresh tokens, and supports both web (cookie-based) and mobile (token-in-body) clients.

For machine-readable request/response schemas, see the OpenAPI Reference at the bottom of this page, backed by authapi.yaml.

Handles:

  • Login — platform-aware (web/mobile), company-code org resolution
  • Token refresh — silent rotation for web and mobile, session-sticky
  • Logout / logout-all — single or full session revocation
  • Multi-device sessions — list, revoke one, revoke-others, live SSE push on revocation
  • Org self-signup — public application, deferred (no Zitadel org/user) until platform-admin approval
  • Org & user provisioning — platform admin creates orgs/admins; org admin invites/creates users
  • Magic-link invite flow — admin-issued one-time link (not a typed code) that activates an invited account and signs the user in directly; rewritten from the older invite-code design on 2026-08-13 — see Invite Flow
  • Forgot-password flow — self-service reset via emailed magic link, separate from both the invite flow above and the logged-in "change my password" endpoint — see Password Reset Flow
  • Company code — public hosting-mode lookup, on-prem phone-home, license status
  • On-prem licensing — Ed25519-signed license keys, expiry grace period, cloud-issued for on-prem instances
  • Zitadel reconciliation — one-way sync (Zitadel → AuthAPI Postgres → CoreAPI) for drift repair
  • JWKS / OIDC discovery — .well-known endpoints for token verification by other services
  • Org member CRUD, org-scoped invitations, self-service profile edit — routed but not implemented (stubs return 501); see Endpoints

Architecture

graph LR
    Client["Client (Web / Mobile)"]
    AuthAPI["AuthAPI (Go/Fiber)"]
    Zitadel["Zitadel<br/>(identity provider — orgs, users, credentials)"]
    DB["PostgreSQL (GORM)<br/>orgs, users, refresh_tokens,<br/>invite_codes, local_license"]
    CoreAPI["CoreAPI<br/>(business slice — mirrors orgs/users)"]
    Cloud["PlayTelly Cloud<br/>(license issuance for on-prem)"]

    Client -->|POST /login, /refresh| AuthAPI
    AuthAPI -->|authenticate, create org/user| Zitadel
    AuthAPI --> DB
    AuthAPI -->|internal sync, PSK-signed| CoreAPI
    AuthAPI -->|reconcile: pull org/user list| Zitadel
    AuthAPI -->|on-prem: fetch/cache license| Cloud
    AuthAPI -->|Bearer JWT / cookies| Client

Zitadel remains the system of record for credentials, org membership, and password policy — Login calls zitadel.Authenticate, not a local password check. AuthAPI's Postgres database is a local mirror plus PlayTelly-specific state: account_state (invited/pending/disabled — a superset of anything Zitadel tracks), refresh-token sessions with device info, invite codes, company codes, and on-prem license caching. POST /internal/platform/reconcile repairs drift between the two.


Account & Org State Machine

Two independent status fields gate authentication — both are checked on every Login and Refresh.

User account_state (users.account_state)

State Meaning Login outcome
active Normal, usable account Allowed
pending Reserved in the schema and accepted by PUT .../users/:userId/status, but no current flow ever puts a user into this state — every creation path (Upsert, AdminCreateUser, invite, reconcile) writes active or invited directly 403 ACCOUNT_PENDING
disabled Deactivated by an admin 403 ACCOUNT_DISABLED (also blocks refresh rotation)
invited Admin-created via POST .../users/invite; has a random, never-exposed password in Zitadel — the account activates only via the magic link, never via /login 403 ACCOUNT_INVITED — "Please use the sign-in link from your invitation email to activate your account." /login no longer accepts anything that activates an invited account; see Invite Flow

Org status (orgs.status)

Status Meaning Login outcome
active Normal Allowed
pending Self-signup application awaiting platform-admin approval — no Zitadel org/user exists yet 403 ORG_PENDING for every user in the org
disabled Platform admin disabled the org 403 ORG_DISABLED
sequenceDiagram
    participant Applicant
    participant AuthAPI
    participant PlatformAdmin
    participant Zitadel

    Applicant->>AuthAPI: POST /internal/platform/orgs/self-signup<br/>{name, admin{username,email,given_name,family_name}, company_code}
    AuthAPI->>AuthAPI: Store org row, status="pending"<br/>(applicant_* columns only — no Zitadel calls yet)
    AuthAPI-->>Applicant: 201 { org }

    PlatformAdmin->>AuthAPI: PUT /internal/platform/orgs/:orgId/status {"status":"active"}
    AuthAPI->>Zitadel: CreateOrg(name) + CreateUser(applicant, random 8-char password)
    Zitadel-->>AuthAPI: zitadel_org_id, zitadel_user_id
    AuthAPI->>AuthAPI: org.status="active", user.account_state="active",<br/>password_change_required=true
    AuthAPI-->>PlatformAdmin: 200 { org, admin_user, temporary_password }

hosting_type (cloud | on_prem) is a third, orthogonal field on orgs — see Company Code & Licensing.


Token Design

Token Algorithm Expiry (default) Storage
Access Token RS256 900 s / AUTHAPI_JWT_ACCESS_EXPIRY Not persisted — validated via public key / JWKS
Refresh Token HS256 168h / AUTHAPI_JWT_REFRESH_EXPIRY Hash stored in refresh_tokens, rotated on every use

Each user belongs to exactly one org today — the claim carries a single org object, not an array. Refresh rotation preserves the same session_id (sid) across the token's lifetime, which is what session listing/revocation key off.

JWT Payload (access token)

{
  "iss": "authapi",
  "sub": "<user-uuid>",
  "uid": "<user-uuid>",
  "zid": "<zitadel-user-id>",
  "sid": "<session-uuid>",
  "iat": 1719144000,
  "exp": 1719144900,
  "jti": "<uuid>",
  "org": {
    "org_id": "<uuid>",
    "org_slug": "acme",
    "role": "member"
  },
  "app_roles": [
    { "scope": "organization", "scope_id": "<org-uuid>", "role": "ORG_ADMIN" }
  ]
}

org.role and app_roles are fetched from CoreAPI (GetUserRoles) at token-issuance time — if that call fails, the failure is logged and swallowed, and the token is still issued with role: "member" and empty app_roles rather than failing login. A CoreAPI outage therefore silently downgrades everyone's permissions instead of blocking auth.


Sessions & Multi-Device

Every login/refresh creates or rotates a refresh_tokens row carrying a stable session_id (survives rotation) plus device metadata. Web clients are fingerprinted from User-Agent; mobile clients send a base64-JSON PlayTelly-SessionDetail header (OS, OS version, device model, app version) — see deviceFromRequest.

  • GET /api/v1/sessions — list all of the caller's sessions with device info, flagging which one is current.
  • POST /api/v1/sessions/:id/revoke — kill one session by its stable ID.
  • POST /api/v1/sessions/revoke-others — kill every session except the current one.
  • GET /api/v1/sessions/stream — an SSE stream of Event{type, session_id, reason} for the caller's own session: connected on open, then revoked (with reason: logout, revoked_other_devices, etc.) if this session gets killed elsewhere, followed by a : ping heartbeat every 25s.

Revocation only deletes the refresh token and pushes an SSE event — there's no per-request access-token revocation check, so a killed session's access token stays valid until it naturally expires (hence keeping AUTHAPI_JWT_ACCESS_EXPIRY short matters). The SSE hub (internal/sse) is in-process only — a multi-replica AuthAPI deployment needs a shared transport (e.g. Redis pub/sub) for cross-replica delivery, which doesn't exist yet.


Company Code & Licensing

company_code is a short, unique, per-org slug (lowercase alphanumeric) that lets a client discover which backend to talk to before it knows anything else — this matters because PlayTelly can be self-hosted (on_prem) as well as multi-tenant (cloud).

  • GET /api/v1/company-code/:code — public. Returns hosting_type, and for on_prem orgs, the api_url/auth_url the client should actually use (set via phone-home below).
  • POST /api/v1/company-code/:code/phone-home — an on-prem instance calls this (with X-License-Key) to register its own reachable URLs against its company code.
  • GET /api/v1/company-code/:code/status — an on-prem instance checks its own license status.
  • POST /api/v1/login also accepts company_code to resolve which org to authenticate against (required for web; mobile omits it and uses the org already bound to the Zitadel session).

On-prem licenses are Ed25519-signed JWT-like tokens (internal/license) binding a company_code + expiry. license.Guard re-verifies the license once at boot and every 24h; once expired by more than a configured grace window it flips to StatusBlocked, and Login/Refresh on that instance start returning 503 LICENSE_EXPIRED — cheap in-memory check on every request, no per-request re-verification. On-prem instances with no cached license fetch one from PlayTelly Cloud on first boot via AUTHAPI_LICENSE_CLOUD_URL and cache it in the single-row local_license table for offline validation thereafter. Cloud-hosted orgs never populate that table.

POST /api/v1/platform/orgs (platform admin, internal) creates either kind of org: cloud creates the Zitadel org + admin user immediately and returns tokens; on_prem mints a license key and creates only the local orgs row (no Zitadel involvement, no admin user, no tokens — the instance is expected to run login itself).


Invite Flow

Rewritten 2026-08-13 — this used to be a 6-character typed invite code redeemed through /login. It is now a magic link: a one-time, opaque, 32-byte URL-safe token redeemed through its own dedicated endpoint, never through /login. If you're reading cached knowledge of this section from before 2026-09-20, the whole mechanism changed, not just the token format.

An org admin can invite a user directly (as opposed to org self-signup, which is for onboarding a whole new org):

sequenceDiagram
    participant OrgAdmin
    participant CoreAPI
    participant AuthAPI
    participant Zitadel
    participant Invitee

    OrgAdmin->>CoreAPI: POST /tenancy/organizations/:orgId/invitations<br/>{username, email, given_name, family_name}
    CoreAPI->>AuthAPI: POST /internal/orgs/:orgId/users/invite<br/>{username, email, given_name, family_name}
    AuthAPI->>AuthAPI: generate a random internal password<br/>(never returned to anyone, never used to sign in)
    AuthAPI->>Zitadel: CreateUser(..., internalPassword)
    AuthAPI->>AuthAPI: users.account_state = "invited"<br/>issue a magic-link token (opaque, hashed at rest, 10m default TTL)
    AuthAPI-->>CoreAPI: 201 { user, magic_link_token, magic_link_expires_at, company_code }
    CoreAPI->>CoreAPI: build the sign-in link from magic_link_token,<br/>send it via the org's "user_invite" EmailProfile
    CoreAPI-->>OrgAdmin: 201 { user, email_sent, email_error }

    Invitee->>AuthAPI: POST /auth/magic-link/redeem {token}
    AuthAPI->>AuthAPI: consume token (single-use) → account_state = "active"
    AuthAPI-->>Invitee: tokens (same shape as /login) — signed in directly, no password ever entered

Key points, verified against AuthAPI/internal/services/invite.go and internal/handlers/{user,auth}.go:

  • No invite code exists anywhere in this flow anymore. POST /internal/orgs/:orgId/users/invite's request body is just {username, given_name, family_name, email} — no password field. Zitadel still needs some credential on record, so AuthAPI generates one internally and never returns or logs it — the account is only ever unlocked via the magic link, never a typed password.
  • POST /login no longer accepts an invite_code field at all (its request struct is now just {username, password, company_code}) and cannot activate an invited account under any input — see the account-state table above.
  • AuthAPI's own internal response (to CoreAPI) includes the raw magic_link_token in plaintext, since CoreAPI needs it to build the actual emailed link. CoreAPI's own public-facing response to the org admin does not repeat the token — it's just {user, email_sent, email_error}. If the org has no EmailProfile mapped to usage type user_invite (or fallback), CoreAPI's invite call fails fast with 400 before ever reaching AuthAPI: "No email profile is configured for user invites. Set one up under Integrations before inviting users."
  • POST /internal/orgs/:orgId/users/:userId/reinvite issues a fresh magic-link token — the old one is invalidated the moment a new one is created (magicLinkRepo.Upsert, one row per user, not appended).
  • Redemption is a dedicated pair of public endpoints, not /login:
  • GET /api/v1/auth/magic-link/status?token=... — read-only validity check, 204 if valid, 403 MAGIC_LINK_INVALID/MAGIC_LINK_EXPIRED otherwise. Useful for a frontend to show "this link is still good" before rendering an activation screen.
  • POST /api/v1/auth/magic-link/redeem {token} — consumes the token, flips invited → active, and returns a real login session (same response shape as /login, web cookies or mobile token pair depending on PlayTelly-Platform).
  • Token lifetime defaults to 10 minutes (PLAYTELLY_MAGIC_LINK_TOKEN_DURATION) — much shorter than the old 7-day invite code, since it's meant to be clicked immediately from an email rather than typed in later.
  • PLAYTELLY_INVITE_CODE_LENGTH/PLAYTELLY_INVITE_CODE_DURATION (the old config vars) are dead — grep -rn "PLAYTELLY_INVITE_CODE" . across all of AuthAPI/internal returns nothing. Don't set them; they do nothing.

Password Reset Flow

New 2026-08-31, entirely separate from both the invite flow above and the already-logged-in PUT /users/me/password (change, not reset). This is the "forgot my password" self-service path — no admin action needed.

sequenceDiagram
    participant User
    participant AuthAPI
    participant CoreAPI
    participant Zitadel

    User->>AuthAPI: POST /auth/password-reset/request {email, company_code}
    AuthAPI->>AuthAPI: look up org by company_code, then user by email<br/>(deliberately silent on mismatch — no account enumeration)
    AuthAPI->>AuthAPI: generate reset token (hashed at rest, 1h default TTL)
    AuthAPI->>CoreAPI: POST /internal/email/password-reset<br/>{org_id, email, reset_link, expiry_minutes}
    CoreAPI->>CoreAPI: resolve the org's "password_reset" (or "fallback") EmailProfile, send the link
    AuthAPI-->>User: 200 { success: true }  (always, win or lose — see below)

    User->>AuthAPI: POST /auth/password-reset/confirm {token, new_password}
    AuthAPI->>Zitadel: validate password complexity, set new password
    AuthAPI->>AuthAPI: consume token (single-use), clear password_change_required if set
    AuthAPI-->>User: 200 { success: true }
  • POST /auth/password-reset/request always responds 200 {success: true}, whether or not the email/company_code combination actually matched anyone — deliberate no-account-enumeration behavior. A failure to actually send the email (e.g. CoreAPI's internal call errors) is logged server-side but does not change the response or fail the request.
  • POST /auth/password-reset/confirm — 403 PASSWORD_RESET_INVALID if the token is unknown or already used, 403 PASSWORD_RESET_EXPIRED past the TTL, 400 WEAK_PASSWORD if Zitadel's complexity check rejects the new password.
  • Token lifetime defaults to 1 hour (PLAYTELLY_PASSWORD_RESET_TOKEN_DURATION). The link itself is built from AUTHAPI_FRONTEND_URL (default http://localhost:5173).
  • This makes three separate, real reset/change-password mechanisms in the platform: this one (AuthAPI, self-service), ticketing's own /auth/customer/reset-password//auth/admin/reset-password (documented in domain/identity's ticketing-side docs, unrelated legacy HS256 stack), and PUT /api/v1/protected/users/me/password (change while already authenticated, documented above). Don't conflate them.

Zitadel Reconciliation

POST /api/v1/internal/platform/reconcile?dry_run=true|false walks every org AuthAPI knows about, lists its users in Zitadel, and:

  1. Upserts any Zitadel user missing from AuthAPI's local users table (active, from Zitadel's own record — this can resurrect a user Zitadel still has but AuthAPI's DB lost track of).
  2. Pushes each synced user into CoreAPI (UpsertUserInCoreDB).
  3. Separately, discovers any Zitadel org with no matching orgs row at all and creates one (cloud, active), then reconciles its users too.

dry_run=true reports what would change (created/already_present per user) without writing anything. This is a one-way pull from Zitadel — it never deletes or pushes local-only state back to Zitadel.


Endpoints

Health

GET /health

Liveness probe. 200 { "status": "ok" }.


OIDC / SSO discovery — no auth, no /api/v1 prefix

GET /.well-known/jwks.json

RSA public key set for verifying access tokens (kid, n, e), cached 1h.

GET /.well-known/openid-configuration

Minimal OIDC discovery document pointing token_endpoint at /api/v1/login and introspection_endpoint at /api/v1/token/introspect.


Public — no authentication required

POST /api/v1/login

Request

Header Required Values
PlayTelly-Platform No (defaults to web) web | mobile
{
  "username": "alice",
  "password": "s3cur3p@ss",
  "company_code": "acme"
}

company_code is required for web (resolves which org to authenticate against); omitted for mobile, which uses the org already bound to the Zitadel session. There is no invite_code field — /login cannot activate an invited account under any input; see Invite Flow.

web — sets access_token (/, HttpOnly, Secure, SameSite=Lax, path /) and refresh_token (path /api/v1) cookies; localhost cookie domains skip Secure/Domain.

Response 200 (web)

{
  "user": { "id": "<uuid>", "username": "alice", "email": "alice@acme.io", "org_id": "<uuid>", "password_change_required": false },
  "org": { "org_id": "<uuid>", "org_slug": "acme", "role": "member" }
}

Response 200 (mobile) — same shape plus a token pair:

{
  "access_token": "<RS256 JWT>",
  "refresh_token": "<HS256 JWT>",
  "expires_in": 900,
  "token_type": "Bearer",
  "user": { "...": "as above" },
  "org": { "...": "as above" }
}

Status Code Meaning
400 INVALID_REQUEST Missing username/password, or missing company_code on web
401 INVALID_CREDENTIALS Bad credentials
403 ORG_PENDING / ORG_DISABLED / ACCOUNT_PENDING / ACCOUNT_DISABLED / ACCOUNT_INVITED See state machine. INVITE_CODE_INVALID/INVITE_CODE_EXPIRED no longer exist — activation moved to the magic-link endpoints below, not /login
404 ORG_NOT_FOUND Company code doesn't resolve to an org, or user isn't a member
503 LICENSE_EXPIRED This instance's license has expired
500 SERVER_ERROR Internal error

GET /api/v1/auth/magic-link/status?token=...

Read-only validity check for an invite or reinvite magic link — does not consume it. 204 if valid. 403 MAGIC_LINK_INVALID/MAGIC_LINK_EXPIRED otherwise. See Invite Flow.

POST /api/v1/auth/magic-link/redeem

{ "token": "<magic link token from the emailed URL>" }
Consumes the token (single-use), activates invited → active, and returns a full login session — same response shape as /login (web cookies vs. mobile token pair, per PlayTelly-Platform). 400 INVALID_REQUEST missing token, 403 MAGIC_LINK_INVALID/MAGIC_LINK_EXPIRED.

POST /api/v1/auth/password-reset/request

{ "email": "alice@acme.io", "company_code": "acme" }
Always 200 { "success": true }, whether or not the email/company_code actually matched an account — no account-enumeration signal. See Password Reset Flow.

POST /api/v1/auth/password-reset/confirm

{ "token": "<reset token from the emailed URL>", "new_password": "N3wP@ssw0rd" }
200 { "success": true }. 400 WEAK_PASSWORD, 403 PASSWORD_RESET_INVALID/PASSWORD_RESET_EXPIRED.


POST /api/v1/refresh

{ "client_type": "web" | "mobile", "refresh_token": "<only for mobile>" }

web reads the refresh token from the cookie (body value ignored); mobile requires it in the body. Response mirrors /login's shape for that client type, minus the user object (just org). Blocked the same way as login by a disabled account, a non-active org, or an expired license (503). The submitted refresh token is invalidated immediately — a concurrent second refresh with the same token gets 401.


POST /api/v1/token/introspect

RFC 7662-style. Never 401s — invalid/expired tokens just come back {"active": false} with 200.

{ "token": "<access token>" }
{ "active": true, "sub": "...", "uid": "...", "org": {...}, "exp": 1719144900, "iss": "authapi", "iat": 1719144000, "jti": "..." }


GET /api/v1/company-code/:code

See Company Code & Licensing. 404 if unknown.

POST /api/v1/company-code/:code/phone-home

Header X-License-Key required. Body { "api_url": "https://...", "auth_url": "https://..." } (must be absolute URLs). 401 invalid license, 403 expired license.

GET /api/v1/company-code/:code/status

Header X-License-Key required. 200 { "hosting_type": "on_prem", "license_key": "..." }. 401 invalid license, 403 expired.


Sessions — requires valid access token (/api/v1/sessions)

Endpoint Notes
GET /sessions List sessions, see Sessions & Multi-Device
GET /sessions/stream SSE stream of this session's events
POST /sessions/revoke-others 400 if the caller's token predates session tracking (no sid)
POST /sessions/:id/revoke 404 if not found or not owned by the caller

Endpoint Status Notes
PUT /users/me not implemented (501)
DELETE /users/me not implemented (501)
PUT /users/me/password ✅ {current_password, new_password} → 204. 400 weak password, 401 wrong current password
POST /logout ✅ Revokes one session (body/cookie refresh token); with neither, revokes all sessions for the caller
POST /logout/all ✅ Revokes every session for the caller
GET /users/me/sessions ✅ Alias of GET /sessions
DELETE /users/me/sessions/:id ✅ Alias of POST /sessions/:id/revoke
POST /invitations/:invitationId/accept not implemented (501)

Internal — /api/v1/internal, requires header X-Internal-Token matching COREAPI_AUTHAPI_PSK

These are not exposed to end users directly; CoreAPI calls them on behalf of already-authorized platform/org admins. Note this layer trusts the shared secret only — the per-caller RequireOrgRole/RequirePermission middleware exists in code but isn't wired into any route yet, so any holder of the PSK can act on any org.

Org (/internal/orgs/:orgId)

Endpoint Status
GET "" (get org) not implemented
PUT "" (update org) not implemented
GET /members, DELETE /members/:userId not implemented
POST /invitations, GET /invitations, DELETE /invitations/:invitationId not implemented
GET /users ✅ Paginated (page, limit, max 100), enriched with Zitadel given/family name
POST /users ✅ Admin-creates an active user directly; returns the new user + a token pair
POST /users/invite ✅ See Invite Flow
POST /users/:userId/reinvite ✅ 409 if user is no longer invited
GET /users/:userId, PUT /users/:userId, DELETE /users/:userId ✅
PUT /users/:userId/status ✅ {"status": "active"\|"disabled"\|"pending"\|"invited"}

Platform (/internal/platform)

Endpoint Status
GET /orgs ✅ List all orgs
POST /orgs ✅ Create org (cloud or on_prem) — see Company Code & Licensing
POST /orgs/self-signup ✅ Self-signup application — conceptually public, but sits behind RequireInternalToken so it must be proxied by a trusted frontend/service holding the PSK, not called directly by end users. See state machine
GET /orgs/:orgId not implemented
PUT /orgs/:orgId/status ✅ {"status": "active"\|"disabled"} — active on a pending org triggers approval (creates the Zitadel org/user)
DELETE /orgs/:orgId ✅ Deletes from Zitadel then locally
GET /users/email-exists?email=... ✅ {"exists": true\|false} — used by CoreAPI's org self-signup flow to check for a duplicate email before issuing a verification code, ahead of any Zitadel/AuthAPI user creation
GET /users, GET /users/:userId, DELETE /users/:userId not implemented
POST /reconcile ✅ See Zitadel Reconciliation

Known Gaps

  • Error response shape is inconsistent. auth.go uses {"error":{"code","message"}} (apiError()); the global Fiber ErrorHandler fallback uses {"error": "<slug>", "message": "<text>"}; almost everywhere else (org.go, user.go, token.go) uses a bare {"error": "<string>"}. Check which handler you're calling before parsing error bodies programmatically.
  • account_state: "pending" is a valid, checked value with its own login error (ACCOUNT_PENDING), but no current code path assigns it to a user — every creation route writes active or invited directly. Reserved for a future flow.
  • org_invitations table and its repository methods exist (CreateInvitation/ListInvitations/AcceptInvitation/DeleteInvitation) but nothing calls them — the sysadmin-invite-into-another-org endpoints (POST/GET/DELETE /orgs/:orgId/invitations, POST /invitations/:invitationId/accept) are all 501 stubs. This is a distinct, unrelated concept from the user invite-code flow above.
  • An authorization-code SSO path exists in the service layer (AuthService.WebLogin / ExchangeAuthCode, backed by the auth_codes table) but no route in routes.go calls it — dead/legacy code, not part of the current public API.
  • Internal routes only check the shared PSK. RequireOrgRole, RequireOrgMembership, and RequirePermission middleware are fully implemented in internal/middleware/auth.go but never attached in routes.Register — any caller holding COREAPI_AUTHAPI_PSK can act on any org or platform endpoint under /api/v1/internal. Per-caller authorization for that tree is expected to happen upstream (e.g. in CoreAPI, before it calls in).

Configuration Reference

Variable Default Purpose
AUTHAPI_SERVER_PORT 8080 Listen port
AUTHAPI_WRITE_TIMEOUT 0 (unlimited) Must stay unlimited — the SSE stream is a long-lived response
AUTHAPI_CORS_ORIGIN http://localhost:8080 Comma-separated allowed origins
AUTHAPI_COOKIE_SECURE true Set false only for local HTTP dev
AUTHAPI_COOKIE_DOMAIN (required) localhost/127.0.0.1 auto-disables Secure/Domain
AUTHAPI_DB_HOST/PORT/USER/PASSWORD/NAME/SSLMODE/MAX_CONNS — Postgres connection
AUTHAPI_JWT_RSA_PRIVATE_KEY (required) Base64 PEM, PKCS#8 or PKCS#1
AUTHAPI_JWT_REFRESH_SECRET (required) HS256 signing secret
AUTHAPI_JWT_ACCESS_EXPIRY 900 (seconds) Keep short — it's the exposure window after a session is revoked
AUTHAPI_JWT_REFRESH_EXPIRY 168h Go duration syntax
JWT_ISSUER authapi iss claim / OIDC discovery issuer
ZITADEL_DOMAIN (required) Zitadel instance base URL
ZITADEL_PAT / ZITADEL_PAT_PATH (one required) Service-user personal access token; file path takes priority
AUTHAPI_ZITADEL_DEFAULT_ORG (required) Name of the platform org, resolved in Zitadel at bootstrap and seeded locally as fixed UUID 00000000-0000-0000-0000-000000000001
AUTHAPI_DEFAULT_ORG_COMPANY_CODE (required) Company code assigned to the platform org
AUTHAPI_ZITADEL_SYSADMIN_USER (required) Login name bootstrapped as the platform's first sysadmin
COREAPI_BASE_URL http://localhost:8081 For internal sync calls
COREAPI_AUTHAPI_PSK "" Shared secret CoreAPI presents as X-Internal-Token
~~PLAYTELLY_INVITE_CODE_LENGTH~~ — Dead since the 2026-08-13 magic-link rewrite — grep finds zero references. Setting it does nothing.
~~PLAYTELLY_INVITE_CODE_DURATION~~ — Dead, same reason.
PLAYTELLY_MAGIC_LINK_TOKEN_DURATION 10m Go duration syntax. How long an invite/reinvite magic link stays valid — see Invite Flow
PLAYTELLY_PASSWORD_RESET_TOKEN_DURATION 1h Go duration syntax. See Password Reset Flow
AUTHAPI_FRONTEND_URL http://localhost:5173 Base URL used to build the password-reset link sent by email
AUTHAPI_HOSTING_MODE cloud cloud | on_prem — this instance's own mode
AUTHAPI_LICENSE_KEY — Required if cloud; the multi-tenant deployment's own license
AUTHAPI_LICENSE_CLOUD_URL — Required if on_prem; where to fetch/refresh a license on first boot
AUTHAPI_LICENSE_PUBLIC_KEY (required) Base64 Ed25519 public key, verifies all license keys
AUTHAPI_LICENSE_PRIVATE_KEY — Only set on the instance that mints on-prem licenses (i.e. cloud)

Schema is managed by GORM AutoMigrate at boot (internal/migration) — there are no standalone .sql migration files to apply separately.


Login Flow

sequenceDiagram
    participant Client
    participant AuthAPI
    participant Zitadel
    participant PostgreSQL

    Client->>AuthAPI: POST /login {username, password, company_code}
    AuthAPI->>Zitadel: Authenticate(username, password)
    Zitadel-->>AuthAPI: user info + zitadel_session_id + zitadel_org_id
    AuthAPI->>Zitadel: SearchOrg(company_code) / SearchUserInOrg (membership check)
    AuthAPI->>PostgreSQL: Resolve local org by zitadel_org_id, check status
    AuthAPI->>PostgreSQL: Upsert local user (preserves existing account_state)
    AuthAPI->>AuthAPI: Check account_state (active/pending/disabled/invited)
    AuthAPI->>AuthAPI: Sign RS256 access token (15 min) + HS256 refresh token (7d)
    AuthAPI->>PostgreSQL: Store refresh token (session_id, device info, zitadel_session_id)
    AuthAPI-->>Client: tokens (body or cookie) + org claim

OpenAPI Reference