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