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
invitedaccount 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-knownendpoints 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 iscurrent.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 ofEvent{type, session_id, reason}for the caller's own session:connectedon open, thenrevoked(withreason:logout,revoked_other_devices, etc.) if this session gets killed elsewhere, followed by a: pingheartbeat 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. Returnshosting_type, and foron_premorgs, theapi_url/auth_urlthe client should actually use (set via phone-home below).POST /api/v1/company-code/:code/phone-home— an on-prem instance calls this (withX-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/loginalso acceptscompany_codeto 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}— nopasswordfield. 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 /loginno longer accepts aninvite_codefield 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 rawmagic_link_tokenin 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 noEmailProfilemapped to usage typeuser_invite(orfallback), CoreAPI's invite call fails fast with400before 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/reinviteissues 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,204if valid,403 MAGIC_LINK_INVALID/MAGIC_LINK_EXPIREDotherwise. 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, flipsinvited → active, and returns a real login session (same response shape as/login, web cookies or mobile token pair depending onPlayTelly-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 ofAuthAPI/internalreturns 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/requestalways responds200 {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_INVALIDif the token is unknown or already used,403 PASSWORD_RESET_EXPIREDpast the TTL,400 WEAK_PASSWORDif Zitadel's complexity check rejects the new password.- Token lifetime defaults to 1 hour (
PLAYTELLY_PASSWORD_RESET_TOKEN_DURATION). The link itself is built fromAUTHAPI_FRONTEND_URL(defaulthttp://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 indomain/identity's ticketing-side docs, unrelated legacy HS256 stack), andPUT /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:
- Upserts any Zitadel user missing from AuthAPI's local
userstable (active, from Zitadel's own record — this can resurrect a user Zitadel still has but AuthAPI's DB lost track of). - Pushes each synced user into CoreAPI (
UpsertUserInCoreDB). - Separately, discovers any Zitadel org with no matching
orgsrow 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>" }
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" }
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 |
Authenticated — /api/v1/protected, requires Authorization: Bearer or access_token cookie
| 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.gouses{"error":{"code","message"}}(apiError()); the global FiberErrorHandlerfallback 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 writesactiveorinviteddirectly. Reserved for a future flow.org_invitationstable 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 all501stubs. 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 theauth_codestable) but no route inroutes.gocalls it — dead/legacy code, not part of the current public API. - Internal routes only check the shared PSK.
RequireOrgRole,RequireOrgMembership, andRequirePermissionmiddleware are fully implemented ininternal/middleware/auth.gobut never attached inroutes.Register— any caller holdingCOREAPI_AUTHAPI_PSKcan 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