@seliseblocks/cli-os 0.2.9 → 0.2.11

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.
@@ -1,169 +1,169 @@
1
- ---
2
- name: blocks-iam-account
3
- description: "Signed-in (or partially-signed-in) user's own SELISE Blocks IAM account actions via @seliseblocks/client — never raw fetch/curl. Covers activation, forgot/reset/change password, logout(-all), profile bootstrap (iam.me/updateMe), self-service MFA, signup, and login-options discovery. Use for activation/password pages, logout buttons, profile bootstrap, signup forms, or letting a user manage their own MFA. The self-service half of IAM — not admin CRUD on other users (blocks-iam-users/blocks-iam-access-control), not hosted-login redirect (blocks-iam-sso-oidc-implementation)."
4
- ---
5
-
6
- # Blocks IAM — Account Self-Service
7
-
8
- Account-lifecycle and account-security actions the signed-in (or not-yet-fully-signed-in) user takes on **their own** account, all through the single `@seliseblocks/client` instance the scaffold gives you — `blocks new web` wires up `createBlocksClient({ apiUrl, xBlocksKey, oidc, accessToken })` once; every call below hangs off that instance's `.auth`, `.iam`, or `.mfa` namespace. **Never** hand-roll `fetch`/`curl` against `api.seliseblocks.com` for these.
9
-
10
- Source of truth: `auth-client.ts`, `iam-client.ts`, and `mfa-client.ts` in `@seliseblocks/client`. Every method has a What/Why/How docstring in source — this skill surfaces them, it doesn't add new ones.
11
-
12
- ## Scope: this vs. the other IAM skills
13
-
14
- - **This skill** — the current user acting on themselves: activate their own invite, reset their own forgotten password, change their own password, log themselves out, read/edit their own profile, sign up, discover login options, enroll/manage their own MFA.
15
- - **blocks-iam-users / blocks-iam-access-control** — an admin managing *other* users (create, deactivate, grant/revoke access). Different actor, different skill. Don't duplicate that here.
16
- - **blocks-iam-sso-oidc-implementation** — the hosted-login redirect/callback flow (`auth.idp.initiate`/`redirectToProvider`/`callback`, `oidc.refreshToken`). This skill only covers direct account-lifecycle calls (activate, recover, reset, change-password, logout) that a user takes outside that redirect dance — don't reimplement hosted login here.
17
- - **blocks-iam-mfa** (not yet written) — the full self-service MFA walkthrough (enrollment UX, challenge flows, backup codes). This skill only notes that `mfa.*` exists and is in scope; go there for depth.
18
-
19
- ## The SDK never owns your session
20
-
21
- Every method here just relays IAM's request/response. The SDK **does not** read or write cookies, localStorage, or any token store — your app decides where the access token, refresh token, and "am I logged in" flag live, and passes the access token in via the `accessToken` option (string or async callback) on `createBlocksClient`. After `logout`/`logoutAll`, activation, or a password reset, **you** clear/update that app-owned state; the SDK call alone doesn't do it for you.
22
-
23
- Request/payload types for most of these methods are intentionally loose (`Record<string, unknown>` passthrough — IAM, not the SDK, defines the exact fields). `BlocksLogoutRequest` is the one exception with a typed hint (`refreshToken?: string`). Where the SDK doesn't pin the shape, confirm exact field names against your tenant's IAM contract rather than guessing — the examples below show the well-known fields, not an exhaustive schema.
24
-
25
- ## Activation — finishing account setup
26
-
27
- Three related calls, all under `blocksClient.auth`, all public (no bearer token needed for `activate`/`validateActivation` — the emailed code is the credential):
28
-
29
- - **`auth.validateActivation(request)`** — no auth required. Check the activation code/state *before* showing the final "set your password" step, so an expired/invalid link fails fast with a clear message instead of after the user fills out the form.
30
- - **`auth.activate(request)`** — no auth required. Completes setup for a user created/invited in an inactive state: pass the emailed `code` plus the new password (and whatever else your tenant's activation contract needs, e.g. `firstName`/`lastName`) after your UI confirms password === confirm-password client-side (don't send a confirm field — that's a UI-only check).
31
- - **`auth.resendActivation(request)`** — Send a new code/link when the old one expired. This call attaches the bearer token if one happens to be configured, but works either way — typical callers are not-yet-active, so don't gate this behind requiring a token.
32
-
33
- ```ts
34
- // after the user opens /activate?code=... and submits password + confirm
35
- const state = await blocksClient.auth.validateActivation({ code });
36
- if (!state.valid) {
37
- // show "this link expired" + a resend option
38
- }
39
-
40
- await blocksClient.auth.activate({
41
- code,
42
- password,
43
- firstName,
44
- lastName
45
- });
46
- // account is now active — route to login / hosted-login (blocks-iam-sso-oidc-implementation)
47
- ```
48
-
49
- ## Password — forgot, reset, and authenticated change
50
-
51
- - **`auth.recover(request)`** — no auth required. Public entry point for "forgot password" — typically just the account's email. Triggers IAM to send a reset link/code.
52
- - **`auth.resetPassword(request)`** — no auth required. Completes the recovery: pass the emailed reset token plus the new password. IAM owns token validation and password-policy enforcement — surface its response/errors directly rather than pre-validating password rules yourself.
53
- - **`auth.changePassword(request)`** — requires an access token (an authenticated account-security action, not part of the recovery flow). Use this for a signed-in "change my password" settings-page action — current password + new password.
54
-
55
- ```ts
56
- // forgot-password page
57
- await blocksClient.auth.recover({ email });
58
-
59
- // reset-password page (link from the recovery email)
60
- await blocksClient.auth.resetPassword({ code, password: newPassword });
61
-
62
- // signed-in settings page
63
- await blocksClient.auth.changePassword({ oldPassword, newPassword });
64
- ```
65
-
66
- ## Logout — end this session or all sessions
67
-
68
- - **`auth.logout(request = {})`** — Ends the current session; commonly takes `{ refreshToken }` if your app manages a refresh token directly (the typed field on `BlocksLogoutRequest`). If your app relies on the hosted IdP's session cookie instead, an empty `{}` is enough — the SDK always sends the request with `credentials: "include"`.
69
- - **`auth.logoutAll(request = {})`** — "Sign out everywhere" — invalidates every session for the account, not just the current one. Good for a security settings page next to change-password.
70
-
71
- ```ts
72
- async function signOut() {
73
- try {
74
- await blocksClient.auth.logout({ refreshToken });
75
- } finally {
76
- // clear app-owned session state even if the network call fails,
77
- // so the UI never shows a stale signed-in state
78
- clearLocalSession();
79
- navigate("/login");
80
- }
81
- }
82
- ```
83
-
84
- ## Profile bootstrap and self-edit
85
-
86
- - **`iam.me()`** — The current IAM user record: roles, permissions, active organization context, resolved from the access token. This is the right call to bootstrap an app's profile/account page or a permission-gated shell after login — don't reconstruct this from token claims yourself.
87
- - **`iam.updateMe(request)`** — Updates the CURRENT authenticated user's own profile fields (name, etc., per your tenant's IAM contract). The backend resolves the user id from the token — **never** pass another user's id here; that's `iam.users.update(id, request)` in the admin skill, a different call entirely.
88
-
89
- ```ts
90
- const me = await blocksClient.iam.me();
91
- // me.data?.roles / me.data?.permissions -> gate nav items, feature flags, etc.
92
- // (iam.me() wraps the user record in a { data } envelope, not the fields directly)
93
-
94
- await blocksClient.iam.updateMe({ firstName, lastName });
95
- ```
96
-
97
- ## Self-service MFA
98
-
99
- Enrolling, challenging, or turning off MFA for the **signed-in user's own** account, via `blocksClient.mfa.*` (see `mfa-client.ts`'s own docstrings — they call this out as self-service, distinct from `mfa.saveConfig`, which is a tenant/admin policy action, not covered here):
100
-
101
- - **`mfa.totp.setup()`** — Starts authenticator-app enrollment; render IAM's returned secret/QR in your UI.
102
- - **`mfa.totp.verifySetup({ code })`** — Confirms enrollment with the 6-digit code from the authenticator app.
103
- - **`mfa.generate({ mfaType, sendPhoneNumberAsEmailDomain? })`** — Sends an email/SMS OTP challenge; returns an `mfaId` for `resend`/`verify`.
104
- - **`mfa.resend({ mfaId, sendPhoneNumberAsEmailDomain? })`** — Re-sends a pending OTP.
105
- - **`mfa.verify({ mfaId, verificationCode, authType, isFromTokenCall? })`** — Confirms an OTP or step-up challenge; set `isFromTokenCall` when verifying as part of a login/token exchange.
106
- - **`mfa.setMethod({ mfaType })`** — Switches which enrolled method is active.
107
- - **`mfa.disable()`** — Self-service opt-out, where the tenant's policy allows it.
108
- - **`mfa.backupCodes.list()`** / **`.generate()`** / **`.use({ code, userId })`** — View remaining recovery codes, mint a fresh set (treat the response as sensitive, show once), or consume one when the primary method is unavailable.
109
-
110
- ```ts
111
- await blocksClient.mfa.totp.setup();
112
- await blocksClient.mfa.totp.verifySetup({ code });
113
- ```
114
-
115
- The same self-service surface is also reachable from a terminal via `blocks mfa totp setup/verify-setup/enable`, `blocks mfa generate/resend/verify`, `blocks mfa method set`, `blocks mfa disable`, and `blocks mfa backup-codes list/generate/use` (project-scoped, impersonated-user token). **See also:** `blocks-iam-mfa` for the full enrollment/challenge walkthrough — this section only flags that self-service MFA exists and is in this skill's scope.
116
-
117
- ## Signup and login discovery
118
-
119
- - **`auth.signup(request)`** — no auth required. Registers a new account; IAM owns account-creation rules — send its expected payload and render its response/errors directly rather than pre-validating fields yourself.
120
- - **`auth.loginOptions()`** — no auth required. Discovers which login methods the tenant supports; call before rendering the login screen so you only show controls IAM actually accepts.
121
-
122
- ```ts
123
- const options = await blocksClient.auth.loginOptions();
124
- // options -> render enabled login methods (password, social, etc.)
125
-
126
- await blocksClient.auth.signup({ email, password, firstName, lastName });
127
- ```
128
-
129
- ## Signup/invite dedup checks
130
-
131
- Useful inside a signup or invite form before submit — both still send `x-blocks-key` even though they don't require a signed-in user:
132
-
133
- - **`iam.users.emailAvailable(query)`** — no auth required. Returns an availability flag (`isAvailable`/`IsAvailable` — IAM's casing varies, check both) for a candidate email.
134
- - **`iam.users.exists(email)`** — Existence check by email.
135
-
136
- ```ts
137
- const availability = await blocksClient.iam.users.emailAvailable({ email });
138
- if (availability.isAvailable === false || availability.IsAvailable === false) {
139
- // show "email already in use" before the user finishes the form
140
- }
141
- ```
142
-
143
- ## Gotchas
144
-
145
- - **Don't invent payload fields.** Several of these methods (`activate`, `resendActivation`, `validateActivation`, `changePassword`, `recover`, `resetPassword`, `logoutAll`, `updateMe`) take an untyped `Record<string, unknown>` in the SDK — the shape is IAM's contract, not something the client library enforces. Use the well-known fields shown above; confirm anything beyond that against the tenant's actual IAM behavior instead of guessing new field names.
146
- - **`activate`/`validateActivation`/`recover`/`resetPassword` are public (no bearer token)** — the emailed code/token *is* the credential. `changePassword` and `updateMe` require an access token to be configured on the client (via `accessToken` on `createBlocksClient`). `logout`/`logoutAll`/`resendActivation` will attach a bearer token if one is configured, but don't require it.
147
- - **`iam.me()` is not `auth.userInfo()` or `auth.isAuthenticated()`.** `auth.userInfo()`/`isAuthenticated()` (OIDC-style claims, session-cookie aware) belong to the SSO/OIDC login-flow territory. `iam.me()` is the full IAM user record — roles, permissions, org context — wrapped in a `{ data }` envelope (`BlocksMeResponse = BlocksQueryResponse<BlocksUser>`), so read `me.data?.roles` etc., not `me.roles` directly.
148
- - **Always clear local app state after logout, even on failure.** The SDK doesn't clear anything for you; a network error from `logout`/`logoutAll` shouldn't leave the UI showing a signed-in user.
149
- - **`updateMe` never takes a user id.** If you find yourself passing an id, you want the admin `iam.users.update(id, request)` call instead — wrong skill for that.
150
- - **Confirm-password fields are UI-only.** IAM's `activate`/`resetPassword`/`changePassword` contracts want the new password once; matching against a second "confirm" field is validated client-side and never sent.
151
-
152
- ## Example trigger prompts
153
-
154
- - "Activate a new account with the emailed code."
155
- - "Build the /activate page that sets a password from an invite link."
156
- - "The activation link expired — let the user request a new one."
157
- - "Add a forgot-password flow to the login page."
158
- - "Build the reset-password page for the emailed reset link."
159
- - "Let a signed-in user change their password from account settings."
160
- - "Add a logout button."
161
- - "Add a 'sign out of all devices' option."
162
- - "Fetch the current user's roles and permissions after login."
163
- - "Let a user edit their own name on their profile page."
164
- - "Check if an email is already taken before letting someone submit the signup form."
165
- - "Register a new account from the signup page."
166
- - "Show which login methods are enabled before rendering the login screen."
167
- - "Let a signed-in user enroll in authenticator-app MFA."
168
- - "Add a 'turn off MFA' option to account security settings."
169
- - "Let a user view or regenerate their MFA backup codes."
1
+ ---
2
+ name: blocks-iam-account
3
+ description: "Signed-in (or partially-signed-in) user's own SELISE Blocks IAM account actions via @seliseblocks/client — never raw fetch/curl. Covers activation, forgot/reset/change password, logout(-all), profile bootstrap (iam.me/updateMe), self-service MFA, signup, and login-options discovery. Use for activation/password pages, logout buttons, profile bootstrap, signup forms, or letting a user manage their own MFA. The self-service half of IAM — not admin CRUD on other users (blocks-iam-users/blocks-iam-access-control), not hosted-login redirect (blocks-iam-sso-oidc-implementation)."
4
+ ---
5
+
6
+ # Blocks IAM — Account Self-Service
7
+
8
+ Account-lifecycle and account-security actions the signed-in (or not-yet-fully-signed-in) user takes on **their own** account, all through the single `@seliseblocks/client` instance the scaffold gives you — `blocks new web` wires up `createBlocksClient({ apiUrl, xBlocksKey, oidc, accessToken })` once; every call below hangs off that instance's `.auth`, `.iam`, or `.mfa` namespace. **Never** hand-roll `fetch`/`curl` against `api.seliseblocks.com` for these.
9
+
10
+ Source of truth: `auth-client.ts`, `iam-client.ts`, and `mfa-client.ts` in `@seliseblocks/client`. Every method has a What/Why/How docstring in source — this skill surfaces them, it doesn't add new ones.
11
+
12
+ ## Scope: this vs. the other IAM skills
13
+
14
+ - **This skill** — the current user acting on themselves: activate their own invite, reset their own forgotten password, change their own password, log themselves out, read/edit their own profile, sign up, discover login options, enroll/manage their own MFA.
15
+ - **blocks-iam-users / blocks-iam-access-control** — an admin managing *other* users (create, deactivate, grant/revoke access). Different actor, different skill. Don't duplicate that here.
16
+ - **blocks-iam-sso-oidc-implementation** — the hosted-login redirect/callback flow (`auth.idp.initiate`/`redirectToProvider`/`callback`, `oidc.refreshToken`). This skill only covers direct account-lifecycle calls (activate, recover, reset, change-password, logout) that a user takes outside that redirect dance — don't reimplement hosted login here.
17
+ - **blocks-iam-mfa** (not yet written) — the full self-service MFA walkthrough (enrollment UX, challenge flows, backup codes). This skill only notes that `mfa.*` exists and is in scope; go there for depth.
18
+
19
+ ## The SDK never owns your session
20
+
21
+ Every method here just relays IAM's request/response. The SDK **does not** read or write cookies, localStorage, or any token store — your app decides where the access token, refresh token, and "am I logged in" flag live, and passes the access token in via the `accessToken` option (string or async callback) on `createBlocksClient`. After `logout`/`logoutAll`, activation, or a password reset, **you** clear/update that app-owned state; the SDK call alone doesn't do it for you.
22
+
23
+ Request/payload types for most of these methods are intentionally loose (`Record<string, unknown>` passthrough — IAM, not the SDK, defines the exact fields). `BlocksLogoutRequest` is the one exception with a typed hint (`refreshToken?: string`). Where the SDK doesn't pin the shape, confirm exact field names against your tenant's IAM contract rather than guessing — the examples below show the well-known fields, not an exhaustive schema.
24
+
25
+ ## Activation — finishing account setup
26
+
27
+ Three related calls, all under `blocksClient.auth`, all public (no bearer token needed for `activate`/`validateActivation` — the emailed code is the credential):
28
+
29
+ - **`auth.validateActivation(request)`** — no auth required. Check the activation code/state *before* showing the final "set your password" step, so an expired/invalid link fails fast with a clear message instead of after the user fills out the form.
30
+ - **`auth.activate(request)`** — no auth required. Completes setup for a user created/invited in an inactive state: pass the emailed `code` plus the new password (and whatever else your tenant's activation contract needs, e.g. `firstName`/`lastName`) after your UI confirms password === confirm-password client-side (don't send a confirm field — that's a UI-only check).
31
+ - **`auth.resendActivation(request)`** — Send a new code/link when the old one expired. This call attaches the bearer token if one happens to be configured, but works either way — typical callers are not-yet-active, so don't gate this behind requiring a token.
32
+
33
+ ```ts
34
+ // after the user opens /activate?code=... and submits password + confirm
35
+ const state = await blocksClient.auth.validateActivation({ code });
36
+ if (!state.valid) {
37
+ // show "this link expired" + a resend option
38
+ }
39
+
40
+ await blocksClient.auth.activate({
41
+ code,
42
+ password,
43
+ firstName,
44
+ lastName
45
+ });
46
+ // account is now active — route to login / hosted-login (blocks-iam-sso-oidc-implementation)
47
+ ```
48
+
49
+ ## Password — forgot, reset, and authenticated change
50
+
51
+ - **`auth.recover(request)`** — no auth required. Public entry point for "forgot password" — typically just the account's email. Triggers IAM to send a reset link/code.
52
+ - **`auth.resetPassword(request)`** — no auth required. Completes the recovery: pass the emailed reset token plus the new password. IAM owns token validation and password-policy enforcement — surface its response/errors directly rather than pre-validating password rules yourself.
53
+ - **`auth.changePassword(request)`** — requires an access token (an authenticated account-security action, not part of the recovery flow). Use this for a signed-in "change my password" settings-page action — current password + new password.
54
+
55
+ ```ts
56
+ // forgot-password page
57
+ await blocksClient.auth.recover({ email });
58
+
59
+ // reset-password page (link from the recovery email)
60
+ await blocksClient.auth.resetPassword({ code, password: newPassword });
61
+
62
+ // signed-in settings page
63
+ await blocksClient.auth.changePassword({ oldPassword, newPassword });
64
+ ```
65
+
66
+ ## Logout — end this session or all sessions
67
+
68
+ - **`auth.logout(request = {})`** — Ends the current session; commonly takes `{ refreshToken }` if your app manages a refresh token directly (the typed field on `BlocksLogoutRequest`). If your app relies on the hosted IdP's session cookie instead, an empty `{}` is enough — the SDK always sends the request with `credentials: "include"`.
69
+ - **`auth.logoutAll(request = {})`** — "Sign out everywhere" — invalidates every session for the account, not just the current one. Good for a security settings page next to change-password.
70
+
71
+ ```ts
72
+ async function signOut() {
73
+ try {
74
+ await blocksClient.auth.logout({ refreshToken });
75
+ } finally {
76
+ // clear app-owned session state even if the network call fails,
77
+ // so the UI never shows a stale signed-in state
78
+ clearLocalSession();
79
+ navigate("/login");
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## Profile bootstrap and self-edit
85
+
86
+ - **`iam.me()`** — The current IAM user record: roles, permissions, active organization context, resolved from the access token. This is the right call to bootstrap an app's profile/account page or a permission-gated shell after login — don't reconstruct this from token claims yourself.
87
+ - **`iam.updateMe(request)`** — Updates the CURRENT authenticated user's own profile fields (name, etc., per your tenant's IAM contract). The backend resolves the user id from the token — **never** pass another user's id here; that's `iam.users.update(id, request)` in the admin skill, a different call entirely.
88
+
89
+ ```ts
90
+ const me = await blocksClient.iam.me();
91
+ // me.data?.roles / me.data?.permissions -> gate nav items, feature flags, etc.
92
+ // (iam.me() wraps the user record in a { data } envelope, not the fields directly)
93
+
94
+ await blocksClient.iam.updateMe({ firstName, lastName });
95
+ ```
96
+
97
+ ## Self-service MFA
98
+
99
+ Enrolling, challenging, or turning off MFA for the **signed-in user's own** account, via `blocksClient.mfa.*` (see `mfa-client.ts`'s own docstrings — they call this out as self-service, distinct from `mfa.saveConfig`, which is a tenant/admin policy action, not covered here):
100
+
101
+ - **`mfa.totp.setup()`** — Starts authenticator-app enrollment; render IAM's returned secret/QR in your UI.
102
+ - **`mfa.totp.verifySetup({ code })`** — Confirms enrollment with the 6-digit code from the authenticator app.
103
+ - **`mfa.generate({ mfaType, sendPhoneNumberAsEmailDomain? })`** — Sends an email/SMS OTP challenge; returns an `mfaId` for `resend`/`verify`.
104
+ - **`mfa.resend({ mfaId, sendPhoneNumberAsEmailDomain? })`** — Re-sends a pending OTP.
105
+ - **`mfa.verify({ mfaId, verificationCode, authType, isFromTokenCall? })`** — Confirms an OTP or step-up challenge; set `isFromTokenCall` when verifying as part of a login/token exchange.
106
+ - **`mfa.setMethod({ mfaType })`** — Switches which enrolled method is active.
107
+ - **`mfa.disable()`** — Self-service opt-out, where the tenant's policy allows it.
108
+ - **`mfa.backupCodes.list()`** / **`.generate()`** / **`.use({ code, userId })`** — View remaining recovery codes, mint a fresh set (treat the response as sensitive, show once), or consume one when the primary method is unavailable.
109
+
110
+ ```ts
111
+ await blocksClient.mfa.totp.setup();
112
+ await blocksClient.mfa.totp.verifySetup({ code });
113
+ ```
114
+
115
+ The same self-service surface is also reachable from a terminal via `blocks mfa totp setup/verify-setup/enable`, `blocks mfa generate/resend/verify`, `blocks mfa method set`, `blocks mfa disable`, and `blocks mfa backup-codes list/generate/use` (project-scoped, impersonated-user token). **See also:** `blocks-iam-mfa` for the full enrollment/challenge walkthrough — this section only flags that self-service MFA exists and is in this skill's scope.
116
+
117
+ ## Signup and login discovery
118
+
119
+ - **`auth.signup(request)`** — no auth required. Registers a new account; IAM owns account-creation rules — send its expected payload and render its response/errors directly rather than pre-validating fields yourself.
120
+ - **`auth.loginOptions()`** — no auth required. Discovers which login methods the tenant supports; call before rendering the login screen so you only show controls IAM actually accepts.
121
+
122
+ ```ts
123
+ const options = await blocksClient.auth.loginOptions();
124
+ // options -> render enabled login methods (password, social, etc.)
125
+
126
+ await blocksClient.auth.signup({ email, password, firstName, lastName });
127
+ ```
128
+
129
+ ## Signup/invite dedup checks
130
+
131
+ Useful inside a signup or invite form before submit — both still send `x-blocks-key` even though they don't require a signed-in user:
132
+
133
+ - **`iam.users.emailAvailable(query)`** — no auth required. Returns an availability flag (`isAvailable`/`IsAvailable` — IAM's casing varies, check both) for a candidate email.
134
+ - **`iam.users.exists(email)`** — Existence check by email.
135
+
136
+ ```ts
137
+ const availability = await blocksClient.iam.users.emailAvailable({ email });
138
+ if (availability.isAvailable === false || availability.IsAvailable === false) {
139
+ // show "email already in use" before the user finishes the form
140
+ }
141
+ ```
142
+
143
+ ## Gotchas
144
+
145
+ - **Don't invent payload fields.** Several of these methods (`activate`, `resendActivation`, `validateActivation`, `changePassword`, `recover`, `resetPassword`, `logoutAll`, `updateMe`) take an untyped `Record<string, unknown>` in the SDK — the shape is IAM's contract, not something the client library enforces. Use the well-known fields shown above; confirm anything beyond that against the tenant's actual IAM behavior instead of guessing new field names.
146
+ - **`activate`/`validateActivation`/`recover`/`resetPassword` are public (no bearer token)** — the emailed code/token *is* the credential. `changePassword` and `updateMe` require an access token to be configured on the client (via `accessToken` on `createBlocksClient`). `logout`/`logoutAll`/`resendActivation` will attach a bearer token if one is configured, but don't require it.
147
+ - **`iam.me()` is not `auth.userInfo()` or `auth.isAuthenticated()`.** `auth.userInfo()`/`isAuthenticated()` (OIDC-style claims, session-cookie aware) belong to the SSO/OIDC login-flow territory. `iam.me()` is the full IAM user record — roles, permissions, org context — wrapped in a `{ data }` envelope (`BlocksMeResponse = BlocksQueryResponse<BlocksUser>`), so read `me.data?.roles` etc., not `me.roles` directly.
148
+ - **Always clear local app state after logout, even on failure.** The SDK doesn't clear anything for you; a network error from `logout`/`logoutAll` shouldn't leave the UI showing a signed-in user.
149
+ - **`updateMe` never takes a user id.** If you find yourself passing an id, you want the admin `iam.users.update(id, request)` call instead — wrong skill for that.
150
+ - **Confirm-password fields are UI-only.** IAM's `activate`/`resetPassword`/`changePassword` contracts want the new password once; matching against a second "confirm" field is validated client-side and never sent.
151
+
152
+ ## Example trigger prompts
153
+
154
+ - "Activate a new account with the emailed code."
155
+ - "Build the /activate page that sets a password from an invite link."
156
+ - "The activation link expired — let the user request a new one."
157
+ - "Add a forgot-password flow to the login page."
158
+ - "Build the reset-password page for the emailed reset link."
159
+ - "Let a signed-in user change their password from account settings."
160
+ - "Add a logout button."
161
+ - "Add a 'sign out of all devices' option."
162
+ - "Fetch the current user's roles and permissions after login."
163
+ - "Let a user edit their own name on their profile page."
164
+ - "Check if an email is already taken before letting someone submit the signup form."
165
+ - "Register a new account from the signup page."
166
+ - "Show which login methods are enabled before rendering the login screen."
167
+ - "Let a signed-in user enroll in authenticator-app MFA."
168
+ - "Add a 'turn off MFA' option to account security settings."
169
+ - "Let a user view or regenerate their MFA backup codes."
@@ -37,7 +37,7 @@ All of these commands are project-scoped: they need a selected project (`blocks
37
37
  [--dry-run] [--yes]
38
38
  ```
39
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:
40
+ 3. **Verify the auto-created provider before handing off.** `--register-as-identity-provider` creates the provider record for you. Current CLI builds the provider discovery URL by default, matching the portal checkbox behavior, but still check what landed with `blocks auth idp list --json`. If an older provider has `authorizationUrl` 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:
41
41
  ```
42
42
  blocks auth idp update <providerItemId> \
43
43
  --authorization-url "<tenant authorize endpoint>" \
@@ -72,7 +72,7 @@ Never raw `fetch`/`curl` these endpoints to route around the CLI's confirmation/
72
72
  ## Verified footguns
73
73
 
74
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.
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 CLI now sends that value automatically on `oidc-clients save --register-as-identity-provider` and `new web` interactive client creation; use `--external-discovery-endpoint` only to override it for an external provider or non-standard IAM base URL. Older clients created without this value may still have null endpoint fields; check both `authorizationUrl` and `scope` on the provider after registering.
76
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
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
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.
@@ -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?"