agentleFS
SELISEdigitalplatformsSELISEdigitalplatforms/blocks-skills/skills/blocks-iam-sso-oidc-implementation/SKILL.md

blocks-iam-sso-oidc-implementation

Skill42 starsChanged 12 days agoMITView on GitHub

Extend or debug the hosted SSO/OIDC login flow `blocks new web` scaffolds into every Blocks app: redirectToProvider → `/login/callback` → session, through the single `blocksClient`. Covers `AuthProvider` status/claims, `RequireAuth`/`RedirectIfAuthenticated` guards, and token refresh. Use for a login button, the OIDC callback, protected routes, redirect loops, or a session that doesn't stick. Needs a registered OIDC client and HTTPS on the real domain to test.

  • Reads credentials

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    When invoking a project-scoped blocks command, either use the resolved account's saved selection or pass --project <tenantId> for that one …

    line 6
  2. Very specific:

    If data.error is present, completeLogin returns { ok: false, message } and CallbackPage shows an inline error Alert plus a button back to …

    line 36

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

When invoking a project-scoped blocks command, either use the resolved account's saved selection or pass --project <tenantId> for that one command without changing saved state. --project applies to CLI commands only, never SDK calls.

Blocks IAM — SSO / OIDC Implementation (scaffolded frontend)

blocks new web <name> already generates a complete, working hosted-login flow. Don't reinvent it — read what's there, extend it, or fix it. Every Blocks call in this flow goes through the single blocksClient instance (src/lib/blocks/client.ts, @seliseblocks/client); there is no raw fetch/curl anywhere in this stack.

The files, and what each one actually does

FileRole
src/lib/blocks/config.tsReads VITE_BLOCKS_* env vars; isLoginConfigured() = apiUrl && oidcUrl && oidcClientId all present
src/lib/blocks/client.tsThe one blocksClient = createBlocksClient({...}) instance, with oidc: { clientId, scope, url: oidcUrl }
src/lib/blocks/auth.tsstartLogin, completeLogin, fetchSessionClaims, logout, getValidAccessToken — the session/token logic
src/lib/blocks/jwt.tsdecodeJwtPayload/isJwtExpired — only relevant if a tenant's OIDC config returns bearer tokens in the body
src/app/providers/AuthProvider.tsxReact context: status/claims/login/logout/refresh, polling + visibility-driven refresh
src/app/router/guards.tsxRequireAuth, RedirectIfAuthenticated
src/app/router/routes.tsxWires /login, /login/callback, and the protected route table (/, /assets, /profile, /error)
src/features/auth/LoginPage.tsxThe login button
src/features/auth/CallbackPage.tsxThe /login/callback handler

