@close-by/clay 0.2.21 → 0.2.22

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.
package/docs/api.md CHANGED
@@ -99,6 +99,7 @@ ui.run({
99
99
  | `sessionAbsoluteMs` | `number` | | Optional: clear auth + redirect after this time since hello |
100
100
  | `sessionExpiredPath` | `string` | `'/'` | SPA path after idle/absolute expiry |
101
101
  | `resolveUserId` | `(ctx) => …` | cookie helper when `authSecret` set | Trusted identity on WebSocket hello |
102
+ | `routes` | `Record<path, (req, ctx) => Response>` | | Extra HTTP routes, exact path, any method. `ctx.signIn(userId, location)` / `ctx.signOut(location)` redirect and set/clear the auth cookie. Used for OAuth callbacks — see [Production — single sign-on](./production.md#single-sign-on-oidc) |
102
103
 
103
104
  #### Auth cookie recipe
104
105
 
@@ -118,7 +119,7 @@ ui.establishAuthSession(userId, { path: '/account' });
118
119
  ui.clearAuthSession({ path: '/login' });
119
120
  ```
120
121
 
121
- Password hashing, login rate limits, and `requireAuth` / `requireRole` live in optional `@close-by/clay-auth`. See `/examples/auth`.
122
+ Password hashing, login rate limits, OIDC single sign-on (`createOidcLogin`), and `requireAuth` / `requireRole` live in optional `@close-by/clay-auth`. See `/examples/auth` and [Production — single sign-on](./production.md#single-sign-on-oidc).
122
123
 
123
124
  Returns `ClayServer` with `.start()` / `.stop()` / `.port` (`.start()` is already called by `ui.run`).
124
125
 
@@ -1815,6 +1816,7 @@ onClick: async () => {
1815
1816
  | `ui.updateUrlSearch(patch, opts?)` | `void` | Merge query keys; `null`/`''` deletes |
1816
1817
  | `ui.urlState(defaults, opts?)` | reactive object | `ui.state` + hydrate/sync query string (prefer this) |
1817
1818
  | `ui.openExternal(url)` | `void` | `window.open` in a new tab (`noopener`) |
1819
+ | `ui.redirect(url)` | `void` | Full-page navigation in the current tab (`location.assign`) — for sign-in routes / external pages; use `ui.navigate` for app pages |
1818
1820
  | `ui.theme.set(mode)` | `void` | Push `light` \| `dark` \| `system` to this client |
1819
1821
  | `ui.theme.get()` | `ThemeMode \| null` | Last value set on this session |
1820
1822
  | `ui.theme.palette(name)` | `void` | Switch colour palette for this client (`kiln`, `cobalt`, `ocean`, `forest`, `orchid`, `citrus`, `graphite`, `neon`, `sky`, `navy`, `indigo`, `denim`, `midnight`, or a custom one); remembered across reloads |
@@ -65,6 +65,48 @@ ui.page('/admin', () => {
65
65
 
66
66
  Nav filtering is not authorization. Always guard pages that must not leak data.
67
67
 
68
+ ## Single sign-on (OIDC)
69
+
70
+ `@close-by/clay-auth` signs users in with any OpenID Connect provider (authorization code + PKCE). The provider is only used to sign in: once its ID token is verified, Clay sets its usual signed auth cookie, so everything above (`resolveUserId`, guards, idle expiry) works unchanged and there are no provider tokens to refresh.
71
+
72
+ ```ts
73
+ import { createOidcLogin } from '@close-by/clay-auth';
74
+
75
+ const sso = createOidcLogin({
76
+ issuer: process.env.OIDC_ISSUER!, // discovery: ${issuer}/.well-known/openid-configuration
77
+ clientId: process.env.OIDC_CLIENT_ID!,
78
+ clientSecret: process.env.OIDC_CLIENT_SECRET, // only for confidential clients
79
+ redirectUri: 'https://hub.example.com/auth/callback',
80
+ async onSignIn(claims) {
81
+ // Upsert your user; return the Clay user id, or null to refuse.
82
+ await users.upsert(claims.sub, { email: claims.email });
83
+ return claims.sub;
84
+ },
85
+ });
86
+
87
+ ui.run({
88
+ authSecret: process.env.CLAY_AUTH_SECRET!,
89
+ routes: sso.routes({ afterLogin: '/', afterLogout: '/login', errorPath: '/login' }),
90
+ });
91
+
92
+ // Sign-in button (full page load — the route redirects to the provider):
93
+ ui.button('Sign in', { onClick: () => ui.redirect('/auth/login?returnTo=/reports') });
94
+ // Sign-out (clears the cookie, then signs out at the provider):
95
+ ui.button('Sign out', { onClick: () => ui.redirect('/auth/logout') });
96
+ ```
97
+
98
+ Routes: `GET /auth/login` (accepts `?returnTo=/path`, same-origin paths only) → provider → `GET /auth/callback` → cookie set → `returnTo`. `GET /auth/logout` clears the cookie and goes to the provider's sign-out. Failures land on `errorPath?auth_error=<state|expired|invalid_token|denied|provider|…>`.
99
+
100
+ What is checked: `state` (one-shot HttpOnly cookie scoped to the callback path, 10 min), PKCE `S256`, ID token RS256 signature against the provider's JWKS (re-fetched on unknown `kid`), `iss`, `aud`, `exp`/`iat` (60 s skew), and `nonce`.
101
+
102
+ **Provider setup:** register an app/client that allows the authorization code flow with scopes `openid email profile`, callback URL `https://<host>/auth/callback`, and sign-out return URL `https://<host><afterLogout>`. Map roles from whatever claim your provider puts groups in, inside `onSignIn`.
103
+
104
+ **Sign-out:** uses the discovery `end_session_endpoint` when the provider has one. Otherwise pass `logoutUrl: (returnTo) => '…'` with the provider's sign-out URL; without either, sign-out only clears Clay's cookie.
105
+
106
+ **Behind an authenticating proxy:** if a reverse proxy or load balancer already signs users in and forwards a signed identity header, skip the routes and read (and verify) that header in `resolveUserId`.
107
+
108
+ The demo enables a "Sign in with SSO" button on `/examples/auth/login` when `OIDC_ISSUER` and `OIDC_CLIENT_ID` are set (see `apps/demo/src/examples/_oidc.ts` for the optional variables).
109
+
68
110
  ## Rate limits & abuse
69
111
 
70
112
  Clay does not ship a global rate limiter. For handlers that trigger expensive work (ClickHouse queries, re-dispatch, file export):
package/docs/protocol.md CHANGED
@@ -180,6 +180,14 @@ Client sets `location.hash` via `history.replaceState` (`hash` has no leading `#
180
180
 
181
181
  Client sets `location.search` via `history.replaceState` or `pushState` (`mode`, default `replace`). `search` has no leading `?`; empty string clears. Pathname and hash are preserved. Hydrated the other way on `hello.search` and re-synced in place on `urlchange`. Prefer `ui.setUrlSearch` / `ui.updateUrlSearch` / `ui.getUrlSearch`. See [URL search](./url-search.md).
182
182
 
183
+ ### `redirect`
184
+
185
+ ```json
186
+ { "op": "redirect", "url": "/auth/login?returnTo=/reports" }
187
+ ```
188
+
189
+ Client runs `window.location.assign(url)` — a full page load that leaves the SPA. Prefer `ui.redirect`.
190
+
183
191
  ### `openExternal`
184
192
 
185
193
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@close-by/clay",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "NiceGUI-like ui.* facade for Clay apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,10 +37,10 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@close-by/clay-compiler": "0.2.21",
41
- "@close-by/clay-core": "0.2.21",
42
- "@close-by/clay-components": "0.2.21",
43
- "@close-by/clay-server": "0.2.21",
40
+ "@close-by/clay-compiler": "0.2.22",
41
+ "@close-by/clay-core": "0.2.22",
42
+ "@close-by/clay-components": "0.2.22",
43
+ "@close-by/clay-server": "0.2.22",
44
44
  "@tailwindcss/cli": "^4.3.3",
45
45
  "tailwindcss": "^4.3.3"
46
46
  }
package/src/index.ts CHANGED
@@ -464,6 +464,7 @@ import {
464
464
  setUrlSearch as setUrlSearchCore,
465
465
  updateUrlSearch as updateUrlSearchCore,
466
466
  openExternal as openExternalCore,
467
+ redirect as redirectCore,
467
468
  scroll as scrollCore,
468
469
  timer as timerCore,
469
470
  storage as storageCore,
@@ -1681,6 +1682,14 @@ export function updateUrlSearch(
1681
1682
  updateUrlSearchCore(patch, opts);
1682
1683
  }
1683
1684
 
1685
+ /**
1686
+ * Full-page navigation in the current tab — for external sign-in pages or
1687
+ * server routes (`/auth/login`). Use `ui.navigate` for app pages.
1688
+ */
1689
+ export function redirect(url: string): void {
1690
+ redirectCore(url);
1691
+ }
1692
+
1684
1693
  /** Open a URL in a new tab. Prefer over `runJavaScript('window.open…')`. */
1685
1694
  export function openExternal(url: string): void {
1686
1695
  openExternalCore(url);
@@ -2025,6 +2034,7 @@ export const ui = {
2025
2034
  setUrlSearch,
2026
2035
  updateUrlSearch,
2027
2036
  openExternal,
2037
+ redirect,
2028
2038
  runJavaScript,
2029
2039
  scroll,
2030
2040
  timer,