The GUI's OAuth-link callback calls cirisClient.users.linkOAuthAccount(user_id, …), which resolves to:
POST /v1/users/{user_id}/oauth-links
CIRISServer does not route that path, and never has. A web user linking a Google account gets a 404.
Where the path came from
client/generated-api/…/UsersApi.kt is generated from the agent's OpenAPI spec, so /v1/users/{id}/oauth-links is the agent server's surface. The node's equivalent has always been a different route, and the two halves each pointed at a counterpart that did not exist — tracked as CIRISServer#432.
What to call instead
POST /v1/self/oauth-link on the node, owner-session gated.
{ "provider": "google", "email": "person@example.org" }
Omit wa_id to target the node's owner — the case someone linking their own account is in. Pass it to pre-provision a different user.
It takes an email, not an external_id. That is the substantive change in CIRISServer 0.5.182 and the reason the surface is usable at all: the route previously required a provider subject id, and nobody can look up their own Google sub. An address is a handle a person actually has. The node records it and binds the verified pair on that person's next sign-in — so the response is linked: false with a note saying so, and the link completes on the following OAuth round trip rather than immediately.
Typed refusals worth surfacing distinctly, because they need different things from the reader:
| reason_id |
meaning |
auth.oauth_link.already_has_identity |
that account already has a linked identity; remove it first |
auth.link.ambiguous_handle |
both an account id and an email were sent; they mean different things |
auth.not_the_owner |
signed in, but not as the owner |
auth.not_signed_in |
no owner session |
Both new ids are localized into all 29 languages as of 0.5.182.
Reference implementation
CIRISApiClient.preprovisionOAuthEmail in the vendored KMP client (client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/api/CIRISApiClient.kt) does exactly this call and is the shape to mirror.
Verification
tests/oauth_state_matrix.rs in CIRISServer covers the flow end to end against a synthetic provider, including the assertion that matters: the identity lands on the owner's existing cert rather than minting a second account for the same human.
The GUI's OAuth-link callback calls
cirisClient.users.linkOAuthAccount(user_id, …), which resolves to:CIRISServer does not route that path, and never has. A web user linking a Google account gets a 404.
Where the path came from
client/generated-api/…/UsersApi.ktis generated from the agent's OpenAPI spec, so/v1/users/{id}/oauth-linksis the agent server's surface. The node's equivalent has always been a different route, and the two halves each pointed at a counterpart that did not exist — tracked as CIRISServer#432.What to call instead
POST /v1/self/oauth-linkon the node, owner-session gated.{ "provider": "google", "email": "person@example.org" }Omit
wa_idto target the node's owner — the case someone linking their own account is in. Pass it to pre-provision a different user.It takes an email, not an
external_id. That is the substantive change in CIRISServer 0.5.182 and the reason the surface is usable at all: the route previously required a provider subject id, and nobody can look up their own Googlesub. An address is a handle a person actually has. The node records it and binds the verified pair on that person's next sign-in — so the response islinked: falsewith a note saying so, and the link completes on the following OAuth round trip rather than immediately.Typed refusals worth surfacing distinctly, because they need different things from the reader:
auth.oauth_link.already_has_identityauth.link.ambiguous_handleauth.not_the_ownerauth.not_signed_inBoth new ids are localized into all 29 languages as of 0.5.182.
Reference implementation
CIRISApiClient.preprovisionOAuthEmailin the vendored KMP client (client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/api/CIRISApiClient.kt) does exactly this call and is the shape to mirror.Verification
tests/oauth_state_matrix.rsin CIRISServer covers the flow end to end against a synthetic provider, including the assertion that matters: the identity lands on the owner's existing cert rather than minting a second account for the same human.