The flow, traced through the generated code

  1. Login button. LoginPage's button calls useAuth().login(returnTo), which is AuthProvider's login calling startLogin(returnTo) in lib/blocks/auth.ts. startLogin throws a clear error if oidcClientId isn't set ("Login is not configured. Set VITE_BLOCKS_OIDC_CLIENT_ID in .env."), stashes returnTo (default "/") in sessionStorage, then calls blocksClient.auth.idp.redirectToProvider() with no arguments — it relies entirely on the client's configured oidc defaults.
    • The button itself is disabled={!configured || pending} — if isLoginConfigured() is false, LoginPage renders a warning Alert with the exact callback URL ({origin}/login/callback) to register, instead of letting the click fail. "Login button does nothing" is almost always an empty VITE_BLOCKS_OIDC_CLIENT_ID.
  2. redirectToProvider() (SDK, auth-client.ts) calls auth.idp.initiate(), then window.location.assign(response.redirect_uri). initiate itself is also directly callable (e.g. to get the URL without immediately navigating, such as opening it in a new tab) but the scaffold never calls it directly; only redirectToProvider is wired to the button.
  3. The user authenticates on Blocks-hosted IAM.
  4. IAM redirects back to <origin>/login/callback?code=...&state=.... That path is the SDK's default redirectUri — the scaffold's client.ts never passes an explicit redirectUri, so createBlocksClient derives ${window.location.origin}/login/callback at runtime (see browserRedirectUri() in the SDK's client.ts). This is exactly the route routes.tsx handles, so it lines up with zero config — but it means the OIDC client's registered redirect_uris must include /login/callback under every origin this app runs on (dev HTTPS origin and prod origin both — see the scaffold's own README and blocks-iam-sso-oidc-configuration).
  5. routes.tsx matches path === "/login/callback" and renders CallbackPage directly — not wrapped in RequireAuth or RedirectIfAuthenticated, since the user is by definition not yet authenticated when they land here.
  6. CallbackPage's one-shot effect (guarded with a useRef so React 18 Strict Mode's double-invoke doesn't run it twice) calls completeLogin(window.location.href). completeLogin reads and clears the stashed returnTo, then calls blocksClient.auth.idp.callback(callbackUrl), passing the full URL so the SDK parses code/state/error itself.
    • On the default cookie flow, IAM sets the session as a Secure, httpOnly cookie via Set-Cookie on this response and returns no token in the body — completeLogin only caches a bearer token if the response body actually contains one (a non-default, explicit-token OIDC config). The SDK never stores tokens itself either way; every call sets credentials: "include" so the cookie rides along automatically once IAM has set it.
    • If data.error is present, completeLogin returns { ok: false, message } and CallbackPage shows an inline error Alert plus a button back to /login — it never silently strands the user on a blank screen.
  7. On success, CallbackPage calls refresh() (from AuthProvider) and then onNavigate(result.returnTo). refresh() calls fetchSessionClaims() → blocksClient.auth.userInfo() to confirm the cookie actually landed and to populate claims/status before the app navigates away from the callback screen.

Session state and route guards

  • AuthProvider is the single source of truth for status ("loading" | "authenticated" | "unauthenticated") and claims. It calls refresh() on mount, every 5 minutes (STATUS_POLL_MS, a backup interval — not the primary signal), and immediately whenever the tab regains visibility (catches sign-out in another tab or session expiry while backgrounded). It never inspects local storage to decide auth state — asking IAM directly (userInfo()) is the only source of truth, because the default flow holds no locally readable token by design.
  • RequireAuth wraps every protected route in routes.tsx (/, /assets, /profile, /error). While status !== "authenticated" it renders LoadingScreen; once status resolves to "unauthenticated" it navigates to /login?returnTo=<currentPath> from a useEffect (not render-time — reading window.location live at render would double-nest the returnTo param under Strict Mode's double-invoked effects).
  • RedirectIfAuthenticated wraps /login itself so an already-signed-in user hitting /login bounces straight to / instead of seeing the login button again.
  • Adding a new protected page: add it to the protectedRoutes map in routes.tsx — it's automatically wrapped in RequireAuth and AppShell by the existing router code, nothing else to wire.

The @seliseblocks/client methods behind all of this

All under blocksClient.auth:

  • idp.initiate(request?) — starts the flow, returns { redirect_uri }. Uses the client's configured oidc defaults (clientId, redirectUri) unless you pass overrides per call.
  • idp.redirectToProvider(request?) — calls initiate then window.location.assign(...). This is what startLogin (and therefore the login button) actually calls; reach for this directly in any new login entry point rather than re-implementing initiate+navigate.
  • idp.callback(callbackUrlOrObject) — completes the flow. Pass window.location.href directly (what completeLogin does), or { code, state, error?, error_description? } if you've parsed the URL yourself. Returns IAM's auth response as-is; the SDK never stores tokens — your app decides what, if anything, to keep (the scaffold keeps nothing in the default cookie flow).
  • idp.uiConfig() — public UI config (e.g. captcha settings). Not currently called anywhere in the scaffold — if you're extending LoginPage with captcha or tenant-specific login UI, call this before rendering that UI, not before.
  • oidc.refreshToken(request?) — a separate call from the IdP-controller hosted flow, using a refresh-token grant. getValidAccessToken() in lib/blocks/auth.ts is already wired as the 401-retry/expiry path: it returns a cached, unexpired token if present, otherwise calls this (de-duplicating concurrent callers via refreshInFlight) if a refresh token happens to be cached. In the default cookie-only flow there's usually nothing cached to refresh, so this mostly matters for tenants whose OIDC config explicitly returns tokens in the response body.
  • isAuthenticated() — returns a plain boolean. The scaffold's own fetchSessionClaims() calls the lower-level userInfo() instead (same underlying check) because AuthProvider needs the claims payload, not just a boolean — reach for isAuthenticated() yourself for a one-off check that doesn't need claims, rather than hand-rolling another call.

Config

createBlocksClient needs an oidc block: clientId (required), url (required — kept for app metadata, not used to build the authorize URL), redirectUri/scope (optional, default to ${origin}/login/callback / openid profile). The scaffold populates this from VITE_BLOCKS_OIDC_CLIENT_ID / VITE_BLOCKS_OIDC_URL / VITE_BLOCKS_OIDC_SCOPE in .env.

This clientId is the public OIDC client registered for this app — see the sibling blocks-iam-sso-oidc-configuration skill for how to resolve or create one (blocks auth oidc-clients list / save, no portal needed). Don't confuse it with blocks login itself, which authenticates the CLI with no setup and needs no registration at all (see blocks-bootstrap) — the two are unrelated and neither can substitute for the other.

--client-id and --app-domain are non-interactive-unsafe when omitted. blocks new web's client-id and domain resolution both fall back to an interactive selection prompt ("Choose an OIDC client... or create/skip" / "Multiple domains found... choose one") when the flag is missing and there's more than one candidate (or, for the client id, always — even zero candidates offers "Create"/"Skip"). There is no stdin in a non-interactive/agent-driven run, so this hangs waiting for a selection instead of quietly scaffolding with a blank/default value. A blank oidcClientId only happens if a human sitting at the terminal interactively picks "Skip". An agent running blocks new web should always resolve and pass both --client-id and --app-domain explicitly up front — see blocks-iam-sso-oidc-configuration for resolving/creating an OIDC client via auth oidc-clients list/save, and project.applications[].domain (from the project record) for the app domain — rather than omitting either and hoping for a graceful non-interactive default.

Gotchas

  • Disabled login button, no error → isLoginConfigured() is false, almost always because VITE_BLOCKS_OIDC_CLIENT_ID is empty in .env. Don't assume blocks new web was just run without --client-id and "left this blank on purpose" — omitting --client-id (or --app-domain, when a project has multiple domains) requires an interactive selection and fails with interactive_input_required in an agent-driven run. A blank client id only results from a human interactively choosing "Skip." Always pass --client-id explicitly (see the Config section above).
  • Login redirects back but the app still shows logged out → this is an HTTPS/cookie problem, not an app-logic bug — the session cookie is Secure and won't be stored/sent on http://localhost. Cross-reference blocks-frontend-local-https rather than debugging AuthProvider.
  • Redirect URI mismatch → the SDK derives redirectUri from window.location.origin at runtime; if the app runs under more than one origin (dev HTTPS host, prod domain), the registered OIDC client's redirect_uris must list /login/callback under each of them, or IAM rejects the authorize request for the ones missing.
  • Activation is a separate concern. Already-activated users go straight through this flow. Only users invited/created inactive via the portal or API need a one-time /activate step first — out of scope here, see blocks-iam-account.
  • Don't add a RequireAuth/RedirectIfAuthenticated guard around /login/callback — it must stay reachable while the user is still unauthenticated, by design.
  • Don't hand-roll a "check if logged in" fetch — call blocksClient.auth.isAuthenticated() or reuse AuthProvider's status/refresh(), never infer auth state from sessionStorage/localStorage (the default flow keeps no readable token there at all).
  • Custom app domain, session never sticks (cookie calls silently fail) → on a custom (non-*.seliseblocks.com) app domain, the hosted-login session cookie is only stored/sent if VITE_BLOCKS_API_URL shares the app's registrable domain. The default https://api.seliseblocks.com does not share a registrable domain with e.g. abc.slsblx.com, so the browser never stores the cross-site cookie and cookie-based calls (userInfo()//iam/me, logout, the OIDC callback flow this skill documents) silently fail. For a custom domain, VITE_BLOCKS_API_URL must be https://blocksapi.<registrable-domain> (e.g. abc.slsblx.com → https://blocksapi.slsblx.com), not the default.

Example trigger prompts

  • "Add a login button and handle the OIDC callback"
  • "Why is my login button disabled?"
  • "Add a new protected page that requires the user to be signed in"
  • "The user gets redirected back from IAM but the app still shows them as logged out"
  • "Wire up token refresh for when the session expires"
  • "How does this scaffolded app know if someone is logged in?"

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.