diff --git a/.changeset/descope-auth-addon.md b/.changeset/descope-auth-addon.md new file mode 100644 index 00000000..6efa5e12 --- /dev/null +++ b/.changeset/descope-auth-addon.md @@ -0,0 +1,5 @@ +--- +"@tanstack/create": minor +--- + +Add Descope authentication add-on for React. Wires `` at the app root via `VITE_DESCOPE_PROJECT_ID`, a header user menu, and a `/demo/descope` route rendering the `sign-up-or-in` flow. Mutually exclusive with other auth providers. diff --git a/packages/cli/tests-e2e/descope-smoke.spec.ts b/packages/cli/tests-e2e/descope-smoke.spec.ts new file mode 100644 index 00000000..ddf3a50a --- /dev/null +++ b/packages/cli/tests-e2e/descope-smoke.spec.ts @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { expect, test } from '@playwright/test' +import type { BrowserContext } from '@playwright/test' +import { createReactAppFixture } from './helpers' + +// The real provider UI is served locally, with its published archive pinned by +// integrity. Every provider request is intercepted; this test never signs in. +const UI_INTEGRITY = + 'sha512-FNfuyNYsiR4ScLpm2/5Ob49wX8MLuhrp9XtpKLBdGBNHl0suK88FWLqq9b6Ud0PtacNMeKZP2NRJwbMjgjVCmg==' + +test('@blocking handles Descope initialization failure, retry, and session events', async ({ + browser, +}) => { + let app: Awaited> | undefined + let context: BrowserContext | undefined + let assets = '' + try { + app = await createReactAppFixture({ + appName: 'descope-lifecycle', + addOns: ['descope'], + packageManager: 'pnpm', + runQualityGatesChecks: true, + afterCreate: async (dir) => { + fs.writeFileSync( + dir + '/.env.local', + 'VITE_DESCOPE_PROJECT_ID=P3testlocalfixture\n', + ) + assets = path.resolve(dir, '..', 'descope-ui-fixture') + fs.mkdirSync(assets) + let archive: Buffer + let response: Response + try { + response = await fetch( + 'https://registry.npmjs.org/@descope/web-components-ui/-/web-components-ui-3.27.2.tgz', + { signal: AbortSignal.timeout(30_000) }, + ) + archive = Buffer.from(await response.arrayBuffer()) + } catch (cause) { + throw new Error( + 'Could not download the pinned Descope UI within 30 seconds', + { cause }, + ) + } + assert.ok(response.ok, `Registry returned ${response.status}`) + assert.equal( + 'sha512-' + createHash('sha512').update(archive).digest('base64'), + UI_INTEGRITY, + ) + const archivePath = path.join(assets, 'ui.tgz') + fs.writeFileSync(archivePath, archive) + const entries = execFileSync('tar', ['-tzf', archivePath], { + encoding: 'utf8', + }) + .trim() + .split('\n') + assert.ok( + entries.every( + (entry) => + entry.startsWith('package/') && !entry.split('/').includes('..'), + ), + ) + execFileSync('tar', ['-xzf', archivePath, '-C', assets]) + assets = path.join(assets, 'package/dist/umd') + }, + }) + context = await browser.newContext({ locale: 'de' }) + const page = await context.newPage() + const errors: Array = [] + page.on('pageerror', (e) => errors.push(e.message)) + let polling = false + let htmlRequests = 0 + let pollRequests = 0 + let releasePoll!: () => void + const pollRecovery = new Promise((resolve) => { + releasePoll = resolve + }) + let healthy = false, + configRequests = 0, + logoutRequests = 0 + const unexpected: Array = [] + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()) + if (['localhost', '127.0.0.1'].includes(url.hostname)) + return route.continue() + if ( + url.hostname.endsWith('descope.com') || + url.hostname === 'descopecdn.com' || + url.hostname === 'cdn.jsdelivr.net' + ) { + if (url.pathname.endsWith('/config.json')) { + configRequests++ + return healthy + ? route.fulfill({ + json: { + flows: { + 'sign-up-or-in': { version: 1, targetLocales: ['de'] }, + }, + componentsVersion: '3.27.2', + }, + }) + : route.abort() + } + if (url.pathname.endsWith('/theme.json')) + return route.fulfill({ + json: { + light: { globals: '', components: {} }, + dark: { globals: '', components: {} }, + }, + }) + if ( + url.pathname.includes('/@descope/web-components-ui@') && + url.pathname.includes('/dist/umd/') + ) { + const file = path.resolve(assets, url.pathname.split('/dist/umd/')[1]) + assert.ok(file.startsWith(assets + '/')) + if (fs.existsSync(file)) + return route.fulfill({ path: file, contentType: 'text/javascript' }) + } + if (url.pathname.endsWith('-de.html')) { + return route.fulfill({ status: 404, body: 'Localized page missing' }) + } + if (url.pathname.endsWith('.html')) { + htmlRequests++ + return route.fulfill({ + body: url.pathname.includes('recovered-screen') + ? '
Recovered sign-in fixture
' + : '
Local sign-in fixture
' + + (polling && htmlRequests === 1 + ? '
' + : ''), + contentType: 'text/html', + }) + } + if (url.pathname.endsWith('/next')) { + pollRequests++ + if (pollRequests === 1) + return route.fulfill({ + json: { status: 'waiting', action: 'poll' }, + }) + if (pollRequests === 2) + return route.fulfill({ + status: 400, + json: { + errorCode: 'LOCAL_TRANSIENT', + errorDescription: 'Temporary local test failure', + }, + }) + await pollRecovery + return route.fulfill({ + json: { + executionId: 'local-execution', + stepId: '5', + status: 'waiting', + action: '', + screen: { + id: 'recovered-screen', + state: { project: { name: 'Local test' } }, + }, + stepName: 'Recovered', + }, + }) + } + if (url.pathname.endsWith('/start')) + return route.fulfill({ + json: { + executionId: 'local-execution', + stepId: '4', + status: 'waiting', + action: '', + screen: { + id: 'local-screen', + state: { project: { name: 'Local test' } }, + }, + stepName: 'Sign In', + }, + }) + if (url.pathname.endsWith('/logout')) { + logoutRequests++ + return route.fulfill({ json: {} }) + } + unexpected.push(url.origin + url.pathname) + return route.abort() + } + return route.abort() + }) + await page.goto(app.url + '/demo/descope', { + waitUntil: 'domcontentloaded', + }) + await page + .getByRole('button', { name: 'Restart sign-in' }) + .waitFor({ timeout: 15000 }) + assert.ok(configRequests > 0) + const failedRequests = configRequests + healthy = true + await page.getByRole('button', { name: 'Restart sign-in' }).click() + await page + .getByText('Local sign-in fixture', { exact: true }) + .waitFor({ timeout: 20000 }) + assert.ok(configRequests > failedRequests) + await expect(page.getByRole('alert')).toHaveCount(0) + + polling = true + htmlRequests = 0 + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.getByText('Local sign-in fixture', { exact: true }).waitFor() + const flow = await page.locator('descope-wc').elementHandle() + assert.ok(flow) + await page.getByRole('alert').waitFor() + assert.ok( + await flow.evaluate((element) => element.isConnected), + 'Polling error removed the flow', + ) + await page.getByRole('textbox', { name: 'Fixture input' }).fill('keep-me') + assert.equal( + await page.getByRole('textbox', { name: 'Fixture input' }).inputValue(), + 'keep-me', + ) + releasePoll() + await page.getByText('Recovered sign-in fixture', { exact: true }).waitFor() + assert.ok(pollRequests >= 3) + assert.ok(await flow.evaluate((element) => element.isConnected)) + await expect(page.getByRole('alert')).toHaveCount(0) + + assert.equal( + await page + .getByRole('status') + .filter({ hasText: 'Loading sign-in' }) + .count(), + 0, + ) + const now = Math.floor(Date.now() / 1000) + const jwt = [ + { alg: 'HS256', typ: 'JWT' }, + { + sub: 'local-user', + iss: 'P3testlocalfixture', + iat: now, + exp: now + 3600, + }, + 'local-only-signature', + ] + .map((x) => + Buffer.from(typeof x === 'string' ? x : JSON.stringify(x)).toString( + 'base64url', + ), + ) + .join('.') + await page.locator('descope-wc').evaluate( + (element, jwt) => + element.dispatchEvent( + new CustomEvent('success', { + detail: { + sessionJwt: jwt, + refreshJwt: jwt, + user: { + userId: 'local-user', + name: 'Local User', + email: 'local@example.test', + loginIds: ['local@example.test'], + }, + }, + }), + ), + jwt, + ) + await page + .getByRole('button', { name: 'Sign out', exact: true }) + .waitFor({ timeout: 10000 }) + await page.getByText('Signed in', { exact: true }).waitFor() + await page.getByRole('button', { name: 'Sign out', exact: true }).click() + await page.getByRole('heading', { name: 'Sign in with Descope' }).waitFor() + assert.equal( + await page.getByRole('button', { name: 'Sign out', exact: true }).count(), + 0, + ) + assert.equal(logoutRequests, 1) + assert.deepEqual(unexpected, []) + assert.deepEqual(errors, []) + } finally { + if (context) await context.close() + if (app) { + await app.stop() + await app.cleanup() + } + } +}) diff --git a/packages/create/src/frameworks/react/add-ons/descope/README.md b/packages/create/src/frameworks/react/add-ons/descope/README.md new file mode 100644 index 00000000..4c6d6c74 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/README.md @@ -0,0 +1,45 @@ +## Setting up Descope + +1. Sign up at [descope.com](https://descope.com) and create a project +2. Copy your **Project ID** from the [Console](https://app.descope.com/settings/project) +3. Set it in your `.env.local`: + ```bash + VITE_DESCOPE_PROJECT_ID=P... + ``` +4. Make sure a **`sign-up-or-in`** flow exists in the Console (it ships by default) +5. Add `http://localhost:3000` to your project's approved domains / redirect URLs +6. Visit the demo route at `/demo/descope` once `npm run dev` is running + +### What's wired up + +- **``** at the app root (`src/integrations/descope/provider.tsx`) supplies auth context to the whole tree +- **Header user menu** (`src/integrations/descope/header-user.tsx`) renders the signed-in avatar + "Sign out" button (hidden when signed out) +- **`/demo/descope`** renders the Descope `` flow and a signed-in greeting + +### Protecting a route + +Use the `useSession` hook to gate content: + +```tsx +import { useSession } from '@descope/react-sdk' + +function ProtectedPage() { + const { isAuthenticated, isSessionLoading } = useSession() + + if (isSessionLoading) return

