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
2 changes: 1 addition & 1 deletion packages/javascript/src/constants/OIDCRequestConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const OIDCRequestConstants: {
/**
* The default scopes used in OIDC sign-in requests.
*/
DEFAULT_SCOPES: [ScopeConstants.OPENID, ScopeConstants.PROFILE, ScopeConstants.INTERNAL_LOGIN],
DEFAULT_SCOPES: [ScopeConstants.OPENID, ScopeConstants.PROFILE],
},
},

Expand Down
8 changes: 0 additions & 8 deletions packages/javascript/src/constants/ScopeConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,9 @@
* ```
*/
const ScopeConstants: {
INTERNAL_LOGIN: string;
OPENID: string;
PROFILE: string;
} = {
/**
* The scope for accessing the user's profile information from SCIM.
* This scope allows the client to retrieve basic user information such as
* name, email, profile picture, etc.
*/
INTERNAL_LOGIN: 'internal_login',

/**
* The base OpenID Connect scope.
* Required for all OpenID Connect flows. Indicates that the client
Expand Down
21 changes: 21 additions & 0 deletions packages/nextjs/src/ThunderIDNextClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ThunderIDNodeClient,
ThunderIDRuntimeError,
AuthClientConfig,
EmbeddedSignInFlowResponse,
ExtendedAuthorizeRequestUrlParams,
FlattenedSchema,
IdToken,
Expand All @@ -31,6 +32,7 @@ import {
TokenResponse,
User,
UserProfile,
executeEmbeddedSignInFlow,
extractUserClaimsFromIdToken,
flattenUserSchema,
generateFlattenedUserProfile,
Expand Down Expand Up @@ -249,6 +251,25 @@ class ThunderIDNextClient<T extends ThunderIDNextConfig = ThunderIDNextConfig> e
const arg3: any = args[2];
const arg4: any = args[3];

// An embedded sign-in flow payload initiates or continues a `POST /flow/execute` step
// (identified by `applicationId` for a new flow or `executionId` to continue one). This is
// distinct from the OAuth authorization_code exchange handled by `super.signIn` below, which
// is used once the embedded flow completes and returns an authorization code.
const isEmbeddedFlowPayload: boolean =
typeof arg1 === 'object' && arg1 !== null && ('executionId' in arg1 || 'applicationId' in arg1);

if (isEmbeddedFlowPayload) {
await this.ensureInitialized();

const configData: AuthClientConfig<T> = await this.getStorageManager().getConfigData();

return executeEmbeddedSignInFlow({
baseUrl: configData?.baseUrl,
payload: arg1,
url: arg2?.url,
}) as unknown as Promise<EmbeddedSignInFlowResponse>;
}

return super.signIn(arg4, arg3, arg1?.code, arg1?.session_state, arg1?.state, arg1) as unknown as Promise<User>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@

'use client';

import {ThunderIDRuntimeError} from '@thunderid/node';
import {BaseSignIn, BaseSignInProps} from '@thunderid/react';
import {FC} from 'react';
import {EmbeddedFlowComponent, EmbeddedFlowType, ThunderIDRuntimeError} from '@thunderid/node';
import {BaseSignIn, BaseSignInProps, normalizeFlowResponse, useTranslation} from '@thunderid/react';
import {FC, useEffect, useRef, useState} from 'react';
import useThunderID from '../../../contexts/ThunderID/useThunderID';

/**
Expand All @@ -31,13 +31,74 @@ export type SignInProps = Pick<BaseSignInProps, 'className' | 'onSuccess' | 'onE

/**
* A SignIn component for Next.js that provides native authentication flow.
* This component delegates to the BaseSignIn from @thunderid/react and requires
* the API functions to be provided as props.
* This component delegates to the BaseSignIn from @thunderid/react. Unlike BaseSignUp,
* BaseSignIn does not manage its own flow state, so this component initiates the flow
* (using the server-action-backed `signIn`) and tracks `components`/`executionId` itself.
*/
const SignIn: FC<SignInProps> = ({size = 'medium', variant = 'outlined', ...rest}: SignInProps) => {
const {signIn} = useThunderID();
const SignIn: FC<SignInProps> = ({size = 'medium', variant = 'outlined', onError, ...rest}: SignInProps) => {
const {signIn, applicationId, scopes} = useThunderID();
const {t} = useTranslation();

const handleOnSubmit = async (payload: any, request: any): Promise<void> => {
const [components, setComponents] = useState<EmbeddedFlowComponent[]>([]);
const [additionalData, setAdditionalData] = useState<Record<string, any>>({});
const [executionId, setExecutionId] = useState<string | undefined>(undefined);
const [isFlowInitialized, setIsFlowInitialized] = useState(false);
const [flowError, setFlowError] = useState<Error | null>(null);
const initializationAttemptedRef = useRef(false);
// A fresh challenge token is required on every step to prevent replay attacks; the server
// rotates it on each response, so it's tracked in a ref (not state) to avoid a stale value
// from a closure captured before the previous response's state update committed.
const challengeTokenRef = useRef<string | undefined>(undefined);

const applyFlowResponse = (response: any): void => {
const normalized = normalizeFlowResponse(response, t, {resolveTranslations: false});

challengeTokenRef.current = response?.challengeToken ?? undefined;
setExecutionId(normalized.executionId ?? undefined);
setComponents(normalized.components);
setAdditionalData(normalized.additionalData);
};

const handleFlowError = (error: unknown): void => {
const normalizedError: Error = error instanceof Error ? error : new Error(String(error));

setFlowError(normalizedError);
onError?.(normalizedError);
};

useEffect(() => {
if (initializationAttemptedRef.current || !signIn) {
return;
}

initializationAttemptedRef.current = true;

(async (): Promise<void> => {
try {
const response: any = await signIn(
{
applicationId,
flowType: EmbeddedFlowType.Authentication,
...(scopes && {scopes}),
},
{},
);

// `response` is undefined when the provider already redirected
// (OAuth-redirect or completed flows are handled there directly).
if (response) {
applyFlowResponse(response);
}
} catch (error) {
handleFlowError(error);
} finally {
setIsFlowInitialized(true);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signIn]);

const handleOnSubmit = async (payload: any): Promise<void> => {
if (!signIn) {
throw new ThunderIDRuntimeError(
'`signIn` function is not available.',
Expand All @@ -46,12 +107,27 @@ const SignIn: FC<SignInProps> = ({size = 'medium', variant = 'outlined', ...rest
);
}

await signIn(payload, request);
const response: any = await signIn(
{
...payload,
executionId,
...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}),
},
{},
);

if (response) {
applyFlowResponse(response);
}
};

return (
<BaseSignIn
// isLoading={isLoading || !isInitialized}
additionalData={additionalData}
components={components}
error={flowError}
isLoading={!isFlowInitialized}
onError={onError}
onSubmit={handleOnSubmit}
size={size}
variant={variant}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,12 @@ const SignUp: FC<SignUpProps> = ({
);
}

return (await signUp(
payload || {
flowType: EmbeddedFlowType.Registration,
...(contextApplicationId && {applicationId: contextApplicationId}),
...(scopes && {scopes}),
},
)) as unknown as Promise<any>;
return (await signUp({
flowType: EmbeddedFlowType.Registration,
...(contextApplicationId && {applicationId: contextApplicationId}),
...(scopes && {scopes}),
...payload,
})) as unknown as Promise<any>;
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

'use client';

import {BaseUserAvatar, BaseUserAvatarProps} from '@thunderid/react';
import {FC, ReactElement} from 'react';
import useThunderID from '../../../contexts/ThunderID/useThunderID';

export type UserAvatarProps = Omit<BaseUserAvatarProps, 'user'>;

/**
* UserAvatar component renders an avatar for the currently signed-in user.
* This component is the Next.js-specific implementation that uses BaseUserAvatar
* and automatically retrieves the user data from ThunderID context.
*
* @example
* ```tsx
* <UserAvatar size={48} />
* ```
*/
const UserAvatar: FC<UserAvatarProps> = (props: UserAvatarProps): ReactElement => {
const {user} = useThunderID();

return <BaseUserAvatar user={user} {...props} />;
};

export default UserAvatar;
102 changes: 91 additions & 11 deletions packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import {
EmbeddedFlowExecuteRequestConfig,
FlowMetadataResponse,
generateFlattenedUserProfile,
UpdateMeProfileConfig,
User,
Expand All @@ -32,6 +33,8 @@ import {
FlowProvider,
UserProvider,
ThemeProvider,
ThunderIDContext as ReactThunderIDContext,
ThunderIDContextProps as ReactThunderIDContextProps,
ThunderIDProviderProps,
getActiveTheme,
} from '@thunderid/react';
Expand All @@ -55,6 +58,11 @@ export type ThunderIDClientProviderProps = Partial<Omit<ThunderIDProviderProps,
state: string,
sessionState?: string,
) => Promise<{error?: string; redirectUrl?: string; success: boolean}>;
/**
* Flow metadata fetched server-side ahead of time, seeding `FlowMetaProvider` so it can skip
* its own initial client-side fetch (avoiding a flash of untranslated i18n keys).
*/
initialMeta?: FlowMetadataResponse | null;
isSignedIn: boolean;
organizationHandle: ThunderIDContextProps['organizationHandle'];
refreshToken: () => Promise<RefreshResult>;
Expand Down Expand Up @@ -88,6 +96,7 @@ const ThunderIDClientProvider: FC<PropsWithChildren<ThunderIDClientProviderProps
applicationId,
organizationHandle,
scopes,
initialMeta = null,
}: PropsWithChildren<ThunderIDClientProviderProps>) => {
const reRenderCheckRef: RefObject<boolean> = useRef(false);
const router: AppRouterInstance = useRouter();
Expand Down Expand Up @@ -298,19 +307,90 @@ const ThunderIDClientProvider: FC<PropsWithChildren<ThunderIDClientProviderProps
}));
};

// Bridge into @thunderid/react's own ThunderIDContext. Internal react components rendered by
// BaseSignIn/BaseSignUp — most notably FlowMetaProvider, which fetches `/flow/meta` and injects
// its i18n bundle — call react's `useThunderID()` directly. Without this bridge they'd only see
// react's context default (applicationId/baseUrl undefined, isInitialized false), so the meta
// fetch would never fire and flow labels would render as untranslated i18n keys. Fields with no
// nextjs equivalent (token/http helpers meant for direct browser API calls) are stubbed out,
// since nextjs routes those operations through server actions instead.
const unsupported = (name: string): (() => Promise<never>) => {
return () =>
Promise.reject(
new ThunderIDRuntimeError(
`\`${name}\` is not supported in @thunderid/nextjs.`,
`ThunderIDClientProvider-${name}-NotSupportedError-001`,
'nextjs',
),
);
};

