@seliseblocks/cli-os 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/AI_USAGE_GUIDE.md +560 -560
  2. package/LICENSE +21 -21
  3. package/README.md +173 -173
  4. package/bin/run.js +2 -2
  5. package/dist/commands/auth/idp/create.d.ts +7 -0
  6. package/dist/commands/auth/idp/create.js +22 -3
  7. package/dist/commands/auth/idp/update.d.ts +7 -0
  8. package/dist/commands/auth/idp/update.js +22 -3
  9. package/dist/commands/new/web.js +4 -0
  10. package/dist/index.js +692 -672
  11. package/dist/lib/scaffold-web/app-core.js +6 -1
  12. package/dist/lib/scaffold-web/blocks-lib.js +36 -2
  13. package/dist/skills/blocks-data-gateway-configuration/SKILL.md +204 -204
  14. package/dist/skills/blocks-data-gateway-crud/SKILL.md +223 -223
  15. package/dist/skills/blocks-data-storage/SKILL.md +253 -253
  16. package/dist/skills/blocks-data-storage/flows/object-management.md +124 -124
  17. package/dist/skills/blocks-frontend-local-https/SKILL.md +100 -100
  18. package/dist/skills/blocks-iam-access-control/SKILL.md +49 -49
  19. package/dist/skills/blocks-iam-access-control/flows/feature-gating.md +38 -38
  20. package/dist/skills/blocks-iam-access-control/flows/manage-roles-permissions.md +110 -110
  21. package/dist/skills/blocks-iam-account/SKILL.md +169 -169
  22. package/dist/skills/blocks-iam-mfa/SKILL.md +124 -124
  23. package/dist/skills/blocks-iam-organizations/SKILL.md +43 -43
  24. package/dist/skills/blocks-iam-organizations/flows/admin-mutations.md +89 -89
  25. package/dist/skills/blocks-iam-organizations/flows/read-and-switch.md +57 -57
  26. package/dist/skills/blocks-iam-sso-oidc-configuration/SKILL.md +105 -89
  27. package/dist/skills/blocks-iam-sso-oidc-implementation/SKILL.md +80 -80
  28. package/dist/skills/blocks-iam-users/SKILL.md +131 -131
  29. package/dist/skills/blocks-localization-configuration/SKILL.md +149 -149
  30. package/dist/skills/blocks-localization-implementation/SKILL.md +63 -63
  31. package/dist/skills/blocks-mail/SKILL.md +95 -95
  32. package/dist/skills/blocks-notification/SKILL.md +69 -69
  33. package/dist/skills/blocks-notifier/SKILL.md +107 -107
  34. package/dist/skills/blocks-onboarding/SKILL.md +78 -78
  35. package/dist/skills/blocks-release-deployment/SKILL.md +81 -81
  36. package/dist/skills/blocks-secrets/SKILL.md +81 -81
  37. package/dist/skills/blocks-storage-configuration/SKILL.md +93 -93
  38. package/dist/skills/lint.mjs +168 -168
  39. package/package.json +47 -47
