Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default defineNuxtModule<ThunderIDNuxtConfig>({
applicationId: publicConfig.applicationId,
baseUrl: publicConfig.baseUrl,
clientId: publicConfig.clientId,
endpoints: publicConfig.endpoints,
platform: publicConfig.platform,
preferences: publicConfig.preferences,
scopes: publicConfig.scopes,
Expand All @@ -102,6 +103,7 @@ export default defineNuxtModule<ThunderIDNuxtConfig>({
applicationId?: string;
baseUrl: string;
clientId: string;
endpoints?: ThunderIDNuxtConfig['endpoints'];
platform?: ThunderIDNuxtConfig['platform'];
preferences: ThunderIDNuxtConfig['preferences'];
scopes: string | string[];
Expand Down Expand Up @@ -286,6 +288,7 @@ declare module '@nuxt/schema' {
applicationId?: string;
baseUrl: string;
clientId: string;
endpoints?: ThunderIDNuxtConfig['endpoints'];
platform?: ThunderIDNuxtConfig['platform'];
preferences?: ThunderIDNuxtConfig['preferences'];
scopes: string | string[];
Expand Down
9 changes: 7 additions & 2 deletions packages/nuxt/src/runtime/components/ThunderIDRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
// SPDX-License-Identifier: Apache-2.0

import {generateFlattenedUserProfile} from '@thunderid/browser';
import type {UpdateMeProfileConfig, User, UserProfile} from '@thunderid/node';
import type {AttributeSchema, UpdateMeProfileConfig, User, UserProfile} from '@thunderid/node';
import {FlowMetaProvider, FlowProvider, I18nProvider, ThemeProvider, UserProvider} from '@thunderid/vue';
import {defineComponent, h, type Component, type Ref, type SetupContext, type VNode} from 'vue';
import type {ThunderIDAuthState, ThunderIDNuxtConfig} from '../types';
import {getAuthStateKey, getUserProfileStateKey} from '../utils/stateKeys';
import {getAuthStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys';
import {useState, useRuntimeConfig} from '#imports';

/**
Expand Down Expand Up @@ -55,6 +55,10 @@ const ThunderIDRoot: Component = defineComponent({

// ── Read SSR-hydrated state keys (seeded by the Nuxt plugin) ────────────
const userProfileState: Ref<UserProfile | null> = useState<UserProfile | null>(getUserProfileStateKey(vendor));
const userSchemaState: Ref<Record<string, AttributeSchema> | null> = useState<Record<
string,
AttributeSchema
> | null>(getUserSchemaStateKey(vendor));
// Used by onUpdateProfile to keep the top-level auth user claim in sync.
const authState: Ref<ThunderIDAuthState> = useState<ThunderIDAuthState>(getAuthStateKey(vendor));

Expand Down Expand Up @@ -176,6 +180,7 @@ const ThunderIDRoot: Component = defineComponent({
profile: shouldFetchProfile ? userProfileState.value : null,
revalidateProfile: shouldFetchProfile ? revalidateProfile : undefined,
updateProfile: shouldFetchProfile ? updateProfile : undefined,
userSchema: shouldFetchProfile ? userSchemaState.value : null,
},
{
default: (): VNode | VNode[] | undefined => slots.default?.(),
Expand Down
9 changes: 7 additions & 2 deletions packages/nuxt/src/runtime/plugins/thunderid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@

import {getRedirectBasedSignUpUrl} from '@thunderid/browser';
import {VendorConstants} from '@thunderid/node';
import type {UserProfile} from '@thunderid/node';
import type {AttributeSchema, UserProfile} from '@thunderid/node';
import {ThunderIDPlugin, THUNDERID_KEY} from '@thunderid/vue';
import type {H3Event} from 'h3';
import {computed} from 'vue';
import type {ComputedRef, Ref} from 'vue';
import ThunderIDRoot from '../components/ThunderIDRoot';
import type {ThunderIDAuthState, ThunderIDSSRData} from '../types';
import {getAuthStateKey, getUserProfileStateKey} from '../utils/stateKeys';
import {getAuthStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys';
import type {NuxtApp} from '#app';
import {defineNuxtPlugin, useState, useRequestEvent, useRuntimeConfig, navigateTo} from '#app';

Expand Down Expand Up @@ -88,6 +88,10 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => {
getUserProfileStateKey(vendor),
() => null,
);
const userSchemaState: Ref<Record<string, AttributeSchema> | null> = useState<Record<string, AttributeSchema> | null>(
getUserSchemaStateKey(vendor),
() => null,
);

if (import.meta.server) {
const event: H3Event | undefined = useRequestEvent();
Expand All @@ -101,6 +105,7 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => {
user: ssr.user,
};
userProfileState.value = ssr.userProfile;
userSchemaState.value = ssr.userSchema ?? null;
} else {
// Backwards-compat: fall back to the legacy context shape (pre-Step-2 plugin).
const ssrContext: {isSignedIn?: boolean; session?: {sub?: string}} | undefined = (
Expand Down
93 changes: 86 additions & 7 deletions packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@

import {
ThunderIDNodeClient,
ThunderIDRuntimeError,
extractUserClaimsFromIdToken,
generateFlattenedUserProfile,
getUsersMe,
getUsersMeMeta,
resolveResourceEndpoint,
updateMeProfile,
type AttributeSchema,
type AuthClientConfig,
type IdToken,
type Storage,
Expand Down Expand Up @@ -44,6 +52,7 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient<ThunderIDNuxtConfig> {
clientId: config.clientId!,
clientSecret: config.clientSecret || undefined,
enablePKCE: true,
endpoints: config.endpoints,
scopes: config.scopes || ['openid', 'profile'],
tokenRequest: config.tokenRequest,
} as AuthClientConfig<ThunderIDNuxtConfig>;
Expand Down Expand Up @@ -124,8 +133,23 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient<ThunderIDNuxtConfig> {
return (configData?.afterSignOutUrl as string) || (configData?.afterSignInUrl as string) || '/';
}

override getUser(sessionId?: string): Promise<User> {
return super.getUser(sessionId);
override async getUser(sessionId?: string): Promise<User> {
try {
const configData: AuthClientConfig<ThunderIDNuxtConfig> = await this.getStorageManager().getConfigData();
const baseUrl: string | undefined = configData?.baseUrl;

const profile: User = await getUsersMe({
baseUrl,
url: resolveResourceEndpoint('usersMe', configData),
headers: {
Authorization: `Bearer ${await this.getAccessToken(sessionId)}`,
},
});

return profile;
} catch (error) {
return await super.getUser(sessionId);
}
}

override getAccessToken(sessionId?: string): Promise<string> {
Expand All @@ -144,13 +168,68 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient<ThunderIDNuxtConfig> {
return super.exchangeToken(config, sessionId) as unknown as Promise<TokenResponse | Response>;
}

override async getUserProfile(sessionId: string): Promise<UserProfile> {
const user: User = await this.getUser(sessionId);
return {flattenedProfile: user, profile: user};
override async getUserProfile(sessionId?: string): Promise<UserProfile> {
try {
const configData: AuthClientConfig<ThunderIDNuxtConfig> = await this.getStorageManager().getConfigData();
const baseUrl: string | undefined = configData?.baseUrl;

const profile: User = await getUsersMe({
baseUrl,
url: resolveResourceEndpoint('usersMe', configData),
headers: {
Authorization: `Bearer ${await this.getAccessToken(sessionId)}`,
},
});

return {
flattenedProfile: generateFlattenedUserProfile(profile),
profile,
};
} catch (error) {
const claims = extractUserClaimsFromIdToken(await super.getDecodedIdToken(sessionId));
return {
flattenedProfile: claims,
profile: claims,
};
}
}

override async updateUserProfile(config: UpdateMeProfileConfig, sessionId?: string): Promise<User> {
try {
const configData: AuthClientConfig<ThunderIDNuxtConfig> = await this.getStorageManager().getConfigData();
const baseUrl: string | undefined = configData?.baseUrl;

return updateMeProfile({
baseUrl,
url: resolveResourceEndpoint('usersMe', configData),
headers: {
Authorization: `Bearer ${await this.getAccessToken(sessionId)}`,
},
payload: (config as any)?.payload ?? config,
});
} catch (error) {
throw new ThunderIDRuntimeError(
`Failed to update user profile: ${error instanceof Error ? error.message : String(error)}`,
'ThunderIDNuxtClient-UpdateProfileError-001',
'nuxt',
'An error occurred while updating the user profile. Please check your configuration and network connection.',
);
}
}

override async updateUserProfile(config: UpdateMeProfileConfig, sessionId: string): Promise<User> {
throw new Error('Profile updates are not supported for the ThunderID platform.');
async getUserSchema(sessionId?: string): Promise<Record<string, AttributeSchema> | null> {
const configData: AuthClientConfig<ThunderIDNuxtConfig> = await this.getStorageManager().getConfigData();
const baseUrl: string | undefined = configData?.baseUrl;

const metaRes = await getUsersMeMeta({
baseUrl,
url: resolveResourceEndpoint('usersMeMeta', configData),
headers: {
Authorization: `Bearer ${await this.getAccessToken(sessionId)}`,
},
});

return metaRes?.schema ?? null;
}

public override getStorageManager(): any {
Expand Down
10 changes: 9 additions & 1 deletion packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => {
baseUrl: publicConfig.baseUrl,
clientId: publicConfig.clientId,
clientSecret: privateConfig?.clientSecret || undefined,
endpoints: publicConfig.endpoints,
platform: publicConfig.platform,
scopes: publicConfig.scopes || ['openid', 'profile'],
tokenRequest: publicConfig.tokenRequest,
Expand Down Expand Up @@ -144,12 +145,15 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => {
// ── 4. Parallel SSR data fetches (gated by preferences) ───────────────
const shouldFetchProfile: boolean = prefs?.user?.fetchUserProfile !== false;

const [userResult, userProfileResult] = await Promise.allSettled([
const [userResult, userProfileResult, userSchemaResult] = await Promise.allSettled([
// Always fetch the basic user object (needed for ThunderIDAuthState.user)
client.getUser(session.sessionId),

// User profile (flattened)
shouldFetchProfile ? client.getUserProfile(session.sessionId) : Promise.resolve(null),

// User schema metadata from /users/me/meta
shouldFetchProfile ? client.getUserSchema(session.sessionId) : Promise.resolve(null),
]);

if (userResult.status === 'rejected') {
Expand All @@ -158,6 +162,9 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => {
if (userProfileResult.status === 'rejected') {
log.warn('Failed to fetch user profile:', userProfileResult.reason);
}
if (userSchemaResult.status === 'rejected') {
log.warn('Failed to fetch user schema:', userSchemaResult.reason);
}

// ── 5. Write to event context ──────────────────────────────────────────
const ssrData: ThunderIDSSRData = {
Expand All @@ -166,6 +173,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => {
session,
user: userResult.status === 'fulfilled' ? userResult.value : null,
userProfile: userProfileResult.status === 'fulfilled' ? userProfileResult.value : null,
userSchema: userSchemaResult.status === 'fulfilled' ? userSchemaResult.value : null,
};

const eventContext: Record<string, unknown> = event.context;
Expand Down
8 changes: 4 additions & 4 deletions packages/nuxt/src/runtime/server/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,15 @@ export async function verifyTempSessionToken(
/**
* Session cookie name.
*/
export function getSessionCookieName(): string {
return CookieConfig.SESSION_COOKIE_NAME;
export function getSessionCookieName(vendor?: string): string {
return CookieConfig.getSessionCookieName(vendor);
}

/**
* Temp session cookie name.
*/
export function getTempSessionCookieName(): string {
return CookieConfig.TEMP_SESSION_COOKIE_NAME;
export function getTempSessionCookieName(vendor?: string): string {
return CookieConfig.getTempSessionCookieName(vendor);
}

/**
Expand Down
78 changes: 14 additions & 64 deletions packages/nuxt/src/runtime/types.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,25 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import type {I18nPreferences, TokenEndpointAuthMethod, User, UserProfile} from '@thunderid/node';
import type {
AttributeSchema,
AuthClientConfig,
I18nPreferences,
TokenEndpointAuthMethod,
User,
UserProfile,
} from '@thunderid/node';
import type {JWTPayload} from 'jose';

/**
* Configuration for the ThunderID Nuxt module.
* Extends `AuthClientConfig` from `@thunderid/node` for 1:1 SDK parity.
*/
export interface ThunderIDNuxtConfig {
/** URL to redirect to after sign-in (default: '/') */
afterSignInUrl?: string;
/** URL to redirect to after sign-out (default: '/') */
afterSignOutUrl?: string;
/**
* ThunderID application id (`spId`) — appended to the redirect-based sign-up
* URL when present. Mirrors `applicationId` in the React/Next.js SDKs.
*/
applicationId?: string;
/** Base URL of the ThunderID org tenant (e.g. https://localhost:8090) */
baseUrl?: string;
/** OAuth2 Client ID */
clientId?: string;
/** OAuth2 Client Secret (server-only, use THUNDERID_CLIENT_SECRET env var) */
clientSecret?: string;
/**
* Feature-gating preferences that control which server-side data fetches
* the Nitro plugin performs on every SSR request.
*/
preferences?: {
/** i18n configuration forwarded to `I18nProvider`. */
i18n?: I18nPreferences;
theme?: {
/**
* Theme mode forwarded to the Vue SDK's `ThemeProvider`.
* - `'light'` (default) | `'dark'`: Fixed color scheme. Toggle at runtime with `useTheme().toggleTheme()`.
* - `'system'`: Follows the OS `prefers-color-scheme`.
* - `'class'`: Reads a CSS class on `<html>` (works well with Tailwind dark-mode).
* - `'branding'`: Follows the active theme from the tenant's branding preference.
*/
mode?: 'light' | 'dark' | 'system' | 'class' | 'branding';
};
user?: {
/** Whether to fetch the user profile during SSR (default: true). */
fetchUserProfile?: boolean;
};
};
/** OAuth2 scopes to request */
scopes?: string | string[];
export interface ThunderIDNuxtConfig extends AuthClientConfig {
/** Secret for signing session JWTs (use THUNDERID_SESSION_SECRET env var) */
sessionSecret?: string;
/**
* Optional override for the redirect-based sign-in URL. Reserved for
* parity with the React/Next.js SDKs; not currently used by the redirect
* flow (which goes through `/api/auth/signin`).
*/
signInUrl?: string;
/**
* Optional override for the redirect-based sign-up URL. When set,
* `<ThunderIDSignUpButton>` and `useThunderID().signUp()` (no-arg) navigate
* here instead of deriving the URL from `baseUrl`/`clientId`.
*/
signUpUrl?: string;
/**
* Configuration for the token endpoint request.
*/
tokenRequest?: {
/**
* OAuth 2.0 client authentication method used at the token endpoint.
* Defaults to `client_secret_basic` for ThunderIDV2 and `client_secret_post`
* for all other platforms when not specified.
*/
authMethod?: TokenEndpointAuthMethod;
};
/** Platform identifier */
platform?: any;
/**
* Vendor/brand namespace used to prefix Nuxt `useState` keys, the
* `event.context` namespace, and other server-side identifiers.
Expand Down Expand Up @@ -133,6 +81,8 @@ export interface ThunderIDSSRData {
user: User | null;
/** Flattened user profile + raw profile (null when `preferences.user.fetchUserProfile` is false). */
userProfile: UserProfile | null;
/** User schema metadata from /users/me/meta (null when `preferences.user.fetchUserProfile` is false). */
userSchema?: Record<string, AttributeSchema> | null;
}

/**
Expand Down
Loading
Loading