const reactContextValue: ReactThunderIDContextProps = useMemo(
() => ({
afterSignInUrl: undefined,
applicationId,
baseUrl,
clearSession,
clientId: undefined,
discovery: {wellKnown: null},
exchangeToken: unsupported('exchangeToken'),
getAccessToken: unsupported('getAccessToken'),
getDecodedIdToken: unsupported('getDecodedIdToken'),
getIdToken: unsupported('getIdToken'),
getStorageManager: () => Promise.resolve(null),
http: {
request: unsupported('http.request'),
requestAll: unsupported('http.requestAll'),
},
instanceId: 0,
isInitialized: !isLoading,
isLoading,
isMetaLoading: false,
isSignedIn,
meta: initialMeta ?? null,
organizationHandle,
reInitialize: unsupported('reInitialize'),
recover: unsupported('recover'),
resolveFlowTemplateLiterals: (text: string | undefined): string => text ?? '',
scopes,
signIn: handleSignIn,
signInSilently: unsupported('signInSilently'),
signInUrl,
signOut: handleSignOut,
signUp: handleSignUp,
signUpUrl,
user,
}),
[
applicationId,
baseUrl,
clearSession,
isLoading,
isSignedIn,
organizationHandle,
scopes,
signInUrl,
signUpUrl,
user,
initialMeta,
],
);

