Prima Get an API key

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
  • alchemy_labs/backend/api/routers/auth_infomaniak_router.py
  • alchemy_labs/backend/services/infomaniak_oidc.py
  • alchemy_labs/backend/core/config.py
  • alchemy_labs/backend/tests/v2/test_infomaniak_oidc.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • alchemycloud main b3d34da 2026-09-12
  • philosophers_stone main 6322fa3 2026-09-12
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:

NameDefaultBehaviour
INFOMANIAK_OIDC_ENABLEDFalseboth routes answer 503 Infomaniak sign-in is not enabled on this deployment. until true (_require_enabled, router 30 to 35)
INFOMANIAK_OIDC_ISSUERthe provider's login origindiscovery 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_IDemptyclient_id on both legs and the audience the id token must carry
INFOMANIAK_AUTHCLIENT_SECRETemptysent as client_secret on the token exchange only; declared as a plain str, not SecretStr (Gotchas)
INFOMANIAK_OIDC_REDIRECT_URIa production API-host callback pathmust 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_PROVISIONFalsean unknown verified email is refused with oidc_error=no_account; true creates the user (Application logic step 9)
FRONTEND_URLhttp://localhost:3000every redirect target of the callback, on every surface (Gotchas)
REDIS_ENABLED, REDIS_URLTrue, 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.

