Module kit / Identity and auth
Infomaniak OIDC login
identity-auth/infomaniak-oidc-login
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod-flag-off |
| Reusable | extract |
| Stack | fastapi, httpx, pyjwt, redis |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope, _shared/test-layout |
| Frontends | identity-auth/ui/auth-screens |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
A person clicks "Sign in with Infomaniak", authenticates at the identity provider, and comes back holding a session on the surface they started from, with the tokens carried in the URL fragment so they never reach a log, a proxy or a Referer. The invariant that makes it trustworthy is that everything the callback trusts was minted by the start leg and stored server-side, single-use, in Redis: the surface, the nonce and the PKCE verifier come from the consumed state and never from the callback URL, the id token is verified against the provider's JWKS with the audience and issuer pinned, and an address the provider has not verified, or that no account carries, is refused rather than linked.
This recipe fixes the two legs, the Redis state record, the discovery and JWKS caching, the linking rule (email equality, provisioning off by default), and the flag that keeps both endpoints at 503 until an OAuth client exists. It is prod-flag-off: the code ships on the deployed line, and the production heredoc carries none of its keys.
When to use
Trigger phrases: "sign in with Infomaniak", "the OIDC button", "503 Infomaniak sign-in is not enabled", "oidc_error=no_account", "state store unavailable", "id_token verification failed", "auto-provision", "the callback lands on the wrong app". It is the relying-party half of ADR-0030 (status: proposed, gated on the provider's written answer); the staff surfaces (Forgejo, Aurum) use the provider's generic OIDC clients and are not this recipe.
Do not use it for the session that results (identity-auth/session-login-refresh-logout, B2 and S5: the callback ends in create_session), for the grant table (identity-auth/surface-registry-and-gate, B4), for the two OAuth flows that store provider tokens for a workspace (Google Ads, Meta, LinkedIn: those are integrations, and the in-process state dict of the first one is the scar this module's Redis rule comes from), or for the login page that shows the button (F1, gated by a build-time VITE_INFOMANIAK_LOGIN).
Stack assumptions
Pinned as measured at fd8558f (backend/requirements.txt): fastapi==0.118.2, httpx==0.28.1 (discovery and the token exchange, async clients with 10 s and 15 s timeouts), PyJWT==2.10.1 (jwt.decode with PyJWKClient, RS256 only), cryptography==46.0.0 underneath it, redis==6.4.0 (redis.asyncio, the state store; the server must answer GETDEL, Redis 6.2 or later, and the client must be built with decode_responses=True, because consume_state splits the returned value as str and would raise on bytes: core/cache.py 17 to 21). Foundations assumed: S4 (mounted mount_router(app, auth_infomaniak_router.router, prefix="/api/auth", surfaces=ALL_SURFACES), app_fastapi.py 821, whatever the flag says), S5, S7 (the 400 and 503 bodies are the Labs envelope; the callback's refusals are redirects, not envelopes), S9.
Environment names (core/config.py 157 to 169) and their fail behaviour:
| Name | Default | Behaviour |
|---|---|---|
INFOMANIAK_OIDC_ENABLED | False | both routes answer 503 Infomaniak sign-in is not enabled on this deployment. until true (_require_enabled, router 30 to 35) |
INFOMANIAK_OIDC_ISSUER | the provider's login origin | discovery is <issuer>/.well-known/openid-configuration; the issuer the id token is checked against is the discovery document's, not this value (redeem_code 212) |
INFOMANIAK_AUTHCLIENT_ID | empty | client_id on both legs and the audience the id token must carry |
INFOMANIAK_AUTHCLIENT_SECRET | empty | sent as client_secret on the token exchange only; declared as a plain str, not SecretStr (Gotchas) |
INFOMANIAK_OIDC_REDIRECT_URI | a production API-host callback path | must be the API host's /api/auth/infomaniak/callback, registered with the provider byte for byte; the SPA host serves index.html for any path and would swallow the code |
INFOMANIAK_OIDC_AUTO_PROVISION | False | an unknown verified email is refused with oidc_error=no_account; true creates the user (Application logic step 9) |
FRONTEND_URL | http://localhost:3000 | every redirect target of the callback, on every surface (Gotchas) |
REDIS_ENABLED, REDIS_URL | True, built from REDIS_* | core/cache.py::redis_client is None when Redis is off or unreachable at boot; the start leg then answers 503 identity provider unavailable and the callback redirects with signin_failed: fail closed, no in-process fallback (service 127 to 157) |
The flag does not unmount the router (S4's "not mounted when off" rule does not hold here; the inventory's wave-1 findings record it): the routes exist and answer 503, which is what test_disabled_by_default_503s asserts.
Data model
No table. The state record lives in Redis (services/infomaniak_oidc.py 127 to 157):
key oidc:ik:<state> -- state = secrets.token_urlsafe(32)
value "<nonce>|<verifier>|<surface>" -- nonce = token_urlsafe(32); verifier = token_urlsafe(64); surface an issuable id
ttl 600 s -- _STATE_TTL_SECONDS; consumed with GETDEL, so at most one redemption
Process-local caches: the discovery document for 3600 s (_discovery_cache, 74 to 94: authorization_endpoint, token_endpoint, jwks_uri, issuer must all be present) and one PyJWKClient per jwks_uri with its own key cache (_jwks, 96 to 103); both per worker, reset by _reset_caches in tests.
What the callback writes on the user side: on provisioning, a users row with is_active = true, is_verified = true, username from the local part of the address made unique by a numeric suffix (_unique_username, router 134 to 144), first_name, last_name and display_name from the claims, plus one user_surface_grants row for the surface the flow started on (grant_surfaces(db, user.id, [surface_id]), 101). Then a sessions row through create_session.
What is not stored: the provider's sub (no identity-link table; the account link is email equality on every login), the access or refresh token from the provider (only id_token is read; tokens is discarded after that, 200 to 202), the groups claim (parsed into OIDCResult.groups, 237, used by nothing), the PKCE verifier beyond the state's TTL, and any audit row of the login beyond the sessions row and two INFO lines.
API surface
Both routes on auth_infomaniak_router, prefix /api/auth, mounted on ALL_SURFACES; neither declares a rate limiter or an auth dependency.
| Route | Auth dep | Tenancy | Codes | Error codes and bodies |
|---|---|---|---|---|
GET /api/auth/infomaniak/start?surface=<id> | none | none | 302 to <authorization_endpoint>?client_id&redirect_uri&response_type=code&scope=openid email profile&state&nonce&code_challenge&code_challenge_method=S256 (service 163 to 178) | 503 Infomaniak sign-in is not enabled on this deployment.; 400 unknown surface for anything outside ISSUABLE_SURFACES (router 41 to 44); 503 identity provider unavailable when discovery answers a non-200, the document lacks a required key, or Redis is absent (45 to 49). At fd8558f an unreachable or slow provider, or a 200 that is not JSON, is not one of these: the httpx error escapes except OIDCError and the request answers 500 Internal server error (Gotchas) |
GET /api/auth/infomaniak/callback?code&state&error | none | none | 302 to <FRONTEND_URL>/auth/callback#access_token=<a>&refresh_token=<r>&surface=<id> with the two Labs-shaped cookies (122 to 131) | 503 flag off; 302 <FRONTEND_URL>/login?oidc_error=<error> when the provider sent error (63 to 65, the value echoed); 400 missing code or state (66 to 67); 302 ...?oidc_error=signin_failed for an unknown or reused state, a non-200 or id_token-less token response, an unverifiable id token, a nonce mismatch, a missing or unverified email (69 to 73); 302 ...?oidc_error=no_account for an unknown address with provisioning off (83 to 88); 302 ...?oidc_error=disabled for an inactive account (105 to 106); 302 ...?oidc_error=no_grant when create_session raises SurfaceAccessDenied (109 to 117). Two escapes at fd8558f, both a JSON 500 in the browser rather than a redirect: a transport error or a non-JSON body on discovery or the token POST (Gotchas), and DeskAccessUnavailable out of create_session (auth_service.py 738 to 746, deliberately propagated there and caught nowhere here) |
The oidc_error vocabulary the SPA renders as German copy: signin_failed, no_account, no_grant, disabled, missing_tokens (the SPA's own, when the fragment is empty), access_denied (the provider's, passed through), anything else falling back to signin_failed (LoginAlchemy.tsx 32 to 46). Tokens travel in the fragment, never the query (test_callback_happy_path_existing_user asserts access_token= is absent before the #).
Application logic
The start leg (infomaniak_start, router 38 to 50; build_authorization_url, service 163 to 178):
_require_enabled(): 503 unless the flag is true.assert_issuable(surface); the default query value islabs; an external or unknown id is 400._discovery(): the cached document, or aGET <issuer>/.well-known/openid-configurationwith a 10 s timeout; a non-200 or a document missing one of the four keys raisesOIDCError(74 to 94). A rebuilt service also wraps the call itself, which fd8558f does not (Gotchas): a transport error or a body that will not parse is not anOIDCErrorthere and does not reach the 503.issue_state(surface):OIDCError("state store unavailable")whenredis_clientisNone(131 to 133); otherwise mintstate,nonce, the PKCE pair (verifier = token_urlsafe(64),challenge = b64url(sha256(verifier))without padding, 117 to 124) andSETEX oidc:ik:<state> 600 "<nonce>|<verifier>|<surface>"; the verifier never leaves the server (137 to 142).- 302 to the authorization endpoint with
client_id,redirect_uri,response_type=code,scope=openid email profile,state,nonce,code_challenge,code_challenge_method=S256.
The callback (infomaniak_callback, router 53 to 131; redeem_code, service 181 to 238), in commit order:
_require_enabled(); then, if the provider senterror, redirect to the login page with it; then 400 whencodeorstateis missing.consume_state(state):GETDELthe key; nothing there isOIDCError("unknown or already-used state"); split into(nonce, verifier, surface)(146 to 156). The surface for everything that follows is this value.POST <token_endpoint>withgrant_type=authorization_code,code,redirect_uri,client_id,client_secret,code_verifier, 15 s timeout; a non-200 is logged with the status only and raised astoken exchange failed; a response withoutid_tokenisno id_token in token response(185 to 203). ThePOSTand theresp.json()at 200 are unwrapped at fd8558f, the same gap as discovery (Gotchas); a rebuilt service raisesOIDCErrorfor a transport error and an unparseable body too, so that every exchange failure ends at the same redirect. Verify the id token:PyJWKClient(jwks_uri).get_signing_key_from_jwtpicks the key bykid,jwt.decode(..., algorithms=["RS256"], audience=<client id>, issuer=<discovery issuer>, options={"require": ["exp", "iat", "sub", "iss"]}); anyPyJWTErroris logged by class name and raised asid_token verification failed(205 to 217). Thenclaims["nonce"] == nonceornonce mismatch;emailpresent and lowercased orid_token carries no email;email_verifiedtrue oremail not verified by the identity provider(219 to 228). ReturnOIDCResultand the surface. EveryOIDCErrorhere becomes thesignin_failedredirect (router 69 to 73), with the internal reason in the log only.- In one
get_db_context()session: select the user byemail. None and provisioning off: logInfomaniak login refused for unknown emailand redirectno_account. None and provisioning on: insert the user (is_verified = True, the docstring's reason: the provider verified the address, that is the point), flush,grant_surfaces(db, user.id, [surface_id]), commit, log (80 to 103). not user.is_active: redirectdisabled(105 to 106).create_session(user_id, ip, ua, surface=surface_id);SurfaceAccessDeniedredirectsno_grant(109 to 117). The grant is therefore checked here and only here; an existing account without a grant for the started surface is refused at this step.SurfaceAccessDeniedis the only exception the callback catches from it:DeskAccessUnavailable, whichcreate_sessionpropagates on purpose rather than mint a token without thedskclaim (auth_service.py738 to 746), leaves this route as a 500 envelope mid-redirect. A rebuilt service catches it here and redirectssignin_failedrather than answer JSON to a browser that expected a page.- Build the 302 to
<FRONTEND_URL>/auth/callback#access_token=...&refresh_token=...&surface=..., set the cookies throughauth_router._set_auth_cookies(Labs-shaped), return (122 to 131). The SPA's/auth/callbackreads the fragment, stores the tokens, callshistory.replaceStateto strip it, and navigates to/(AuthCallback.tsx22 to 33).
No _ensure_workspace call on this path (compare verify_magic_link, B5 step 6): a provisioned account has zero workspaces until another login path or S2's 409 bootstrap creates one.
Invariants
- The flag off is a 503 on both routes before any other work (
test_disabled_by_default_503s,tests/v2/test_infomaniak_oidc.py208). - State is single-use: a second callback with the same
stateissignin_failed(test_state_is_single_use, 320); the store is Redis, never a process dict, and no Redis is fail closed (service header lines 13 to 16,issue_state131 to 133). - The surface of the session is the surface stored with the state at start; the callback URL carries no surface field and none is read from it (
consume_state,redeem_code182;test_callback_happy_path_existing_userassertssurface=labsin the fragment for a start with?surface=labs). - The id token is accepted only when its signature verifies against the provider's JWKS,
algis RS256,audequals the client id,issequals the discovery document's issuer,exp,iat,subandissare present, andnonceequals the stored one (test_callback_rejects_wrong_audience288,test_callback_rejects_nonce_mismatch304). - An address the provider reports as unverified is never linked (
test_callback_rejects_unverified_email, 342): linking on it would let any provider account holder walk into the Labs account of that address (comment at 226 to 227). - An unknown address is refused unless
INFOMANIAK_OIDC_AUTO_PROVISIONis true (test_callback_unknown_email_refused_without_provision246,test_callback_auto_provision_creates_user_with_grant261); provisioning writes the grant for the started surface and nothing else. - Tokens travel in the fragment; the query string of the final redirect carries only
surfaceinside the fragment and nothing before the#(test_callback_happy_path_existing_user, 222). - The session is minted by
create_session, which re-checks the grant; this module mints no token itself (header lines 24 to 25;grep -c "create_access_token\|_mint" services/infomaniak_oidc.pyprints0). - Internal failure reasons stay in the log: every
OIDCErrorcollapses tosignin_failedfor the browser (router 69 to 73), and a failed exchange logs the HTTP status, never the body.
Frontend
The frontend modules that render this recipe, by id, and what each shows of it:
identity-auth/ui/auth-screens LoginAlchemy shows the Infomaniak button beside Passkey only when the
build has VITE_INFOMANIAK_LOGIN=true (frontend/src/pages/LoginAlchemy.tsx
327 to 345); the click is a full-page navigation to
<API_URL>/api/auth/infomaniak/start?surface=labs; on return with
?oidc_error= the page maps the six codes to German copy (32 to 46);
AuthCallback (/auth/callback) reads the fragment, stores the tokens,
strips the address bar and navigates to / (pages/AuthCallback.tsx 14 to 42)
No Prima surface renders the button at fd8558f, and the callback would land a Prima start on the Labs SPA anyway (Gotchas). Minimum admin and debug surface: the WARNING lines infomaniak/start failed: <reason>, infomaniak/callback rejected: <reason>, Infomaniak token exchange failed: HTTP <n>, Infomaniak id_token rejected: <exception class>, and the INFO lines for a refused unknown email and a provisioned user; redis-cli --scan --pattern 'oidc:ik:*' for pending states (values contain the verifier, so read counts, not values). There is no admin route.
Security & tenancy
Auth dependency: none on either route; the identity comes from the provider's signed id token and the account link from email equality. Workspace derivation: none. Fail direction, per seam: flag closed (503); state store closed (503 at start, signin_failed at callback, no in-process fallback); discovery closed on a non-200 or a missing key, but open to an unhandled 500 on a transport error or an unparseable body, which is also true of the token exchange (Gotchas); signature, audience, issuer, nonce, required claims closed; unverified email closed; unknown email closed by default; grant closed (no_grant); the provider's own error passed through to the login page. Two seams this route does not close at all, both recorded in Gotchas: the state is not bound to the browser that started the flow (no cookie is set at start), and the email-verification gate that login and verify_magic_link run is not run here.
Credentials at rest: the client credential in INFOMANIAK_AUTHCLIENT_SECRET, read from the environment and sent only on the token exchange over TLS; declared str rather than SecretStr (Gotchas). The PKCE verifier and nonce in Redis for at most 600 s. The Labs session tokens in the fragment of one redirect, then in the SPA's storage per S2; never in the query string, so never in the API host's access log.
Rate limits: none on either route. The start leg writes one Redis key per call with a 600 s TTL, so an unauthenticated loop can fill Redis at the rate the API accepts requests; the callback does one outbound POST to the provider per call with a valid-looking state. A rebuilt service declares auth_rate_limit on both (B18), which is one line each.
Attacker-controlled inputs: surface on start (validated against ISSUABLE_SURFACES); code (opaque, forwarded to the provider, useless without the verifier stored with the state); state (looked up by exact key; unknown or reused is a refusal, but not bound to the browser that minted it: Gotchas); error (reflected into the redirect's query string unencoded, router 65; a value containing & or # shapes the URL the browser lands on, though only the login page reads it and only through a fixed map); the id token itself (parsed by PyJWT with the algorithm list pinned, so alg: none and HS256 with the public key are refused); the claims (email lowercased and used as a lookup key, name and the two name parts stored on provisioning, groups parsed and dropped).
Tenancy: the session is bound to the surface stored at start and to one user; a person who holds the prima_chat grant, which every Labs registration writes through LABS_SIGNUP_SURFACES (core/surfaces.py 185), and starts on prima_chat gets a prima_chat session (S5: aud), but is redirected to the Labs SPA with it (Gotchas). Holding the labs grant alone is not enough: create_session re-reads the grants and raises SurfaceAccessDenied for a surface with no row (auth_service.py 700 to 707), which the callback turns into no_grant. Provisioning grants the started surface only, not LABS_SIGNUP_SURFACES, so an account provisioned through a labs start cannot use the Prima surfaces until a grant is added elsewhere.
Tests
Backend (alchemy_labs/backend/tests/v2/test_infomaniak_oidc.py, run 2026-09-12 at fd8558f with python -m pytest tests/v2/test_infomaniak_oidc.py -q --no-cov -o addopts="" -W ignore): 9 passed in 6.08s. The provider is faked (discovery, token endpoint and JWKS through a patched httpx.AsyncClient and _jwks, 64 to 116), Redis is a dict-shaped fake with setex and getdel (53 to 62), the database is the transactional test session routed into both get_db_context import sites (_patch_db, 155 to 166). What is not faked is the token: a real RSA-2048 key signs a real RS256 id token (_sign_id_token, 33 to 50) and real PyJWT verifies it, because the crypto is the point.
test_disabled_by_default_503s(208): both routes 503 with the flag off.test_start_redirects_with_pkce_state_nonce(214): the 302 carriescode_challenge=,code_challenge_method=S256,nonce=,state=and starts at the faked issuer's/authorize.test_callback_happy_path_existing_user(222): a user with alabsgrant lands on/auth/callback#access_token=...withsurface=labs, and nothing before the#carries a token.test_callback_unknown_email_refused_without_provision(246),test_callback_auto_provision_creates_user_with_grant(261): the two provisioning branches, the second asserting the grant row.test_callback_rejects_wrong_audience(288),test_callback_rejects_nonce_mismatch(304),test_state_is_single_use(320),test_callback_rejects_unverified_email(342): each is asignin_failedredirect.
Hermetic versus live: all hermetic; no test reaches the provider, and none exercises a real Redis GETDEL. Not covered at fd8558f: a discovery non-200 (503 at start), a missing id_token, an inactive account (disabled), an existing account without the grant (no_grant), the provider error pass-through, the 400 for a missing code. Nor is the provider-outage case at all: no test points either leg at an unreachable host, which is why the unwrapped httpx calls (Gotchas) went unnoticed, and the one outage a test does cover is Redis. A rebuilt service adds two: a ConnectError on discovery is a 503 at start, and a ConnectError on the token POST is the signin_failed redirect. Playwright: none (the provider is external).
Known-answer vectors a rebuilt service must reproduce: the Redis key oidc:ik:<state> with the three-field value and a 600 s TTL; the PKCE challenge as the unpadded base64url SHA-256 of the verifier (_pkce_pair, 117 to 124: for verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk the challenge is E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, RFC 7636 appendix B, which the function reproduces because it hashes the ASCII verifier and strips =); the authorization URL's eight parameters; the fragment shape #access_token=<a>&refresh_token=<r>&surface=<id>; the six oidc_error codes.
Deploy & ops
Config keys per app heredoc (S13, alchemycloud b3d34da): the labs-staging heredoc (362 to 550) carries INFOMANIAK_AUTHCLIENT_ID, INFOMANIAK_AUTHCLIENT_SECRET, INFOMANIAK_OIDC_ENABLED and INFOMANIAK_OIDC_REDIRECT_URI (543 to 549, with the note that the callback lives on the API host, never the SPA host); the labs-prod heredoc (215 to 360) carries none of the four, so production runs on the defaults and answers 503, which is the prod-flag-off maturity. INFOMANIAK_OIDC_ISSUER and INFOMANIAK_OIDC_AUTO_PROVISION are set on neither. The SPA build needs VITE_INFOMANIAK_LOGIN=true for the button to exist at all; a backend with the flag on and a build without it is reachable only by typing the start URL.
Turning it on, in order: register the OAuth client with the provider using the API host's callback; set the four keys on the target heredoc and distribute; confirm Redis is up on that host (the start leg is 503 without it); redeploy the backend; rebuild the SPA with the build flag; then ADR-0030's step 2 (an additional method, never mandatory). Turning it off is the flag alone: pending states expire within 600 s and no row remains.
Migrations order: none owned; provisioning writes users and user_surface_grants, both older than this module. Scheduler jobs: none; Redis TTL is the cleanup. Argus checks: with the flag on, GET /api/auth/infomaniak/start?surface=labs answers 302 with a Location at the provider carrying code_challenge_method=S256; with the flag off, 503 with the fixed sentence; GET /api/auth/infomaniak/callback with no query answers 400 missing code or state when on and 503 when off. Rollback: the flag; the module holds no state that outlives ten minutes.
Gotchas
- 2026-09-12 (measured at fd8558f): every redirect the callback builds targets
settings.FRONTEND_URL(router 61, then 65, 73, 87, 106, 117 and 123), which is the Labs SPA, whatever surface the state carried. A start with?surface=prima_chattherefore produces aprima_chatsession (correctaud, grant checked) and hands it to the Labs SPA's/auth/callback, where every gated Labs route answers 403This session belongs to the 'prima_chat' surface and cannot be used here.(S5). The magic link solved the same question withsurface.origin(B5). Fix:frontend = link_base_for(get_surface(surface_id))for the success and the post-state refusals, andFRONTEND_URLonly for the two refusals that happen before the state is consumed (error, missing parameters), where the surface is unknown; and the surface-awareset_auth_cookiesinstead ofauth_router._set_auth_cookiesat 128 to 130. Until then the start leg should refuse any surface butlabs, which is the only one the SPA can receive. - 2026-09-12 (measured at fd8558f):
INFOMANIAK_AUTHCLIENT_SECRETisstr = Field(default="")(core/config.py163) while the rest of the settings module wraps credentials inSecretStr(JWT_ED25519_PRIVATE_KEY92,DB_PASSWORD225,RESEND_API_KEY437,EMAIL_SMTP_PASSWORD465,INFOMANIAK_API_TOKEN669). Asettings.model_dump()or a debug endpoint that prints settings would print it. Fix:SecretStrand.get_secret_value()at the one read site (service 193). - 2026-09-12 (measured at fd8558f): the provider's
errorquery value is reflected unencoded into theLocationheader (router 63 to 65). The provider is the only party that would send it, and the login page consults a fixed map, so the exposure is a shaped URL on the login page, not script; a rebuilt service still runs it throughurllib.parse.quoteor maps it to a known code before building the URL. - 2026-09-12 (measured at fd8558f): neither route declares
auth_rate_limit. The eight password and e-mail routes on/api/authdo (auth_router.py234, 436, 879, 901, 921, 955, 978, 1007; the same eight S5's Gotchas name), but that is not every credential-issuing route:POST /verify-magic-link(1126),POST /refresh(642),GET /cli-login(1510) andPOST /ws-ticket(1623) hand out credentials with no limiter either, so this module is not an exception among its neighbours. Fix here regardless:dependencies=[Depends(auth_rate_limit)]on both. - 2026-09-12 (measured at fd8558f):
httpxerrors escape both legs. Neither_discovery(84 to 88:async with httpx.AsyncClient(timeout=10.0), notry) nor the tokenPOST(185 to 200, which handlesresp.status_code != 200and then callsresp.json()unguarded) wraps the call, so aConnectError, aReadTimeoutor a 200 whose body is not JSON leaves as something other thanOIDCError, and the routers catch onlyOIDCError(router 47 to 49 and 71 to 73). The request falls through to the generic handler (app_fastapi.py790 to 809) and answers 500Internal server error: on/startinstead of the documented 503identity provider unavailable, and on/callbacka JSON envelope in the browser instead of any redirect to the login page. Measured by pointingINFOMANIAK_OIDC_ISSUERat a closed port and awaitingbuild_authorization_url("labs"):httpx.ConnectError All connection attempts failed, notOIDCError. This is the dominant provider-outage case, and the only outage the tests and check 4 cover is Redis, which does raiseOIDCError. Fix: wrap both calls inexcept (httpx.HTTPError, ValueError)and re-raiseOIDCError, so a provider outage reaches the 503 and thesignin_failedredirect the API surface promises. - 2026-09-12 (measured at fd8558f): the state is never bound to the browser that started the flow.
issue_state(127 to 143) writes the Redis key and returns; the start handler returns a bareRedirectResponsewith noSet-Cookie(router 38 to 50), andconsume_state(146 to 156) looks the state up by key alone. A callback URL carrying an attacker'sstateandcode, opened in a victim's browser, therefore completes and hands that browser the attacker's session in the fragment, whichAuthCallback.tsx(22 to 33) stores: the login-CSRF and session-fixation class of the OAuth 2.0 Security BCP, section 4.6. Single-use state limits it to one shot per start, not to one browser. Fix: set a short-livedhttpOnly,SameSite=Laxcookie carrying the state (or its hash) on the start response, and require it to equal the callback'sstatebeforeconsume_stateruns. - 2026-09-12 (measured at fd8558f): the callback is the third path in the estate that hands out a session, and the only one that skips the FIND-012 verification gate.
login(auth_router.py486 to 502) andverify_magic_link(1170 to 1186) both computeREQUIRE_EMAIL_VERIFICATION and ENABLE_EMAIL_VERIFICATION and not user.is_verifiedand return_verification_required_response(88 to 114, whose docstring says it is "reused for every path that can hand out a session"). This route readsuser.is_activeonly (router 105) and then callscreate_session, which checks the surface grant and nothing else (auth_service.py683 to 769). An existingis_verified = Falseaccount signs in through the provider and walks out with a full session while the same account is refused at the other two doors. Not reachable by a third party, since the address must verify at the provider first, but it is an asymmetry the fail-direction register has to name. Fix: either run the same gate and redirectoidc_error=verification_required, or decide that a provider-verified address satisfies it and flipusers.is_verifiedon first OIDC login; silence is the one option that is wrong. - 2026-09-12 (measured at fd8558f): a provisioned account gets the started surface's grant only and no workspace: the callback never calls
_ensure_workspace(compareauth_router.py605 and 1218), andLABS_SIGNUP_SURFACES(core/surfaces.py185) is not consulted, so alabsprovisioning yields an account that cannot use the Prima surfaces a normal Labs registration can (B1). WithINFOMANIAK_OIDC_AUTO_PROVISIONoff on every heredoc this is dormant; the day-one policy comment (config 167 to 168) says provisioning is a separate decision, and the grant set is part of that decision. - 2026-09-12 (measured at fd8558f): the account link is email equality with no stored
sub. A person who changes the address on their provider account, or two provider accounts that verified the same address at different times, resolve to whateverusers.emailmatches at click time; there is nooidc_identities (provider, sub, user_id)row to make the link stable.OIDCResult.subis populated (233) and unused. A rebuilt service stores(issuer, sub) -> user_idon first login and matches on it before falling back to email. - 2026-09-12 (measured at fd8558f, code wins over the wiki): ADR-0030 (6322fa3) is
proposedand says "Do not flip flags before that answer lands"; the labs-staging heredoc carriesINFOMANIAK_OIDC_ENABLEDand the three other keys (b3d34da 543 to 549). Whether the staging value is true is the estate's business (not printed here); the recipe records that the keys exist on staging and not on production, which matchesprod-flag-off. - 2026-09-12 (measured at fd8558f): the discovery document and the JWKS client are cached per worker for 3600 s and per
kidrespectively; a provider key rotation is picked up byPyJWKClienton an unknownkid(its own refresh), but a change ofauthorization_endpointortoken_endpointwaits up to an hour per worker. No operator switch flushes either; a restart does. - 2026-08-08 (429841b, measured at fd8558f; the design rules in the module header, 9 to 27): the state store is Redis with no in-process fallback because
google_ads_oauth_router's_oauth_statesdict "broke silently at--workers 2" and the LinkedIn flow was fixed the same way; the image runs two workers (Dockerfile117). The same reasoning applies to B6's six-digit-code dict, which has not had that fix.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
alchemy_labs(branchd1a-token-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):backend/api/routers/auth_infomaniak_router.py: header (1 to 11),_require_enabled(30 to 35),infomaniak_start(38 to 50),infomaniak_callback(53 to 131: theerrorpass-through 63 to 65, the 400 66 to 67,redeem_code69 to 73, link or provision 75 to 103,disabled105 to 106,create_session109 to 117, the fragment redirect and cookies 119 to 131),_unique_username(134 to 144); the mount atbackend/app_fastapi.py821.backend/services/infomaniak_oidc.py: the design rules (1 to 27), constants (46 to 50),OIDCError(53 to 54),OIDCResult(57 to 67),_discovery(79 to 94),_jwks(96 to 103),_reset_caches(105 to 111),_pkce_pair(117 to 124),issue_state(127 to 143),consume_state(146 to 156),build_authorization_url(163 to 178),redeem_code(181 to 238).backend/core/config.py: the six keys (157 to 169),REDIS_ENABLEDandREDIS_URL(388 to 389).backend/core/cache.py:redis_client(10),init_redis(12 to 26).backend/api/routers/auth_router.py:_set_auth_cookies(59 to 80, imported by the callback).backend/services/auth_service.py:create_session(683 to 769),SurfaceAccessDenied(332 to 337);backend/services/surface_service.py::grant_surfaces(60).backend/core/surfaces.py:assert_issuable(211 to 220),LABS_SIGNUP_SURFACES(185),link_base_for(327 to 339, the function the fix uses).- Tests:
backend/tests/v2/test_infomaniak_oidc.py(fakes 33 to 116, fixture 118 to 134, helpers 137 to 205, cases 208 to 356). frontend/src/pages/LoginAlchemy.tsx(32 to 46, 327 to 345),frontend/src/pages/AuthCallback.tsx(14 to 42),frontend/src/App.tsx242 (the/auth/callbackroute).Dockerfile117 (--workers).
alchemycloud(main, b3d34da):scripts/distribute-env.shlabs-staging (362 to 550; the four keys 543 to 549) and labs-prod (215 to 360; none), key names only.- Wiki
philosophers_stone(6322fa3):operations/decisions/0030-infomaniak-as-identity-provider.md(status, the three-step order, the discovery verified 2026-08-07),platform/architecture.md,operations/prima-modules-inventory.md(row B8; the wave-1 note that the router is mounted while off).
Deviations of the reference from this recipe, all dated 2026-09-12: the unwrapped httpx calls on both legs, so a provider outage is a 500 rather than the 503 and the signin_failed redirect; the state not bound to the browser that started the flow; the FIND-012 verification gate not run; every callback redirect targets the Labs SPA regardless of surface, with the Labs-shaped cookies; the client credential as str; the provider's error reflected unencoded; no rate limiter; provisioning without a workspace and with the started surface's grant only; no stored sub; DeskAccessUnavailable uncaught at the callback; five callback branches and one start branch untested.
The router being mounted while the flag is off is not a deviation from this recipe, which ratifies it in Stack assumptions and names the test that asserts it; it is a deviation from S4's "not mounted when off" rule, recorded there and in the inventory's wave-1 findings.
Acceptance checks
Run from alchemy_labs/backend at the commit in measured_on with the :5434 test Postgres up, or from a new service built to this recipe. Expected observations are exact.
python -m pytest tests/v2/test_infomaniak_oidc.py -q --no-cov -o addopts="" -W ignoreprints9 passed.- With the app running and the flag off (the default):
curl -s -i http://127.0.0.1:8000/api/auth/infomaniak/startshows503andInfomaniak sign-in is not enabled on this deployment.;curl -s -i 'http://127.0.0.1:8000/api/auth/infomaniak/callback?code=x&state=y'shows the same503. - With
INFOMANIAK_OIDC_ENABLED=true, a reachable Redis, and the issuer pointed at a local stub serving/.well-known/openid-configuration(or the real provider):curl -s -i 'http://127.0.0.1:8000/api/auth/infomaniak/start?surface=labs'shows302and alocationcontainingresponse_type=code,scope=openid+email+profile(or%20),code_challenge_method=S256,state=,nonce=;redis-cli --scan --pattern 'oidc:ik:*' | wc -lis one higher than before andredis-cli TTL oidc:ik:<state>is at most600;?surface=primapvsand?surface=nopeboth show400unknown surface. - With the flag on and Redis stopped: the same start request shows
503andidentity provider unavailable, and the log showsinfomaniak/start failed: state store unavailable. - With the flag on, Redis up, and
INFOMANIAK_OIDC_ISSUERpointed at a closed port (http://127.0.0.1:1):curl -s -i 'http://127.0.0.1:8000/api/auth/infomaniak/start?surface=labs'shows500andInternal server errorat fd8558f, and503identity provider unavailableon a service built to this recipe. The service-level form of the same measurement, which needs no server:python -c "import asyncio,os; os.environ['INFOMANIAK_OIDC_ISSUER']='http://127.0.0.1:1'; from services.infomaniak_oidc import build_authorization_url, OIDCError; asyncio.run(...)"raiseshttpx.ConnectErrorat fd8558f andOIDCErroron a service built to this recipe. - With the flag on:
curl -s -i 'http://127.0.0.1:8000/api/auth/infomaniak/callback'shows400missing code or state;...?error=access_deniedshows302withlocation: <FRONTEND_URL>/login?oidc_error=access_denied;...?code=x&state=unknownshows302to/login?oidc_error=signin_failedand the log showsinfomaniak/callback rejected: unknown or already-used state. - Against the stub provider, complete one round trip in a browser as a user holding
labs: the finalLocationis<FRONTEND_URL>/auth/callback#access_token=...&refresh_token=...&surface=labs, the access log line for the callback carries noaccess_token=(the fragment is not sent), and decoding the token shows"aud":"labs"; a second request with the samestateissignin_failed. python -c "from services.infomaniak_oidc import _pkce_pair; import hashlib,base64; v,c=_pkce_pair(); print(len(v)>=43, c==base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode())"printsTrue True.grep -c "auth_rate_limit" api/routers/auth_infomaniak_router.pyprints0at fd8558f and2on a service built to this recipe;grep -c "FRONTEND_URL" api/routers/auth_infomaniak_router.pyprints1at fd8558f, and on a service built to this recipe the success redirect reads the surface's origin (grep -c "link_base_for" api/routers/auth_infomaniak_router.pyprints at least1).grep -n "INFOMANIAK_AUTHCLIENT_SECRET" core/config.pyshowsstr = Field(default="")at fd8558f andSecretStron a service built to this recipe;grep -c "create_access_token\|_mint(" services/infomaniak_oidc.pyprints0on both.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.