return (
<ThunderIDContext.Provider value={contextValue}>
<I18nProvider preferences={preferences?.i18n}>
<FlowMetaProvider enabled={preferences?.resolveFromMeta !== false}>
<ThemeProvider theme={preferences?.theme?.overrides} mode={getActiveTheme(preferences?.theme?.mode as any)}>
<FlowProvider>
<UserProvider profile={userProfile} onUpdateProfile={handleProfileUpdate} updateProfile={updateProfile}>
{children}
</UserProvider>
</FlowProvider>
</ThemeProvider>
</FlowMetaProvider>
</I18nProvider>
<ReactThunderIDContext.Provider value={reactContextValue}>
<I18nProvider preferences={preferences?.i18n}>
<FlowMetaProvider enabled={preferences?.resolveFromMeta !== false} initialMeta={initialMeta}>
<ThemeProvider theme={preferences?.theme?.overrides} mode={getActiveTheme(preferences?.theme?.mode as any)}>
<FlowProvider>
<UserProvider profile={userProfile} onUpdateProfile={handleProfileUpdate} updateProfile={updateProfile}>
{children}
</UserProvider>
</FlowProvider>
</ThemeProvider>
</FlowMetaProvider>
</I18nProvider>
</ReactThunderIDContext.Provider>
</ThunderIDContext.Provider>
);
};
Expand Down
Loading
Loading