From 0c7be89f71cb01df16a4cd55f3ab650f85bc90a6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 6 Oct 2025 16:49:36 -0500 Subject: [PATCH 1/2] convert AuthKit example to use new SDK --- examples/react/start-workos/package.json | 3 +- .../src/authkit/serverFunctions.ts | 45 --- .../start-workos/src/authkit/ssr/config.ts | 162 -------- .../src/authkit/ssr/interfaces.ts | 137 ------- .../start-workos/src/authkit/ssr/session.ts | 268 -------------- .../start-workos/src/authkit/ssr/utils.ts | 18 - .../start-workos/src/authkit/ssr/workos.ts | 33 -- .../react/start-workos/src/routes/__root.tsx | 14 +- .../src/routes/_authenticated.tsx | 10 +- .../src/routes/_authenticated/account.tsx | 24 +- .../src/routes/api/auth/callback.tsx | 88 +---- .../react/start-workos/src/routes/client.tsx | 347 ++++++++++++++++++ .../react/start-workos/src/routes/index.tsx | 8 +- .../react/start-workos/src/routes/logout.tsx | 2 +- examples/react/start-workos/src/start.ts | 13 + examples/react/start-workos/vite.config.ts | 7 + pnpm-lock.yaml | 58 ++- 17 files changed, 451 insertions(+), 786 deletions(-) delete mode 100644 examples/react/start-workos/src/authkit/serverFunctions.ts delete mode 100644 examples/react/start-workos/src/authkit/ssr/config.ts delete mode 100644 examples/react/start-workos/src/authkit/ssr/interfaces.ts delete mode 100644 examples/react/start-workos/src/authkit/ssr/session.ts delete mode 100644 examples/react/start-workos/src/authkit/ssr/utils.ts delete mode 100644 examples/react/start-workos/src/authkit/ssr/workos.ts create mode 100644 examples/react/start-workos/src/routes/client.tsx create mode 100644 examples/react/start-workos/src/start.ts diff --git a/examples/react/start-workos/package.json b/examples/react/start-workos/package.json index 6b49f77eef7..13d59fa3b24 100644 --- a/examples/react/start-workos/package.json +++ b/examples/react/start-workos/package.json @@ -18,6 +18,7 @@ "@tanstack/react-router-devtools": "^1.132.47", "@tanstack/react-start": "^1.132.47", "@workos-inc/node": "^7.45.0", + "@workos/authkit-tanstack-react-start": "^0.1.0", "iron-session": "^8.0.4", "jose": "^6.0.10", "react": "^19.0.0", @@ -27,8 +28,8 @@ "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", - "postcss": "^8.5.1", "autoprefixer": "^10.4.20", + "postcss": "^8.5.1", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", "vite": "^7.1.7", diff --git a/examples/react/start-workos/src/authkit/serverFunctions.ts b/examples/react/start-workos/src/authkit/serverFunctions.ts deleted file mode 100644 index 07d5909f8bd..00000000000 --- a/examples/react/start-workos/src/authkit/serverFunctions.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { createServerFn } from '@tanstack/react-start'; -import { deleteCookie } from '@tanstack/react-start/server'; -import { getConfig } from './ssr/config'; -import { terminateSession, withAuth } from './ssr/session'; -import { getWorkOS } from './ssr/workos'; -import type { GetAuthURLOptions, NoUserInfo, UserInfo } from './ssr/interfaces'; - -export const getAuthorizationUrl = createServerFn({ method: 'GET' }) - .inputValidator((options?: GetAuthURLOptions) => options) - .handler(({ data: options = {} }) => { - const { returnPathname, screenHint, redirectUri } = options; - - return getWorkOS().userManagement.getAuthorizationUrl({ - provider: 'authkit', - clientId: getConfig('clientId'), - redirectUri: redirectUri || getConfig('redirectUri'), - state: returnPathname ? btoa(JSON.stringify({ returnPathname })) : undefined, - screenHint, - }); - }); - -export const getSignInUrl = createServerFn({ method: 'GET' }) - .inputValidator((data?: string) => data) - .handler(async ({ data: returnPathname }) => { - return await getAuthorizationUrl({ data: { returnPathname, screenHint: 'sign-in' } }); - }); - -export const getSignUpUrl = createServerFn({ method: 'GET' }) - .inputValidator((data?: string) => data) - .handler(async ({ data: returnPathname }) => { - return getAuthorizationUrl({ data: { returnPathname, screenHint: 'sign-up' } }); - }); - -export const signOut = createServerFn({ method: 'POST' }) - .inputValidator((data?: string) => data) - .handler(async ({ data: returnTo }) => { - const cookieName = getConfig('cookieName') || 'wos_session'; - deleteCookie(cookieName); - await terminateSession({ returnTo }); - }); - -export const getAuth = createServerFn({ method: 'GET' }).handler(async (): Promise => { - const auth = await withAuth(); - return auth; -}); diff --git a/examples/react/start-workos/src/authkit/ssr/config.ts b/examples/react/start-workos/src/authkit/ssr/config.ts deleted file mode 100644 index e815e54cea8..00000000000 --- a/examples/react/start-workos/src/authkit/ssr/config.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { lazy } from './utils.js'; -import type { AuthKitConfig } from './interfaces.js'; - -type ValueSource = Record | ((key: string) => any); - -/** - * Default environment variable source that uses process.env - */ -const defaultSource: ValueSource = (key: string): string | undefined => { - try { - return process.env[key]; - } catch { - return undefined; - } -}; - -/** - * Configuration class for AuthKit. - * This class is used to manage configuration values and provide defaults. - * It also provides a way to get configuration values from environment variables. - * @internal - */ -export class Configuration { - private config: Partial = { - cookieName: 'wos-session', - apiHttps: true, - // Defaults to 400 days, the maximum allowed by Chrome - // It's fine to have a long cookie expiry date as the access/refresh tokens - // act as the actual time-limited aspects of the session. - cookieMaxAge: 60 * 60 * 24 * 400, - apiHostname: 'api.workos.com', - }; - - private valueSource: ValueSource = defaultSource; - - private readonly requiredKeys: Array = ['clientId', 'apiKey', 'redirectUri', 'cookiePassword']; - - /** - * Convert a camelCase string to an uppercase, underscore-separated environment variable name. - * @param str The string to convert - * @returns The environment variable name - */ - protected getEnvironmentVariableName(str: string) { - return `WORKOS_${str.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()}`; - } - - private updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - setValueSource(source: ValueSource): void { - this.valueSource = source; - } - - configure(configOrSource: Partial | ValueSource, source?: ValueSource): void { - if (typeof configOrSource === 'function') { - this.setValueSource(configOrSource); - } else if (typeof configOrSource === 'object' && !source) { - this.updateConfig(configOrSource); - } else if (typeof configOrSource === 'object' && source) { - this.updateConfig(configOrSource); - this.setValueSource(source); - } - - // Validate the cookiePassword if provided - if (this.config.cookiePassword && this.config.cookiePassword.length < 32) { - throw new Error('cookiePassword must be at least 32 characters long'); - } - } - - getValue(key: T): AuthKitConfig[T] { - // First check environment variables - const envKey = this.getEnvironmentVariableName(key); - let envValue: AuthKitConfig[T] | undefined = undefined; - - const { valueSource, config } = this; - if (typeof valueSource === 'function') { - envValue = valueSource(envKey); - } else { - envValue = valueSource[envKey]; - } - - // If environment variable exists, use it - if (envValue != null) { - // Convert string values to appropriate types - if (key === 'apiHttps' && typeof envValue === 'string') { - return (envValue === 'true') as AuthKitConfig[T]; - } - - if ((key === 'apiPort' || key === 'cookieMaxAge') && typeof envValue === 'string') { - const num = parseInt(envValue, 10); - return (isNaN(num) ? undefined : num) as AuthKitConfig[T]; - } - - return envValue as AuthKitConfig[T]; - } - - // Then check programmatically provided config - if (key in config && config[key] != undefined) { - return config[key] as AuthKitConfig[T]; - } - - if (this.requiredKeys.includes(key)) { - throw new Error(`Missing required configuration value for ${key} (${envKey}).`); - } - - return undefined as AuthKitConfig[T]; - } -} - -// lazy-instantiate the Configuration instance -const getConfigurationInstance = lazy(() => new Configuration()); - -/** - * Configure AuthKit with a custom value source. - * @param source The source of configuration values - * - * @example - * configure(key => Deno.env.get(key)); - */ -export function configure(source: ValueSource): void; -/** - * Configure AuthKit with custom values. - * @param config The configuration values - * - * @example - * configure({ - * clientId: 'your-client-id', - * redirectUri: 'https://your-app.com/auth/callback', - * apiKey: 'your-api-key', - * cookiePassword: 'your-cookie-password', - * }); - */ -export function configure(config: Partial): void; -/** - * Configure AuthKit with custom values and a custom value source. - * @param config The configuration values - * @param source The source of configuration values - * - * @example - * configure({ - * clientId: 'your-client-id', - * }, env); - */ -export function configure(config: Partial, source: ValueSource): void; -export function configure(configOrSource: Partial | ValueSource, source?: ValueSource): void { - const config = getConfigurationInstance(); - config.configure(configOrSource, source); -} - -/** - * Get a configuration value by key. - * This function will first check environment variables, then programmatically provided config, - * and finally fall back to defaults for optional settings. - * If a required setting is missing, an error will be thrown. - * @param key The configuration key - * @returns The configuration value - */ -export function getConfig(key: T): AuthKitConfig[T] { - const config = getConfigurationInstance(); - return config.getValue(key); -} diff --git a/examples/react/start-workos/src/authkit/ssr/interfaces.ts b/examples/react/start-workos/src/authkit/ssr/interfaces.ts deleted file mode 100644 index bd17a17d56b..00000000000 --- a/examples/react/start-workos/src/authkit/ssr/interfaces.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { Impersonator, User } from '@workos-inc/node'; - -export interface GetAuthURLOptions { - redirectUri?: string; - screenHint?: 'sign-up' | 'sign-in'; - returnPathname?: string; -} - -export interface CookieOptions { - path: '/'; - httpOnly: true; - secure: boolean; - sameSite: 'lax' | 'strict' | 'none'; - maxAge: number; - domain: string | undefined; -} - -export interface UserInfo { - user: User; - sessionId: string; - organizationId?: string; - role?: string; - permissions?: Array; - entitlements?: Array; - impersonator?: Impersonator; - accessToken: string; -} -export interface NoUserInfo { - user: null; - sessionId?: undefined; - organizationId?: undefined; - role?: undefined; - permissions?: undefined; - entitlements?: undefined; - impersonator?: undefined; - accessToken?: undefined; -} - -export interface AuthkitOptions { - debug?: boolean; - redirectUri?: string; - screenHint?: 'sign-up' | 'sign-in'; -} - -export interface AuthkitResponse { - session: UserInfo | NoUserInfo; - headers: Headers; - authorizationUrl?: string; -} - -/** - * AuthKit Session - */ -export interface Session { - /** - * The session access token - */ - accessToken: string; - /** - * The session refresh token - used to refresh the access token - */ - refreshToken: string; - /** - * The logged-in user - */ - user: User; - /** - * The impersonator user, if any - */ - impersonator?: Impersonator; -} - -/** - * AuthKit Configuration Options - */ -export interface AuthKitConfig { - /** - * The WorkOS Client ID - * Equivalent to the WORKOS_CLIENT_ID environment variable - */ - clientId: string; - - /** - * The WorkOS API Key - * Equivalent to the WORKOS_API_KEY environment variable - */ - apiKey: string; - - /** - * The redirect URI for the authentication callback - * Equivalent to the WORKOS_REDIRECT_URI environment variable - */ - redirectUri: string; - - /** - * The password used to encrypt the session cookie - * Equivalent to the WORKOS_COOKIE_PASSWORD environment variable - * Must be at least 32 characters long - */ - cookiePassword: string; - - /** - * The hostname of the API to use - * Equivalent to the WORKOS_API_HOSTNAME environment variable - */ - apiHostname?: string; - - /** - * Whether to use HTTPS for API requests - * Equivalent to the WORKOS_API_HTTPS environment variable - */ - apiHttps: boolean; - - /** - * The port to use for the API - * Equivalent to the WORKOS_API_PORT environment variable - */ - apiPort?: number; - - /** - * The maximum age of the session cookie in seconds - * Equivalent to the WORKOS_COOKIE_MAX_AGE environment variable - */ - cookieMaxAge: number; - - /** - * The name of the session cookie - * Equivalent to the WORKOS_COOKIE_NAME environment variable - * Defaults to "wos-session" - */ - cookieName: string; - - /** - * The domain for the session cookie - */ - cookieDomain?: string; -} diff --git a/examples/react/start-workos/src/authkit/ssr/session.ts b/examples/react/start-workos/src/authkit/ssr/session.ts deleted file mode 100644 index 1c58b6736d7..00000000000 --- a/examples/react/start-workos/src/authkit/ssr/session.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { redirect } from '@tanstack/react-router'; -import { getCookie, setCookie } from '@tanstack/react-start/server'; -import { sealData, unsealData } from 'iron-session'; -import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; -import { getConfig } from './config'; -import { lazy } from './utils'; -import { getWorkOS } from './workos'; -import type { AccessToken, AuthenticationResponse } from '@workos-inc/node'; -import type { AuthkitOptions, AuthkitResponse, CookieOptions, GetAuthURLOptions, Session } from './interfaces'; - -const sessionHeaderName = 'x-workos-session'; -const middlewareHeaderName = 'x-workos-middleware'; - -export function getAuthorizationUrl(options: GetAuthURLOptions = {}) { - const { returnPathname, screenHint, redirectUri } = options; - - return getWorkOS().userManagement.getAuthorizationUrl({ - provider: 'authkit', - clientId: getConfig('clientId'), - redirectUri: redirectUri || getConfig('redirectUri'), - state: returnPathname ? btoa(JSON.stringify({ returnPathname })) : undefined, - screenHint, - }); -} - -export function serializeCookie(name: string, value: string, options: Partial = {}): string { - const { - path = '/', - maxAge = getConfig('cookieMaxAge'), - secure = options.sameSite === 'none' ? true : getConfig('redirectUri').startsWith('https:'), - sameSite = 'lax', - domain = getConfig('cookieDomain'), - } = options; - - let cookie = `${name}=${encodeURIComponent(value)}; Path=${path}; sameSite=${sameSite}; HttpOnly`; - cookie += `; Max-Age=${maxAge}`; - if (!maxAge) cookie += `; Expires=${new Date(0).toUTCString()}`; - if (secure) cookie += '; Secure'; - if (domain) cookie += `; Domain=${domain}`; - - return cookie; -} - -export async function decryptSession(encryptedSession: string): Promise { - const cookiePassword = getConfig('cookiePassword'); - return unsealData(encryptedSession, { - password: cookiePassword, - }); -} - -export async function encryptSession(session: Session) { - return sealData(session, { - password: getConfig('cookiePassword'), - ttl: 0, - }); -} - -export async function withAuth() { - const session = await getSessionFromCookie(); - - if (!session?.user) { - return { user: null }; - } - - const { - sid: sessionId, - org_id: organizationId, - role, - permissions, - entitlements, - } = decodeJwt(session.accessToken); - - return { - sessionId, - user: session.user, - organizationId, - role, - permissions, - entitlements, - impersonator: session.impersonator, - accessToken: session.accessToken, - }; -} - -export async function getSessionFromCookie() { - const cookieName = getConfig('cookieName') || 'wos-session'; - const cookie = getCookie(cookieName); - - if (cookie) { - return decryptSession(cookie); - } -} - -export async function saveSession(sessionOrResponse: Session | AuthenticationResponse): Promise { - const cookieName = getConfig('cookieName') || 'wos-session'; - const encryptedSession = await encryptSession(sessionOrResponse); - setCookie(cookieName, encryptedSession); -} - -// JWKS call only happens once and the result is cached. The lazy function ensures that -// the JWK set is only created when it's needed, and not before. -const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(getConfig('clientId'))))); - -async function verifyAccessToken(accessToken: string): Promise { - try { - await jwtVerify(accessToken, JWKS()); - return true; - } catch { - return false; - } -} - -function getReturnPathname(url: string): string { - const newUrl = new URL(url); - - return `${newUrl.pathname}${newUrl.searchParams.size > 0 ? '?' + newUrl.searchParams.toString() : ''}`; -} - -export async function updateSession( - request: Request, - options: AuthkitOptions = { debug: false }, -): Promise { - const session = await getSessionFromCookie(); - - const newRequestHeaders = new Headers(); - - // Record that the request was routed through the middleware so we can check later for DX purposes - newRequestHeaders.set(middlewareHeaderName, 'true'); - - // We store the current request url in a custom header, so we can always have access to it - // This is because on hard navigations we don't have access to `next-url` but need to get the current - // `pathname` to be able to return the users where they came from before sign-in - newRequestHeaders.set('x-url', request.url); - - if (options.redirectUri) { - // Store the redirect URI in a custom header, so we always have access to it and so that subsequent - // calls to `getAuthorizationUrl` will use the same redirect URI - newRequestHeaders.set('x-redirect-uri', options.redirectUri); - } - - newRequestHeaders.delete(sessionHeaderName); - - if (!session) { - if (options.debug) { - console.log('No session found from cookie'); - } - - return { - session: { user: null }, - headers: newRequestHeaders, - authorizationUrl: getAuthorizationUrl({ - returnPathname: getReturnPathname(request.url), - redirectUri: options.redirectUri || getConfig('redirectUri'), - screenHint: options.screenHint, - }), - }; - } - - const hasValidSession = await verifyAccessToken(session.accessToken); - - const cookieName = getConfig('cookieName') || 'wos-session'; - - if (hasValidSession) { - newRequestHeaders.set(sessionHeaderName, getCookie(cookieName)!); - - const { - sid: sessionId, - org_id: organizationId, - role, - permissions, - entitlements, - } = decodeJwt(session.accessToken); - - return { - session: { - sessionId, - user: session.user, - organizationId, - role, - permissions, - entitlements, - impersonator: session.impersonator, - accessToken: session.accessToken, - }, - headers: newRequestHeaders, - }; - } - - try { - if (options.debug) { - // istanbul ignore next - console.log( - `Session invalid. ${session.accessToken ? `Refreshing access token that ends in ${session.accessToken.slice(-10)}` : 'Access token missing.'}`, - ); - } - - const { org_id: organizationIdFromAccessToken } = decodeJwt(session.accessToken); - - const { accessToken, refreshToken, user, impersonator } = - await getWorkOS().userManagement.authenticateWithRefreshToken({ - clientId: getConfig('clientId'), - refreshToken: session.refreshToken, - organizationId: organizationIdFromAccessToken, - }); - - if (options.debug) { - console.log('Session successfully refreshed'); - } - // Encrypt session with new access and refresh tokens - const encryptedSession = await encryptSession({ - accessToken, - refreshToken, - user, - impersonator, - }); - - newRequestHeaders.append('Set-Cookie', serializeCookie(cookieName, encryptedSession)); - newRequestHeaders.set(sessionHeaderName, encryptedSession); - - const { - sid: sessionId, - org_id: organizationId, - role, - permissions, - entitlements, - } = decodeJwt(accessToken); - - return { - session: { - sessionId, - user, - organizationId, - role, - permissions, - entitlements, - impersonator, - accessToken, - }, - headers: newRequestHeaders, - }; - } catch (e) { - if (options.debug) { - console.log('Failed to refresh. Deleting cookie.', e); - } - - // When we need to delete a cookie, return it as a header as you can't delete cookies from edge middleware - const deleteCookie = serializeCookie(cookieName, '', { maxAge: 0 }); - newRequestHeaders.append('Set-Cookie', deleteCookie); - - return { - session: { user: null }, - headers: newRequestHeaders, - authorizationUrl: getAuthorizationUrl({ - returnPathname: getReturnPathname(request.url), - }), - }; - } -} - -export async function terminateSession({ returnTo }: { returnTo?: string } = {}) { - const { sessionId } = await withAuth(); - if (sessionId) { - const href = getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo }); - return redirect({ href, throw: true, reloadDocument: true }); - } - - return redirect({ to: returnTo ?? '/', throw: true, reloadDocument: true }); -} diff --git a/examples/react/start-workos/src/authkit/ssr/utils.ts b/examples/react/start-workos/src/authkit/ssr/utils.ts deleted file mode 100644 index 73b807cb568..00000000000 --- a/examples/react/start-workos/src/authkit/ssr/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Returns a function that can only be called once. - * Subsequent calls will return the result of the first call. - * This is useful for lazy initialization. - * @param fn - The function to be called once. - * @returns A function that can only be called once. - */ -export function lazy(fn: () => T): () => T { - let called = false; - let result: T; - return () => { - if (!called) { - result = fn(); - called = true; - } - return result; - }; -} diff --git a/examples/react/start-workos/src/authkit/ssr/workos.ts b/examples/react/start-workos/src/authkit/ssr/workos.ts deleted file mode 100644 index b3107992105..00000000000 --- a/examples/react/start-workos/src/authkit/ssr/workos.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { WorkOS } from '@workos-inc/node'; -import { getConfig } from './config'; -import { lazy } from './utils'; - -/** - * Create a WorkOS instance with the provided API key and optional settings. - */ -export function createWorkOSInstance() { - // Get required API key from config - const apiKey = getConfig('apiKey'); - - // Get optional settings - const apiHostname = getConfig('apiHostname'); - const apiHttps = getConfig('apiHttps'); - const apiPort = getConfig('apiPort'); - - const options = { - apiHostname, - https: apiHttps, - port: apiPort, - }; - - // Initialize the WorkOS client with config values - const workos = new WorkOS(apiKey, options); - - return workos; -} - -/** - * Create a WorkOS instance with the provided API key and optional settings. - * This function is lazy loaded to avoid loading the WorkOS SDK when it's not needed. - */ -export const getWorkOS = lazy(createWorkOSInstance); diff --git a/examples/react/start-workos/src/routes/__root.tsx b/examples/react/start-workos/src/routes/__root.tsx index 8cdf0bd5bc8..9b4733d7969 100644 --- a/examples/react/start-workos/src/routes/__root.tsx +++ b/examples/react/start-workos/src/routes/__root.tsx @@ -3,17 +3,12 @@ import '@radix-ui/themes/styles.css'; import { HeadContent, Link, Outlet, Scripts, createRootRoute } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import { Suspense } from 'react'; -import { getAuth, getSignInUrl } from '../authkit/serverFunctions'; +import { getAuth, getSignInUrl } from '@workos/authkit-tanstack-react-start'; import Footer from '../components/footer'; import SignInButton from '../components/sign-in-button'; import type { ReactNode } from 'react'; export const Route = createRootRoute({ - beforeLoad: async () => { - const { user } = await getAuth(); - - return { user }; - }, head: () => ({ meta: [ { @@ -24,12 +19,13 @@ export const Route = createRootRoute({ content: 'width=device-width, initial-scale=1', }, { - title: 'AuthKit Example in TanStack Start', + title: '@workos/authkit-tanstack-react-start', }, ], }), - loader: async ({ context }) => { - const { user } = context; + loader: async () => { + // getAuth() is a server function - works during client-side navigation + const { user } = await getAuth(); const url = await getSignInUrl(); return { user, diff --git a/examples/react/start-workos/src/routes/_authenticated.tsx b/examples/react/start-workos/src/routes/_authenticated.tsx index 20be3d7f1b5..10269ba089f 100644 --- a/examples/react/start-workos/src/routes/_authenticated.tsx +++ b/examples/react/start-workos/src/routes/_authenticated.tsx @@ -1,11 +1,13 @@ import { redirect, createFileRoute } from '@tanstack/react-router'; -import { getSignInUrl } from '../authkit/serverFunctions'; +import { getAuth, getSignInUrl } from '@workos/authkit-tanstack-react-start'; export const Route = createFileRoute('/_authenticated')({ - beforeLoad: async ({ context, location }) => { - if (!context.user) { + loader: async ({ location }) => { + // Loader runs on server (even during client-side navigation via RPC) + const { user } = await getAuth(); + if (!user) { const path = location.pathname; - const href = await getSignInUrl({ data: path }); + const href = await getSignInUrl({ data: { returnPathname: path } }); throw redirect({ href }); } }, diff --git a/examples/react/start-workos/src/routes/_authenticated/account.tsx b/examples/react/start-workos/src/routes/_authenticated/account.tsx index 5c8b688f928..734daf19da8 100644 --- a/examples/react/start-workos/src/routes/_authenticated/account.tsx +++ b/examples/react/start-workos/src/routes/_authenticated/account.tsx @@ -1,27 +1,25 @@ import { createFileRoute } from '@tanstack/react-router'; import { Box, Flex, Heading, Text, TextField } from '@radix-ui/themes'; -import {} from '@tanstack/react-router'; -import { getAuth } from '../../authkit/serverFunctions'; +import { getAuth } from '@workos/authkit-tanstack-react-start'; export const Route = createFileRoute('/_authenticated/account')({ component: RouteComponent, - loader: async ({ context }) => { - const { user } = context; - const { role, permissions } = await getAuth(); + loader: async () => { + const auth = await getAuth(); const userFields: Array<[label: string, value: string | undefined]> = [ - ['First name', user?.firstName ?? ''], - ['Last name', user?.lastName ?? ''], - ['Email', user?.email], - ['Id', user?.id], + ['First name', auth.user?.firstName ?? ''], + ['Last name', auth.user?.lastName ?? ''], + ['Email', auth.user?.email], + ['Id', auth.user?.id], ]; - if (role) { - userFields.push(['Role', role]); + if (auth.user && 'role' in auth && auth.role) { + userFields.push(['Role', auth.role]); } - if (permissions) { - userFields.push(['Permissions', permissions.join(', ')]); + if (auth.user && 'permissions' in auth && auth.permissions) { + userFields.push(['Permissions', auth.permissions.join(', ')]); } return userFields; diff --git a/examples/react/start-workos/src/routes/api/auth/callback.tsx b/examples/react/start-workos/src/routes/api/auth/callback.tsx index 8ba493b7dc9..581932587d1 100644 --- a/examples/react/start-workos/src/routes/api/auth/callback.tsx +++ b/examples/react/start-workos/src/routes/api/auth/callback.tsx @@ -1,94 +1,10 @@ import { createFileRoute } from '@tanstack/react-router'; -import { getConfig } from '../../../authkit/ssr/config'; -import { saveSession } from '../../../authkit/ssr/session'; -import { getWorkOS } from '../../../authkit/ssr/workos'; +import { handleCallbackRoute } from '@workos/authkit-tanstack-react-start'; export const Route = createFileRoute('/api/auth/callback')({ server: { handlers: { - GET: async ({ request }) => { - const url = new URL(request.url); - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - let returnPathname = state && state !== 'null' ? JSON.parse(atob(state)).returnPathname : null; - - if (code) { - try { - // Use the code returned to us by AuthKit and authenticate the user with WorkOS - const { accessToken, refreshToken, user, impersonator } = - await getWorkOS().userManagement.authenticateWithCode({ - clientId: getConfig('clientId'), - code, - }); - - // If baseURL is provided, use it instead of request.nextUrl - // This is useful if the app is being run in a container like docker where - // the hostname can be different from the one in the request - const url = new URL(request.url); - - // Cleanup params - url.searchParams.delete('code'); - url.searchParams.delete('state'); - - // Redirect to the requested path and store the session - returnPathname = returnPathname ?? '/'; - - // Extract the search params if they are present - if (returnPathname.includes('?')) { - const newUrl = new URL(returnPathname, 'https://example.com'); - url.pathname = newUrl.pathname; - - for (const [key, value] of newUrl.searchParams) { - url.searchParams.append(key, value); - } - } else { - url.pathname = returnPathname; - } - - const response = redirectWithFallback(url.toString()); - - if (!accessToken || !refreshToken) throw new Error('response is missing tokens'); - - await saveSession({ accessToken, refreshToken, user, impersonator }); - - return response; - } catch (error) { - const errorRes = { - error: error instanceof Error ? error.message : String(error), - }; - - console.error(errorRes); - - return errorResponse(); - } - } - - return errorResponse(); - - function errorResponse() { - return errorResponseWithFallback({ - error: { - message: 'Something went wrong', - description: - "Couldn't sign in. If you are not sure what happened, please contact your organization admin.", - }, - }); - } - }, + GET: handleCallbackRoute, }, }, }); - -function redirectWithFallback(redirectUri: string, headers?: Headers) { - const newHeaders = headers ? new Headers(headers) : new Headers(); - newHeaders.set('Location', redirectUri); - - return new Response(null, { status: 307, headers: newHeaders }); -} - -function errorResponseWithFallback(errorBody: { error: { message: string; description: string } }) { - return new Response(JSON.stringify(errorBody), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/examples/react/start-workos/src/routes/client.tsx b/examples/react/start-workos/src/routes/client.tsx new file mode 100644 index 00000000000..c7725356d51 --- /dev/null +++ b/examples/react/start-workos/src/routes/client.tsx @@ -0,0 +1,347 @@ +import { createFileRoute, Link } from '@tanstack/react-router'; +import { Badge, Box, Button, Code, Flex, Heading, Text, TextField, Callout } from '@radix-ui/themes'; +import { useAccessToken, useAuth } from '@workos/authkit-tanstack-react-start/client'; +import { useState } from 'react'; + +export const Route = createFileRoute('/client')({ + component: RouteComponent, +}); + +function RouteComponent() { + const { + user, + loading, + sessionId, + organizationId, + role, + roles, + permissions, + entitlements, + featureFlags, + impersonator, + signOut, + switchToOrganization, + } = useAuth(); + const { accessToken, loading: tokenLoading, error: tokenError, refresh, getAccessToken } = useAccessToken(); + + const handleRefreshToken = async () => { + try { + await refresh(); + } catch (err) { + console.error('Token refresh failed:', err); + } + }; + + const handleGetFreshToken = async () => { + try { + const token = await getAccessToken(); + console.log('Fresh token:', token); + } catch (err) { + console.error('Get fresh token failed:', err); + } + }; + + const handleClientSignOut = async () => { + console.log('๐Ÿงช Testing client-side signOut() from useAuth()...'); + try { + await signOut({ returnTo: '/' }); + console.log('โœ… signOut() completed'); + } catch (err) { + console.error('โŒ signOut() failed:', err); + } + }; + + const [orgIdInput, setOrgIdInput] = useState(''); + const [switchOrgResult, setSwitchOrgResult] = useState(null); + + const handleSwitchOrg = async () => { + if (!orgIdInput.trim()) { + setSwitchOrgResult('Please enter an organization ID'); + return; + } + + console.log(`๐Ÿ”„ Switching to organization: ${orgIdInput}...`); + setSwitchOrgResult(null); + + try { + const result = await switchToOrganization(orgIdInput.trim()); + if (result && 'error' in result) { + console.error('โŒ Switch failed:', result.error); + setSwitchOrgResult(`Error: ${result.error}`); + } else { + console.log('โœ… Successfully switched organizations'); + setSwitchOrgResult('โœ… Success! Check updated claims above.'); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('โŒ Switch error:', message); + setSwitchOrgResult(`Error: ${message}`); + } + }; + + if (loading) { + return ( + + Loading... + + ); + } + + if (!user) { + return ( + + + Client-Side Hooks Demo + + + This page demonstrates the client-side hooks from @workos/authkit-tanstack-start/client + + + โ„น๏ธ Please sign in to see the client-side hooks in action. + + + ); + } + + return ( + + + + Client-Side Hooks Demo + + + Using useAuth() and useAccessToken() + + + + + + โ„น๏ธ This page uses client-side React hooks to access authentication data. Unlike server-side loaders, these + hooks work in client components and automatically update when auth state changes. + + + + + useAuth() Hook + + + + User ID: + + + + + + Email: + + + + + + First Name: + + + + + + Last Name: + + + + + + Session ID: + + + + {organizationId && ( + + + Organization ID: + + + + )} + {role && ( + + + Role: + + + + )} + {roles && roles.length > 0 && ( + + + Roles: + + + {roles.map((r) => ( + {r} + ))} + + + )} + {permissions && permissions.length > 0 && ( + + + Permissions: + + + {permissions.map((p) => ( + + {p} + + ))} + + + )} + {entitlements && entitlements.length > 0 && ( + + + Entitlements: + + + {entitlements.map((e) => ( + + {e} + + ))} + + + )} + {featureFlags && featureFlags.length > 0 && ( + + + Feature Flags: + + + {featureFlags.map((f) => ( + + {f} + + ))} + + + )} + {impersonator && ( + + + Impersonator: + + + + )} + + + + + useAccessToken() Hook + + + + Token Status: + + + {tokenLoading ? 'Loading' : accessToken ? 'Available' : 'None'} + + + {tokenError && ( + + + Error: + + {tokenError.message} + + )} + {accessToken && ( + + + Access Token: + + + + ...{accessToken.slice(-20)} + + + + )} + + + + + + + + + Organization Management + + Switch to a different organization. Requires multi-organization setup in WorkOS. + + + + Setup required: This feature requires your WorkOS user to be a member of multiple + organizations. Create organizations in the WorkOS dashboard and add your user to them. + + + + + + Current Org: + + + {organizationId || 'None'} + + + + + Switch to Org: + + setOrgIdInput(e.target.value)} + style={{ flexGrow: 1 }} + /> + + + {switchOrgResult && ( + + {switchOrgResult} + + )} + + + + + Sign Out Methods + + Test different sign out approaches. Check the browser console for logs. + + + + + + + + Client-Side useAuth: Calls signOut() from the provider context. This tests the + redirect handling logic we just fixed. +
+ Route Loader: Uses the /logout route which calls the server function directly + in a loader. +
+
+
+
+ ); +} diff --git a/examples/react/start-workos/src/routes/index.tsx b/examples/react/start-workos/src/routes/index.tsx index 6f2c64415b5..c69922a124a 100644 --- a/examples/react/start-workos/src/routes/index.tsx +++ b/examples/react/start-workos/src/routes/index.tsx @@ -1,12 +1,12 @@ import { Button, Flex, Heading, Text } from '@radix-ui/themes'; import { Link, createFileRoute } from '@tanstack/react-router'; -import { getSignInUrl } from '../authkit/serverFunctions'; +import { getAuth, getSignInUrl } from '@workos/authkit-tanstack-react-start'; import SignInButton from '../components/sign-in-button'; export const Route = createFileRoute('/')({ component: Home, - loader: async ({ context }) => { - const { user } = context; + loader: async () => { + const { user } = await getAuth(); const url = await getSignInUrl(); return { user, url }; }, @@ -21,7 +21,7 @@ function Home() { <> Welcome back{user?.firstName && `, ${user?.firstName}`} - You are now authenticated into the application + You are now authenticated into the TanStack Start application + + + + + + + + +
+ + - - + - Loading...}> - - -
-
+ +
+ + Loading...}> + + + +
- -
- -
+ +
+ +
+
-
- - -