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
25 changes: 13 additions & 12 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import type {Nuxt} from '@nuxt/schema';
import {VendorConstants} from '@thunderid/node';
import {defu} from 'defu';
import NuxtAPIRoutes from './runtime/constants/NuxtAPIRoutes';
import type {ThunderIDNuxtConfig, ThunderIDSessionPayload, ThunderIDSSRData} from './runtime/types';

type ViteUserConfig = Parameters<Parameters<typeof extendViteConfig>[0]>[0];
Expand Down Expand Up @@ -141,39 +142,39 @@ export default defineNuxtModule<ThunderIDNuxtConfig>({
// Register server API routes
const serverRoutes: ServerRoute[] = [
// ── Auth flow ──────────────────────────────────────────────────────
{handler: resolve('./runtime/server/routes/auth/session/signin.get'), route: '/api/auth/signin'},
{handler: resolve('./runtime/server/routes/auth/session/signin.get'), route: NuxtAPIRoutes.SIGN_IN},
{
handler: resolve('./runtime/server/routes/auth/session/signin.post'),
method: 'post' as const,
route: '/api/auth/signin',
route: NuxtAPIRoutes.SIGN_IN,
},
{
handler: resolve('./runtime/server/routes/auth/session/signup.post'),
method: 'post' as const,
route: '/api/auth/signup',
route: NuxtAPIRoutes.SIGN_UP,
},
{handler: resolve('./runtime/server/routes/auth/session/callback.get'), route: '/api/auth/callback'},
{handler: resolve('./runtime/server/routes/auth/session/callback.get'), route: NuxtAPIRoutes.CALLBACK},
{
handler: resolve('./runtime/server/routes/auth/session/callback.post'),
method: 'post' as const,
route: '/api/auth/callback',
route: NuxtAPIRoutes.CALLBACK,
},
{
handler: resolve('./runtime/server/routes/auth/session/signout.post'),
method: 'post' as const,
route: '/api/auth/signout',
route: NuxtAPIRoutes.SIGN_OUT,
},
// ── Session / token ───────────────────────────────────────────────
{handler: resolve('./runtime/server/routes/auth/session/session.get'), route: '/api/auth/session'},
{handler: resolve('./runtime/server/routes/auth/session/token.get'), route: '/api/auth/token'},
{handler: resolve('./runtime/server/routes/auth/session/meta.get'), route: '/api/auth/meta'},
{handler: resolve('./runtime/server/routes/auth/session/session.get'), route: NuxtAPIRoutes.SESSION},
{handler: resolve('./runtime/server/routes/auth/session/token.get'), route: NuxtAPIRoutes.TOKEN},
{handler: resolve('./runtime/server/routes/auth/session/meta.get'), route: NuxtAPIRoutes.META},
// ── User ──────────────────────────────────────────────────────────
{handler: resolve('./runtime/server/routes/auth/user/user.get'), route: '/api/auth/user'},
{handler: resolve('./runtime/server/routes/auth/user/profile.get'), route: '/api/auth/user/profile'},
{handler: resolve('./runtime/server/routes/auth/user/user.get'), route: NuxtAPIRoutes.USER},
{handler: resolve('./runtime/server/routes/auth/user/profile.get'), route: NuxtAPIRoutes.USER_PROFILE},
{
handler: resolve('./runtime/server/routes/auth/user/profile.patch'),
method: 'patch' as const,
route: '/api/auth/user/profile',
route: NuxtAPIRoutes.USER_PROFILE,
},
];

Expand Down
13 changes: 7 additions & 6 deletions packages/nuxt/src/runtime/components/ThunderIDRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {generateFlattenedUserProfile} from '@thunderid/browser';
import type {AttributeSchema, FlowMetadataResponse, 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 NuxtAPIRoutes from '../constants/NuxtAPIRoutes';
import type {ThunderIDAuthState, ThunderIDNuxtConfig} from '../types';
import {getAuthStateKey, getFlowMetaStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys';
import {useState, useRuntimeConfig} from '#imports';
Expand Down Expand Up @@ -100,7 +101,7 @@ const ThunderIDRoot: Component = defineComponent({
};

/**
* profile PATCH via the `/api/auth/user/profile` Nitro route.
* profile PATCH via the `NuxtAPIRoutes.USER_PROFILE` Nitro route.
* Signature matches `UserProvider.updateProfile` exactly.
*
* On success, applies an optimistic local update via `onUpdateProfile`
Expand All @@ -116,7 +117,7 @@ const ThunderIDRoot: Component = defineComponent({
// no-op: session is resolved server-side
}
try {
const result: {data: {user: User}; error: string; success: boolean} = await $fetch('/api/auth/user/profile', {
const result: {data: {user: User}; error: string; success: boolean} = await $fetch(NuxtAPIRoutes.USER_PROFILE, {
body: requestConfig,
method: 'PATCH',
});
Expand All @@ -130,13 +131,13 @@ const ThunderIDRoot: Component = defineComponent({
};

/**
* Re-fetch the full user profile (and its attribute schema) from `/api/auth/user/profile`.
* Re-fetch the full user profile (and its attribute schema) from `NuxtAPIRoutes.USER_PROFILE`.
*/
const revalidateProfile = async (): Promise<void> => {
try {
const res: (UserProfile & {userSchema?: Record<string, AttributeSchema> | null}) | null = await $fetch<
UserProfile & {userSchema?: Record<string, AttributeSchema> | null}
>('/api/auth/user/profile');
>(NuxtAPIRoutes.USER_PROFILE);
if (res) {
const {userSchema: fetchedSchema, ...profile} = res;
userProfileState.value = profile as UserProfile;
Expand All @@ -148,14 +149,14 @@ const ThunderIDRoot: Component = defineComponent({
};

/**
* Fetches flow metadata via the `/api/auth/meta` Nitro route instead of `FlowMetaProvider`'s
* Fetches flow metadata via the `NuxtAPIRoutes.META` Nitro route instead of `FlowMetaProvider`'s
* default direct browser-to-`baseUrl` fetch — so the browser never talks to the ThunderID
* server directly and no CORS configuration is required there. Used for both the initial
* fetch (when SSR seeding via `flowMetaState` didn't happen, e.g. it failed server-side) and
* subsequent `switchLanguage()` calls.
*/
const fetchMeta = async (params: {applicationId?: string; language?: string}): Promise<FlowMetadataResponse> =>
$fetch<FlowMetadataResponse>('/api/auth/meta', {
$fetch<FlowMetadataResponse>(NuxtAPIRoutes.META, {
query: {...(params.language ? {language: params.language} : {})},
});

Expand Down
13 changes: 8 additions & 5 deletions packages/nuxt/src/runtime/composables/useThunderID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {navigateTo, useState, useRuntimeConfig} from '#app';
import {EmbeddedSignInFlowStatus, EmbeddedSignUpFlowStatus, getRedirectBasedSignUpUrl} from '@thunderid/browser';
import {useThunderID as useThunderIDVue, type ThunderIDContext} from '@thunderid/vue';
import type {Ref} from 'vue';
import NuxtAPIRoutes from '../constants/NuxtAPIRoutes';
import type {ThunderIDAuthState} from '../types';

/**
Expand Down Expand Up @@ -53,7 +54,7 @@ export function useThunderID(): ThunderIDContext {
if (isEmbedded) {
const payload: Record<string, unknown> = arg0 as Record<string, unknown>;
const request: Record<string, unknown> = (args[1] ?? {}) as Record<string, unknown>;
const res: {data: any; success: boolean} = await $fetch<{data: any; success: boolean}>('/api/auth/signin', {
const res: {data: any; success: boolean} = await $fetch<{data: any; success: boolean}>(NuxtAPIRoutes.SIGN_IN, {
body: {payload, request},
method: 'POST',
});
Expand All @@ -71,7 +72,7 @@ export function useThunderID(): ThunderIDContext {
if (res.data?.afterSignInUrl) {
if (import.meta.client) {
try {
const session: ThunderIDAuthState = await $fetch<ThunderIDAuthState>('/api/auth/session');
const session: ThunderIDAuthState = await $fetch<ThunderIDAuthState>(NuxtAPIRoutes.SESSION);
const authState: Ref<ThunderIDAuthState> = useState<ThunderIDAuthState>('thunderid:auth');
authState.value = session;
} catch {
Expand All @@ -89,13 +90,15 @@ export function useThunderID(): ThunderIDContext {
// Redirect flow.
const options: Record<string, unknown> | undefined = arg0 as Record<string, unknown> | undefined;
const returnTo: string | undefined = typeof options?.returnTo === 'string' ? options.returnTo : undefined;
const url: string = returnTo ? `/api/auth/signin?returnTo=${encodeURIComponent(returnTo)}` : '/api/auth/signin';
const url: string = returnTo
? `${NuxtAPIRoutes.SIGN_IN}?returnTo=${encodeURIComponent(returnTo)}`
: NuxtAPIRoutes.SIGN_IN;
await navigateTo(url, {external: true});
return undefined;
};

const signOut = async (): Promise<void> => {
const res: {redirectUrl: string} = await $fetch<{redirectUrl: string}>('/api/auth/signout', {method: 'POST'});
const res: {redirectUrl: string} = await $fetch<{redirectUrl: string}>(NuxtAPIRoutes.SIGN_OUT, {method: 'POST'});
await navigateTo(res.redirectUrl || '/', {external: true});
};

Expand All @@ -120,7 +123,7 @@ export function useThunderID(): ThunderIDContext {
const isEmbedded: boolean = typeof payload === 'object' && payload !== null && 'flowType' in payload;

if (isEmbedded) {
const res: {data: any; success: boolean} = await $fetch<{data: any; success: boolean}>('/api/auth/signup', {
const res: {data: any; success: boolean} = await $fetch<{data: any; success: boolean}>(NuxtAPIRoutes.SIGN_UP, {
body: {payload},
method: 'POST',
});
Expand Down
50 changes: 50 additions & 0 deletions packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2025 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

/**
* Nitro API route paths registered by the ThunderID Nuxt SDK.
*
* Single source of truth for the `/api/auth/*` paths registered as server
* handlers in `module.ts` and consumed across `runtime/composables/useThunderID.ts`,
* `runtime/components/ThunderIDRoot.ts`, `runtime/plugins/thunderid.ts`,
* `runtime/middleware/defineThunderIDMiddleware.ts`, and
* `runtime/server/plugins/thunderid-ssr.ts`. Update a route here, not at each
* call site.
*
* @example
* ```typescript
* await $fetch(NuxtAPIRoutes.SIGN_IN, { method: 'POST' });
* ```
*/
const NuxtAPIRoutes: {
CALLBACK: string;
META: string;
SESSION: string;
SIGN_IN: string;
SIGN_OUT: string;
SIGN_UP: string;
TOKEN: string;
USER: string;
USER_PROFILE: string;
} = {
/** Resolves the OAuth callback and completes sign-in. */
CALLBACK: '/api/auth/callback',
/** Serves flow metadata (design config + i18n bundle). */
META: '/api/auth/meta',
/** Returns the current server-verified session. */
SESSION: '/api/auth/session',
/** Starts (embedded) or redirects to (redirect flow) sign-in. */
SIGN_IN: '/api/auth/signin',
/** Clears the session and returns a post-sign-out redirect URL. */
SIGN_OUT: '/api/auth/signout',
/** Starts (embedded) or redirects to (redirect flow) sign-up. */
SIGN_UP: '/api/auth/signup',
/** Returns the current access token. */
TOKEN: '/api/auth/token',
/** Returns the current user object. */
USER: '/api/auth/user',
/** Fetches (GET) and updates (PATCH) the user profile. */
USER_PROFILE: '/api/auth/user/profile',
} as const;

export default NuxtAPIRoutes;
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import {defineNuxtRouteMiddleware, navigateTo, useRuntimeConfig, useState} from '#app';
import type {Ref} from 'vue';
import type {RouteLocationNormalized} from 'vue-router';
import NuxtAPIRoutes from '../constants/NuxtAPIRoutes';
import type {ThunderIDAuthState} from '../types';
import {getAuthStateKey} from '../utils/stateKeys';

export interface ThunderIDMiddlewareOptions {
/**
* The path to redirect unauthenticated (or unauthorised) requests to.
* Defaults to `'/api/auth/signin'`.
* Defaults to `NuxtAPIRoutes.SIGN_IN`.
*/
redirectTo?: string;
/**
Expand All @@ -25,7 +26,7 @@ export interface ThunderIDMiddlewareOptions {
requireScopes?: string[];
}

const DEFAULT_REDIRECT_TO = '/api/auth/signin';
const DEFAULT_REDIRECT_TO = NuxtAPIRoutes.SIGN_IN;

/**
* Typed factory for ThunderID route middleware.
Expand Down
11 changes: 7 additions & 4 deletions packages/nuxt/src/runtime/plugins/thunderid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {H3Event} from 'h3';
import {computed} from 'vue';
import type {ComputedRef, Ref} from 'vue';
import ThunderIDRoot from '../components/ThunderIDRoot';
import NuxtAPIRoutes from '../constants/NuxtAPIRoutes';
import type {ThunderIDAuthState, ThunderIDSSRData} from '../types';
import {getAuthStateKey, getFlowMetaStateKey, getUserProfileStateKey, getUserSchemaStateKey} from '../utils/stateKeys';
import type {NuxtApp} from '#app';
Expand Down Expand Up @@ -154,12 +155,14 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => {
// ── 3. Action helpers (Nuxt-aware navigation) ───────────────────────────
const signIn = async (options?: Record<string, unknown>): Promise<void> => {
const returnTo: string | undefined = typeof options?.returnTo === 'string' ? options.returnTo : undefined;
const url: string = returnTo ? `/api/auth/signin?returnTo=${encodeURIComponent(returnTo)}` : '/api/auth/signin';
const url: string = returnTo
? `${NuxtAPIRoutes.SIGN_IN}?returnTo=${encodeURIComponent(returnTo)}`
: NuxtAPIRoutes.SIGN_IN;
await navigateTo(url, {external: true});
};

const signOut = async (): Promise<void> => {
const res: {redirectUrl: string} = await $fetch<{redirectUrl: string}>('/api/auth/signout', {method: 'POST'});
const res: {redirectUrl: string} = await $fetch<{redirectUrl: string}>(NuxtAPIRoutes.SIGN_OUT, {method: 'POST'});
await navigateTo(res.redirectUrl || '/', {external: true});
};

Expand Down Expand Up @@ -187,12 +190,12 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => {
// Last-resort fallback for unrecognised baseUrls — keeps the historical
// behaviour of hitting the (POST-only) Nitro route, which will surface a
// 405 in the network tab and make the misconfiguration obvious.
await navigateTo('/api/auth/signup', {external: true});
await navigateTo(NuxtAPIRoutes.SIGN_UP, {external: true});
};

const getAccessToken = async (): Promise<string> => {
try {
const res: {accessToken: string} = await $fetch<{accessToken: string}>('/api/auth/token');
const res: {accessToken: string} = await $fetch<{accessToken: string}>(NuxtAPIRoutes.TOKEN);
return res.accessToken ?? '';
} catch {
return '';
Expand Down
5 changes: 2 additions & 3 deletions packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@thunderid/node';
import {getRequestURL, type H3Event} from 'h3';
import {defineNitroPlugin} from 'nitropack/runtime';
import NuxtAPIRoutes from '../../constants/NuxtAPIRoutes';
import type {ThunderIDAuthState, ThunderIDNuxtConfig, ThunderIDSSRData} from '../../types';
import {createLogger} from '../../utils/log';
import ThunderIDNuxtClient from '../ThunderIDNuxtClient';
Expand All @@ -18,15 +19,13 @@ import {useRuntimeConfig} from '#imports';

const log: ReturnType<typeof createLogger> = createLogger('thunderid-ssr');

const CALLBACK_PATH = '/api/auth/callback';

/**
* Build the OAuth redirect_uri from the incoming request origin.
* Honors X-Forwarded-* headers so it works correctly behind a reverse proxy.
*/
function resolveCallbackUrl(event: H3Event): string {
const url: URL = getRequestURL(event, {xForwardedHost: true, xForwardedProto: true});
return `${url.origin}${CALLBACK_PATH}`;
return `${url.origin}${NuxtAPIRoutes.CALLBACK}`;
}

/**
Expand Down
Loading