From cd28cec8954bc8a89ef8fdf4c21ea8bc25e17f8c Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 21 Oct 2026 14:06:14 +0530 Subject: [PATCH] Add the remaining playgrounds, fix the SDK issues they surfaced, and move the Better Auth sample Add playgrounds for Vue, Next.js, Nuxt, Browser and Node.js next to React and Express, so every JavaScript SDK can be exercised from source. Every playground header now shows the ThunderID logo next to the playground's technology instead of an SDK switcher. Fix the SDK issues the playgrounds surfaced: generate the authorization state from random bytes and check it on the code exchange, validate the discovered issuer, load discovery on demand, report OAuth error responses readably, fail clearly when there is no session, refresh expired sessions in protect(), stop handleSignIn() from calling next() after redirecting, count session expiry in seconds, keep every on() subscriber and fire the HTTP request hooks consistently, fix the route matcher's (.*) patterns, the Vue route guard context, Nuxt's scope and organization checks, scope merging and dependency pre-bundling, field label associations in React, unhandled sign-in errors, and listener and typing issues across the packages. The Better Auth Next.js integration sample now lives in thunder-id/samples as integrations/better-auth-nextjs and depends on the published @thunderid/better-auth. With it gone, samples/ is empty, so the workspace glob, the ESLint copyright exemption, the PR builder's samples filters and the pack-sample action go too. Update vite and launch-editor for GHSA-587h-7wmj-h5pm, which fails the Security Audit job: vite 6.4.4 and 7.3.7 and launch-editor 2.14.2 through a refresh, and vite 8 pinned to 8.2.4, which pnpm otherwise keeps at 8.2.1 for vitest's peer range. Refs thunder-id/thunderid#5904 Refs thunder-id/thunderid#5919 Signed-off-by: Brion --- .github/actions/pack-sample/action.yml | 57 - .github/workflows/pr-builder.yml | 4 +- .github/workflows/release.yml | 11 +- AGENTS.md | 11 +- README.md | 5 + eslint.config.js | 6 - .../browser/src/ThunderIDBrowserClient.ts | 163 +- .../__tests__/ThunderIDBrowserClient.test.ts | 173 ++ packages/browser/src/index.ts | 2 +- packages/browser/src/models/BrowserConfig.ts | 3 +- .../theme/__tests__/themeDetection.test.ts | 62 + packages/browser/src/theme/themeDetection.ts | 24 +- .../express/src/ThunderIDExpressClient.ts | 4 +- .../express/src/__tests__/middleware.test.ts | 105 ++ packages/express/src/middleware/protect.ts | 13 +- packages/javascript/rolldown.config.js | 5 +- packages/javascript/src/DefaultCrypto.ts | 14 +- packages/javascript/src/IsomorphicCrypto.ts | 11 + packages/javascript/src/StorageManager.ts | 6 + .../src/ThunderIDJavaScriptClient.ts | 156 +- .../src/__tests__/StorageManager.test.ts | 45 + .../ThunderIDJavaScriptClient.session.test.ts | 339 ++++ .../src/constants/OIDCRequestConstants.ts | 15 + packages/javascript/src/models/client.ts | 4 +- .../src/utils/AuthenticationHelper.ts | 9 +- .../getAuthorizeRequestUrlParams.test.ts | 15 + .../__tests__/getOAuthErrorMessage.test.ts | 34 + packages/javascript/src/utils/base64Encode.ts | 7 +- .../src/utils/getAuthorizeRequestUrlParams.ts | 9 +- .../src/utils/getOAuthErrorMessage.ts | 41 + packages/javascript/tsconfig.json | 4 + packages/nextjs/src/ThunderIDNextClient.ts | 2 +- .../__tests__/fetchFlowMetaFromServer.test.ts | 48 + .../contexts/ThunderID/ThunderIDProvider.tsx | 12 +- .../src/client/fetchFlowMetaFromServer.ts | 29 + .../nextjs/src/server/ThunderIDProvider.tsx | 2 - .../__tests__/getFlowMetaAction.test.ts | 79 + .../src/server/actions/getFlowMetaAction.ts | 52 +- .../__tests__/createRouteMatcher.test.ts | 67 + .../src/server/proxy/createRouteMatcher.ts | 40 +- packages/nextjs/src/utils/SessionManager.ts | 6 +- packages/nextjs/tsconfig.json | 3 + packages/node/rolldown.config.js | 5 +- packages/node/src/ThunderIDNodeClient.ts | 64 +- .../ThunderIDNodeClient.isSignedIn.test.ts | 117 ++ packages/node/src/utils/NodeCryptoUtils.ts | 23 +- packages/node/src/utils/SessionUtils.ts | 5 +- .../src/utils/__tests__/SessionUtils.test.ts | 25 + packages/node/tsconfig.json | 3 + packages/nuxt/src/module.ts | 33 +- .../src/runtime/components/auth/SignIn.ts | 15 +- .../middleware/defineThunderIDMiddleware.ts | 6 +- .../nuxt/src/runtime/plugins/thunderid.ts | 2 + .../src/runtime/server/ThunderIDNuxtClient.ts | 7 +- .../server/routes/auth/session/session.get.ts | 10 +- packages/nuxt/src/runtime/types.ts | 11 +- .../unit/define-thunderid-middleware.test.ts | 44 +- packages/nuxt/tests/unit/module.test.ts | 111 ++ packages/nuxt/tests/unit/sign-in.test.ts | 93 + .../presentation/auth/SignIn/SignIn.tsx | 3 +- .../auth/SignIn/__tests__/SignIn.test.tsx | 76 + .../primitives/AffixedField/AffixedField.tsx | 12 +- .../primitives/Checkbox/Checkbox.tsx | 11 +- .../primitives/DatePicker/DatePicker.tsx | 11 +- .../primitives/FormControl/FormControl.tsx | 6 + .../primitives/MultiInput/MultiInput.tsx | 12 +- .../primitives/OtpField/OtpField.tsx | 15 +- .../components/primitives/Select/Select.tsx | 12 +- .../primitives/TextField/TextField.tsx | 12 +- .../TextField/__tests__/TextField.test.tsx | 64 + .../components/primitives/Toggle/Toggle.tsx | 10 +- .../primitives/__tests__/fieldLabels.test.tsx | 79 + .../src/contexts/Theme/ThemeProvider.tsx | 4 +- .../Theme/__tests__/ThemeProvider.test.tsx | 40 + .../contexts/ThunderID/ThunderIDContext.ts | 8 +- .../contexts/ThunderID/ThunderIDProvider.tsx | 16 +- .../client/ThunderIDVueClient.test.ts | 50 + .../src/__tests__/components/sign-in.test.ts | 48 +- .../__tests__/providers/ThemeProvider.test.ts | 49 + .../vue/src/__tests__/router/guard.test.ts | 85 + .../vue/src/components/auth/sign-in/SignIn.ts | 2 +- packages/vue/src/index.ts | 2 +- packages/vue/src/providers/ThemeProvider.ts | 7 +- .../vue/src/providers/ThunderIDProvider.ts | 25 +- packages/vue/src/router/guard.ts | 2 +- packages/vue/src/theme/themeDetection.ts | 1 + playgrounds/browser/.env.example | 18 + playgrounds/browser/.gitignore | 5 + playgrounds/browser/README.md | 73 + playgrounds/browser/eslint.config.js | 11 + playgrounds/browser/index.html | 18 + playgrounds/browser/package.json | 29 + playgrounds/browser/prettier.config.js | 6 + playgrounds/browser/public/favicon.svg | 1 + playgrounds/browser/public/thunderid-logo.svg | 14 + .../browser/src/components/eventLog.ts | 46 + playgrounds/browser/src/components/icons.ts | 85 + .../browser/src/components/sessionCard.ts | 185 ++ playgrounds/browser/src/components/topbar.ts | 73 + playgrounds/browser/src/config.ts | 37 + playgrounds/browser/src/lib/client.ts | 194 ++ playgrounds/browser/src/lib/dom.ts | 68 + playgrounds/browser/src/lib/events.ts | 63 + playgrounds/browser/src/lib/health.ts | 44 + playgrounds/browser/src/lib/inspect.ts | 31 + playgrounds/browser/src/lib/jwt.ts | 47 + playgrounds/browser/src/lib/router.ts | 15 + playgrounds/browser/src/lib/session.ts | 60 + playgrounds/browser/src/lib/store.ts | 55 + playgrounds/browser/src/lib/user.ts | 46 + playgrounds/browser/src/main.ts | 40 + playgrounds/browser/src/pages/capabilities.ts | 511 ++++++ playgrounds/browser/src/pages/helpers/a11y.ts | 235 +++ .../browser/src/pages/helpers/explorer.ts | 518 ++++++ .../browser/src/pages/helpers/stories.ts | 592 ++++++ .../browser/src/pages/helpers/types.ts | 46 + playgrounds/browser/src/pages/overview.ts | 352 ++++ playgrounds/browser/src/pages/tokens.ts | 219 +++ playgrounds/browser/src/styles.css | 1435 +++++++++++++++ playgrounds/browser/src/vite-env.d.ts | 4 + playgrounds/browser/tsconfig.json | 20 + playgrounds/browser/vite.config.ts | 15 + playgrounds/express/public/app.js | 2 - playgrounds/express/public/index.html | 19 +- playgrounds/express/public/styles.css | 33 +- playgrounds/express/public/thunderid-logo.svg | 14 + playgrounds/express/src/catalog.ts | 6 +- playgrounds/express/src/server.ts | 3 +- playgrounds/nextjs/.env.example | 31 + playgrounds/nextjs/.gitignore | 7 + playgrounds/nextjs/.prettierignore | 2 + playgrounds/nextjs/README.md | 94 + playgrounds/nextjs/eslint.config.js | 19 + playgrounds/nextjs/next.config.ts | 13 + playgrounds/nextjs/package.json | 35 + playgrounds/nextjs/prettier.config.js | 6 + playgrounds/nextjs/public/favicon.svg | 1 + playgrounds/nextjs/public/thunderid-logo.svg | 14 + playgrounds/nextjs/src/app/actions.ts | 41 + .../app/api/auth/backchannel-logout/route.ts | 8 + .../src/app/api/protected/profile/route.ts | 26 + .../nextjs/src/app/api/session/route.ts | 23 + .../src/app/components/[[...slug]]/page.tsx | 13 + playgrounds/nextjs/src/app/globals.css | 1526 ++++++++++++++++ playgrounds/nextjs/src/app/layout.tsx | 43 + playgrounds/nextjs/src/app/page.tsx | 9 + playgrounds/nextjs/src/app/protected/page.tsx | 43 + playgrounds/nextjs/src/app/server/page.tsx | 114 ++ playgrounds/nextjs/src/app/signin/page.tsx | 34 + playgrounds/nextjs/src/app/tokens/page.tsx | 9 + .../nextjs/src/components/ErrorBoundary.tsx | 47 + .../nextjs/src/components/EventLog.tsx | 38 + playgrounds/nextjs/src/components/Icons.tsx | 166 ++ .../nextjs/src/components/SdkObserver.tsx | 65 + .../nextjs/src/components/SessionCard.tsx | 159 ++ playgrounds/nextjs/src/components/TopBar.tsx | 70 + playgrounds/nextjs/src/components/Trace.tsx | 30 + playgrounds/nextjs/src/explorer/Explorer.tsx | 204 +++ playgrounds/nextjs/src/explorer/Narrow.tsx | 11 + playgrounds/nextjs/src/explorer/Workbench.tsx | 298 +++ playgrounds/nextjs/src/explorer/a11y.ts | 235 +++ playgrounds/nextjs/src/explorer/stories.tsx | 564 ++++++ playgrounds/nextjs/src/explorer/types.ts | 43 + playgrounds/nextjs/src/lib/events.ts | 81 + playgrounds/nextjs/src/lib/health.ts | 56 + playgrounds/nextjs/src/lib/jwt.ts | 47 + playgrounds/nextjs/src/lib/sdk.ts | 39 + playgrounds/nextjs/src/lib/serverConfig.ts | 41 + playgrounds/nextjs/src/lib/serverSession.ts | 102 ++ playgrounds/nextjs/src/lib/session.ts | 59 + playgrounds/nextjs/src/lib/user.ts | 38 + playgrounds/nextjs/src/middleware.ts | 59 + playgrounds/nextjs/src/overview/Overview.tsx | 367 ++++ playgrounds/nextjs/src/overview/Tokens.tsx | 219 +++ .../nextjs/src/overview/capabilities.ts | 539 ++++++ playgrounds/nextjs/src/server/ApiExplorer.tsx | 326 ++++ playgrounds/nextjs/src/server/endpoints.ts | 128 ++ playgrounds/nextjs/tsconfig.json | 24 + playgrounds/nextjs/turbo.json | 9 + playgrounds/node/.env.example | 32 + playgrounds/node/.gitignore | 4 + playgrounds/node/.prettierignore | 4 + playgrounds/node/README.md | 72 + playgrounds/node/eslint.config.js | 39 + playgrounds/node/package.json | 28 + playgrounds/node/prettier.config.js | 6 + playgrounds/node/public/app.js | 484 +++++ playgrounds/node/public/favicon.svg | 1 + playgrounds/node/public/index.html | 179 ++ playgrounds/node/public/styles.css | 1022 +++++++++++ playgrounds/node/public/thunderid-logo.svg | 14 + playgrounds/node/src/catalog.ts | 429 +++++ playgrounds/node/src/client.ts | 36 + playgrounds/node/src/config.ts | 39 + playgrounds/node/src/explorer.ts | 263 +++ playgrounds/node/src/operations.ts | 215 +++ playgrounds/node/src/server.ts | 164 ++ playgrounds/node/src/trace.ts | 310 ++++ playgrounds/node/tsconfig.eslint.json | 8 + playgrounds/node/tsconfig.json | 22 + playgrounds/node/turbo.json | 9 + playgrounds/nuxt/.env.example | 28 + playgrounds/nuxt/.gitignore | 7 + playgrounds/nuxt/.prettierignore | 4 + playgrounds/nuxt/README.md | 82 + playgrounds/nuxt/app.vue | 25 + playgrounds/nuxt/assets/styles.css | 1596 +++++++++++++++++ playgrounds/nuxt/components/ClaimsTable.vue | 56 + .../nuxt/components/ComposableCard.vue | 27 + playgrounds/nuxt/components/EventLog.vue | 23 + playgrounds/nuxt/components/NuxtLogo.vue | 15 + playgrounds/nuxt/components/PgIcon.vue | 106 ++ .../nuxt/components/ProtectedNotice.vue | 26 + playgrounds/nuxt/components/SdkObserver.vue | 53 + playgrounds/nuxt/components/SessionCard.vue | 170 ++ playgrounds/nuxt/components/TopBar.vue | 59 + .../nuxt/components/explorer/ControlInput.vue | 49 + .../nuxt/components/explorer/StoryFrame.vue | 24 + .../nuxt/components/explorer/Workbench.vue | 199 ++ playgrounds/nuxt/eslint.config.js | 27 + playgrounds/nuxt/lib/a11y.ts | 235 +++ playgrounds/nuxt/lib/capabilities.ts | 574 ++++++ playgrounds/nuxt/lib/events.ts | 79 + playgrounds/nuxt/lib/health.ts | 35 + playgrounds/nuxt/lib/inspect.ts | 34 + playgrounds/nuxt/lib/jwt.ts | 47 + playgrounds/nuxt/lib/serverRoutes.ts | 254 +++ playgrounds/nuxt/lib/session.ts | 41 + playgrounds/nuxt/lib/stories.ts | 918 ++++++++++ playgrounds/nuxt/lib/storyTypes.ts | 45 + playgrounds/nuxt/lib/storyWidgets.ts | 136 ++ playgrounds/nuxt/lib/types.ts | 49 + playgrounds/nuxt/lib/user.ts | 38 + playgrounds/nuxt/nuxt.config.ts | 38 + playgrounds/nuxt/package.json | 33 + .../nuxt/pages/components/[...slug].vue | 192 ++ playgrounds/nuxt/pages/composables.vue | 170 ++ playgrounds/nuxt/pages/index.vue | 318 ++++ playgrounds/nuxt/pages/protected/index.vue | 20 + playgrounds/nuxt/pages/protected/scopes.vue | 19 + playgrounds/nuxt/pages/server.vue | 440 +++++ playgrounds/nuxt/prettier.config.js | 6 + playgrounds/nuxt/public/favicon.svg | 1 + playgrounds/nuxt/public/thunderid-logo.svg | 14 + .../nuxt/server/api/playground/health.get.ts | 37 + .../server/api/playground/protected.get.ts | 22 + .../nuxt/server/api/playground/session.get.ts | 59 + .../nuxt/server/api/playground/source.get.ts | 33 + .../nuxt/server/api/playground/token.get.ts | 30 + playgrounds/nuxt/server/lib/claims.ts | 18 + playgrounds/nuxt/server/lib/trace.ts | 64 + .../nuxt/server/middleware/playground.ts | 63 + playgrounds/nuxt/server/tsconfig.json | 3 + playgrounds/nuxt/tsconfig.json | 3 + playgrounds/nuxt/turbo.json | 9 + playgrounds/react/.env.example | 2 - playgrounds/react/README.md | 20 +- playgrounds/react/package.json | 2 +- playgrounds/react/public/thunderid-logo.svg | 14 + playgrounds/react/src/components/Icons.tsx | 28 +- playgrounds/react/src/components/TopBar.tsx | 41 +- playgrounds/react/src/config.ts | 2 - playgrounds/react/src/lib/sdk.ts | 10 - playgrounds/react/src/lib/session.ts | 13 +- playgrounds/react/src/pages/Overview.tsx | 3 - playgrounds/react/src/pages/capabilities.ts | 11 +- playgrounds/react/src/styles.css | 43 +- playgrounds/vue/.env.example | 20 + playgrounds/vue/.gitignore | 5 + playgrounds/vue/README.md | 70 + playgrounds/vue/eslint.config.js | 15 + playgrounds/vue/index.html | 18 + playgrounds/vue/package.json | 33 + playgrounds/vue/prettier.config.js | 6 + playgrounds/vue/public/favicon.svg | 1 + playgrounds/vue/public/thunderid-logo.svg | 14 + playgrounds/vue/src/App.vue | 22 + playgrounds/vue/src/Shell.vue | 31 + .../vue/src/components/ClaimsTable.vue | 53 + .../vue/src/components/ComposableCard.vue | 24 + playgrounds/vue/src/components/EventLog.vue | 23 + .../vue/src/components/SessionCard.vue | 156 ++ playgrounds/vue/src/components/TokenCard.vue | 69 + playgrounds/vue/src/components/TopBar.vue | 63 + playgrounds/vue/src/components/icons.ts | 143 ++ playgrounds/vue/src/config.ts | 37 + playgrounds/vue/src/lib/events.ts | 66 + playgrounds/vue/src/lib/health.ts | 52 + playgrounds/vue/src/lib/inspect.ts | 35 + playgrounds/vue/src/lib/jwt.ts | 47 + playgrounds/vue/src/lib/observer.ts | 66 + playgrounds/vue/src/lib/router.ts | 23 + playgrounds/vue/src/lib/sdk.ts | 19 + playgrounds/vue/src/lib/session.ts | 100 ++ playgrounds/vue/src/lib/user.ts | 38 + playgrounds/vue/src/main.ts | 9 + playgrounds/vue/src/pages/Composables.vue | 234 +++ playgrounds/vue/src/pages/Overview.vue | 322 ++++ playgrounds/vue/src/pages/Tokens.vue | 81 + playgrounds/vue/src/pages/capabilities.ts | 513 ++++++ .../vue/src/pages/explorer/ControlInput.vue | 49 + .../vue/src/pages/explorer/Explorer.vue | 179 ++ .../vue/src/pages/explorer/StoryHost.ts | 70 + .../vue/src/pages/explorer/Workbench.vue | 202 +++ playgrounds/vue/src/pages/explorer/a11y.ts | 235 +++ playgrounds/vue/src/pages/explorer/code.ts | 25 + playgrounds/vue/src/pages/explorer/stories.ts | 1236 +++++++++++++ playgrounds/vue/src/pages/explorer/types.ts | 52 + playgrounds/vue/src/pages/explorer/widgets.ts | 46 + playgrounds/vue/src/styles.css | 1249 +++++++++++++ playgrounds/vue/src/vite-env.d.ts | 12 + playgrounds/vue/tsconfig.json | 21 + playgrounds/vue/vite.config.ts | 17 + pnpm-lock.yaml | 1020 ++++++++--- pnpm-workspace.yaml | 13 +- .../better-auth/nextjs/.env.example | 15 - .../better-auth/nextjs/.stackblitzrc | 4 - .../integrations/better-auth/nextjs/README.md | 69 - .../nextjs/app/api/auth/[...all]/route.ts | 6 - .../nextjs/app/components/ConfigNotice.tsx | 114 -- .../nextjs/app/components/HeroCtas.tsx | 20 - .../better-auth/nextjs/app/components/Nav.tsx | 64 - .../nextjs/app/components/ThunderMark.tsx | 36 - .../app/components/icons/BetterAuthLogo.tsx | 11 - .../better-auth/nextjs/app/globals.css | 672 ------- .../better-auth/nextjs/app/layout.tsx | 37 - .../better-auth/nextjs/app/not-found.tsx | 13 - .../better-auth/nextjs/app/page.tsx | 246 --- .../better-auth/nextjs/lib/auth-client.ts | 5 - .../better-auth/nextjs/lib/auth.ts | 41 - .../better-auth/nextjs/next-env.d.ts | 6 - .../better-auth/nextjs/next.config.ts | 5 - .../better-auth/nextjs/package.json | 24 - .../nextjs/scripts/prepare-dev.cjs | 36 - .../better-auth/nextjs/tsconfig.json | 21 - 335 files changed, 30704 insertions(+), 2193 deletions(-) delete mode 100644 .github/actions/pack-sample/action.yml create mode 100644 packages/browser/src/__tests__/ThunderIDBrowserClient.test.ts create mode 100644 packages/browser/src/theme/__tests__/themeDetection.test.ts create mode 100644 packages/express/src/__tests__/middleware.test.ts create mode 100644 packages/javascript/src/__tests__/StorageManager.test.ts create mode 100644 packages/javascript/src/__tests__/ThunderIDJavaScriptClient.session.test.ts create mode 100644 packages/javascript/src/utils/__tests__/getOAuthErrorMessage.test.ts create mode 100644 packages/javascript/src/utils/getOAuthErrorMessage.ts create mode 100644 packages/nextjs/src/client/__tests__/fetchFlowMetaFromServer.test.ts create mode 100644 packages/nextjs/src/client/fetchFlowMetaFromServer.ts create mode 100644 packages/nextjs/src/server/actions/__tests__/getFlowMetaAction.test.ts create mode 100644 packages/nextjs/src/server/proxy/__tests__/createRouteMatcher.test.ts create mode 100644 packages/node/src/__tests__/ThunderIDNodeClient.isSignedIn.test.ts create mode 100644 packages/node/src/utils/__tests__/SessionUtils.test.ts create mode 100644 packages/nuxt/tests/unit/module.test.ts create mode 100644 packages/nuxt/tests/unit/sign-in.test.ts create mode 100644 packages/react/src/components/presentation/auth/SignIn/__tests__/SignIn.test.tsx create mode 100644 packages/react/src/components/primitives/__tests__/fieldLabels.test.tsx create mode 100644 packages/react/src/contexts/Theme/__tests__/ThemeProvider.test.tsx create mode 100644 packages/vue/src/__tests__/client/ThunderIDVueClient.test.ts create mode 100644 packages/vue/src/__tests__/providers/ThemeProvider.test.ts create mode 100644 packages/vue/src/__tests__/router/guard.test.ts create mode 100644 playgrounds/browser/.env.example create mode 100644 playgrounds/browser/.gitignore create mode 100644 playgrounds/browser/README.md create mode 100644 playgrounds/browser/eslint.config.js create mode 100644 playgrounds/browser/index.html create mode 100644 playgrounds/browser/package.json create mode 100644 playgrounds/browser/prettier.config.js create mode 100644 playgrounds/browser/public/favicon.svg create mode 100644 playgrounds/browser/public/thunderid-logo.svg create mode 100644 playgrounds/browser/src/components/eventLog.ts create mode 100644 playgrounds/browser/src/components/icons.ts create mode 100644 playgrounds/browser/src/components/sessionCard.ts create mode 100644 playgrounds/browser/src/components/topbar.ts create mode 100644 playgrounds/browser/src/config.ts create mode 100644 playgrounds/browser/src/lib/client.ts create mode 100644 playgrounds/browser/src/lib/dom.ts create mode 100644 playgrounds/browser/src/lib/events.ts create mode 100644 playgrounds/browser/src/lib/health.ts create mode 100644 playgrounds/browser/src/lib/inspect.ts create mode 100644 playgrounds/browser/src/lib/jwt.ts create mode 100644 playgrounds/browser/src/lib/router.ts create mode 100644 playgrounds/browser/src/lib/session.ts create mode 100644 playgrounds/browser/src/lib/store.ts create mode 100644 playgrounds/browser/src/lib/user.ts create mode 100644 playgrounds/browser/src/main.ts create mode 100644 playgrounds/browser/src/pages/capabilities.ts create mode 100644 playgrounds/browser/src/pages/helpers/a11y.ts create mode 100644 playgrounds/browser/src/pages/helpers/explorer.ts create mode 100644 playgrounds/browser/src/pages/helpers/stories.ts create mode 100644 playgrounds/browser/src/pages/helpers/types.ts create mode 100644 playgrounds/browser/src/pages/overview.ts create mode 100644 playgrounds/browser/src/pages/tokens.ts create mode 100644 playgrounds/browser/src/styles.css create mode 100644 playgrounds/browser/src/vite-env.d.ts create mode 100644 playgrounds/browser/tsconfig.json create mode 100644 playgrounds/browser/vite.config.ts create mode 100644 playgrounds/express/public/thunderid-logo.svg create mode 100644 playgrounds/nextjs/.env.example create mode 100644 playgrounds/nextjs/.gitignore create mode 100644 playgrounds/nextjs/.prettierignore create mode 100644 playgrounds/nextjs/README.md create mode 100644 playgrounds/nextjs/eslint.config.js create mode 100644 playgrounds/nextjs/next.config.ts create mode 100644 playgrounds/nextjs/package.json create mode 100644 playgrounds/nextjs/prettier.config.js create mode 100644 playgrounds/nextjs/public/favicon.svg create mode 100644 playgrounds/nextjs/public/thunderid-logo.svg create mode 100644 playgrounds/nextjs/src/app/actions.ts create mode 100644 playgrounds/nextjs/src/app/api/auth/backchannel-logout/route.ts create mode 100644 playgrounds/nextjs/src/app/api/protected/profile/route.ts create mode 100644 playgrounds/nextjs/src/app/api/session/route.ts create mode 100644 playgrounds/nextjs/src/app/components/[[...slug]]/page.tsx create mode 100644 playgrounds/nextjs/src/app/globals.css create mode 100644 playgrounds/nextjs/src/app/layout.tsx create mode 100644 playgrounds/nextjs/src/app/page.tsx create mode 100644 playgrounds/nextjs/src/app/protected/page.tsx create mode 100644 playgrounds/nextjs/src/app/server/page.tsx create mode 100644 playgrounds/nextjs/src/app/signin/page.tsx create mode 100644 playgrounds/nextjs/src/app/tokens/page.tsx create mode 100644 playgrounds/nextjs/src/components/ErrorBoundary.tsx create mode 100644 playgrounds/nextjs/src/components/EventLog.tsx create mode 100644 playgrounds/nextjs/src/components/Icons.tsx create mode 100644 playgrounds/nextjs/src/components/SdkObserver.tsx create mode 100644 playgrounds/nextjs/src/components/SessionCard.tsx create mode 100644 playgrounds/nextjs/src/components/TopBar.tsx create mode 100644 playgrounds/nextjs/src/components/Trace.tsx create mode 100644 playgrounds/nextjs/src/explorer/Explorer.tsx create mode 100644 playgrounds/nextjs/src/explorer/Narrow.tsx create mode 100644 playgrounds/nextjs/src/explorer/Workbench.tsx create mode 100644 playgrounds/nextjs/src/explorer/a11y.ts create mode 100644 playgrounds/nextjs/src/explorer/stories.tsx create mode 100644 playgrounds/nextjs/src/explorer/types.ts create mode 100644 playgrounds/nextjs/src/lib/events.ts create mode 100644 playgrounds/nextjs/src/lib/health.ts create mode 100644 playgrounds/nextjs/src/lib/jwt.ts create mode 100644 playgrounds/nextjs/src/lib/sdk.ts create mode 100644 playgrounds/nextjs/src/lib/serverConfig.ts create mode 100644 playgrounds/nextjs/src/lib/serverSession.ts create mode 100644 playgrounds/nextjs/src/lib/session.ts create mode 100644 playgrounds/nextjs/src/lib/user.ts create mode 100644 playgrounds/nextjs/src/middleware.ts create mode 100644 playgrounds/nextjs/src/overview/Overview.tsx create mode 100644 playgrounds/nextjs/src/overview/Tokens.tsx create mode 100644 playgrounds/nextjs/src/overview/capabilities.ts create mode 100644 playgrounds/nextjs/src/server/ApiExplorer.tsx create mode 100644 playgrounds/nextjs/src/server/endpoints.ts create mode 100644 playgrounds/nextjs/tsconfig.json create mode 100644 playgrounds/nextjs/turbo.json create mode 100644 playgrounds/node/.env.example create mode 100644 playgrounds/node/.gitignore create mode 100644 playgrounds/node/.prettierignore create mode 100644 playgrounds/node/README.md create mode 100644 playgrounds/node/eslint.config.js create mode 100644 playgrounds/node/package.json create mode 100644 playgrounds/node/prettier.config.js create mode 100644 playgrounds/node/public/app.js create mode 100644 playgrounds/node/public/favicon.svg create mode 100644 playgrounds/node/public/index.html create mode 100644 playgrounds/node/public/styles.css create mode 100644 playgrounds/node/public/thunderid-logo.svg create mode 100644 playgrounds/node/src/catalog.ts create mode 100644 playgrounds/node/src/client.ts create mode 100644 playgrounds/node/src/config.ts create mode 100644 playgrounds/node/src/explorer.ts create mode 100644 playgrounds/node/src/operations.ts create mode 100644 playgrounds/node/src/server.ts create mode 100644 playgrounds/node/src/trace.ts create mode 100644 playgrounds/node/tsconfig.eslint.json create mode 100644 playgrounds/node/tsconfig.json create mode 100644 playgrounds/node/turbo.json create mode 100644 playgrounds/nuxt/.env.example create mode 100644 playgrounds/nuxt/.gitignore create mode 100644 playgrounds/nuxt/.prettierignore create mode 100644 playgrounds/nuxt/README.md create mode 100644 playgrounds/nuxt/app.vue create mode 100644 playgrounds/nuxt/assets/styles.css create mode 100644 playgrounds/nuxt/components/ClaimsTable.vue create mode 100644 playgrounds/nuxt/components/ComposableCard.vue create mode 100644 playgrounds/nuxt/components/EventLog.vue create mode 100644 playgrounds/nuxt/components/NuxtLogo.vue create mode 100644 playgrounds/nuxt/components/PgIcon.vue create mode 100644 playgrounds/nuxt/components/ProtectedNotice.vue create mode 100644 playgrounds/nuxt/components/SdkObserver.vue create mode 100644 playgrounds/nuxt/components/SessionCard.vue create mode 100644 playgrounds/nuxt/components/TopBar.vue create mode 100644 playgrounds/nuxt/components/explorer/ControlInput.vue create mode 100644 playgrounds/nuxt/components/explorer/StoryFrame.vue create mode 100644 playgrounds/nuxt/components/explorer/Workbench.vue create mode 100644 playgrounds/nuxt/eslint.config.js create mode 100644 playgrounds/nuxt/lib/a11y.ts create mode 100644 playgrounds/nuxt/lib/capabilities.ts create mode 100644 playgrounds/nuxt/lib/events.ts create mode 100644 playgrounds/nuxt/lib/health.ts create mode 100644 playgrounds/nuxt/lib/inspect.ts create mode 100644 playgrounds/nuxt/lib/jwt.ts create mode 100644 playgrounds/nuxt/lib/serverRoutes.ts create mode 100644 playgrounds/nuxt/lib/session.ts create mode 100644 playgrounds/nuxt/lib/stories.ts create mode 100644 playgrounds/nuxt/lib/storyTypes.ts create mode 100644 playgrounds/nuxt/lib/storyWidgets.ts create mode 100644 playgrounds/nuxt/lib/types.ts create mode 100644 playgrounds/nuxt/lib/user.ts create mode 100644 playgrounds/nuxt/nuxt.config.ts create mode 100644 playgrounds/nuxt/package.json create mode 100644 playgrounds/nuxt/pages/components/[...slug].vue create mode 100644 playgrounds/nuxt/pages/composables.vue create mode 100644 playgrounds/nuxt/pages/index.vue create mode 100644 playgrounds/nuxt/pages/protected/index.vue create mode 100644 playgrounds/nuxt/pages/protected/scopes.vue create mode 100644 playgrounds/nuxt/pages/server.vue create mode 100644 playgrounds/nuxt/prettier.config.js create mode 100644 playgrounds/nuxt/public/favicon.svg create mode 100644 playgrounds/nuxt/public/thunderid-logo.svg create mode 100644 playgrounds/nuxt/server/api/playground/health.get.ts create mode 100644 playgrounds/nuxt/server/api/playground/protected.get.ts create mode 100644 playgrounds/nuxt/server/api/playground/session.get.ts create mode 100644 playgrounds/nuxt/server/api/playground/source.get.ts create mode 100644 playgrounds/nuxt/server/api/playground/token.get.ts create mode 100644 playgrounds/nuxt/server/lib/claims.ts create mode 100644 playgrounds/nuxt/server/lib/trace.ts create mode 100644 playgrounds/nuxt/server/middleware/playground.ts create mode 100644 playgrounds/nuxt/server/tsconfig.json create mode 100644 playgrounds/nuxt/tsconfig.json create mode 100644 playgrounds/nuxt/turbo.json create mode 100644 playgrounds/react/public/thunderid-logo.svg create mode 100644 playgrounds/vue/.env.example create mode 100644 playgrounds/vue/.gitignore create mode 100644 playgrounds/vue/README.md create mode 100644 playgrounds/vue/eslint.config.js create mode 100644 playgrounds/vue/index.html create mode 100644 playgrounds/vue/package.json create mode 100644 playgrounds/vue/prettier.config.js create mode 100644 playgrounds/vue/public/favicon.svg create mode 100644 playgrounds/vue/public/thunderid-logo.svg create mode 100644 playgrounds/vue/src/App.vue create mode 100644 playgrounds/vue/src/Shell.vue create mode 100644 playgrounds/vue/src/components/ClaimsTable.vue create mode 100644 playgrounds/vue/src/components/ComposableCard.vue create mode 100644 playgrounds/vue/src/components/EventLog.vue create mode 100644 playgrounds/vue/src/components/SessionCard.vue create mode 100644 playgrounds/vue/src/components/TokenCard.vue create mode 100644 playgrounds/vue/src/components/TopBar.vue create mode 100644 playgrounds/vue/src/components/icons.ts create mode 100644 playgrounds/vue/src/config.ts create mode 100644 playgrounds/vue/src/lib/events.ts create mode 100644 playgrounds/vue/src/lib/health.ts create mode 100644 playgrounds/vue/src/lib/inspect.ts create mode 100644 playgrounds/vue/src/lib/jwt.ts create mode 100644 playgrounds/vue/src/lib/observer.ts create mode 100644 playgrounds/vue/src/lib/router.ts create mode 100644 playgrounds/vue/src/lib/sdk.ts create mode 100644 playgrounds/vue/src/lib/session.ts create mode 100644 playgrounds/vue/src/lib/user.ts create mode 100644 playgrounds/vue/src/main.ts create mode 100644 playgrounds/vue/src/pages/Composables.vue create mode 100644 playgrounds/vue/src/pages/Overview.vue create mode 100644 playgrounds/vue/src/pages/Tokens.vue create mode 100644 playgrounds/vue/src/pages/capabilities.ts create mode 100644 playgrounds/vue/src/pages/explorer/ControlInput.vue create mode 100644 playgrounds/vue/src/pages/explorer/Explorer.vue create mode 100644 playgrounds/vue/src/pages/explorer/StoryHost.ts create mode 100644 playgrounds/vue/src/pages/explorer/Workbench.vue create mode 100644 playgrounds/vue/src/pages/explorer/a11y.ts create mode 100644 playgrounds/vue/src/pages/explorer/code.ts create mode 100644 playgrounds/vue/src/pages/explorer/stories.ts create mode 100644 playgrounds/vue/src/pages/explorer/types.ts create mode 100644 playgrounds/vue/src/pages/explorer/widgets.ts create mode 100644 playgrounds/vue/src/styles.css create mode 100644 playgrounds/vue/src/vite-env.d.ts create mode 100644 playgrounds/vue/tsconfig.json create mode 100644 playgrounds/vue/vite.config.ts delete mode 100644 samples/integrations/better-auth/nextjs/.env.example delete mode 100644 samples/integrations/better-auth/nextjs/.stackblitzrc delete mode 100644 samples/integrations/better-auth/nextjs/README.md delete mode 100644 samples/integrations/better-auth/nextjs/app/api/auth/[...all]/route.ts delete mode 100644 samples/integrations/better-auth/nextjs/app/components/ConfigNotice.tsx delete mode 100644 samples/integrations/better-auth/nextjs/app/components/HeroCtas.tsx delete mode 100644 samples/integrations/better-auth/nextjs/app/components/Nav.tsx delete mode 100644 samples/integrations/better-auth/nextjs/app/components/ThunderMark.tsx delete mode 100644 samples/integrations/better-auth/nextjs/app/components/icons/BetterAuthLogo.tsx delete mode 100644 samples/integrations/better-auth/nextjs/app/globals.css delete mode 100644 samples/integrations/better-auth/nextjs/app/layout.tsx delete mode 100644 samples/integrations/better-auth/nextjs/app/not-found.tsx delete mode 100644 samples/integrations/better-auth/nextjs/app/page.tsx delete mode 100644 samples/integrations/better-auth/nextjs/lib/auth-client.ts delete mode 100644 samples/integrations/better-auth/nextjs/lib/auth.ts delete mode 100644 samples/integrations/better-auth/nextjs/next-env.d.ts delete mode 100644 samples/integrations/better-auth/nextjs/next.config.ts delete mode 100644 samples/integrations/better-auth/nextjs/package.json delete mode 100644 samples/integrations/better-auth/nextjs/scripts/prepare-dev.cjs delete mode 100644 samples/integrations/better-auth/nextjs/tsconfig.json diff --git a/.github/actions/pack-sample/action.yml b/.github/actions/pack-sample/action.yml deleted file mode 100644 index 0a573d62..00000000 --- a/.github/actions/pack-sample/action.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Pack Sample App -description: > - Packages a sample app into a standalone zip using pnpm pack, which - rewrites workspace:* and catalog: references to real npm versions. - Requires the pnpm workspace to be installed before this action runs. - -inputs: - sdk: - description: SDK slug used to name the zip file (e.g. better-auth) - required: true - version: - description: Version being released (e.g. 1.2.3), used to name the zip file - required: true - path: - description: > - Sample app directory, relative to the repo root (e.g. samples/integrations//). - The quickstarts live in thunder-id/samples, which releases them itself. - required: true - -outputs: - archive: - description: Filename of the produced zip (relative to the workspace root) - value: ${{ steps.pack.outputs.archive }} - -runs: - using: composite - steps: - - name: Pack - id: pack - shell: bash - run: | - SDK="${{ inputs.sdk }}" - VERSION="${{ inputs.version }}" - SAMPLE_PATH="${{ inputs.path }}" - DEST_DIR="$(pwd)" - FOLDER="thunderid-${SDK}-sdk-sample-v${VERSION}" - ARCHIVE="${FOLDER}.zip" - - PACK_DIR="$(mktemp -d)" - trap 'rm -rf "$PACK_DIR"' EXIT - - cd "${SAMPLE_PATH}" - pnpm pack --pack-destination "${PACK_DIR}" - cd "${DEST_DIR}" - - tgz=$(ls "${PACK_DIR}"/*.tgz | head -1) - if [ -z "$tgz" ]; then - echo "Error: pnpm pack did not produce a .tgz for ${SDK}" - exit 1 - fi - - tar xzf "$tgz" - mv package "${FOLDER}" - (find "${FOLDER}" | sort | zip "${ARCHIVE}" -@) - rm -rf "${FOLDER}" - - echo "archive=${ARCHIVE}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr-builder.yml b/.github/workflows/pr-builder.yml index bab94f59..3dbd99f7 100644 --- a/.github/workflows/pr-builder.yml +++ b/.github/workflows/pr-builder.yml @@ -94,10 +94,10 @@ jobs: run: pnpm format:check - name: 🧹 Lint - run: pnpm --filter '!./packages/**' --filter '!./samples/**' lint + run: pnpm --filter '!./packages/**' lint - name: πŸ§ͺ Test - run: pnpm --filter '!./packages/**' --filter '!./samples/**' test + run: pnpm --filter '!./packages/**' test e2e: name: 🎭 Playwright E2E Tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a223a79..aad8b73a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -255,22 +255,13 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: πŸ“¦ Pack Sample - id: pack - uses: ./.github/actions/pack-sample - with: - sdk: better-auth - version: ${{ steps.bump.outputs.version }} - path: samples/integrations/better-auth/nextjs - - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/better-auth/v${{ steps.bump.outputs.version }}" \ --title "ThunderID Better Auth SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes \ - "${{ steps.pack.outputs.archive }}" + --generate-notes # ── Level 1: depends on javascript ───────────────────────────────────────── release-browser: diff --git a/AGENTS.md b/AGENTS.md index 17713347..4dc8ed30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,10 @@ pnpm + Turborepo monorepo of TypeScript SDKs under `packages/`: a framework-agnostic core (`@thunderid/javascript`), a browser layer (`@thunderid/browser`), a server layer (`@thunderid/node`), and framework SDKs built on top of those (`react`, `vue`, `nuxt`, `nextjs`, `express`, `react-router`, -`tanstack-router`). The per-framework quickstarts live in +`tanstack-router`). The per-framework quickstarts and integration samples live in [thunder-id/samples](https://github.com/thunder-id/samples) and depend on the published packages; the E2E -suite fetches them into `tests/e2e/.samples` and wires them to this workspace's builds -(`tests/e2e/scripts/prepare-samples.ts`). `samples/integrations/` holds integration samples that are not -part of any published package. +suite fetches the quickstarts into `tests/e2e/.samples` and wires them to this workspace's builds +(`tests/e2e/scripts/prepare-samples.ts`). ## SDK development specification @@ -96,8 +95,7 @@ field, so storage keys, cookie names, CSS class prefixes, state keys, etc. shoul ## Code style - TypeScript, ESLint via `@thunderid/eslint-plugin`, Prettier. -- Every source file carries a copyright header (`@thunderid/copyright-header` ESLint rule); `samples/**` is - exempt (see `eslint.config.js`). +- Every source file carries a copyright header (`@thunderid/copyright-header` ESLint rule). - Prefer editing or re-exporting an existing util over duplicating a helper across packages (see hierarchy above). @@ -116,6 +114,5 @@ packages/ react-router/ React Router integration tanstack-router/ TanStack Router integration playgrounds/ Playground apps that exercise the SDKs from source (not published) -samples/ Integration samples (quickstarts live in thunder-id/samples) tests/e2e/ Playwright suite driving the quickstarts against a real ThunderID server ``` diff --git a/README.md b/README.md index b9bd49f4..86ed0389 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,13 @@ Apps under [`playgrounds/`](playgrounds) exercise the SDKs from the source in th | Playground | Run | What it shows | |---|---|---| +| [Browser](playgrounds/browser) | `pnpm --filter @thunderid/playground-browser dev` | Capability checks against `ThunderIDBrowserClient` with a live `on()` hook log, an explorer for the `@thunderid/browser` UI helpers, and tokens | | [Express](playgrounds/express) | `pnpm --filter @thunderid/playground-express dev` | API explorer for the `@thunderid/express` handlers and protected routes, with an SDK trace | +| [Next.js](playgrounds/nextjs) | `pnpm --filter @thunderid/playground-nextjs dev` | Client and server capability checks, a component explorer for the `@thunderid/nextjs` components, a Server page with an API explorer for the proxy and route handlers, and tokens | +| [Node.js](playgrounds/node) | `pnpm --filter @thunderid/playground-node dev` | Operations explorer for every `ThunderIDNodeClient` method from `@thunderid/node`, with the HTTP requests and an SDK trace | +| [Nuxt](playgrounds/nuxt) | `pnpm --filter @thunderid/playground-nuxt dev` | Client and server capability checks, a component explorer for the `@thunderid/nuxt` components with Vue SFC snippets, live composables, and a Server page with the SSR context, the server session, and an API explorer for the module routes, server utilities, and route middleware | | [React](playgrounds/react) | `pnpm --filter @thunderid/playground-react dev` | Capability checks, a component explorer for the `@thunderid/react` components, and live hooks and tokens | +| [Vue](playgrounds/vue) | `pnpm --filter @thunderid/playground-vue dev` | Capability checks, a component explorer for the `@thunderid/vue` components with Vue SFC snippets, and live composables and tokens | ## Contributing diff --git a/eslint.config.js b/eslint.config.js index c10c2852..d0035b9e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,12 +8,6 @@ export default [ ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**', 'tests/e2e/.samples/**'], }, ...thunderIdPlugin.configs.typescript, - { - files: ['samples/**'], - rules: { - '@thunderid/copyright-header': 'off', - }, - }, { // Progress/setup/teardown output is the intended UX for E2E tooling, not stray debug // logging β€” same reasoning thunderid's own tests/e2e applies (no no-console rule there). diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 955b2703..a15a2c8e 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -16,8 +16,13 @@ import { HttpError, HttpRequestConfig, HttpResponse, + UserProfile, createPackageComponentLogger, + generateFlattenedUserProfile, + getUsersMe, + resolveResourceEndpoint, } from '@thunderid/javascript'; +import Hooks from './constants/Hooks'; import {REFRESH_ACCESS_TOKEN_ERR0R, SILENT_SIGN_IN_STATE, TOKEN_REQUEST_CONFIG_KEY} from './constants/SPAConstants'; import FetchHttpClient from './FetchHttpClient'; import {BrowserAuthConfig} from './models/BrowserConfig'; @@ -29,6 +34,7 @@ import LocalStore from './stores/LocalStore'; import MemoryStore from './stores/MemoryStore'; import SessionStore from './stores/SessionStore'; import AuthenticationHelper from './utils/AuthenticationHelper'; +import createHttpClientFetcher from './utils/createHttpClientFetcher'; import navigate from './utils/navigate'; import SPACryptoUtils from './utils/SPACryptoUtils'; import SPAHelper from './utils/SPAHelper'; @@ -41,6 +47,8 @@ const BROWSER_DEFAULT_CONFIG = { periodicTokenRefresh: false, }; +type HookCallback = (response?: any) => void | Promise; + const initiateStore = (storage: BrowserStorage | string | undefined): Storage => { switch (storage) { case BrowserStorage.LocalStorage: @@ -62,14 +70,7 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC private _storage: BrowserStorage | string = BrowserStorage.SessionStorage; private _getSignOutURLFromSessionStorage = false; private _isHttpHandlerEnabled = true; - private _httpErrorCallback: ((error: HttpError) => void | Promise) | undefined; - private _httpFinishCallback: (() => void) | undefined; - private _onSignInCallback: (response: User) => void = () => null; - private _onSignOutCallback: () => void = () => null; - private _onSignOutFailedCallback: (error: SignOutError) => void = () => null; - private _onEndUserSession: (response: any) => void = () => null; - private _onInitialize: (response: boolean) => void = () => null; - private _onCustomGrant = new Map void>(); + private _hookCallbacks = new Map>(); private _initialized = false; private _startedInitialize = false; @@ -104,6 +105,12 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC }; this._httpClient = FetchHttpClient.getInstance(this._browserInstanceId, true, attachToken); + this._httpClient.setHttpRequestStartCallback((request: HttpRequestConfig) => { + void this._emitHook(Hooks.HttpRequestStart, request); + }); + this._httpClient.setHttpRequestSuccessCallback((response: HttpResponse) => { + void this._emitHook(Hooks.HttpRequestSuccess, response); + }); this._authHelper = new AuthenticationHelper(sm, this._spaHelper, this._browserInstanceId, { exchangeToken: (cfg: SPATokenExchangeConfig) => super.exchangeToken(cfg as any), @@ -121,9 +128,7 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC this._initialized = true; - if (this._onInitialize) { - this._onInitialize(true); - } + void this._emitHook(Hooks.Initialize, true); if (!merged.autoLogoutOnTokenRefreshError) { return true; @@ -201,8 +206,8 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC const user = await this._signInInternal(config, authorizationCode, state, tokenRequestConfig); - if (user && this._onSignInCallback) { - this._onSignInCallback(user); + if (user) { + void this._emitHook(Hooks.SignIn, user); } return user; @@ -322,8 +327,8 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC const response = await this._signInSilentlyInternal(additionalParams, tokenRequestConfig); - if (this._onSignInCallback && response) { - this._onSignInCallback(response as User); + if (response) { + void this._emitHook(Hooks.SignIn, response); } return response; @@ -430,25 +435,37 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC public async httpRequest(requestConfig: HttpRequestConfig): Promise { await this._validateMethod(false); - return this._authHelper!.httpRequest( + const response: HttpResponse = await this._authHelper!.httpRequest( this._httpClient!, requestConfig, this._isHttpHandlerEnabled, - this._httpErrorCallback, - this._httpFinishCallback, + this._emitHttpError, + this._emitHttpFinish, ); + + if (this._isHttpHandlerEnabled) { + this._emitHttpFinish(); + } + + return response; } public async httpRequestAll(configs: HttpRequestConfig[]): Promise { await this._validateMethod(false); - return this._authHelper!.httpRequestAll( + const responses: HttpResponse[] | undefined = await this._authHelper!.httpRequestAll( configs, this._httpClient!, this._isHttpHandlerEnabled, - this._httpErrorCallback, - this._httpFinishCallback, + this._emitHttpError, + this._emitHttpFinish, ); + + if (this._isHttpHandlerEnabled) { + this._emitHttpFinish(); + } + + return responses; } public override async getUser(userId?: string): Promise { @@ -457,6 +474,29 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC return super.getUser(userId); } + /** + * Fetches the signed-in user's profile from the `/users/me` endpoint (or `endpoints.usersMe` when + * configured), sending the request through the client's HTTP client so the access token is attached. + * + * @param options - Optional `baseUrl` or full `url` overrides for the profile endpoint. + * @returns The user's profile. + */ + public override async getUserProfile(options?: {baseUrl?: string; url?: string}): Promise { + await this._validateMethod(); + + const configData = await this.getStorageManager().getConfigData(); + const profile: User = await getUsersMe({ + baseUrl: options?.baseUrl ?? (configData as any)?.baseUrl, + fetcher: createHttpClientFetcher(this._browserInstanceId), + url: resolveResourceEndpoint('usersMe', configData as any, options?.url), + }); + + return { + flattenedProfile: generateFlattenedUserProfile(profile), + profile, + }; + } + public override async getAccessToken(sessionId?: string): Promise { return super.getAccessToken(sessionId); } @@ -504,7 +544,7 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } protected notifySignIn(user: User): void { - this._onSignInCallback(user); + void this._emitHook(Hooks.SignIn, user); } public async isSessionActive(): Promise { @@ -531,7 +571,7 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC await this._spaHelper!.clearRefreshTokenTimeout(timer); - this._onEndUserSession && (await this._onEndUserSession(true)); + await this._emitHook(Hooks.RevokeAccessToken, true); return true; } @@ -555,8 +595,7 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC this._enableRetrievingSignOutURLFromSession(cfg), ); - const cb = this._onCustomGrant.get(config.id); - cb?.(response); + void this._emitHook(`${Hooks.CustomGrant}:${config.id}`, response); return response; } @@ -596,6 +635,15 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC return this.getCryptoHelper().decodeJwtToken(token); } + /** + * Subscribes a callback to a lifecycle hook. Every callback registered for a hook is called when it + * fires; registering the same callback again for the same hook has no further effect. Hooks can be + * registered before or after `initialize()`. + * + * @param hook - The hook to subscribe to (see `Hooks`). + * @param callback - Called when the hook fires. + * @param id - The custom grant id. Required for `custom-grant`. + */ public async on(hook: string, callback: (response?: any) => void | Promise, id?: string): Promise { await this.isInitialized(); @@ -607,47 +655,33 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC ); } - switch (hook) { - case 'sign-in': - this._onSignInCallback = callback; + switch (hook as Hooks) { + case Hooks.SignIn: + case Hooks.RevokeAccessToken: + case Hooks.Initialize: + case Hooks.HttpRequestError: + case Hooks.HttpRequestFinish: + case Hooks.HttpRequestStart: + case Hooks.HttpRequestSuccess: + this._addHookCallback(hook, callback); break; - case 'sign-out': - this._onSignOutCallback = callback; + case Hooks.SignOut: if ( await SPAUtils.isSignOutSuccessful( ThunderIDBrowserClient.isSignOutSuccessful.bind(ThunderIDBrowserClient), () => ThunderIDBrowserClient.clearSession(), ) ) { - this._onSignOutCallback(); + void callback(); } break; - case 'revoke-access-token': - this._onEndUserSession = callback; - break; - case 'initialize': - this._onInitialize = callback; - break; - case 'http-request-error': - this._httpErrorCallback = callback; - break; - case 'http-request-finish': - this._httpFinishCallback = callback; - break; - case 'http-request-start': - this._httpClient?.setHttpRequestStartCallback?.(callback); - break; - case 'http-request-success': - this._httpClient?.setHttpRequestSuccessCallback?.(callback); - break; - case 'custom-grant': - id && this._onCustomGrant.set(id, callback); + case Hooks.CustomGrant: + id && this._addHookCallback(`${Hooks.CustomGrant}:${id}`, callback); break; - case 'sign-out-failed': { - this._onSignOutFailedCallback = callback; + case Hooks.SignOutFailed: { const signOutFail = SPAUtils.didSignOutFail(ThunderIDBrowserClient.didSignOutFail.bind(ThunderIDBrowserClient)); if (signOutFail) { - this._onSignOutFailedCallback(signOutFail as SignOutError); + void callback(signOutFail as SignOutError); } break; } @@ -656,6 +690,27 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } } + private _addHookCallback(hook: string, callback: HookCallback): void { + const callbacks: Set = this._hookCallbacks.get(hook) ?? new Set(); + + callbacks.add(callback); + this._hookCallbacks.set(hook, callbacks); + } + + private _emitHook(hook: string, response?: unknown): Promise { + return Promise.all( + [...(this._hookCallbacks.get(hook) ?? [])].map((callback: HookCallback) => Promise.resolve(callback(response))), + ); + } + + private _emitHttpError = async (error: HttpError): Promise => { + await this._emitHook(Hooks.HttpRequestError, error); + }; + + private _emitHttpFinish = (): void => { + void this._emitHook(Hooks.HttpRequestFinish); + }; + private _enableRetrievingSignOutURLFromSession(config: SPATokenExchangeConfig): void { if (config.preventSignOutURLUpdate) { this._getSignOutURLFromSessionStorage = true; diff --git a/packages/browser/src/__tests__/ThunderIDBrowserClient.test.ts b/packages/browser/src/__tests__/ThunderIDBrowserClient.test.ts new file mode 100644 index 00000000..061db617 --- /dev/null +++ b/packages/browser/src/__tests__/ThunderIDBrowserClient.test.ts @@ -0,0 +1,173 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import Hooks from '../constants/Hooks'; +import FetchHttpClient from '../FetchHttpClient'; +import {BrowserAuthConfig} from '../models/BrowserConfig'; +import ThunderIDBrowserClient from '../ThunderIDBrowserClient'; + +const BASE_URL = 'https://localhost:8090'; + +// Typed against the default generic: `storage` takes a storage name, and the browser-specific keys are accepted. +const config: BrowserAuthConfig = { + allowedExternalUrls: [BASE_URL], + baseUrl: BASE_URL, + clientId: 'client-id', + storage: 'browserMemory', +}; + +const jsonResponse = (body: unknown, status = 200): Response => + ({ + headers: new Headers({'content-type': 'application/json'}), + json: () => Promise.resolve(body), + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Bad Request', + text: () => Promise.resolve(JSON.stringify(body)), + }) as Response; + +describe('ThunderIDBrowserClient', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + FetchHttpClient.destroyInstance(); + fetchMock = vi.fn().mockResolvedValue(jsonResponse({ok: true})); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('accepts the documented config without an explicit generic', async () => { + const client = new ThunderIDBrowserClient(); + + await expect(client.initialize(config)).resolves.toBe(true); + }); + + describe('on()', () => { + it('fires http-request-start and http-request-success hooks registered before initialize()', async () => { + const client = new ThunderIDBrowserClient(); + const onStart = vi.fn(); + const onSuccess = vi.fn(); + + await client.on(Hooks.HttpRequestStart, onStart); + await client.on(Hooks.HttpRequestSuccess, onSuccess); + await client.initialize(config); + + await client.httpRequest({attachToken: false, method: 'GET', url: `${BASE_URL}/resource`}); + + expect(onStart).toHaveBeenCalledTimes(1); + expect(onStart).toHaveBeenCalledWith(expect.objectContaining({url: `${BASE_URL}/resource`})); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({status: 200})); + }); + + it('fires http-request-finish once when a request succeeds', async () => { + const client = new ThunderIDBrowserClient(); + const onFinish = vi.fn(); + + await client.initialize(config); + await client.on(Hooks.HttpRequestFinish, onFinish); + + await client.httpRequest({attachToken: false, method: 'GET', url: `${BASE_URL}/resource`}); + + expect(onFinish).toHaveBeenCalledTimes(1); + }); + + it('fires http-request-finish once when a request fails', async () => { + const client = new ThunderIDBrowserClient(); + const onError = vi.fn(); + const onFinish = vi.fn(); + + fetchMock.mockResolvedValue(jsonResponse({error: 'bad'}, 400)); + await client.initialize(config); + await client.on(Hooks.HttpRequestError, onError); + await client.on(Hooks.HttpRequestFinish, onFinish); + + await expect( + client.httpRequest({attachToken: false, method: 'GET', url: `${BASE_URL}/resource`}), + ).rejects.toBeDefined(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledTimes(1); + }); + + it('fires http-request-finish once when a batch of requests succeeds', async () => { + const client = new ThunderIDBrowserClient(); + const onFinish = vi.fn(); + + await client.initialize(config); + await client.on(Hooks.HttpRequestFinish, onFinish); + + await client.httpRequestAll([ + {attachToken: false, method: 'GET', url: `${BASE_URL}/a`}, + {attachToken: false, method: 'GET', url: `${BASE_URL}/b`}, + ]); + + expect(onFinish).toHaveBeenCalledTimes(1); + }); + + it('calls every callback subscribed to a hook', async () => { + const client = new ThunderIDBrowserClient(); + const first = vi.fn(); + const second = vi.fn(); + + await client.on(Hooks.Initialize, first); + await client.on(Hooks.Initialize, second); + await client.initialize(config); + + expect(first).toHaveBeenCalledWith(true); + expect(second).toHaveBeenCalledWith(true); + }); + + it('calls a callback subscribed twice to the same hook only once', async () => { + const client = new ThunderIDBrowserClient(); + const callback = vi.fn(); + + await client.on(Hooks.Initialize, callback); + await client.on(Hooks.Initialize, callback); + await client.initialize(config); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUserProfile()', () => { + it('fetches the profile from /users/me through the client HTTP client', async () => { + const client = new ThunderIDBrowserClient(); + + await client.initialize(config); + vi.spyOn(client, 'isSignedIn').mockResolvedValue(true); + fetchMock.mockResolvedValue(jsonResponse({attributes: {email: 'alex@example.com'}, id: 'user-1'})); + + const profile = await client.getUserProfile(); + + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/users/me`, expect.objectContaining({method: 'GET'})); + expect(profile.profile).toMatchObject({email: 'alex@example.com', id: 'user-1'}); + expect(profile.flattenedProfile).toMatchObject({email: 'alex@example.com', id: 'user-1'}); + }); + + it('uses endpoints.usersMe when configured', async () => { + const client = new ThunderIDBrowserClient(); + + await client.initialize({...config, endpoints: {usersMe: `${BASE_URL}/custom/me`}}); + vi.spyOn(client, 'isSignedIn').mockResolvedValue(true); + fetchMock.mockResolvedValue(jsonResponse({id: 'user-1'})); + + await client.getUserProfile(); + + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/custom/me`, expect.anything()); + }); + + it('rejects when the user is not signed in', async () => { + const client = new ThunderIDBrowserClient(); + + await client.initialize(config); + + await expect(client.getUserProfile()).rejects.toMatchObject({code: 'SPA-AUTH_CLIENT-VM-IV02'}); + }); + }); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index b21739db..80ecbd22 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -59,7 +59,7 @@ export type {ResolvedLogo, ResolvedLogoKind} from './utils/resolveLogoUri'; // Theme export {detectThemeMode, createClassObserver, createMediaQueryListener} from './theme/themeDetection'; -export type {BrowserThemeDetection} from './theme/themeDetection'; +export type {BrowserThemeDetection, MediaQueryListener} from './theme/themeDetection'; export {default as getActiveTheme} from './theme/getActiveTheme'; export {default as normalizeBorderRadius} from './theme/normalizeBorderRadius'; diff --git a/packages/browser/src/models/BrowserConfig.ts b/packages/browser/src/models/BrowserConfig.ts index a01b374e..aca1c06b 100644 --- a/packages/browser/src/models/BrowserConfig.ts +++ b/packages/browser/src/models/BrowserConfig.ts @@ -24,4 +24,5 @@ export interface BrowserClientConfig { } /** Full browser SDK configuration, combining base OIDC config with browser-specific fields. */ -export type BrowserAuthConfig = AuthClientConfig; +export type BrowserAuthConfig = AuthClientConfig> & + Omit; diff --git a/packages/browser/src/theme/__tests__/themeDetection.test.ts b/packages/browser/src/theme/__tests__/themeDetection.test.ts new file mode 100644 index 00000000..95798ae8 --- /dev/null +++ b/packages/browser/src/theme/__tests__/themeDetection.test.ts @@ -0,0 +1,62 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {createMediaQueryListener} from '../themeDetection'; + +type ChangeListener = (event: MediaQueryListEvent) => void; + +const stubMatchMedia = (): {emit: (matches: boolean) => void} => { + const listeners: Set = new Set(); + + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + addEventListener: (_type: string, listener: ChangeListener): void => { + listeners.add(listener); + }, + matches: false, + media: query, + removeEventListener: (_type: string, listener: ChangeListener): void => { + listeners.delete(listener); + }, + })), + ); + + return { + emit: (matches: boolean): void => listeners.forEach((listener) => listener({matches} as MediaQueryListEvent)), + }; +}; + +describe('createMediaQueryListener', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('calls the callback when the system theme changes', () => { + const {emit} = stubMatchMedia(); + const callback = vi.fn(); + + createMediaQueryListener(callback); + emit(true); + + expect(callback).toHaveBeenCalledWith(true); + }); + + it('stops calling the callback after disconnect()', () => { + const {emit} = stubMatchMedia(); + const callback = vi.fn(); + + const listener = createMediaQueryListener(callback); + listener?.disconnect(); + emit(true); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('still returns the MediaQueryList', () => { + stubMatchMedia(); + + expect(createMediaQueryListener(vi.fn())?.media).toBe('(prefers-color-scheme: dark)'); + }); +}); diff --git a/packages/browser/src/theme/themeDetection.ts b/packages/browser/src/theme/themeDetection.ts index 3135eadd..ed5a0919 100644 --- a/packages/browser/src/theme/themeDetection.ts +++ b/packages/browser/src/theme/themeDetection.ts @@ -99,9 +99,19 @@ export const createClassObserver = ( }; /** - * Creates a media query listener for system theme changes + * A `MediaQueryList` returned by `createMediaQueryListener`, with a `disconnect()` that removes the + * listener it registered. */ -export const createMediaQueryListener = (callback: (isDark: boolean) => void): MediaQueryList | null => { +export type MediaQueryListener = MediaQueryList & { + /** Stops calling the callback on system theme changes. */ + disconnect: () => void; +}; + +/** + * Creates a media query listener for system theme changes. Call `disconnect()` on the returned + * object to remove the listener. + */ +export const createMediaQueryListener = (callback: (isDark: boolean) => void): MediaQueryListener | null => { if (typeof window === 'undefined' || !window.matchMedia) { return null; } @@ -120,5 +130,13 @@ export const createMediaQueryListener = (callback: (isDark: boolean) => void): M mediaQuery.addListener(handleChange); } - return mediaQuery; + return Object.assign(mediaQuery, { + disconnect: (): void => { + if (mediaQuery.removeEventListener) { + mediaQuery.removeEventListener('change', handleChange); + } else { + mediaQuery.removeListener(handleChange); + } + }, + }); }; diff --git a/packages/express/src/ThunderIDExpressClient.ts b/packages/express/src/ThunderIDExpressClient.ts index adea9a85..8200b7e0 100644 --- a/packages/express/src/ThunderIDExpressClient.ts +++ b/packages/express/src/ThunderIDExpressClient.ts @@ -58,7 +58,7 @@ class ThunderIDExpressClient, ): Promise { if (hasErrorInURL(req.originalUrl)) { @@ -88,8 +88,8 @@ class ThunderIDExpressClient + ({ + cookie: vi.fn(), + end: vi.fn(), + redirect: vi.fn(), + status: vi.fn().mockReturnThis(), + }) as unknown as express.Response; + +const newClient = async (): Promise => { + const client: ThunderIDExpressClient = new ThunderIDExpressClient(); + await client.initialize({baseUrl: 'https://auth.example.com', clientId: 'test-client'}); + + return client; +}; + +describe('handleSignIn', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('redirects to the authorize URL and does not pass the request on to later routes', async () => { + const client: ThunderIDExpressClient = await newClient(); + vi.spyOn(client, 'getSignInUrl').mockResolvedValue('https://auth.example.com/oauth2/authorize?state=x'); + const req = {cookies: {}, originalUrl: '/login', query: {}, thunderIDAuth: client} as unknown as express.Request; + const res: express.Response = fakeResponse(); + const next = vi.fn(); + + await handleSignIn()(req, res, next); + + expect(res.redirect).toHaveBeenCalledWith('https://auth.example.com/oauth2/authorize?state=x'); + expect(next).not.toHaveBeenCalled(); + }); +}); + +describe('protect', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('answers 401 for a request without a session and logs it below the error level', async () => { + const client: ThunderIDExpressClient = await newClient(); + const error = vi.spyOn(logger, 'error'); + const debug = vi.spyOn(logger, 'debug'); + const req = {cookies: {}, thunderIDAuth: client} as unknown as express.Request; + const res: express.Response = fakeResponse(); + const next = vi.fn(); + + await protect()(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalled(); + }); + + it('answers 401 for a session that is not signed in, without an error log', async () => { + const client: ThunderIDExpressClient = await newClient(); + vi.spyOn(client, 'isSignedIn').mockResolvedValue(false); + const error = vi.spyOn(logger, 'error'); + const req = { + cookies: {[client.getSessionCookieName()]: 'session-1'}, + thunderIDAuth: client, + } as unknown as express.Request; + const res: express.Response = fakeResponse(); + + await protect()(req, res, vi.fn()); + + expect(res.status).toHaveBeenCalledWith(401); + expect(error).not.toHaveBeenCalled(); + }); + + it('logs an error when thunderID() is not mounted', async () => { + const error = vi.spyOn(logger, 'error').mockImplementation(() => undefined); + const res: express.Response = fakeResponse(); + + await protect()({cookies: {}} as unknown as express.Request, res, vi.fn()); + + expect(error).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('calls next() for a signed-in session', async () => { + const client: ThunderIDExpressClient = await newClient(); + vi.spyOn(client, 'isSignedIn').mockResolvedValue(true); + const req = { + cookies: {[client.getSessionCookieName()]: 'session-1'}, + thunderIDAuth: client, + } as unknown as express.Request; + const next = vi.fn(); + + await protect()(req, fakeResponse(), next); + + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/express/src/middleware/protect.ts b/packages/express/src/middleware/protect.ts index 7d8a3933..2f6d7e6b 100644 --- a/packages/express/src/middleware/protect.ts +++ b/packages/express/src/middleware/protect.ts @@ -28,8 +28,15 @@ const protect = ( } }; - if (!client || !sessionId) { - Logger.error('No session ID found in the request cookies'); + if (!client) { + Logger.error('thunderID() middleware must be mounted before protect()'); + reject(); + return; + } + + // A request without a session, or with one that has ended, is an ordinary unauthenticated request. + if (!sessionId) { + Logger.debug('No session ID found in the request cookies'); reject(); return; } @@ -40,7 +47,7 @@ const protect = ( return next(); } - Logger.error('Invalid session ID found in the request cookies'); + Logger.debug('The session in the request cookies is not signed in'); reject(); }; }; diff --git a/packages/javascript/rolldown.config.js b/packages/javascript/rolldown.config.js index 7903065b..810ba7a7 100644 --- a/packages/javascript/rolldown.config.js +++ b/packages/javascript/rolldown.config.js @@ -7,7 +7,10 @@ import {defineConfig} from 'rolldown'; const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); -const external = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; +const dependencies = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; + +// A dependency's subpath imports (for example `jose/jwt/verify`) stay external too. +const external = (id) => dependencies.some((dep) => id === dep || id.startsWith(`${dep}/`)); const commonOptions = { input: [join('src', 'index.ts')], diff --git a/packages/javascript/src/DefaultCrypto.ts b/packages/javascript/src/DefaultCrypto.ts index ff7002c2..24580d07 100644 --- a/packages/javascript/src/DefaultCrypto.ts +++ b/packages/javascript/src/DefaultCrypto.ts @@ -1,7 +1,11 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import * as jose from 'jose'; +import type {CryptoKey, JWK} from 'jose'; +// Subpath imports keep jose's JWE code, whose CompressionStream use the Edge runtime rejects, out of the bundle. +import * as base64url from 'jose/base64url'; +import {importJWK} from 'jose/key/import'; +import {jwtVerify} from 'jose/jwt/verify'; import {Crypto, JWKInterface} from './models/crypto'; /** @@ -10,12 +14,12 @@ import {Crypto, JWKInterface} from './models/crypto'; */ export class DefaultCrypto implements Crypto { public base64URLDecode(value: string): string { - const decodedArray: Uint8Array = jose.base64url.decode(value); + const decodedArray: Uint8Array = base64url.decode(value); return new TextDecoder().decode(decodedArray); } public base64URLEncode(value: Uint8Array): string { - return jose.base64url.encode(value); + return base64url.encode(value); } public generateRandomBytes(length: number): Uint8Array { @@ -40,9 +44,9 @@ export class DefaultCrypto implements Crypto { clockTolerance?: number, validateJwtIssuer = true, ): Promise { - const key: jose.CryptoKey | Uint8Array = await jose.importJWK(jwk as jose.JWK); + const key: CryptoKey | Uint8Array = await importJWK(jwk as JWK); - await jose.jwtVerify(idToken, key, { + await jwtVerify(idToken, key, { algorithms, audience: [clientId], clockTolerance, diff --git a/packages/javascript/src/IsomorphicCrypto.ts b/packages/javascript/src/IsomorphicCrypto.ts index 8be5c4f7..b0dbe954 100644 --- a/packages/javascript/src/IsomorphicCrypto.ts +++ b/packages/javascript/src/IsomorphicCrypto.ts @@ -21,6 +21,17 @@ export class IsomorphicCrypto { return this.cryptoUtils.base64URLEncode(this.cryptoUtils.generateRandomBytes(32)); } + /** + * Generates the random part of an authorization request `state` from 32 random bytes. Only + * letters and digits are kept, so the value never contains the `_` that separates the parts of + * the state. + * + * @returns A random string of about 42 characters. + */ + public generateState(): string { + return this.cryptoUtils.base64URLEncode(this.cryptoUtils.generateRandomBytes(32)).replace(/[^A-Za-z0-9]/g, ''); + } + /** * Derive code challenge from the code verifier. * diff --git a/packages/javascript/src/StorageManager.ts b/packages/javascript/src/StorageManager.ts index dba94000..da0393b1 100644 --- a/packages/javascript/src/StorageManager.ts +++ b/packages/javascript/src/StorageManager.ts @@ -79,6 +79,12 @@ class StorageManager { } protected static isLocalStorageAvailable(): boolean { + // Outside a browser window, do not touch `localStorage`: Node.js defines it as an experimental + // global, and reading it prints a warning (and, when enabled, shares it across every session). + if (typeof window === 'undefined') { + return false; + } + try { const testValue = '__AUTH_SDK_STORAGE_TEST__'; diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index a591434d..2293c800 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -10,6 +10,7 @@ import {DefaultCacheStore} from './DefaultCacheStore'; import {DefaultCrypto} from './DefaultCrypto'; import {ThunderIDAuthException} from './errors/exception'; import ThunderIDAPIError from './errors/ThunderIDAPIError'; +import ThunderIDRuntimeError from './errors/ThunderIDRuntimeError'; import {IsomorphicCrypto} from './IsomorphicCrypto'; import {AgentConfig} from './models/agent'; import {AuthCodeResponse} from './models/auth-code-response'; @@ -32,11 +33,56 @@ import deepMerge from './utils/deepMerge'; import extractPkceStorageKeyFromState from './utils/extractPkceStorageKeyFromState'; import generatePkceStorageKey from './utils/generatePkceStorageKey'; import getAuthorizeRequestUrlParams from './utils/getAuthorizeRequestUrlParams'; +import getOAuthErrorMessage from './utils/getOAuthErrorMessage'; import logger from './utils/logger'; import processOpenIDScopes from './utils/processOpenIDScopes'; const WELL_KNOWN_PATH = '/.well-known/openid-configuration'; +/** + * The issuer a discovery document must name: the configured `endpoints.issuer`, else the URL the + * well-known path was appended to (OpenID Connect Discovery 1.0, section 4.3). + */ +const getExpectedIssuer = ( + configuredIssuer: string | undefined, + wellKnownOverride: string | undefined, + baseUrl: string | undefined, +): string | undefined => { + if (configuredIssuer) { + return configuredIssuer; + } + + if (wellKnownOverride) { + return wellKnownOverride.endsWith(WELL_KNOWN_PATH) + ? wellKnownOverride.slice(0, -WELL_KNOWN_PATH.length) + : undefined; + } + + return baseUrl || undefined; +}; + +/** + * Compares two issuers exactly, apart from a trailing slash on either side. + */ +const issuersMatch = (discovered: unknown, expected: string): boolean => + typeof discovered === 'string' && discovered.replace(/\/+$/, '') === expected.replace(/\/+$/, ''); + +/** + * Reads a readable message from the body of a failed token, revocation, or custom grant response. + */ +const readErrorMessage = async (response: Response, fallback: string): Promise => + getOAuthErrorMessage(await response.json().catch((): undefined => undefined), fallback); + +/** + * The storage key of the `state` sent with the authorization request whose PKCE key is given. Both + * share the request index, so the callback finds the state from the index at the end of `state`. + */ +const getStateStorageKey = (pkceKey: string): string => + pkceKey.replace( + PKCEConstants.Storage.StorageKeys.CODE_VERIFIER, + OIDCRequestConstants.SignIn.Storage.StorageKeys.STATE, + ); + const REVOKE_ACCESS_TOKEN_REQUEST_TIMEOUT_MS = 10_000; const DEFAULT_CONFIG: Partial> = { @@ -134,13 +180,24 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { return true; } - public getConfiguration(): T { - return this.storageManager.getConfigData() as unknown as T; + public async getConfiguration(): Promise { + return (await this.storageManager.getConfigData()) as unknown as T; } + /** + * Returns the signed-in user, read from the claims of the session's ID token. + * + * @param userId - The session to read, on a server. + * @throws {ThunderIDRuntimeError} With code `JS-AUTH_CORE-GU-NF01` when the session holds no ID token. + */ public async getUser(userId?: string): Promise { const sessionData: SessionData = await this.storageManager.getSessionData(userId); - const authenticatedUser: User = this.authHelper.getAuthenticatedUserInfo(sessionData?.id_token); + + if (!sessionData?.id_token) { + throw new ThunderIDRuntimeError('No signed-in session was found.', 'JS-AUTH_CORE-GU-NF01', 'javascript'); + } + + const authenticatedUser: User = this.authHelper.getAuthenticatedUserInfo(sessionData.id_token); Object.keys(authenticatedUser).forEach((key: string) => { if (authenticatedUser[key] === undefined || authenticatedUser[key] === '' || authenticatedUser[key] === null) { @@ -281,7 +338,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { if (config.attachToken) { requestHeaders = { ...requestHeaders, - Authorization: `Bearer ${(await this.storageManager.getSessionData(sessionId)).access_token}`, + Authorization: `Bearer ${(await this.storageManager.getSessionData(sessionId))?.access_token}`, }; } @@ -308,7 +365,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { throw new ThunderIDAuthException( 'JS-AUTH_CORE-RCG-HE03', `Invalid response status received for the custom grant request. (${response.statusText})`, - (await response.json()) as string, + await readErrorMessage(response, 'The custom grant request was rejected.'), ); } @@ -402,10 +459,34 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { if (response) { let discoveryResolved = false; + let discovered: OIDCDiscoveryApiResponse | undefined; + try { - await this.storageManager.setOIDCProviderMetaData( - await this.authHelper.resolveEndpoints(await response.json()), - ); + discovered = (await response.json()) as OIDCDiscoveryApiResponse; + } catch { + discovered = undefined; + } + + // Validate the discovered issuer before any endpoint from the document is used. A mismatch + // fails here rather than falling back to endpoints derived from the base URL. + if (discovered && typeof discovered === 'object') { + const expectedIssuer: string | undefined = getExpectedIssuer(endpoints?.issuer, wellKnownOverride, baseUrl); + + if (expectedIssuer !== undefined && !issuersMatch(discovered.issuer, expectedIssuer)) { + throw new ThunderIDRuntimeError( + 'The discovered issuer does not match the expected issuer. Set endpoints.issuer if your server uses a custom issuer.', + 'JS-AUTH_CORE-GOPMD-IV03', + 'javascript', + ); + } + } + + try { + if (!discovered || typeof discovered !== 'object') { + throw new Error('The discovery response is not a JSON object.'); + } + + await this.storageManager.setOIDCProviderMetaData(await this.authHelper.resolveEndpoints(discovered)); discoveryResolved = true; } catch (error: unknown) { // Parsing or endpoint resolution failed; fall through to baseUrl fallback. @@ -517,6 +598,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { codeChallengeMethod: PKCEConstants.DEFAULT_CODE_CHALLENGE_METHOD, instanceId: this.getInstanceId().toString(), prompt: configData.prompt, + randomState: this.cryptoHelper.generateState(), redirectUri: configData.afterSignInUrl ?? '', responseMode: configData.responseMode, scopes: processOpenIDScopes(configData.scopes), @@ -526,6 +608,13 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { authRequestConfig, ); + // Kept so the callback can be compared with the state this request sent. + await this.storageManager.setHybridDataParameter( + getStateStorageKey(pkceKey), + authorizeRequestParams.get(OIDCRequestConstants.Params.STATE) ?? '', + userId, + ); + Array.from(authorizeRequestParams.entries()).forEach(([paramKey, paramValue]: [string, string]) => { authorizeRequest.searchParams.append(paramKey, paramValue); }); @@ -558,6 +647,21 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { await this.loadOpenIDProviderConfiguration(false); } + const stateStorageKey: string = getStateStorageKey(extractPkceStorageKeyFromState(state ?? '')); + const expectedState: unknown = await this.storageManager.getHybridDataParameter(stateStorageKey, userId); + + // The stored state is left in place on a mismatch, so a stray callback cannot cancel the + // sign-in that is still in progress. + if (!state || typeof expectedState !== 'string' || !expectedState || state !== expectedState) { + throw new ThunderIDRuntimeError( + 'The state returned to the callback does not match the state of the authorization request.', + 'JS-AUTH_CORE-RAT1-IV04', + 'javascript', + ); + } + + await this.storageManager.removeHybridDataParameter(stateStorageKey, userId); + const tokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).token_endpoint; const configData = await this.configProvider(); @@ -630,7 +734,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { throw new ThunderIDAuthException( 'JS-AUTH_CORE-RAT1-HE03', `Requesting access token failed with ${tokenResponse.statusText}`, - (await tokenResponse.json()) as string, + await readErrorMessage(tokenResponse, 'The token request was rejected.'), ); } @@ -681,7 +785,12 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { return `${logoutEndpoint}?${queryParams.toString()}`; } + /** + * Returns the endpoints the SDK calls. Loads the discovery document first when no other call has. + */ protected async getOpenIDProviderEndpoints(): Promise> { + await this.loadOpenIDProviderConfiguration(false); + const meta: OIDCDiscoveryApiResponse = await this.oidcProviderMetaDataProvider(); return { @@ -697,22 +806,37 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { }; } + /** + * Returns the discovery document. Loads it first when no other call has. Resolves to `null` + * before `initialize()`. + */ public async getDiscoveryResponse(): Promise { if (!this.storageManager) { return null; } + await this.loadOpenIDProviderConfiguration(false); + return this.storageManager.loadOpenIDProviderConfiguration(); } + /** + * Returns the claims of the session's ID token, or of `idToken` when the session holds none. + * + * @throws {ThunderIDRuntimeError} With code `JS-AUTH_CORE-GDIT-NF01` when there is no ID token to decode. + */ protected async getDecodedIdToken(userId?: string, idToken?: string): Promise { - const storedIdToken: string = (await this.storageManager.getSessionData(userId)).id_token; + const token: string | undefined = (await this.storageManager.getSessionData(userId))?.id_token ?? idToken; + + if (!token) { + throw new ThunderIDRuntimeError('No signed-in session was found.', 'JS-AUTH_CORE-GDIT-NF01', 'javascript'); + } - return this.cryptoHelper.decodeJwtToken(storedIdToken ?? idToken); + return this.cryptoHelper.decodeJwtToken(token); } protected async getIdToken(userId?: string): Promise { - return (await this.storageManager.getSessionData(userId)).id_token; + return (await this.storageManager.getSessionData(userId))?.id_token; } protected async getUserSession(userId?: string): Promise { @@ -775,7 +899,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { throw new ThunderIDAuthException( 'JS-AUTH_CORE-RAT2-HE04', `Refreshing access token failed with ${tokenResponse.statusText}`, - (await tokenResponse.json()) as string, + await readErrorMessage(tokenResponse, 'The refresh token request was rejected.'), ); } @@ -862,7 +986,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { throw new ThunderIDAuthException( 'JS-AUTH_CORE-RCCT-HE03', `Requesting client credentials token failed with ${tokenResponse.statusText}`, - (await tokenResponse.json()) as string, + await readErrorMessage(tokenResponse, 'The client credentials token request was rejected.'), ); } @@ -932,7 +1056,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { throw new ThunderIDAuthException( 'JS-AUTH_CORE-RAT3-HE03', `Invalid response status received for revoke access token request (${response.statusText}).`, - (await response.json()) as string, + await readErrorMessage(response, 'The revocation request was rejected.'), ); } @@ -1052,7 +1176,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { if (!response.ok) { throw new ThunderIDAPIError( - parsedBody as string, + getOAuthErrorMessage(parsedBody, 'The backchannel authentication request was rejected.'), 'JS-AUTH_CORE-CIBA1-HE03', 'javascript', response.status, diff --git a/packages/javascript/src/__tests__/StorageManager.test.ts b/packages/javascript/src/__tests__/StorageManager.test.ts new file mode 100644 index 00000000..cbc2926e --- /dev/null +++ b/packages/javascript/src/__tests__/StorageManager.test.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import type {Storage} from '../models/store'; +import StorageManager from '../StorageManager'; + +class MemoryStore implements Storage { + private store = new Map(); + + async getData(key: string): Promise { + return this.store.get(key) ?? null!; + } + + async setData(key: string, value: string): Promise { + this.store.set(key, value); + } + + async removeData(key: string): Promise { + this.store.delete(key); + } +} + +describe('StorageManager', () => { + const original: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + + afterEach(() => { + if (original) { + Object.defineProperty(globalThis, 'localStorage', original); + } else { + delete (globalThis as {localStorage?: unknown}).localStorage; + } + }); + + it('keeps hybrid data in the given store without touching localStorage outside a browser window', async () => { + const read = vi.fn((): undefined => undefined); + Object.defineProperty(globalThis, 'localStorage', {configurable: true, get: read}); + const manager: StorageManager = new StorageManager('instance_0', new MemoryStore()); + + await manager.setHybridDataParameter('pkce_code_verifier#0', 'verifier', 'session-1'); + + expect(await manager.getHybridDataParameter('pkce_code_verifier#0', 'session-1')).toBe('verifier'); + expect(read).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.session.test.ts b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.session.test.ts new file mode 100644 index 00000000..e0a0c673 --- /dev/null +++ b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.session.test.ts @@ -0,0 +1,339 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {DefaultCrypto} from '../DefaultCrypto'; +import type {Storage} from '../models/store'; +import ThunderIDJavaScriptClient from '../ThunderIDJavaScriptClient'; + +class MemoryStore implements Storage { + private store = new Map(); + + async getData(key: string): Promise { + return this.store.get(key) ?? null!; + } + + async setData(key: string, value: string): Promise { + this.store.set(key, value); + } + + async removeData(key: string): Promise { + this.store.delete(key); + } +} + +const OIDC_META = { + authorization_endpoint: 'https://example.com/oauth2/authorize', + issuer: 'https://example.com', + revocation_endpoint: 'https://example.com/oauth2/revoke', + token_endpoint: 'https://example.com/oauth2/token', +}; + +const ID_TOKEN = `header.${Buffer.from(JSON.stringify({sub: 'user-1', username: 'alice'})).toString('base64url')}.sig`; + +const jsonResponse = (body: unknown, ok = true, status = 200): unknown => ({ + json: () => Promise.resolve(body), + ok, + status, + statusText: ok ? 'OK' : 'Bad Request', + text: () => Promise.resolve(JSON.stringify(body)), +}); + +const newClient = async (config: Record = {}, loadMetadata = true): Promise => { + const client: any = new ThunderIDJavaScriptClient(new MemoryStore(), new DefaultCrypto()); + + await client.initialize({ + afterSignInUrl: 'https://app.example.com/callback', + baseUrl: 'https://example.com', + clientId: 'test-client', + ...config, + }); + + if (loadMetadata) { + await client.storageManager.setOIDCProviderMetaData(OIDC_META); + await client.storageManager.setTemporaryDataParameter('op_config_initiated', true); + } + + return client; +}; + +const stateOf = (url: string): string => new URL(url).searchParams.get('state') ?? ''; + +describe('ThunderIDJavaScriptClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('authorization state', () => { + it('generates the authorization state from random bytes on every request', async () => { + const client = await newClient(); + + const first: string = stateOf(await client.getSignInUrl({}, 'session-1')); + const second: string = stateOf(await client.getSignInUrl({}, 'session-2')); + + expect(first).toMatch(/^instance_0_[A-Za-z0-9]{40,}_request_0$/); + expect(second).toMatch(/^instance_0_[A-Za-z0-9]{40,}_request_0$/); + expect(first).not.toBe(second); + expect(first).not.toBe('instance_0_request_0'); + }); + + it('keeps the random part free of the separators the state is parsed by', async () => { + const client = await newClient(); + + for (let i = 0; i < 50; i += 1) { + const state: string = stateOf(await client.getSignInUrl({}, `session-${i}`)); + const random: string = state.slice('instance_0_'.length, -'_request_0'.length); + + expect(random).toMatch(/^[A-Za-z0-9]+$/); + expect(random).not.toContain('request_'); + expect(state.split('request_')).toHaveLength(2); + } + }); + + it('exchanges the code when the callback carries the state the request sent', async () => { + const client = await newClient(); + const state: string = stateOf(await client.getSignInUrl({}, 'session-1')); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({access_token: 'at'})); + vi.stubGlobal('fetch', fetchMock); + const handleTokenResponse = vi.spyOn(client.authHelper, 'handleTokenResponse').mockResolvedValue({}); + + await client.requestAccessToken('code-1', state, 'session-1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(handleTokenResponse).toHaveBeenCalledTimes(1); + }); + + it('fails without a token request or a stored session when the state does not match', async () => { + const client = await newClient(); + const state: string = stateOf(await client.getSignInUrl({}, 'session-1')); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(client.requestAccessToken('code-1', 'instance_0_request_0', 'session-1')).rejects.toMatchObject({ + code: 'JS-AUTH_CORE-RAT1-IV04', + }); + await expect(client.requestAccessToken('code-1', `${state}x`, 'session-1')).rejects.toMatchObject({ + code: 'JS-AUTH_CORE-RAT1-IV04', + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(await client.storageManager.getSessionData('session-1')).toBeNull(); + }); + + it('fails when the state was issued for another session', async () => { + const client = await newClient(); + const state: string = stateOf(await client.getSignInUrl({}, 'session-1')); + vi.stubGlobal('fetch', vi.fn()); + + await expect(client.requestAccessToken('code-1', state, 'session-2')).rejects.toMatchObject({ + code: 'JS-AUTH_CORE-RAT1-IV04', + }); + }); + + it('accepts a state only once', async () => { + const client = await newClient(); + const state: string = stateOf(await client.getSignInUrl({}, 'session-1')); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({access_token: 'at'}))); + vi.spyOn(client.authHelper, 'handleTokenResponse').mockResolvedValue({}); + + await client.requestAccessToken('code-1', state, 'session-1'); + + await expect(client.requestAccessToken('code-1', state, 'session-1')).rejects.toMatchObject({ + code: 'JS-AUTH_CORE-RAT1-IV04', + }); + }); + }); + + describe('error messages', () => { + it('reads the message of a rejected token request from error and error_description', async () => { + const client = await newClient(); + const state: string = stateOf(await client.getSignInUrl({}, 'session-1')); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({error: 'invalid_grant', error_description: 'The code expired.'}, false, 400), + ), + ); + + await expect(client.requestAccessToken('code-1', state, 'session-1')).rejects.toMatchObject({ + code: 'JS-AUTH_CORE-RAT1-HE03', + message: 'invalid_grant: The code expired.', + }); + }); + + it('reads the message of a rejected client credentials request', async () => { + const client = await newClient({clientSecret: 'secret', grantType: 'client_credentials'}); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({error: 'unauthorized_client'}, false, 400))); + + await expect(client.getAccessToken()).rejects.toMatchObject({ + code: 'JS-AUTH_CORE-RCCT-HE03', + message: 'unauthorized_client', + }); + }); + + it('falls back to a fixed message when the revocation response has no error body', async () => { + const client = await newClient(); + await client.setSession({access_token: 'at'}, 'session-1'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse('not json', false, 400))); + + const error: Error = await client.revokeAccessToken('session-1').catch((e: Error) => e); + + expect(error).toMatchObject({code: 'JS-AUTH_CORE-RAT3-HE03'}); + expect(error.message).toBe('The revocation request was rejected.'); + }); + }); + + describe('without a session', () => { + it('getUser() fails with a documented error', async () => { + const client = await newClient(); + + await expect(client.getUser('missing')).rejects.toMatchObject({ + code: 'JS-AUTH_CORE-GU-NF01', + name: 'ThunderIDRuntimeError', + }); + }); + + it('getDecodedIdToken() fails with a documented error', async () => { + const client = await newClient(); + + await expect(client.getDecodedIdToken('missing')).rejects.toMatchObject({code: 'JS-AUTH_CORE-GDIT-NF01'}); + }); + + it('getDecodedIdToken() decodes a given ID token', async () => { + const client = await newClient(); + + await expect(client.getDecodedIdToken('missing', ID_TOKEN)).resolves.toMatchObject({sub: 'user-1'}); + }); + + it('getUser() returns the claims of a stored ID token', async () => { + const client = await newClient(); + await client.setSession({access_token: 'at', id_token: ID_TOKEN}, 'session-1'); + + await expect(client.getUser('session-1')).resolves.toMatchObject({username: 'alice'}); + }); + + it('exchangeToken() does not need an ID token when the template has no {{username}}', async () => { + const client = await newClient(); + await client.setSession({access_token: 'at'}, 'session-1'); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({access_token: 'exchanged'})); + vi.stubGlobal('fetch', fetchMock); + + await expect( + client.exchangeToken( + {data: {client_id: '{{clientId}}', subject_token: '{{accessToken}}'}, id: 'x'}, + 'session-1', + ), + ).resolves.toEqual({access_token: 'exchanged'}); + expect(fetchMock.mock.calls[0][1].body).toBe('client_id=test-client&subject_token=at'); + }); + }); + + describe('getConfiguration()', () => { + it('resolves to the configuration', async () => { + const client = await newClient(); + const result: unknown = client.getConfiguration(); + + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toMatchObject({clientId: 'test-client'}); + }); + }); + + describe('discovery', () => { + it('getOpenIDProviderEndpoints() loads the discovery document when no other call has', async () => { + const client = await newClient({}, false); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(OIDC_META))); + + await expect(client.getOpenIDProviderEndpoints()).resolves.toMatchObject({ + authorizationEndpoint: OIDC_META.authorization_endpoint, + tokenEndpoint: OIDC_META.token_endpoint, + }); + }); + + it('getDiscoveryResponse() loads the discovery document when no other call has', async () => { + const client = await newClient({}, false); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(OIDC_META))); + + await expect(client.getDiscoveryResponse()).resolves.toMatchObject({token_endpoint: OIDC_META.token_endpoint}); + }); + + it('validates the discovered issuer and stores nothing when it does not match', async () => { + const client = await newClient({}, false); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({...OIDC_META, issuer: 'https://evil.example'}))); + + await expect(client.getDiscoveryResponse()).rejects.toMatchObject({code: 'JS-AUTH_CORE-GOPMD-IV03'}); + expect(await client.storageManager.loadOpenIDProviderConfiguration()).toBeNull(); + expect(await client.storageManager.getTemporaryDataParameter('op_config_initiated')).toBeFalsy(); + }); + + it('rejects an issuer that only shares a prefix with the expected one', async () => { + const client = await newClient({}, false); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({...OIDC_META, issuer: 'https://example.com.attacker.test'})), + ); + + await expect(client.getOpenIDProviderEndpoints()).rejects.toMatchObject({code: 'JS-AUTH_CORE-GOPMD-IV03'}); + }); + + it('accepts the issuer of a local server at its base URL', async () => { + const client = await newClient({baseUrl: 'https://localhost:8090/'}, false); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({issuer: 'https://localhost:8090', token_endpoint: 'https://localhost:8090/oauth2/token'}), + ), + ); + + await expect(client.getDiscoveryResponse()).resolves.toMatchObject({issuer: 'https://localhost:8090'}); + }); + + it('ignores a trailing slash on either side of the comparison', async () => { + const withSlash = await newClient({}, false); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({...OIDC_META, issuer: 'https://example.com/'}))); + await expect(withSlash.getDiscoveryResponse()).resolves.toMatchObject({issuer: 'https://example.com/'}); + + const baseWithSlash = await newClient({baseUrl: 'https://example.com/'}, false); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(OIDC_META))); + await expect(baseWithSlash.getDiscoveryResponse()).resolves.toMatchObject({issuer: 'https://example.com'}); + }); + + it('compares the discovered issuer with endpoints.issuer when it is set', async () => { + const custom = await newClient({endpoints: {issuer: 'https://id.example.com'}}, false); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({...OIDC_META, issuer: 'https://id.example.com'}))); + await expect(custom.getDiscoveryResponse()).resolves.toMatchObject({token_endpoint: OIDC_META.token_endpoint}); + + // With the override set, the base URL is no longer accepted as the issuer. + const strict = await newClient({endpoints: {issuer: 'https://id.example.com'}}, false); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(OIDC_META))); + await expect(strict.getDiscoveryResponse()).rejects.toMatchObject({code: 'JS-AUTH_CORE-GOPMD-IV03'}); + }); + + it('names the setting to change, and nothing from the document, in the mismatch message', async () => { + const client = await newClient({}, false); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({...OIDC_META, issuer: 'https://other.example'}))); + + const error: Error = await client.getDiscoveryResponse().catch((e: Error) => e); + + expect(error.message).toContain('endpoints.issuer'); + expect(error.message).not.toContain('https://other.example'); + }); + + it('getDiscoveryResponse() does not fetch the document again once loaded', async () => { + const client = await newClient(); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await client.getDiscoveryResponse(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/javascript/src/constants/OIDCRequestConstants.ts b/packages/javascript/src/constants/OIDCRequestConstants.ts index b7b8fd31..20db8ba8 100644 --- a/packages/javascript/src/constants/OIDCRequestConstants.ts +++ b/packages/javascript/src/constants/OIDCRequestConstants.ts @@ -17,6 +17,11 @@ const OIDCRequestConstants: { readonly Payload: { readonly DEFAULT_SCOPES: readonly string[]; }; + readonly Storage: { + readonly StorageKeys: { + readonly STATE: string; + }; + }; }; readonly SignOut: { readonly Storage: { @@ -59,6 +64,16 @@ const OIDCRequestConstants: { */ DEFAULT_SCOPES: [ScopeConstants.OPENID, ScopeConstants.PROFILE], }, + + Storage: { + StorageKeys: { + /** + * Key prefix under which the `state` sent with an authorization request is kept, so the + * callback can be compared with it. Suffixed with the request index, like the PKCE key. + */ + STATE: 'authorization_state', + }, + }, }, /** diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts index 61a0e600..62b9aede 100644 --- a/packages/javascript/src/models/client.ts +++ b/packages/javascript/src/models/client.ts @@ -47,9 +47,9 @@ export interface ThunderIDClient { /** * Gets the client configuration. - * @returns The client configuration. + * @returns A promise that resolves to the client configuration. */ - getConfiguration(): T; + getConfiguration(): Promise; /** * Gets user information from the session. diff --git a/packages/javascript/src/utils/AuthenticationHelper.ts b/packages/javascript/src/utils/AuthenticationHelper.ts index 8a34c28d..b4e4acac 100644 --- a/packages/javascript/src/utils/AuthenticationHelper.ts +++ b/packages/javascript/src/utils/AuthenticationHelper.ts @@ -432,11 +432,12 @@ class AuthenticationHelper { return text; } + // Each value is read only when the template uses its placeholder, so a template without + // {{username}} works for a session that holds no ID token. return text - .replace(TokenExchangeConstants.Placeholders.ACCESS_TOKEN, sessionData.access_token) - .replace( - TokenExchangeConstants.Placeholders.USERNAME, - this.getAuthenticatedUserInfo(sessionData.id_token).username ?? '', + .replace(TokenExchangeConstants.Placeholders.ACCESS_TOKEN, (): string => sessionData?.access_token ?? '') + .replace(TokenExchangeConstants.Placeholders.USERNAME, (): string => + sessionData?.id_token ? (this.getAuthenticatedUserInfo(sessionData.id_token).username ?? '') : '', ) .replace(TokenExchangeConstants.Placeholders.SCOPES, scope) .replace(TokenExchangeConstants.Placeholders.CLIENT_ID, configData.clientId ?? '') diff --git a/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts b/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts index 9b6a628b..6faf3128 100644 --- a/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts +++ b/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts @@ -159,6 +159,21 @@ describe('getAuthorizeRequestUrlParams', (): void => { expect(params.get(OIDCRequestConstants.Params.STATE)).toBe('instance_0_sign-in-silently_request_1'); }); + it('should place the random state between the instance prefix and the request index', (): void => { + const params: Map = getAuthorizeRequestUrlParams( + { + clientId: 'client123', + instanceId: '0', + randomState: 'R4nd0m', + redirectUri: 'https://app/callback', + }, + {key: pkceKey}, + {[OIDCRequestConstants.Params.STATE]: 'sign-in-silently'}, + ); + + expect(params.get(OIDCRequestConstants.Params.STATE)).toBe('instance_0_sign-in-silently_R4nd0m_request_1'); + }); + it('should set scope to undefined if none provided', (): void => { const params: Map = getAuthorizeRequestUrlParams( { diff --git a/packages/javascript/src/utils/__tests__/getOAuthErrorMessage.test.ts b/packages/javascript/src/utils/__tests__/getOAuthErrorMessage.test.ts new file mode 100644 index 00000000..329e42f0 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/getOAuthErrorMessage.test.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import getOAuthErrorMessage from '../getOAuthErrorMessage'; + +describe('getOAuthErrorMessage', (): void => { + it('joins error and error_description', (): void => { + expect(getOAuthErrorMessage({error: 'invalid_grant', error_description: 'Expired.'}, 'fallback')).toBe( + 'invalid_grant: Expired.', + ); + }); + + it('uses whichever of the two is present', (): void => { + expect(getOAuthErrorMessage({error: 'invalid_client'}, 'fallback')).toBe('invalid_client'); + expect(getOAuthErrorMessage({error_description: 'Bad client.'}, 'fallback')).toBe('Bad client.'); + }); + + it('parses a JSON string body', (): void => { + expect(getOAuthErrorMessage('{"error":"invalid_request"}', 'fallback')).toBe('invalid_request'); + }); + + it('never copies other members of the body into the message', (): void => { + expect(getOAuthErrorMessage({access_token: 'secret-token', refresh_token: 'secret-refresh'}, 'fallback')).toBe( + 'fallback', + ); + }); + + it('falls back for a body that is not an OAuth error', (): void => { + expect(getOAuthErrorMessage(undefined, 'fallback')).toBe('fallback'); + expect(getOAuthErrorMessage('', 'fallback')).toBe('fallback'); + expect(getOAuthErrorMessage({error: 42}, 'fallback')).toBe('fallback'); + }); +}); diff --git a/packages/javascript/src/utils/base64Encode.ts b/packages/javascript/src/utils/base64Encode.ts index ae016fd9..ab9957ec 100644 --- a/packages/javascript/src/utils/base64Encode.ts +++ b/packages/javascript/src/utils/base64Encode.ts @@ -1,12 +1,13 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import * as jose from 'jose'; +// Subpath imports keep jose's JWE code, whose CompressionStream use the Edge runtime rejects, out of the bundle. +import * as base64url from 'jose/base64url'; /** * Encodes a string to standard base64 using `jose` (already a package dependency). * - * `jose.base64url.encode` is environment-agnostic (browser, Node.js, Deno, Bun, + * jose's `base64url.encode` is environment-agnostic (browser, Node.js, Deno, Bun, * edge/service-worker runtimes). It produces base64url output, which is then * converted to standard base64 by restoring the `+`/`/` characters and adding * `=` padding. @@ -20,7 +21,7 @@ import * as jose from 'jose'; * ``` */ const base64Encode = (value: string): string => { - const b64url: string = jose.base64url.encode(new TextEncoder().encode(value)); + const b64url: string = base64url.encode(new TextEncoder().encode(value)); const rem: number = b64url.length % 4; const padded: string = rem === 0 ? b64url : b64url + '='.repeat(4 - rem); diff --git a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts index 5fa4d1c8..9d049433 100644 --- a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts +++ b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts @@ -12,7 +12,7 @@ import {ExtendedAuthorizeRequestUrlParams} from '../models/oauth-request'; * This utility ensures the `openid` scope is always included, handles both string and array forms of the `scope` parameter, * and supports PKCE and custom parameters. Throws if a code challenge is provided without a code challenge method. * - * @param options - The main options for the authorization request, including redirectUri, clientId, scope, responseMode, codeChallenge, codeChallengeMethod, and prompt. + * @param options - The main options for the authorization request, including redirectUri, clientId, scope, responseMode, codeChallenge, codeChallengeMethod, prompt, and randomState (a value generated from random bytes that makes the `state` unique to this request). * @param pkceOptions - PKCE options, including the PKCE key for state correlation. * @param customParams - Optional custom parameters to include in the request (excluding the `state` param, which is handled separately). * @returns A Map of key-value pairs representing the authorization request URL parameters. @@ -42,6 +42,7 @@ const getAuthorizeRequestUrlParams = ( codeChallengeMethod?: string; instanceId?: string; prompt?: string; + randomState?: string; redirectUri: string; responseMode?: string; scopes?: string; @@ -104,6 +105,12 @@ const getAuthorizeRequestUrlParams = ( customStateValue = AUTH_INSTANCE_PREFIX + options.instanceId + (customState ? `_${customState}` : ''); } + // The random part goes after the routing prefix and before the request index, so the callback + // can still be routed and correlated while the full value cannot be predicted. + if (options.randomState) { + customStateValue = customStateValue ? `${customStateValue}_${options.randomState}` : options.randomState; + } + authorizeRequestParams.set( OIDCRequestConstants.Params.STATE, generateStateParamForRequestCorrelation(pkceKey, customStateValue), diff --git a/packages/javascript/src/utils/getOAuthErrorMessage.ts b/packages/javascript/src/utils/getOAuthErrorMessage.ts new file mode 100644 index 00000000..cfb08ff4 --- /dev/null +++ b/packages/javascript/src/utils/getOAuthErrorMessage.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Builds a readable message from the body of an OAuth 2.0 error response (RFC 6749 section 5.2). + * + * Only the `error` and `error_description` members are read, so nothing else in the body, such as + * a token, can end up in the message. + * + * @param body - The parsed response body, or its raw text. + * @param fallback - The message to use when the body carries neither member. + * @returns `": "`, whichever of the two is present, or `fallback`. + * + * @example + * getOAuthErrorMessage({error: 'invalid_grant', error_description: 'The code has expired.'}, 'Failed'); + * // Returns: "invalid_grant: The code has expired." + */ +const getOAuthErrorMessage = (body: unknown, fallback: string): string => { + let parsed: unknown = body; + + if (typeof body === 'string') { + try { + parsed = JSON.parse(body); + } catch { + return fallback; + } + } + + if (!parsed || typeof parsed !== 'object') { + return fallback; + } + + const {error, error_description: description} = parsed as {error?: unknown; error_description?: unknown}; + const parts: string[] = [error, description].filter( + (part: unknown): part is string => typeof part === 'string' && part.trim().length > 0, + ); + + return parts.length > 0 ? parts.join(': ') : fallback; +}; + +export default getOAuthErrorMessage; diff --git a/packages/javascript/tsconfig.json b/packages/javascript/tsconfig.json index 529abca5..9f691c51 100644 --- a/packages/javascript/tsconfig.json +++ b/packages/javascript/tsconfig.json @@ -9,6 +9,10 @@ "lib": ["ESNext", "DOM"], "module": "ESNext", "moduleResolution": "node", + "paths": { + "jose/base64url": ["./node_modules/jose/dist/types/util/base64url"], + "jose/*": ["./node_modules/jose/dist/types/*"] + }, "skipLibCheck": true, "skipDefaultLibCheck": true, "sourceMap": true, diff --git a/packages/nextjs/src/ThunderIDNextClient.ts b/packages/nextjs/src/ThunderIDNextClient.ts index 2fe5b944..0518f381 100644 --- a/packages/nextjs/src/ThunderIDNextClient.ts +++ b/packages/nextjs/src/ThunderIDNextClient.ts @@ -349,7 +349,7 @@ class ThunderIDNextClient e throw new Error('The second argument must be a string.'); } - const config: T = this.getConfiguration(); + const config: T = await this.getConfiguration(); const afterSignOutUrl: string = config?.afterSignOutUrl || '/'; const resolvedSessionId: string = args[1] || (await getSessionId())!; diff --git a/packages/nextjs/src/client/__tests__/fetchFlowMetaFromServer.test.ts b/packages/nextjs/src/client/__tests__/fetchFlowMetaFromServer.test.ts new file mode 100644 index 00000000..4fca05c0 --- /dev/null +++ b/packages/nextjs/src/client/__tests__/fetchFlowMetaFromServer.test.ts @@ -0,0 +1,48 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDAPIError, ThunderIDRuntimeError} from '@thunderid/node'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import getFlowMetaAction from '../../server/actions/getFlowMetaAction'; +import fetchFlowMetaFromServer from '../fetchFlowMetaFromServer'; + +vi.mock('../../server/actions/getFlowMetaAction', () => ({default: vi.fn()})); + +describe('fetchFlowMetaFromServer', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns the metadata the server action produced and forwards the request', async () => { + const meta = {design: {}, i18n: {}} as never; + vi.mocked(getFlowMetaAction).mockResolvedValue({meta, success: true}); + + await expect(fetchFlowMetaFromServer({applicationId: 'app-1', language: 'fr'})).resolves.toBe(meta); + expect(getFlowMetaAction).toHaveBeenCalledWith({applicationId: 'app-1', language: 'fr'}); + }); + + it('throws a runtime error when the action reports a failure without a status', async () => { + vi.mocked(getFlowMetaAction).mockResolvedValue({ + message: 'Failed to load flow metadata: no base URL is configured.', + success: false, + }); + + const thrown = await fetchFlowMetaFromServer({}).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(ThunderIDRuntimeError); + expect((thrown as Error).message).toBe('Failed to load flow metadata: no base URL is configured.'); + }); + + it('throws an API error carrying the status when the action reports one', async () => { + vi.mocked(getFlowMetaAction).mockResolvedValue({ + message: 'Failed to load flow metadata (HTTP 404).', + statusCode: 404, + success: false, + }); + + const thrown = await fetchFlowMetaFromServer({}).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(ThunderIDAPIError); + expect((thrown as ThunderIDAPIError).statusCode).toBe(404); + }); +}); diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index 3027ac90..c0a93430 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -31,6 +31,7 @@ import {FC, PropsWithChildren, RefObject, useEffect, useMemo, useRef, useState} import ThunderIDContext, {ThunderIDContextProps} from './ThunderIDContext'; import {RefreshResult} from '../../../server/actions/refreshToken'; import logger from '../../../utils/logger'; +import fetchFlowMetaFromServer from '../../fetchFlowMetaFromServer'; /** * Props interface of {@link ThunderIDClientProvider} @@ -39,14 +40,6 @@ export type ThunderIDClientProviderProps = Partial & { applicationId: ThunderIDContextProps['applicationId']; clearSession: () => Promise; - /** - * Server Action that fetches `GET /flow/meta` server-side, passed down to `FlowMetaProvider` so - * it never calls `baseUrl` directly from the browser β€” avoiding the CORS requirement that a - * direct client-side fetch to the ThunderID server would otherwise impose. Used for both the - * initial fetch (when `initialMeta` isn't seeded, e.g. it failed server-side) and subsequent - * `switchLanguage()` calls. - */ - fetchMeta?: (params: {applicationId?: string; language?: string}) => Promise; handleOAuthCallback: ( code: string, state: string, @@ -93,7 +86,6 @@ const ThunderIDClientProvider: FC) => { const reRenderCheckRef: RefObject = useRef(false); const router: AppRouterInstance = useRouter(); @@ -399,7 +391,7 @@ const ThunderIDClientProvider: FC diff --git a/packages/nextjs/src/client/fetchFlowMetaFromServer.ts b/packages/nextjs/src/client/fetchFlowMetaFromServer.ts new file mode 100644 index 00000000..88a7f4cb --- /dev/null +++ b/packages/nextjs/src/client/fetchFlowMetaFromServer.ts @@ -0,0 +1,29 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {FlowMetadataResponse, ThunderIDAPIError, ThunderIDRuntimeError} from '@thunderid/node'; +import getFlowMetaAction, {GetFlowMetaActionResult} from '../server/actions/getFlowMetaAction'; + +/** + * The `fetchMeta` behind `FlowMetaProvider`: loads flow metadata through {@link getFlowMetaAction} so the browser + * never calls `baseUrl` directly, and throws the failure the action returned as an SDK error, which + * `FlowMetaProvider` reports through its `error` state. + */ +const fetchFlowMetaFromServer = async (params: { + applicationId?: string; + language?: string; +}): Promise => { + const result: GetFlowMetaActionResult = await getFlowMetaAction(params); + + if (result.success) { + return result.meta; + } + + if (result.statusCode !== undefined) { + throw new ThunderIDAPIError(result.message, 'getFlowMetaAction-Error-001', 'nextjs', result.statusCode); + } + + throw new ThunderIDRuntimeError(result.message, 'getFlowMetaAction-Error-001', 'nextjs'); +}; + +export default fetchFlowMetaFromServer; diff --git a/packages/nextjs/src/server/ThunderIDProvider.tsx b/packages/nextjs/src/server/ThunderIDProvider.tsx index 84ca163d..65221527 100644 --- a/packages/nextjs/src/server/ThunderIDProvider.tsx +++ b/packages/nextjs/src/server/ThunderIDProvider.tsx @@ -30,7 +30,6 @@ import signInAction from './actions/signInAction'; import signOutAction from './actions/signOutAction'; import signUpAction from './actions/signUpAction'; import updateUserProfileAction from './actions/updateUserProfileAction'; -import getFlowMetaAction from './actions/getFlowMetaAction'; import getClient from './getClient'; import ThunderIDClientProvider from '../client/contexts/ThunderID/ThunderIDProvider.js'; import {ThunderIDNextConfig} from '../models/config'; @@ -205,7 +204,6 @@ const ThunderIDServerProvider: FC ({default: vi.fn()})); +vi.mock('@thunderid/node', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + getFlowMeta: vi.fn(), +})); + +const mockGetConfiguration = vi.fn(); + +const meta = {design: {}, i18n: {}} as unknown as Awaited>; + +describe('getFlowMetaAction', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getClient).mockReturnValue({ + getConfiguration: mockGetConfiguration, + } as unknown as ReturnType); + }); + + it('returns an error result instead of throwing when no base URL is configured', async () => { + mockGetConfiguration.mockResolvedValue({applicationId: 'app-1'}); + + const result = await getFlowMetaAction(); + + expect(result).toEqual({message: 'Failed to load flow metadata: no base URL is configured.', success: false}); + expect(getFlowMeta).not.toHaveBeenCalled(); + }); + + it('fetches with an endpoints.flowMeta override even without a base URL', async () => { + mockGetConfiguration.mockResolvedValue({endpoints: {flowMeta: 'https://rs.example.com/flow/meta'}}); + vi.mocked(getFlowMeta).mockResolvedValue(meta); + + await expect(getFlowMetaAction()).resolves.toEqual({meta, success: true}); + expect(getFlowMeta).toHaveBeenCalledWith(expect.objectContaining({url: 'https://rs.example.com/flow/meta'})); + }); + + it('returns the metadata for the configured application and requested language', async () => { + mockGetConfiguration.mockResolvedValue({applicationId: 'app-1', baseUrl: 'https://localhost:8090'}); + vi.mocked(getFlowMeta).mockResolvedValue(meta); + + const result = await getFlowMetaAction({language: 'fr'}); + + expect(result).toEqual({meta, success: true}); + expect(getFlowMeta).toHaveBeenCalledWith({ + baseUrl: 'https://localhost:8090', + id: 'app-1', + language: 'fr', + type: 'APP', + url: undefined, + }); + }); + + it('maps an API failure to a plain result with the status, without the upstream reply text', async () => { + mockGetConfiguration.mockResolvedValue({baseUrl: 'https://localhost:8090'}); + vi.mocked(getFlowMeta).mockRejectedValue( + new ThunderIDAPIError('upstream secret detail', 'getFlowMeta-ResponseError-001', 'javascript', 404), + ); + + await expect(getFlowMetaAction()).resolves.toEqual({ + message: 'Failed to load flow metadata (HTTP 404).', + statusCode: 404, + success: false, + }); + }); + + it('maps any other failure to a plain result', async () => { + mockGetConfiguration.mockResolvedValue({baseUrl: 'https://localhost:8090'}); + vi.mocked(getFlowMeta).mockRejectedValue(new TypeError('fetch failed')); + + await expect(getFlowMetaAction()).resolves.toEqual({message: 'Failed to load flow metadata.', success: false}); + }); +}); diff --git a/packages/nextjs/src/server/actions/getFlowMetaAction.ts b/packages/nextjs/src/server/actions/getFlowMetaAction.ts index b02cfbe7..99ab53ef 100644 --- a/packages/nextjs/src/server/actions/getFlowMetaAction.ts +++ b/packages/nextjs/src/server/actions/getFlowMetaAction.ts @@ -3,11 +3,26 @@ 'use server'; -import {FlowMetadataResponse, FlowMetaType, getFlowMeta, resolveResourceEndpoint} from '@thunderid/node'; +import { + FlowMetadataResponse, + FlowMetaType, + getFlowMeta, + resolveResourceEndpoint, + ThunderIDAPIError, +} from '@thunderid/node'; import {ThunderIDNextConfig} from '../../models/config'; import logger from '../../utils/logger'; import getClient from '../getClient'; +/** + * What {@link getFlowMetaAction} returns: the flow metadata, or a plain failure. It never throws, because a thrown + * error reaches the browser as a generic 500 and loses its prototype chain crossing the server action boundary. A + * failure carries a message built on the server and the status, never the text of the upstream reply. + */ +export type GetFlowMetaActionResult = + | {meta: FlowMetadataResponse; success: true} + | {message: string; statusCode?: number; success: false}; + /** * Server Action that fetches `GET /flow/meta` server-side. * @@ -16,28 +31,47 @@ import getClient from '../getClient'; * origin. Routing it through this action instead means the browser never talks to `baseUrl` * directly β€” the request happens server-to-server, same as `signInAction`/`signUpAction`. * + * Never throws across the server action boundary; failures are mapped to a plain result. + * * @param language - Optional language override, used by `FlowMetaProvider.switchLanguage()`. */ const getFlowMetaAction = async (params?: { applicationId?: string; language?: string; -}): Promise => { - const client = getClient(); - const config: ThunderIDNextConfig = await client.getConfiguration(); +}): Promise => { + try { + const client = getClient(); + const config: ThunderIDNextConfig = await client.getConfiguration(); - const applicationId: string | undefined = params?.applicationId ?? config?.applicationId; + const url: string | undefined = resolveResourceEndpoint('flowMeta', config); - try { - return await getFlowMeta({ + if (!url && !config?.baseUrl) { + return {message: 'Failed to load flow metadata: no base URL is configured.', success: false}; + } + + const applicationId: string | undefined = params?.applicationId ?? config?.applicationId; + + const meta: FlowMetadataResponse = await getFlowMeta({ baseUrl: config?.baseUrl, - url: resolveResourceEndpoint('flowMeta', config), + url, ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), ...(params?.language ? {language: params.language} : {}), }); + + return {meta, success: true}; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error(`[getFlowMetaAction] Error fetching flow metadata: ${message}`); - throw error; + + if (error instanceof ThunderIDAPIError && error.statusCode !== undefined) { + return { + message: `Failed to load flow metadata (HTTP ${error.statusCode}).`, + statusCode: error.statusCode, + success: false, + }; + } + + return {message: 'Failed to load flow metadata.', success: false}; } }; diff --git a/packages/nextjs/src/server/proxy/__tests__/createRouteMatcher.test.ts b/packages/nextjs/src/server/proxy/__tests__/createRouteMatcher.test.ts new file mode 100644 index 00000000..0aba0d7e --- /dev/null +++ b/packages/nextjs/src/server/proxy/__tests__/createRouteMatcher.test.ts @@ -0,0 +1,67 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {NextRequest} from 'next/server'; +import {describe, expect, it} from 'vitest'; +import createRouteMatcher from '../createRouteMatcher'; + +const request = (pathname: string): NextRequest => ({nextUrl: {pathname}}) as unknown as NextRequest; + +describe('createRouteMatcher', () => { + it.each([ + ['/dashboard(.*)', '/dashboard'], + ['/dashboard(.*)', '/dashboard/'], + ['/dashboard(.*)', '/dashboard/settings'], + ['/dashboard(.*)', '/dashboard/a/b/c'], + ['/admin(.*)', '/admin/users'], + ['/settings(.*)', '/settings/profile'], + ['/api/private(.*)', '/api/private'], + ['/api/private(.*)', '/api/private/data'], + ])('matches the documented pattern %s against %s', (pattern: string, pathname: string) => { + expect(createRouteMatcher([pattern])(request(pathname))).toBe(true); + }); + + it.each([ + ['/dashboard(.*)', '/'], + ['/dashboard(.*)', '/other/dashboard'], + ['/api/private(.*)', '/api/public/data'], + ])('does not match the documented pattern %s against %s', (pattern: string, pathname: string) => { + expect(createRouteMatcher([pattern])(request(pathname))).toBe(false); + }); + + it('matches an exact path only', () => { + const isProfile = createRouteMatcher(['/profile']); + + expect(isProfile(request('/profile'))).toBe(true); + expect(isProfile(request('/profile/edit'))).toBe(false); + }); + + it('treats a dot outside a group as a literal dot', () => { + const isFile = createRouteMatcher(['/robots.txt']); + + expect(isFile(request('/robots.txt'))).toBe(true); + expect(isFile(request('/robotsXtxt'))).toBe(false); + }); + + it('treats a bare * as a wildcard', () => { + const isProtected = createRouteMatcher(['/protected/*']); + + expect(isProtected(request('/protected/settings'))).toBe(true); + expect(isProtected(request('/protected'))).toBe(false); + }); + + it('keeps other regex groups such as (.+) working', () => { + const isUser = createRouteMatcher(['/users/(.+)']); + + expect(isUser(request('/users/42'))).toBe(true); + expect(isUser(request('/users/'))).toBe(false); + }); + + it('matches when any of several patterns matches', () => { + const isProtected = createRouteMatcher(['/dashboard(.*)', '/profile']); + + expect(isProtected(request('/profile'))).toBe(true); + expect(isProtected(request('/dashboard/x'))).toBe(true); + expect(isProtected(request('/public'))).toBe(false); + }); +}); diff --git a/packages/nextjs/src/server/proxy/createRouteMatcher.ts b/packages/nextjs/src/server/proxy/createRouteMatcher.ts index 2b175906..8f86189c 100644 --- a/packages/nextjs/src/server/proxy/createRouteMatcher.ts +++ b/packages/nextjs/src/server/proxy/createRouteMatcher.ts @@ -22,16 +22,38 @@ import {NextRequest} from 'next/server'; * } * ``` */ -const createRouteMatcher = (patterns: string[]): ((req: NextRequest) => boolean) => { - const regexPatterns: RegExp[] = patterns.map((pattern: string): RegExp => { - // Convert glob-like patterns to regex - const regexPattern: string = pattern - .replace(/\./g, '\\.') // Escape dots - .replace(/\*/g, '.*') // Convert * to .* - .replace(/\(\.\*\)/g, '(.*)'); // Handle explicit (.*) patterns +/** + * Converts one route pattern to a regular expression source. A parenthesized group such as `(.*)` is kept as + * regex, so `/dashboard(.*)` matches `/dashboard` and everything under it. Outside a group a `.` is a literal dot + * and a `*` matches any run of characters. + */ +const toRegexSource = (pattern: string): string => { + let source = ''; + let depth = 0; + + for (const char of pattern) { + if (char === '(') { + depth += 1; + source += char; + } else if (char === ')') { + depth = Math.max(depth - 1, 0); + source += char; + } else if (depth > 0) { + source += char; + } else if (char === '.') { + source += '\\.'; + } else if (char === '*') { + source += '.*'; + } else { + source += char; + } + } - return new RegExp(`^${regexPattern}$`); - }); + return source; +}; + +const createRouteMatcher = (patterns: string[]): ((req: NextRequest) => boolean) => { + const regexPatterns: RegExp[] = patterns.map((pattern: string): RegExp => new RegExp(`^${toRegexSource(pattern)}$`)); return (req: NextRequest): boolean => { const {pathname} = req.nextUrl; diff --git a/packages/nextjs/src/utils/SessionManager.ts b/packages/nextjs/src/utils/SessionManager.ts index 3613adf2..88a5010e 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import {ThunderIDRuntimeError, CookieConfig} from '@thunderid/node'; -import {SignJWT, jwtVerify, compactVerify, JWTPayload} from 'jose'; +import type {JWTPayload} from 'jose'; +// Subpath imports keep the proxy's bundle free of jose's JWE code, whose CompressionStream use the Edge runtime rejects. +import {compactVerify} from 'jose/jws/compact/verify'; +import {SignJWT} from 'jose/jwt/sign'; +import {jwtVerify} from 'jose/jwt/verify'; import {getLoggedOutSessions} from './loggedOutSessions'; import {DEFAULT_SESSION_COOKIE_EXPIRY_TIME} from '../constants/sessionConstants'; diff --git a/packages/nextjs/tsconfig.json b/packages/nextjs/tsconfig.json index 65129c48..c39a2a1a 100644 --- a/packages/nextjs/tsconfig.json +++ b/packages/nextjs/tsconfig.json @@ -10,6 +10,9 @@ "lib": ["DOM", "ESNext"], "module": "ESNext", "moduleResolution": "node", + "paths": { + "jose/*": ["./node_modules/jose/dist/types/*"] + }, "skipLibCheck": true, "skipDefaultLibCheck": true, "sourceMap": true, diff --git a/packages/node/rolldown.config.js b/packages/node/rolldown.config.js index 5add6b92..eb9c9f11 100644 --- a/packages/node/rolldown.config.js +++ b/packages/node/rolldown.config.js @@ -7,7 +7,10 @@ import {defineConfig} from 'rolldown'; const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); -const external = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; +const dependencies = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; + +// A dependency's subpath imports (for example `jose/jwt/verify`) stay external too. +const external = (id) => dependencies.some((dep) => id === dep || id.startsWith(`${dep}/`)); const commonOptions = { input: [join('src', 'index.ts')], diff --git a/packages/node/src/ThunderIDNodeClient.ts b/packages/node/src/ThunderIDNodeClient.ts index 2e90ea7c..dbfbdb28 100644 --- a/packages/node/src/ThunderIDNodeClient.ts +++ b/packages/node/src/ThunderIDNodeClient.ts @@ -32,7 +32,6 @@ import {ThunderIDNodeConfig} from './models/config'; import MemoryCacheStore from './stores/MemoryCacheStore'; import NodeCryptoUtils from './utils/NodeCryptoUtils'; import SessionIndex, {IndexedSessions, SessionBinding} from './utils/SessionIndex'; -import SessionUtils from './utils/SessionUtils'; class ThunderIDNodeClient extends ThunderIDJavaScriptClient { private _nodeInstanceId = 0; @@ -40,6 +39,12 @@ class ThunderIDNodeClient e // Logout tokens already handled, by `jti`, with the time each expires in milliseconds. private _handledLogoutTokens: Map = new Map(); + // Refreshes in progress, by session, so concurrent callers share one. + private _refreshesInFlight: Map> = new Map< + string, + Promise + >(); + public constructor(instanceId = 0) { super(undefined, new NodeCryptoUtils()); this._nodeInstanceId = instanceId; @@ -307,22 +312,39 @@ class ThunderIDNodeClient e this._handledLogoutTokens.set(claims.jti, claims.exp * 1000); } + /** + * Whether the session holds a valid access token. An expired access token is refreshed when the + * session holds a refresh token. A session whose access token expired and cannot be refreshed is + * removed. + */ public override async isSignedIn(userId?: string): Promise { try { - if (!(await super.isSignedIn(userId))) { - return false; + // The parent answers for an unexpired access token, and gets a new one for a + // client_credentials client. + if (await super.isSignedIn(userId)) { + return true; } + const sm = this.getStorageManager(); - const sessionData = await sm.getSessionData(userId); - if (await SessionUtils.validateSession(sessionData)) { - return true; + const sessionData: SessionData = await sm.getSessionData(userId); + + if (!sessionData?.access_token) { + return false; } - const refreshedToken = await this.refreshAccessToken(userId); - if (refreshedToken) { - return true; + + if (sessionData.refresh_token) { + try { + await this.refreshAccessToken(userId); + + return true; + } catch (error: unknown) { + logger.debug('The expired session could not be refreshed, so it is removed.', error); + } } + await this.forgetSession(userId, sessionData); await sm.removeSessionData(userId); + return false; } catch { return false; @@ -342,8 +364,30 @@ class ThunderIDNodeClient e return super.getIdToken(userId); } + /** + * Refreshes the session's tokens. Concurrent calls for one session share a single request, so a + * rotated refresh token is not sent twice. + */ public override async refreshAccessToken(userId?: string): Promise { - return super.refreshAccessToken(userId); + const key: string = userId ?? ''; + const inFlight: Promise | undefined = this._refreshesInFlight.get(key); + + if (inFlight) { + return inFlight; + } + + const refresh: Promise = (async (): Promise => { + // A process can serve a stored session before any call loaded the discovery document. + await this.loadOpenIDProviderConfiguration(false); + + return super.refreshAccessToken(userId); + })().finally((): void => { + this._refreshesInFlight.delete(key); + }); + + this._refreshesInFlight.set(key, refresh); + + return refresh; } public override async revokeAccessToken(userId?: string): Promise { diff --git a/packages/node/src/__tests__/ThunderIDNodeClient.isSignedIn.test.ts b/packages/node/src/__tests__/ThunderIDNodeClient.isSignedIn.test.ts new file mode 100644 index 00000000..31f70e7e --- /dev/null +++ b/packages/node/src/__tests__/ThunderIDNodeClient.isSignedIn.test.ts @@ -0,0 +1,117 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {randomUUID} from 'node:crypto'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import ThunderIDNodeClient from '../ThunderIDNodeClient'; + +// The default store is one process-wide cache, so every test uses a session ID of its own. +let s1: string; + +const OIDC_META = {issuer: 'https://auth.example.com', token_endpoint: 'https://auth.example.com/oauth2/token'}; + +const newClient = async (): Promise => { + const client: any = new ThunderIDNodeClient(); + await client.initialize({baseUrl: 'https://auth.example.com', clientId: 'test-client'}); + await client.getStorageManager().setOIDCProviderMetaData(OIDC_META); + await client.getStorageManager().setTemporaryDataParameter('op_config_initiated', true); + + return client; +}; + +const expiredSession = (refreshToken?: string): Record => ({ + access_token: 'old-at', + created_at: Date.now() - 120_000, + expires_in: '60', + ...(refreshToken ? {refresh_token: refreshToken} : {}), +}); + +const tokenResponse = (): unknown => ({ + json: () => + Promise.resolve({access_token: 'new-at', expires_in: '3600', refresh_token: 'new-rt', token_type: 'Bearer'}), + ok: true, + status: 200, +}); + +describe('ThunderIDNodeClient.isSignedIn', () => { + beforeEach(() => { + s1 = randomUUID(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('is true for an unexpired access token without a request', async () => { + const client = await newClient(); + await client.setSession({access_token: 'at', created_at: Date.now(), expires_in: '3600'}, s1); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(client.isSignedIn(s1)).resolves.toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes an expired access token when the session has a refresh token', async () => { + const client = await newClient(); + await client.setSession(expiredSession('old-rt'), s1); + vi.spyOn(client.authHelper, 'handleTokenResponse').mockImplementation(async () => { + await client.setSession({access_token: 'new-at', created_at: Date.now(), expires_in: '3600'}, s1); + + return {}; + }); + const fetchMock = vi.fn().mockResolvedValue(tokenResponse()); + vi.stubGlobal('fetch', fetchMock); + + await expect(client.isSignedIn(s1)).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][1].body)).toContain('refresh_token=old-rt'); + expect((await client.getStorageManager().getSessionData(s1)).access_token).toBe('new-at'); + }); + + it('sends one refresh request for concurrent checks of one session', async () => { + const client = await newClient(); + await client.setSession(expiredSession('old-rt'), s1); + vi.spyOn(client.authHelper, 'handleTokenResponse').mockResolvedValue({}); + const fetchMock = vi.fn().mockResolvedValue(tokenResponse()); + vi.stubGlobal('fetch', fetchMock); + + await expect(Promise.all([client.isSignedIn(s1), client.isSignedIn(s1)])).resolves.toEqual([true, true]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('removes an expired session that has no refresh token', async () => { + const client = await newClient(); + await client.setSession(expiredSession(), s1); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(client.isSignedIn(s1)).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + expect((await client.getStorageManager().getSessionData(s1)).access_token).toBeUndefined(); + }); + + it('removes an expired session whose refresh is rejected', async () => { + const client = await newClient(); + await client.setSession(expiredSession('old-rt'), s1); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + json: () => Promise.resolve({error: 'invalid_grant'}), + ok: false, + status: 400, + statusText: 'Bad Request', + }), + ); + + await expect(client.isSignedIn(s1)).resolves.toBe(false); + expect((await client.getStorageManager().getSessionData(s1)).access_token).toBeUndefined(); + }); + + it('is false for a session that does not exist', async () => { + const client = await newClient(); + + await expect(client.isSignedIn('missing')).resolves.toBe(false); + }); +}); diff --git a/packages/node/src/utils/NodeCryptoUtils.ts b/packages/node/src/utils/NodeCryptoUtils.ts index fb440647..248b7804 100644 --- a/packages/node/src/utils/NodeCryptoUtils.ts +++ b/packages/node/src/utils/NodeCryptoUtils.ts @@ -4,7 +4,10 @@ import {Crypto, JWKInterface} from '@thunderid/javascript'; import base64url from 'base64url'; import sha256 from 'fast-sha256'; -import * as jose from 'jose'; +import type {CryptoKey} from 'jose'; +// Subpath imports keep jose's JWE code, whose CompressionStream use the Edge runtime rejects, out of the bundle. +import {importJWK} from 'jose/key/import'; +import {jwtVerify} from 'jose/jwt/verify'; import randombytes from 'secure-random-bytes'; /** @@ -37,16 +40,14 @@ class NodeCryptoUtils implements Crypto { subject: string, clockTolerance?: number, ): Promise { - const key: jose.CryptoKey | Uint8Array = await jose.importJWK(jwk); - return jose - .jwtVerify(idToken, key, { - algorithms, - audience: [clientId], - clockTolerance, - issuer, - subject, - }) - .then(() => Promise.resolve(true)); + const key: CryptoKey | Uint8Array = await importJWK(jwk); + return jwtVerify(idToken, key, { + algorithms, + audience: [clientId], + clockTolerance, + issuer, + subject, + }).then(() => Promise.resolve(true)); } } diff --git a/packages/node/src/utils/SessionUtils.ts b/packages/node/src/utils/SessionUtils.ts index 72f3804d..61e24178 100644 --- a/packages/node/src/utils/SessionUtils.ts +++ b/packages/node/src/utils/SessionUtils.ts @@ -36,11 +36,12 @@ class SessionUtils { /** * Returns `true` if the session token is still within its validity window. * - * @param sessionData - The session data to check. + * @param sessionData - The session data to check. `expires_in` is in seconds, as the token + * endpoint returns it, and `created_at` in milliseconds. */ public static validateSession(sessionData: SessionData): Promise { const currentTime: number = Date.now(); - const expiryTimeStamp: number = sessionData.created_at + parseInt(sessionData.expires_in, 10) * 60 * 1000; + const expiryTimeStamp: number = sessionData.created_at + parseInt(sessionData.expires_in, 10) * 1000; return Promise.resolve(currentTime < expiryTimeStamp); } } diff --git a/packages/node/src/utils/__tests__/SessionUtils.test.ts b/packages/node/src/utils/__tests__/SessionUtils.test.ts new file mode 100644 index 00000000..9e8c5bdb --- /dev/null +++ b/packages/node/src/utils/__tests__/SessionUtils.test.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {SessionData} from '@thunderid/javascript'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import SessionUtils from '../SessionUtils'; + +const session = (createdAt: number, expiresIn: string): SessionData => + ({access_token: 'at', created_at: createdAt, expires_in: expiresIn}) as SessionData; + +describe('SessionUtils.validateSession', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('reads expires_in as seconds', async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + + // Issued 61 seconds ago with a 60 second lifetime: expired. + await expect(SessionUtils.validateSession(session(1_000_000 - 61_000, '60'))).resolves.toBe(false); + // Issued 59 seconds ago with a 60 second lifetime: still valid. + await expect(SessionUtils.validateSession(session(1_000_000 - 59_000, '60'))).resolves.toBe(true); + }); +}); diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index 9b3b3ad8..89441f95 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -9,6 +9,9 @@ "lib": ["ESNext"], "module": "ESNext", "moduleResolution": "node", + "paths": { + "jose/*": ["./node_modules/jose/dist/types/*"] + }, "skipLibCheck": true, "skipDefaultLibCheck": true, "sourceMap": true, diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index b42e138a..79e4404c 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -14,7 +14,7 @@ import { } from '@nuxt/kit'; import type {Nuxt} from '@nuxt/schema'; import {VendorConstants} from '@thunderid/node'; -import {defu} from 'defu'; +import {createDefu} from 'defu'; import NuxtAPIRoutes from './runtime/constants/NuxtAPIRoutes'; import type {ThunderIDNuxtConfig, ThunderIDSessionPayload, ThunderIDSSRData} from './runtime/types'; import getEnabledResourcePickers from './runtime/utils/getEnabledResourcePickers'; @@ -23,6 +23,18 @@ type ViteUserConfig = Parameters[0]>[0]; const PACKAGE_NAME = '@thunderid/nuxt'; +// `defu` concatenates arrays, which would add the default scopes to the scopes the application lists. A list given at a +// higher layer replaces the one below it instead. +const defu: ReturnType = createDefu( + (object: Record, key: string | number | symbol, value: unknown): boolean => { + if (Array.isArray(value)) { + object[key as string] = value; + return true; + } + return false; + }, +); + interface ServerRoute { handler: string; method?: 'patch' | 'post'; @@ -57,6 +69,9 @@ export default defineNuxtModule({ { afterSignInUrl: '/', afterSignOutUrl: '/', + // An empty string keeps the key in the built runtime config, so NUXT_PUBLIC_THUNDERID_BASE_URL can still set it + // at runtime when nuxt.config has none. + baseUrl: '', scopes: ['openid', 'profile'], vendor: VendorConstants.VENDOR_PREFIX, }, @@ -283,19 +298,21 @@ export default defineNuxtModule({ // ── Auth callback ──────────────────────────────────────────────────────── addComponent({filePath: resolve('./runtime/components/auth/Callback'), name: 'Callback'}); - // Tell Vite to pre-bundle the CJS-only packages that @thunderid/browser, - // @thunderid/javascript, and @thunderid/vue carry as external dependencies. + // Tell Vite to pre-bundle @thunderid/browser, @thunderid/javascript, and @thunderid/vue, and the CJS-only packages + // they carry as external dependencies. // Without this, Vite serves them raw from disk via @fs URLs and fails with // "Export 'X' is not defined in module" errors when installed from npm. // This is only needed for the client Vite build; Nitro handles the server. extendViteConfig( (viteConfig: ViteUserConfig) => { + // The application depends on @thunderid/nuxt only, so each package is resolved through the package that + // depends on it (Vite's nested `a > b` syntax) rather than from the application root. const deps: string[] = [ - '@thunderid/browser', - '@thunderid/javascript', - '@thunderid/vue', - 'base64url', - 'fast-sha256', + `${PACKAGE_NAME} > @thunderid/browser`, + `${PACKAGE_NAME} > @thunderid/browser > @thunderid/javascript`, + `${PACKAGE_NAME} > @thunderid/vue`, + `${PACKAGE_NAME} > @thunderid/browser > base64url`, + `${PACKAGE_NAME} > @thunderid/browser > fast-sha256`, ]; const existingInclude: string[] = viteConfig.optimizeDeps?.include! ?? []; diff --git a/packages/nuxt/src/runtime/components/auth/SignIn.ts b/packages/nuxt/src/runtime/components/auth/SignIn.ts index 702649e0..787ea55e 100644 --- a/packages/nuxt/src/runtime/components/auth/SignIn.ts +++ b/packages/nuxt/src/runtime/components/auth/SignIn.ts @@ -300,14 +300,17 @@ const SignIn: Component = defineComponent({ const effectiveApplicationId: string | null | undefined = applicationId || urlParams.applicationId; + // Reported through `error` (state, event and render props) only: the flow is started from a watcher, where a + // thrown error would go unhandled. if (!urlParams.executionId && !effectiveApplicationId) { - const err: ThunderIDRuntimeError = new ThunderIDRuntimeError( - 'Either executionId or applicationId is required for authentication', - 'SIGN_IN_ERROR', - 'nuxt', + setError( + new ThunderIDRuntimeError( + 'Either executionId or applicationId is required for authentication', + 'SIGN_IN_ERROR', + 'nuxt', + ), ); - setError(err); - throw err; + return; } try { diff --git a/packages/nuxt/src/runtime/middleware/defineThunderIDMiddleware.ts b/packages/nuxt/src/runtime/middleware/defineThunderIDMiddleware.ts index be50cdda..8e04f3a3 100644 --- a/packages/nuxt/src/runtime/middleware/defineThunderIDMiddleware.ts +++ b/packages/nuxt/src/runtime/middleware/defineThunderIDMiddleware.ts @@ -62,14 +62,12 @@ export function defineThunderIDMiddleware( return navigateTo(`${redirectTo}?returnTo=${returnTo}`, {external: true}); } - const user: Record | null = authState.value.user as Record | null; - - if (requireOrganization && !user?.organizationId) { + if (requireOrganization && !authState.value.organizationId) { return navigateTo(redirectTo, {external: true}); } if (requireScopes.length > 0) { - const sessionScopes: string[] = String(user?.scopes ?? '').split(' '); + const sessionScopes: string[] = (authState.value.scopes ?? '').split(' '); const hasAllScopes: boolean = requireScopes.every((s: string) => sessionScopes.includes(s)); if (!hasAllScopes) { return navigateTo(redirectTo, {external: true}); diff --git a/packages/nuxt/src/runtime/plugins/thunderid.ts b/packages/nuxt/src/runtime/plugins/thunderid.ts index 7a4f85fd..9b3333c9 100644 --- a/packages/nuxt/src/runtime/plugins/thunderid.ts +++ b/packages/nuxt/src/runtime/plugins/thunderid.ts @@ -116,6 +116,8 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { authState.value = { isLoading: false, isSignedIn: ssr.isSignedIn, + organizationId: ssr.session?.organizationId, + scopes: ssr.session?.scopes, user: ssr.user, }; userProfileState.value = ssr.userProfile; diff --git a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts index 565d1481..0ea55ebf 100644 --- a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts +++ b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts @@ -27,7 +27,10 @@ import { } from '@thunderid/node'; import type {ThunderIDNuxtConfig, ThunderIDSessionPayload} from '../types'; -class ThunderIDNuxtClient extends ThunderIDNodeClient { +// `baseUrl` is optional in the module options, but the server only initializes the client once it has one. +type ThunderIDNuxtClientConfig = ThunderIDNuxtConfig & Pick; + +class ThunderIDNuxtClient extends ThunderIDNodeClient { private static instance: ThunderIDNuxtClient; public isInitialized = false; @@ -61,7 +64,7 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { tokenRequest: config.tokenRequest, } as AuthClientConfig; - const result: boolean = await super.initialize(authConfig as unknown as ThunderIDNuxtConfig, storage); + const result: boolean = await super.initialize(authConfig as unknown as ThunderIDNuxtClientConfig, storage); this.isInitialized = true; return result; } diff --git a/packages/nuxt/src/runtime/server/routes/auth/session/session.get.ts b/packages/nuxt/src/runtime/server/routes/auth/session/session.get.ts index c658f728..f9bc98e1 100644 --- a/packages/nuxt/src/runtime/server/routes/auth/session/session.get.ts +++ b/packages/nuxt/src/runtime/server/routes/auth/session/session.get.ts @@ -11,7 +11,7 @@ import {useRuntimeConfig} from '#imports'; /** * GET /api/auth/session * - * Returns the current auth state: { isSignedIn, user, isLoading }. + * Returns the current auth state: { isSignedIn, user, isLoading, organizationId, scopes }. * Used by the client-side composable to hydrate auth state. */ export default defineEventHandler(async (event: H3Event): Promise => { @@ -29,7 +29,13 @@ export default defineEventHandler(async (event: H3Event): Promise> = await client.getUser(session.sessionId); - return {isLoading: false, isSignedIn: true, user}; + return { + isLoading: false, + isSignedIn: true, + organizationId: session.organizationId, + scopes: session.scopes, + user, + }; } catch { return {isLoading: false, isSignedIn: false, user: null}; } diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index b2f6b997..2152944b 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -18,7 +18,12 @@ import type {JWTPayload} from 'jose'; * Configuration for the ThunderID Nuxt module. * Extends `AuthClientConfig` from `@thunderid/node` for 1:1 SDK parity. */ -export interface ThunderIDNuxtConfig extends AuthClientConfig { +export interface ThunderIDNuxtConfig extends Omit { + /** + * Base URL of the ThunderID server. Optional here because `NUXT_PUBLIC_THUNDERID_BASE_URL` can supply it, and the + * server refuses to initialize when neither does. + */ + baseUrl?: string; /** * OpenID Connect back-channel logout. When enabled, the module serves * `POST /api/auth/backchannel-logout` (or `path`), which ThunderID calls when a session this @@ -134,6 +139,10 @@ export interface ThunderIDSSRData { export interface ThunderIDAuthState { isLoading: boolean; isSignedIn: boolean; + /** Organization the session acts within, from the session's `organizationId`. */ + organizationId?: string; + /** Space-separated scopes granted to the session, from the session's `scopes`. */ + scopes?: string; user: User | null; } diff --git a/packages/nuxt/tests/unit/define-thunderid-middleware.test.ts b/packages/nuxt/tests/unit/define-thunderid-middleware.test.ts index 6574e828..883e0e7b 100644 --- a/packages/nuxt/tests/unit/define-thunderid-middleware.test.ts +++ b/packages/nuxt/tests/unit/define-thunderid-middleware.test.ts @@ -15,7 +15,13 @@ import { // and useState from '#app'. We stub all three so the tests run in pure Node. let _navigateToTarget: string | undefined; -let _authState = {isSignedIn: false, user: null as Record | null, isLoading: false}; +let _authState = { + isSignedIn: false, + user: null as Record | null, + isLoading: false, + organizationId: undefined as string | undefined, + scopes: undefined as string | undefined, +}; vi.mock('#app', () => { const navigateTo = vi.fn(async (url: string) => { @@ -46,7 +52,7 @@ const makeTo = (fullPath = '/dashboard') => ({fullPath}); beforeEach(() => { _navigateToTarget = undefined; - _authState = {isSignedIn: false, user: null, isLoading: false}; + _authState = {isSignedIn: false, user: null, isLoading: false, organizationId: undefined, scopes: undefined}; }); describe('defineThunderIDMiddleware', () => { @@ -73,7 +79,13 @@ describe('defineThunderIDMiddleware', () => { describe('authenticated user', () => { beforeEach(() => { - _authState = {isSignedIn: true, user: {organizationId: 'org1', scopes: 'openid profile'}, isLoading: false}; + _authState = { + isSignedIn: true, + user: {sub: 'user1'}, + isLoading: false, + organizationId: 'org1', + scopes: 'openid profile', + }; }); it('allows access when signed in', async () => { @@ -89,6 +101,17 @@ describe('defineThunderIDMiddleware', () => { expect(_navigateToTarget).toBeUndefined(); }); + it('reads the scopes granted to the session, not a scopes field on the user', async () => { + _authState.user = {scopes: 'admin'}; + _authState.scopes = 'openid profile'; + + await (defineThunderIDMiddleware({requireScopes: ['openid', 'profile']}) as Function)(makeTo('/a'), makeTo('/')); + expect(_navigateToTarget).toBeUndefined(); + + await (defineThunderIDMiddleware({requireScopes: ['admin']}) as Function)(makeTo('/a'), makeTo('/')); + expect(_navigateToTarget).toBeDefined(); + }); + it('redirects when a required scope is missing', async () => { const middleware = defineThunderIDMiddleware({requireScopes: ['admin']}); await (middleware as Function)(makeTo('/admin'), makeTo('/')); @@ -101,8 +124,21 @@ describe('defineThunderIDMiddleware', () => { expect(_navigateToTarget).toBeUndefined(); }); + it('reads the organization of the session, not an organizationId field on the user', async () => { + _authState.user = {organizationId: 'org-from-user'}; + _authState.organizationId = undefined; + await (defineThunderIDMiddleware({requireOrganization: true}) as Function)(makeTo('/o'), makeTo('/')); + expect(_navigateToTarget).toBeDefined(); + + _navigateToTarget = undefined; + _authState.user = {sub: 'user1'}; + _authState.organizationId = 'org1'; + await (defineThunderIDMiddleware({requireOrganization: true}) as Function)(makeTo('/o'), makeTo('/')); + expect(_navigateToTarget).toBeUndefined(); + }); + it('redirects when requireOrganization is true but organizationId is absent', async () => { - _authState.user = {email: 'user@example.com'}; // no organizationId + _authState.organizationId = undefined; const middleware = defineThunderIDMiddleware({requireOrganization: true}); await (middleware as Function)(makeTo('/org-page'), makeTo('/')); expect(_navigateToTarget).toBeDefined(); diff --git a/packages/nuxt/tests/unit/module.test.ts b/packages/nuxt/tests/unit/module.test.ts new file mode 100644 index 00000000..073494ff --- /dev/null +++ b/packages/nuxt/tests/unit/module.test.ts @@ -0,0 +1,111 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type */ + +import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'; + +import type {ThunderIDNuxtConfig} from '../../src/runtime/types'; + +const viteConfigExtenders: ((config: Record) => void)[] = []; + +vi.mock('@nuxt/kit', () => ({ + addComponent: vi.fn(), + addImports: vi.fn(), + addPlugin: vi.fn(), + addRouteMiddleware: vi.fn(), + addServerHandler: vi.fn(), + addServerPlugin: vi.fn(), + createResolver: () => ({resolve: (path: string) => path}), + // Hand back the module definition so the test can call `setup` directly. + defineNuxtModule: (definition: unknown) => definition, + extendViteConfig: (extender: (config: Record) => void) => { + viteConfigExtenders.push(extender); + }, +})); + +const {default: thunderIDModule} = await import('../../src/module'); + +const setupModule = (userOptions: ThunderIDNuxtConfig) => { + const nuxt = {options: {runtimeConfig: {public: {}}}} as any; + (thunderIDModule as any).setup(userOptions, nuxt); + return nuxt.options.runtimeConfig; +}; + +const ENV_KEYS = ['NUXT_PUBLIC_THUNDERID_BASE_URL', 'NUXT_PUBLIC_THUNDERID_CLIENT_ID']; + +beforeEach(() => { + viteConfigExtenders.length = 0; + ENV_KEYS.forEach((key) => vi.stubEnv(key, undefined as unknown as string)); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('@thunderid/nuxt module', () => { + describe('scopes', () => { + it('uses an array of scopes from nuxt.config as given, without adding the default scopes to it', () => { + const runtimeConfig = setupModule({scopes: ['openid', 'profile', 'email']}); + + expect(runtimeConfig.public.thunderid.scopes).toEqual(['openid', 'profile', 'email']); + }); + + it('lets an array of scopes in runtimeConfig replace the scopes from nuxt.config', () => { + const nuxt = { + options: {runtimeConfig: {public: {thunderid: {scopes: ['openid', 'groups']}}}}, + } as any; + (thunderIDModule as any).setup({scopes: ['openid', 'profile']}, nuxt); + + expect(nuxt.options.runtimeConfig.public.thunderid.scopes).toEqual(['openid', 'groups']); + }); + + it('defaults to openid and profile', () => { + expect(setupModule({}).public.thunderid.scopes).toEqual(['openid', 'profile']); + }); + }); + + describe('baseUrl', () => { + it('is optional in the config type, since NUXT_PUBLIC_THUNDERID_BASE_URL can supply it', () => { + expectTypeOf<{clientId: string}>().toExtend(); + }); + + it('keeps the key in the runtime config when nothing sets it, so the environment can set it at runtime', () => { + const runtimeConfig = setupModule({clientId: 'client'}); + + expect(JSON.parse(JSON.stringify(runtimeConfig.public.thunderid))).toHaveProperty('baseUrl', ''); + }); + + it('takes the base URL from NUXT_PUBLIC_THUNDERID_BASE_URL when nuxt.config has none', () => { + vi.stubEnv('NUXT_PUBLIC_THUNDERID_BASE_URL', 'https://localhost:8090'); + + expect(setupModule({clientId: 'client'}).public.thunderid.baseUrl).toBe('https://localhost:8090'); + }); + }); + + describe('optimizeDeps', () => { + it('resolves every pre-bundled package through @thunderid/nuxt, so the application need not depend on it', () => { + setupModule({}); + const viteConfig: Record = {}; + viteConfigExtenders.forEach((extend) => extend(viteConfig)); + + const include: string[] = viteConfig['optimizeDeps'].include; + expect(include).toEqual([ + '@thunderid/nuxt > @thunderid/browser', + '@thunderid/nuxt > @thunderid/browser > @thunderid/javascript', + '@thunderid/nuxt > @thunderid/vue', + '@thunderid/nuxt > @thunderid/browser > base64url', + '@thunderid/nuxt > @thunderid/browser > fast-sha256', + ]); + }); + + it('keeps the entries the application already listed', () => { + setupModule({}); + const viteConfig: Record = {optimizeDeps: {include: ['lodash']}}; + viteConfigExtenders.forEach((extend) => extend(viteConfig)); + + expect(viteConfig['optimizeDeps'].include[0]).toBe('lodash'); + expect(viteConfig['optimizeDeps'].include).toHaveLength(6); + }); + }); +}); diff --git a/packages/nuxt/tests/unit/sign-in.test.ts b/packages/nuxt/tests/unit/sign-in.test.ts new file mode 100644 index 00000000..8d79ddd7 --- /dev/null +++ b/packages/nuxt/tests/unit/sign-in.test.ts @@ -0,0 +1,93 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {effectScope, nextTick, ref} from 'vue'; + +const signIn = vi.fn(); + +vi.mock('@thunderid/browser', () => ({ + ConsentConstants: {}, + EmbeddedFlowType: {Authentication: 'AUTHENTICATION'}, + EmbeddedSignInFlowStatus: {Complete: 'COMPLETE', Error: 'ERROR'}, + EmbeddedSignInFlowType: {Redirection: 'REDIRECTION'}, + ThunderIDRuntimeError: class ThunderIDRuntimeError extends Error { + constructor( + message: string, + public code: string, + public origin: string, + ) { + super(message); + } + }, +})); + +vi.mock('@thunderid/vue', () => ({ + BaseSignIn: {name: 'BaseSignIn'}, + extractErrorMessage: vi.fn(() => 'error'), + handlePasskeyAuthentication: vi.fn(), + handlePasskeyRegistration: vi.fn(), + initiateOAuthRedirect: vi.fn(), + normalizeFlowResponse: vi.fn(() => ({})), + useFlowMeta: () => ({meta: ref(null)}), + useI18n: () => ({t: (key: string) => key}), + useOAuthCallback: vi.fn(), +})); + +vi.mock('#imports', () => ({ + useThunderID: () => ({ + afterSignInUrl: '/', + applicationId: undefined, + getStorageManager: undefined, + isInitialized: ref(true), + isLoading: ref(false), + scopes: undefined, + signIn, + vendor: 'thunderid', + }), +})); + +const {default: SignIn} = await import('../../src/runtime/components/auth/SignIn'); + +const unhandled: unknown[] = []; +const onUnhandledRejection = (reason: unknown) => { + unhandled.push(reason); +}; + +beforeEach(() => { + unhandled.length = 0; + process.on('unhandledRejection', onUnhandledRejection); +}); + +afterEach(() => { + process.off('unhandledRejection', onUnhandledRejection); +}); + +describe('SignIn', () => { + it('reports a missing application ID as an error without an unhandled rejection', async () => { + const emit = vi.fn(); + const scope = effectScope(); + const render = scope.run(() => + (SignIn as any).setup({className: '', size: 'medium', variant: 'outlined'}, {attrs: {}, emit, slots: {}} as any), + ); + + await nextTick(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(unhandled).toEqual([]); + expect(signIn).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({message: 'Either executionId or applicationId is required for authentication'}), + ); + expect(render().props.error).toEqual( + expect.objectContaining({message: 'Either executionId or applicationId is required for authentication'}), + ); + + scope.stop(); + }); +}); diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index 566ea98d..15f86091 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -574,7 +574,8 @@ const SignIn: FC = ({ 'react', ); setError(error); - throw error; + + return; } try { diff --git a/packages/react/src/components/presentation/auth/SignIn/__tests__/SignIn.test.tsx b/packages/react/src/components/presentation/auth/SignIn/__tests__/SignIn.test.tsx new file mode 100644 index 00000000..22f761c1 --- /dev/null +++ b/packages/react/src/components/presentation/auth/SignIn/__tests__/SignIn.test.tsx @@ -0,0 +1,76 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, render, waitFor} from '@testing-library/react'; +import {ThunderIDRuntimeError} from '@thunderid/browser'; +import {afterEach, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import I18nProvider from '../../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../../contexts/Theme/ThemeProvider'; +import ThunderIDContext, {ThunderIDContextProps} from '../../../../../contexts/ThunderID/ThunderIDContext'; +import SignIn from '../SignIn'; + +const mockSignIn = vi.fn() as Mock; + +const thunderIDContext: ThunderIDContextProps = { + applicationId: undefined, + getStorageManager: vi.fn(() => + Promise.resolve({ + getTemporaryData: vi.fn(() => Promise.resolve({})), + removeHybridDataParameter: vi.fn(), + removeTemporaryDataParameter: vi.fn(), + setHybridDataParameter: vi.fn(), + setTemporaryDataParameter: vi.fn(), + }), + ), + isInitialized: true, + isLoading: false, + signIn: mockSignIn, + vendor: 'thunderid', +} as unknown as ThunderIDContextProps; + +describe('SignIn without an application', () => { + const unhandled: unknown[] = []; + const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + unhandled.push(event.reason); + event.preventDefault(); + }; + + beforeEach(() => { + vi.clearAllMocks(); + unhandled.length = 0; + sessionStorage.clear(); + window.history.replaceState({}, '', '/signin'); + window.addEventListener('unhandledrejection', onUnhandledRejection); + }); + + afterEach(() => { + cleanup(); + window.removeEventListener('unhandledrejection', onUnhandledRejection); + }); + + it('reports the missing applicationId through onError only', async () => { + const onError = vi.fn(); + + render( + + + + + + + , + ); + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1); + }); + expect(onError.mock.calls[0][0]).toBeInstanceOf(ThunderIDRuntimeError); + expect(mockSignIn).not.toHaveBeenCalled(); + + // Give a rejected initialization promise time to surface as an unhandled rejection. + await new Promise((resolve: (value: unknown) => void) => { + setTimeout(resolve, 50); + }); + expect(unhandled).toEqual([]); + }); +}); diff --git a/packages/react/src/components/primitives/AffixedField/AffixedField.tsx b/packages/react/src/components/primitives/AffixedField/AffixedField.tsx index 949a3969..d4ae8f9d 100644 --- a/packages/react/src/components/primitives/AffixedField/AffixedField.tsx +++ b/packages/react/src/components/primitives/AffixedField/AffixedField.tsx @@ -74,6 +74,9 @@ const AffixedField: FC = ({ // Stable id for the visually-hidden prefix announcement; only referenced when a // fixed prefix is present, but generated unconditionally so the hook order is stable. const prefixDescriptionId: string = `${useId()}-prefix`; + const generatedId: string = useId(); + const inputId: string = rest.id ?? generatedId; + const helperTextId: string | undefined = error || helperText ? `${inputId}-helper-text` : undefined; const containerClassName: string = cx( withVendorCSSClassPrefix(bem('affixed-field', 'container')), @@ -124,11 +127,12 @@ const AffixedField: FC = ({ {label && ( - + {label} )} @@ -141,9 +145,11 @@ const AffixedField: FC = ({ aria-invalid={hasError} aria-required={required} {...rest} + id={inputId} aria-describedby={ - [rest['aria-describedby'], hasFixedPrefix ? prefixDescriptionId : undefined].filter(Boolean).join(' ') || - undefined + [rest['aria-describedby'], hasFixedPrefix ? prefixDescriptionId : undefined, helperTextId] + .filter(Boolean) + .join(' ') || undefined } /> diff --git a/packages/react/src/components/primitives/Checkbox/Checkbox.tsx b/packages/react/src/components/primitives/Checkbox/Checkbox.tsx index 651adaf1..4fdb1a98 100644 --- a/packages/react/src/components/primitives/Checkbox/Checkbox.tsx +++ b/packages/react/src/components/primitives/Checkbox/Checkbox.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {withVendorCSSClassPrefix, bem} from '@thunderid/browser'; -import {FC, InputHTMLAttributes} from 'react'; +import {FC, InputHTMLAttributes, useId} from 'react'; import useStyles from './Checkbox.styles'; import useTheme from '../../../contexts/Theme/useTheme'; import {cx} from '../../../styles/emotion'; @@ -43,12 +43,18 @@ const Checkbox: FC = ({ }: CheckboxProps) => { const {theme, colorScheme}: ReturnType = useTheme(); const hasError = !!error; + const generatedId: string = useId(); + const inputId: string = rest.id ?? generatedId; + const helperTextId: string | undefined = error || helperText ? `${inputId}-helper-text` : undefined; + const describedBy: string | undefined = + [rest['aria-describedby'], helperTextId].filter(Boolean).join(' ') || undefined; const styles: Record = useStyles(theme, colorScheme, hasError, !!required); return ( @@ -61,9 +67,12 @@ const Checkbox: FC = ({ aria-invalid={hasError} aria-required={required} {...rest} + id={inputId} + aria-describedby={describedBy} /> {label && ( = ({ }: DatePickerProps) => { const {theme, colorScheme}: ReturnType = useTheme(); const hasError = !!error; + const generatedId: string = useId(); + const inputId: string = rest.id ?? generatedId; + const helperTextId: string | undefined = error || helperText ? `${inputId}-helper-text` : undefined; + const describedBy: string | undefined = + [rest['aria-describedby'], helperTextId].filter(Boolean).join(' ') || undefined; const styles: Record = useStyles(theme, colorScheme, hasError, !!disabled); return ( {label && ( = ({ aria-invalid={hasError} aria-required={required} {...rest} + id={inputId} + aria-describedby={describedBy} /> ); diff --git a/packages/react/src/components/primitives/FormControl/FormControl.tsx b/packages/react/src/components/primitives/FormControl/FormControl.tsx index ec8c7e84..939f24af 100644 --- a/packages/react/src/components/primitives/FormControl/FormControl.tsx +++ b/packages/react/src/components/primitives/FormControl/FormControl.tsx @@ -27,6 +27,10 @@ export interface FormControlProps { * Helper text to display below the content */ helperText?: string; + /** + * id of the helper text element, so the field it describes can reference it with `aria-describedby` + */ + helperTextId?: string; /** * Custom alignment for helper text (default: left, center for OTP) */ @@ -49,6 +53,7 @@ const FormControl: FC = ({ children, error, helperText, + helperTextId, className, id, helperTextAlign = 'left', @@ -62,6 +67,7 @@ const FormControl: FC = ({ {children} {(error || helperText) && ( = ({ style = {}, }: MultiInputProps) => { const {theme, colorScheme}: ReturnType = useTheme(); + const inputId: string = useId(); + // The inner field renders its own helper text with `${inputId}-helper-text`, so this one needs a distinct id. + const helperTextId: string | undefined = error || helperText ? `${inputId}-description` : undefined; const canAddMore: boolean = !maxFields || values.length < maxFields; const canRemove: boolean = values.length > minFields; const styles: Record = useStyles(theme, colorScheme, !!disabled, !!error, canAddMore, canRemove); @@ -162,9 +165,11 @@ const MultiInput: FC = ({ const finalEndIcon: ReactNode = attachedEndIcon || endIcon; const commonProps: Record = { + 'aria-describedby': helperTextId, disabled, endIcon: finalEndIcon, error, + id: inputId, onChange: handleInputChange, onEndIconClick, onKeyDown: handleKeyDown, @@ -188,7 +193,7 @@ const MultiInput: FC = ({ return ; } }, - [placeholder, disabled, startIcon, endIcon, error, fieldType, type], + [placeholder, disabled, startIcon, endIcon, error, fieldType, type, inputId, helperTextId], ); // State for the current input value @@ -205,11 +210,12 @@ const MultiInput: FC = ({ {label && ( - + {label} )} diff --git a/packages/react/src/components/primitives/OtpField/OtpField.tsx b/packages/react/src/components/primitives/OtpField/OtpField.tsx index a2e6da39..1d740af2 100644 --- a/packages/react/src/components/primitives/OtpField/OtpField.tsx +++ b/packages/react/src/components/primitives/OtpField/OtpField.tsx @@ -12,6 +12,7 @@ import { useEffect, useState, CSSProperties, + useId, } from 'react'; import useStyles from './OtpField.styles'; import useTheme from '../../../contexts/Theme/useTheme'; @@ -110,6 +111,9 @@ const OtpField: FC = ({ const styles: Record = useStyles(theme, colorScheme, !!disabled, !!error, length); const [otp, setOtp] = useState(Array(length).fill('')); const inputRefs: MutableRefObject = useRef([]); + const fieldId: string = useId(); + const labelId = `${fieldId}-label`; + const helperTextId: string | undefined = error || helperText ? `${fieldId}-helper-text` : undefined; useEffect(() => { inputRefs.current = inputRefs.current.slice(0, length); @@ -227,16 +231,22 @@ const OtpField: FC = ({ {label && ( - + {label} )} -
+
{Array.from({length}, (_: unknown, index: number) => ( = ({ aria-label={`${label || 'OTP'} digit ${index + 1}`} aria-invalid={!!error} aria-required={required} + aria-describedby={helperTextId} autoComplete="one-time-code" /> ))} diff --git a/packages/react/src/components/primitives/Select/Select.tsx b/packages/react/src/components/primitives/Select/Select.tsx index a6a2d4f4..a8a380e0 100644 --- a/packages/react/src/components/primitives/Select/Select.tsx +++ b/packages/react/src/components/primitives/Select/Select.tsx @@ -3,7 +3,7 @@ import {bem, normalizeSelectOptions, SelectConstants, withVendorCSSClassPrefix} from '@thunderid/browser'; import type {SelectOption, SelectOptionInput} from '@thunderid/browser'; -import {ChangeEvent, FC, RefObject, SelectHTMLAttributes, useEffect, useMemo, useRef} from 'react'; +import {ChangeEvent, FC, RefObject, SelectHTMLAttributes, useEffect, useId, useMemo, useRef} from 'react'; import useStyles from './Select.styles'; import useSelectMessages, {SelectMessages} from './useSelectMessages'; import useTheme from '../../../contexts/Theme/useTheme'; @@ -85,6 +85,11 @@ const Select: FC = ({ const {theme, colorScheme}: ReturnType = useTheme(); const messages: SelectMessages = useSelectMessages(); const hasError = !!error; + const generatedId: string = useId(); + const inputId: string = rest.id ?? generatedId; + const helperTextId: string | undefined = error || helperText ? `${inputId}-helper-text` : undefined; + const describedBy: string | undefined = + [rest['aria-describedby'], helperTextId].filter(Boolean).join(' ') || undefined; const styles: Record = useStyles(theme, colorScheme, disabled ?? false, hasError); const items: SelectOption[] = useMemo(() => normalizeSelectOptions(options), [options]); const selectRef: RefObject = useRef(null); @@ -139,11 +144,12 @@ const Select: FC = ({ {label && ( - + {label} )} @@ -154,6 +160,8 @@ const Select: FC = ({ aria-required={required} aria-busy={loading || undefined} {...rest} + id={inputId} + aria-describedby={describedBy} ref={selectRef} onChange={onLoadMore ? handleChange : onChange} > diff --git a/packages/react/src/components/primitives/TextField/TextField.tsx b/packages/react/src/components/primitives/TextField/TextField.tsx index fbeb9a07..54045681 100644 --- a/packages/react/src/components/primitives/TextField/TextField.tsx +++ b/packages/react/src/components/primitives/TextField/TextField.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {withVendorCSSClassPrefix, bem} from '@thunderid/browser'; -import {FC, InputHTMLAttributes, KeyboardEvent, ReactNode} from 'react'; +import {FC, InputHTMLAttributes, KeyboardEvent, ReactNode, useId} from 'react'; import useStyles from './TextField.styles'; import useTheme from '../../../contexts/Theme/useTheme'; import {cx} from '../../../styles/emotion'; @@ -90,6 +90,11 @@ const TextField: FC = ({ ...rest }: TextFieldProps) => { const {theme, colorScheme}: ReturnType = useTheme(); + const generatedId: string = useId(); + const inputId: string = rest.id ?? generatedId; + const helperTextId: string | undefined = error || helperText ? `${inputId}-helper-text` : undefined; + const describedBy: string | undefined = + [rest['aria-describedby'], helperTextId].filter(Boolean).join(' ') || undefined; const hasError = !!error; const hasStartIcon = !!startIcon; const hasEndIcon = !!endIcon; @@ -129,11 +134,12 @@ const TextField: FC = ({ {label && ( - + {label} )} @@ -157,6 +163,8 @@ const TextField: FC = ({ aria-invalid={hasError} aria-required={required} {...rest} + id={inputId} + aria-describedby={describedBy} /> {endIcon && (
{ expect(container.querySelector('[class*="end-adornment"]')).toBeNull(); }); }); + +describe('TextField label and description', () => { + afterEach(cleanup); + + it('names the input with its label', () => { + render(withProviders()); + + expect(screen.getByLabelText('Email')).toBe(screen.getByRole('textbox')); + }); + + it('keeps an id the caller gives', () => { + render(withProviders()); + + expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'email'); + }); + + it('gives each field its own id', () => { + render( + withProviders( + <> + + + , + ), + ); + + expect(screen.getByLabelText('First').id).not.toBe(screen.getByLabelText('Second').id); + }); + + it('describes an invalid input with its error', () => { + render(withProviders()); + const input: HTMLElement = screen.getByLabelText('Email'); + + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription('Enter a valid email'); + }); + + it('describes the input with its helper text', () => { + render(withProviders()); + const input: HTMLElement = screen.getByLabelText('Email'); + + expect(input).toHaveAttribute('aria-invalid', 'false'); + expect(input).toHaveAccessibleDescription('We never share it'); + }); + + it('keeps a description the caller gives', () => { + render( + withProviders( + <> + Work address + + , + ), + ); + + expect(screen.getByLabelText('Email')).toHaveAccessibleDescription('Work address Required'); + }); + + it('has no description without helper text or an error', () => { + render(withProviders()); + + expect(screen.getByLabelText('Email')).not.toHaveAttribute('aria-describedby'); + }); +}); diff --git a/packages/react/src/components/primitives/Toggle/Toggle.tsx b/packages/react/src/components/primitives/Toggle/Toggle.tsx index 1adf22ed..083bea74 100644 --- a/packages/react/src/components/primitives/Toggle/Toggle.tsx +++ b/packages/react/src/components/primitives/Toggle/Toggle.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import {withVendorCSSClassPrefix, bem} from '@thunderid/browser'; -import {FC, InputHTMLAttributes} from 'react'; +import {FC, InputHTMLAttributes, useId} from 'react'; import useStyles from './Toggle.styles'; import useTheme from '../../../contexts/Theme/useTheme'; import {cx} from '../../../styles/emotion'; @@ -33,12 +33,18 @@ export interface ToggleProps extends Omit, const Toggle: FC = ({label, error, className, required, helperText, style = {}, ...rest}: ToggleProps) => { const {theme, colorScheme}: ReturnType = useTheme(); const hasError = !!error; + const generatedId: string = useId(); + const inputId: string = rest.id ?? generatedId; + const helperTextId: string | undefined = error || helperText ? `${inputId}-helper-text` : undefined; + const describedBy: string | undefined = + [rest['aria-describedby'], helperTextId].filter(Boolean).join(' ') || undefined; const styles: Record = useStyles(theme, colorScheme, hasError, !!required); return ( @@ -50,6 +56,8 @@ const Toggle: FC = ({label, error, className, required, helperText, aria-invalid={hasError} aria-required={required} {...rest} + id={inputId} + aria-describedby={describedBy} />
diff --git a/packages/react/src/components/primitives/__tests__/fieldLabels.test.tsx b/packages/react/src/components/primitives/__tests__/fieldLabels.test.tsx new file mode 100644 index 00000000..c469bfef --- /dev/null +++ b/packages/react/src/components/primitives/__tests__/fieldLabels.test.tsx @@ -0,0 +1,79 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {cleanup, render, screen} from '@testing-library/react'; +import {ReactElement} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import ThemeProvider from '../../../contexts/Theme/ThemeProvider'; +import AffixedField from '../AffixedField/AffixedField'; +import Checkbox from '../Checkbox/Checkbox'; +import DatePicker from '../DatePicker/DatePicker'; +import MultiInput from '../MultiInput/MultiInput'; +import OtpField from '../OtpField/OtpField'; +import PasswordField from '../PasswordField/PasswordField'; +import Select from '../Select/Select'; +import Toggle from '../Toggle/Toggle'; + +const withProviders = (ui: ReactElement): ReactElement => {ui}; + +const fields: [string, (props: {error?: string; id?: string}) => ReactElement][] = [ + ['PasswordField', (props) => ], + ['Select', (props) => ) => setSearch(event.target.value)} + /> + / + + + +
+

+ Stories render the real @thunderid/nextjs components. Ones marked live call + the SDK's server actions against your local ThunderID server and session. +

+ + +
+
+
+ {component.group} + + {component.name} + + {story.name} +
+
+
+ {VIEWPORTS.map(([id, label, Icon]: [Viewport, string, () => ReactElement]) => ( + + ))} +
+ + +
+ + +
+
+ ); +}; + +export default Explorer; diff --git a/playgrounds/nextjs/src/explorer/Narrow.tsx b/playgrounds/nextjs/src/explorer/Narrow.tsx new file mode 100644 index 00000000..638c96db --- /dev/null +++ b/playgrounds/nextjs/src/explorer/Narrow.tsx @@ -0,0 +1,11 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ReactElement, ReactNode} from 'react'; + +/** Caps a story's width so form-like components render at a realistic size. */ +const Narrow = ({children, width = 360}: {children: ReactNode; width?: number}): ReactElement => ( +
{children}
+); + +export default Narrow; diff --git a/playgrounds/nextjs/src/explorer/Workbench.tsx b/playgrounds/nextjs/src/explorer/Workbench.tsx new file mode 100644 index 00000000..674155bf --- /dev/null +++ b/playgrounds/nextjs/src/explorer/Workbench.tsx @@ -0,0 +1,298 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {createTheme, Theme} from '@thunderid/browser'; +import {ThemeContext, ThemeContextValue} from '@thunderid/react'; +import {ChangeEvent, CSSProperties, ReactElement, useEffect, useMemo, useRef, useState} from 'react'; +import {A11yFinding, auditElement, CANVAS_BACKGROUND} from './a11y'; +import type {Args, ArgValue, ComponentDef, PropControl, StoryDef} from './types'; +import ErrorBoundary from '../components/ErrorBoundary'; +import {Copy} from '../components/Icons'; +import {timestamp} from '../lib/events'; + +export type Viewport = 'desktop' | 'tablet' | 'mobile'; +export type PreviewTheme = 'dark' | 'light'; +export type PanelTab = 'controls' | 'code' | 'actions' | 'a11y'; + +interface WorkbenchProps { + component: ComponentDef; + grid: boolean; + onTabChange: (tab: PanelTab) => void; + previewTheme: PreviewTheme; + story: StoryDef; + tab: PanelTab; + viewport: Viewport; +} + +interface ActionEntry { + detail: string; + id: number; + name: string; + time: string; +} + +const VIEWPORT_WIDTH: Record = {desktop: '100%', mobile: '375px', tablet: '768px'}; + +const TABS: [PanelTab, string][] = [ + ['controls', 'Controls'], + ['code', 'Code'], + ['actions', 'Actions'], + ['a11y', 'Accessibility'], +]; + +const formatDefault = (value: ArgValue | undefined): string => { + if (value === undefined || value === '') return '-'; + if (typeof value === 'string') return `'${value}'`; + + return String(value); +}; + +const ControlInput = ({ + control, + onChange, + value, +}: { + control: PropControl; + onChange: (value: ArgValue) => void; + value: ArgValue | undefined; +}): ReactElement => { + if (control.control === 'bool') { + return ( + + ); + } + + if (control.control === 'text' || control.control === 'number') { + const isNumber: boolean = control.control === 'number'; + + return ( + ) => + onChange(isNumber ? Number(event.target.value) : event.target.value) + } + /> + ); + } + + return ( +
+ {control.control.map((option: string) => ( + + ))} +
+ ); +}; + +const buildCode = (component: ComponentDef, args: Args): string => { + const snippet = component.code(args); + const imports: string[] = Array.from(new Set(snippet.imports)).sort(); + const body: string = snippet.jsx + .split('\n') + .map((line: string) => ` ${line}`) + .join('\n'); + + // SDK components use hooks and server actions, so the file that renders them is a client component. + return `'use client';\n\nimport {${imports.join(', ')}} from '${snippet.from ?? '@thunderid/nextjs'}';\n\nexport default function Example() {\n return (\n${body}\n );\n}`; +}; + +const Workbench = ({ + component, + grid, + onTabChange, + previewTheme, + story, + tab, + viewport, +}: WorkbenchProps): ReactElement => { + const [args, setArgs] = useState(() => ({...component.defaults, ...story.args})); + const [log, setLog] = useState([]); + const [findings, setFindings] = useState([]); + const [copied, setCopied] = useState(false); + const frameRef = useRef(null); + + const dark: boolean = previewTheme === 'dark'; + const theme: Theme = useMemo(() => createTheme({}, dark), [dark]); + const themeValue: ThemeContextValue = useMemo( + () => ({colorScheme: previewTheme, direction: 'ltr', theme, toggleTheme: () => undefined}), + [previewTheme, theme], + ); + + const action = (name: string, detail = ''): void => { + setLog((previous: ActionEntry[]) => + [{detail, id: (previous[0]?.id ?? 0) + 1, name, time: timestamp()}, ...previous].slice(0, 50), + ); + }; + + // Re-audit whenever the rendered story changes, including async renders from live components. + useEffect(() => { + const frame: HTMLDivElement | null = frameRef.current; + + if (!frame) return undefined; + + let pending = 0; + const audit = (): void => { + cancelAnimationFrame(pending); + pending = requestAnimationFrame(() => setFindings(auditElement(frame, CANVAS_BACKGROUND[previewTheme]))); + }; + const observer: MutationObserver = new MutationObserver(audit); + + observer.observe(frame, {attributes: true, characterData: true, childList: true, subtree: true}); + audit(); + + return () => { + observer.disconnect(); + cancelAnimationFrame(pending); + }; + }, [previewTheme, viewport]); + + const code: string = buildCode(component, args); + + const copyCode = async (): Promise => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + }; + + const frameStyle: CSSProperties = { + ...(theme.cssVariables as CSSProperties), + width: VIEWPORT_WIDTH[viewport], + }; + + return ( + <> +
+
+
+ + {component.render(args, action)} + +
+
+
+ +
+
+ {TABS.map(([id, label]: [PanelTab, string]) => ( + + ))} +
+
+ {tab === 'controls' && + (component.controls.length ? ( +
+
+ Name + Description + Default + Control +
+ {component.controls.map((control: PropControl) => ( +
+ + {control.name} + + + {control.description} + {control.type} + + + {formatDefault(component.defaults[control.name])} + +
+ + setArgs((previous: Args) => ({...previous, [control.name]: value})) + } + /> +
+
+ ))} +
+ ) : ( +

This component has no editable props in the playground.

+ ))} + + {tab === 'code' && ( +
+
+ Next.js client component Β· generated from the current props +
+ +
+
{code}
+
+ )} + + {tab === 'actions' && ( +
+ {log.map((entry: ActionEntry) => ( +
+ {entry.time} + {entry.name} + {entry.detail} +
+ ))} + {log.length === 0 &&

Interact with the preview to log events here.

} +
+ )} + + {tab === 'a11y' && ( +
    + {findings.map((finding: A11yFinding) => ( +
  • + + {finding.result} + + {finding.rule} + {finding.detail} +
  • + ))} +
+ )} +
+
+ + ); +}; + +export default Workbench; diff --git a/playgrounds/nextjs/src/explorer/a11y.ts b/playgrounds/nextjs/src/explorer/a11y.ts new file mode 100644 index 00000000..a5beaddd --- /dev/null +++ b/playgrounds/nextjs/src/explorer/a11y.ts @@ -0,0 +1,235 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +export type A11yResult = 'Pass' | 'Warn' | 'Fail'; + +export interface A11yFinding { + detail: string; + result: A11yResult; + rule: string; +} + +type Rgba = [number, number, number, number]; + +const parseColor = (value: string): Rgba | null => { + const match: RegExpExecArray | null = /rgba?\(([^)]+)\)/.exec(value); + + if (!match) return null; + + const parts: number[] = match[1] + .split(/[\s,/]+/) + .filter(Boolean) + .map(Number); + + return [parts[0], parts[1], parts[2], parts[3] ?? 1]; +}; + +const luminance = ([r, g, b]: Rgba): number => { + const channel = (c: number): number => { + const v: number = c / 255; + + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +}; + +const blend = (top: Rgba, bottom: Rgba): Rgba => { + const alpha: number = top[3]; + + return [ + top[0] * alpha + bottom[0] * (1 - alpha), + top[1] * alpha + bottom[1] * (1 - alpha), + top[2] * alpha + bottom[2] * (1 - alpha), + 1, + ]; +}; + +/** + * Resolves the effective background behind an element by compositing ancestor backgrounds. + */ +const backgroundOf = (element: Element, fallback: Rgba): Rgba => { + const layers: Rgba[] = []; + let node: Element | null = element; + + while (node) { + const color: Rgba | null = parseColor(getComputedStyle(node).backgroundColor); + + if (color && color[3] > 0) { + layers.push(color); + if (color[3] >= 1) break; + } + node = node.parentElement; + } + + return layers.reverse().reduce((under: Rgba, layer: Rgba) => blend(layer, under), fallback); +}; + +const contrast = (a: Rgba, b: Rgba): number => { + const [light, dark] = [luminance(a), luminance(b)].sort((x: number, y: number) => y - x); + + return (light + 0.05) / (dark + 0.05); +}; + +const accessibleName = (element: Element): string => { + const labelledBy: string | null = element.getAttribute('aria-labelledby'); + + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id: string) => element.ownerDocument.getElementById(id)?.textContent ?? '') + .join(' ') + .trim(); + } + + return ( + element.getAttribute('aria-label') ?? + element.getAttribute('title') ?? + (element.textContent?.trim() ? element.textContent : (element.querySelector('img')?.getAttribute('alt') ?? '')) + ).trim(); +}; + +const hasLabel = (input: HTMLElement): boolean => { + if (input.getAttribute('aria-label') || input.getAttribute('aria-labelledby')) return true; + if (input.closest('label')) return true; + if (input.id && input.ownerDocument.querySelector(`label[for="${CSS.escape(input.id)}"]`)) return true; + + return false; +}; + +/** + * Runs a few lightweight checks against the rendered story. Not a replacement for axe or a screen reader + * pass, but enough to catch missing names, labels, and low contrast while iterating on props. + */ +export const auditElement = (root: HTMLElement, canvasBackground: Rgba): A11yFinding[] => { + const findings: A11yFinding[] = []; + + const buttons: HTMLElement[] = Array.from(root.querySelectorAll('button, [role="button"], a[href]')); + const unnamed: HTMLElement[] = buttons.filter((button: HTMLElement) => !accessibleName(button)); + + findings.push( + buttons.length === 0 + ? {detail: 'no buttons or links', result: 'Pass', rule: 'Accessible name'} + : { + detail: unnamed.length ? `${unnamed.length} of ${buttons.length} unnamed` : `${buttons.length} named`, + result: unnamed.length ? 'Fail' : 'Pass', + rule: 'Accessible name', + }, + ); + + const inputs: HTMLElement[] = Array.from( + root.querySelectorAll('input:not([type="hidden"]), select, textarea'), + ); + const unlabeled: HTMLElement[] = inputs.filter((input: HTMLElement) => !hasLabel(input)); + + findings.push( + inputs.length === 0 + ? {detail: 'no form fields', result: 'Pass', rule: 'Label associated'} + : { + detail: unlabeled.length ? `${unlabeled.length} of ${inputs.length} unlabeled` : `${inputs.length} labeled`, + result: unlabeled.length ? 'Fail' : 'Pass', + rule: 'Label associated', + }, + ); + + const invalid: HTMLElement[] = inputs.filter((input: HTMLElement) => input.getAttribute('aria-invalid') === 'true'); + const described: HTMLElement[] = invalid.filter((input: HTMLElement) => input.getAttribute('aria-describedby')); + + if (invalid.length) { + findings.push({ + detail: `${described.length} of ${invalid.length} invalid fields use aria-describedby`, + result: described.length === invalid.length ? 'Pass' : 'Warn', + rule: 'Error described', + }); + } + + const images: HTMLImageElement[] = Array.from(root.querySelectorAll('img')); + const noAlt: HTMLImageElement[] = images.filter((image: HTMLImageElement) => !image.hasAttribute('alt')); + + if (images.length) { + findings.push({ + detail: noAlt.length ? `${noAlt.length} of ${images.length} missing alt` : `${images.length} with alt`, + result: noAlt.length ? 'Fail' : 'Pass', + rule: 'Image alternative text', + }); + } + + const roles: string[] = Array.from( + new Set(Array.from(root.querySelectorAll('[role]')).map((element: Element) => element.getAttribute('role') ?? '')), + ).filter(Boolean); + + findings.push({ + detail: roles.length ? roles.join(', ') : 'none (native semantics only)', + result: 'Pass', + rule: 'ARIA roles found', + }); + + const busy: HTMLElement[] = Array.from(root.querySelectorAll('button:disabled, [aria-disabled="true"]')); + + if ( + root.querySelector('svg[class*="spin"], [class*="spinner"]') && + !root.querySelector('[aria-busy="true"], [role="status"], [role="progressbar"]') + ) { + findings.push({detail: 'spinner without aria-busy or role="status"', result: 'Warn', rule: 'Busy state announced'}); + } else if (busy.length) { + findings.push({detail: `${busy.length} disabled control(s)`, result: 'Pass', rule: 'Disabled state'}); + } + + const positiveTab: Element[] = Array.from(root.querySelectorAll('[tabindex]')).filter( + (element: Element) => Number(element.getAttribute('tabindex')) > 0, + ); + + findings.push({ + detail: positiveTab.length ? `${positiveTab.length} element(s) with tabindex > 0` : 'natural order', + result: positiveTab.length ? 'Warn' : 'Pass', + rule: 'Focus order', + }); + + const ids: string[] = Array.from(root.querySelectorAll('[id]')).map((element: Element) => element.id); + const duplicates: string[] = Array.from( + new Set(ids.filter((id: string, index: number) => ids.indexOf(id) !== index)), + ); + + if (duplicates.length) { + findings.push({detail: duplicates.slice(0, 3).join(', '), result: 'Fail', rule: 'Unique ids'}); + } + + let lowest = Infinity; + let lowestText = ''; + const walker: TreeWalker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); + + for (let node: Node | null = walker.nextNode(); node; node = walker.nextNode()) { + const text: string = node.textContent?.trim() ?? ''; + const parent: HTMLElement | null = node.parentElement; + + if (text && parent && parent.offsetParent !== null) { + const style: CSSStyleDeclaration = getComputedStyle(parent); + const foreground: Rgba | null = parseColor(style.color); + + if (foreground) { + const background: Rgba = backgroundOf(parent, canvasBackground); + const ratio: number = contrast(blend(foreground, background), background); + + if (ratio < lowest) { + lowest = ratio; + lowestText = text.slice(0, 24); + } + } + } + } + + if (lowest !== Infinity) { + findings.push({ + detail: `lowest ${lowest.toFixed(1)} : 1 ("${lowestText}")`, + result: lowest >= 4.5 ? 'Pass' : lowest >= 3 ? 'Warn' : 'Fail', + rule: 'Color contrast', + }); + } + + return findings; +}; + +export const CANVAS_BACKGROUND: Record<'dark' | 'light', Rgba> = { + dark: [7, 16, 34, 1], + light: [238, 241, 246, 1], +}; diff --git a/playgrounds/nextjs/src/explorer/stories.tsx b/playgrounds/nextjs/src/explorer/stories.tsx new file mode 100644 index 00000000..83727625 --- /dev/null +++ b/playgrounds/nextjs/src/explorer/stories.tsx @@ -0,0 +1,564 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { + AuthFlowSelect, + ChangeCredential, + SignedIn, + SignedOut, + SignIn, + SignInButton, + SignOutButton, + SignUp, + SignUpButton, + User, + UserAvatar, + UserDropdown, + UserProfile, + UserSelect, + UserTypeSelect, +} from '@thunderid/nextjs'; +import { + BaseChangeCredential, + BaseSignInButton, + BaseSignOutButton, + BaseSignUpButton, + BaseUserAvatar, + BaseUserDropdown, + BaseUserProfile, + ChangePasswordValues, + Typography, +} from '@thunderid/react'; +import {ChangeEvent, FC} from 'react'; +import Narrow from './Narrow'; +import type {Action, Args, ArgValue, ComponentDef, PropControl, Snippet} from './types'; +import {getDisplayName} from '../lib/user'; + +const str = (args: Args, key: string): string => String(args[key] ?? ''); +const bool = (args: Args, key: string): boolean => args[key] === true; +const num = (args: Args, key: string): number => Number(args[key] ?? 0); + +const attr = (key: string, value: ArgValue): string => { + if (value === true) return key; + if (typeof value === 'number') return `${key}={${value}}`; + + return `${key}="${String(value).replace(/"/g, '"')}"`; +}; + +/** + * Builds a JSX element string from the props that differ from the component defaults. + */ +export const jsx = ( + tag: string, + props: Args, + options: {children?: string; defaults?: Args; extra?: string[]} = {}, +): string => { + const parts: string[] = Object.entries(props) + .filter( + ([key, value]: [string, ArgValue]) => + value !== false && value !== '' && value !== undefined && options.defaults?.[key] !== value, + ) + .map(([key, value]: [string, ArgValue]) => attr(key, value)) + .concat(options.extra ?? []); + const inline: string = parts.length ? ` ${parts.join(' ')}` : ''; + const multiline: boolean = inline.length > 70; + const attrs: string = multiline ? `\n ${parts.join('\n ')}\n` : inline; + + if (options.children) return `<${tag}${attrs}>${options.children}`; + + return `<${tag}${attrs}${multiline ? '' : ' '}/>`; +}; + +const omit = (args: Args, ...keys: string[]): Args => + Object.fromEntries(Object.entries(args).filter(([key]: [string, ArgValue]) => !keys.includes(key))); + +/** Base components are not re-exported by @thunderid/nextjs; apps import them from @thunderid/react. */ +const fromReact = (snippet: Snippet): Snippet => ({...snippet, from: '@thunderid/react'}); + +const isLive = (args: Args): boolean => str(args, 'source') === 'live'; + +const SAMPLE_USER: Record = { + email: 'liam.chen@acme.io', + family_name: 'Chen', + given_name: 'Liam', + sub: 'usr_8f2c41', + username: 'liam.chen', +}; + +const SAMPLE_PROFILE: Record = { + country: 'New Zealand', + email: 'liam.chen@acme.io', + familyName: 'Chen', + givenName: 'Liam', + username: 'liam.chen', +}; + +const SIZE: readonly string[] = ['small', 'medium', 'large']; +const VARIANT: readonly string[] = ['default', 'outlined', 'elevated']; + +const SOURCE_CONTROL: PropControl = { + control: ['base', 'live'], + description: + 'base renders the presentational Base component from @thunderid/react with sample data; live renders the @thunderid/nextjs component, which calls the SDK server actions.', + name: 'source', + type: "'base' | 'live' (playground only)", +}; + +const LABEL_CONTROL: PropControl = { + control: 'text', + description: 'Button label; empty uses the translated default.', + name: 'children', + type: 'ReactNode', +}; + +const selectComponent = ( + id: string, + name: string, + Select: FC<{ + label?: string; + onChange?: (event: ChangeEvent) => void; + onError?: (error: Error) => void; + pageSize?: number; + placeholder?: string; + }>, + label: string, + loads: string, +): ComponentDef => ({ + code: (args: Args) => ({ + imports: [name], + jsx: jsx(name, args, {defaults: {pageSize: 20}, extra: ['onChange={handleChange}', 'onError={handleError}']}), + }), + controls: [ + {control: 'text', description: 'Visible label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Placeholder option.', name: 'placeholder', type: 'string'}, + {control: 'number', description: 'Options loaded per page.', name: 'pageSize', type: 'number'}, + ], + defaults: {label, pageSize: 20, placeholder: `Choose ${label.toLowerCase()}`}, + group: 'Management', + id, + live: true, + name, + render: (args: Args, action: Action) => ( + + + + +
+ + +
+ +
+
+ +
+
+

Source

+ src/operations.ts +
+

+            
+ + +
+
+

Result

+ + +
+
+ + + +
+
+
+ +

Run the operation to see what it returns.

+

+ The HTTP tab lists the requests the SDK sent to ThunderID. The SDK trace shows the client methods the + call went through, those requests and the SDK's log lines. +

+
+ +
+
+ +
+ + diff --git a/playgrounds/node/public/styles.css b/playgrounds/node/public/styles.css new file mode 100644 index 00000000..3561b03d --- /dev/null +++ b/playgrounds/node/public/styles.css @@ -0,0 +1,1022 @@ +/* + * Copyright 2026 The ThunderID Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +:root { + --bg: #050b18; + --surface: rgba(255, 255, 255, 0.02); + --surface-2: rgba(255, 255, 255, 0.05); + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(255, 255, 255, 0.1); + --text: #fff; + --text-2: rgba(255, 255, 255, 0.75); + --muted: rgba(255, 255, 255, 0.5); + --code: #cfe3ff; + --primary: #3688ff; + --primary-2: #2060c8; + --accent: #8bf9fa; + --ok: #34d399; + --warn: #f59e0b; + --error: #ef4444; + --info: #9cc4ff; + --mono: 'JetBrains Mono', ui-monospace, monospace; + --sans: 'Inter', system-ui, sans-serif; + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +[hidden] { + display: none !important; +} + +/* Show code as typed: JetBrains Mono would otherwise render => and === as ligatures. */ +* { + font-variant-ligatures: none !important; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +button, +input, +textarea { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; + border: none; + background: none; +} + +button:disabled { + cursor: not-allowed; +} + +a { + color: #6aa8ff; + text-decoration: none; +} + +code, +.mono { + font-family: var(--mono); +} + +.muted { + color: var(--muted); + font-size: 11.5px; + font-weight: 500; +} + +.spacer { + flex: 1; +} + +/* Top bar */ + +.topbar { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding: 10px 20px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.015); +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; +} + +.brand-divider { + width: 1px; + height: 20px; + background: var(--line-strong); +} + +.brand-name { + font-weight: 600; + letter-spacing: -0.2px; +} + +.sections { + display: flex; + gap: 2px; + padding: 3px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.04); +} + +.sections a { + padding: 6px 12px; + border-radius: 7px; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); +} + +.sections a[aria-current='page'] { + color: var(--text); + background: rgba(54, 136, 255, 0.18); +} + +.status { + display: inline-flex; + align-items: center; + gap: 7px; + font: 500 12px/1 var(--mono); + color: rgba(255, 255, 255, 0.6); +} + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); +} + +.status[data-state='ok'] .dot { + background: var(--ok); + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); +} + +.status[data-state='down'] .dot { + background: var(--error); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.session-chip { + display: flex; + align-items: center; + gap: 10px; +} + +.avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + background: linear-gradient(135deg, #22d3ee, #0e7490); +} + +.session-name { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12.5px; + font-weight: 500; +} + +.session-name small { + font-size: 11px; + font-weight: 400; + color: var(--muted); +} + +.btn-primary, +.btn-secondary, +.btn-link { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; +} + +.btn-primary { + background: linear-gradient(90deg, var(--primary) 0%, var(--primary-2) 100%); + color: #fff; + padding: 10px 18px; + font-size: 13.5px; +} + +.btn-primary.small { + padding: 7px 14px; + font-size: 13px; +} + +.btn-secondary { + padding: 10px 14px; + background: var(--surface-2); + border: 1px solid var(--line-strong); + color: rgba(255, 255, 255, 0.8); +} + +.btn-link { + padding: 7px 10px; + color: rgba(255, 255, 255, 0.7); + border: 1px solid var(--line-strong); +} + +/* Notices */ + +.notice { + display: flex; + gap: 10px; + align-items: baseline; + padding: 10px 20px; + font-size: 13px; + line-height: 1.5; + border-bottom: 1px solid rgba(245, 158, 11, 0.2); + background: rgba(245, 158, 11, 0.07); + color: #fcd34d; +} + +.notice code { + color: #fde68a; + font-size: 12px; +} + +/* Layout */ + +.layout { + flex: 1; + display: flex; + flex-wrap: wrap; + min-height: 0; +} + +.routes { + flex: 1 1 280px; + max-width: 100%; + border-right: 1px solid var(--line); + padding: 14px 10px; + display: flex; + flex-direction: column; + gap: 16px; + background: rgba(255, 255, 255, 0.01); +} + +.routes-intro { + padding: 0 6px; +} + +.routes-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; +} + +.routes-intro p { + font-size: 12px; + line-height: 1.5; + color: var(--muted); +} + +.routes-intro code { + color: var(--accent); +} + +#route-groups { + display: flex; + flex-direction: column; + gap: 16px; +} + +.group-name { + padding: 0 6px 6px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); +} + +.group ul { + list-style: none; + display: flex; + flex-direction: column; + gap: 1px; +} + +.route-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 8px; + border-radius: 7px; + text-align: left; + color: rgba(255, 255, 255, 0.72); +} + +.route-btn:hover { + background: rgba(255, 255, 255, 0.04); +} + +.route-btn[aria-current='true'] { + background: rgba(54, 136, 255, 0.16); + color: var(--text); +} + +.route-path { + font: 500 12.5px/1.3 var(--mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lock { + margin-left: auto; + flex: none; + opacity: 0.55; +} + +.method { + flex: none; + display: inline-flex; + justify-content: center; + width: 42px; + padding: 3px 0; + font: 600 10.5px/1.2 var(--mono); + border-radius: 5px; +} + +.method-big { + width: auto; + min-width: 58px; + padding: 5px 10px; + font-size: 12px; + border-radius: 6px; +} + +.method[data-method='GET'] { + color: #6ee7b7; + background: rgba(52, 211, 153, 0.12); +} + +.method[data-method='POST'] { + color: #9cc4ff; + background: rgba(54, 136, 255, 0.12); +} + +.main { + flex: 999 1 640px; + min-width: 0; + display: flex; + flex-direction: column; +} + +.endpoint-head { + padding: 22px 28px 18px; + border-bottom: 1px solid var(--line); +} + +.endpoint-title { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.endpoint-title h1 { + font: 500 20px/1.3 var(--mono); + letter-spacing: -0.3px; +} + +.tags { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; +} + +.tag { + display: inline-flex; + align-items: center; + font: 500 11.5px/1 var(--mono); + color: var(--accent); + background: rgba(139, 249, 250, 0.07); + border: 1px solid rgba(139, 249, 250, 0.16); + border-radius: 6px; + padding: 5px 8px; +} + +.summary { + font-size: 14px; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 4px; +} + +.desc { + font-size: 13px; + line-height: 1.55; + color: rgba(255, 255, 255, 0.55); + max-width: 820px; +} + +.columns { + display: flex; + flex-wrap: wrap; + gap: 20px; + padding: 20px 28px; +} + +.stack { + flex: 1 1 380px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.card { + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--line); +} + +.card-head h2 { + font-size: 13px; + font-weight: 600; +} + +.card-body { + padding: 14px 16px; +} + +.request-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.field-label { + display: block; + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.65); + margin-bottom: 8px; +} + +.identities { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.identity { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 12px; + border-radius: 9px; + border: 1px solid var(--line-strong); + text-align: left; +} + +.identity[aria-checked='true'] { + background: rgba(54, 136, 255, 0.12); + border-color: var(--primary); +} + +.identity:disabled { + opacity: 0.45; +} + +.identity .id-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} + +.identity .id-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.identity .id-label { + font-size: 12.5px; + font-weight: 500; +} + +.identity .id-sub { + font-size: 11px; + color: var(--muted); +} + +.params { + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; +} + +.param { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 9px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.param:last-child { + border-bottom: none; +} + +.param-name { + display: flex; + flex-direction: column; + gap: 3px; + font: 500 12.5px/1 var(--mono); +} + +.param-name small { + font: 400 11px/1 var(--mono); + color: rgba(255, 255, 255, 0.45); +} + +.required { + color: #f87171; +} + +.param input, +.body-field textarea { + width: 100%; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 7px 9px; + font: 400 12px/1.3 var(--mono); + color: var(--code); +} + +.body-field { + display: flex; + flex-direction: column; +} + +.body-field textarea { + resize: vertical; + background: rgba(0, 0, 0, 0.3); + padding: 10px 12px; + font-size: 12.5px; + line-height: 1.6; +} + +.actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.icon-spin { + display: none; + animation: pg-spin 0.8s linear infinite; +} + +.sending .icon-spin { + display: block; +} + +.sending .icon-play { + display: none; +} + +@keyframes pg-spin { + to { + transform: rotate(360deg); + } +} + +.code-block { + padding: 12px 14px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--line); + font: 400 12px/1.6 var(--mono); + color: var(--code); + white-space: pre-wrap; + word-break: break-all; +} + +.source { + padding: 14px 16px; + font: 400 12.5px/1.65 var(--mono); + color: var(--code); + white-space: pre; + overflow-x: auto; +} + +/* Response */ + +.response { + flex: 1 1 380px; + min-width: 0; + display: flex; + flex-direction: column; + min-height: 520px; +} + +.response-head { + justify-content: flex-start; + flex-wrap: wrap; + padding: 10px 16px; +} + +.status-badge { + display: inline-flex; + padding: 4px 9px; + border-radius: 6px; + font: 600 12px/1 var(--mono); +} + +.status-badge[data-class='2'] { + color: #6ee7b7; + background: rgba(52, 211, 153, 0.12); +} + +.status-badge[data-class='3'] { + color: #9cc4ff; + background: rgba(54, 136, 255, 0.12); +} + +.status-badge[data-class='4'] { + color: #fcd34d; + background: rgba(245, 158, 11, 0.12); +} + +.status-badge[data-class='5'] { + color: #fca5a5; + background: rgba(239, 68, 68, 0.12); +} + +.tabs { + display: flex; + gap: 2px; + padding: 3px; + border-radius: 8px; + background: var(--surface-2); +} + +.tabs button { + padding: 5px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); +} + +.tabs button[aria-selected='true'] { + background: rgba(54, 136, 255, 0.2); + color: var(--info); +} + +.empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px; + text-align: center; + color: var(--muted); + font-size: 12.5px; + line-height: 1.5; +} + +.empty p { + max-width: 320px; +} + +.empty .empty-title { + font-size: 13.5px; + color: var(--text-2); +} + +.res-content { + flex: 1; + min-height: 0; + overflow: auto; +} + +.headers { + display: grid; + grid-template-columns: minmax(140px, auto) minmax(0, 1fr); +} + +.headers dt, +.headers dd { + padding: 9px 16px; + font: 400 12px/1.4 var(--mono); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.headers dt { + font-weight: 500; + color: rgba(255, 255, 255, 0.55); +} + +.headers dd { + color: var(--code); + word-break: break-all; +} + +.trace { + list-style: none; + padding: 16px; +} + +.trace li { + display: flex; + gap: 12px; +} + +.trace-rail { + display: flex; + flex-direction: column; + align-items: center; +} + +.trace-dot { + width: 10px; + height: 10px; + margin-top: 4px; + flex: none; + border-radius: 50%; +} + +.trace-line { + width: 1px; + flex: 1; + background: rgba(255, 255, 255, 0.1); +} + +.trace-dot[data-kind='ok'] { + background: var(--ok); + box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.13); +} + +.trace-dot[data-kind='warn'] { + background: var(--warn); + box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.13); +} + +.trace-dot[data-kind='error'] { + background: var(--error); + box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.13); +} + +.trace-dot[data-kind='info'] { + background: var(--info); + box-shadow: 0 0 0 4px rgba(156, 196, 255, 0.13); +} + +.trace-main { + padding-bottom: 16px; + flex: 1; + min-width: 0; +} + +.trace-row { + display: flex; + align-items: baseline; + gap: 8px; +} + +.trace-step { + font-size: 13px; + color: rgba(255, 255, 255, 0.9); + word-break: break-word; +} + +.trace-step.code { + font: 500 12.5px/1.4 var(--mono); +} + +.trace-source { + flex: none; + font: 500 10px/1 var(--mono); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 3px 5px; + border-radius: 4px; + color: rgba(255, 255, 255, 0.55); + background: rgba(255, 255, 255, 0.06); +} + +.trace-ms { + margin-left: auto; + font: 500 11.5px/1 var(--mono); + color: rgba(255, 255, 255, 0.45); + white-space: nowrap; +} + +.trace-detail { + font: 400 11.5px/1.5 var(--mono); + color: var(--muted); + margin-top: 3px; + word-break: break-word; +} + +.error-text { + padding: 16px; + font-size: 13px; + color: #fca5a5; +} + +/* Operations explorer */ + +.method[data-method='HTTP'] { + color: var(--accent); + background: rgba(139, 249, 250, 0.1); +} + +.method[data-method='LOCAL'] { + color: rgba(255, 255, 255, 0.65); + background: rgba(255, 255, 255, 0.07); +} + +.method[data-method='N/A'] { + color: rgba(255, 255, 255, 0.4); + background: transparent; + border: 1px dashed rgba(255, 255, 255, 0.15); +} + +.endpoint-title h1.call { + font-size: 18px; + word-break: break-word; +} + +.endpoint-head .tags { + margin-bottom: 12px; +} + +.filter { + width: 100%; + margin-top: 12px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--line-strong); + background: rgba(0, 0, 0, 0.25); + color: var(--text); + font: 400 12.5px/1.3 var(--sans); +} + +.filter:focus { + outline: none; + border-color: rgba(54, 136, 255, 0.6); +} + +.notice-inline { + padding: 10px 12px; + border-radius: 8px; + font-size: 12.5px; + line-height: 1.5; + color: #fcd34d; + background: rgba(245, 158, 11, 0.07); + border: 1px solid rgba(245, 158, 11, 0.2); +} + +.variant { + margin-left: auto; + flex: none; + font: 400 10.5px/1.2 var(--mono); + color: var(--muted); +} + +#res-body { + white-space: pre-wrap; + word-break: break-word; +} + +.res-session { + padding: 10px 16px 0; + font-size: 11.5px; +} + +.res-session:empty { + display: none; +} + +.source.rejected { + color: #fca5a5; +} + +.http { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; +} + +.http > .trace-detail { + margin: 0; +} + +.exchange { + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.015); +} + +.exchange summary { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + list-style: none; +} + +.exchange summary::-webkit-details-marker { + display: none; +} + +.exchange .method { + width: auto; + padding: 3px 7px; + color: #9cc4ff; + background: rgba(54, 136, 255, 0.12); +} + +.exchange-url { + flex: 1; + min-width: 0; + font: 500 12px/1.3 var(--mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exchange-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 12px 12px; + border-top: 1px solid var(--line); +} + +.exchange-body .field-label { + margin-top: 8px; +} + +.exchange-body .headers dt, +.exchange-body .headers dd { + padding: 6px 8px; +} + +.exchange-body .code-block { + max-height: 320px; + overflow: auto; +} + +@media (max-width: 720px) { + .topbar, + .notice { + padding-left: 16px; + padding-right: 16px; + } + + .endpoint-head, + .columns { + padding-left: 16px; + padding-right: 16px; + } + + .routes { + border-right: none; + border-bottom: 1px solid var(--line); + } + + .param { + grid-template-columns: 1fr; + } +} diff --git a/playgrounds/node/public/thunderid-logo.svg b/playgrounds/node/public/thunderid-logo.svg new file mode 100644 index 00000000..98937164 --- /dev/null +++ b/playgrounds/node/public/thunderid-logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/playgrounds/node/src/catalog.ts b/playgrounds/node/src/catalog.ts new file mode 100644 index 00000000..f148e2d6 --- /dev/null +++ b/playgrounds/node/src/catalog.ts @@ -0,0 +1,429 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import config from './config.ts'; + +export interface OperationParam { + name: string; + required: boolean; + type: string; + value: string; +} + +export type OperationGroup = + | 'Discovery' + | 'Sign-in' + | 'Session' + | 'Tokens' + | 'Sign-out' + | 'Management API' + | 'Not available'; + +export interface OperationSpec { + /** Example JSON argument, edited in the explorer's Arguments field. */ + args?: string; + /** The call, as written in application code. */ + call: string; + description: string; + group: OperationGroup; + id: string; + /** Whether the operation sends requests to ThunderID. */ + network: boolean; + params: OperationParam[]; + /** `#region` names (in `src/client.ts`, `src/operations.ts` and `src/server.ts`) that make up its source. */ + regions: string[]; + /** What the call resolves to. */ + returns: string; + /** Whether the operation reads or changes a session. */ + session: boolean; + summary: string; + /** Why the operation cannot run, for methods the Node client does not implement. */ + unavailable?: string; + /** Tells apart two operations that call the same method. */ + variant?: string; +} + +const param = (name: string, value = '', type = 'string', required = false): OperationParam => ({ + name, + required, + type, + value, +}); + +const json = (value: unknown): string => JSON.stringify(value, null, 2); + +const NOT_IMPLEMENTED = + 'ThunderIDNodeClient inherits this method from ThunderIDJavaScriptClient without implementing it, so it throws "Method not implemented."'; + +const operations: OperationSpec[] = [ + { + call: 'client.getOpenIDProviderEndpoints()', + description: + 'The endpoints the SDK calls (authorize, token, userinfo, end session, JWKS, revocation, introspection), read from the discovery document. The first call that needs the document fetches it and checks its issuer; later calls reuse it.', + group: 'Discovery', + id: 'getOpenIDProviderEndpoints', + network: false, + params: [], + regions: ['setup', 'getOpenIDProviderEndpoints'], + returns: 'Promise>', + session: false, + summary: 'OpenID Connect endpoints.', + }, + { + call: 'client.getDiscoveryResponse()', + description: + 'The raw discovery document, as ThunderID served it, with any configured endpoint overrides applied. Fetched on the first call that needs it.', + group: 'Discovery', + id: 'getDiscoveryResponse', + network: false, + params: [], + regions: ['getDiscoveryResponse'], + returns: 'Promise', + session: false, + summary: 'Discovery document.', + }, + { + call: 'client.getConfiguration()', + description: + 'Returns the configuration the client resolved at initialize(): your settings merged with the SDK defaults. The playground masks the client secret.', + group: 'Discovery', + id: 'getConfiguration', + network: false, + params: [], + regions: ['setup', 'getConfiguration'], + returns: 'Promise', + session: false, + summary: 'Resolved client configuration.', + }, + { + args: json({prompt: 'login'}), + call: 'client.getSignInUrl(requestConfig, sessionId)', + description: + 'Builds the authorize URL for a session, with PKCE and state kept in that session. The argument adds authorize request parameters. Open the URL as your browser session and ThunderID redirects back to /login, which finishes sign-in.', + group: 'Sign-in', + id: 'getSignInUrl', + network: true, + params: [], + regions: ['getSignInUrl'], + returns: 'Promise', + session: true, + summary: 'Authorize URL.', + }, + { + call: 'client.signIn(authURLCallback, sessionId)', + description: + 'Starts sign-in: hands the authorize URL to the callback, which redirects the browser in an app. A session that is already signed in resolves with its tokens instead.', + group: 'Sign-in', + id: 'signInStart', + network: true, + params: [], + regions: ['signInStart', 'login'], + returns: 'Promise', + session: true, + summary: 'Start sign-in.', + variant: 'start', + }, + { + call: 'client.signIn(undefined, sessionId, code, state)', + description: + 'Finishes sign-in: exchanges the authorization code for tokens, validates the ID token and stores the tokens in the session. /login runs this on the redirect back from ThunderID. A code is single use, so a code sent here by hand gets the SDK error.', + group: 'Sign-in', + id: 'signInCode', + network: true, + params: [param('code', '', 'string', true), param('state', '', 'string', true)], + regions: ['signInCode', 'login'], + returns: 'Promise', + session: true, + summary: 'Exchange the authorization code.', + variant: 'code', + }, + { + call: 'client.getOBOSignInURL({agentID})', + description: + 'Builds an authorize URL that asks the user to let an AI agent act on their behalf (on-behalf-of), by sending the agent as requested_actor.', + group: 'Sign-in', + id: 'getOBOSignInURL', + network: true, + params: [param('agentID', 'agent-1', 'string', true)], + regions: ['getOBOSignInURL'], + returns: 'Promise', + session: false, + summary: 'On-behalf-of sign-in URL for an agent.', + }, + { + args: json({bindingMessage: 'Playground sign-in', loginHint: 'alice@example.com'}), + call: 'client.initiateCIBA(options)', + description: + 'Starts a Client-Initiated Backchannel Authentication (CIBA) request: ThunderID asks the user on another device. Needs exactly one of loginHint, loginHintToken or idTokenHint.', + group: 'Sign-in', + id: 'initiateCIBA', + network: true, + params: [], + regions: ['initiateCIBA'], + returns: 'Promise', + session: false, + summary: 'Start a CIBA request.', + }, + { + call: 'client.pollCIBA(authReqId, interval, {signal})', + description: + 'Polls the token endpoint until the user approves or denies the CIBA request, slowing down when ThunderID asks. Take authReqId and interval from initiateCIBA().', + group: 'Sign-in', + id: 'pollCIBA', + network: true, + params: [ + param('authReqId', '', 'string', true), + param('interval', '5', 'number', true), + param('timeoutSeconds', '30', 'number'), + ], + regions: ['pollCIBA'], + returns: 'Promise', + session: false, + summary: 'Wait for a CIBA decision.', + }, + { + call: 'client.isSignedIn(sessionId)', + description: + 'Whether the session holds a valid access token. An expired one is refreshed when the session has a refresh token; a session that cannot be refreshed is removed.', + group: 'Session', + id: 'isSignedIn', + network: false, + params: [], + regions: ['isSignedIn'], + returns: 'Promise', + session: true, + summary: 'Session status.', + }, + { + call: 'client.getUser(sessionId)', + description: + 'The signed-in user, read from the claims of the session ID token. Sends no request. Rejects with JS-AUTH_CORE-GU-NF01 when the session holds no ID token.', + group: 'Session', + id: 'getUser', + network: false, + params: [], + regions: ['getUser'], + returns: 'Promise', + session: true, + summary: 'Current user.', + }, + { + call: 'client.getIdToken(sessionId)', + description: 'The raw ID token of a signed-in session. Rejects when the session is not signed in.', + group: 'Session', + id: 'getIdToken', + network: false, + params: [], + regions: ['getIdToken'], + returns: 'Promise', + session: true, + summary: 'ID token.', + }, + { + call: 'client.getDecodedIdToken(sessionId)', + description: 'The claims of the session ID token. Rejects with JS-AUTH_CORE-GDIT-NF01 when there is none.', + group: 'Session', + id: 'getDecodedIdToken', + network: false, + params: [], + regions: ['getDecodedIdToken'], + returns: 'Promise', + session: true, + summary: 'Decoded ID token.', + }, + { + call: 'client.getAccessToken(sessionId)', + description: 'The access token of the session, to call your APIs with. Empty for a session without tokens.', + group: 'Session', + id: 'getAccessToken', + network: false, + params: [], + regions: ['getAccessToken'], + returns: 'Promise', + session: true, + summary: 'Access token.', + }, + { + call: 'client.decodeJwtToken(token)', + description: 'Decodes the payload of any JWT, without verifying it. Paste an access or ID token.', + group: 'Session', + id: 'decodeJwtToken', + network: false, + params: [param('token', '', 'string', true)], + regions: ['decodeJwtToken'], + returns: 'Promise>', + session: false, + summary: 'Decode a JWT.', + }, + { + call: 'client.clearSession(sessionId)', + description: + 'Removes the stored session and its back-channel logout index entries, locally. Running it as your browser session signs you out of the playground without telling ThunderID.', + group: 'Session', + id: 'clearSession', + network: false, + params: [], + regions: ['clearSession'], + returns: 'void', + session: true, + summary: 'Clear a session.', + }, + { + call: 'client.refreshAccessToken(sessionId)', + description: + 'Uses the refresh token in the session to get new tokens and stores them. Needs the refresh_token grant on the application.', + group: 'Tokens', + id: 'refreshAccessToken', + network: true, + params: [], + regions: ['refreshAccessToken'], + returns: 'Promise', + session: true, + summary: 'Refresh tokens.', + }, + { + call: 'client.revokeAccessToken(sessionId)', + description: 'Revokes the access token at ThunderID and clears the session.', + group: 'Tokens', + id: 'revokeAccessToken', + network: true, + params: [], + regions: ['revokeAccessToken'], + returns: 'Promise', + session: true, + summary: 'Revoke the access token.', + }, + { + args: json({ + attachToken: false, + data: { + client_id: '{{clientId}}', + client_secret: '{{clientSecret}}', + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', + subject_token: '{{accessToken}}', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + }, + id: 'playground-exchange', + returnsSession: false, + signInRequired: true, + }), + call: 'client.exchangeToken(config, sessionId)', + description: + 'Sends a custom grant to the token endpoint: here an RFC 8693 token exchange of the session access token. With returnsSession, the response replaces the session tokens.', + group: 'Tokens', + id: 'exchangeToken', + network: true, + params: [], + regions: ['exchangeToken'], + returns: 'Promise', + session: true, + summary: 'Token exchange (custom grant).', + }, + { + call: 'serviceClient.getAccessToken()', + description: `Gets a token for the client itself with the client_credentials grant, and caches it until shortly before it expires. Uses ${config.serviceClientId ? `client ${config.serviceClientId}` : 'THUNDERID_SERVICE_CLIENT_ID, or THUNDERID_CLIENT_ID'}, which needs that grant.`, + group: 'Tokens', + id: 'clientCredentials', + network: true, + params: [], + regions: ['serviceSetup', 'clientCredentials'], + returns: 'Promise', + session: false, + summary: 'Client credentials token.', + }, + { + call: 'client.signOut(sessionId)', + description: + 'Returns the ThunderID end-session URL for the session, with its ID token as the hint. /logout redirects to it.', + group: 'Sign-out', + id: 'signOut', + network: true, + params: [], + regions: ['signOut', 'logout'], + returns: 'Promise', + session: true, + summary: 'End-session URL.', + }, + { + call: 'client.handleBackchannelLogout(logoutToken)', + description: + 'Validates a back-channel logout token that ThunderID posted, then ends the local sessions it names by sid, or by sub for sessions that began before it. An invalid or replayed token throws InvalidLogoutTokenError.', + group: 'Sign-out', + id: 'handleBackchannelLogout', + network: true, + params: [param('logoutToken', 'not-a-real-token', 'string', true)], + regions: ['handleBackchannelLogout'], + returns: 'Promise', + session: false, + summary: 'OpenID Connect back-channel logout.', + }, + { + call: 'client.getUsers({filter, limit, offset}, sessionId)', + description: + 'One page of the ThunderID user directory, requested with the session access token. The token needs the scopes the management API asks for.', + group: 'Management API', + id: 'getUsers', + network: true, + params: [param('limit', '10', 'number'), param('offset', '0', 'number'), param('filter')], + regions: ['getUsers'], + returns: 'Promise', + session: true, + summary: 'List users.', + }, + { + call: 'client.getFlows({flowType, limit, offset}, sessionId)', + description: 'One page of the flows configured in ThunderID, requested with the session access token.', + group: 'Management API', + id: 'getFlows', + network: true, + params: [param('flowType', 'AUTHENTICATION'), param('limit', '10', 'number'), param('offset', '0', 'number')], + regions: ['getFlows'], + returns: 'Promise', + session: true, + summary: 'List flows.', + }, + { + call: 'client.getUserTypes({limit, offset}, sessionId)', + description: 'One page of the user types (schemas) in ThunderID, requested with the session access token.', + group: 'Management API', + id: 'getUserTypes', + network: true, + params: [param('limit', '10', 'number'), param('offset', '0', 'number')], + regions: ['getUserTypes'], + returns: 'Promise', + session: true, + summary: 'List user types.', + }, + { + args: json({password: ''}), + call: 'client.updateUserCredentials(payload, sessionId)', + description: + 'Updates credentials of the signed-in user, such as password, through the /users/me credentials endpoint. Running it changes your real credentials.', + group: 'Management API', + id: 'updateUserCredentials', + network: true, + params: [], + regions: ['updateUserCredentials'], + returns: 'Promise', + session: true, + summary: 'Update own credentials.', + }, + ...['signInSilently', 'signUp', 'recover', 'getUserProfile', 'updateUserProfile'].map( + (name: string): OperationSpec => ({ + call: `client.${name}()`, + description: NOT_IMPLEMENTED, + group: 'Not available', + id: name, + network: false, + params: [], + regions: [], + returns: 'Promise', + session: false, + summary: 'Not implemented in @thunderid/node.', + unavailable: NOT_IMPLEMENTED, + }), + ), +]; + +export default operations; diff --git a/playgrounds/node/src/client.ts b/playgrounds/node/src/client.ts new file mode 100644 index 00000000..aa214370 --- /dev/null +++ b/playgrounds/node/src/client.ts @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +// The explorer shows the `#region` blocks of this file next to the operations that use them. + +import {ThunderIDNodeClient} from '@thunderid/node'; +import config from './config.ts'; + +// #region setup +export const client: ThunderIDNodeClient = new ThunderIDNodeClient(); + +await client.initialize({ + afterSignInUrl: config.afterSignInUrl, + afterSignOutUrl: config.afterSignOutUrl, + applicationId: config.applicationId, + baseUrl: config.baseUrl, + clientId: config.clientId, + clientSecret: config.clientSecret, + scopes: config.scopes, +}); +// #endregion + +// #region serviceSetup +// A second client, for machine-to-machine calls. With grantType 'client_credentials', +// getAccessToken() requests (and caches) a token for the client itself instead of reading a +// user's session. +export const serviceClient: ThunderIDNodeClient = new ThunderIDNodeClient(1); + +await serviceClient.initialize({ + baseUrl: config.baseUrl, + clientId: config.serviceClientId, + clientSecret: config.serviceClientSecret, + grantType: 'client_credentials', + scopes: config.scopes, +}); +// #endregion diff --git a/playgrounds/node/src/config.ts b/playgrounds/node/src/config.ts new file mode 100644 index 00000000..5b6f6fa8 --- /dev/null +++ b/playgrounds/node/src/config.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +const env = (name: string): string | undefined => { + const value: string | undefined = process.env[name]?.trim(); + return value === '' ? undefined : value; +}; + +const port = Number(env('PORT') ?? 3003); +const appUrl: string = env('APP_URL') ?? `http://localhost:${port}`; + +/** Playground settings, read from the environment (see `.env.example`). */ +const config: { + afterSignInUrl: string; + afterSignOutUrl: string; + appUrl: string; + applicationId: string | undefined; + baseUrl: string; + clientId: string | undefined; + clientSecret: string | undefined; + port: number; + scopes: string[]; + serviceClientId: string | undefined; + serviceClientSecret: string | undefined; +} = { + afterSignInUrl: env('THUNDERID_AFTER_SIGN_IN_URL') ?? `${appUrl}/login`, + afterSignOutUrl: env('THUNDERID_AFTER_SIGN_OUT_URL') ?? `${appUrl}/logout`, + appUrl, + applicationId: env('THUNDERID_APPLICATION_ID'), + baseUrl: env('THUNDERID_BASE_URL') ?? 'https://localhost:8090', + clientId: env('THUNDERID_CLIENT_ID'), + clientSecret: env('THUNDERID_CLIENT_SECRET'), + port, + scopes: (env('THUNDERID_SCOPES') ?? 'openid profile email').split(/\s+/), + serviceClientId: env('THUNDERID_SERVICE_CLIENT_ID') ?? env('THUNDERID_CLIENT_ID'), + serviceClientSecret: env('THUNDERID_SERVICE_CLIENT_SECRET') ?? env('THUNDERID_CLIENT_SECRET'), +}; + +export default config; diff --git a/playgrounds/node/src/explorer.ts b/playgrounds/node/src/explorer.ts new file mode 100644 index 00000000..205b5634 --- /dev/null +++ b/playgrounds/node/src/explorer.ts @@ -0,0 +1,263 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {randomUUID} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {generateSessionId, type SessionData, type User} from '@thunderid/node'; +import operations, {type OperationSpec} from './catalog.ts'; +import {client, serviceClient} from './client.ts'; +import config from './config.ts'; +import runners, {type Runner} from './operations.ts'; +import {traced, type Traced} from './trace.ts'; + +export type Identity = 'anonymous' | 'session' | 'expired'; + +const SOURCE_FILES: string[] = ['./client.ts', './operations.ts', './server.ts']; + +interface Region { + code: string; + file: string; +} + +const readRegions = (): Map => { + const regions: Map = new Map(); + const pattern = /\/\/ #region (\S+)\n([\s\S]*?)\/\/ #endregion/g; + SOURCE_FILES.forEach((file: string): void => { + const source: string = readFileSync(new URL(file, import.meta.url), 'utf8'); + let match: RegExpExecArray | null = pattern.exec(source); + while (match) { + regions.set(match[1], {code: match[2].trimEnd(), file: `src/${file.slice(2)}`}); + match = pattern.exec(source); + } + }); + return regions; +}; + +const readSdkVersion = (): string => { + const entry: string = fileURLToPath(import.meta.resolve('@thunderid/node')); + const manifest: {version: string} = JSON.parse( + readFileSync(new URL('../package.json', `file://${entry}`), 'utf8'), + ) as {version: string}; + return manifest.version; +}; + +/** + * The session record behind a session id. `@thunderid/node` exposes no public read of it (expiry, + * refresh token presence), so the playground reaches the client's storage manager directly. + */ +const sessionRecord = (sessionId: string): Promise => + (client as unknown as {getStorageManager(): {getSessionData(id: string): Promise}}) + .getStorageManager() + .getSessionData(sessionId); + +const expiresAt = (data: SessionData | undefined): number | undefined => + data?.created_at && data.expires_in ? data.created_at + parseInt(data.expires_in, 10) * 1000 : undefined; + +const displayName = (user: User): string => { + const u: Record = user as Record; + const given: unknown = u['given_name'] ?? u['givenName']; + const family: unknown = u['family_name'] ?? u['familyName']; + const named: string = [given, family].filter((part: unknown): part is string => typeof part === 'string').join(' '); + const candidates: unknown[] = [u['name'], named, u['username'], u['email'], u['sub']]; + const found: unknown = candidates.find((value: unknown): boolean => typeof value === 'string' && value !== ''); + return typeof found === 'string' ? found : 'Signed-in user'; +}; + +const idpStatus = async (): Promise<{error?: string; reachable: boolean}> => { + try { + await fetch(`${config.baseUrl}/.well-known/openid-configuration`, {signal: AbortSignal.timeout(1500)}); + return {reachable: true}; + } catch (e: unknown) { + const cause: unknown = e instanceof Error ? e.cause : undefined; + return {error: cause instanceof Error ? cause.message : String(e), reachable: false}; + } +}; + +// One expired copy per real session, rebuilt before every run that uses it. +const expiredCopies: Map = new Map(); + +/** + * Builds the "expired access token" identity: a copy of the browser session whose access token + * expired a minute ago. The SDK then sees a real expired session, and the browser session is left + * alone. + */ +const expiredCopyOf = async (sessionId: string): Promise => { + const data: SessionData | undefined = await sessionRecord(sessionId); + if (!data?.access_token) return undefined; + + const copyId: string = expiredCopies.get(sessionId) ?? randomUUID(); + expiredCopies.set(sessionId, copyId); + const lifetimeSeconds: number = parseInt(data.expires_in, 10) || 3600; + await client.setSession({...data, created_at: Date.now() - (lifetimeSeconds + 60) * 1000}, copyId); + return copyId; +}; + +/** The session summary the explorer shows in its top bar and "Run as" choices. */ +export const sessionSummary = async (sessionId: string | undefined): Promise> => { + if (!sessionId) return {present: false}; + const data: SessionData | undefined = await sessionRecord(sessionId); + const signedIn: boolean = data?.access_token ? await client.isSignedIn(sessionId) : false; + let name: string | undefined; + try { + name = data?.id_token ? displayName(await client.getUser(sessionId)) : undefined; + } catch { + name = undefined; + } + return { + expiresAt: expiresAt(data), + hasRefreshToken: Boolean(data?.refresh_token), + hasTokens: Boolean(data?.access_token), + id: `${sessionId.slice(0, 8)}...`, + name, + present: true, + scopes: data?.scope?.split(' ') ?? [], + signedIn, + }; +}; + +const toJson = (value: unknown): unknown => { + if (value === undefined) return null; + return JSON.parse(JSON.stringify(value, (_key: string, v: unknown) => (typeof v === 'bigint' ? String(v) : v))); +}; + +/** An error as the explorer shows it: SDK errors carry a code, and some carry a server response. */ +const describeError = (error: unknown): Record => { + if (!(error instanceof Error)) return {message: String(error)}; + const own: Record = Object.fromEntries( + Object.entries(error).filter(([key]: [string, unknown]): boolean => key !== 'stack'), + ); + return toJson({ + ...own, + message: error.message, + name: error.name, + ...(error.cause ? {cause: error.cause instanceof Error ? error.cause.message : error.cause} : {}), + }) as Record; +}; + +const literal = (value: string, type: string): string => + type === 'number' && value !== '' && !Number.isNaN(Number(value)) ? value : JSON.stringify(value); + +/** The call as code, with the values that were sent, for "Copy as code". */ +const callAsCode = ( + operation: OperationSpec, + params: Record, + args: unknown, + sessionLabel: string, +): string => { + const lines: string[] = []; + if (args !== undefined) lines.push(`const args = ${JSON.stringify(args, null, 2)};`); + operation.params.forEach((p: {name: string; type: string}): void => { + lines.push(`const ${p.name} = ${literal(params[p.name] ?? '', p.type)};`); + }); + if (operation.session) lines.push(`const sessionId = '${sessionLabel}';`); + const call: string = operation.call.replace(/\((requestConfig|options|config|payload)\b/, '(args'); + lines.push('', `const result = await ${call};`); + return lines.join('\n'); +}; + +export interface RunRequest { + args?: string; + identity?: Identity; + operationId?: string; + params?: Record; +} + +export interface RunResult { + code: string; + error?: Record; + ms: number; + ok: boolean; + requests: Traced['requests']; + session: string; + steps: Traced['steps']; + value?: unknown; +} + +/** The operations explorer's backend: the operation catalogue, session state and the runner. */ +const explorer = (): { + meta: (browserSession: string | undefined) => Promise>; + run: (request: RunRequest, browserSession: string | undefined) => Promise<{body: unknown; status: number}>; +} => { + const regions: Map = readRegions(); + const sdkVersion: string = readSdkVersion(); + + const meta = async (browserSession: string | undefined): Promise> => ({ + app: config.appUrl, + configured: Boolean(config.clientId), + idp: {baseUrl: config.baseUrl, ...(await idpStatus())}, + operations: operations.map((operation: OperationSpec) => { + const blocks: Region[] = operation.regions + .map((name: string): Region | undefined => regions.get(name)) + .filter((region: Region | undefined): region is Region => region !== undefined); + const sourceFile: string = regions.get(operation.id)?.file ?? ''; + return { + ...operation, + source: blocks + .map((region: Region): string => + region.file === sourceFile ? region.code : `// ${region.file}\n${region.code}`, + ) + .join('\n\n'), + sourceFile, + }; + }), + sdk: {name: '@thunderid/node', version: sdkVersion}, + session: await sessionSummary(browserSession), + }); + + const run = async ( + request: RunRequest, + browserSession: string | undefined, + ): Promise<{body: unknown; status: number}> => { + const operation: OperationSpec | undefined = operations.find( + (o: OperationSpec): boolean => o.id === request.operationId, + ); + const runner: Runner | undefined = operation ? runners[operation.id] : undefined; + + if (!operation || !runner) { + return {body: {message: operation?.unavailable ?? 'Unknown operation.'}, status: 404}; + } + + let args: unknown; + if (operation.args !== undefined) { + try { + args = JSON.parse(request.args ?? operation.args); + } catch (e: unknown) { + return {body: {message: `The arguments are not valid JSON: ${(e as Error).message}`}, status: 400}; + } + } + + let sessionId: string = generateSessionId(); + let sessionLabel = 'a new session id'; + if (request.identity === 'session' || request.identity === 'expired') { + if (!browserSession) { + return {body: {message: 'Reload the page: your browser has no playground session yet.'}, status: 409}; + } + sessionId = browserSession; + sessionLabel = 'your browser session'; + if (request.identity === 'expired') { + const copy: string | undefined = await expiredCopyOf(browserSession); + if (!copy) return {body: {message: 'Your session holds no tokens to expire. Sign in first.'}, status: 409}; + sessionId = copy; + sessionLabel = 'a copy of your session, access token expired'; + } + } + + const params: Record = request.params ?? {}; + const result: Traced = await traced(() => runner({args, client, params, serviceClient, sessionId})); + const body: RunResult = { + code: callAsCode(operation, params, args, operation.session ? `${sessionId.slice(0, 8)}...` : ''), + ms: result.ms, + ok: result.ok, + requests: result.requests, + session: operation.session ? `${sessionLabel} (${sessionId.slice(0, 8)}...)` : 'none', + steps: result.steps, + ...(result.ok ? {value: toJson(result.value)} : {error: describeError(result.error)}), + }; + return {body, status: 200}; + }; + + return {meta, run}; +}; + +export default explorer; diff --git a/playgrounds/node/src/operations.ts b/playgrounds/node/src/operations.ts new file mode 100644 index 00000000..3b2356a5 --- /dev/null +++ b/playgrounds/node/src/operations.ts @@ -0,0 +1,215 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +// What each operation in the explorer runs. The explorer shows an operation's source by reading +// its `#region` block in this file, so keep each runner inside its region. + +import type { + AgentConfig, + CIBAInitiateOptions, + ExtendedAuthorizeRequestUrlParams, + ThunderIDNodeClient, + TokenExchangeRequestConfig, +} from '@thunderid/node'; + +export interface RunContext { + /** The JSON argument from the explorer's Arguments field, already parsed. */ + args: unknown; + client: ThunderIDNodeClient; + /** Values of the operation's parameters, as typed in the explorer. */ + params: Record; + serviceClient: ThunderIDNodeClient; + /** The session the operation runs as (see "Run as" in the explorer). */ + sessionId: string; +} + +export type Runner = (ctx: RunContext) => Promise; + +const num = (value: string | undefined): number | undefined => (value ? Number(value) : undefined); + +// #region getOpenIDProviderEndpoints +const getOpenIDProviderEndpoints: Runner = ({client}) => client.getOpenIDProviderEndpoints(); +// #endregion + +// #region getDiscoveryResponse +const getDiscoveryResponse: Runner = ({client}) => client.getDiscoveryResponse(); +// #endregion + +// #region getConfiguration +// The playground masks the client secret before showing the configuration. +const getConfiguration: Runner = async ({client}) => { + const {clientSecret, ...resolved} = await client.getConfiguration(); + return {...resolved, clientSecret: clientSecret ? '***' : undefined}; +}; +// #endregion + +// #region getSignInUrl +const getSignInUrl: Runner = ({args, client, sessionId}) => + client.getSignInUrl(args as ExtendedAuthorizeRequestUrlParams, sessionId); +// #endregion + +// #region signInStart +// Without a code, signIn() builds the authorize URL (keeping PKCE and state in the session) and +// hands it to the callback, which would normally redirect the browser. +const signInStart: Runner = async ({client, sessionId}) => { + let authorizeUrl: string | undefined; + const tokens = await client.signIn((url: string) => { + authorizeUrl = url; + }, sessionId); + return {authorizeUrl, tokens}; +}; +// #endregion + +// #region signInCode +// With a code and state (the redirect back from ThunderID), signIn() exchanges the code for +// tokens and stores them in the session. +const signInCode: Runner = ({client, params, sessionId}) => + client.signIn(undefined, sessionId, params['code'], params['state']); +// #endregion + +// #region getOBOSignInURL +const getOBOSignInURL: Runner = ({client, params}) => + client.getOBOSignInURL({agentID: params['agentID']} as AgentConfig); +// #endregion + +// #region initiateCIBA +const initiateCIBA: Runner = ({args, client}) => client.initiateCIBA(args as CIBAInitiateOptions); +// #endregion + +// #region pollCIBA +// pollCIBA() keeps polling until the user decides; the signal stops it after the timeout. +const pollCIBA: Runner = ({client, params}) => + client.pollCIBA(params['authReqId'] ?? '', Number(params['interval'] ?? 5), { + signal: AbortSignal.timeout(Number(params['timeoutSeconds'] ?? 30) * 1000), + }); +// #endregion + +// #region isSignedIn +// Also refreshes the access token when it has expired and a refresh token is at hand. +const isSignedIn: Runner = ({client, sessionId}) => client.isSignedIn(sessionId); +// #endregion + +// #region getUser +const getUser: Runner = ({client, sessionId}) => client.getUser(sessionId); +// #endregion + +// #region getIdToken +const getIdToken: Runner = ({client, sessionId}) => client.getIdToken(sessionId); +// #endregion + +// #region getDecodedIdToken +const getDecodedIdToken: Runner = ({client, sessionId}) => client.getDecodedIdToken(sessionId); +// #endregion + +// #region getAccessToken +const getAccessToken: Runner = ({client, sessionId}) => client.getAccessToken(sessionId); +// #endregion + +// #region decodeJwtToken +const decodeJwtToken: Runner = ({client, params}) => client.decodeJwtToken(params['token'] ?? ''); +// #endregion + +// #region clearSession +// clearSession() returns nothing and finishes in the background. +const clearSession: Runner = ({client, sessionId}) => { + client.clearSession(sessionId); + return Promise.resolve({cleared: sessionId}); +}; +// #endregion + +// #region refreshAccessToken +const refreshAccessToken: Runner = ({client, sessionId}) => client.refreshAccessToken(sessionId); +// #endregion + +// #region revokeAccessToken +// Revokes the access token at ThunderID and clears the session. +const revokeAccessToken: Runner = async ({client, sessionId}) => { + const response = await client.revokeAccessToken(sessionId); + return response instanceof Response ? {ok: response.ok, status: response.status} : response; +}; +// #endregion + +// #region exchangeToken +// Values in `data` may use {{accessToken}}, {{clientId}}, {{clientSecret}}, {{scopes}} and +// {{username}}; the SDK fills them in from the config and the session. +const exchangeToken: Runner = ({args, client, sessionId}) => + client.exchangeToken(args as TokenExchangeRequestConfig, sessionId); +// #endregion + +// #region clientCredentials +// The service client was initialised with grantType 'client_credentials' (see its setup). +const clientCredentials: Runner = async ({serviceClient}) => { + const accessToken = await serviceClient.getAccessToken(); + return {accessToken, claims: await serviceClient.decodeJwtToken(accessToken).catch(() => 'opaque token')}; +}; +// #endregion + +// #region signOut +// Returns the end-session URL to redirect to. The stored session stays, so a back-channel logout +// that follows can still find it. +const signOut: Runner = async ({client, sessionId}) => ({signOutUrl: await client.signOut(sessionId)}); +// #endregion + +// #region handleBackchannelLogout +const handleBackchannelLogout: Runner = ({client, params}) => + client.handleBackchannelLogout(params['logoutToken'] ?? ''); +// #endregion + +// #region getUsers +const getUsers: Runner = ({client, params, sessionId}) => + client.getUsers( + {filter: params['filter'] || undefined, limit: num(params['limit']), offset: num(params['offset'])}, + sessionId, + ); +// #endregion + +// #region getFlows +const getFlows: Runner = ({client, params, sessionId}) => + client.getFlows( + {flowType: params['flowType'] || undefined, limit: num(params['limit']), offset: num(params['offset'])}, + sessionId, + ); +// #endregion + +// #region getUserTypes +const getUserTypes: Runner = ({client, params, sessionId}) => + client.getUserTypes({limit: num(params['limit']), offset: num(params['offset'])}, sessionId); +// #endregion + +// #region updateUserCredentials +const updateUserCredentials: Runner = async ({args, client, sessionId}) => { + await client.updateUserCredentials(args as Record, sessionId); + return {updated: Object.keys(args as Record)}; +}; +// #endregion + +const runners: Record = { + clearSession, + clientCredentials, + decodeJwtToken, + exchangeToken, + getAccessToken, + getConfiguration, + getDecodedIdToken, + getDiscoveryResponse, + getFlows, + getIdToken, + getOBOSignInURL, + getOpenIDProviderEndpoints, + getSignInUrl, + getUser, + getUserTypes, + getUsers, + handleBackchannelLogout, + initiateCIBA, + isSignedIn, + pollCIBA, + refreshAccessToken, + revokeAccessToken, + signInCode, + signInStart, + signOut, + updateUserCredentials, +}; + +export default runners; diff --git a/playgrounds/node/src/server.ts b/playgrounds/node/src/server.ts new file mode 100644 index 00000000..2b2ef140 --- /dev/null +++ b/playgrounds/node/src/server.ts @@ -0,0 +1,164 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +// A plain node:http server: the sign-in and sign-out routes an app built on @thunderid/node needs, +// and the operations explorer. The explorer shows the `#region` blocks of this file next to the +// operations they use. + +import {readdirSync, readFileSync} from 'node:fs'; +import {createServer, type IncomingMessage, type ServerResponse} from 'node:http'; +import {CookieConfig, generateSessionId, getSessionCookieOptions, type SessionCookieConfig} from '@thunderid/node'; +import {client, serviceClient} from './client.ts'; +import config from './config.ts'; +import explorer, {type RunRequest} from './explorer.ts'; +import {traceClient, traceFetch, traceSdkLogs} from './trace.ts'; + +traceSdkLogs(); +traceFetch(); +traceClient(client, 'client'); +traceClient(serviceClient, 'serviceClient'); + +const COOKIE_NAME: string = CookieConfig.getSessionCookieName(); +const cookieOptions: Omit = getSessionCookieOptions({ + secure: config.appUrl.startsWith('https:'), +}); + +const PUBLIC_DIR: URL = new URL('../public/', import.meta.url); +const PUBLIC_FILES: Set = new Set(readdirSync(PUBLIC_DIR)); +const CONTENT_TYPES: Record = { + css: 'text/css; charset=utf-8', + html: 'text/html; charset=utf-8', + js: 'text/javascript; charset=utf-8', + svg: 'image/svg+xml', +}; + +const readSessionId = (req: IncomingMessage): string | undefined => + req.headers.cookie + ?.split(';') + .map((part: string): string[] => part.trim().split('=')) + .find(([name]: string[]): boolean => name === COOKIE_NAME)?.[1]; + +const sessionCookie = (value: string, maxAge?: number): string => + [ + `${COOKIE_NAME}=${value}`, + 'Path=/', + `SameSite=${cookieOptions.sameSite ?? 'lax'}`, + cookieOptions.httpOnly ? 'HttpOnly' : '', + cookieOptions.secure ? 'Secure' : '', + maxAge === undefined ? '' : `Max-Age=${maxAge}`, + ] + .filter(Boolean) + .join('; '); + +const redirect = (res: ServerResponse, location: string, cookie?: string): void => { + res.writeHead(302, {Location: location, ...(cookie ? {'Set-Cookie': cookie} : {})}).end(); +}; + +const sendJson = (res: ServerResponse, status: number, body: unknown, cookie?: string): void => { + res + .writeHead(status, {'Content-Type': 'application/json', ...(cookie ? {'Set-Cookie': cookie} : {})}) + .end(JSON.stringify(body)); +}; + +const readBody = async (req: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf8'); +}; + +const errorMessage = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +// #region login +// GET /login: without a code, start sign-in for the browser session; with one (the redirect back +// from ThunderID, the afterSignInUrl), finish it. +const login = async (req: IncomingMessage, res: ServerResponse, url: URL): Promise => { + const sessionId: string = readSessionId(req) ?? generateSessionId(); + const code: string | null = url.searchParams.get('code'); + const state: string | null = url.searchParams.get('state'); + + if (!code || !state) { + await client.signIn((authorizeUrl: string) => redirect(res, authorizeUrl, sessionCookie(sessionId)), sessionId); + if (!res.headersSent) redirect(res, '/'); // Already signed in. + return; + } + + await client.signIn(undefined, sessionId, code, state); + redirect(res, '/'); +}; +// #endregion + +// #region logout +// GET /logout: redirect to the ThunderID end-session endpoint, which comes back here (the +// afterSignOutUrl) with state=sign_out_success. +const logout = async (req: IncomingMessage, res: ServerResponse, url: URL): Promise => { + const sessionId: string | undefined = readSessionId(req); + + if (url.searchParams.get('state') === 'sign_out_success' || !sessionId) { + redirect(res, '/'); + return; + } + + const signOutUrl: string = await client.signOut(sessionId); + redirect(res, signOutUrl, sessionCookie(generateSessionId())); +}; +// #endregion + +const ops: ReturnType = explorer(); + +const serveStatic = (res: ServerResponse, name: string): void => { + if (!PUBLIC_FILES.has(name)) { + sendJson(res, 404, {error: 'not_found'}); + return; + } + res + .writeHead(200, {'Content-Type': CONTENT_TYPES[name.split('.').pop() ?? ''] ?? 'application/octet-stream'}) + .end(readFileSync(new URL(name, PUBLIC_DIR))); +}; + +const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const url: URL = new URL(req.url ?? '/', config.appUrl); + + if (req.method === 'GET' && url.pathname === '/login') { + await login(req, res, url); + return; + } + if (req.method === 'GET' && url.pathname === '/logout') { + await logout(req, res, url); + return; + } + if (req.method === 'GET' && url.pathname === '/_playground/meta') { + // Every browser gets a session id up front, so operations can run as it before sign-in. + const existing: string | undefined = readSessionId(req); + const sessionId: string = existing ?? generateSessionId(); + sendJson(res, 200, await ops.meta(sessionId), existing ? undefined : sessionCookie(sessionId)); + return; + } + if (req.method === 'POST' && url.pathname === '/_playground/run') { + let request: RunRequest; + try { + request = JSON.parse((await readBody(req)) || '{}') as RunRequest; + } catch { + sendJson(res, 400, {message: 'The request body is not valid JSON.'}); + return; + } + const {body, status} = await ops.run(request, readSessionId(req)); + sendJson(res, status, body); + return; + } + if (req.method === 'GET') { + serveStatic(res, url.pathname === '/' ? 'index.html' : url.pathname.slice(1)); + return; + } + sendJson(res, 405, {error: 'method_not_allowed'}); +}; + +createServer((req: IncomingMessage, res: ServerResponse): void => { + handle(req, res).catch((e: unknown): void => { + process.stderr.write(`${errorMessage(e)}\n`); + if (!res.headersSent) { + sendJson(res, 500, {error: (e as {code?: string}).code ?? 'error', message: errorMessage(e)}); + } + }); +}).listen(config.port, () => { + process.stdout.write(`ThunderID Node.js playground on ${config.appUrl} (ThunderID at ${config.baseUrl})\n`); +}); diff --git a/playgrounds/node/src/trace.ts b/playgrounds/node/src/trace.ts new file mode 100644 index 00000000..905e5381 --- /dev/null +++ b/playgrounds/node/src/trace.ts @@ -0,0 +1,310 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Playground-only tracing. `@thunderid/node` has no debug event stream, so the playground builds + * the SDK trace of an operation from three sources: + * + * - the SDK's own logger, through its public `formatter` hook (`logger.configure()`); + * - wrappers around the client's methods, including the protected ones the public methods call, + * so nested SDK calls show up under the operation that made them; + * - a wrapper around `fetch`, which the SDK uses for every request to ThunderID. It also keeps the + * request and response, which the explorer shows on its HTTP tab. + * + * Each operation runs inside an `AsyncLocalStorage` context that collects its steps. + */ + +import {AsyncLocalStorage} from 'node:async_hooks'; +import {logger, type LogLevel, type ThunderIDNodeClient} from '@thunderid/node'; + +export type TraceKind = 'ok' | 'info' | 'warn' | 'error'; + +export type TraceSource = 'client' | 'http' | 'log'; + +export interface TraceStep { + at: number; + depth: number; + detail: string; + durationMs?: number; + kind: TraceKind; + source: TraceSource; + step: string; +} + +export interface HttpExchange { + durationMs?: number; + error?: string; + method: string; + requestBody?: string; + requestHeaders: [string, string][]; + responseBody?: string; + responseHeaders: [string, string][]; + status?: number; + statusText?: string; + url: string; +} + +export interface Traced { + error?: unknown; + ms: number; + ok: boolean; + requests: HttpExchange[]; + steps: TraceStep[]; + value?: R; +} + +interface TraceContext { + depth: number; + requests: HttpExchange[]; + start: number; + steps: TraceStep[]; +} + +const storage: AsyncLocalStorage = new AsyncLocalStorage(); + +const begin = (step: string, source: TraceSource): ((detail: string, kind: TraceKind) => void) => { + const ctx: TraceContext | undefined = storage.getStore(); + + if (!ctx) { + return (): void => undefined; + } + + const startedAt: number = performance.now(); + const entry: TraceStep = { + at: Math.round(startedAt - ctx.start), + depth: ctx.depth, + detail: '', + kind: 'info', + source, + step, + }; + + ctx.steps.push(entry); + ctx.depth += 1; + + return (detail: string, kind: TraceKind): void => { + ctx.depth -= 1; + entry.detail = detail; + entry.kind = kind; + entry.durationMs = Math.round(performance.now() - startedAt); + }; +}; + +/** Runs `fn` and returns what it resolved or threw, with the steps and HTTP requests it made. */ +export const traced = async (fn: () => Promise): Promise> => { + const ctx: TraceContext = {depth: 0, requests: [], start: performance.now(), steps: []}; + + return storage.run(ctx, async (): Promise> => { + try { + const value: R = await fn(); + return {ms: Math.round(performance.now() - ctx.start), ok: true, requests: ctx.requests, steps: ctx.steps, value}; + } catch (error: unknown) { + return { + error, + ms: Math.round(performance.now() - ctx.start), + ok: false, + requests: ctx.requests, + steps: ctx.steps, + }; + } + }); +}; + +const summarize = (value: unknown): string => { + if (typeof value === 'boolean') return String(value); + if (value === undefined || value === null || value === '') return 'nothing'; + if (typeof value === 'string') { + if (value.startsWith('http')) { + const url: URL = new URL(value); + return `${url.host}${url.pathname}`; + } + if (/^[\w-]+\.[\w-]+\.[\w-]*$/.test(value)) return `JWT (${value.length} chars)`; + return `string (${value.length} chars)`; + } + if (value instanceof Response) return `Response ${value.status}`; + if (typeof value === 'object') { + const keys: string[] = Object.keys(value); + return `{${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', ...' : ''}}`; + } + return typeof value; +}; + +const errorText = (e: unknown): string => { + if (!(e instanceof Error)) return String(e); + const code: unknown = (e as {code?: unknown}).code; + return typeof code === 'string' ? `${code}: ${e.message}` : e.message; +}; + +const shortId = (value: unknown): string => (typeof value === 'string' ? `sid=${value.slice(0, 8)}...` : ''); + +/** + * Client methods to trace, with the position of their session id argument (-1: none). The + * protected ones are the steps the public methods are built from. + */ +const CLIENT_METHODS: Record = { + clearSession: 0, + clearSessionAsync: 0, + decodeJwtToken: -1, + exchangeToken: 1, + getAccessToken: 0, + getConfiguration: -1, + getDecodedIdToken: 0, + getDiscoveryResponse: -1, + getFlows: 1, + getIdToken: 0, + getOBOSignInURL: -1, + getOpenIDProviderEndpoints: -1, + getSignInUrl: 1, + getSignOutUrl: 0, + getUser: 0, + getUserTypes: 1, + getUsers: 1, + handleBackchannelLogout: -1, + initiateCIBA: -1, + isSignedIn: 0, + loadOpenIDProviderConfiguration: -1, + pollCIBA: -1, + refreshAccessToken: 0, + requestAccessToken: 2, + requestAccessTokenRevocation: 0, + requestClientCredentialsToken: 0, + revokeAccessToken: 0, + setSession: 1, + signIn: 1, + signOut: 0, + updateUserCredentials: 1, + validateLogoutToken: -1, +}; + +/** + * Wraps the client's methods on the instance. Methods the SDK calls through `this` then go through + * the wrappers too, so the trace shows how one call is built from the others. + */ +export const traceClient = (client: ThunderIDNodeClient, label: string): void => { + const target: Record = client as unknown as Record; + + Object.entries(CLIENT_METHODS).forEach(([name, sessionIndex]: [string, number]): void => { + const original: unknown = target[name]; + if (typeof original !== 'function') return; + + target[name] = (...args: unknown[]): unknown => { + const sessionArg: unknown = sessionIndex >= 0 ? args[sessionIndex] : undefined; + const end: (detail: string, kind: TraceKind) => void = begin( + `${label}.${name}(${shortId(sessionArg)})`, + 'client', + ); + const done = (result: unknown): unknown => { + end(`returned ${summarize(result)}`, result === false ? 'warn' : 'ok'); + return result; + }; + const fail = (e: unknown): never => { + end(`threw ${errorText(e)}`, 'error'); + throw e; + }; + + try { + const result: unknown = (original as (...a: unknown[]) => unknown).apply(client, args); + return result instanceof Promise ? result.then(done, fail) : done(result); + } catch (e: unknown) { + return fail(e); + } + }; + }); +}; + +const kindForStatus = (status: number): TraceKind => { + if (status >= 500) return 'error'; + if (status >= 400) return 'warn'; + return 'ok'; +}; + +const SECRET_FIELDS: string[] = ['client_secret', 'password']; +const SECRET_HEADERS: string[] = ['authorization', 'flow-secret']; + +const mask = (value: string): string => (value.length > 12 ? `${value.slice(0, 10)}...` : '***'); + +const maskBody = (body: string): string => + SECRET_FIELDS.reduce( + (masked: string, field: string): string => + masked + .replace(new RegExp(`(^|&)${field}=[^&]*`, 'g'), `$1${field}=***`) + .replace(new RegExp(`("${field}"\\s*:\\s*)"[^"]*"`, 'g'), '$1"***"'), + body, + ); + +const headerList = (headers: ConstructorParameters[0]): [string, string][] => + [...new Headers(headers).entries()].map(([name, value]: [string, string]): [string, string] => [ + name, + SECRET_HEADERS.includes(name) ? mask(value) : value, + ]); + +const describeBody = (body: string | undefined): string => { + if (!body) return ''; + const grant: string | null = new URLSearchParams(body).get('grant_type'); + return grant ? ` grant_type=${grant}` : ''; +}; + +/** Wraps `fetch` so the SDK's requests to ThunderID appear in the trace and on the HTTP tab. */ +export const traceFetch = (): void => { + const original: typeof fetch = globalThis.fetch; + + globalThis.fetch = async ( + input: string | URL | globalThis.Request, + init?: RequestInit, + ): Promise => { + const ctx: TraceContext | undefined = storage.getStore(); + if (!ctx) return original(input, init); + + const url: URL = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); + const method: string = init?.method ?? (input instanceof globalThis.Request ? input.method : 'GET'); + const body: string | undefined = + typeof init?.body === 'string' || init?.body instanceof URLSearchParams ? init.body.toString() : undefined; + const exchange: HttpExchange = { + method, + requestBody: body ? maskBody(body) : undefined, + requestHeaders: headerList(init?.headers), + responseHeaders: [], + url: url.toString(), + }; + ctx.requests.push(exchange); + + const startedAt: number = performance.now(); + const end: (detail: string, kind: TraceKind) => void = begin( + `${method} ${url.host}${url.pathname}${describeBody(body)}`, + 'http', + ); + + try { + const response: globalThis.Response = await original(input, init); + exchange.status = response.status; + exchange.statusText = response.statusText; + exchange.responseHeaders = [...response.headers.entries()]; + exchange.responseBody = await response.clone().text(); + exchange.durationMs = Math.round(performance.now() - startedAt); + end(`${response.status} ${response.statusText}`, kindForStatus(response.status)); + return response; + } catch (e: unknown) { + const cause: unknown = e instanceof Error ? e.cause : undefined; + exchange.error = cause instanceof Error ? cause.message : String(e); + exchange.durationMs = Math.round(performance.now() - startedAt); + end(`failed: ${exchange.error}`, 'error'); + throw e; + } + }; +}; + +const LOG_KIND: Record = {debug: 'info', error: 'error', info: 'info', warn: 'warn'}; + +/** + * Routes the SDK logger through its `formatter` hook: every line goes to stdout and, while an + * operation runs, into its trace. + */ +export const traceSdkLogs = (): void => { + logger.configure({ + formatter: (level: LogLevel, message: string): void => { + process.stdout.write(`[ThunderID SDK] ${level.toUpperCase()} ${message}\n`); + begin('SDK log', 'log')(message, LOG_KIND[level]); + }, + level: 'debug', + }); +}; diff --git a/playgrounds/node/tsconfig.eslint.json b/playgrounds/node/tsconfig.eslint.json new file mode 100644 index 00000000..b03733cd --- /dev/null +++ b/playgrounds/node/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": true + }, + "include": ["**/*.js", "**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/playgrounds/node/tsconfig.json b/playgrounds/node/tsconfig.json new file mode 100644 index 00000000..6b0a5b83 --- /dev/null +++ b/playgrounds/node/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/playgrounds/node/turbo.json b/playgrounds/node/turbo.json new file mode 100644 index 00000000..b743d5d2 --- /dev/null +++ b/playgrounds/node/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": [] + } + } +} diff --git a/playgrounds/nuxt/.env.example b/playgrounds/nuxt/.env.example new file mode 100644 index 00000000..af3e3fa3 --- /dev/null +++ b/playgrounds/nuxt/.env.example @@ -0,0 +1,28 @@ +# Base URL of your ThunderID server. +NUXT_PUBLIC_THUNDERID_BASE_URL=https://localhost:8090 + +# Client ID of the application registered in ThunderID. Needed for the redirect sign-in flow +# (GET /api/auth/signin). Register http://localhost:3002/api/auth/callback as its redirect URI. +NUXT_PUBLIC_THUNDERID_CLIENT_ID= + +# Optional: client secret, for a confidential client. Server-only, never sent to the browser. +THUNDERID_CLIENT_SECRET= + +# Optional: ID of the same application. Needed for the app-native (embedded) sign-in and sign-up +# flows rendered by and , and for the flow metadata of this application. +NUXT_PUBLIC_THUNDERID_APPLICATION_ID= + +# Flow Secret of the application, sent when an app-native flow starts. Server-only. +THUNDERID_FLOW_SECRET= + +# Secret that signs the session cookie. Generate one locally, for example with +# `openssl rand -base64 32`. Unset uses an insecure development default. +THUNDERID_SESSION_SECRET= + +# Where the browser goes after sign-in and sign-out. +NUXT_PUBLIC_THUNDERID_AFTER_SIGN_IN_URL=/ +NUXT_PUBLIC_THUNDERID_AFTER_SIGN_OUT_URL=/ + +# DANGER: turns off TLS verification for every request the Nuxt server makes. Only for a local +# ThunderID server with a self-signed certificate. Never use it in production. +NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/playgrounds/nuxt/.gitignore b/playgrounds/nuxt/.gitignore new file mode 100644 index 00000000..22fec972 --- /dev/null +++ b/playgrounds/nuxt/.gitignore @@ -0,0 +1,7 @@ +node_modules +.nuxt +.output +.data +.env +.env.* +!.env.example diff --git a/playgrounds/nuxt/.prettierignore b/playgrounds/nuxt/.prettierignore new file mode 100644 index 00000000..c246a051 --- /dev/null +++ b/playgrounds/nuxt/.prettierignore @@ -0,0 +1,4 @@ +/.nuxt +/.output +/node_modules +/coverage diff --git a/playgrounds/nuxt/README.md b/playgrounds/nuxt/README.md new file mode 100644 index 00000000..980db59f --- /dev/null +++ b/playgrounds/nuxt/README.md @@ -0,0 +1,82 @@ +# ThunderID Nuxt Playground + +A Nuxt 3 app that exercises every capability of `@thunderid/nuxt`, in the browser and on the Nuxt server, against a +local ThunderID server. It links the SDK packages from this workspace (`workspace:*`), so it always runs the code you +are changing. It is not published. + +| Page | What it does | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Overview (`/`) | Capability checks for the client and the server (run one or run all, pass/fail with a reason, filters), a session card with an access token countdown read from the server session, the module configuration, and a live SDK event log. | +| Components (`/components`) | A component explorer: the components the module auto-imports and the primitives of `@thunderid/vue`, with stories, a viewport, theme, and grid toolbar, and Controls, Code (a Vue SFC generated from the props), Actions, and Accessibility panels. | +| Composables (`/composables`) | Live values of `useThunderID`, `useUser`, `useThunderIDI18n`, `useTheme`, `useFlowMeta`, and `useFlow`. | +| Server (`/server`) | The context the Nitro plugin built for the server render (`getThunderIDContext`), the session behind `useServerSession`, and an API explorer for every `/api/auth/*` route the module mounts, the playground's own server routes, and the pages behind route middleware, with an SDK trace. | + +The playground's own server code shows how an app uses the module: + +- `server/api/playground/*.ts`: API routes built on `useServerSession`, `requireServerSession`, and + `getValidAccessToken` from `@thunderid/nuxt/server`. +- `server/middleware/playground.ts`: server middleware that uses `createRouteMatcher` from `@thunderid/nuxt/utils`. It + starts the SDK trace for API routes and records `getThunderIDContext` for page renders. +- `pages/protected/index.vue` and `pages/protected/scopes.vue`: pages behind the named `auth` route middleware and a + `defineThunderIDMiddleware({requireScopes})` middleware. + +## Set up a local ThunderID server + +1. Start ThunderID on `https://localhost:8090` (see the [ThunderID docs](https://thunderid.dev)) and sign in to the + Console. +2. Create an application and register this redirect URI: `http://localhost:3002/api/auth/callback`. The browser never + calls ThunderID directly, so no allowed origins (CORS) are needed. +3. Note the application's client ID, and for the app-native flows its application ID and Flow Secret. + +## Run + +```bash +# From the repository root: install and build the SDKs once. +pnpm install +pnpm build + +# Configure the playground. +cp playgrounds/nuxt/.env.example playgrounds/nuxt/.env +# Set NUXT_PUBLIC_THUNDERID_CLIENT_ID, and NUXT_PUBLIC_THUNDERID_APPLICATION_ID and THUNDERID_FLOW_SECRET for the +# app-native flows. + +pnpm --filter @thunderid/playground-nuxt dev +``` + +Open http://localhost:3002. After changing SDK source, rebuild that package (for example +`pnpm --filter @thunderid/nuxt build`) and restart the dev server. + +| Variable | Purpose | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `NUXT_PUBLIC_THUNDERID_BASE_URL` | ThunderID server, for example `https://localhost:8090`. | +| `NUXT_PUBLIC_THUNDERID_CLIENT_ID` | Client ID of the application. Needed for the redirect sign-in. | +| `THUNDERID_CLIENT_SECRET` | Client secret, for a confidential client. Server-only. | +| `NUXT_PUBLIC_THUNDERID_APPLICATION_ID` | Application ID. Needed by ``, ``, and their checks. | +| `THUNDERID_FLOW_SECRET` | Flow Secret of the application, sent when an app-native flow starts. Server-only. | +| `THUNDERID_SESSION_SECRET` | Signs the session cookie. Unset uses an insecure development default. | +| `NUXT_PUBLIC_THUNDERID_AFTER_SIGN_IN_URL`, `NUXT_PUBLIC_THUNDERID_AFTER_SIGN_OUT_URL` | Where the browser goes after sign-in and sign-out. Default to `/`. | +| `NODE_TLS_REJECT_UNAUTHORIZED=0` | Lets the Nuxt server talk to a local ThunderID with a self-signed certificate. Local only. | + +`nuxt.config.ts` turns on the opt-in features of the module so the playground can show them: back-channel logout and the +three resource pickers (`UserSelect`, `AuthFlowSelect`, `UserTypeSelect`). + +Without a client ID the app still runs: the Nitro plugin does not initialize, and checks that need it fail with the +reason. + +## Checks + +Checks that would leave the page (redirect sign-in, sign-out) only run from their own **Run** button; **Run all checks** +marks them as not run. Results are kept in `sessionStorage` so they survive the sign-in redirect. + +## Known SDK gaps + +The playground reports these instead of faking them: + +- The client context is a facade over the server session: `getIdToken`, `getDecodedIdToken`, `signInSilently`, + `exchangeToken`, and `http.request` are no-ops, and there is no `getStorageManager`. Their checks are shown as + unavailable; the Server page reads claims on the server instead. +- No organization API (`useOrganization`, `switchOrganization`): the Organizations check is shown as unavailable. +- No public event subscription. The event log is built from observed composable state plus the calls the playground + makes. +- No way to issue an expired copy of a session: the cookie is signed with `THUNDERID_SESSION_SECRET` and the module + exposes no helper for it, so the API explorer's "Expired access token" identity is disabled. diff --git a/playgrounds/nuxt/app.vue b/playgrounds/nuxt/app.vue new file mode 100644 index 00000000..60f66aad --- /dev/null +++ b/playgrounds/nuxt/app.vue @@ -0,0 +1,25 @@ + + + diff --git a/playgrounds/nuxt/assets/styles.css b/playgrounds/nuxt/assets/styles.css new file mode 100644 index 00000000..f4d8132d --- /dev/null +++ b/playgrounds/nuxt/assets/styles.css @@ -0,0 +1,1596 @@ +/* + * Copyright 2026 The ThunderID Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +:root { + --pg-bg: #050b18; + --pg-surface: rgba(255, 255, 255, 0.02); + --pg-surface-2: rgba(255, 255, 255, 0.05); + --pg-border: rgba(255, 255, 255, 0.08); + --pg-border-2: rgba(255, 255, 255, 0.1); + --pg-text: #fff; + --pg-text-2: rgba(255, 255, 255, 0.6); + --pg-text-3: rgba(255, 255, 255, 0.45); + --pg-primary: #3688ff; + --pg-primary-2: #2060c8; + --pg-primary-soft: rgba(54, 136, 255, 0.18); + --pg-link: #6aa8ff; + --pg-accent: #8bf9fa; + --pg-code: #cfe3ff; + --pg-ok: #6ee7b7; + --pg-ok-bg: #34d399; + --pg-err: #fca5a5; + --pg-err-bg: #ef4444; + --pg-warn: #fcd34d; + --pg-warn-bg: #f59e0b; + --pg-info: #9cc4ff; + --pg-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + --pg-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; + --pg-radius-card: 16px; + --pg-radius-control: 8px; + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: var(--pg-bg); + color: var(--pg-text); + font-family: var(--pg-sans); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--pg-link); + text-decoration: none; +} + +a:hover { + color: var(--pg-info); +} + +.pg-mono { + font-family: var(--pg-mono); +} + +@keyframes pg-spin { + to { + transform: rotate(360deg); + } +} + +.pg-spin { + animation: pg-spin 0.8s linear infinite; +} + +/* Shell */ + +.pg-app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.pg-topbar { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding: 10px 20px; + border-bottom: 1px solid var(--pg-border); + background: rgba(255, 255, 255, 0.015); +} + +.pg-brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; +} + +.pg-brand-divider { + width: 1px; + height: 20px; + background: var(--pg-border-2); +} + +.pg-brand strong { + font-weight: 600; + letter-spacing: -0.2px; +} + +.pg-nav { + display: flex; + gap: 2px; + padding: 3px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.04); +} + +.pg-nav a { + padding: 6px 12px; + border-radius: 7px; + font-size: 13px; + font-weight: 500; + color: var(--pg-text-2); +} + +.pg-nav a[aria-current='page'] { + color: var(--pg-text); + background: var(--pg-primary-soft); +} + +.pg-spacer { + flex: 1; +} + +.pg-server { + display: inline-flex; + align-items: center; + gap: 7px; + font: 500 12px/1 var(--pg-mono); + color: var(--pg-text-2); +} + +.pg-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #94a3b8; +} + +.pg-dot[data-state='up'] { + background: #34d399; + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); +} + +.pg-dot[data-state='down'] { + background: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.pg-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + background: linear-gradient(135deg, #22d3ee, #0e7490); + flex: none; +} + +.pg-avatar[data-size='lg'] { + width: 40px; + height: 40px; + font-size: 13px; +} + +.pg-avatar[data-anon='true'] { + background: rgba(255, 255, 255, 0.08); + color: var(--pg-text-2); +} + +.pg-main { + flex: 1; + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 28px 24px 40px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.pg-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.pg-heading h1 { + font-size: 24px; + font-weight: 400; + letter-spacing: -0.4px; + margin: 0 0 6px; +} + +.pg-heading p { + font-size: 13.5px; + color: rgba(255, 255, 255, 0.55); + margin: 0; +} + +.pg-accent { + font-family: var(--pg-mono); + color: var(--pg-accent); +} + +/* Controls */ + +.pg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 9px 14px; + border-radius: var(--pg-radius-control); + background: var(--pg-surface-2); + border: 1px solid var(--pg-border-2); + font: 500 13px var(--pg-sans); + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + white-space: nowrap; +} + +.pg-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.pg-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pg-btn[data-variant='primary'] { + background: linear-gradient(90deg, var(--pg-primary) 0%, var(--pg-primary-2) 100%); + border-color: transparent; + color: #fff; +} + +.pg-btn[data-variant='danger'] { + background: transparent; + border-color: rgba(239, 68, 68, 0.45); + color: #f87171; +} + +.pg-btn[data-size='sm'] { + padding: 6px 12px; + font-size: 12px; + border-radius: 7px; +} + +.pg-link-btn { + background: none; + border: none; + font: 500 12px var(--pg-sans); + color: var(--pg-link); + cursor: pointer; + padding: 0; +} + +.pg-segmented { + display: flex; + gap: 2px; + padding: 3px; + border-radius: var(--pg-radius-control); + background: var(--pg-surface-2); +} + +.pg-segmented button { + padding: 5px 10px; + border-radius: 6px; + font: 500 12px var(--pg-sans); + color: var(--pg-text-2); + background: none; + border: none; + cursor: pointer; +} + +.pg-segmented button[aria-pressed='true'], +.pg-segmented button[aria-checked='true'] { + background: rgba(54, 136, 255, 0.2); + color: var(--pg-info); +} + +.pg-input { + width: 100%; + background: var(--pg-surface-2); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 7px; + padding: 7px 10px; + font: 12.5px var(--pg-sans); + color: var(--pg-text); +} + +.pg-input::placeholder { + color: rgba(255, 255, 255, 0.32); +} + +/* Cards */ + +.pg-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.pg-stat { + padding: 16px 18px; + border-radius: 12px; + border: 1px solid var(--pg-border); + background: var(--pg-surface); +} + +.pg-stat-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; +} + +.pg-stat-value { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.pg-stat-value strong { + font-size: 26px; + font-weight: 400; + letter-spacing: -0.5px; +} + +.pg-stat-value span { + font-size: 12px; + color: var(--pg-text-3); +} + +.pg-columns { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-start; +} + +.pg-col-main { + flex: 999 1 560px; + min-width: 0; +} + +.pg-col-side { + flex: 1 1 340px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 20px; +} + +.pg-card { + border: 1px solid var(--pg-border); + border-radius: 12px; + background: var(--pg-surface); + min-width: 0; +} + +.pg-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--pg-border); + flex-wrap: wrap; +} + +.pg-card-head h2 { + font-size: 14px; + font-weight: 600; + margin: 0; +} + +.pg-card-meta { + font: 500 11.5px/1 var(--pg-mono); + color: var(--pg-text-3); +} + +.pg-card-body { + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.pg-section-label { + padding: 12px 18px 6px; + font: 600 10.5px/1 var(--pg-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pg-text-3); +} + +/* Capability rows */ + +.pg-cap { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.pg-cap-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.pg-cap-name { + font-size: 13.5px; + color: rgba(255, 255, 255, 0.92); +} + +.pg-cap-api { + font: 400 11.5px/1.4 var(--pg-mono); + color: var(--pg-text-3); +} + +.pg-cap-note { + font-size: 12px; + text-align: right; + max-width: 280px; + color: var(--pg-text-3); + overflow-wrap: anywhere; +} + +.pg-cap-note[data-status='fail'] { + color: var(--pg-err); +} + +.pg-cap-note[data-status='unavailable'] { + color: var(--pg-warn); +} + +.pg-status-icon { + width: 22px; + height: 22px; + flex: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.55); + background: rgba(148, 163, 184, 0.14); +} + +.pg-status-icon[data-status='pass'] { + color: var(--pg-ok); + background: rgba(52, 211, 153, 0.14); +} + +.pg-status-icon[data-status='fail'] { + color: var(--pg-err); + background: rgba(239, 68, 68, 0.14); +} + +.pg-status-icon[data-status='running'] { + color: var(--pg-info); + background: rgba(54, 136, 255, 0.14); +} + +.pg-status-icon[data-status='unavailable'] { + color: var(--pg-warn); + background: rgba(245, 158, 11, 0.14); +} + +.pg-empty { + padding: 16px 18px; + font-size: 12.5px; + color: rgba(255, 255, 255, 0.5); +} + +/* Session */ + +.pg-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 500; + background: rgba(148, 163, 184, 0.14); + color: var(--pg-text-2); +} + +.pg-pill::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.pg-pill[data-state='ok'] { + background: rgba(52, 211, 153, 0.12); + color: var(--pg-ok); +} + +.pg-pill[data-state='warn'] { + background: rgba(245, 158, 11, 0.12); + color: var(--pg-warn); +} + +.pg-user { + display: flex; + align-items: center; + gap: 12px; +} + +.pg-user-name { + font-size: 14px; +} + +.pg-user-sub { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + overflow-wrap: anywhere; +} + +.pg-progress-head { + display: flex; + justify-content: space-between; + font-size: 12px; + color: rgba(255, 255, 255, 0.55); + margin-bottom: 6px; +} + +.pg-progress { + height: 6px; + border-radius: 999px; + background: var(--pg-border); + overflow: hidden; +} + +.pg-progress > div { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--pg-primary), var(--pg-accent)); + transition: width 0.5s linear; +} + +.pg-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 14px; + font-size: 12px; + margin: 0; +} + +.pg-dl dt { + color: rgba(255, 255, 255, 0.5); +} + +.pg-dl dd { + margin: 0; + font-family: var(--pg-mono); + color: var(--pg-code); + overflow-wrap: anywhere; +} + +.pg-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pg-row > .pg-btn { + flex: 1; + padding: 8px 12px; + font-size: 12.5px; +} + +.pg-pre { + margin: 0; + padding: 14px 18px; + font: 400 12px/1.7 var(--pg-mono); + color: var(--pg-code); + white-space: pre; + overflow-x: auto; +} + +.pg-warning { + padding: 12px 16px; + border-radius: 12px; + border: 1px solid rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.08); + color: var(--pg-warn); + font-size: 13px; + line-height: 1.5; +} + +/* Events */ + +.pg-events { + list-style: none; + padding: 6px 0; + margin: 0; + max-height: 260px; + overflow: auto; +} + +.pg-events li { + display: flex; + gap: 12px; + padding: 6px 18px; + font: 400 12px/1.4 var(--pg-mono); +} + +.pg-events .pg-ev-time { + color: rgba(255, 255, 255, 0.4); + flex: none; +} + +.pg-events .pg-ev-name { + white-space: nowrap; +} + +.pg-events .pg-ev-name[data-kind='ok'] { + color: var(--pg-ok); +} + +.pg-events .pg-ev-name[data-kind='error'] { + color: var(--pg-err); +} + +.pg-events .pg-ev-name[data-kind='info'] { + color: var(--pg-info); +} + +.pg-events .pg-ev-detail { + color: rgba(255, 255, 255, 0.55); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Hooks and tokens */ + +.pg-grid-2 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); + gap: 20px; +} + +.pg-json { + margin: 0; + padding: 14px 18px; + font: 400 12px/1.6 var(--pg-mono); + color: var(--pg-code); + white-space: pre-wrap; + word-break: break-all; + max-height: 320px; + overflow: auto; +} + +.pg-token { + font: 400 12px/1.6 var(--pg-mono); + color: var(--pg-code); + word-break: break-all; + padding: 12px 14px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--pg-border); +} + +.pg-token[data-masked='true'] { + color: var(--pg-text-3); +} + +.pg-claims { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} + +.pg-claims th, +.pg-claims td { + text-align: left; + padding: 7px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + vertical-align: top; +} + +.pg-claims th { + font: 500 12px var(--pg-mono); + color: var(--pg-accent); + width: 160px; +} + +.pg-claims td { + font-family: var(--pg-mono); + color: var(--pg-code); + word-break: break-all; +} + +.pg-claims td small { + display: block; + color: var(--pg-text-3); + font-family: var(--pg-sans); +} + +.pg-hook-sig { + font: 400 12px/1.5 var(--pg-mono); + color: var(--pg-text-3); +} + +/* Explorer */ + +.pg-explorer { + flex: 1; + display: flex; + flex-wrap: wrap; + min-height: 0; +} + +.pg-sidebar { + flex: 1 1 248px; + max-width: 100%; + border-right: 1px solid var(--pg-border); + padding: 14px 12px; + display: flex; + flex-direction: column; + gap: 14px; + background: rgba(255, 255, 255, 0.01); +} + +.pg-search { + position: relative; + display: block; +} + +.pg-search svg { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.4); +} + +.pg-search input { + width: 100%; + background: var(--pg-surface-2); + border: 1px solid var(--pg-border-2); + border-radius: var(--pg-radius-control); + padding: 8px 40px 8px 32px; + font: 13px var(--pg-sans); + color: var(--pg-text); +} + +.pg-search kbd { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font: 500 10.5px/1 var(--pg-mono); + color: var(--pg-text-3); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 4px; + padding: 3px 5px; +} + +.pg-tree { + display: flex; + flex-direction: column; + gap: 12px; +} + +.pg-tree ul { + list-style: none; + margin: 4px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.pg-tree-group { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 4px 6px; + font: 600 10.5px/1 var(--pg-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.5); + background: none; + border: none; + cursor: pointer; +} + +.pg-tree-group svg { + transition: transform 0.15s; +} + +.pg-tree-group[aria-expanded='true'] svg { + transform: rotate(90deg); +} + +.pg-tree-group span { + margin-left: auto; + font: 500 10.5px/1 var(--pg-mono); + letter-spacing: 0; + color: rgba(255, 255, 255, 0.35); +} + +.pg-tree-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border-radius: 7px; + font-size: 13px; + color: rgba(255, 255, 255, 0.7); +} + +.pg-tree-item[aria-current='true'] { + background: rgba(54, 136, 255, 0.16); + color: #fff; + font-weight: 500; +} + +.pg-tree-item small { + margin-left: auto; + font: 500 10px/1 var(--pg-mono); + color: var(--pg-warn); +} + +.pg-tree .pg-stories { + margin: 2px 0 6px 22px; + border-left: 1px solid var(--pg-border-2); + gap: 0; +} + +.pg-story-link { + display: block; + padding: 5px 10px; + font-size: 12.5px; + border-radius: 0 6px 6px 0; + margin-left: -1px; + color: rgba(255, 255, 255, 0.55); + border-left: 2px solid transparent; +} + +.pg-story-link[aria-current='true'] { + color: var(--pg-info); + border-left-color: var(--pg-primary); + background: rgba(54, 136, 255, 0.06); +} + +.pg-sidebar-note { + font-size: 11.5px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.4); + margin: 0; +} + +.pg-stage { + flex: 999 1 560px; + min-width: 0; + display: flex; + flex-direction: column; +} + +.pg-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 10px 16px; + border-bottom: 1px solid var(--pg-border); +} + +.pg-crumbs { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + min-width: 0; +} + +.pg-crumbs strong { + color: #fff; + font-weight: 500; +} + +.pg-icon-btn { + width: 30px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--pg-text-2); + background: none; + border: none; + cursor: pointer; +} + +.pg-icon-btn[aria-pressed='true'] { + background: rgba(54, 136, 255, 0.2); + color: var(--pg-info); +} + +.pg-canvas { + flex: 1; + min-height: 380px; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background-color: #071022; + overflow: auto; +} + +.pg-canvas[data-theme='light'] { + background-color: #eef1f6; +} + +.pg-canvas[data-grid='true'] { + background-image: radial-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px); + background-size: 16px 16px; +} + +.pg-canvas[data-theme='light'][data-grid='true'] { + background-image: radial-gradient(rgba(15, 23, 42, 0.12) 1px, transparent 1px); +} + +.pg-frame { + max-width: 100%; + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + border-radius: 12px; + transition: width 0.2s; + color: var(--thunderid-color-text-primary, inherit); +} + +.pg-frame[data-viewport='tablet'], +.pg-frame[data-viewport='mobile'] { + border: 1px dashed rgba(255, 255, 255, 0.15); +} + +.pg-canvas[data-theme='light'] .pg-frame[data-viewport='tablet'], +.pg-canvas[data-theme='light'] .pg-frame[data-viewport='mobile'] { + border-color: rgba(15, 23, 42, 0.18); +} + +.pg-frame-inner { + width: 100%; + display: flex; + justify-content: center; +} + +.pg-panel { + border-top: 1px solid var(--pg-border); + background: rgba(255, 255, 255, 0.015); + height: 320px; + display: flex; + flex-direction: column; +} + +.pg-tabs { + display: flex; + gap: 2px; + padding: 0 12px; + border-bottom: 1px solid var(--pg-border); +} + +.pg-tabs button { + display: inline-flex; + align-items: center; + padding: 10px 12px; + margin-bottom: -1px; + font: 500 12.5px var(--pg-sans); + color: rgba(255, 255, 255, 0.55); + border: none; + border-bottom: 2px solid transparent; + background: none; + cursor: pointer; +} + +.pg-tabs button[aria-selected='true'] { + color: #fff; + border-bottom-color: var(--pg-primary); +} + +.pg-count { + margin-left: 6px; + font: 500 10.5px/1 var(--pg-mono); + padding: 2px 6px; + border-radius: 999px; + background: rgba(54, 136, 255, 0.2); + color: var(--pg-info); +} + +.pg-panel-body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.pg-props { + min-width: 640px; +} + +.pg-props-row { + display: grid; + grid-template-columns: 160px minmax(0, 1.4fr) 140px minmax(0, 1.2fr); + gap: 16px; + align-items: center; + padding: 10px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.pg-props-row[data-head='true'] { + font-size: 11.5px; + font-weight: 500; + color: var(--pg-text-3); +} + +.pg-prop-name { + font: 500 12.5px/1.3 var(--pg-mono); + color: var(--pg-accent); +} + +.pg-prop-desc { + font-size: 12.5px; + line-height: 1.45; + color: var(--pg-text-2); +} + +.pg-prop-desc code { + display: block; + margin-top: 3px; + font: 500 11px/1.3 var(--pg-mono); + color: rgba(255, 255, 255, 0.4); +} + +.pg-prop-default { + font: 500 12px/1 var(--pg-mono); + color: rgba(255, 255, 255, 0.55); +} + +.pg-switch { + position: relative; + width: 36px; + height: 20px; + border-radius: 999px; + border: none; + background: rgba(255, 255, 255, 0.15); + transition: background 0.15s; + cursor: pointer; +} + +.pg-switch[aria-checked='true'] { + background: var(--pg-primary); +} + +.pg-switch span { + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: left 0.15s; +} + +.pg-switch[aria-checked='true'] span { + left: 19px; +} + +.pg-options { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.pg-options button { + padding: 5px 10px; + border-radius: 6px; + font: 500 12px var(--pg-sans); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.65); + border: 1px solid var(--pg-border-2); + cursor: pointer; +} + +.pg-options button[aria-checked='true'] { + background: rgba(54, 136, 255, 0.2); + color: var(--pg-info); + border-color: rgba(54, 136, 255, 0.5); +} + +.pg-code { + padding: 14px 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.pg-code pre { + margin: 0; + padding: 16px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--pg-border); + font: 400 12.5px/1.65 var(--pg-mono); + color: var(--pg-code); + white-space: pre; + overflow-x: auto; +} + +.pg-actions-log > div { + display: flex; + align-items: center; + gap: 14px; + padding: 7px 20px; + font: 400 12.5px/1.4 var(--pg-mono); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.pg-actions-log span:nth-child(1) { + color: rgba(255, 255, 255, 0.4); +} + +.pg-actions-log span:nth-child(2) { + color: var(--pg-accent); +} + +.pg-actions-log span:nth-child(3) { + color: var(--pg-text-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pg-a11y { + list-style: none; + padding: 8px 0; + margin: 0; +} + +.pg-a11y li { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.pg-a11y-badge { + min-width: 46px; + text-align: center; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: rgba(52, 211, 153, 0.14); + color: var(--pg-ok); +} + +.pg-a11y-badge[data-result='Warn'] { + background: rgba(245, 158, 11, 0.14); + color: var(--pg-warn); +} + +.pg-a11y-badge[data-result='Fail'] { + background: rgba(239, 68, 68, 0.14); + color: var(--pg-err); +} + +.pg-a11y-rule { + font-size: 13px; + color: rgba(255, 255, 255, 0.85); +} + +.pg-a11y-detail { + margin-left: auto; + font: 500 12px/1.3 var(--pg-mono); + color: rgba(255, 255, 255, 0.5); + text-align: right; + overflow-wrap: anywhere; +} + +.pg-story-note { + font-size: 12.5px; + color: var(--thunderid-color-text-secondary, rgba(255, 255, 255, 0.6)); + text-align: center; + max-width: 420px; + line-height: 1.5; +} + +/* Server page */ + +.pg-card-body .pg-cap-note, +.pg-api-main .pg-cap-note { + max-width: none; + margin: 0; + text-align: left; +} + +.pg-card-body .pg-empty, +.pg-api-request .pg-empty { + margin: 0; + padding: 0; + line-height: 1.5; +} + +.pg-handler-file { + font: 400 12px/1.4 var(--pg-mono); + color: var(--pg-text-2); + text-transform: none; + letter-spacing: 0; +} + +.pg-stats-tight { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.pg-api { + display: grid; + grid-template-columns: 330px minmax(0, 1fr); + overflow: hidden; +} + +.pg-api-routes { + border-right: 1px solid var(--pg-border); + padding-bottom: 12px; + background: rgba(255, 255, 255, 0.01); +} + +.pg-route { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 18px; + border: none; + background: none; + color: rgba(255, 255, 255, 0.75); + font: 400 12.5px/1.3 var(--pg-mono); + text-align: left; + cursor: pointer; +} + +.pg-route:hover { + background: var(--pg-surface-2); +} + +.pg-route[aria-current='true'] { + background: var(--pg-primary-soft); + color: #fff; +} + +.pg-route-path { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pg-route-lock { + margin-left: auto; + font: 500 10px/1 var(--pg-sans); + color: var(--pg-text-3); + border: 1px solid var(--pg-border-2); + border-radius: 999px; + padding: 2px 6px; +} + +.pg-method { + flex: none; + min-width: 42px; + padding: 2px 6px; + border-radius: 6px; + font: 600 10.5px/1.4 var(--pg-mono); + text-align: center; + color: var(--pg-ok); + background: rgba(52, 211, 153, 0.12); +} + +.pg-method[data-method='POST'] { + color: var(--pg-info); + background: rgba(54, 136, 255, 0.16); +} + +.pg-method[data-method='PATCH'] { + color: var(--pg-warn); + background: rgba(245, 158, 11, 0.14); +} + +.pg-method[data-size='lg'] { + font-size: 12px; + padding: 4px 10px; +} + +.pg-api-main { + padding: 18px 20px 20px; + min-width: 0; +} + +.pg-api-title { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.pg-api-title h2 { + margin: 0; + font-size: 17px; + font-weight: 500; +} + +.pg-api-desc { + margin: 10px 0 18px; + color: var(--pg-text-2); + font-size: 13px; + line-height: 1.55; + max-width: 900px; +} + +.pg-api-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 20px; + align-items: start; +} + +.pg-api-request { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.pg-field-label { + font: 600 10.5px/1 var(--pg-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pg-text-3); +} + +.pg-identities { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.pg-identities button { + display: flex; + flex-direction: column; + gap: 3px; + padding: 9px 10px; + border: 1px solid var(--pg-border-2); + border-radius: var(--pg-radius-control); + background: var(--pg-surface); + color: #fff; + text-align: left; + cursor: pointer; +} + +.pg-identities button strong { + font-size: 12.5px; + font-weight: 500; +} + +.pg-identities button span { + font-size: 11px; + color: var(--pg-text-3); +} + +.pg-identities button[aria-checked='true'] { + border-color: var(--pg-primary); + background: var(--pg-primary-soft); +} + +.pg-identities button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.pg-body-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pg-body-field textarea { + font: 400 12px/1.6 var(--pg-mono); + resize: vertical; + height: auto; +} + +.pg-source { + margin: 0; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--pg-border); + background: rgba(0, 0, 0, 0.35); + font: 400 11.5px/1.6 var(--pg-mono); + color: var(--pg-code); + white-space: pre; + overflow: auto; + max-height: 360px; +} + +.pg-api-request .pg-pre { + border-radius: 10px; + border: 1px solid var(--pg-border); + background: rgba(0, 0, 0, 0.35); +} + +.pg-api-response { + border: 1px solid var(--pg-border); + border-radius: 12px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 14px; +} + +.pg-api-response > :not(.pg-card-head) { + margin-left: 14px; + margin-right: 14px; +} + +.pg-api-response-head { + padding: 6px 14px 0; +} + +.pg-tabs-inline { + border-bottom: none; + padding: 0; +} + +.pg-status-badge { + font: 600 11.5px/1 var(--pg-mono); + padding: 4px 8px; + border-radius: 999px; + background: rgba(52, 211, 153, 0.14); + color: var(--pg-ok); +} + +.pg-status-badge[data-kind='warn'] { + background: rgba(245, 158, 11, 0.14); + color: var(--pg-warn); +} + +.pg-status-badge[data-kind='err'] { + background: rgba(239, 68, 68, 0.14); + color: var(--pg-err); +} + +.pg-api-empty { + color: var(--pg-text-2); + font-size: 13px; + line-height: 1.55; + padding: 24px 6px; + text-align: center; +} + +.pg-headers { + font-size: 12px; +} + +.pg-trace { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.pg-trace li { + display: grid; + grid-template-columns: 70px minmax(0, 220px) minmax(0, 1fr); + gap: 12px; + padding: 8px 10px; + border-left: 2px solid var(--pg-info); + background: rgba(255, 255, 255, 0.02); + font: 400 12px/1.4 var(--pg-mono); +} + +.pg-trace li[data-status='ok'] { + border-left-color: var(--pg-ok-bg); +} + +.pg-trace li[data-status='error'] { + border-left-color: var(--pg-err-bg); +} + +.pg-trace-ms { + color: var(--pg-text-3); +} + +.pg-trace-label { + color: #fff; +} + +.pg-trace-detail { + color: var(--pg-text-2); + overflow-wrap: anywhere; +} + +.pg-details summary { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 14px 18px; + cursor: pointer; + font-size: 13px; +} + +.pg-details .pg-source { + margin: 0 18px 18px; +} + +@media (max-width: 720px) { + .pg-main { + padding: 20px 16px 32px; + } + + .pg-topbar { + padding: 10px 16px; + } + + .pg-grid-2 { + grid-template-columns: 1fr; + } + + .pg-cap { + flex-wrap: wrap; + } + + .pg-cap-note { + text-align: left; + max-width: none; + } + + .pg-api { + grid-template-columns: 1fr; + } + + .pg-api-routes { + border-right: none; + border-bottom: 1px solid var(--pg-border); + } + + .pg-identities { + grid-template-columns: 1fr; + } + + .pg-trace li { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/playgrounds/nuxt/components/ClaimsTable.vue b/playgrounds/nuxt/components/ClaimsTable.vue new file mode 100644 index 00000000..bc5478ab --- /dev/null +++ b/playgrounds/nuxt/components/ClaimsTable.vue @@ -0,0 +1,56 @@ + + + diff --git a/playgrounds/nuxt/components/ComposableCard.vue b/playgrounds/nuxt/components/ComposableCard.vue new file mode 100644 index 00000000..c2e6ce02 --- /dev/null +++ b/playgrounds/nuxt/components/ComposableCard.vue @@ -0,0 +1,27 @@ + + + diff --git a/playgrounds/nuxt/components/EventLog.vue b/playgrounds/nuxt/components/EventLog.vue new file mode 100644 index 00000000..2eb5b766 --- /dev/null +++ b/playgrounds/nuxt/components/EventLog.vue @@ -0,0 +1,23 @@ + + + diff --git a/playgrounds/nuxt/components/NuxtLogo.vue b/playgrounds/nuxt/components/NuxtLogo.vue new file mode 100644 index 00000000..d0606dd9 --- /dev/null +++ b/playgrounds/nuxt/components/NuxtLogo.vue @@ -0,0 +1,15 @@ + + + diff --git a/playgrounds/nuxt/components/PgIcon.vue b/playgrounds/nuxt/components/PgIcon.vue new file mode 100644 index 00000000..94c56151 --- /dev/null +++ b/playgrounds/nuxt/components/PgIcon.vue @@ -0,0 +1,106 @@ + + + diff --git a/playgrounds/nuxt/components/ProtectedNotice.vue b/playgrounds/nuxt/components/ProtectedNotice.vue new file mode 100644 index 00000000..a88a84cd --- /dev/null +++ b/playgrounds/nuxt/components/ProtectedNotice.vue @@ -0,0 +1,26 @@ + + + diff --git a/playgrounds/nuxt/components/SdkObserver.vue b/playgrounds/nuxt/components/SdkObserver.vue new file mode 100644 index 00000000..e6d33f94 --- /dev/null +++ b/playgrounds/nuxt/components/SdkObserver.vue @@ -0,0 +1,53 @@ + + + diff --git a/playgrounds/nuxt/components/SessionCard.vue b/playgrounds/nuxt/components/SessionCard.vue new file mode 100644 index 00000000..134bb26e --- /dev/null +++ b/playgrounds/nuxt/components/SessionCard.vue @@ -0,0 +1,170 @@ + + + diff --git a/playgrounds/nuxt/components/TopBar.vue b/playgrounds/nuxt/components/TopBar.vue new file mode 100644 index 00000000..ada06ac0 --- /dev/null +++ b/playgrounds/nuxt/components/TopBar.vue @@ -0,0 +1,59 @@ + + + diff --git a/playgrounds/nuxt/components/explorer/ControlInput.vue b/playgrounds/nuxt/components/explorer/ControlInput.vue new file mode 100644 index 00000000..92ae9514 --- /dev/null +++ b/playgrounds/nuxt/components/explorer/ControlInput.vue @@ -0,0 +1,49 @@ + + + diff --git a/playgrounds/nuxt/components/explorer/StoryFrame.vue b/playgrounds/nuxt/components/explorer/StoryFrame.vue new file mode 100644 index 00000000..35c7099b --- /dev/null +++ b/playgrounds/nuxt/components/explorer/StoryFrame.vue @@ -0,0 +1,24 @@ + + + diff --git a/playgrounds/nuxt/components/explorer/Workbench.vue b/playgrounds/nuxt/components/explorer/Workbench.vue new file mode 100644 index 00000000..fe2b3151 --- /dev/null +++ b/playgrounds/nuxt/components/explorer/Workbench.vue @@ -0,0 +1,199 @@ + + + diff --git a/playgrounds/nuxt/eslint.config.js b/playgrounds/nuxt/eslint.config.js new file mode 100644 index 00000000..9d9b6677 --- /dev/null +++ b/playgrounds/nuxt/eslint.config.js @@ -0,0 +1,27 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import thunderIdPlugin from '@thunderid/eslint-plugin'; + +// The block of the TypeScript config that turns type-aware linting off for plain JavaScript files. +// The Vue config sets type-aware parser options for every file, so it is applied again last. +const untypedJavaScript = thunderIdPlugin.configs.typescript.find((config) => + config.files?.includes('**/*.{js,jsx,cjs,mjs}'), +); + +export default [ + { + ignores: ['.nuxt/**', '.output/**', 'node_modules/**', 'coverage/**'], + }, + ...thunderIdPlugin.configs.typescript, + ...thunderIdPlugin.configs.vue, + { + files: ['**/*.vue'], + languageOptions: { + parserOptions: { + extraFileExtensions: ['.vue'], + }, + }, + }, + untypedJavaScript, +]; diff --git a/playgrounds/nuxt/lib/a11y.ts b/playgrounds/nuxt/lib/a11y.ts new file mode 100644 index 00000000..a5beaddd --- /dev/null +++ b/playgrounds/nuxt/lib/a11y.ts @@ -0,0 +1,235 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +export type A11yResult = 'Pass' | 'Warn' | 'Fail'; + +export interface A11yFinding { + detail: string; + result: A11yResult; + rule: string; +} + +type Rgba = [number, number, number, number]; + +const parseColor = (value: string): Rgba | null => { + const match: RegExpExecArray | null = /rgba?\(([^)]+)\)/.exec(value); + + if (!match) return null; + + const parts: number[] = match[1] + .split(/[\s,/]+/) + .filter(Boolean) + .map(Number); + + return [parts[0], parts[1], parts[2], parts[3] ?? 1]; +}; + +const luminance = ([r, g, b]: Rgba): number => { + const channel = (c: number): number => { + const v: number = c / 255; + + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +}; + +const blend = (top: Rgba, bottom: Rgba): Rgba => { + const alpha: number = top[3]; + + return [ + top[0] * alpha + bottom[0] * (1 - alpha), + top[1] * alpha + bottom[1] * (1 - alpha), + top[2] * alpha + bottom[2] * (1 - alpha), + 1, + ]; +}; + +/** + * Resolves the effective background behind an element by compositing ancestor backgrounds. + */ +const backgroundOf = (element: Element, fallback: Rgba): Rgba => { + const layers: Rgba[] = []; + let node: Element | null = element; + + while (node) { + const color: Rgba | null = parseColor(getComputedStyle(node).backgroundColor); + + if (color && color[3] > 0) { + layers.push(color); + if (color[3] >= 1) break; + } + node = node.parentElement; + } + + return layers.reverse().reduce((under: Rgba, layer: Rgba) => blend(layer, under), fallback); +}; + +const contrast = (a: Rgba, b: Rgba): number => { + const [light, dark] = [luminance(a), luminance(b)].sort((x: number, y: number) => y - x); + + return (light + 0.05) / (dark + 0.05); +}; + +const accessibleName = (element: Element): string => { + const labelledBy: string | null = element.getAttribute('aria-labelledby'); + + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id: string) => element.ownerDocument.getElementById(id)?.textContent ?? '') + .join(' ') + .trim(); + } + + return ( + element.getAttribute('aria-label') ?? + element.getAttribute('title') ?? + (element.textContent?.trim() ? element.textContent : (element.querySelector('img')?.getAttribute('alt') ?? '')) + ).trim(); +}; + +const hasLabel = (input: HTMLElement): boolean => { + if (input.getAttribute('aria-label') || input.getAttribute('aria-labelledby')) return true; + if (input.closest('label')) return true; + if (input.id && input.ownerDocument.querySelector(`label[for="${CSS.escape(input.id)}"]`)) return true; + + return false; +}; + +/** + * Runs a few lightweight checks against the rendered story. Not a replacement for axe or a screen reader + * pass, but enough to catch missing names, labels, and low contrast while iterating on props. + */ +export const auditElement = (root: HTMLElement, canvasBackground: Rgba): A11yFinding[] => { + const findings: A11yFinding[] = []; + + const buttons: HTMLElement[] = Array.from(root.querySelectorAll('button, [role="button"], a[href]')); + const unnamed: HTMLElement[] = buttons.filter((button: HTMLElement) => !accessibleName(button)); + + findings.push( + buttons.length === 0 + ? {detail: 'no buttons or links', result: 'Pass', rule: 'Accessible name'} + : { + detail: unnamed.length ? `${unnamed.length} of ${buttons.length} unnamed` : `${buttons.length} named`, + result: unnamed.length ? 'Fail' : 'Pass', + rule: 'Accessible name', + }, + ); + + const inputs: HTMLElement[] = Array.from( + root.querySelectorAll('input:not([type="hidden"]), select, textarea'), + ); + const unlabeled: HTMLElement[] = inputs.filter((input: HTMLElement) => !hasLabel(input)); + + findings.push( + inputs.length === 0 + ? {detail: 'no form fields', result: 'Pass', rule: 'Label associated'} + : { + detail: unlabeled.length ? `${unlabeled.length} of ${inputs.length} unlabeled` : `${inputs.length} labeled`, + result: unlabeled.length ? 'Fail' : 'Pass', + rule: 'Label associated', + }, + ); + + const invalid: HTMLElement[] = inputs.filter((input: HTMLElement) => input.getAttribute('aria-invalid') === 'true'); + const described: HTMLElement[] = invalid.filter((input: HTMLElement) => input.getAttribute('aria-describedby')); + + if (invalid.length) { + findings.push({ + detail: `${described.length} of ${invalid.length} invalid fields use aria-describedby`, + result: described.length === invalid.length ? 'Pass' : 'Warn', + rule: 'Error described', + }); + } + + const images: HTMLImageElement[] = Array.from(root.querySelectorAll('img')); + const noAlt: HTMLImageElement[] = images.filter((image: HTMLImageElement) => !image.hasAttribute('alt')); + + if (images.length) { + findings.push({ + detail: noAlt.length ? `${noAlt.length} of ${images.length} missing alt` : `${images.length} with alt`, + result: noAlt.length ? 'Fail' : 'Pass', + rule: 'Image alternative text', + }); + } + + const roles: string[] = Array.from( + new Set(Array.from(root.querySelectorAll('[role]')).map((element: Element) => element.getAttribute('role') ?? '')), + ).filter(Boolean); + + findings.push({ + detail: roles.length ? roles.join(', ') : 'none (native semantics only)', + result: 'Pass', + rule: 'ARIA roles found', + }); + + const busy: HTMLElement[] = Array.from(root.querySelectorAll('button:disabled, [aria-disabled="true"]')); + + if ( + root.querySelector('svg[class*="spin"], [class*="spinner"]') && + !root.querySelector('[aria-busy="true"], [role="status"], [role="progressbar"]') + ) { + findings.push({detail: 'spinner without aria-busy or role="status"', result: 'Warn', rule: 'Busy state announced'}); + } else if (busy.length) { + findings.push({detail: `${busy.length} disabled control(s)`, result: 'Pass', rule: 'Disabled state'}); + } + + const positiveTab: Element[] = Array.from(root.querySelectorAll('[tabindex]')).filter( + (element: Element) => Number(element.getAttribute('tabindex')) > 0, + ); + + findings.push({ + detail: positiveTab.length ? `${positiveTab.length} element(s) with tabindex > 0` : 'natural order', + result: positiveTab.length ? 'Warn' : 'Pass', + rule: 'Focus order', + }); + + const ids: string[] = Array.from(root.querySelectorAll('[id]')).map((element: Element) => element.id); + const duplicates: string[] = Array.from( + new Set(ids.filter((id: string, index: number) => ids.indexOf(id) !== index)), + ); + + if (duplicates.length) { + findings.push({detail: duplicates.slice(0, 3).join(', '), result: 'Fail', rule: 'Unique ids'}); + } + + let lowest = Infinity; + let lowestText = ''; + const walker: TreeWalker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); + + for (let node: Node | null = walker.nextNode(); node; node = walker.nextNode()) { + const text: string = node.textContent?.trim() ?? ''; + const parent: HTMLElement | null = node.parentElement; + + if (text && parent && parent.offsetParent !== null) { + const style: CSSStyleDeclaration = getComputedStyle(parent); + const foreground: Rgba | null = parseColor(style.color); + + if (foreground) { + const background: Rgba = backgroundOf(parent, canvasBackground); + const ratio: number = contrast(blend(foreground, background), background); + + if (ratio < lowest) { + lowest = ratio; + lowestText = text.slice(0, 24); + } + } + } + } + + if (lowest !== Infinity) { + findings.push({ + detail: `lowest ${lowest.toFixed(1)} : 1 ("${lowestText}")`, + result: lowest >= 4.5 ? 'Pass' : lowest >= 3 ? 'Warn' : 'Fail', + rule: 'Color contrast', + }); + } + + return findings; +}; + +export const CANVAS_BACKGROUND: Record<'dark' | 'light', Rgba> = { + dark: [7, 16, 34, 1], + light: [238, 241, 246, 1], +}; diff --git a/playgrounds/nuxt/lib/capabilities.ts b/playgrounds/nuxt/lib/capabilities.ts new file mode 100644 index 00000000..c4a4cf90 --- /dev/null +++ b/playgrounds/nuxt/lib/capabilities.ts @@ -0,0 +1,574 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ThunderIDContext} from '@thunderid/vue'; +import {describeError, track} from './events'; +import {checkServerHealth} from './health'; +import {decodeJwt, formatDuration} from './jwt'; +import {loadServerSession} from './session'; +import {COMPONENTS, STORY_COUNT} from './stories'; +import type {PlaygroundSession, SsrSnapshot} from './types'; +import {getDisplayName} from './user'; +import type {useFlowMeta, useTheme, useThunderIDI18n, useUser} from '#imports'; + +export type CapabilityStatus = 'idle' | 'running' | 'pass' | 'fail' | 'skip' | 'unavailable'; + +export interface CapabilityResult { + note: string; + status: Exclude; +} + +export interface PublicThunderIDConfig { + applicationId?: string; + baseUrl?: string; + clientId?: string; + scopes?: string | string[]; +} + +/** + * The live SDK state a check reads. Composables return refs, so this object stays current for the + * whole run without being rebuilt. + */ +export interface CapabilityContext { + config: PublicThunderIDConfig; + flowMeta: ReturnType; + i18n: ReturnType; + sdk: ThunderIDContext; + ssr: SsrSnapshot | null; + theme: ReturnType; + user: ReturnType; +} + +export interface RunOptions { + /** True when the check was started from its own Run button, so it may navigate away. */ + interactive: boolean; +} + +export interface Capability { + api: string; + id: string; + name: string; + run: (context: CapabilityContext, options: RunOptions) => Promise | CapabilityResult; + section: string; +} + +const pass = (note: string): CapabilityResult => ({note, status: 'pass'}); +const fail = (note: string): CapabilityResult => ({note, status: 'fail'}); +const skip = (note: string): CapabilityResult => ({note, status: 'skip'}); +const unavailable = (note: string): CapabilityResult => ({note, status: 'unavailable'}); + +const sleep = (ms: number): Promise => + new Promise((resolve: () => void) => { + setTimeout(resolve, ms); + }); + +const withTimeout = (promise: Promise, ms: number, label: string): Promise => + Promise.race([ + promise, + new Promise((_resolve: (value: T) => void, reject: (reason: Error) => void) => { + setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }), + ]); + +const waitFor = async (predicate: () => boolean, ms: number): Promise => { + const deadline: number = Date.now() + ms; + + while (Date.now() < deadline) { + if (predicate()) return true; + await sleep(150); + } + + return predicate(); +}; + +const requireSignedIn = (context: CapabilityContext): CapabilityResult | null => + context.sdk.isSignedIn.value ? null : skip('sign in first'); + +const asText = (value: unknown): string => { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + + return JSON.stringify(value); +}; + +const flowSummary = (response: unknown): string => { + const flow: Record = (response ?? {}) as Record; + const data: Record = (flow.data ?? {}) as Record; + const components: unknown[] = Array.isArray(data.components) ? (data.components as unknown[]) : []; + + return `flowStatus=${asText(flow.flowStatus) || 'unknown'}, ${components.length} components`; +}; + +const userOf = (context: CapabilityContext): Record | null => { + const value: unknown = context.sdk.user.value; + + return value && typeof value === 'object' ? (value as Record) : null; +}; + +/** Fetches a page without following redirects, so a route middleware redirect shows up as one. */ +const probePage = async (path: string): Promise<'redirect' | number> => { + const response: Response = await fetch(path, {credentials: 'same-origin', redirect: 'manual'}); + + return response.type === 'opaqueredirect' ? 'redirect' : response.status; +}; + +export const CAPABILITIES: Capability[] = [ + { + api: 'GET /api/playground/health', + id: 'server', + name: 'Server reachable from Nuxt', + run: async () => { + const health = await checkServerHealth(); + + return health.state === 'up' ? pass('ThunderID is healthy') : fail(health.detail); + }, + section: 'Setup', + }, + { + api: 'runtimeConfig.public.thunderid', + id: 'config', + name: 'Module configuration', + run: ({config}: CapabilityContext) => { + if (!config.baseUrl) return fail('NUXT_PUBLIC_THUNDERID_BASE_URL is empty'); + if (!config.clientId && !config.applicationId) { + return fail('set NUXT_PUBLIC_THUNDERID_CLIENT_ID or NUXT_PUBLIC_THUNDERID_APPLICATION_ID in .env'); + } + + return pass(config.clientId ? `clientId ${config.clientId}` : `applicationId ${config.applicationId}`); + }, + section: 'Setup', + }, + { + api: 'useThunderID().isInitialized', + id: 'init', + name: 'Plugin initialized', + run: async ({sdk}: CapabilityContext) => + (await waitFor(() => sdk.isInitialized.value, 5000)) + ? pass('auth state hydrated from the server') + : fail('isInitialized stayed false for 5s'), + section: 'Setup', + }, + { + api: 'Nitro plugin Β· getThunderIDContext(event)', + id: 'ssr', + name: 'Server-side context', + run: ({ssr}: CapabilityContext) => { + if (!ssr) return fail('no server render recorded: reload the page'); + if (!ssr.present) + return fail('event.context.thunderid is empty: the plugin did not initialize (see the server log)'); + + return pass(`isSignedIn ${String(ssr.isSignedIn)}, flow meta ${ssr.flowMeta ? 'loaded' : 'missing'}`); + }, + section: 'Setup', + }, + { + api: 'useFlowMeta().meta Β· GET /api/auth/meta', + id: 'meta', + name: 'Flow metadata', + run: async ({flowMeta}: CapabilityContext) => { + if (flowMeta.meta.value) return pass('seeded from the server render'); + + try { + await track('GET /api/auth/meta', () => $fetch>('/api/auth/meta')); + + return pass('GET /api/auth/meta resolved'); + } catch (error) { + return fail(`meta is null: ${describeError(error)}`); + } + }, + section: 'Setup', + }, + { + api: ' Β· signIn() Β· GET /api/auth/signin', + id: 'redirect', + name: 'Sign in (redirect)', + run: async (context: CapabilityContext, {interactive}: RunOptions) => { + const {config, sdk} = context; + + if (sdk.isSignedIn.value) return pass(`signed in as ${getDisplayName(userOf(context))}`); + if (!config.clientId) return fail('NUXT_PUBLIC_THUNDERID_CLIENT_ID is not set'); + if (!interactive) return skip('interactive: press Run to redirect to ThunderID'); + + await track('signIn', () => sdk.signIn() as Promise); + + return skip('redirecting to ThunderID'); + }, + section: 'Authentication', + }, + { + api: ' Β· signIn({applicationId, flowType}) Β· POST /api/auth/signin', + id: 'native', + name: 'Sign in (app-native flow)', + run: async ({config, sdk}: CapabilityContext) => { + if (!config.applicationId) return fail('NUXT_PUBLIC_THUNDERID_APPLICATION_ID is not set'); + + const response: unknown = await track( + 'signIn:embedded', + () => + withTimeout( + sdk.signIn({applicationId: config.applicationId, flowType: 'AUTHENTICATION'}) as Promise, + 10000, + 'flow initiation', + ), + flowSummary, + ); + + return pass(`flow started: ${flowSummary(response)}`); + }, + section: 'Authentication', + }, + { + api: ' Β· signUp({applicationId, flowType}) Β· POST /api/auth/signup', + id: 'signup', + name: 'Sign up (app-native flow)', + run: async ({config, sdk}: CapabilityContext) => { + if (!config.applicationId) return fail('NUXT_PUBLIC_THUNDERID_APPLICATION_ID is not set'); + + const response: unknown = await track( + 'signUp:embedded', + () => + withTimeout( + sdk.signUp({applicationId: config.applicationId, flowType: 'REGISTRATION'}) as Promise, + 10000, + 'flow initiation', + ), + flowSummary, + ); + + return pass(`flow started: ${flowSummary(response)}`); + }, + section: 'Authentication', + }, + { + api: 'useState(thunderid:auth) after reload', + id: 'restore', + name: 'Session restore (SSR hydration)', + run: ({sdk, ssr}: CapabilityContext) => { + if (!sdk.isSignedIn.value) return skip('no session: sign in, then reload the page'); + if (ssr?.isSignedIn) return pass('server render already knew the session; no client fetch'); + + return pass('signed in on the client'); + }, + section: 'Authentication', + }, + { + api: 'signInSilently()', + id: 'silent', + name: 'Silent sign-in', + run: () => unavailable('no-op in @thunderid/nuxt: the session lives in a server cookie'), + section: 'Authentication', + }, + { + api: ' Β· signOut() Β· POST /api/auth/signout', + id: 'signout', + name: 'Sign out', + run: async ({sdk}: CapabilityContext, {interactive}: RunOptions) => { + if (!sdk.isSignedIn.value) return skip('not signed in'); + if (!interactive) return skip('interactive: press Run to sign out'); + + await track('signOut', () => sdk.signOut() as Promise); + + return pass('signOut() resolved'); + }, + section: 'Authentication', + }, + { + api: 'GET /api/auth/session', + id: 'sessionRoute', + name: 'Session route', + run: async ({sdk}: CapabilityContext) => { + const state: {isSignedIn: boolean} = await track('GET /api/auth/session', () => + $fetch<{isSignedIn: boolean}>('/api/auth/session'), + ); + + return state.isSignedIn === sdk.isSignedIn.value + ? pass(`isSignedIn ${String(state.isSignedIn)}, matches the client`) + : fail(`server says isSignedIn ${String(state.isSignedIn)}, client says ${String(sdk.isSignedIn.value)}`); + }, + section: 'Server routes', + }, + { + api: 'getAccessToken() Β· GET /api/auth/token', + id: 'accessToken', + name: 'Access token', + run: async (context: CapabilityContext) => { + const gate: CapabilityResult | null = requireSignedIn(context); + + if (gate) return gate; + + const token: string = await track('getAccessToken', () => context.sdk.getAccessToken()); + + if (!token) return fail('empty: GET /api/auth/token failed'); + + const exp: unknown = decodeJwt(token)?.payload.exp; + + return pass( + typeof exp === 'number' + ? `JWT, expires in ${formatDuration(exp * 1000 - Date.now())}` + : `opaque token, ${token.length} chars`, + ); + }, + section: 'Server routes', + }, + { + api: 'GET /api/auth/user', + id: 'userRoute', + name: 'User route', + run: async (context: CapabilityContext) => { + const gate: CapabilityResult | null = requireSignedIn(context); + + if (gate) return gate; + + const user: Record = await track('GET /api/auth/user', () => + $fetch>('/api/auth/user'), + ); + + return pass(`${getDisplayName(user)}, ${Object.keys(user).length} fields`); + }, + section: 'Server routes', + }, + { + api: 'getIdToken() Β· getDecodedIdToken()', + id: 'idToken', + name: 'ID token on the client', + run: () => unavailable('no-op in @thunderid/nuxt; read claims on the server (Server page)'), + section: 'Server routes', + }, + { + api: 'http.request()', + id: 'http', + name: 'Authenticated request', + run: () => unavailable('no-op in @thunderid/nuxt; call a server route that uses getValidAccessToken()'), + section: 'Server routes', + }, + { + api: 'exchangeToken()', + id: 'exchange', + name: 'Token exchange', + run: () => unavailable('no-op in @thunderid/nuxt'), + section: 'Server routes', + }, + { + api: 'useServerSession(event)', + id: 'useServerSession', + name: 'Read the session on the server', + run: async ({sdk}: CapabilityContext) => { + const session: PlaygroundSession | null = await track('GET /api/playground/session', () => loadServerSession()); + + if (!session) return fail('GET /api/playground/session failed'); + if (session.signedIn !== sdk.isSignedIn.value) { + return fail(`server signedIn ${String(session.signedIn)}, client ${String(sdk.isSignedIn.value)}`); + } + + return pass( + session.signedIn ? `sub ${session.sub ?? '?'}, scopes ${session.scopes ?? '?'}` : 'null without a session', + ); + }, + section: 'Server utilities', + }, + { + api: 'requireServerSession(event)', + id: 'requireServerSession', + name: 'Protect an API route', + run: async ({sdk}: CapabilityContext) => { + const anonymous: Response = await fetch('/api/playground/protected', {credentials: 'omit'}); + + if (anonymous.status !== 401) return fail(`anonymous request got ${anonymous.status}, expected 401`); + if (!sdk.isSignedIn.value) return pass('anonymous request gets 401'); + + const signedIn: Response = await fetch('/api/playground/protected', {credentials: 'same-origin'}); + + return signedIn.ok + ? pass('401 anonymous, 200 with the session cookie') + : fail(`signed-in request got ${signedIn.status}`); + }, + section: 'Server utilities', + }, + { + api: 'getValidAccessToken(event)', + id: 'refresh', + name: 'Token refresh on the server', + run: async (context: CapabilityContext) => { + const gate: CapabilityResult | null = requireSignedIn(context); + + if (gate) return gate; + + const token: {length: number} = await track('GET /api/playground/token', () => + $fetch<{length: number}>('/api/playground/token'), + ); + const session: PlaygroundSession | null = await loadServerSession(); + + if (!session?.hasRefreshToken) { + return fail(`token ok (${token.length} chars) but no refresh token: request offline_access`); + } + + return pass(`token ok, refresh token present: refreshed within 60s of expiry`); + }, + section: 'Server utilities', + }, + { + api: "definePageMeta({middleware: ['auth']})", + id: 'routeMiddleware', + name: 'Protect a page', + run: async ({sdk}: CapabilityContext) => { + const result: 'redirect' | number = await track('GET /protected', () => probePage('/protected'), String); + + if (!sdk.isSignedIn.value) { + return result === 'redirect' + ? pass('signed out: redirected to /api/auth/signin') + : fail(`expected a redirect, got ${String(result)}`); + } + + return result === 200 ? pass('signed in: page rendered') : fail(`signed in but got ${String(result)}`); + }, + section: 'Middleware', + }, + { + api: 'defineThunderIDMiddleware({requireScopes})', + id: 'scopeMiddleware', + name: 'Require scopes', + run: async ({sdk}: CapabilityContext) => { + const result: 'redirect' | number = await track( + 'GET /protected/scopes', + () => probePage('/protected/scopes'), + String, + ); + + if (!sdk.isSignedIn.value) { + return result === 'redirect' + ? pass('signed out: redirected to /api/auth/signin') + : fail(`expected a redirect, got ${String(result)}`); + } + + // The middleware must decide on the scopes granted to the session, so the expected outcome comes from there. + const session: PlaygroundSession | null = await loadServerSession(); + const granted: string[] = (session?.scopes ?? '').split(' '); + const hasScopes: boolean = ['openid', 'profile'].every((scope: string) => granted.includes(scope)); + + if (hasScopes) { + return result === 200 + ? pass('session granted openid and profile: page rendered') + : fail(`session granted openid and profile, but got ${String(result)}`); + } + + return result === 'redirect' + ? pass(`session granted "${granted.join(' ')}", not openid and profile: redirected`) + : fail(`session lacks openid or profile, but got ${String(result)}`); + }, + section: 'Middleware', + }, + { + api: 'server/middleware Β· createRouteMatcher()', + id: 'serverMiddleware', + name: 'Server middleware', + run: async () => { + const response: Response = await fetch('/api/auth/session', {credentials: 'same-origin'}); + const trace: string | null = response.headers.get('x-playground-trace'); + + if (!trace) return fail('no x-playground-trace header: server/middleware/playground.ts did not run'); + + const steps: unknown[] = JSON.parse(decodeURIComponent(trace)) as unknown[]; + + return pass(`ran before /api/auth/session, ${steps.length} trace steps`); + }, + section: 'Middleware', + }, + { + api: 'useThunderID().user', + id: 'user', + name: 'User info', + run: (context: CapabilityContext) => { + const gate: CapabilityResult | null = requireSignedIn(context); + + if (gate) return gate; + + const user: Record | null = userOf(context); + + return user ? pass(getDisplayName(user)) : fail('user is null while signed in'); + }, + section: 'User', + }, + { + api: 'useUser().revalidateProfile() Β· GET /api/auth/user/profile', + id: 'profile', + name: 'User profile', + run: async (context: CapabilityContext) => { + const gate: CapabilityResult | null = requireSignedIn(context); + + if (gate) return gate; + + await track('revalidateProfile', () => context.user.revalidateProfile()); + + const profile: unknown = context.user.profile.value; + + if (!profile) return fail('profile is null after revalidateProfile()'); + + return pass( + `${Object.keys(profile as object).length} fields, schema ${context.user.userSchema?.value ? 'loaded' : 'missing'}`, + ); + }, + section: 'User', + }, + { + api: ' Β· PATCH /api/auth/user/profile', + id: 'updateProfile', + name: 'Update profile', + run: (context: CapabilityContext) => + requireSignedIn(context) ?? skip('writes data: edit a field in Components > UserProfile'), + section: 'User', + }, + { + api: 'useOrganization() Β· switchOrganization()', + id: 'orgs', + name: 'Organizations', + run: () => unavailable('@thunderid/nuxt has no organization API'), + section: 'User', + }, + { + api: 'useThunderIDI18n() Β· preferences.i18n', + id: 'i18n', + name: 'Translations', + run: ({i18n}: CapabilityContext) => { + const key = 'elements.buttons.signin.text'; + const text: string = i18n.t(key); + + if (!text || text === key) return fail(`t('${key}') returned the key`); + + return pass(`${i18n.currentLanguage.value}: "${text}", ${Object.keys(i18n.bundles.value).length} bundles`); + }, + section: 'UI', + }, + { + api: 'useTheme() Β· ThemeProvider', + id: 'theme', + name: 'Theming and dark mode', + run: ({theme}: CapabilityContext) => { + const count: number = Object.keys(theme.theme.value.cssVariables ?? {}).length; + + if (!count) return fail('theme exposes no CSS variables'); + + return pass(`${theme.colorScheme.value} scheme, ${count} CSS variables`); + }, + section: 'UI', + }, + { + api: 'Component explorer', + id: 'components', + name: 'Components', + run: () => pass(`${COMPONENTS.length} components Β· ${STORY_COUNT} stories`), + section: 'UI', + }, +]; + +export const runCapability = async ( + capability: Capability, + context: CapabilityContext, + options: RunOptions, +): Promise => { + try { + return await capability.run(context, options); + } catch (error) { + return fail(describeError(error)); + } +}; diff --git a/playgrounds/nuxt/lib/events.ts b/playgrounds/nuxt/lib/events.ts new file mode 100644 index 00000000..6eee889c --- /dev/null +++ b/playgrounds/nuxt/lib/events.ts @@ -0,0 +1,79 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {shallowRef, type ShallowRef} from 'vue'; + +export type EventKind = 'ok' | 'error' | 'info'; + +export interface PlaygroundEvent { + detail: string; + id: number; + kind: EventKind; + name: string; + time: string; +} + +const MAX_EVENTS = 200; + +let nextId = 1; + +/** + * The SDK event log. It lives in the browser only: module state on the server would be shared by + * every request, so server-side calls are ignored. + */ +export const events: ShallowRef = shallowRef([]); + +export const timestamp = (): string => { + const now: Date = new Date(); + + return `${now.toTimeString().slice(0, 8)}.${String(now.getMilliseconds()).padStart(3, '0')}`; +}; + +export const logEvent = (name: string, detail = '', kind: EventKind = 'info'): void => { + if (import.meta.server) return; + + events.value = [{detail, id: nextId, kind, name, time: timestamp()}, ...events.value].slice(0, MAX_EVENTS); + nextId += 1; +}; + +export const clearEvents = (): void => { + events.value = []; +}; + +export const describeError = (error: unknown): string => { + const data: unknown = (error as {data?: unknown})?.data; + const statusMessage: unknown = (data as {statusMessage?: unknown} | undefined)?.statusMessage; + + if (typeof statusMessage === 'string') { + const statusCode: unknown = (data as {statusCode?: unknown}).statusCode; + + return typeof statusCode === 'number' ? `${statusCode} ${statusMessage}` : statusMessage; + } + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +}; + +/** + * Runs an SDK call and records started, completed, and failed events for it. The Nuxt module has no + * public event subscription, so the playground instruments the calls it makes instead. + */ +export const track = async (name: string, call: () => Promise, describe?: (result: T) => string): Promise => { + logEvent(`${name}:started`, '', 'info'); + + try { + const result: T = await call(); + + logEvent(`${name}:completed`, describe ? describe(result) : '', 'ok'); + + return result; + } catch (error) { + logEvent(`${name}:failed`, describeError(error), 'error'); + throw error; + } +}; diff --git a/playgrounds/nuxt/lib/health.ts b/playgrounds/nuxt/lib/health.ts new file mode 100644 index 00000000..b746b04a --- /dev/null +++ b/playgrounds/nuxt/lib/health.ts @@ -0,0 +1,35 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {shallowRef, type ShallowRef} from 'vue'; +import {describeError, logEvent} from './events'; +import type {PlaygroundHealth} from './types'; + +export interface ServerHealth { + checkedAt: number | null; + detail: string; + state: 'unknown' | 'up' | 'down'; +} + +export const health: ShallowRef = shallowRef({ + checkedAt: null, + detail: 'not checked', + state: 'unknown', +}); + +/** + * Asks the Nuxt server to probe the ThunderID liveness endpoint (`GET /api/playground/health`). + */ +export const checkServerHealth = async (): Promise => { + try { + const result: PlaygroundHealth = await $fetch('/api/playground/health'); + + health.value = {checkedAt: Date.now(), detail: result.detail, state: result.state}; + logEvent('server:health', `${result.detail} (${result.url})`, result.state === 'up' ? 'ok' : 'error'); + } catch (error) { + health.value = {checkedAt: Date.now(), detail: `health route failed: ${describeError(error)}`, state: 'down'}; + logEvent('server:health', health.value.detail, 'error'); + } + + return health.value; +}; diff --git a/playgrounds/nuxt/lib/inspect.ts b/playgrounds/nuxt/lib/inspect.ts new file mode 100644 index 00000000..643b5d2f --- /dev/null +++ b/playgrounds/nuxt/lib/inspect.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {isRef} from 'vue'; + +/** + * Serializes composable values for display: refs show their value, functions become signatures, + * long strings are shortened, and deep or circular structures are cut off. + */ +export const inspect = (value: unknown, maxDepth = 4): string => { + const seen = new WeakSet(); + + const walk = (current: unknown, depth: number): unknown => { + if (isRef(current)) return walk(current.value, depth); + if (typeof current === 'function') return `Ζ’ ${current.name || 'anonymous'}()`; + if (typeof current === 'string') return current.length > 120 ? `${current.slice(0, 117)}…` : current; + if (current === null || typeof current !== 'object') return current; + if (seen.has(current)) return '[circular]'; + if (depth >= maxDepth) return Array.isArray(current) ? `[${current.length} items]` : '{…}'; + + seen.add(current); + + if (Array.isArray(current)) return current.slice(0, 20).map((item: unknown) => walk(item, depth + 1)); + + return Object.fromEntries( + Object.entries(current as Record).map(([key, item]: [string, unknown]) => [ + key, + walk(item, depth + 1), + ]), + ); + }; + + return JSON.stringify(walk(value, 0), null, 2) ?? 'undefined'; +}; diff --git a/playgrounds/nuxt/lib/jwt.ts b/playgrounds/nuxt/lib/jwt.ts new file mode 100644 index 00000000..4276a0f3 --- /dev/null +++ b/playgrounds/nuxt/lib/jwt.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +export type JwtClaims = Record; + +export interface DecodedJwt { + header: JwtClaims; + payload: JwtClaims; +} + +const decodeSegment = (segment: string): JwtClaims => { + const base64: string = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padded: string = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const binary: string = atob(padded); + const bytes: Uint8Array = Uint8Array.from(binary, (char: string) => char.charCodeAt(0)); + + return JSON.parse(new TextDecoder().decode(bytes)) as JwtClaims; +}; + +/** + * Decodes a JWT without verifying it. Returns null for opaque tokens. + */ +export const decodeJwt = (token: string | null | undefined): DecodedJwt | null => { + if (!token) return null; + + const parts: string[] = token.split('.'); + + if (parts.length !== 3) return null; + + try { + return {header: decodeSegment(parts[0]), payload: decodeSegment(parts[1])}; + } catch { + return null; + } +}; + +export const formatClaim = (value: unknown): string => (typeof value === 'string' ? value : JSON.stringify(value)); + +export const formatDuration = (ms: number): string => { + const total: number = Math.max(0, Math.floor(ms / 1000)); + const hours: number = Math.floor(total / 3600); + const minutes: number = Math.floor((total % 3600) / 60); + const seconds: number = total % 60; + const mmss = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + return hours > 0 ? `${hours}:${mmss}` : mmss; +}; diff --git a/playgrounds/nuxt/lib/serverRoutes.ts b/playgrounds/nuxt/lib/serverRoutes.ts new file mode 100644 index 00000000..c8857a3e --- /dev/null +++ b/playgrounds/nuxt/lib/serverRoutes.ts @@ -0,0 +1,254 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +export type RouteGroup = 'Module routes' | 'Playground server routes' | 'Protected pages'; + +export interface RouteSpec { + body?: string; + contentType?: string; + description: string; + /** Sending it as the signed-in user ends the session. */ + destructive?: boolean; + group: RouteGroup; + /** Where the handler lives. */ + handler: string; + id: string; + /** Whether the route needs a session. */ + locked: boolean; + method: 'GET' | 'POST' | 'PATCH'; + path: string; + /** File of the handler, for the routes of this playground; the Server page shows its source. */ + sourceFile?: string; + summary: string; +} + +const MODULE = '@thunderid/nuxt'; + +const moduleRoute = (route: Omit & {file: string}): RouteSpec => { + const {file, ...rest} = route; + + return {...rest, group: 'Module routes', handler: `${MODULE} Β· runtime/server/routes/auth/${file}`}; +}; + +export const buildRoutes = (applicationId: string | undefined): RouteSpec[] => [ + moduleRoute({ + description: + 'Verifies the session cookie and returns {isSignedIn, user, isLoading}. The client calls it to refresh auth state after an app-native sign-in.', + file: 'session/session.get.ts', + id: 'session', + locked: false, + method: 'GET', + path: '/api/auth/session', + summary: 'Auth state.', + }), + moduleRoute({ + description: + 'Returns {accessToken} for the session, refreshed first when it expires within 60 seconds and a refresh token is held. Backs useThunderID().getAccessToken(). Answers 401 without a session.', + file: 'session/token.get.ts', + id: 'token', + locked: true, + method: 'GET', + path: '/api/auth/token', + summary: 'Access token.', + }), + moduleRoute({ + description: 'Returns the signed-in user from the ID token and user info. Answers 401 without a session.', + file: 'user/user.get.ts', + id: 'user', + locked: true, + method: 'GET', + path: '/api/auth/user', + summary: 'Current user.', + }), + moduleRoute({ + description: + 'Returns the SCIM profile, its flattened form and the attribute schema. Backs useUser().revalidateProfile(); PATCH on the same path updates it.', + file: 'user/profile.get.ts', + id: 'profile', + locked: true, + method: 'GET', + path: '/api/auth/user/profile', + summary: 'User profile.', + }), + moduleRoute({ + description: + 'Flow metadata (design and i18n bundle) fetched on the server, so the browser never calls ThunderID directly. Accepts ?language=.', + file: 'session/meta.get.ts', + id: 'meta', + locked: false, + method: 'GET', + path: '/api/auth/meta', + summary: 'Flow metadata.', + }), + moduleRoute({ + description: + 'Redirect sign-in: sets a temporary session cookie and redirects to the ThunderID authorize endpoint. fetch() cannot read a cross-origin redirect, so the response shows as an opaque redirect.', + file: 'session/signin.get.ts', + id: 'signin-get', + locked: false, + method: 'GET', + path: '/api/auth/signin', + summary: 'Start redirect sign-in.', + }), + moduleRoute({ + body: JSON.stringify( + {payload: {applicationId: applicationId ?? '', flowType: 'AUTHENTICATION'}, request: {}}, + null, + 2, + ), + contentType: 'application/json', + description: + 'Drives the app-native sign-in flow one step at a time with the Flow Secret held on the server. On completion it sets the session cookie.', + file: 'session/signin.post.ts', + id: 'signin-post', + locked: false, + method: 'POST', + path: '/api/auth/signin', + summary: 'App-native sign-in step.', + }), + moduleRoute({ + body: JSON.stringify( + {payload: {applicationId: applicationId ?? '', flowType: 'REGISTRATION'}}, + null, + 2, + ), + contentType: 'application/json', + description: 'Drives the app-native sign-up flow one step at a time.', + file: 'session/signup.post.ts', + id: 'signup', + locked: false, + method: 'POST', + path: '/api/auth/signup', + summary: 'App-native sign-up step.', + }), + moduleRoute({ + description: + 'Clears the session cookie and returns {redirectUrl}, the ThunderID end-session URL. Sending it as your session signs you out of the playground.', + destructive: true, + file: 'session/signout.post.ts', + id: 'signout', + locked: false, + method: 'POST', + path: '/api/auth/signout', + summary: 'Sign out.', + }), + moduleRoute({ + description: + 'One page of the user directory for UserSelect, loaded with the session. On with resourcePickers.users.', + file: 'user/users.get.ts', + id: 'users', + locked: true, + method: 'GET', + path: '/api/auth/users', + summary: 'User picker options.', + }), + moduleRoute({ + description: 'One page of authentication flows for AuthFlowSelect. On with resourcePickers.authFlows.', + file: 'select/auth-flows.get.ts', + id: 'auth-flows', + locked: true, + method: 'GET', + path: '/api/auth/auth-flows', + summary: 'Flow picker options.', + }), + moduleRoute({ + description: 'One page of user types for UserTypeSelect. On with resourcePickers.userTypes.', + file: 'select/user-types.get.ts', + id: 'user-types', + locked: true, + method: 'GET', + path: '/api/auth/user-types', + summary: 'User type picker options.', + }), + moduleRoute({ + body: 'logout_token=not-a-real-token', + contentType: 'application/x-www-form-urlencoded', + description: + 'ThunderID posts a signed logout_token here when a session ends elsewhere. The handler verifies it and ends the matching sessions; an invalid token gets 400. On with backchannelLogout.enabled.', + file: 'session/backchannel-logout.post.ts', + id: 'backchannel', + locked: false, + method: 'POST', + path: '/api/auth/backchannel-logout', + summary: 'Back-channel logout.', + }), + { + description: 'Reads the session cookie with useServerSession() and returns its expiry, scopes and decoded claims.', + group: 'Playground server routes', + handler: 'server/api/playground/session.get.ts', + id: 'pg-session', + locked: false, + method: 'GET', + path: '/api/playground/session', + sourceFile: 'server/api/playground/session.get.ts', + summary: 'useServerSession().', + }, + { + description: 'A protected API route: requireServerSession() answers 401 without a session.', + group: 'Playground server routes', + handler: 'server/api/playground/protected.get.ts', + id: 'pg-protected', + locked: true, + method: 'GET', + path: '/api/playground/protected', + sourceFile: 'server/api/playground/protected.get.ts', + summary: 'requireServerSession().', + }, + { + description: 'Gets an access token with getValidAccessToken(), which refreshes it when it is about to expire.', + group: 'Playground server routes', + handler: 'server/api/playground/token.get.ts', + id: 'pg-token', + locked: true, + method: 'GET', + path: '/api/playground/token', + sourceFile: 'server/api/playground/token.get.ts', + summary: 'getValidAccessToken().', + }, + { + description: 'Probes the ThunderID liveness endpoint from the Nuxt server.', + group: 'Playground server routes', + handler: 'server/api/playground/health.get.ts', + id: 'pg-health', + locked: false, + method: 'GET', + path: '/api/playground/health', + sourceFile: 'server/api/playground/health.get.ts', + summary: 'ThunderID reachability.', + }, + { + description: + 'The named auth middleware runs during the server render: without a session the response is a redirect to /api/auth/signin?returnTo=/protected.', + group: 'Protected pages', + handler: 'pages/protected/index.vue', + id: 'page-auth', + locked: true, + method: 'GET', + path: '/protected', + sourceFile: 'pages/protected/index.vue', + summary: "middleware: ['auth'].", + }, + { + description: + 'A route middleware from defineThunderIDMiddleware({requireScopes}). It redirects unless every scope is present.', + group: 'Protected pages', + handler: 'pages/protected/scopes.vue', + id: 'page-scopes', + locked: true, + method: 'GET', + path: '/protected/scopes', + sourceFile: 'pages/protected/scopes.vue', + summary: 'requireScopes.', + }, +]; + +export const SERVER_MIDDLEWARE_FILE = 'server/middleware/playground.ts'; + +/** Loads handler source through `GET /api/playground/source`. */ +export const loadSource = async (file: string): Promise => { + try { + return await $fetch('/api/playground/source', {query: {file}, responseType: 'text'}); + } catch { + return ''; + } +}; diff --git a/playgrounds/nuxt/lib/session.ts b/playgrounds/nuxt/lib/session.ts new file mode 100644 index 00000000..a949e7d0 --- /dev/null +++ b/playgrounds/nuxt/lib/session.ts @@ -0,0 +1,41 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {onBeforeUnmount, onMounted, ref, shallowRef, type Ref, type ShallowRef} from 'vue'; +import {describeError} from './events'; +import type {PlaygroundSession} from './types'; + +export const serverSession: ShallowRef = shallowRef(null); +export const serverSessionError: Ref = ref(null); + +/** + * Loads the server-held session through `GET /api/playground/session` (`useServerSession()`). + */ +export const loadServerSession = async (): Promise => { + try { + serverSession.value = await $fetch('/api/playground/session'); + serverSessionError.value = null; + } catch (error) { + serverSession.value = null; + serverSessionError.value = describeError(error); + } + + return serverSession.value; +}; + +/** A clock that ticks on the client only, so server and client render the same first frame. */ +export const useNow = (intervalMs = 1000): Ref => { + const now: Ref = ref(0); + let timer: ReturnType | undefined; + + onMounted(() => { + now.value = Date.now(); + timer = setInterval(() => { + now.value = Date.now(); + }, intervalMs); + }); + + onBeforeUnmount(() => clearInterval(timer)); + + return now; +}; diff --git a/playgrounds/nuxt/lib/stories.ts b/playgrounds/nuxt/lib/stories.ts new file mode 100644 index 00000000..763ee1fa --- /dev/null +++ b/playgrounds/nuxt/lib/stories.ts @@ -0,0 +1,918 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + Alert, + BaseSignInButton, + BaseSignOutButton, + BaseSignUpButton, + BaseUserDropdown, + BaseUserProfile, + Button, + Card, + Divider, + FacebookButton, + GitHubButton, + GoogleButton, + LanguageSwitcher, + MicrosoftButton, + Spinner, + Typography, +} from '@thunderid/vue'; +import {defineComponent, h, ref, type Component, type PropType, type Ref, type VNode} from 'vue'; +import type {Action, Args, ArgValue, ComponentDef, PropControl} from './storyTypes'; +import {CheckboxStory, narrow, OtpFieldStory, PasswordFieldStory, SelectStory, TextFieldStory} from './storyWidgets'; +import {getDisplayName} from './user'; +import { + AuthFlowSelect, + ChangeCredential, + Loading, + SignedIn, + SignedOut, + SignIn, + SignInButton, + SignOutButton, + SignUp, + SignUpButton, + User, + UserDropdown, + UserProfile, + UserSelect, + UserTypeSelect, +} from '#components'; + +const str = (args: Args, key: string): string => String(args[key] ?? ''); +const bool = (args: Args, key: string): boolean => args[key] === true; + +const kebab = (name: string): string => name.replace(/[A-Z]/g, (char: string) => `-${char.toLowerCase()}`); + +const attr = (key: string, value: ArgValue): string => { + const name: string = kebab(key); + + if (value === true) return name; + if (value === false || typeof value === 'number') return `:${name}="${String(value)}"`; + + return `${name}="${value.replace(/"/g, '"')}"`; +}; + +/** + * Builds a template element from the props that differ from the component's own defaults. + */ +export const tag = ( + name: string, + props: Args, + options: {children?: string; defaults?: Args; extra?: string[]} = {}, +): string => { + const parts: string[] = Object.entries(props) + .filter( + ([key, value]: [string, ArgValue]) => + value !== '' && + options.defaults?.[key] !== value && + !(value === false && options.defaults?.[key] === undefined), + ) + .map(([key, value]: [string, ArgValue]) => attr(key, value)) + .concat(options.extra ?? []); + const inline: string = parts.length ? ` ${parts.join(' ')}` : ''; + const multiline: boolean = inline.length > 70; + const attrs: string = multiline ? `\n ${parts.join('\n ')}\n` : inline; + + if (options.children) return `<${name}${attrs}>${options.children}`; + + return `<${name}${attrs}${multiline ? '' : ' '}/>`; +}; + +const omit = (args: Args, ...keys: string[]): Args => + Object.fromEntries(Object.entries(args).filter(([key]: [string, ArgValue]) => !keys.includes(key))); + +const SAMPLE_USER: Record = { + email: 'liam.chen@acme.io', + family_name: 'Chen', + given_name: 'Liam', + sub: 'usr_8f2c41', + username: 'liam.chen', +}; + +const SAMPLE_PROFILE: Record = { + country: 'New Zealand', + email: 'liam.chen@acme.io', + familyName: 'Chen', + givenName: 'Liam', + username: 'liam.chen', +}; + +const SIZE: readonly string[] = ['small', 'medium', 'large']; +const CARD_VARIANT: readonly string[] = ['elevated', 'outlined', 'flat']; +const SOURCE_CONTROL: PropControl = { + control: ['base', 'live'], + description: + 'base renders the presentational Base component from @thunderid/vue with sample data; live uses the module.', + name: 'source', + type: "'base' | 'live' (playground only)", +}; + +const SOCIAL: Record = {FacebookButton, GitHubButton, GoogleButton, MicrosoftButton}; + +const modelScript: string[] = ["const value = ref('');"]; + +/** Wraps a picker of the module in a story component that holds its `v-model`. */ +const pickerStory = (name: string, picker: Component): Component => + defineComponent({ + name, + props: { + action: {required: true, type: Function as PropType}, + args: {required: true, type: Object as PropType}, + }, + setup(props: {action: Action; args: Args}): () => VNode { + const value: Ref = ref(''); + + return (): VNode => + narrow( + 340, + h(picker, { + label: str(props.args, 'label'), + modelValue: value.value, + 'onUpdate:modelValue': (next: string) => { + value.value = next; + props.action('update:modelValue', `"${next}"`); + }, + }), + ); + }, + }); + +const UserSelectStory: Component = pickerStory('UserSelectStory', UserSelect); +const AuthFlowSelectStory: Component = pickerStory('AuthFlowSelectStory', AuthFlowSelect); +const UserTypeSelectStory: Component = pickerStory('UserTypeSelectStory', UserTypeSelect); + +const pickerDef = ( + id: string, + name: string, + story: Component, + label: string, + route: string, + option: string, +): ComponentDef => ({ + code: (args: Args) => ({ + imports: [], + script: modelScript, + template: tag(name, args, {extra: ['v-model="value"']}), + }), + controls: [{control: 'text', description: 'Visible field label.', name: 'label', type: 'string'}], + defaults: {label}, + group: 'Pickers', + id, + live: true, + name, + render: (args: Args, action: Action) => h(story, {action, args}), + stories: [{id: 'default', name: `Loads ${route} (resourcePickers.${option})`}], +}); + +const actionButton = ( + id: string, + name: string, + base: Component, + live: Component, + label: string, + call: string, +): ComponentDef => ({ + code: (args: Args) => + str(args, 'source') === 'live' + ? {imports: [], template: tag(name, {}, {children: str(args, 'children') || undefined})} + : { + imports: [`Base${name}`], + template: tag(`Base${name}`, {}, {children: str(args, 'children'), extra: ['@click="handleClick"']}), + }, + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Button label; empty uses the translated default.', name: 'children', type: 'slot'}, + ], + defaults: {children: label, source: 'base'}, + group: 'Actions', + id: id, + live: true, + name, + render: (args: Args, action: Action) => { + const text: string = str(args, 'children'); + + if (str(args, 'source') === 'live') { + return h( + live, + { + onClick: () => action('click', `${call} called`), + onError: (error: Error) => action('error', error.message), + }, + text ? {default: () => text} : undefined, + ); + } + + return h(base, {onClick: () => action('click', 'base button, no SDK call')}, {default: () => text}); + }, + stories: [ + {id: 'base', name: 'Base (no SDK call)'}, + {args: {children: '', source: 'live'}, id: 'live', name: `Live (${call})`}, + ], +}); + +export const COMPONENTS: ComponentDef[] = [ + { + code: (args: Args) => ({ + imports: ['Button'], + template: tag('Button', omit(args, 'children'), { + children: str(args, 'children'), + defaults: {color: 'primary', size: 'medium', variant: 'solid'}, + extra: ['@click="handleClick"'], + }), + }), + controls: [ + {control: 'text', description: 'Button label.', name: 'children', type: 'slot'}, + { + control: ['solid', 'outline', 'ghost', 'text'], + description: 'Visual style of the button.', + name: 'variant', + type: "'solid' | 'outline' | 'ghost' | 'text'", + }, + { + control: ['primary', 'secondary', 'danger'], + description: 'Color intent.', + name: 'color', + type: "'primary' | 'secondary' | 'danger'", + }, + {control: SIZE, description: 'Height and padding.', name: 'size', type: "'small' | 'medium' | 'large'"}, + {control: 'bool', description: 'Shows a spinner and blocks clicks.', name: 'loading', type: 'boolean'}, + {control: 'bool', description: 'Disables the button.', name: 'disabled', type: 'boolean'}, + {control: 'bool', description: 'Stretches to the container width.', name: 'fullWidth', type: 'boolean'}, + ], + defaults: { + children: 'Sign in', + color: 'primary', + disabled: false, + fullWidth: false, + loading: false, + size: 'medium', + variant: 'solid', + }, + group: 'Primitives', + id: 'button', + name: 'Button', + render: (args: Args, action: Action) => + h('div', {style: bool(args, 'fullWidth') ? {maxWidth: '100%', width: '360px'} : undefined}, [ + h( + Button, + { + color: str(args, 'color'), + disabled: bool(args, 'disabled'), + fullWidth: bool(args, 'fullWidth'), + loading: bool(args, 'loading'), + onClick: () => action('click', 'MouseEvent { target: button }'), + size: str(args, 'size'), + variant: str(args, 'variant'), + }, + {default: () => str(args, 'children')}, + ), + ]), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {color: 'primary', variant: 'outline'}, id: 'outline', name: 'Outline'}, + {args: {color: 'secondary', variant: 'text'}, id: 'text', name: 'Text'}, + {args: {color: 'danger', variant: 'solid'}, id: 'danger', name: 'Danger'}, + {args: {loading: true}, id: 'loading', name: 'Loading'}, + {args: {disabled: true}, id: 'disabled', name: 'Disabled'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['TextField'], + script: modelScript, + template: tag('TextField', args, {extra: ['v-model="value"']}), + }), + controls: [ + {control: 'text', description: 'Visible field label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Hint shown when empty.', name: 'placeholder', type: 'string'}, + {control: 'text', description: 'Help text below the field.', name: 'helperText', type: 'string'}, + {control: 'text', description: 'Error message; marks the field invalid.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Marks the field as required.', name: 'required', type: 'boolean'}, + {control: 'bool', description: 'Disables the field.', name: 'disabled', type: 'boolean'}, + ], + defaults: { + disabled: false, + error: '', + helperText: '', + label: 'Email address', + placeholder: 'you@example.com', + required: false, + }, + group: 'Primitives', + id: 'textfield', + name: 'TextField', + render: (args: Args, action: Action) => h(TextFieldStory, {action, args}), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {error: 'Enter a valid email address.'}, id: 'error', name: 'With error'}, + {args: {helperText: 'We never share your email.'}, id: 'helper', name: 'With helper text'}, + {args: {disabled: true}, id: 'disabled', name: 'Disabled'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['PasswordField'], + script: modelScript, + template: tag('PasswordField', args, {extra: ['v-model="value"']}), + }), + controls: [ + {control: 'text', description: 'Visible field label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Hint shown when empty.', name: 'placeholder', type: 'string'}, + {control: 'text', description: 'Error message; marks the field invalid.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Marks the field as required.', name: 'required', type: 'boolean'}, + {control: 'bool', description: 'Disables the field.', name: 'disabled', type: 'boolean'}, + ], + defaults: {disabled: false, error: '', label: 'Password', placeholder: 'Enter your password', required: true}, + group: 'Primitives', + id: 'passwordfield', + name: 'PasswordField', + render: (args: Args, action: Action) => h(PasswordFieldStory, {action, args}), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {error: 'Password must be at least 8 characters.'}, id: 'error', name: 'With error'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['OtpField'], + script: modelScript, + template: tag('OtpField', args, {defaults: {length: 6, numericOnly: true}, extra: ['v-model="value"']}), + }), + controls: [ + {control: 'text', description: 'Visible field label.', name: 'label', type: 'string'}, + {control: 'number', description: 'Number of digits.', name: 'length', type: 'number'}, + {control: 'bool', description: 'Accepts digits only.', name: 'numericOnly', type: 'boolean'}, + {control: 'text', description: 'Error message.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Disables the field.', name: 'disabled', type: 'boolean'}, + ], + defaults: {disabled: false, error: '', label: 'Verification code', length: 6, numericOnly: true}, + group: 'Primitives', + id: 'otpfield', + name: 'OtpField', + render: (args: Args, action: Action) => h(OtpFieldStory, {action, args}), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {length: 4}, id: 'four', name: 'Four digits'}, + {args: {error: 'That code has expired.'}, id: 'error', name: 'With error'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['Checkbox'], + script: ['const value = ref(false);'], + template: tag('Checkbox', args, {extra: ['v-model="value"']}), + }), + controls: [ + {control: 'text', description: 'Label next to the box.', name: 'label', type: 'string'}, + {control: 'text', description: 'Error message.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Marks the field as required.', name: 'required', type: 'boolean'}, + {control: 'bool', description: 'Disables the checkbox.', name: 'disabled', type: 'boolean'}, + ], + defaults: {disabled: false, error: '', label: 'I agree to the terms of service', required: false}, + group: 'Primitives', + id: 'checkbox', + name: 'Checkbox', + render: (args: Args, action: Action) => h(CheckboxStory, {action, args}), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {error: 'You must accept the terms.', required: true}, id: 'error', name: 'With error'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['Select'], + script: [ + "const value = ref('');", + 'const options = [', + " {label: 'Engineering', value: 'eng'},", + " {label: 'Design', value: 'design'},", + " {label: 'Sales', value: 'sales'},", + '];', + ], + template: tag('Select', args, {extra: ['v-model="value"', ':options="options"']}), + }), + controls: [ + {control: 'text', description: 'Visible field label.', name: 'label', type: 'string'}, + {control: 'text', description: 'Text of the empty option.', name: 'placeholder', type: 'string'}, + {control: 'text', description: 'Help text below the field.', name: 'helperText', type: 'string'}, + {control: 'text', description: 'Error message.', name: 'error', type: 'string'}, + {control: 'bool', description: 'Shows that options are loading.', name: 'loading', type: 'boolean'}, + {control: 'bool', description: 'Disables the field.', name: 'disabled', type: 'boolean'}, + ], + defaults: { + disabled: false, + error: '', + helperText: '', + label: 'Department', + loading: false, + placeholder: 'Choose a department', + }, + group: 'Primitives', + id: 'select', + name: 'Select', + render: (args: Args, action: Action) => h(SelectStory, {action, args}), + stories: [ + {id: 'playground', name: 'Playground'}, + {args: {loading: true}, id: 'loading', name: 'Loading'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['Alert'], + template: tag('Alert', omit(args, 'message'), { + children: str(args, 'message'), + defaults: {severity: 'info'}, + extra: bool(args, 'dismissible') ? ['@dismiss="handleDismiss"'] : [], + }), + }), + controls: [ + {control: 'text', description: 'Alert text.', name: 'message', type: 'slot'}, + { + control: ['info', 'success', 'warning', 'error'], + description: 'Color and icon.', + name: 'severity', + type: "'success' | 'error' | 'warning' | 'info'", + }, + {control: 'bool', description: 'Shows a close button.', name: 'dismissible', type: 'boolean'}, + ], + defaults: {dismissible: false, message: 'We sent a code to liam.chen@acme.io.', severity: 'info'}, + group: 'Primitives', + id: 'alert', + name: 'Alert', + render: (args: Args, action: Action) => + narrow( + 420, + h( + Alert, + {dismissible: bool(args, 'dismissible'), onDismiss: () => action('dismiss'), severity: str(args, 'severity')}, + {default: () => str(args, 'message')}, + ), + ), + stories: [ + {id: 'info', name: 'Info'}, + {args: {message: 'Your profile was updated.', severity: 'success'}, id: 'success', name: 'Success'}, + {args: {message: 'Your session expires in 2 minutes.', severity: 'warning'}, id: 'warning', name: 'Warning'}, + { + args: {dismissible: true, message: 'Those credentials did not match.', severity: 'error'}, + id: 'error', + name: 'Error, dismissible', + }, + ], + }, + { + code: (args: Args) => ({ + imports: ['Card', 'Typography'], + template: tag('Card', args, { + children: + '\n Card title\n Card content.\n', + defaults: {variant: 'elevated'}, + }), + }), + controls: [ + { + control: CARD_VARIANT, + description: 'Shadow or border.', + name: 'variant', + type: "'elevated' | 'outlined' | 'flat'", + }, + ], + defaults: {variant: 'outlined'}, + group: 'Primitives', + id: 'card', + name: 'Card', + render: (args: Args) => + narrow( + 360, + h( + Card, + {variant: str(args, 'variant')}, + { + default: () => + h('div', {style: {padding: '20px'}}, [ + h(Typography, {variant: 'h6'}, {default: () => 'Workspace settings'}), + h(Typography, {variant: 'body2'}, {default: () => 'Cards group related content and actions.'}), + ]), + }, + ), + ), + stories: [ + {id: 'outlined', name: 'Outlined'}, + {args: {variant: 'elevated'}, id: 'elevated', name: 'Elevated'}, + {args: {variant: 'flat'}, id: 'flat', name: 'Flat'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['Typography'], + template: tag('Typography', omit(args, 'text'), {children: str(args, 'text'), defaults: {variant: 'body1'}}), + }), + controls: [ + {control: 'text', description: 'Text content.', name: 'text', type: 'slot'}, + { + control: [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'subtitle1', + 'subtitle2', + 'body1', + 'body2', + 'caption', + 'overline', + ], + description: 'Type scale step.', + name: 'variant', + type: 'TypographyVariant', + }, + ], + defaults: {text: 'Welcome back', variant: 'h4'}, + group: 'Primitives', + id: 'typography', + name: 'Typography', + render: (args: Args) => h(Typography, {variant: str(args, 'variant')}, {default: () => str(args, 'text')}), + stories: [ + {id: 'heading', name: 'Heading'}, + {args: {text: 'Sign in to continue to Acme.', variant: 'body1'}, id: 'body', name: 'Body'}, + ], + }, + { + code: (args: Args) => ({ + imports: ['Divider'], + template: tag('Divider', omit(args, 'label'), { + children: str(args, 'label') || undefined, + defaults: {orientation: 'horizontal'}, + }), + }), + controls: [{control: 'text', description: 'Optional centered label.', name: 'label', type: 'slot'}], + defaults: {label: 'or'}, + group: 'Primitives', + id: 'divider', + name: 'Divider', + render: (args: Args) => + narrow(360, h(Divider, null, str(args, 'label') ? {default: () => str(args, 'label')} : undefined)), + stories: [ + {id: 'label', name: 'With label'}, + {args: {label: ''}, id: 'plain', name: 'Plain'}, + ], + }, + { + code: (args: Args) => ({imports: ['Spinner'], template: tag('Spinner', args, {defaults: {size: 'medium'}})}), + controls: [{control: SIZE, description: 'Diameter.', name: 'size', type: "'small' | 'medium' | 'large'"}], + defaults: {size: 'medium'}, + group: 'Primitives', + id: 'spinner', + name: 'Spinner', + render: (args: Args) => h(Spinner, {size: str(args, 'size')}), + stories: [ + {id: 'medium', name: 'Medium'}, + {args: {size: 'large'}, id: 'large', name: 'Large'}, + ], + }, + actionButton('signinbutton', 'SignInButton', BaseSignInButton, SignInButton, 'Sign in', 'signIn()'), + actionButton('signoutbutton', 'SignOutButton', BaseSignOutButton, SignOutButton, 'Sign out', 'signOut()'), + actionButton('signupbutton', 'SignUpButton', BaseSignUpButton, SignUpButton, 'Create account', 'signUp()'), + { + code: (args: Args) => ({ + imports: [str(args, 'provider')], + template: tag(str(args, 'provider'), {}, {extra: ['@click="handleClick"']}), + }), + controls: [ + { + control: Object.keys(SOCIAL), + description: 'Which adapter button to render.', + name: 'provider', + type: 'component', + }, + ], + defaults: {provider: 'GoogleButton'}, + group: 'Actions', + id: 'social', + name: 'Social buttons', + render: (args: Args, action: Action) => + narrow( + 320, + h(SOCIAL[str(args, 'provider')] ?? GoogleButton, {onClick: () => action('click', str(args, 'provider'))}), + ), + stories: [ + {id: 'google', name: 'Google'}, + {args: {provider: 'GitHubButton'}, id: 'github', name: 'GitHub'}, + {args: {provider: 'MicrosoftButton'}, id: 'microsoft', name: 'Microsoft'}, + {args: {provider: 'FacebookButton'}, id: 'facebook', name: 'Facebook'}, + ], + }, + { + code: (args: Args) => ({ + imports: [], + template: tag('SignIn', args, { + defaults: {size: 'medium', variant: 'outlined'}, + extra: ['@success="handleSuccess"', '@error="handleError"'], + }), + }), + controls: [ + {control: SIZE, description: 'Spacing and control size.', name: 'size', type: "'small' | 'medium' | 'large'"}, + { + control: CARD_VARIANT, + description: 'Card variant of the flow container.', + name: 'variant', + type: "'elevated' | 'outlined' | 'flat'", + }, + ], + defaults: {size: 'medium', variant: 'outlined'}, + group: 'Auth flows', + id: 'signin', + live: true, + name: 'SignIn', + render: (args: Args, action: Action) => + narrow( + 400, + h(SignIn, { + onError: (error: Error) => action('error', error.message), + onSuccess: (data: unknown) => action('success', JSON.stringify(data).slice(0, 120)), + size: str(args, 'size'), + variant: str(args, 'variant'), + }), + ), + stories: [ + {id: 'default', name: 'Default'}, + {args: {size: 'small', variant: 'elevated'}, id: 'compact', name: 'Compact'}, + ], + }, + { + code: () => ({ + imports: [], + template: '', + }), + controls: [], + defaults: {}, + group: 'Auth flows', + id: 'signup', + live: true, + name: 'SignUp', + render: (_args: Args, action: Action) => + narrow( + 400, + h(SignUp, { + onComplete: () => action('onComplete'), + onError: (error: Error) => action('onError', error.message), + shouldRedirectAfterSignUp: false, + }), + ), + stories: [{id: 'default', name: 'Default'}], + }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? {imports: [], template: tag('UserDropdown', omit(args, 'source'), {defaults: {showTriggerLabel: false}})} + : { + imports: ['BaseUserDropdown'], + template: tag('BaseUserDropdown', omit(args, 'source'), { + defaults: {showTriggerLabel: false}, + extra: [':user="user"', ':on-sign-out="signOut"'], + }), + }, + controls: [ + SOURCE_CONTROL, + {control: 'bool', description: 'Shows the name next to the avatar.', name: 'showTriggerLabel', type: 'boolean'}, + { + control: ['sm', 'md', 'lg'], + description: 'Avatar size and menu density.', + name: 'size', + type: "'sm' | 'md' | 'lg'", + }, + ], + defaults: {showTriggerLabel: true, size: 'md', source: 'base'}, + group: 'User', + id: 'userdropdown', + live: true, + name: 'UserDropdown', + render: (args: Args, action: Action) => + str(args, 'source') === 'live' + ? h(UserDropdown, { + onProfileClick: () => action('profileClick'), + showTriggerLabel: bool(args, 'showTriggerLabel'), + size: str(args, 'size'), + }) + : h(BaseUserDropdown, { + onProfileClick: () => action('profileClick'), + onSignOut: () => action('signOut'), + showTriggerLabel: bool(args, 'showTriggerLabel'), + size: str(args, 'size'), + user: SAMPLE_USER, + }), + stories: [ + {id: 'base', name: 'Sample user'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user'}, + ], + }, + { + code: (args: Args) => + str(args, 'source') === 'live' + ? { + imports: [], + template: tag('UserProfile', omit(args, 'source'), { + defaults: {cardLayout: true, editable: true, title: 'Profile'}, + }), + } + : { + imports: ['BaseUserProfile'], + template: tag('BaseUserProfile', omit(args, 'source'), { + defaults: {cardLayout: true, editable: true, title: 'Profile'}, + extra: [':profile="profile"', ':on-update="handleUpdate"'], + }), + }, + controls: [ + SOURCE_CONTROL, + {control: 'text', description: 'Heading of the profile card.', name: 'title', type: 'string'}, + {control: 'bool', description: 'Allows editing fields.', name: 'editable', type: 'boolean'}, + {control: 'bool', description: 'Wraps the profile in a card.', name: 'cardLayout', type: 'boolean'}, + ], + defaults: {cardLayout: true, editable: true, source: 'base', title: 'Profile'}, + group: 'User', + id: 'userprofile', + live: true, + name: 'UserProfile', + render: (args: Args, action: Action) => + narrow( + 520, + str(args, 'source') === 'live' + ? h(UserProfile, { + cardLayout: bool(args, 'cardLayout'), + editable: bool(args, 'editable'), + title: str(args, 'title'), + }) + : h(BaseUserProfile, { + cardLayout: bool(args, 'cardLayout'), + editable: bool(args, 'editable'), + flattenedProfile: SAMPLE_PROFILE, + onUpdate: (payload: unknown) => { + action('onUpdate', JSON.stringify(payload)); + + return Promise.resolve(); + }, + profile: SAMPLE_PROFILE, + title: str(args, 'title'), + }), + ), + stories: [ + {id: 'base', name: 'Sample profile'}, + {args: {source: 'live'}, id: 'live', name: 'Signed-in user (editable)'}, + ], + }, + { + code: (args: Args) => ({ + imports: [], + template: tag('ChangeCredential', args, { + defaults: {cardLayout: false, showRequirements: true}, + extra: ['@success="handleSuccess"'], + }), + }), + controls: [ + {control: 'text', description: 'Heading; empty uses the attribute name.', name: 'title', type: 'string'}, + {control: 'bool', description: 'Wraps the form in a card.', name: 'cardLayout', type: 'boolean'}, + {control: 'bool', description: 'Lists the password policy.', name: 'showRequirements', type: 'boolean'}, + ], + defaults: {cardLayout: true, showRequirements: true, title: ''}, + group: 'User', + id: 'changecredential', + live: true, + name: 'ChangeCredential', + render: (args: Args, action: Action) => + narrow( + 440, + h(ChangeCredential, { + cardLayout: bool(args, 'cardLayout'), + onSuccess: () => action('success', 'PATCH /api/auth/user/credentials'), + showRequirements: bool(args, 'showRequirements'), + title: str(args, 'title') || undefined, + }), + ), + stories: [{id: 'default', name: 'Change password'}], + }, + { + code: () => ({ + imports: [], + template: + '\n \n \n', + }), + controls: [], + defaults: {}, + group: 'User', + id: 'user', + live: true, + name: 'User', + render: () => + h(User, null, { + default: ({user}: {user: Record | null}) => + h(Typography, {variant: 'body1'}, {default: () => `Hello, ${getDisplayName(user)}`}), + fallback: () => h(Typography, {variant: 'body1'}, {default: () => 'Not signed in'}), + }), + stories: [{id: 'default', name: 'Scoped slot'}], + }, + { + code: () => ({ + imports: [], + template: '\n \n\n\n \n', + }), + controls: [], + defaults: {}, + group: 'Control', + id: 'signedin', + live: true, + name: 'SignedIn / SignedOut', + render: () => + h('div', {style: {alignItems: 'center', display: 'flex', flexDirection: 'column', gap: '12px'}}, [ + h(SignedIn, null, { + default: () => [ + h(Typography, {variant: 'body2'}, {default: () => 'Rendered by '}), + h(SignOutButton), + ], + }), + h(SignedOut, null, { + default: () => [ + h(Typography, {variant: 'body2'}, {default: () => 'Rendered by '}), + h(SignInButton), + ], + }), + ]), + stories: [{id: 'default', name: 'Session gate'}], + }, + { + code: () => ({ + imports: [], + template: '\n Checking your session\n \n', + }), + controls: [], + defaults: {}, + group: 'Control', + id: 'loading', + live: true, + name: 'Loading', + render: () => + h(Loading, null, { + default: () => h(Typography, {variant: 'body2'}, {default: () => 'Checking your session'}), + fallback: () => h(Typography, {variant: 'body2'}, {default: () => 'Ready: isLoading is false'}), + }), + stories: [{id: 'default', name: 'Default'}], + }, + { + code: () => ({imports: ['LanguageSwitcher'], template: ''}), + controls: [], + defaults: {}, + group: 'Control', + id: 'languageswitcher', + name: 'LanguageSwitcher', + render: () => h(LanguageSwitcher), + stories: [{id: 'default', name: 'Default'}], + }, + pickerDef('userselect', 'UserSelect', UserSelectStory, 'Owner', 'GET /api/auth/users', 'users'), + pickerDef( + 'authflowselect', + 'AuthFlowSelect', + AuthFlowSelectStory, + 'Sign-in flow', + 'GET /api/auth/auth-flows', + 'authFlows', + ), + pickerDef( + 'usertypeselect', + 'UserTypeSelect', + UserTypeSelectStory, + 'User type', + 'GET /api/auth/user-types', + 'userTypes', + ), +]; + +export const STORY_COUNT: number = COMPONENTS.reduce( + (total: number, component: ComponentDef) => total + component.stories.length, + 0, +); + +export const GROUPS: string[] = Array.from(new Set(COMPONENTS.map((component: ComponentDef) => component.group))); + +/** Builds the Vue single-file component shown in the Code panel. */ +export const buildCode = (component: ComponentDef, args: Args): string => { + const snippet = component.code(args); + const imports: string[] = Array.from(new Set(snippet.imports)).sort(); + const script: string[] = [ + ...(snippet.script?.length ? ["import {ref} from 'vue';"] : []), + ...(imports.length ? [`import {${imports.join(', ')}} from '@thunderid/vue';`] : []), + ]; + const body: string[] = snippet.script ?? []; + const scriptBlock: string = [...script, ...(script.length && body.length ? [''] : []), ...body].join('\n'); + const template: string = snippet.template + .split('\n') + .map((line: string) => ` ${line}`) + .join('\n'); + const autoImported = ''; + + return `${scriptBlock ? `\n\n` : ''}`; +}; diff --git a/playgrounds/nuxt/lib/storyTypes.ts b/playgrounds/nuxt/lib/storyTypes.ts new file mode 100644 index 00000000..199901cf --- /dev/null +++ b/playgrounds/nuxt/lib/storyTypes.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {VNode} from 'vue'; + +export type ArgValue = string | number | boolean; +export type Args = Record; +export type Action = (name: string, detail?: string) => void; + +export type ControlKind = 'text' | 'bool' | 'number' | readonly string[]; + +export interface PropControl { + control: ControlKind; + description: string; + name: string; + type: string; +} + +export interface StoryDef { + args?: Args; + id: string; + name: string; +} + +/** A Vue single-file component, generated from the current props. */ +export interface Snippet { + /** Names imported from `@thunderid/vue`. Components of `@thunderid/nuxt` are auto-imported. */ + imports: string[]; + /** Lines of ` + + diff --git a/playgrounds/nuxt/pages/composables.vue b/playgrounds/nuxt/pages/composables.vue new file mode 100644 index 00000000..6320e3b4 --- /dev/null +++ b/playgrounds/nuxt/pages/composables.vue @@ -0,0 +1,170 @@ + + + diff --git a/playgrounds/nuxt/pages/index.vue b/playgrounds/nuxt/pages/index.vue new file mode 100644 index 00000000..372506bf --- /dev/null +++ b/playgrounds/nuxt/pages/index.vue @@ -0,0 +1,318 @@ + + + diff --git a/playgrounds/nuxt/pages/protected/index.vue b/playgrounds/nuxt/pages/protected/index.vue new file mode 100644 index 00000000..7d19a115 --- /dev/null +++ b/playgrounds/nuxt/pages/protected/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/playgrounds/nuxt/pages/protected/scopes.vue b/playgrounds/nuxt/pages/protected/scopes.vue new file mode 100644 index 00000000..51df3d27 --- /dev/null +++ b/playgrounds/nuxt/pages/protected/scopes.vue @@ -0,0 +1,19 @@ + + + diff --git a/playgrounds/nuxt/pages/server.vue b/playgrounds/nuxt/pages/server.vue new file mode 100644 index 00000000..39b2f05c --- /dev/null +++ b/playgrounds/nuxt/pages/server.vue @@ -0,0 +1,440 @@ + + +