@@ -1,57 +1,57 @@
1
- # Flow: Read / self-service surface (safe, no special caveat)
2
-
3
- These are read-only or scoped to switching the *caller's own* context. No confirmation ritual needed beyond normal engineering judgment.
4
-
5
- ## `organizations.my()` — the org switcher
6
-
7
- Returns the signed-in user's own available organizations — the standard source for an org switcher / "pick your workspace" UI. Requires the user to already be authenticated (pair with `useCurrentUser` / `blocksClient.iam.me()` from the onboarding/profile scaffold).
8
-
9
- ```ts
10
- // src/features/organizations/useMyOrganizations.ts
11
- import { useQuery } from "@tanstack/react-query";
12
- import { useAuth } from "../../app/providers/AuthProvider";
13
- import { blocksClient } from "../../lib/blocks/client";
14
-
15
- export function useMyOrganizations() {
16
- const { status } = useAuth();
17
- return useQuery({
18
- enabled: status === "authenticated",
19
- queryFn: () => blocksClient.iam.organizations.my(),
20
- queryKey: ["iam", "organizations", "my"]
21
- });
22
- }
23
- ```
24
-
25
- CLI equivalent: `blocks iam organizations my` (read-only, project-scoped).
26
-
27
- ## `auth.switchOrganization(request)` — change active org context
28
-
29
- For a multi-org user, switches which organization the session is scoped to. Pass `{ organizationId, refreshToken }`; the response is a fresh `BlocksAuthResponse` (new tokens for the new org context). **If your app tracks its own session state (stored tokens, an auth context/provider), replace it with this response** — don't just call the endpoint and leave the old tokens in place, or subsequent calls will still act in the old org.
30
-
31
- ```ts
32
- async function switchToOrganization(organizationId: string) {
33
- const refreshToken = getRefreshToken(); // however this app's AuthProvider stores it
34
- const response = await blocksClient.auth.switchOrganization({ organizationId, refreshToken });
35
- applyAuthResponse(response); // app-owned: persist new tokens, refresh useCurrentUser/useMyOrganizations
36
- }
37
- ```
38
-
39
- This is user-directed (they picked an org in the switcher) so it doesn't need the admin-CRUD confirmation ritual — but it does change what the rest of the session sees, so trigger it from an explicit user action (selecting an item in the switcher), not silently.
40
-
41
- **No CLI equivalent exists for this one** — switching the *active session's* org context only makes sense from inside the app that owns that session, so it's SDK/app-only. Don't invent a `blocks iam organizations switch` command.
42
-
43
- ## `signupSettings.get()` — public signup screen
44
-
45
- Public — no auth required (the SDK still sends `x-blocks-key`). Read this on a public signup page to know the tenant's current signup policy (e.g., whether self-signup or org-creation-from-signup is allowed) before rendering the form.
46
-
47
- ```ts
48
- const settings = await blocksClient.iam.signupSettings.get();
49
- ```
50
-
51
- CLI equivalent for scripting/inspection: `blocks iam signup-settings get --json` (no SDK needed, no app context needed).
52
-
53
- ## Gotchas
54
-
55
- - **`switchOrganization` replaces session state.** If the app persists tokens (localStorage, an AuthProvider, React Query cache), apply the new `BlocksAuthResponse` fully — a stale access token after switching orgs will produce confusing "wrong org" data on the next call.
56
- - **`organizations.my()` needs the user already authenticated** — call it after `iam.me()`/`useCurrentUser` resolves, not before, or you'll get an auth failure that looks like "no orgs" but actually means "not logged in yet."
57
- - **Multi-org must be enabled** (`isMultiOrgEnabled` via `organizations.getConfig()` — see [admin-mutations.md](admin-mutations.md)) for more than one org per user to be meaningful — if a user reports "switching orgs doesn't do anything," check this first.
1
+ # Flow: Read / self-service surface (safe, no special caveat)
2
+
3
+ These are read-only or scoped to switching the *caller's own* context. No confirmation ritual needed beyond normal engineering judgment.
4
+
5
+ ## `organizations.my()` — the org switcher
6
+
7
+ Returns the signed-in user's own available organizations — the standard source for an org switcher / "pick your workspace" UI. Requires the user to already be authenticated (pair with `useCurrentUser` / `blocksClient.iam.me()` from the onboarding/profile scaffold).
8
+
9
+ ```ts
10
+ // src/features/organizations/useMyOrganizations.ts
11
+ import { useQuery } from "@tanstack/react-query";
12
+ import { useAuth } from "../../app/providers/AuthProvider";
13
+ import { blocksClient } from "../../lib/blocks/client";
14
+
15
+ export function useMyOrganizations() {
16
+ const { status } = useAuth();
17
+ return useQuery({
18
+ enabled: status === "authenticated",
19
+ queryFn: () => blocksClient.iam.organizations.my(),
20
+ queryKey: ["iam", "organizations", "my"]
21
+ });
22
+ }
23
+ ```
24
+
25
+ CLI equivalent: `blocks iam organizations my` (read-only, project-scoped).
26
+
27
+ ## `auth.switchOrganization(request)` — change active org context
28
+
29
+ For a multi-org user, switches which organization the session is scoped to. Pass `{ organizationId, refreshToken }`; the response is a fresh `BlocksAuthResponse` (new tokens for the new org context). **If your app tracks its own session state (stored tokens, an auth context/provider), replace it with this response** — don't just call the endpoint and leave the old tokens in place, or subsequent calls will still act in the old org.
30
+
31
+ ```ts
32
+ async function switchToOrganization(organizationId: string) {
33
+ const refreshToken = getRefreshToken(); // however this app's AuthProvider stores it
34
+ const response = await blocksClient.auth.switchOrganization({ organizationId, refreshToken });
35
+ applyAuthResponse(response); // app-owned: persist new tokens, refresh useCurrentUser/useMyOrganizations
36
+ }
37
+ ```
38
+
39
+ This is user-directed (they picked an org in the switcher) so it doesn't need the admin-CRUD confirmation ritual — but it does change what the rest of the session sees, so trigger it from an explicit user action (selecting an item in the switcher), not silently.
40
+
41
+ **No CLI equivalent exists for this one** — switching the *active session's* org context only makes sense from inside the app that owns that session, so it's SDK/app-only. Don't invent a `blocks iam organizations switch` command.
42
+
43
+ ## `signupSettings.get()` — public signup screen
44
+
45
+ Public — no auth required (the SDK still sends `x-blocks-key`). Read this on a public signup page to know the tenant's current signup policy (e.g., whether self-signup or org-creation-from-signup is allowed) before rendering the form.
46
+
47
+ ```ts
48
+ const settings = await blocksClient.iam.signupSettings.get();
49
+ ```
50
+
51
+ CLI equivalent for scripting/inspection: `blocks iam signup-settings get --json` (no SDK needed, no app context needed).
52
+
53
+ ## Gotchas
54
+
55
+ - **`switchOrganization` replaces session state.** If the app persists tokens (localStorage, an AuthProvider, React Query cache), apply the new `BlocksAuthResponse` fully — a stale access token after switching orgs will produce confusing "wrong org" data on the next call.
56
+ - **`organizations.my()` needs the user already authenticated** — call it after `iam.me()`/`useCurrentUser` resolves, not before, or you'll get an auth failure that looks like "no orgs" but actually means "not logged in yet."
57
+ - **Multi-org must be enabled** (`isMultiOrgEnabled` via `organizations.getConfig()` — see [admin-mutations.md](admin-mutations.md)) for more than one org per user to be meaningful — if a user reports "switching orgs doesn't do anything," check this first.
@@ -1,89 +1,105 @@
1
- ---
2
- name: blocks-iam-sso-oidc-configuration
3
- description: "Enable/configure SSO for a Blocks project — register an OIDC client and identity provider so end users can log into the app via hosted login. Use for 'enable SSO', 'set up an OIDC identity provider', 'configure single sign-on', 'add a login provider'. CLI-driven by default (`blocks auth oidc-clients *` / `auth idp *`, project-scoped, --dry-run→--yes), not portal-only — the portal remains a valid alternative, especially for federated external providers (Google/Azure/Okta). Don't confuse with `blocks login` (the CLI's own login — see blocks-onboarding)."
4
- ---
5
-
6
- # Blocks IAM — SSO / OIDC Configuration
7
-
8
- Setting up SSO for a Blocks project means provisioning two related tenant records: an **OIDC client** (the app-facing public client used for hosted login) and an **identity provider** (the record the hosted-login redirect/callback flow actually authenticates against). Both are exposed by real, implemented `blocks` CLI commands — this is not a portal-only action.
9
-
10
- ## The one thing to get right: which login is this?
11
-
12
- Don't conflate the CLI's own login with the identity provider this skill configures.
13
-
14
- | | `blocks login` | The one THIS skill covers |
15
- |---|---|---|
16
- | What it's for | Lets `blocks` itself authenticate | Lets **end users log into the user's own app** via hosted SSO |
17
- | Client type | Packaged into the CLI - nothing to register, no secret to hold | Public (browser client, no secret) |
18
- | Registered via | Nothing to register - just run `blocks login` | `blocks auth oidc-clients save` / `blocks auth idp create`, or the portal |
19
- | Owned by | **blocks-onboarding** skill | **This skill**, handing off to **blocks-iam-sso-oidc-implementation** |
20
-
21
- If the user is asking "how do I get `blocks` logged in" or hits `not_logged_in`, that's **blocks-onboarding**, not this skill. This skill is about the identity provider that sits in front of *the user's own application's* login page.
22
-
23
- ## Decision tree
24
-
25
- All of these commands are project-scoped: they need a selected project (`blocks use <tenantId>` or `--project`) and run against an impersonated project token, not the CLI's own account token.
26
-
27
- 1. **Check for an existing OIDC client.** `blocks auth oidc-clients list [--json]` / `blocks auth oidc-clients get <clientId> [--json]`. `client_secret` is excluded from list/get responses — you only ever see it once, at creation or `rotate-secret` time. If a suitable public client already exists (matching redirect URI / display name), reuse its id — you're done, skip to handoff.
28
- 2. **If none exists, create one:**
29
- ```
30
- blocks auth oidc-clients save \
31
- --client-display-name "<app name>" \
32
- --redirect-uris "https://<app-domain>/login/callback" \
33
- --require-pkce --active \
34
- --scope "openid profile" \
35
- --register-as-identity-provider \
36
- [--dry-run] [--yes]
37
- ```
38
- This mirrors exactly what `blocks new web`'s interactive OIDC-client prompt does when scaffolding a new web app. `--register-as-identity-provider` is what turns this from "just an OIDC client" into something the hosted-login redirect flow (`auth.idp.redirectToProvider()` / `auth.idp.callback()`) can authenticate against — per the CLI's own scaffold help text, this registers the client "as a Blocks OIDC identity provider" in the same call. For the common case (your own app logging its own users in via Blocks-hosted login), this single command is usually the entire provisioning step — `blocks new web` never calls `auth idp create` separately.
39
- 3. **Inspect/manage the resulting identity-provider record** with `blocks auth idp list [--json]` / `blocks auth idp get <id> [--json]`. Use `blocks auth idp status <id> --active|--active=false` to enable/disable without deleting, and `blocks auth idp delete <id>` to remove it deleting an identity provider **also deletes its related OIDC client registration**, so treat `idp delete` as the higher-blast-radius operation of the two.
40
- 4. **`blocks auth idp create`/`update` exist as a separate, more general path** for constructing an identity-provider record directly most relevant when federating an *external* identity provider (Google, Azure AD, Okta, etc.) rather than using Blocks' own OIDC client as the login mechanism:
41
- ```
42
- blocks auth idp create --provider <p> --provider-type <t> --protocol <proto> \
43
- --client-id <id> [--client-secret <secret>] [--display-name] [--issuer] \
44
- [--scope] [--redirect-uris a,b] [--active] \
45
- [--body '<json>'|--file <path>] [--dry-run] [--yes]
46
- ```
47
- `--provider`, `--provider-type`, `--protocol`, and `--client-id` are required on create, and are immutable afterward — `auth idp update <id>` accepts the same flags but IAM requires you to either omit them or echo the existing values exactly. Richer provider configs (JWKS, private keys, initial roles, etc.) go through `--body`/`--file` rather than a dedicated flag. **How exactly a `clientId` passed here pairs with an OIDC client record is not shown anywhere documented** — the two collections are related (per the cascading delete behavior above) but the create/update commands don't expose an explicit "link to this OIDC client" field beyond passing the same id. If you need to federate an external provider, treat `idp create`'s field values as IAM's contract and confirm anything beyond the flags above against the tenant's actual behavior rather than guessing.
48
- 5. **Hand off.** Once a client id (and, if relevant, an identity-provider id) exists, the frontend wiring login button, callback route, token handling, `client.auth.idp.initiate()`/`redirectToProvider()`/`.callback()` from `@seliseblocks/client` is owned by **blocks-iam-sso-oidc-implementation**. Do not duplicate that work here; route to it.
49
-
50
- ## Mutation discipline
51
-
52
- Every create/update/delete/status/rotate-secret command above follows the same pattern as the rest of `blocks`:
53
- - `--dry-run` prints the request body and target endpoint without sending it (secrets are redacted in the printed body).
54
- - Without `--dry-run`, the command prompts "Type 'yes' to continue" before mutating anything, unless `--yes` is passed to skip the prompt.
55
- - These are real tenant-security actions (an identity provider or public OIDC client controls who can authenticate as a given app's users) — always show the user what will happen (favor `--dry-run` first) rather than running mutations silently, and don't add `--yes` to a call the user hasn't actually approved.
56
-
57
- Never raw `fetch`/`curl` these endpoints to route around the CLI's confirmation/dry-run discipline — use the commands above so the same guardrails apply.
58
-
59
- ## Two verified footguns
60
-
61
- - **PKCE lives on the OIDC client only — `auth idp create`/`update` has no PKCE field at all.** `requirePkce` is a real flag on `oidc-clients save` (and `blocks new web`'s scaffold sets it to `true`), but `auth idp create`'s body only reads `clientId`, `clientSecret`, `displayName`, `isActive`, `issuer`, `protocol`, `provider`, `providerType`, `redirectUris`, `scope` there is no `--require-pkce`/`requirePkce` equivalent on the identity-provider record, and no such flag is documented in `blocks auth idp create --help`. Don't go looking for a matching PKCE setting on the identity provider, and don't assume passing one through `--body`/`--file` does anything — the command doesn't read it.
62
- - **No `wellKnownUrl` (or equivalent discovery-URL) field is exposed by any `auth idp`/`auth oidc-clients` command in source.** `oidc-clients save` has an `--external-discovery-endpoint` flag (`externalDiscoveryEndpoint` in the request body), but its exact purpose/shape and whether it's tenant-relative or absolute is not explained anywhere in the CLI source or its help text, and `auth idp create`/`update` has no discovery/well-known field whatsoever. **Do not assert a well-known URL shape (e.g. one derived from "the project's own tenant id") as fact — this needs live verification against the tenant API**, not a guess. If a well-known/discovery URL matters for what you're building, treat `--external-discovery-endpoint` as the one lead worth testing live, and confirm the exact shape empirically before documenting it as settled.
63
-
64
- ## Secondary, optional: the SDK's `identityProviders` admin methods
65
-
66
- `@seliseblocks/client` (see `auth-client.ts`, the `readonly identityProviders = { list, get, create, update, updateStatus, delete }` block) also exposes typed methods that call the same identity-provider resource the CLI's `auth idp` commands hit. Reach for this when you're building an **in-app admin settings screen** for a signed-in administrator, where *they* click a button labeled something like "Add identity provider" and *they* fill in a form, in the moment they personally intend to make that change:
67
-
68
- ```tsx
69
- // A settings page for a signed-in admin user. The admin types into the form
70
- // and clicks "Save" themselves — the SDK call fires from THEIR click handler.
71
- async function onSaveClicked(formValues: IdentityProviderFormValues) {
72
- await client.auth.identityProviders.create(formValues); // admin-initiated, in the moment
73
- }
74
- ```
75
-
76
- Request/payload types on these SDK methods are intentionally loose (`Record<string, unknown>` passthrough) confirm field names against the same contract the CLI's `auth idp create` flags document (`provider`, `providerType`, `protocol`, `clientId`, etc.) rather than guessing new ones.
77
-
78
- ## Related skills
79
-
80
- - **blocks-onboarding** — owns `blocks login` itself (authenticates with no setup, nothing to register or look up). Go there first if `blocks` itself isn't authenticated, or if the user is conflating "logging in the CLI" with "SSO for my app."
81
- - **blocks-iam-sso-oidc-implementation** — owns everything that happens once an identity provider/client id exists: wiring the login button, callback route, and token/session handling in the scaffolded React app using `@seliseblocks/client`. This skill hands off to it and does not duplicate its content.
82
-
83
- ## Example trigger prompts → routing
84
-
85
- - "Enable SSO for my project" / "Set up an OIDC identity provider" / "Configure single sign-on for my app" → confirm it's the app's end-user login (not the CLI's), run the decision tree above (`auth oidc-clients list/get` → `auth oidc-clients save --register-as-identity-provider` if none exists), then hand off to **blocks-iam-sso-oidc-implementation**.
86
- - "Register an OIDC client so users can log in" `blocks auth oidc-clients list`/`get` first to avoid duplicates, then `blocks auth oidc-clients save` with `--dry-run` shown to the user before confirming.
87
- - "Can you just create the identity provider via the API so I don't have to click through the portal?" → yes — walk them through `blocks auth oidc-clients save` / `blocks auth idp create` with `--dry-run` first, get explicit confirmation before dropping `--yes`, and mention the portal (https://os.seliseblocks.com) as an alternative if they'd rather use a GUI, especially for federated external providers where they need to register with that provider first.
88
- - "blocks login isn't working" / "not_logged_in" → this is the CLI's own login, not this skill — route to **blocks-onboarding**.
89
- - "I want an admin page in my app where I can manage identity providers" → this skill's SDK section applies: help build the settings screen calling `identityProviders.list/create/update/delete` from the admin's own button clicks.
1
+ ---
2
+ name: blocks-iam-sso-oidc-configuration
3
+ description: "Enable/configure SSO for a Blocks project — register an OIDC client and identity provider so end users can log into the app via hosted login. Use for 'enable SSO', 'set up an OIDC identity provider', 'configure single sign-on', 'add a login provider'. CLI-driven by default (`blocks auth oidc-clients *` / `auth idp *`, project-scoped, --dry-run→--yes), not portal-only — the portal remains a valid alternative, especially for federated external providers (Google/Azure/Okta). Don't confuse with `blocks login` (the CLI's own login — see blocks-onboarding)."
4
+ ---
5
+
6
+ # Blocks IAM — SSO / OIDC Configuration
7
+
8
+ Setting up SSO for a Blocks project means provisioning two related tenant records: an **OIDC client** (the app-facing public client used for hosted login) and an **identity provider** (the record the hosted-login redirect/callback flow actually authenticates against). Both are exposed by real, implemented `blocks` CLI commands — this is not a portal-only action.
9
+
10
+ ## The one thing to get right: which login is this?
11
+
12
+ Don't conflate the CLI's own login with the identity provider this skill configures.
13
+
14
+ | | `blocks login` | The one THIS skill covers |
15
+ |---|---|---|
16
+ | What it's for | Lets `blocks` itself authenticate | Lets **end users log into the user's own app** via hosted SSO |
17
+ | Client type | Packaged into the CLI - nothing to register, no secret to hold | Public (browser client, no secret) |
18
+ | Registered via | Nothing to register - just run `blocks login` | `blocks auth oidc-clients save` / `blocks auth idp create`, or the portal |
19
+ | Owned by | **blocks-onboarding** skill | **This skill**, handing off to **blocks-iam-sso-oidc-implementation** |
20
+
21
+ If the user is asking "how do I get `blocks` logged in" or hits `not_logged_in`, that's **blocks-onboarding**, not this skill. This skill is about the identity provider that sits in front of *the user's own application's* login page.
22
+
23
+ ## Decision tree
24
+
25
+ All of these commands are project-scoped: they need a selected project (`blocks use <tenantId>` or `--project`) and run against an impersonated project token, not the CLI's own account token.
26
+
27
+ 1. **Check for an existing OIDC client.** `blocks auth oidc-clients list [--json]` / `blocks auth oidc-clients get <clientId> [--json]`. `client_secret` is excluded from list/get responses — you only ever see it once, at creation or `rotate-secret` time. If a suitable public client already exists (matching redirect URI / display name), reuse its id — you're done, skip to handoff.
28
+ 2. **If none exists, create one:**
29
+ ```
30
+ blocks auth oidc-clients save \
31
+ --client-display-name "<app name>" \
32
+ --client-type public \
33
+ --redirect-uris "https://<app-domain>/login/callback" \
34
+ --require-pkce --active \
35
+ --scope "openid profile" \
36
+ --register-as-identity-provider \
37
+ [--dry-run] [--yes]
38
+ ```
39
+ This mirrors exactly what `blocks new web`'s interactive OIDC-client prompt does when scaffolding a new web app. `--register-as-identity-provider` is what turns this from "just an OIDC client" into something the hosted-login redirect flow (`auth.idp.redirectToProvider()` / `auth.idp.callback()`) can authenticate against per the CLI's own scaffold help text, this registers the client "as a Blocks OIDC identity provider" in the same call.
40
+ 3. **Verify the auto-created provider before handing off.** `--register-as-identity-provider` creates the provider record for you — but check what landed in it with `blocks auth idp list --json`, because on the common path several fields come back null. See the footguns below. If `authorizationUrl` is null, hosted login will not redirect: `GET /iam/v4/idp/initiate` (what `auth.idp.redirectToProvider()` calls) builds its target as `provider.AuthorizationUrl ?? ""` plus a query string, so the browser navigates to the app's own origin with OIDC params attached. The repair, for a provider that already exists in that state:
41
+ ```
42
+ blocks auth idp update <providerItemId> \
43
+ --authorization-url "<tenant authorize endpoint>" \
44
+ --token-url "<tenant token endpoint>" \
45
+ --user-info-url "<tenant userinfo endpoint>" \
46
+ [--dry-run] [--yes]
47
+ ```
48
+ `idp update` is the only route that persists these three IAM's create path accepts them in its request model and drops them, and the repository's update is a plain replace with no re-discovery, so values set here stick. **Read the tenant's discovery document for the correct endpoint values rather than composing them by hand** — see the last footgun.
49
+ 4. **Inspect/manage the resulting identity-provider record** with `blocks auth idp list [--json]` / `blocks auth idp get <id> [--json]`. Use `blocks auth idp status <id> --active|--active=false` to enable/disable without deleting, and `blocks auth idp delete <id>` to remove it — deleting an identity provider **also deletes its related OIDC client registration**, so treat `idp delete` as the higher-blast-radius operation of the two.
50
+ 5. **`blocks auth idp create`/`update` exist as a separate, more general path** for constructing an identity-provider record directly — most relevant when federating an *external* identity provider (Google, Azure AD, Okta, etc.) rather than using Blocks' own OIDC client as the login mechanism:
51
+ ```
52
+ blocks auth idp create --provider <p> --provider-type <t> --protocol <proto> \
53
+ --client-id <id> [--client-secret <secret>] [--display-name] [--issuer] \
54
+ [--scope] [--redirect-uris a,b] [--active] \
55
+ [--authorization-url] [--token-url] [--user-info-url] [--jwks-uri] \
56
+ [--well-known-url] [--response-type] [--grant-types a,b] [--require-pkce] \
57
+ [--token-endpoint-auth-method] [--initial-roles a,b] [--initial-permissions a,b] \
58
+ [--icon] [--body '<json>'|--file <path>] [--dry-run] [--yes]
59
+ ```
60
+ `--provider`, `--provider-type`, `--protocol`, and `--client-id` are required on create, and are immutable afterward — `auth idp update <id>` accepts the same flags but IAM requires you to either omit them or echo the existing values exactly. Apple-specific fields (`teamId`, `keyId`, `privateKey`, `appleAudience`) go through `--body`/`--file` so no private key lands in shell history. Note that `create` stores `issuer`, `jwksUri` and `wellKnownUrl` but silently drops `authorizationUrl`, `tokenUrl` and `userInfoUrl` — pass those to `idp update` in a second call. **How exactly a `clientId` passed here pairs with an OIDC client record is not shown anywhere documented** — the two collections are related (per the cascading delete behavior above) but the create/update commands don't expose an explicit "link to this OIDC client" field beyond passing the same id. If you need to federate an external provider, treat `idp create`'s field values as IAM's contract and confirm anything beyond the flags above against the tenant's actual behavior rather than guessing.
61
+ 6. **Hand off.** Once a client id (and, if relevant, an identity-provider id) exists, the frontend wiring login button, callback route, token handling, `client.auth.idp.initiate()`/`redirectToProvider()`/`.callback()` from `@seliseblocks/client` — is owned by **blocks-iam-sso-oidc-implementation**. Do not duplicate that work here; route to it.
62
+
63
+ ## Mutation discipline
64
+
65
+ Every create/update/delete/status/rotate-secret command above follows the same pattern as the rest of `blocks`:
66
+ - `--dry-run` prints the request body and target endpoint without sending it (secrets are redacted in the printed body).
67
+ - Without `--dry-run`, the command prompts "Type 'yes' to continue" before mutating anything, unless `--yes` is passed to skip the prompt.
68
+ - These are real tenant-security actions (an identity provider or public OIDC client controls who can authenticate as a given app's users) — always show the user what will happen (favor `--dry-run` first) rather than running mutations silently, and don't add `--yes` to a call the user hasn't actually approved.
69
+
70
+ Never raw `fetch`/`curl` these endpoints to route around the CLI's confirmation/dry-run discipline use the commands above so the same guardrails apply.
71
+
72
+ ## Verified footguns
73
+
74
+ - **`--client-type public` is not cosmetic — omitting it stores a browser app as confidential.** IAM derives `tokenEndpointAuthMethod` from `clientType`: `public` (or any device-flow client) becomes `"none"`, anything else becomes `"client_secret_post"`. Leave `--client-type` off and a PKCE SPA is persisted as a confidential client that is also eligible for the `client_credentials` grant. Always pass `--client-type public` for a browser client. `--require-pkce` alone does not imply it.
75
+ - **The auto-created provider's endpoint URLs come from discovery, and discovery is driven by one field.** IAM's repository-level `CreateIdentityProviderAsync` runs `PopulateProviderEndpointsFromWellKnownAsync` before inserting: if `wellKnownUrl` is set it fetches the document and fills `authorizationUrl`, `tokenUrl`, `userInfoUrl`, `jwksUri` and `issuer` from it. The only input that reaches `wellKnownUrl` on this path is `oidc-clients save --external-discovery-endpoint`. Omit it and the `else` branch runs `GetSocialMetadata(provider)`, which matches only names containing `google` or `microsoft` — for an app-named provider it returns null, so all five fields are written null **and `scope` is overwritten with `"openid profile email"`**, discarding the `offline_access` the OIDC client had just been given. Check both `authorizationUrl` and `scope` on the provider after registering.
76
+ - **This only happens at create.** Re-saving the same OIDC client does not re-run discovery: the `existingProvider` branch never touches `wellKnownUrl`, and the repository's update is a plain replace. A provider already written with null URLs cannot be repaired by re-saving the client — use `idp update`, or delete and recreate.
77
+ - **PKCE and the discovery URL exist on both records and mean different things.** `requirePkce` on the OIDC client governs the app's own authorize flow; `--require-pkce` on `auth idp` governs the *upstream* handshake `/idp/initiate` performs. `--external-discovery-endpoint` on the client is read only as the linked provider's `wellKnownUrl`; on the provider record itself use `--well-known-url`.
78
+ - **Do not compose the tenant's own discovery or authorize URL from a template.** `DiscoveryController` declares `/{tenant_id}/.well-known/openid-configuration` as an absolute route, outside the `/iam/v4` prefix that every other IAM endpoint sits behind, and every `wellKnownUrl` example in IAM's own source and tests is an *external* provider (`accounts.google.com`, `login.microsoftonline.com`, `idp.example.com`) — there is no in-repo example of a Blocks tenant pointing at itself. Whether that route resolves through the `blocksapi.<domain>` gateway as-is or needs an extra segment is **not settled in source**. Fetch the tenant's discovery document and read the endpoints out of it, or ask the user; do not assert a shape you have not seen respond.
79
+
80
+ ## Secondary, optional: the SDK's `identityProviders` admin methods
81
+
82
+ `@seliseblocks/client` (see `auth-client.ts`, the `readonly identityProviders = { list, get, create, update, updateStatus, delete }` block) also exposes typed methods that call the same identity-provider resource the CLI's `auth idp` commands hit. Reach for this when you're building an **in-app admin settings screen** for a signed-in administrator, where *they* click a button labeled something like "Add identity provider" and *they* fill in a form, in the moment they personally intend to make that change:
83
+
84
+ ```tsx
85
+ // A settings page for a signed-in admin user. The admin types into the form
86
+ // and clicks "Save" themselves the SDK call fires from THEIR click handler.
87
+ async function onSaveClicked(formValues: IdentityProviderFormValues) {
88
+ await client.auth.identityProviders.create(formValues); // admin-initiated, in the moment
89
+ }
90
+ ```
91
+
92
+ Request/payload types on these SDK methods are intentionally loose (`Record<string, unknown>` passthrough) — confirm field names against the same contract the CLI's `auth idp create` flags document (`provider`, `providerType`, `protocol`, `clientId`, etc.) rather than guessing new ones.
93
+
94
+ ## Related skills
95
+
96
+ - **blocks-onboarding** — owns `blocks login` itself (authenticates with no setup, nothing to register or look up). Go there first if `blocks` itself isn't authenticated, or if the user is conflating "logging in the CLI" with "SSO for my app."
97
+ - **blocks-iam-sso-oidc-implementation** — owns everything that happens once an identity provider/client id exists: wiring the login button, callback route, and token/session handling in the scaffolded React app using `@seliseblocks/client`. This skill hands off to it and does not duplicate its content.
98
+
99
+ ## Example trigger prompts → routing
100
+
101
+ - "Enable SSO for my project" / "Set up an OIDC identity provider" / "Configure single sign-on for my app" → confirm it's the app's end-user login (not the CLI's), run the decision tree above (`auth oidc-clients list/get` → `auth oidc-clients save --register-as-identity-provider` if none exists), then hand off to **blocks-iam-sso-oidc-implementation**.
102
+ - "Register an OIDC client so users can log in" → `blocks auth oidc-clients list`/`get` first to avoid duplicates, then `blocks auth oidc-clients save` with `--dry-run` shown to the user before confirming.
103
+ - "Can you just create the identity provider via the API so I don't have to click through the portal?" → yes — walk them through `blocks auth oidc-clients save` / `blocks auth idp create` with `--dry-run` first, get explicit confirmation before dropping `--yes`, and mention the portal (https://os.seliseblocks.com) as an alternative if they'd rather use a GUI, especially for federated external providers where they need to register with that provider first.
104
+ - "blocks login isn't working" / "not_logged_in" → this is the CLI's own login, not this skill — route to **blocks-onboarding**.
105
+ - "I want an admin page in my app where I can manage identity providers" → this skill's SDK section applies: help build the settings screen calling `identityProviders.list/create/update/delete` from the admin's own button clicks.
@@ -1,80 +1,80 @@
1
- ---
2
- name: blocks-iam-sso-oidc-implementation
3
- description: "Extend or debug the hosted SSO/OIDC login flow `blocks new web` scaffolds into every Blocks app: redirectToProvider → `/login/callback` → session, via the single `blocksClient`. Covers `AuthProvider` status/claims, `RequireAuth`/`RedirectIfAuthenticated` guards, and token refresh. Use for a login button, the OIDC callback, protected routes, a disabled login button, redirect loops, or a session that doesn't stick — on an app `blocks new web` already created. Requires a registered OIDC client (`blocks-iam-sso-oidc-configuration`) and HTTPS on the real domain for testing (`blocks-frontend-local-https`)."
4
- ---
5
-
6
- # Blocks IAM — SSO / OIDC Implementation (scaffolded frontend)
7
-
8
- `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.
9
-
10
- ## The files, and what each one actually does
11
-
12
- | File | Role |
13
- |---|---|
14
- | `src/lib/blocks/config.ts` | Reads `VITE_BLOCKS_*` env vars; `isLoginConfigured()` = `apiUrl && oidcUrl && oidcClientId` all present |
15
- | `src/lib/blocks/client.ts` | The one `blocksClient = createBlocksClient({...})` instance, with `oidc: { clientId, scope, url: oidcUrl }` |
16
- | `src/lib/blocks/auth.ts` | `startLogin`, `completeLogin`, `fetchSessionClaims`, `logout`, `getValidAccessToken` — the session/token logic |
17
- | `src/lib/blocks/jwt.ts` | `decodeJwtPayload`/`isJwtExpired` — only relevant if a tenant's OIDC config returns bearer tokens in the body |
18
- | `src/app/providers/AuthProvider.tsx` | React context: `status`/`claims`/`login`/`logout`/`refresh`, polling + visibility-driven refresh |
19
- | `src/app/router/guards.tsx` | `RequireAuth`, `RedirectIfAuthenticated` |
20
- | `src/app/router/routes.tsx` | Wires `/login`, `/login/callback`, and the protected route table (`/`, `/assets`, `/profile`, `/error`) |
21
- | `src/features/auth/LoginPage.tsx` | The login button |
22
- | `src/features/auth/CallbackPage.tsx` | The `/login/callback` handler |
23
-
24
- ## The flow, traced through the generated code
25
-
26
- 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.
27
- - 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`.**
28
- 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.
29
- 3. The user authenticates on Blocks-hosted IAM.
30
- 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`).
31
- 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.
32
- 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.
33
- - 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.
34
- - 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.
35
- 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.
36
-
37
- ## Session state and route guards
38
-
39
- - **`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.
40
- - **`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).
41
- - **`RedirectIfAuthenticated`** wraps `/login` itself so an already-signed-in user hitting `/login` bounces straight to `/` instead of seeing the login button again.
42
- - 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.
43
-
44
- ## The `@seliseblocks/client` methods behind all of this
45
-
46
- All under `blocksClient.auth`:
47
-
48
- - **`idp.initiate(request?)`** — starts the flow, returns `{ redirect_uri }`. Uses the client's configured `oidc` defaults (`clientId`, `redirectUri`) unless you pass overrides per call.
49
- - **`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.
50
- - **`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).
51
- - **`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.
52
- - **`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.
53
- - **`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.
54
-
55
- ## Config
56
-
57
- `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`.
58
-
59
- **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-onboarding**) — the two are unrelated and neither can substitute for the other.
60
-
61
- **`--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.
62
-
63
- ## Gotchas
64
-
65
- - **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) drops into an interactive `selectFromList()` prompt with no graceful non-interactive fallback; in an agent-driven run with no stdin, that hangs rather than scaffolding a blank value. A blank client id only results from a human interactively choosing "Skip." Always pass `--client-id` explicitly (see the Config section above).
66
- - **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`.
67
- - **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.
68
- - **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`**.
69
- - **Don't add a `RequireAuth`/`RedirectIfAuthenticated` guard around `/login/callback`** — it must stay reachable while the user is still unauthenticated, by design.
70
- - **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).
71
- - **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.
72
-
73
- ## Example trigger prompts
74
-
75
- - "Add a login button and handle the OIDC callback"
76
- - "Why is my login button disabled?"
77
- - "Add a new protected page that requires the user to be signed in"
78
- - "The user gets redirected back from IAM but the app still shows them as logged out"
79
- - "Wire up token refresh for when the session expires"
80
- - "How does this scaffolded app know if someone is logged in?"
1
+ ---
2
+ name: blocks-iam-sso-oidc-implementation
3
+ description: "Extend or debug the hosted SSO/OIDC login flow `blocks new web` scaffolds into every Blocks app: redirectToProvider → `/login/callback` → session, via the single `blocksClient`. Covers `AuthProvider` status/claims, `RequireAuth`/`RedirectIfAuthenticated` guards, and token refresh. Use for a login button, the OIDC callback, protected routes, a disabled login button, redirect loops, or a session that doesn't stick — on an app `blocks new web` already created. Requires a registered OIDC client (`blocks-iam-sso-oidc-configuration`) and HTTPS on the real domain for testing (`blocks-frontend-local-https`)."
4
+ ---
5
+
6
+ # Blocks IAM — SSO / OIDC Implementation (scaffolded frontend)
7
+
8
+ `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.
9
+
10
+ ## The files, and what each one actually does
11
+
12
+ | File | Role |
13
+ |---|---|
14
+ | `src/lib/blocks/config.ts` | Reads `VITE_BLOCKS_*` env vars; `isLoginConfigured()` = `apiUrl && oidcUrl && oidcClientId` all present |
15
+ | `src/lib/blocks/client.ts` | The one `blocksClient = createBlocksClient({...})` instance, with `oidc: { clientId, scope, url: oidcUrl }` |
16
+ | `src/lib/blocks/auth.ts` | `startLogin`, `completeLogin`, `fetchSessionClaims`, `logout`, `getValidAccessToken` — the session/token logic |
17
+ | `src/lib/blocks/jwt.ts` | `decodeJwtPayload`/`isJwtExpired` — only relevant if a tenant's OIDC config returns bearer tokens in the body |
18
+ | `src/app/providers/AuthProvider.tsx` | React context: `status`/`claims`/`login`/`logout`/`refresh`, polling + visibility-driven refresh |
19
+ | `src/app/router/guards.tsx` | `RequireAuth`, `RedirectIfAuthenticated` |
20
+ | `src/app/router/routes.tsx` | Wires `/login`, `/login/callback`, and the protected route table (`/`, `/assets`, `/profile`, `/error`) |
21
+ | `src/features/auth/LoginPage.tsx` | The login button |
22
+ | `src/features/auth/CallbackPage.tsx` | The `/login/callback` handler |
23
+
24
+ ## The flow, traced through the generated code
25
+
26
+ 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.
27
+ - 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`.**
28
+ 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.
29
+ 3. The user authenticates on Blocks-hosted IAM.
30
+ 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`).
31
+ 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.
32
+ 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.
33
+ - 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.
34
+ - 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.
35
+ 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.
36
+
37
+ ## Session state and route guards
38
+
39
+ - **`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.
40
+ - **`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).
41
+ - **`RedirectIfAuthenticated`** wraps `/login` itself so an already-signed-in user hitting `/login` bounces straight to `/` instead of seeing the login button again.
42
+ - 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.
43
+
44
+ ## The `@seliseblocks/client` methods behind all of this
45
+
46
+ All under `blocksClient.auth`:
47
+
48
+ - **`idp.initiate(request?)`** — starts the flow, returns `{ redirect_uri }`. Uses the client's configured `oidc` defaults (`clientId`, `redirectUri`) unless you pass overrides per call.
49
+ - **`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.
50
+ - **`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).
51
+ - **`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.
52
+ - **`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.
53
+ - **`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.
54
+
55
+ ## Config
56
+
57
+ `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`.
58
+
59
+ **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-onboarding**) — the two are unrelated and neither can substitute for the other.
60
+
61
+ **`--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.
62
+
63
+ ## Gotchas
64
+
65
+ - **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) drops into an interactive `selectFromList()` prompt with no graceful non-interactive fallback; in an agent-driven run with no stdin, that hangs rather than scaffolding a blank value. A blank client id only results from a human interactively choosing "Skip." Always pass `--client-id` explicitly (see the Config section above).
66
+ - **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`.
67
+ - **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.
68
+ - **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`**.
69
+ - **Don't add a `RequireAuth`/`RedirectIfAuthenticated` guard around `/login/callback`** — it must stay reachable while the user is still unauthenticated, by design.
70
+ - **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).
71
+ - **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.
72
+
73
+ ## Example trigger prompts
74
+
75
+ - "Add a login button and handle the OIDC callback"
76
+ - "Why is my login button disabled?"
77
+ - "Add a new protected page that requires the user to be signed in"
78
+ - "The user gets redirected back from IAM but the app still shows them as logged out"
79
+ - "Wire up token refresh for when the session expires"
80
+ - "How does this scaffolded app know if someone is logged in?"