Identity Service

Identity service

TAP signs people in through a separate service, login.human.tech, and TAP is a relying party of it like any other product. That service owns people: accounts, verified email addresses, passkeys, sessions and the sign-in ceremony. TAP owns teams: memberships, roles, invitations, seats, per-credential grants, agent keys, policies and approvals.

The two meet in exactly one fact — this identity subject, with this verified email, is that TAP user — and TAP crosses it in exactly one function.

Everything on this page is off unless configured. With IDP_ISSUER and IDENTITY_INTERNAL_URL unset, TAP behaves exactly as it did before the identity service existed: its own login page, its own passkeys, its own registered-origin table. Nothing here changes a deployment that has not adopted it.

Signing in to the dashboard

Three routes, registered only when IDP_ISSUER is set:

RouteWhat it does
GET /auth/oidc/startRedirects to {IDP_ISSUER}/authorize with PKCE (S256), a nonce, and a single-use state
GET /auth/oidc/callbackClaims the state, exchanges the code, verifies the id token, resolves the person, hands the SPA a continuation token
POST /auth/oidc/completeSpends the continuation token for a session

The shape is the same as /auth/google/* and reuses the same machinery: a DB-backed single-use state row, an HttpOnly browser-binding cookie, and a continuation token that bridges the redirect to the SPA. Two things differ.

PKCE and nonce are required. Google sign-in is a confidential-client exchange over a direct TLS hop, so TAP checks aud/iss/exp as defence in depth. Here TAP is a full OIDC relying party: it verifies the id token’s ES256 signature against the issuer’s published JWKS, requires a single audience naming this client, and requires the nonce it minted at /start and stored on the state row. That is what makes an id token minted for a work box unusable on the dashboard, and the reverse.

There is no second factor after it. The identity service has already run a passkey ceremony under the relying-party id human.tech, which is the same credential TAP would otherwise ask for. So the callback lands in finish_first_factor_login with FirstFactor::ExternalIdp and mints a full session. Asking again would be a second prompt for one credential, not a second factor.

The redirect URI is derived from configured_app_url() — the human façade (app.), never the agent one — because the browser-binding cookie is host-only and a callback on the other host could never read it back. IDP_REDIRECT_URI overrides it.

The dashboard

The three routes above are the server half. The dashboard itself has to decide, before anyone has signed in, which of two products it is: the one with a password field, or the one that sends people to the identity service. It asks GET /dashboard/config, the public contract it already fetches on load, and reads auth_mode:

{
  "identity": true,
  "issuer": "https://login.human.tech",
  "account_url": "https://login.human.tech/account",
  "logout_url": "https://login.human.tech/logout?client_id=tap-dashboard&post_logout_redirect_uri=https%3A%2F%2Fapp.tap.human.tech%2Flogin%3Fsigned_out%3D1"
}

identity is true only when sign-in through the identity service would actually work: an issuer and a registered dashboard client, the same pair /auth/oidc/start requires. Anything less and it is false with three nulls, and the dashboard renders the forms it has always rendered — because those are the only thing that can still sign anybody in. A failed fetch reads the same way, deliberately: the SPA’s fallback contract says identity: false.

Nothing in that object is a secret. An OIDC client id travels in an authorize URL a browser follows, and the two URLs are pages a person is sent to.

What changes in the SPA when it is true.

SurfaceIdentity mode
Login pageNo password, passkey or social form is rendered at all. The page goes straight to /auth/oidc/start and shows “Taking you to sign in…” with a manual link, for a browser that blocked the redirect
Return?idp_login=<token> is spent at POST /auth/oidc/complete, and the session is stored the way every other sign-in stores it
Refusal?idp_login_error=<code>&request_id=<id> renders a sentence for the code and Reference: <id> beneath it. The code itself is never shown (G7)
Sign outPOST /logout deletes the TAP session, the tab is marked signed out, then the browser goes to logout_url and comes back to /login?signed_out=1
Security pageThe passkey table is replaced by one sentence and a link to account_url

Two of those are worth saying plainly.

Sign-out has to end the identity session too. A login page that redirects on its own turns “sign out of TAP only” into “sign out and get signed straight back in”: the shell would leave for /authorize, the identity session would still be alive there, and the person would land back inside without a prompt. So sign-out ends both. The post_logout_redirect_uri is registered for this client by POST /operator/identity/dashboard-client, and the URL the SPA navigates to is built by the server from the same string, because the identity service compares them exactly and a trailing slash apart is a person stranded on a “Logged out.” page. The dashboard holds no id token to send as an id_token_hint, so the identity service asks the person to confirm the logout before it redirects.

And the return address is /login?signed_out=1, not /dashboard (#330). /dashboard is the SPA’s front door, and arriving there with no session is exactly the condition that sends the shell to /authorize — so the redirect that was supposed to finish a sign-out started the next sign-in instead. signed_out=1 is what tells an arrival from a sign-out apart from a person turning up to sign in: the shell renders one line, “You are signed out”, and waits for the person to press Sign in. /login answers GET with the same document /dashboard serves, because a post-logout redirect has to be a page a browser can land on.

The sign-out holds even if the identity service does not answer. Before it navigates anywhere, the SPA marks the tab signed out (sessionStorage), so an end-session request that returns a 400, a 502, or nothing at all still ends with a signed-out dashboard: the next arrival in that tab renders the signed-out page rather than bouncing through /authorize. On the identity service’s side the same case is answered properly — a logout with no session redirects to the registered uri instead of refusing (see the identity service README) — but the dashboard does not depend on that to keep the person out.

Returning from the callback takes a gesture unless this tab started the flow. The continuation token arrives in a URL, and a URL can be sent to somebody. Loading it must not mint a session on its own, or a link would sign a person into an account that is not theirs (login CSRF). The tab that began the sign-in records it in sessionStorage and completes silently; any other tab shows a “Continue to sign in” button first. The same rule the social-login return has always followed.

A tab that starts more than three sign-ins in a minute stops and says the session is not sticking, rather than bouncing between TAP and the identity service forever with nothing on screen to read. That is the shape a rejected-session bug would otherwise take, given that the login page redirects by itself.

Two addresses for one service

IDP_ISSUER is a browser address. It is the URL a person is redirected to, and the value TAP compares an id token’s iss against. It is not where TAP itself calls.

Identity mode needs all three of IDP_ISSUER, IDENTITY_INTERNAL_URL and IDENTITY_SERVICE_KEY. With the issuer missing, nothing verifies an id token and no account ever gets an idp link, so switching the passkey source on would point every approval at a service holding no record of anybody. A deployment with only the last two logs an error at boot and stays off.

Every server-to-server call — the JWKS fetch, the /token code exchange, and the whole internal API — goes to IDENTITY_INTERNAL_URL, directly, never through the egress proxy. The identity service is one hop away on the container group’s loopback; reaching it by its public name would send the request out to the internet, back through TAP’s own TLS front, and into the container it started from, if the enclave’s egress rules allowed it at all.

Where a key was fetched from says nothing about who minted a token, so iss is still checked against IDP_ISSUER regardless. With no IDENTITY_INTERNAL_URL set, the machine calls fall back to the issuer, which is what a deployment running the identity service somewhere else would want.

Ending a session from the identity service

“Sign out everywhere” at /account, and TAP’s own call after a membership removal, reach the dashboard by posting a logout token. A TAP session is minted from a code exchange and then lives its own 24 hours without ever being rechecked, so without this the identity service would end its session and leave this one alive — the half of an offboarding the person being offboarded would notice.

There are two routes with one meaning, differing only in where the caller is:

RouteCaller proves itself withUsed when
POST /internal/identity/backchannel-logoutX-Identity-Service-Keythe two run in one container group — the default
POST /auth/oidc/backchannel-logoutnothing; the token’s signature is all there isthey run apart

The internal one is what POST /operator/identity/dashboard-client registers, at TAP_INTERNAL_PUBLIC_BASE (default http://127.0.0.1:3100) — the same loopback address the identity service already uses for the invite and brand lookups. Publishing an unauthenticated POST that ends sessions, and then defending it with a rate limit and a replay cache, is work done to avoid using a credential the caller had in its hand the whole time.

Both end in the same function, so what a valid token means and what it does are decided once. The public one is rate-limited; the internal one is not, because the service key is already the boundary and a caller holding it can read every passkey in the deployment.

The logout token gets everything an id token gets (ES256 against the published key, iss, aud naming this client, expiry) plus the two rules that make it a logout token: it carries the back-channel-logout events member, and it carries no nonce. Without the first, every id token the issuer ever minted for this client would double as a command to end sessions — and id tokens are handed to browsers.

A logout token is single-use: its jti is claimed in the database and kept until the token expires, which is exactly the window a replay could land in. Without that, an endpoint anyone can post to would log a person out repeatedly, with a valid signature on every one. The claim fails closed on a database error, unlike the rate limiters — without it there is no single-use guarantee, and accepting on a blip would make the blip the way to replay.

A subject TAP has never seen answers 200. The identity service broadcasts to every registered client, most of which do not know that person, and a 404 would turn the endpoint into a way to ask whether an account exists here.

How a person signs in, and what a code cannot do

The identity service offers two ways in and they are not interchangeable. A passkey signs anyone in. An emailed six-digit code signs in only an account that holds no passkey: it is how a new person starts, and how somebody whose account has no credential yet gets one.

An account that holds a passkey cannot be opened with a code. POST /api/auth/sign-in/email-otp answers 403 with the code PASSKEY_REQUIRED and the sentence “This account has a passkey. Use it to sign in; your phone works from another device too.” No session is minted, no authorization code is issued, and the pending sign-in code is burned, so the attempt cannot be retried with the same digits. Cross-device (hybrid) ceremonies mean the passkey on a phone signs in a laptop that has never seen it, so this refusal strands nobody who still has one.

Why it matters to a relying party: without it, control of a mailbox was control of the account. The person could not have changed the passkeys on it, but the session they held was a full one, good at every product that trusts this service. A product reading https://human.tech/amr on an id token sees ["otp"] for a code sign-in and ["webauthn"] for a passkey one, and can hold out for the second.

Step-up is unchanged and stricter: a client asking for max_age=0 is offered passkeys only, and a code is refused there with its own sentence.

An account with no passkey has an authenticator app instead (#386). Every account holds at least one factor that is held rather than received, and the first emailed code is the last thing that signs anybody in on its own. After it, the person sets up a passkey or an app before they go anywhere: /authorize redirects a session whose account holds neither to login.human.tech/account?enrol=1&next=…, so a product session cannot be minted for an account whose only key is a mailbox.

For somebody who has an app, a code sign-in is two steps rather than a refusal: the endpoint answers { "twoFactorRedirect": true }, withholds the session, and POST /api/auth/two-factor/verify-totp (or /two-factor/verify-backup-code) finishes it. A person who has both is offered the passkey first and the app as the fallback on a device the passkey is not on.

What a relying party reads: https://human.tech/amr is ["webauthn"] for a passkey, ["otp","totp"] for a code plus the app, ["otp","mfa"] for a code plus a backup code, and ["swk"] for a passkey assertion that skipped the user-verification check. A client that needs a strong factor should gate on webauthn or totp rather than on the absence of otp.

Losing every passkey is an administrator’s decision, not a self-service flow. TAP’s Team page has a “Reset passkeys” button; it calls the identity service’s POST /internal/users/{sub}/passkeys/reset, which clears every passkey and the authenticator app, revokes every session and mails the person the name of the administrator who did it. Every strong factor goes, because an app refuses a code sign-in exactly as a passkey does and a reset that left it would move the wall rather than remove it. If the identity service cannot be reached the request fails whole rather than clearing TAP’s rows alone, and the sign-in refusal therefore says “ask your workspace admin” rather than pointing people at support.

The seam

resolve_identity(sub, email, email_verified) is the only place an identity subject becomes a TAP user. Both the dashboard callback and the token exchange call it.

  1. Already linked — a user_identities(provider='idp') row exists. Read the user and stop. Nothing in a token can move an existing link; the subject is the identity key, never the address beside it.
  2. A TAP account already holds this email — link it, but only if TAP’s own record of that address is verified. An unverified row is an address somebody claimed, and treating a claim as proof would hand the account to whoever squatted it. Refused as account_email_unverified.
  3. Nobody has it — create the account: email_verified = true (the identity service asserted it and the token was signature-verified), and an unusable password hash, because there is no password anywhere in this system and the column is not nullable.

Then, in every branch, pending invitations for the address are consumed — which is what makes “click the invite mail, sign in, land in the team” one journey.

Every refusal carries a code and a sentence. A caller pairs it with a request_id.

Token exchange (RFC 8693)

A work box signs a person in at login.human.tech, then trades the id token it receives for the TAP tokens it will act with.

POST {TAP_MCP_PUBLIC_URL}/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<the identity service's id token>
subject_token_type=urn:ietf:params:oauth:token-type:id_token
client_id=<the box's OIDC client id>
client_secret=<the box's OIDC client secret>

tap-mcp is a relay. It checks the two shape requirements RFC 8693 places on the request and hands everything else to the proxy, which holds the JWKS, the client registry, the team mapping and the signing key. In order, the proxy:

  1. looks the client_id up in identity_clients — a client TAP did not provision is unknown_client;
  2. checks the presented secret against the SHA-256 hash it stored when it provisioned that client;
  3. verifies the id token against the issuer’s JWKS with aud exactly that client — which is what stops box A’s token being spent as box B;
  4. resolves the subject through the seam;
  5. requires membership of the client’s team, or refuses with the not_a_member sentence and a request_id;
  6. records consent for the box’s origin and mints the tokens.

The scope is a ceiling applied by the proxy: tap:full tap:approve. A box asking for more is narrowed; a box asking for less gets less. It cannot widen, because nothing in the request chooses it.

The response carries TAP’s own id token as well, which adds tap_team_id — a fact the identity service does not know and must not assert.

Refreshing

A box renews at the same endpoint with grant_type=refresh_token, presenting the same identity-issued secret. tap-mcp cannot verify that secret — it did not issue it — so rather than refusing it, it forwards the value and the proxy checks it against the hash it stored at provisioning. A wrong one is refused as invalid_client_secret; a client with no identity_clients row is refused too, so a dynamically registered client whose secret failed tap-mcp’s own check gets no second chance here. Presenting no secret at all behaves as it always has: the refresh token is the bearer.

Every refusal from the exchange carries one body that both of its readers understand: error and error_description for an OAuth client library, and code, message and request_id for the box’s own page, with the same values in each pair. error is the specific reason (not_a_member, missing_email, unknown_client, invalid_client_secret, an id-token refusal) rather than one of RFC 6749’s six token-endpoint codes — a box shown invalid_grant can tell a person nothing, and the HTTP status still carries the generic signal: 401 for a client problem, 400 for anything about the grant or the person.

A client must authorize with scope=openid email. The identity service strips standard OIDC claims from the id token unless the email scope is granted, and a token with no address in it has nobody for the seam to resolve. Provisioning registers every client with that scope and names it in the response; a hand-written client that omits it is refused with missing_email.

Where approval passkeys come from

When IDENTITY_INTERNAL_URL and IDENTITY_SERVICE_KEY are set, an approval ceremony draws its credentials from GET /internal/users/{sub}/passkeys rather than from TAP’s user_passkeys and approver_passkeys tables.

Registration happens once, on login.human.tech. Approvals happen constantly, inside the enclave, where the transaction being approved lives. Moving the assertion to the identity service would move the thing being approved out of the enclave — so the public key moves instead: TAP converts the COSE_Key to a webauthn-rs credential, verifies the assertion itself, and writes the new signature counter back with POST /internal/passkeys/{id}/used.

Nothing secret crosses. A COSE public key is public by construction, and the private half has only ever existed on the person’s device.

TAP’s own tables are not consulted in this mode. After the cutover they hold credentials registered under the old relying-party id, which no device holds any more; falling back to one would produce a challenge no browser can satisfy.

The second factor at an approval (#386)

require_passkey on a credential means require a second factor, and which one depends on what the approver holds. TAP asks GET /internal/users/{sub}/factors before it offers a ceremony:

What they holdPOST /approve/txn/{id}/begin answersThey finish at
A passkeythe WebAuthn challenge, as beforePOST /approve/txn/{id}/finish
An authenticator app and no passkey{"factor":"totp","message":"Enter the code from your authenticator app"}POST /approve/txn/{id}/totp with {"code":"123456"}
Neither412 no_credentials, naming the page that fixes it

A person who holds a passkey may not use a code instead (412 passkey_required): a door somebody can choose between is only as strong as its weaker side, and the app exists for the person who cannot have the stronger one.

Everything around the ceremony is identical for both: the session, the membership check on that transaction, the origin binding from begin, the hourly ceiling on failed attempts, the channels that get resolved, and the audit row. Only the proof differs.

The code is checked by the identity service, not here. TAP sends it to POST /internal/users/{sub}/totp/verify over the group’s loopback, so the TOTP secret stays in one place, and the identity service records the time step it accepted. That record is the reason the route exists: a TOTP code is valid for its whole 30-second step, and at an approval it is the only proof, so without it the same six digits would approve the next thing too. A replay comes back as reused, which TAP turns into “wait for the app to show the next one” rather than into “wrong code”.

The strip, and the token that names the person (#399)

The embedded strip on a box has always been anonymous: no session, and begin mints a challenge over every eligible approver’s credentials, so the assertion names whichever of them answered. A passkey is a name and a decision in one gesture. Six digits are not, and an anonymous POST .../strip/totp would take a code from any eligible approver’s app, from whoever holds the transaction id.

So a box that knows who is sitting in front of it says so, with a token minted for exactly that:

POST /token/approval          → { approval_token, expires_at, header }
  Authorization: Bearer <the person's session or Sign-in-with-TAP token>
  { "txn_id": "…" }

It is bound to the person, to one transaction, to five minutes, and to the credential that minted it: a revoked session or a disconnected box kills it at once. It is signed under its own HMAC domain, so it is not an access token and is refused everywhere one is expected.

The box sends it back as X-TAP-Approval-Token. On the relay path (the box’s own page, same-origin) the browser never holds it at all — the box attaches it server-side. In the iframe path, where TAP’s own page talks to TAP directly, it travels in the URL fragment, and the page drops it from its address bar as soon as it reads it.

With a token, strip/begin answers for that person: their own passkey challenge, or {"factor":"totp"}. strip/totp?decision=approve|deny then takes the code, and strip/finish additionally requires the assertion to belong to the named person. Without a token every one of those behaves exactly as it did.

What a stolen approval token can do: decide one named transaction, for at most five minutes, and only by also producing that person’s second factor. It cannot forward a request, use or list a credential, read the audit log, or touch any other transaction. A stolen TAP access token, by contrast, acts as the person at /forward with every credential they hold, for its whole life, with no second factor at all.

Still passkey-only: the end-user path /app/users/{ext_id}/approvals/*. An end user is an app-scoped approver, not an identity-service account, so there is no authenticator app to ask for — giving them one is a different piece of work than this.

The audit row says which factor

Every ceremony now writes tap:approval-ceremony with approver_identity, policy_reason: approved_with:<factor> and a factor of webauthn or totp in its summary — dashboard and strip, passkey and app, through one function, so the two cannot drift. The forward’s own row says what went upstream; the same txn_id joins them.

Origin policy for ceremonies

In the same mode, an origin may host an approval ceremony if it is one of TAP’s configured origins or its scheme is https and its host sits under a suffix in WEBAUTHN_BOX_ORIGIN_SUFFIXES (for example work.human.tech). Matching is on a dot boundary, so evilwork.human.tech never qualifies — webauthn-rs’s own allow_subdomains option compares with a bare ends_with, which is why TAP does this check itself and hands the verifier concrete origins.

The port is not part of the question. Production boxes are on 443; the composition suite runs them on :8443, because one TLS front cannot bind 443 four times. They are the same box under the same suffix, and a policy that compared whole origins would admit one and refuse the other.

Every ceremony begin goes through one gate, which picks the policy: the suffix rule in identity mode, the registered-origin set outside it. One entry point is the point — the strip, the session-backed approval and the end-user paths each used to reach for whichever check was in front of them, which is how a box on an allowed suffix got refused on one surface and admitted on another.

There is no table to seed, no registration call, and nothing that can be empty after a restart. The set is derived from the policy on demand, per instance, with no I/O.

The second half of the rule is unchanged: a ceremony records the origin it began on, and a finish from anywhere else is refused. An allowed origin is not the same thing as the origin.

In identity mode the registered-origin table is not consulted at all. POST /internal/webauthn-origins and webauthn_origins still exist and still accept writes — removing them is #308 — but no ceremony reads them, and the per-ceremony re-read that keeps a newly provisioned box working without a redeploy is replaced by the suffix rule, which needs no row in the first place.

A deployment in identity mode with WEBAUTHN_BOX_ORIGIN_SUFFIXES empty logs a warning at boot: it is a real configuration (a deployment with no work boxes), and it is also the state in which every box ceremony is refused as origin_not_allowed against a policy nobody set.

Provisioning a box (operator)

POST /operator/teams accepts box_origin (a bare https://host[:port] — no path, no credentials). When the identity service is configured, provisioning also registers the box as a trusted OIDC client and returns:

Field
idp_client_idPut in the box’s environment
idp_client_secretShown once; TAP keeps only a hash
idp_client_scopeopenid email
idp_client_actioncreated, existing, or failed

box_origin also sets the client’s back-channel logout endpoint, defaulting to {box_origin}/auth/backchannel-logout; pass backchannel_logout_uri to override it. Without one, ending a person’s sessions at the identity service ends them there and leaves the box’s own cookie alive until it expires — which is the half of an offboarding anybody would notice.

Where a sign-out lands: post_logout_redirect_uris

The identity service honours a post_logout_redirect_uri only when it matches one it has registered for that client, exactly, and otherwise lands the person on its own “Logged out.” page. So the addresses a box sends people back to are stated at provisioning time:

"post_logout_redirect_uris": [
  "https://acme.work.human.tech/auth/start?signed_out=1",
  "https://acme.work.human.tech/auth/start?switch=1"
]

Each must be an absolute https URL with no credentials — a path and a query are expected here, unlike box_origin — and the list is capped at ten. Omitting the field leaves whatever is already registered alone: silence is not a request to clear them, so a script written before this field existed cannot delete a box’s sign-out by not mentioning it. TAP holds what it registered, which is what lets a re-run tell a changed list from an unchanged one and lets a rotation re-state it.

One client per box, not one per workspace

A workspace runs as many boxes as it has hostnames, and each is its own client with its own secret. The client is found by (team_id, box_origin), so provisioning a second box on a workspace that already has one creates a second client rather than answering with the first one’s id — which is what it used to do, on a lookup keyed by team alone. That bug did two things at once: it handed the operator a client id whose secret they did not have, and it then repointed the first box’s redirect and back-channel logout at the second box’s origin, signing everyone out of a box nobody had touched.

A re-run for one box therefore cannot reach another. A box that changes hostname is a new box to this call: it gets a new client, and the old one is left exactly as it was — retiring a client for an origin nobody serves is an operator decision, and the read below is where you see there is one.

A re-run returns the id, never a second secret

Provisioning is idempotent per box, and deliberately asymmetric about the secret:

Callidp_client_ididp_client_secret
first run for a boxreturnedreturned, once
any later run for that boxreturnedabsent (idp_client_action: "existing")
first run for a second box on the same workspacereturned, a new onereturned, once (created)
rotatereturnedreturned, once

A re-run must not mint a new secret, because the box is already using the old one: issuing a second would sign the box out at the moment someone re-ran a provisioning script for an unrelated reason. So a re-run reports the client and nothing else, and if you need a working secret and do not have it, the answer is to rotate rather than to re-provision.

Reading what a workspace’s boxes are

GET /operator/teams/{team_id}/identity-clients     (X-TAP-Operator-Secret)

One row per box: client_id, box_origin, redirect_uris, backchannel_logout_uri, post_logout_redirect_uris, brand_name, has_secret, created_at, rotated_at. Never a secret — TAP holds only a SHA-256 of each, and has_secret is the fact that matters, because a client with no hash is one the token exchange refuses.

redirect_uris and post_logout_redirect_uris are what TAP registered: the first derived from the origin, the second the list provisioning was given. They are not read back from the identity service, which exposes no route to ask — they are what the registration should be, and the thing to compare against when a sign-in is refused or a sign-out strands somebody.

Rotation

Rotation is the separate, explicit call — provision.sh --rotate-idp-client is the operator-facing name for it:

POST /operator/teams/{team_id}/idp-client/rotate
{ "box_origin": "https://acme.work.human.tech" }

Per box. A workspace with one box may omit box_origin; one with several must name it, and is refused with ambiguous_box_origin and the list of origins if it does not — rotating “whichever one came first” would sign out a box nobody was talking about.

The old secret stops working the instant the identity service answers, so that box is signed out until its environment is updated. That is the intended effect, and it is why this is not what a re-run does.

A rotation is a full re-registration. Before the secret moves, the box’s redirect_uris, backchannel_logout_uri and post_logout_redirect_uris are PATCHed from what TAP holds and the answer is read back — the same order and the same reason as the dashboard client route (#341): rotating first spends the one irreversible step on a registration already known to be wrong. A PATCH the identity service refuses ends the call with 503 box_client_not_registered and nothing moved; one it accepts without storing the post-logout redirects ends it with 503 post_logout_redirect_not_registered, also with nothing moved. On success the answer carries the registration it read back, because idp_client_action: "rotated" does not tell you whether sign-in and sign-out will work and this does.

That also makes this the one-call repair for a box whose registration has drifted — a redirect pointing at another box, a sign-out address that was never registered.

Registering TAP’s own client

Every box client is created by POST /operator/teams, which runs inside the container group. The dashboard’s own client had no equivalent, and /internal/clients is loopback-only — so there was no way to register it except by getting a shell in the enclave.

POST /operator/identity/dashboard-client     (X-TAP-Operator-Secret)

Creates, or re-secrets, the trusted client for the dashboard: redirect_uris from configured_app_url() (a browser address), the internal back-channel logout endpoint from TAP_INTERNAL_PUBLIC_BASE (a loopback one), <app>/login?signed_out=1 as the one post_logout_redirect_uris entry, scope openid email, TAP branding. It is idempotent by a fixed client id, and rotates only when the identity service answers that the client already exists — every other error is returned as itself, because none of them is fixed by minting a new secret.

A re-run against a client that is already there PATCHes that registration before it rotates, so a client registered before the dashboard had a sign-out path, or one still holding the old /dashboard return address, is brought up to date without being deleted and remade. A redirect the identity service does not hold is not honoured, and the person signing out ends on its own signed-out page instead of back here.

That repair is not best-effort, and the answer says what was registered (#341). It used to warn and rotate anyway, so a run whose repair had failed still answered 200 {"idp_client_action":"rotated","stored":true} — which is how staging ended up with a dashboard whose sign-out had never been registered behind a cutover step that reported success. Now:

  • the PATCH runs before the rotation, and a failure ends the call with 503 and the identity service’s own reason in detail. Nothing has moved at that point — no secret is rotated, no row is written — so the deployment is exactly as it was and the call is safe to run again once the objection is fixed;
  • the record the identity service answers with is read back, and a PATCH it accepted without storing the post-logout redirect is refused the same way (post_logout_redirect_not_registered). Accepted is not stored, and from TAP’s side the two are indistinguishable unless it looks;
  • the success body carries post_logout_redirect_uris, because that is the field a cutover gets wrong and cannot otherwise see. idp_client_action: "rotated" does not tell an operator whether sign-out will work; this does.

TAP keeps the secret

The route stores the client id and secret, in one config row, encrypted under this deployment’s data key by exactly the helper that protects the credentials TAP forwards with. So the answer is:

{ "idp_client_id": "tap-dashboard", "idp_client_action": "created", "stored": true, ... }

and there is nothing to put anywhere. Dashboard sign-in reads the record back; IDP_CLIENT_ID and IDP_CLIENT_SECRET no longer have to be set at all.

This is the inverse of the rule for the credentials TAP forwards with, and for the same reason. Those are other people’s secrets, which is why they are encrypted rather than held in the open; this one is TAP’s own, for TAP’s own sign-in, and handing it back to a person to paste into a key vault made it the single secret in the system that a human ended up holding a copy of. In the enclave the stored form has the better custody of the two: plaintext exists only in attested container memory, a database dump yields ciphertext, and no console, vault entry or terminal buffer has ever seen it.

The environment still wins, as a pair. Set both IDP_CLIENT_ID and IDP_CLIENT_SECRET and they override the stored record entirely, which is what a local run wants. Set one and neither is used: the two describe a single registration, and an environment id paired with a stored secret from another generation fails at the token endpoint with invalid_client, which reads as the identity service’s fault rather than as a half-finished override.

?reveal=true returns the secret in the response as well as storing it, for a deployment that still injects the pair. Discouraged, and the response says so: it puts a copy of this deployment’s own sign-in credential somewhere the data key does not reach.

Rotation is the same route. It re-secrets at the identity service and re-stores both halves in one statement, so there is no window in which the deployment holds a new client id beside an old secret. If that write fails, the route answers 500 with stored: false rather than returning a secret nobody asked to hold — the identity service has already moved, so the remedy is to run it again, which rotates again.

Migrating existing users

POST /operator/identity/migrate              (X-TAP-Operator-Secret)
{ "dry_run": true, "clear_sessions": false }

One call, run once per environment with the identity service configured. For each row in users it creates the matching identity-service account (POST /internal/users, idempotent by email) and writes the user_identities(provider='idp') link. After it, the seam finds branch (1) — already linked — for everyone who existed before the cutover, so nobody’s first sign-in has to be an account creation racing their invitations.

curl -sS -X POST https://app.tap.human.tech/operator/identity/migrate \
  -H "X-TAP-Operator-Secret: $TAP_OPERATOR_SECRET" \
  -H 'content-type: application/json' \
  -d '{"dry_run": true}'

It answers with the whole summary:

{
  "users": 42, "linked_before": 0, "linked_now": 42, "created": 42,
  "failed": [], "dry_run": true,
  "sessions_cleared": 0, "families_revoked": 0, "remaining": 0
}

Why a route rather than only a command. tap-proxy identity-migrate exists and is still the way to run this locally against a scratch database. It cannot be run where it matters: the enclave is an Azure Confidential ACI container group, az container exec is not available against one, and no other way in exists by design — the point of the deployment is that nobody gets a shell beside the data key. The route and the subcommand call the same function, with no second implementation between them; a cutover step that runs once, unattended, against a production database is exactly the shape where the reachable copy is the one that was never exercised.

The guard is the operator secret, the same X-TAP-Operator-Secret every operator route takes, from the operator machine and never from a box. Unset ⇒ 404, wrong ⇒ 401, exactly as the others.

dry_run defaults to true. An empty body is a dry run: it answers with the counts and writes nothing — not to TAP, and not to the identity service, whose create route has a side effect, so “what would happen” cannot be answered by asking it. Send {"dry_run": false} when you mean it.

clear_sessions deletes every admin_sessions row and revokes every box token family. That is the cutover moment, not part of the migration, which is why it is a separate flag: the migration alone changes nothing anybody can see and is safe to run early and often. It is refused under dry_run (400 clear_sessions_under_dry_run) — a dry run writes nothing, including this, and an operator who asked for both is about to believe one of them happened. It is also skipped by a run that stopped at its batch ceiling, because signing everybody out of an unfinished cutover is the failure this step is sequenced to avoid.

It is idempotent: a user already carrying an idp link is skipped without a call, and the identity service’s own create route is idempotent by email, so even a user whose account was created and whose link was not written converges on the second run. That matters because it runs against a production database during a cutover, which is exactly when a half-finished run is a live possibility and the remedy has to be “run it again”.

One run at a time. A second call while one is running is 409 migration_in_progress. Two concurrent runs would race on the same rows and the loser’s write would read as “already linked to a different TAP account” — the sentence reserved for a real duplicate.

remaining is how many people the run did not reach. The route answers synchronously, so it caps one request at 500 people’s worth of work — a table that grew must not turn this call into a socket that times out with nobody able to say what it did. Already-linked people cost no call and do not consume the budget, so running it again continues rather than re-walking the prefix. remaining: 0 means the table is done, and only a run that ends there will act on clear_sessions.

failed is a list, not a count. Each entry is {email, request_id, reason}: the address to act on, one sentence, and the id of the warn! line written at the moment it happened, which carries the underlying error. The error itself is deliberately not in the answer — it names the identity service’s internal address. One unreachable account never abandons the rest of the run.

Passkeys are never touched. They cannot be: a passkey is a private key on a device, and the cutover changes the relying-party id it was created under, so every existing one stops verifying whatever this does. Everyone registers one new passkey at first sign-in, via an emailed code.

Every run writes one audit row, tap:operator-identity-migrate, carrying the counts and no addresses. There is no session behind the call and the process that ran it may be gone by the time anybody asks, so that row is the only record it happened.

Nothing is deleted. Retiring TAP’s own login, passkey tables and origin table is the second half of #308, after a cutover that has been observed to work.

The same thing from a terminal

tap-proxy identity-migrate [--dry-run] [--clear-sessions]

For a local run against a scratch database, where a shell is the natural way to do it. Same function, same counts; it ends on one line —

identity-migrate done: users= linked_before= linked_now= created= failed= sessions_cleared= families_revoked= remaining=

— and exits 1 if anybody failed, 2 if it could not start. It applies no batch ceiling, because nothing is waiting on a socket. --clear-sessions under --dry-run is warned about rather than refused: the summary line a second later says sessions_cleared=0.

What the identity service asks TAP

Two routes, behind the header X-Identity-Service-Key carrying the shared IDENTITY_SERVICE_KEY — the same header name and the same secret TAP presents when it calls the identity service, so one value is configured on both containers and neither has to know about a second one. Unset on TAP ⇒ 404, wrong ⇒ 401:

RouteAnswers
POST /internal/identity/backchannel-logoutends the named person’s dashboard sessions (above)
GET /internal/identity/invite/{token}{email, team_name, inviter_name, brand, redirect_uri, expires_at}
GET /internal/identity/clients/{client_id}/brand{name, logo_url, accent}

The invite route answers 404 for unknown, expired and already-accepted alike: the join page shows one sentence for all three, and distinguishing them would let anyone holding a token probe which state it is in. Lookups are rate-limited across all tokens — a per-token counter would bound nothing, because guessing means trying different ones.

brand.name in both answers is the name a person chose: the invitation’s own brand, then the team’s display_name, and only then teams.name. That last one is a slug — lowercase, hyphenated, and the key every URL and lookup uses — so a join page that said “Join acme-corp” would look like it came from the wrong kind of system. team_name keeps returning the slug, because callers key on it.

Set the display name with company on POST /operator/teams (it falls back to brand.name), or later with company on PUT /team/settings.

Rate limits

Three hourly ceilings, all on the same DB-backed counter /forward already uses, so a deployment has one rate-limit mechanism rather than four and the count is shared across instances (G6).

BucketKeyDefaultEnv
Token exchangeclient_id600/hTAP_IDP_EXCHANGES_PER_HOUR
Failed passkey assertionscredential id10/hTAP_PASSKEY_FAILURES_PER_HOUR
Invitation lookupsthe route600/hTAP_IDP_INVITE_LOOKUPS_PER_HOUR
Back-channel logoutsthe client600/hTAP_IDP_LOGOUTS_PER_HOUR

The failed-assertion bucket is keyed on (ceremony, credential), not on the credential alone. auth.id is whatever the caller put in the body and the strip’s begin needs no session, so a credential-only bucket would be one anyone holding a transaction id could fill on a colleague’s behalf — ten junk posts and that colleague approves nothing for an hour. A failure is counted only when the assertion named a credential the challenge actually allowed and the signature check ran and failed.

They fail open on a counter error, matching /forward: the ceremony or the signature is the security boundary, and a database blip must not lock a customer out of their own workspace. Only failed assertions are counted — counting successes would put a ceiling on ordinary use.

The invitation mail

When an identity service is configured, an invitation links to {IDP_ISSUER}/join/{token} and nothing else — not the box’s own join page, whatever join_url the invite carries. That page is where the ceremony lives: it resolves the invitation over /internal/identity/invite/{token}, says “Join {Company}” with the inviter’s name, registers or asserts a passkey, and hands the person into the box afterwards. Linking to the box first would send them to a screen that immediately bounces them here, and the address bar would change twice on the way in.

It is the same link the operator gets back as accept_url, which is the documented fallback for a mail that never arrives — the printed link and the mailed one are the same by construction rather than by agreement.

With no identity service, the link is unchanged: the box’s join_url with ?invite_token=, or the TAP dashboard.

RESEND_BASE_URL, when set, is reached directly rather than through the egress proxy. The proxy exists to get out of the enclave to the internet; a mailbox on 127.0.0.1 is not the internet, and sending a loopback address through one either fails or succeeds against the proxy while the mail goes nowhere — which is how a run reports email_delivered: true with an empty inbox. Only the default host takes the proxy. email_delivered is false with an email_error whenever the send did not succeed.

Removing a member

Removing someone from a team deletes the membership row, which is what every TAP session and every /forward resolves through — so their access ends on the next request. What TAP does not own is their session at login.human.tech, so it calls POST /internal/users/{sub}/logout, which revokes those sessions and notifies every registered client’s back-channel logout.

Best-effort and after the removal: the membership is gone either way, and a slow identity service must not hold up an offboarding.

Serving login.human.tech

The identity service runs as a second container in the same confidential container group, with no public listener of its own. TAP’s in-enclave TLS front already serves several names on one rustls-acme listener, so login.human.tech is one more SAN via TLS_ADDITIONAL_DOMAINS rather than a second certificate and a second ACME account.

One axum layer does the rest: when the request’s Host equals IDENTITY_PUBLIC_HOST, the request is streamed to IDENTITY_UPSTREAM (default http://127.0.0.1:4000) with X-Forwarded-Proto: https, X-Forwarded-Host and any X-Forwarded-For, and the response is streamed back. Bodies never land in TAP’s memory.

The layer runs outermost, before any routing, auditing or session middleware — a request for the identity service is not a TAP request — and is not installed at all when IDENTITY_PUBLIC_HOST is unset.

Two things it will not do. /internal/* and /email-preview/* are refused with a 404 and the upstream is never called: the identity service’s internal API is gated on “the peer is loopback and the service key matches”, and a forwarded request satisfies the first half from the wrong side. And X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-For, Forwarded and X-Identity-Service-Key are removed from the client’s request before this layer sets its own — the identity service trusts the forwarded headers because it has to, so a client setting them would be a client choosing what it believes.

The loopback back to TAP

The traffic described above runs TAP → identity. The other direction exists too, and it is the one the sign-in page depends on: the identity service asks TAP for invitation info when someone opens /join/{token}, and for a client’s brand every time it renders a sign-in page. Both go to TAP_INTERNAL_URL (default http://127.0.0.1:3100) with X-Identity-Service-Key.

So tap-proxy binds two listeners when TLS is on: the TLS one on TAP_TLS_LISTEN_ADDR (default 0.0.0.0:443), and a plaintext one on TAP_INTERNAL_LISTEN_ADDR (default 127.0.0.1:3100). Both serve the same router; both must stay up, and either failing takes the process down. The address must be a loopback address or the process refuses to start — that listener carries every route TAP has, in the clear, and the only thing keeping it off the internet is where it is bound.

Plaintext is right for this hop, not a concession: it never leaves the container group’s network namespace, so there is no boundary for TLS to protect, and a second certificate would be a second lifecycle for a hop nobody outside the group can observe.

It is also the hop that was missing. Between the cutover and 2026-09-12, TLS mode opened the TLS listener and nothing else, so every identity → TAP call was refused at connect: login.human.tech/join/{token} answered 500 internal_error for every token, which killed every invitation link sent since the cutover, and every box’s sign-in page said “Sign in to TAP” because the brand lookup failed the same way. The composition harness runs tap-proxy in plain-HTTP mode, where the plaintext listener is the only listener, so it never saw any of it. deploy/azure/smoke.sh probe 11 is the post-deploy check for it.

A request arriving on the loopback listener does not loop back out to the identity service: the forwarding layer above matches on Host, and Host: 127.0.0.1:3100 is not IDENTITY_PUBLIC_HOST. /internal/* is on the never-forwarded list regardless.

Environment

VariableWhat it turns on
IDP_ISSUERTAP is a relying party. Registers /auth/oidc/*, enables id-token verification, and is the browser-facing address: redirects, iss, and the invitation link.
IDP_CLIENT_ID, IDP_CLIENT_SECRETThe dashboard’s own OIDC client. Optional since #319 — with neither set, the record POST /operator/identity/dashboard-client stored is used. Both set ⇒ they override it; one set ⇒ neither is used.
IDP_REDIRECT_URIOverrides the callback derived from TAP_APP_URL.
IDENTITY_INTERNAL_URLWhere every server-to-server call goes — JWKS, /token, and the internal API. With the key below, also switches approval passkeys and the origin policy.
IDENTITY_SERVICE_KEYShared secret for both directions of the internal API.
IDENTITY_PUBLIC_HOSTThe hostname TAP fronts for the identity service.
IDENTITY_UPSTREAMWhere to send it. Default http://127.0.0.1:4000.
TAP_INTERNAL_PUBLIC_BASEWhere the identity service reaches TAP from inside the group. Default http://127.0.0.1:3100; it is what the dashboard client’s back-channel logout uri is built from.
TAP_INTERNAL_LISTEN_ADDRWhere tap-proxy binds that plaintext listener when TLS is on. Default 127.0.0.1:3100. Must be a loopback address; anything else refuses to start.
IDP_JWKS_MAX_AGE_SECSHow long a cached key set is used before the next verification refetches it. Default 600.
WEBAUTHN_RP_IDhuman.tech after cutover, so one passkey works on every origin we own.
WEBAUTHN_BOX_ORIGIN_SUFFIXESComma-separated host suffixes an approval ceremony may run on.
TLS_ADDITIONAL_DOMAINSAdd login.human.tech so the certificate covers it.
RESEND_BASE_URLPoints mail at a local mailbox server in the composition suite, reached directly. Production leaves it unset.