Skip to content
Updated Jul 12, 2026

Identity

The identity service owns login, token issuance and the onboarding (quote-funnel) credential flows for the employer and member apps. It wraps Keycloak: the SPA never talks to auth.dev.hiolly.com directly, and holds no Keycloak client credentials. Credential exchange happens server-side (ROPC against a confidential flow client), and the service returns a uniform token envelope.

Routed at the gateway under /identity/* (prefix stripped, upstream identity:8080; infra/local/apisix/seed-routes.sh). The legacy alias /policy-admin/onboarding/* reaches the same handlers. /internal/* is blocked at the APISIX edge and reachable only in-cluster.

Token envelope

Every endpoint that issues tokens returns the same shape (internal/handler/login.go):

json
{
  "access_token": "...",
  "refresh_token": "...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_expires_in": 3600,
  "user": {
    "sub": "...",
    "email": "...",
    "party_locator": "PTY-...",
    "firstName": "...",
    "lastName": "..."
  }
}

user.party_locator comes from the Keycloak user attribute party_locator (not org_locator). The user block is omitted when the issuer has no user context (refresh).

API routes

All routes below are public in the sense that the credential is in the request body; none require a prior JWT unless noted.

Password login

MethodPathBodySuccessErrors
POST/onboarding/login{email, password}200 token envelope + user{}400 bad body · 401 · 403 disabled · 503 not configured
POST/onboarding/refresh{refresh_token}200 fresh envelope (no user{})400 · 401 session expired · 503

login performs server-side Keycloak ROPC (grant_type=password). The 401 response is identical for an unknown email and a wrong password ("Invalid email or password"), so login cannot be used to enumerate accounts. The one deliberate non-uniform response is 403 for a disabled account. Passwords are never logged.

refresh exchanges the refresh token for a fresh envelope (grant_type=refresh_token); an expired or revoked token gets 401 and the SPA routes back to login. The response omits user{}; clients keep the block they got at login.

OTP (work email verification)

MethodPathBodySuccessErrors
POST/onboarding/send-otp{email}200 {message}400 · 422 non-work email · 500
POST/onboarding/verify-otp{email, code}200 (two shapes, below)400 · 422 invalid/expired code · 500

OTPs are 6 digits, stored in Valkey with a 10 minute TTL and burned after 5 wrong attempts. send-otp requires a work (non-freemail) address unless that freemail already owns an account.

verify-otp always mints an onboarding session on success. The 200 shape depends on whether an account already exists for the email:

  • No account: {verified: "true", onboarding_session: "<token>"}.
  • Account exists: full token envelope + onboarding_session + user{}.
MethodPathBodySuccessErrors
POST/onboarding/redeem-magic-link{token}200 token envelope + user{}400 · 422 invalid/expired/used · 500
POST/onboarding/verify-pin{pin}200 token envelope + user{}400 · 401 PIN not recognised · 503
POST/onboarding/set-password{email, password}200 {message, userId}400 · 401 no ownership proof · 403 proof does not own email · 404 no account · 422 password < 8 chars

Magic links are single-use (Valkey GETDEL) with a 15 minute TTL; the invalid/expired/already-used error is uniform. verify-pin resolves the member by activation PIN and mints tokens.

set-password requires proof of ownership of the target email: either an X-Onboarding-Session header (from verify-otp) or an Authorization: Bearer JWT whose email claim matches. Without proof it is 401; with proof for a different email it is 403.

Onboarding sessions

A server-minted random token, stored in Valkey with a 1 hour TTL, bound to exactly one verified email and (bind-once) at most one scheme. It authorizes only the pre-account quote-funnel hops: creating one scheme and bulk-enrolling into it via group-scheme-service, plus acting as ownership proof for set-password. It is not a general credential.

Downstream services verify sessions via the cluster-only POST /internal/onboarding-sessions/verify (gated by an X-Internal-Service shared secret), which returns the bound {email, schemeLocator}, 404 for unknown/expired, or 409 when the session is already bound to a different scheme.

Other routes

POST /onboarding/send-magic-link (employer activation), POST /member/send-magic-link, POST /member-onboarding (create member Keycloak user), and legacy POST /employer-onboarding. Cluster-only /internal/* routes exist for party minting and the e2e test harness; all are X-Internal-Service gated and return 404 at the gateway edge.

Events

Identity is DB-free, so its event stream is the only durable record of the onboarding funnel. Every semantic moment publishes onboarding.<event> to identity.events (landed in BigQuery olly_analytics by the analytics sink) via handler.publishOnboarding: fire-and-forget on a goroutine with a 3s timeout, so a broker outage never blocks or fails a member flow.

The envelope (internal/kafka/producer.go; see the Kafka Event Catalog) carries eventId, eventType, occurredAt, the client lineage (sessionId / activityId / activityName) lifted from W3C baggage, a payload built from the emit site's log attributes, and an optional state snapshot. It carries no correlationId; for pre-account events the sessionId is the only tie to the client walk. Messages are keyed on party_locator when the payload carries one, else email, else unkeyed. Where a Keycloak user is in scope, the emit freezes it as state {user} (internal/handler/state.go; the pin_hash credential attribute is never included), and the pre-account session event freezes state {onboarding}. OTP and PIN values never ride the bus.

Sign-in and account creation

eventTypeEmitted whenState subjects
onboarding.login_successemail + password ROPC grant succeededuser
onboarding.login_invalid_passwordgrant refused for a live account (uniform 401)user
onboarding.login_disabled_accountlogin resolved to a disabled account (the one deliberate 403)user
onboarding.login_unknown_emailno account owns the emailnone
onboarding.login_lookup_errorthe account lookup itself errored (still a uniform 401)none
onboarding.otp_sentverification code generated and emailednone (the code is credential material)
onboarding.otp_verifiedcode matched, onboarding session minteduser (when an account exists)
onboarding.otp_invalidcode mismatch or none pendingnone
onboarding.account_createdKeycloak account provisioned (OTP D2C flow, Google signup, or member-onboarding)user (member-onboarding path emits without state)
onboarding.google_signed_inGoogle id_token verified, identity (re-)linked, tokens minteduser
onboarding.google_signup_failedGoogle id_token failed verificationnone
onboarding.google_linkedGoogle identity linked to an existing signed-in accountuser
onboarding.google_link_failedlink attempt presented a bad id_tokennone
eventTypeEmitted whenState subjects
onboarding.magic_link_sentmember sign-in link generated and emaileduser
onboarding.magic_link_redeemedsingle-use link redeemed, tokens minteduser (absent if the fresh lookup fails)
onboarding.magic_link_invalidunknown, expired or already-used tokennone
onboarding.employer_activation_sentemployer activation email sent (legacy execute-actions path, or the magic-link variant)user (magic-link variant only)
onboarding.employer_magic_link_sentemployer portal sign-in link sentuser

PIN and devices

eventTypeEmitted whenState subjects
onboarding.pin_validatedPIN sign-in succeeded, tokens minteduser
onboarding.pin_invalidPIN sign-in failed (unknown identifier, wrong PIN, or legacy reverse-lookup miss)user (only when a member resolved)
onboarding.pin_device_unboundsign-in from an unbound or revoked device refuseduser
onboarding.pin_setmember set their app PINnone (credential material)
onboarding.pin_legacy_clear_failednew hash stored but the legacy plaintext attribute survivednone
onboarding.pin_gate_passedstep-up PIN re-verification passed, gate token minteduser
onboarding.pin_gate_failedstep-up PIN re-verification failed (drives the cooldown counter)none
onboarding.device_bounddevice bound or re-bounduser (with the fresh device list)
onboarding.device_revokeddevice revokeduser (with the fresh device list)
onboarding.device_faceid_setFace ID enrolment toggled on a deviceuser (with the fresh device list)

Credential and contact changes

eventTypeEmitted whenState subjects
onboarding.password_setonboarding set-password wrote the credentialuser
onboarding.password_set_deniedset-password refused (no ownership proof, or proof mismatch)none
onboarding.password_change_startedchange started, confirmation code sentuser
onboarding.password_change_resentconfirmation code re-sentnone
onboarding.password_change_confirmednew password durable in Keycloaknone
onboarding.email_change_startedchange started, code sent to the new addressnone
onboarding.email_change_resentcode re-sentnone
onboarding.email_change_confirmedprimary email moved, old address in its grace windownone
onboarding.secondary_email_requestedlink-a-secondary verification mailed to the candidatenone
onboarding.secondary_email_linkedsecondary address attached to the accountuser
onboarding.secondary_email_rejectedredemption by a bearer the token was not issued fornone

Employer funnel and imports

eventTypeEmitted whenState subjects
onboarding.onboarding_session_scheme_boundpre-account session bound to an employer schemeonboarding
onboarding.slack_connect_state_mintedSlack workspace-connect state credential mintednone
onboarding.google_import_startedredirect to Google's consent screen for directory importnone
onboarding.google_import_finishedGoogle directory import callback stashed its outcomenone
onboarding.slack_import_startedredirect to Slack's consent screen for directory importnone
onboarding.slack_import_finishedSlack directory import callback stashed its outcomenone
onboarding.waitlist_joinedpublic waitlist join (idempotent per email)none

Operator (god-mode)

eventTypeEmitted whenState subjects
onboarding.pin_issuedfresh member created with a newly allocated PINnone
onboarding.pin_reissuedexisting member's PIN surfaced again instead of re-keyinguser
onboarding.godmode_user_deletedoperator deleted a Keycloak usernone

Identity consumes nothing: no Kafka consumer runs. Contracts (payload schema, required state subjects, lineage, producers/consumers as code refs, golden examples) live in the event registry, one directory per type under packages/go/domain/eventregistry/registry/.

Invariants

  • One token envelope shape across login, refresh, verify-otp, verify-pin and redeem-magic-link.
  • Login failure responses never distinguish unknown email from wrong password.
  • Magic links and OTPs are single-use with bounded TTLs (15 min / 10 min); onboarding sessions expire after 1 hour and bind to at most one scheme.
  • set-password never changes a password without proof of ownership of the email.
  • The service refuses to start without its required secrets (KEYCLOAK_SA_SECRET, INTERNAL_SVC_SECRET, GOOGLE_WEB_CLIENT_ID, GODMODE_KEY).

Caveats

  • Login rate limiting is not enforced at the application layer; the Keycloak realm lockout is the backstop (#1437).
  • verify-pin token minting is best-effort: on a Keycloak failure it falls back to placeholder tokens rather than failing the flow.
  • Access tokens default to 15 minutes, refresh tokens to 1 hour, driven by realm settings.

Olly Health Insurance Platform