- Shipped
- September 23, 2026 at 2:58 AM UTC
- Author
- Kamo
- Commit
- 78c4a87
/api/email/oauth/callback and **************** are public and sessionless (the provider redirects the browser here with no Kamo session), so `state` was the only place the organization could come from — and it was self-describing base64 JSON the CALLER built: {"orgId": 1, "nonce": "...", "provider": "GOOGLE_WORKSPACE"}. Anyone could hand-craft one naming a different org and replay it against an authorization code they obtained for their own; the "nonce" inside it was generated but never stored or checked against anything, so it stopped nothing. The callback trusted orgId/provider/memberId straight out of that decode and activated the connected provider for whichever org the state named. OAuthStateStore now mints an opaque, unguessable token on `issue`, keeping the org/member/provider/ flow it resolves to only in a Redis record with a 10-minute TTL — never in the token itself. `peek` reads that record without spending it (used to pick the redirect URI and the flow before a callback commits to a handler); `consume` reads and permanently deletes it, so a callback is single-use: a replayed or reused token resolves to nothing, indistinguishable from one that never existed. OAuthFlowService and GroupwareOAuthService — which used to build and decode that blob independently, each with its own copy of the same mechanism — now both mint and redeem tokens through this one store, so there is exactly one place that decides what a state token means. OAuthStateStoreTest fails without the fix (a second consume of the same token still returns the record instead of null); OAuthFlowServiceTest, OAuthFlowKindStateTest and GroupwareOAuthServiceTest are updated for the new mechanism — they no longer build or decode a state blob directly, since there is nothing left to decode.