RouteAuth depTenancyCodesError codes and bodies
GET /api/auth/infomaniak/start?surface=<id>nonenone302 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&errornonenone302 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):

  1. _require_enabled(): 503 unless the flag is true.
  2. assert_issuable(surface); the default query value is labs; an external or unknown id is 400.
  3. _discovery(): the cached document, or a GET <issuer>/.well-known/openid-configuration with a 10 s timeout; a non-200 or a document missing one of the four keys raises OIDCError (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 an OIDCError there and does not reach the 503.
  4. issue_state(surface): OIDCError("state store unavailable") when redis_client is None (131 to 133); otherwise mint state, nonce, the PKCE pair (verifier = token_urlsafe(64), challenge = b64url(sha256(verifier)) without padding, 117 to 124) and SETEX oidc:ik:<state> 600 "<nonce>|<verifier>|<surface>"; the verifier never leaves the server (137 to 142).
  5. 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:

  1. _require_enabled(); then, if the provider sent error, redirect to the login page with it; then 400 when code or state is missing.
  2. consume_state(state): GETDEL the key; nothing there is OIDCError("unknown or already-used state"); split into (nonce, verifier, surface) (146 to 156). The surface for everything that follows is this value.
  3. POST <token_endpoint> with grant_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 as token exchange failed; a response without id_token is no id_token in token response (185 to 203). The POST and the resp.json() at 200 are unwrapped at fd8558f, the same gap as discovery (Gotchas); a rebuilt service raises OIDCError for 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_jwt picks the key by kid, jwt.decode(..., algorithms=["RS256"], audience=<client id>, issuer=<discovery issuer>, options={"require": ["exp", "iat", "sub", "iss"]}); any PyJWTError is logged by class name and raised as id_token verification failed (205 to 217). Then claims["nonce"] == nonce or nonce mismatch; email present and lowercased or id_token carries no email; email_verified true or email not verified by the identity provider (219 to 228). Return OIDCResult and the surface. Every OIDCError here becomes the signin_failed redirect (router 69 to 73), with the internal reason in the log only.
  4. In one get_db_context() session: select the user by email. None and provisioning off: log Infomaniak login refused for unknown email and redirect no_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).
  5. not user.is_active: redirect disabled (105 to 106).
  6. create_session(user_id, ip, ua, surface=surface_id); SurfaceAccessDenied redirects no_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. SurfaceAccessDenied is the only exception the callback catches from it: DeskAccessUnavailable, which create_session propagates on purpose rather than mint a token without the dsk claim (auth_service.py 738 to 746), leaves this route as a 500 envelope mid-redirect. A rebuilt service catches it here and redirects signin_failed rather than answer JSON to a browser that expected a page.
  7. Build the 302 to <FRONTEND_URL>/auth/callback#access_token=...&refresh_token=...&surface=..., set the cookies through auth_router._set_auth_cookies (Labs-shaped), return (122 to 131). The SPA's /auth/callback reads the fragment, stores the tokens, calls history.replaceState to strip it, and navigates to / (AuthCallback.tsx 22 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.py 208).
  • State is single-use: a second callback with the same state is signin_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_state 131 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_code 182; test_callback_happy_path_existing_user asserts surface=labs in the fragment for a start with ?surface=labs).
  • The id token is accepted only when its signature verifies against the provider's JWKS, alg is RS256, aud equals the client id, iss equals the discovery document's issuer, exp, iat, sub and iss are present, and nonce equals the stored one (test_callback_rejects_wrong_audience 288, test_callback_rejects_nonce_mismatch 304).
  • 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_PROVISION is true (test_callback_unknown_email_refused_without_provision 246, test_callback_auto_provision_creates_user_with_grant 261); 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 surface inside 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.py prints 0).
  • Internal failure reasons stay in the log: every OIDCError collapses to signin_failed for 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 carries code_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 a labs grant lands on /auth/callback#access_token=... with surface=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 a signin_failed redirect.

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_chat therefore produces a prima_chat session (correct aud, grant checked) and hands it to the Labs SPA's /auth/callback, where every gated Labs route answers 403 This session belongs to the 'prima_chat' surface and cannot be used here. (S5). The magic link solved the same question with surface.origin (B5). Fix: frontend = link_base_for(get_surface(surface_id)) for the success and the post-state refusals, and FRONTEND_URL only for the two refusals that happen before the state is consumed (error, missing parameters), where the surface is unknown; and the surface-aware set_auth_cookies instead of auth_router._set_auth_cookies at 128 to 130. Until then the start leg should refuse any surface but labs, which is the only one the SPA can receive.
  • 2026-09-12 (measured at fd8558f): INFOMANIAK_AUTHCLIENT_SECRET is str = Field(default="") (core/config.py 163) while the rest of the settings module wraps credentials in SecretStr (JWT_ED25519_PRIVATE_KEY 92, DB_PASSWORD 225, RESEND_API_KEY 437, EMAIL_SMTP_PASSWORD 465, INFOMANIAK_API_TOKEN 669). A settings.model_dump() or a debug endpoint that prints settings would print it. Fix: SecretStr and .get_secret_value() at the one read site (service 193).
  • 2026-09-12 (measured at fd8558f): the provider's error query value is reflected unencoded into the Location header (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 through urllib.parse.quote or 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/auth do (auth_router.py 234, 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) and POST /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): httpx errors escape both legs. Neither _discovery (84 to 88: async with httpx.AsyncClient(timeout=10.0), no try) nor the token POST (185 to 200, which handles resp.status_code != 200 and then calls resp.json() unguarded) wraps the call, so a ConnectError, a ReadTimeout or a 200 whose body is not JSON leaves as something other than OIDCError, and the routers catch only OIDCError (router 47 to 49 and 71 to 73). The request falls through to the generic handler (app_fastapi.py 790 to 809) and answers 500 Internal server error: on /start instead of the documented 503 identity provider unavailable, and on /callback a JSON envelope in the browser instead of any redirect to the login page. Measured by pointing INFOMANIAK_OIDC_ISSUER at a closed port and awaiting build_authorization_url("labs"): httpx.ConnectError All connection attempts failed, not OIDCError. This is the dominant provider-outage case, and the only outage the tests and check 4 cover is Redis, which does raise OIDCError. Fix: wrap both calls in except (httpx.HTTPError, ValueError) and re-raise OIDCError, so a provider outage reaches the 503 and the signin_failed redirect 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 bare RedirectResponse with no Set-Cookie (router 38 to 50), and consume_state (146 to 156) looks the state up by key alone. A callback URL carrying an attacker's state and code, opened in a victim's browser, therefore completes and hands that browser the attacker's session in the fragment, which AuthCallback.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-lived httpOnly, SameSite=Lax cookie carrying the state (or its hash) on the start response, and require it to equal the callback's state before consume_state runs.
  • 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.py 486 to 502) and verify_magic_link (1170 to 1186) both compute REQUIRE_EMAIL_VERIFICATION and ENABLE_EMAIL_VERIFICATION and not user.is_verified and return _verification_required_response (88 to 114, whose docstring says it is "reused for every path that can hand out a session"). This route reads user.is_active only (router 105) and then calls create_session, which checks the surface grant and nothing else (auth_service.py 683 to 769). An existing is_verified = False account 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 redirect oidc_error=verification_required, or decide that a provider-verified address satisfies it and flip users.is_verified on 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 (compare auth_router.py 605 and 1218), and LABS_SIGNUP_SURFACES (core/surfaces.py 185) is not consulted, so a labs provisioning yields an account that cannot use the Prima surfaces a normal Labs registration can (B1). With INFOMANIAK_OIDC_AUTO_PROVISION off 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 whatever users.email matches at click time; there is no oidc_identities (provider, sub, user_id) row to make the link stable. OIDCResult.sub is populated (233) and unused. A rebuilt service stores (issuer, sub) -> user_id on 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 proposed and says "Do not flip flags before that answer lands"; the labs-staging heredoc carries INFOMANIAK_OIDC_ENABLED and 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 matches prod-flag-off.
  • 2026-09-12 (measured at fd8558f): the discovery document and the JWKS client are cached per worker for 3600 s and per kid respectively; a provider key rotation is picked up by PyJWKClient on an unknown kid (its own refresh), but a change of authorization_endpoint or token_endpoint waits 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_states dict "broke silently at --workers 2" and the LinkedIn flow was fixed the same way; the image runs two workers (Dockerfile 117). 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 (branch d1a-token-source at fd8558f, backend identical to feat/service-desk b1089b6):
    • 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: the error pass-through 63 to 65, the 400 66 to 67, redeem_code 69 to 73, link or provision 75 to 103, disabled 105 to 106, create_session 109 to 117, the fragment redirect and cookies 119 to 131), _unique_username (134 to 144); the mount at backend/app_fastapi.py 821.
    • 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_ENABLED and REDIS_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.tsx 242 (the /auth/callback route).
    • Dockerfile 117 (--workers).
  • alchemycloud (main, b3d34da): scripts/distribute-env.sh labs-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.

  1. python -m pytest tests/v2/test_infomaniak_oidc.py -q --no-cov -o addopts="" -W ignore prints 9 passed.
  2. With the app running and the flag off (the default): curl -s -i http://127.0.0.1:8000/api/auth/infomaniak/start shows 503 and Infomaniak 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 same 503.
  3. 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' shows 302 and a location containing response_type=code, scope=openid+email+profile (or %20), code_challenge_method=S256, state=, nonce=; redis-cli --scan --pattern 'oidc:ik:*' | wc -l is one higher than before and redis-cli TTL oidc:ik:<state> is at most 600; ?surface=primapvs and ?surface=nope both show 400 unknown surface.
  4. With the flag on and Redis stopped: the same start request shows 503 and identity provider unavailable, and the log shows infomaniak/start failed: state store unavailable.
  5. With the flag on, Redis up, and INFOMANIAK_OIDC_ISSUER pointed 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' shows 500 and Internal server error at fd8558f, and 503 identity provider unavailable on 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(...)" raises httpx.ConnectError at fd8558f and OIDCError on a service built to this recipe.
  6. With the flag on: curl -s -i 'http://127.0.0.1:8000/api/auth/infomaniak/callback' shows 400 missing code or state; ...?error=access_denied shows 302 with location: <FRONTEND_URL>/login?oidc_error=access_denied; ...?code=x&state=unknown shows 302 to /login?oidc_error=signin_failed and the log shows infomaniak/callback rejected: unknown or already-used state.
  7. Against the stub provider, complete one round trip in a browser as a user holding labs: the final Location is <FRONTEND_URL>/auth/callback#access_token=...&refresh_token=...&surface=labs, the access log line for the callback carries no access_token= (the fragment is not sent), and decoding the token shows "aud":"labs"; a second request with the same state is signin_failed.
  8. 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())" prints True True.
  9. grep -c "auth_rate_limit" api/routers/auth_infomaniak_router.py prints 0 at fd8558f and 2 on a service built to this recipe; grep -c "FRONTEND_URL" api/routers/auth_infomaniak_router.py prints 1 at 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.py prints at least 1).
  10. grep -n "INFOMANIAK_AUTHCLIENT_SECRET" core/config.py shows str = Field(default="") at fd8558f and SecretStr on a service built to this recipe; grep -c "create_access_token\|_mint(" services/infomaniak_oidc.py prints 0 on 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.