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, upstreamidentity: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):
{
"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
| Method | Path | Body | Success | Errors |
|---|---|---|---|---|
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)
| Method | Path | Body | Success | Errors |
|---|---|---|---|---|
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{}.
Magic link, PIN, set-password
| Method | Path | Body | Success | Errors |
|---|---|---|---|---|
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
eventType | Emitted when | State subjects |
|---|---|---|
onboarding.login_success | email + password ROPC grant succeeded | user |
onboarding.login_invalid_password | grant refused for a live account (uniform 401) | user |
onboarding.login_disabled_account | login resolved to a disabled account (the one deliberate 403) | user |
onboarding.login_unknown_email | no account owns the email | none |
onboarding.login_lookup_error | the account lookup itself errored (still a uniform 401) | none |
onboarding.otp_sent | verification code generated and emailed | none (the code is credential material) |
onboarding.otp_verified | code matched, onboarding session minted | user (when an account exists) |
onboarding.otp_invalid | code mismatch or none pending | none |
onboarding.account_created | Keycloak account provisioned (OTP D2C flow, Google signup, or member-onboarding) | user (member-onboarding path emits without state) |
onboarding.google_signed_in | Google id_token verified, identity (re-)linked, tokens minted | user |
onboarding.google_signup_failed | Google id_token failed verification | none |
onboarding.google_linked | Google identity linked to an existing signed-in account | user |
onboarding.google_link_failed | link attempt presented a bad id_token | none |
Magic links
eventType | Emitted when | State subjects |
|---|---|---|
onboarding.magic_link_sent | member sign-in link generated and emailed | user |
onboarding.magic_link_redeemed | single-use link redeemed, tokens minted | user (absent if the fresh lookup fails) |
onboarding.magic_link_invalid | unknown, expired or already-used token | none |
onboarding.employer_activation_sent | employer activation email sent (legacy execute-actions path, or the magic-link variant) | user (magic-link variant only) |
onboarding.employer_magic_link_sent | employer portal sign-in link sent | user |
PIN and devices
eventType | Emitted when | State subjects |
|---|---|---|
onboarding.pin_validated | PIN sign-in succeeded, tokens minted | user |
onboarding.pin_invalid | PIN sign-in failed (unknown identifier, wrong PIN, or legacy reverse-lookup miss) | user (only when a member resolved) |
onboarding.pin_device_unbound | sign-in from an unbound or revoked device refused | user |
onboarding.pin_set | member set their app PIN | none (credential material) |
onboarding.pin_legacy_clear_failed | new hash stored but the legacy plaintext attribute survived | none |
onboarding.pin_gate_passed | step-up PIN re-verification passed, gate token minted | user |
onboarding.pin_gate_failed | step-up PIN re-verification failed (drives the cooldown counter) | none |
onboarding.device_bound | device bound or re-bound | user (with the fresh device list) |
onboarding.device_revoked | device revoked | user (with the fresh device list) |
onboarding.device_faceid_set | Face ID enrolment toggled on a device | user (with the fresh device list) |
Credential and contact changes
eventType | Emitted when | State subjects |
|---|---|---|
onboarding.password_set | onboarding set-password wrote the credential | user |
onboarding.password_set_denied | set-password refused (no ownership proof, or proof mismatch) | none |
onboarding.password_change_started | change started, confirmation code sent | user |
onboarding.password_change_resent | confirmation code re-sent | none |
onboarding.password_change_confirmed | new password durable in Keycloak | none |
onboarding.email_change_started | change started, code sent to the new address | none |
onboarding.email_change_resent | code re-sent | none |
onboarding.email_change_confirmed | primary email moved, old address in its grace window | none |
onboarding.secondary_email_requested | link-a-secondary verification mailed to the candidate | none |
onboarding.secondary_email_linked | secondary address attached to the account | user |
onboarding.secondary_email_rejected | redemption by a bearer the token was not issued for | none |
Employer funnel and imports
eventType | Emitted when | State subjects |
|---|---|---|
onboarding.onboarding_session_scheme_bound | pre-account session bound to an employer scheme | onboarding |
onboarding.slack_connect_state_minted | Slack workspace-connect state credential minted | none |
onboarding.google_import_started | redirect to Google's consent screen for directory import | none |
onboarding.google_import_finished | Google directory import callback stashed its outcome | none |
onboarding.slack_import_started | redirect to Slack's consent screen for directory import | none |
onboarding.slack_import_finished | Slack directory import callback stashed its outcome | none |
onboarding.waitlist_joined | public waitlist join (idempotent per email) | none |
Operator (god-mode)
eventType | Emitted when | State subjects |
|---|---|---|
onboarding.pin_issued | fresh member created with a newly allocated PIN | none |
onboarding.pin_reissued | existing member's PIN surfaced again instead of re-keying | user |
onboarding.godmode_user_deleted | operator deleted a Keycloak user | none |
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-passwordnever 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-pintoken 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.