Loading…

+ if (!isAuthenticated) return + + return +} +``` + +For server-side checks (route loaders, server functions), validate the session +token with the [`@descope/node-sdk`](https://docs.descope.com/sdks/nodejs) and +`validateSession()`. + +### Production checklist + +- Use a dedicated **production project** and its Project ID +- Configure your production domain under **Project Settings → Domains** +- Customize your flows (branding, connectors, MFA) in the **Flows** section of the Console +- Enable the social / SSO connectors you need under **Authentication Methods** diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/_dot_env.local.append b/packages/create/src/frameworks/react/add-ons/descope/assets/_dot_env.local.append new file mode 100644 index 00000000..4a89c2a8 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/_dot_env.local.append @@ -0,0 +1,2 @@ +# Descope configuration, get your Project ID from the [Console](https://app.descope.com/settings/project) +VITE_DESCOPE_PROJECT_ID= diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx new file mode 100644 index 00000000..e8ef82a4 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/header-user.tsx @@ -0,0 +1,35 @@ +import { useDescope, useSession, useUser } from '@descope/react-sdk' + +export default function HeaderUser() { + const { isAuthenticated, isSessionLoading } = useSession() + const { user, isUserLoading } = useUser() + const sdk = useDescope() + + if (isSessionLoading || isUserLoading || !isAuthenticated) return null + + const email = user?.email + const initial = (user?.name || email || 'U').charAt(0).toUpperCase() + + return ( +
+ {user?.picture ? ( + + ) : ( +
+ + {initial} + +
+ )} + +
+ ) +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/provider.tsx b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/provider.tsx new file mode 100644 index 00000000..b4d31dcb --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/src/integrations/descope/provider.tsx @@ -0,0 +1,14 @@ +import { AuthProvider } from '@descope/react-sdk' + +const PROJECT_ID = import.meta.env.VITE_DESCOPE_PROJECT_ID +if (!PROJECT_ID) { + throw new Error('Add your Descope Project ID to the .env.local file') +} + +export default function AppDescopeProvider({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx b/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx new file mode 100644 index 00000000..15666b75 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/assets/src/routes/demo/descope.tsx @@ -0,0 +1,123 @@ +import { useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { Descope, useSession, useUser } from '@descope/react-sdk' + +export const Route = createFileRoute('/demo/descope')({ + component: DescopeDemo, +}) + +function DescopeDemo() { + const { isAuthenticated, isSessionLoading } = useSession() + + return ( +
+
+ {isSessionLoading ? ( +

Loading…

+ ) : isAuthenticated ? ( + + ) : ( + + )} +
+
+ ) +} + +function SignedOut() { + const [attempt, setAttempt] = useState(0) + + return ( + <> +

Sign in with Descope

+ setAttempt((value) => value + 1)} + /> + + ) +} + +function SignInFlow({ onRetry }: { onRetry: () => void }) { + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const ready = useRef(false) + const logger = useMemo( + () => ({ + debug: console.debug, + info: console.info, + warn: console.warn, + error: (...messages: unknown[]) => { + console.error(...messages) + // Logging can also precede the SDK's own fallback or retry. Report the + // problem without unmounting the flow and interrupting that recovery. + if (!ready.current) setStatus('error') + }, + }), + [], + ) + + return ( +
+ {status === 'loading' &&

Loading sign-in…

} + { + ready.current = true + setStatus('ready') + }} + onError={() => setStatus('error')} + onScreenUpdate={() => { + if (ready.current) setStatus('ready') + return false + }} + /> + {status === 'error' && ( +
+

+ Sign-in reported a problem. Check your connection, or restart this + flow. +

+ +
+ )} +
+ ) +} + +function SignedInGreeting() { + const { user } = useUser() + if (!user) return null + + const email = user.email + const initial = (user.name || email || 'U').charAt(0).toUpperCase() + + return ( +
+
+

Signed in

+
+ +
+ {user.picture ? ( + + ) : ( +
+ + {initial} + +
+ )} +
+

{user.name}

+

+ {email} +

+
+
+
+ ) +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/info.json b/packages/create/src/frameworks/react/add-ons/descope/info.json new file mode 100644 index 00000000..66302e86 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/info.json @@ -0,0 +1,42 @@ +{ + "name": "Descope", + "description": "Drop-in auth with hosted flows, passwordless, SSO, and social login (managed).", + "phase": "add-on", + "modes": ["file-router"], + "type": "add-on", + "category": "auth", + "exclusive": ["auth"], + "color": "#0081B3", + "priority": 170, + "link": "https://descope.com", + "routes": [ + { + "icon": "CircleUserRound", + "url": "/demo/descope", + "name": "Descope", + "path": "src/routes/demo.descope.tsx", + "jsName": "DescopeDemo" + } + ], + "integrations": [ + { + "type": "header-user", + "jsName": "DescopeHeader", + "path": "src/integrations/descope/header-user.tsx" + }, + { + "type": "provider", + "jsName": "DescopeProvider", + "path": "src/integrations/descope/provider.tsx" + } + ], + "envVars": [ + { + "name": "VITE_DESCOPE_PROJECT_ID", + "description": "Descope project ID", + "required": true, + "secret": false, + "file": ".env.local" + } + ] +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/logo.svg b/packages/create/src/frameworks/react/add-ons/descope/logo.svg new file mode 100644 index 00000000..1c1539e1 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/logo.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/create/src/frameworks/react/add-ons/descope/package.json b/packages/create/src/frameworks/react/add-ons/descope/package.json new file mode 100644 index 00000000..4ed93902 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@descope/react-sdk": "^2.30.3" + } +} diff --git a/packages/create/src/frameworks/react/add-ons/descope/small-logo.svg b/packages/create/src/frameworks/react/add-ons/descope/small-logo.svg new file mode 100644 index 00000000..96717294 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/descope/small-logo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +