diff --git a/.changeset/metal-colts-lose.md b/.changeset/metal-colts-lose.md new file mode 100644 index 00000000..1690cee8 --- /dev/null +++ b/.changeset/metal-colts-lose.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +Stabilize the SDK diff --git a/packages/javascript/src/AsgardeoJavaScriptClient.ts b/packages/javascript/src/AsgardeoJavaScriptClient.ts index 653fae7b..905ad078 100644 --- a/packages/javascript/src/AsgardeoJavaScriptClient.ts +++ b/packages/javascript/src/AsgardeoJavaScriptClient.ts @@ -46,6 +46,8 @@ abstract class AsgardeoJavaScriptClient implements AsgardeoClient abstract isSignedIn(): Promise; + abstract getConfiguration(): T; + abstract signIn( options?: SignInOptions, sessionId?: string, @@ -58,11 +60,11 @@ abstract class AsgardeoJavaScriptClient implements AsgardeoClient onSignInSuccess?: (afterSignInUrl: string) => void, ): Promise; - abstract signOut(options?: SignOutOptions, afterSignOut?: (redirectUrl: string) => void): Promise; + abstract signOut(options?: SignOutOptions, afterSignOut?: (afterSignOutUrl: string) => void): Promise; abstract signOut( options?: SignOutOptions, sessionId?: string, - afterSignOut?: (redirectUrl: string) => void, + afterSignOut?: (afterSignOutUrl: string) => void, ): Promise; abstract signUp(options?: SignUpOptions): Promise; diff --git a/packages/javascript/src/api/__tests__/getScim2Me.test.ts b/packages/javascript/src/api/__tests__/getScim2Me.test.ts new file mode 100644 index 00000000..dc9ff20b --- /dev/null +++ b/packages/javascript/src/api/__tests__/getScim2Me.test.ts @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2025, 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. + */ + +import {describe, it, expect, vi} from 'vitest'; +import getScim2Me from '../getScim2Me'; +import AsgardeoAPIError from '../../../errors/AsgardeoAPIError'; + +// Mock user data +const mockUser = { + id: '123', + username: 'testuser', + email: 'test@example.com', + givenName: 'Test', + familyName: 'User', +}; + +describe('getScim2Me', () => { + it('should fetch user profile successfully with default fetch', async () => { + // Mock fetch + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockUser), + text: () => Promise.resolve(JSON.stringify(mockUser)), + }); + + // Replace global fetch + global.fetch = mockFetch; + + const result = await getScim2Me({ + url: 'https://api.asgardeo.io/t/test/scim2/Me', + }); + + expect(result).toEqual(mockUser); + expect(mockFetch).toHaveBeenCalledWith('https://api.asgardeo.io/t/test/scim2/Me', { + method: 'GET', + headers: { + 'Content-Type': 'application/scim+json', + Accept: 'application/json', + }, + }); + }); + + it('should use custom fetcher when provided', async () => { + const customFetcher = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockUser), + text: () => Promise.resolve(JSON.stringify(mockUser)), + }); + + const result = await getScim2Me({ + url: 'https://api.asgardeo.io/t/test/scim2/Me', + fetcher: customFetcher, + }); + + expect(result).toEqual(mockUser); + expect(customFetcher).toHaveBeenCalledWith('https://api.asgardeo.io/t/test/scim2/Me', { + method: 'GET', + headers: { + 'Content-Type': 'application/scim+json', + Accept: 'application/json', + }, + }); + }); + + it('should throw AsgardeoAPIError for invalid URL', async () => { + await expect( + getScim2Me({ + url: 'invalid-url', + }), + ).rejects.toThrow(AsgardeoAPIError); + }); + + it('should throw AsgardeoAPIError for failed response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('User not found'), + }); + + global.fetch = mockFetch; + + await expect( + getScim2Me({ + url: 'https://api.asgardeo.io/t/test/scim2/Me', + }), + ).rejects.toThrow(AsgardeoAPIError); + }); + + it('should handle network errors', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')); + + global.fetch = mockFetch; + + await expect( + getScim2Me({ + url: 'https://api.asgardeo.io/t/test/scim2/Me', + }), + ).rejects.toThrow(AsgardeoAPIError); + }); +}); diff --git a/packages/javascript/src/api/getScim2Me.ts b/packages/javascript/src/api/getScim2Me.ts new file mode 100644 index 00000000..a690678c --- /dev/null +++ b/packages/javascript/src/api/getScim2Me.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2025, 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. + */ + +import {User} from '../models/user'; +import AsgardeoAPIError from '../errors/AsgardeoAPIError'; + +/** + * Configuration for the getScim2Me request + */ +export interface GetScim2MeConfig extends Omit { + /** + * The absolute API endpoint. + */ + url?: string; + /** + * The base path of the API endpoint. + */ + baseUrl?: string; + /** + * Optional custom fetcher function. + * If not provided, native fetch will be used + */ + fetcher?: (url: string, config: RequestInit) => Promise; +} + +/** + * Retrieves the user profile information from the specified SCIM2 /Me endpoint. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the user profile information. + * @example + * ```typescript + * // Using default fetch + * try { + * const userProfile = await getScim2Me({ + * url: "https://api.asgardeo.io/t//scim2/Me", + * }); + * console.log(userProfile); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get user profile:', error.message); + * } + * } + * ``` + * + * @example + * ```typescript + * // Using custom fetcher (e.g., axios-based httpClient) + * try { + * const userProfile = await getScim2Me({ + * url: "https://api.asgardeo.io/t//scim2/Me", + * fetcher: async (url, config) => { + * const response = await httpClient({ + * url, + * method: config.method, + * headers: config.headers, + * ...config + * }); + * // Convert axios-like response to fetch-like Response + * return { + * ok: response.status >= 200 && response.status < 300, + * status: response.status, + * statusText: response.statusText, + * json: () => Promise.resolve(response.data), + * text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)) + * } as Response; + * } + * }); + * console.log(userProfile); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get user profile:', error.message); + * } + * } + * ``` + */ +const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeConfig): Promise => { + try { + new URL(url ?? baseUrl); + } catch (error) { + throw new AsgardeoAPIError( + `Invalid URL provided. ${error?.toString()}`, + 'getScim2Me-ValidationError-001', + 'javascript', + 400, + 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', + ); + } + + const fetchFn = fetcher || fetch; + const resolvedUrl: string = url ?? `${baseUrl}/scim2/Me` + + const requestInit: RequestInit = { + method: 'GET', + headers: { + 'Content-Type': 'application/scim+json', + Accept: 'application/json', + ...requestConfig.headers, + }, + ...requestConfig, + }; + + try { + const response: Response = await fetchFn(resolvedUrl, requestInit); + + if (!response?.ok) { + const errorText = await response.text(); + + throw new AsgardeoAPIError( + `Failed to fetch user profile: ${errorText}`, + 'getScim2Me-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + return (await response.json()) as User; + } catch (error) { + if (error instanceof AsgardeoAPIError) { + throw error; + } + + throw new AsgardeoAPIError( + `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'getScim2Me-NetworkError-001', + 'javascript', + 0, + 'Network Error', + ); + } +}; + +export default getScim2Me; diff --git a/packages/javascript/src/api/scim2/__tests__/getMeProfile.test.ts b/packages/javascript/src/api/scim2/__tests__/getMeProfile.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/javascript/src/api/scim2/getMeProfile.ts b/packages/javascript/src/api/scim2/getMeProfile.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/javascript/src/api/scim2/index.ts b/packages/javascript/src/api/scim2/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 2f810d59..2a110aed 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -25,6 +25,7 @@ export {default as initializeEmbeddedSignInFlow} from './api/initializeEmbeddedS export {default as executeEmbeddedSignInFlow} from './api/executeEmbeddedSignInFlow'; export {default as executeEmbeddedSignUpFlow} from './api/executeEmbeddedSignUpFlow'; export {default as getUserInfo} from './api/getUserInfo'; +export {default as getScim2Me, GetScim2MeConfig} from './api/getScim2Me'; export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants'; export {default as TokenConstants} from './constants/TokenConstants'; diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts index a8bd52f5..405c7e26 100644 --- a/packages/javascript/src/models/client.ts +++ b/packages/javascript/src/models/client.ts @@ -57,6 +57,8 @@ export interface AsgardeoClient { */ switchOrganization(organization: Organization): Promise; + getConfiguration(): T; + /** * Gets user information from the session. * @@ -132,7 +134,7 @@ export interface AsgardeoClient { * @param afterSignOut - Callback function to be executed after sign-out is complete. * @returns A promise that resolves to true if sign-out is successful */ - signOut(options?: SignOutOptions, afterSignOut?: (redirectUrl: string) => void): Promise; + signOut(options?: SignOutOptions, afterSignOut?: (afterSignOutUrl: string) => void): Promise; /** * Signs out the currently signed-in user with an optional session ID. @@ -143,7 +145,7 @@ export interface AsgardeoClient { * @param afterSignOut - Callback function to be executed after sign-out is complete. * @returns A promise that resolves to true if sign-out is successful */ - signOut(options?: SignOutOptions, sessionId?: string, afterSignOut?: (redirectUrl: string) => void): Promise; + signOut(options?: SignOutOptions, sessionId?: string, afterSignOut?: (afterSignOutUrl: string) => void): Promise; /** * Initiates a redirection-based sign-up process for the user. diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index c2410614..056363fd 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -76,6 +76,21 @@ export interface BaseConfig extends WithPreferences { * scopes: ["openid", "profile", "email"] */ scopes?: string | string[] | undefined; + + /** + * Optional URL to redirect the user to sign-in. + * By default, this will be the sign-in page of Asgardeo. + * If you want to use a custom sign-in page, you can provide the URL here and use the `SignIn` component to render it. + */ + signInUrl?: string | undefined; + + /** + * Optional URL to redirect the user to sign-up. + * By default, this will be the sign-up page of Asgardeo. + * If you want to use a custom sign-up page, you can provide the URL here + * and use the `SignUp` component to render it. + */ + signUpUrl?: string | undefined; } export interface WithPreferences { diff --git a/packages/nextjs/QUICKSTART.md b/packages/nextjs/QUICKSTART.md index 54b01581..4ef1d3b4 100644 --- a/packages/nextjs/QUICKSTART.md +++ b/packages/nextjs/QUICKSTART.md @@ -212,6 +212,51 @@ pnpm dev yarn dev ``` +## Step 10: Embedded Login Page (Optional) + +If you want to use an embedded login page instead of redirecting to Asgardeo, you can use the `SignIn` component: + +Configure the path of the sign-in page in the `middleware.ts` file: + +```diff +import { AsgardeoNext } from '@asgardeo/nextjs'; +import { NextRequest } from 'next/server'; + +const asgardeo = new AsgardeoNext(); + +asgardeo.initialize({ + baseUrl: process.env.NEXT_PUBLIC_ASGARDEO_BASE_URL, + clientId: process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_ID, + clientSecret: process.env.ASGARDEO_CLIENT_SECRET, ++ signInUrl: '/signin', +}); + +export async function middleware(request: NextRequest) { + return await asgardeo.middleware(request); +} + +export const config = { + matcher: [ + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + '/(api|trpc)(.*)', + ], +}; +``` + +Then, create a new page for the sign-in component in `app/signin/page.tsx`: + +```tsx +'use client'; + +import {SignIn} from '@asgardeo/nextjs'; + +export default function SignInPage() { + return ; +} +``` + +Once you have set this up, clicking on the "Sign In" button will render the embedded login page instead of redirecting to Asgardeo. + ## Next Steps 🎉 **Congratulations!** You've successfully integrated Asgardeo authentication into your Next.js app. diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 159e9627..7d24da4b 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -23,6 +23,8 @@ yarn add @asgardeo/nextjs ## Quick Start +### Option 1: Provider-based Configuration (Recommended) + 1. Create a `.env.local` file with your Asgardeo configuration: ```bash @@ -31,24 +33,64 @@ NEXT_PUBLIC_ASGARDEO_CLIENT_ID= NEXT_PUBLIC_ASGARDEO_CLIENT_SECRET= ``` -2. Then create a `middleware.ts` file in your project root to handle authentication: +2. Add the `AsgardeoProvider` to your root layout with configuration: + +```tsx +// app/layout.tsx +import { AsgardeoProvider } from '@asgardeo/nextjs'; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const asgardeoConfig = { + baseUrl: process.env.NEXT_PUBLIC_ASGARDEO_BASE_URL, + clientId: process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_ID, + clientSecret: process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_SECRET, + afterSignInUrl: process.env.NEXT_PUBLIC_ASGARDEO_AFTER_SIGN_IN_URL || 'http://localhost:3000', + }; + + return ( + + + + {children} + + + + ); +} +``` + +3. Create a simple `middleware.ts` file in your project root: ```typescript -import { AsgardeoNext } from '@asgardeo/nextjs'; -import { NextRequest } from 'next/server'; +import { asgardeoMiddleware } from '@asgardeo/nextjs/middleware'; + +export default asgardeoMiddleware; + +export const config = { + matcher: [ + // Skip Next.js internals and all static files, unless found in search params + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + // Always run for API routes + '/(api|trpc)(.*)', + ], +}; +``` -const asgardeo = new AsgardeoNext(); +### Option 2: Middleware-based Configuration + +2. Then create a `middleware.ts` file in your project root to handle authentication: -asgardeo.initialize({ +```typescript +import { createAsgardeoMiddleware } from '@asgardeo/nextjs/middleware'; + +const middleware = createAsgardeoMiddleware({ baseUrl: process.env.NEXT_PUBLIC_ASGARDEO_BASE_URL, clientId: process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_ID, clientSecret: process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_SECRET, afterSignInUrl: 'http://localhost:3000', }); -export async function middleware(request: NextRequest) { - return await asgardeo.middleware(request); -} +export { middleware }; export const config = { matcher: [ @@ -82,6 +124,24 @@ export default function Home() { } ``` +## Server-side Usage + +You can access the Asgardeo client instance in server actions and other server-side code: + +```typescript +import { getAsgardeoClient } from '@asgardeo/nextjs/server'; + +export async function getUserProfile() { + const client = getAsgardeoClient(); + const user = await client.getUser(); + return user; +} +``` + +## Architecture + +The SDK uses a singleton pattern for the `AsgardeoNextClient` to ensure consistent authentication state across your application. The client is automatically initialized when you provide configuration through the `AsgardeoProvider` or through the middleware configuration. + ## License Apache-2.0 diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 826318a1..a11c8619 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -19,8 +19,18 @@ "module": "dist/index.js", "commonjs": "dist/cjs/index.js", "exports": { - "import": "./dist/index.js", - "require": "./dist/cjs/index.js" + ".": { + "import": "./dist/index.js", + "require": "./dist/cjs/index.js" + }, + "./middleware": { + "import": "./dist/middleware/index.js", + "require": "./dist/cjs/middleware/index.js" + }, + "./server": { + "import": "./dist/server/index.js", + "require": "./dist/cjs/server/index.js" + } }, "files": [ "dist", diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 7afa1b4d..c93b996d 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -37,12 +37,8 @@ import { EmbeddedSignInFlowStatus, } from '@asgardeo/node'; import {NextRequest, NextResponse} from 'next/server'; -import InternalAuthAPIRoutesConfig from './configs/InternalAuthAPIRoutesConfig'; import {AsgardeoNextConfig} from './models/config'; -import deleteSessionId from './server/actions/deleteSessionId'; import getSessionId from './server/actions/getSessionId'; -import getIsSignedIn from './server/actions/isSignedIn'; -import setSessionId from './server/actions/setSessionId'; import decorateConfigWithNextEnv from './utils/decorateConfigWithNextEnv'; const removeTrailingSlash = (path: string): string => (path.endsWith('/') ? path.slice(0, -1) : path); @@ -50,25 +46,74 @@ const removeTrailingSlash = (path: string): string => (path.endsWith('/') ? path * Client for mplementing Asgardeo in Next.js applications. * This class provides the core functionality for managing user authentication and sessions. * + * This class is implemented as a singleton to ensure a single instance across the application. + * * @typeParam T - Configuration type that extends AsgardeoNextConfig. */ class AsgardeoNextClient extends AsgardeoNodeClient { + private static instance: AsgardeoNextClient; private asgardeo: LegacyAsgardeoNodeClient; + private isInitialized: boolean = false; - constructor() { + private constructor() { super(); this.asgardeo = new LegacyAsgardeoNodeClient(); } + /** + * Get the singleton instance of AsgardeoNextClient + */ + public static getInstance(): AsgardeoNextClient { + if (!AsgardeoNextClient.instance) { + AsgardeoNextClient.instance = new AsgardeoNextClient(); + } + return AsgardeoNextClient.instance as AsgardeoNextClient; + } + + /** + * Ensures the client is initialized before using it. + * Throws an error if the client is not initialized. + */ + private async ensureInitialized(): Promise { + if (!this.isInitialized) { + throw new Error( + '[AsgardeoNextClient] Client is not initialized. Make sure you have wrapped your app with AsgardeoProvider and provided the required configuration (baseUrl, clientId, etc.).', + ); + } + } + override initialize(config: T): Promise { - const {baseUrl, clientId, clientSecret, afterSignInUrl, ...rest} = decorateConfigWithNextEnv(config); + if (this.isInitialized) { + console.warn('[AsgardeoNextClient] Client is already initialized'); + return Promise.resolve(true); + } + + const {baseUrl, clientId, clientSecret, signInUrl, afterSignInUrl, afterSignOutUrl, signUpUrl, ...rest} = + decorateConfigWithNextEnv(config); + + this.isInitialized = true; + + console.log('[AsgardeoNextClient] Initializing with decorateConfigWithNextEnv:', { + baseUrl, + clientId, + clientSecret, + signInUrl, + signUpUrl, + afterSignInUrl, + afterSignOutUrl, + enablePKCE: false, + ...rest, + }); return this.asgardeo.initialize({ baseUrl, clientId, clientSecret, + signInUrl, + signUpUrl, afterSignInUrl, + afterSignOutUrl, enablePKCE: false, ...rest, } as any); @@ -104,6 +149,10 @@ class AsgardeoNextClient exte return this.asgardeo.isSignedIn(sessionId as string); } + override getConfiguration(): T { + return this.asgardeo.getConfigData() as unknown as T; + } + override signIn( options?: SignInOptions, sessionId?: string, @@ -145,11 +194,11 @@ class AsgardeoNextClient exte ) as unknown as Promise; } - override signOut(options?: SignOutOptions, afterSignOut?: (redirectUrl: string) => void): Promise; + override signOut(options?: SignOutOptions, afterSignOut?: (afterSignOutUrl: string) => void): Promise; override signOut( options?: SignOutOptions, sessionId?: string, - afterSignOut?: (redirectUrl: string) => void, + afterSignOut?: (afterSignOutUrl: string) => void, ): Promise; override async signOut(...args: any[]): Promise { if (args[1] && typeof args[1] !== 'string') { @@ -172,165 +221,40 @@ class AsgardeoNextClient exte ); } - async handler(req: NextRequest): Promise { - const {pathname, searchParams} = req.nextUrl; - const sanitizedPathname: string = removeTrailingSlash(pathname); - const {method} = req; - - // Handle POST sign-in request - if (method === 'POST' && sanitizedPathname === InternalAuthAPIRoutesConfig.signIn) { - try { - // Get session ID from cookies directly since we're in middleware context - let userId: string | undefined = req.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value; - - // Generate session ID if not present - if (!userId) { - userId = generateSessionId(); - } - - const signInUrl: URL = new URL(await this.asgardeo.getSignInUrl({response_mode: 'direct'}, userId)); - const {pathname: urlPathname, origin, searchParams: urlSearchParams} = signInUrl; - - console.log('[AsgardeoNextClient] Sign-in URL:', signInUrl.toString()); - console.log('[AsgardeoNextClient] Search Params:', Object.fromEntries(urlSearchParams.entries())); - const body = await req.json(); - - console.log('[AsgardeoNextClient] Sign-in request:', body); - - const {payload, request} = body; - - const response: any = await this.signIn( - payload, - { - url: request?.url ?? `${origin}${urlPathname}`, - payload: request?.payload ?? Object.fromEntries(urlSearchParams.entries()), - }, - userId, - ); - - // Clean the response to remove any non-serializable properties - const cleanResponse = response ? JSON.parse(JSON.stringify(response)) : {success: true}; - - // Create response with session cookie - const nextResponse = NextResponse.json(cleanResponse); - - // Set session cookie if it was generated - if (!req.cookies.get(CookieConfig.SESSION_COOKIE_NAME)) { - nextResponse.cookies.set(CookieConfig.SESSION_COOKIE_NAME, userId, { - httpOnly: CookieConfig.DEFAULT_HTTP_ONLY, - maxAge: CookieConfig.DEFAULT_MAX_AGE, - sameSite: CookieConfig.DEFAULT_SAME_SITE, - secure: CookieConfig.DEFAULT_SECURE, - }); - } - - if (response.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - const res = await this.signIn( - { - code: response?.authData?.code, - session_state: response?.authData?.session_state, - state: response?.authData?.state, - } as any, - {}, - userId, - (afterSignInUrl: string) => null, - ); - - const afterSignInUrl = await ( - await this.asgardeo.getStorageManager() - ).getConfigDataParameter('afterSignInUrl'); - const redirectUrl = String(afterSignInUrl); - console.log('[AsgardeoNextClient] Sign-in successful, redirecting to:', redirectUrl); - - return NextResponse.redirect(redirectUrl, 303); - } - - return nextResponse; - } catch (error) { - console.error('[AsgardeoNextClient] Failed to initialize embedded sign-in flow:', error); - return NextResponse.json({error: 'Failed to initialize sign-in flow'}, {status: 500}); - } - } - - // Handle GET sign-in request or callback with code - if ((method === 'GET' && sanitizedPathname === InternalAuthAPIRoutesConfig.signIn) || searchParams.get('code')) { - try { - if (searchParams.get('code')) { - // Handle OAuth callback - await this.signIn(); - - const cleanUrl: URL = new URL(req.url); - cleanUrl.searchParams.delete('code'); - cleanUrl.searchParams.delete('state'); - cleanUrl.searchParams.delete('session_state'); - - return NextResponse.redirect(cleanUrl.toString()); - } - - // Regular GET sign-in request - await this.signIn(); - return NextResponse.next(); - } catch (error) { - console.error('[AsgardeoNextClient] Sign-in failed:', error); - return NextResponse.json({error: 'Sign-in failed'}, {status: 500}); - } - } - - if (method === 'GET' && sanitizedPathname === InternalAuthAPIRoutesConfig.session) { - try { - const isSignedIn: boolean = await getIsSignedIn(); - - return NextResponse.json({isSignedIn}); - } catch (error) { - return NextResponse.json({error: 'Failed to check session'}, {status: 500}); - } - } - - if (method === 'GET' && sanitizedPathname === InternalAuthAPIRoutesConfig.user) { - try { - const user: User = await this.getUser(); - - console.log('[AsgardeoNextClient] User fetched successfully:', user); - - return NextResponse.json({user}); - } catch (error) { - console.error('[AsgardeoNextClient] Failed to get user:', error); - return NextResponse.json({error: 'Failed to get user'}, {status: 500}); - } - } - - if (method === 'GET' && sanitizedPathname === InternalAuthAPIRoutesConfig.signOut) { - try { - const afterSignOutUrl: string = await this.signOut(); - - await deleteSessionId(); - - return NextResponse.redirect(afterSignOutUrl, 302); - } catch (error) { - console.error('[AsgardeoNextClient] Sign-out failed:', error); - return NextResponse.json({error: 'Failed to sign out'}, {status: 500}); - } - } + /** + * Gets the sign-in URL for authentication. + * Ensures the client is initialized before making the call. + * + * @param userId - The user ID + * @returns Promise that resolves to the sign-in URL + */ + public async getSignInUrl(userId?: string): Promise { + await this.ensureInitialized(); + return this.asgardeo.getSignInUrl(undefined, userId); + } - // no auth handler found, simply touch the sessions - // TODO: this should only happen if rolling sessions are enabled. Also, we should - // try to avoid reading from the DB (for stateful sessions) on every request if possible. - // const res = NextResponse.next(); - // const session = await this.sessionStore.get(req.cookies); - - // if (session) { - // // we pass the existing session (containing an `createdAt` timestamp) to the set method - // // which will update the cookie's `maxAge` property based on the `createdAt` time - // await this.sessionStore.set(req.cookies, res.cookies, { - // ...session, - // }); - // } - - return NextResponse.next(); + /** + * Gets the sign-in URL for authentication with custom request config. + * Ensures the client is initialized before making the call. + * + * @param requestConfig - Custom request configuration + * @param userId - The user ID + * @returns Promise that resolves to the sign-in URL + */ + public async getSignInUrlWithConfig(requestConfig?: any, userId?: string): Promise { + await this.ensureInitialized(); + return this.asgardeo.getSignInUrl(requestConfig, userId); } - middleware(req: NextRequest): Promise { - return this.handler(req); + /** + * Gets the storage manager from the underlying Asgardeo client. + * Ensures the client is initialized before making the call. + * + * @returns Promise that resolves to the storage manager + */ + public async getStorageManager(): Promise { + await this.ensureInitialized(); + return this.asgardeo.getStorageManager(); } } diff --git a/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx b/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx index b2732d72..e6476536 100644 --- a/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx +++ b/packages/nextjs/src/client/components/actions/SignInButton/SignInButton.tsx @@ -18,9 +18,11 @@ 'use client'; -import {forwardRef, ForwardRefExoticComponent, ReactElement, Ref, RefAttributes} from 'react'; -import InternalAuthAPIRoutesConfig from '../../../../configs/InternalAuthAPIRoutesConfig'; -import {BaseSignInButton, BaseSignInButtonProps} from '@asgardeo/react'; +import {forwardRef, ForwardRefExoticComponent, ReactElement, Ref, RefAttributes, useState, MouseEvent} from 'react'; +import {AsgardeoRuntimeError} from '@asgardeo/node'; +import {BaseSignInButton, BaseSignInButtonProps, useTranslation} from '@asgardeo/react'; +import useAsgardeo from '../../../../client/contexts/Asgardeo/useAsgardeo'; +import {useRouter} from 'next/navigation'; /** * Props interface of {@link SignInButton} @@ -28,7 +30,7 @@ import {BaseSignInButton, BaseSignInButtonProps} from '@asgardeo/react'; export type SignInButtonProps = BaseSignInButtonProps; /** - * SignInButton component that supports both render props and traditional props patterns for Next.js. + * SignInButton component that uses server actions for authentication in Next.js. * * @example Using render props * ```tsx @@ -47,16 +49,58 @@ export type SignInButtonProps = BaseSignInButtonProps; * ``` * * @remarks - * In Next.js with server actions, the sign-in is handled via form submission. + * In Next.js with server actions, the sign-in is handled via the server action. * When using render props, the custom button should use `type="submit"` instead of `onClick={signIn}`. * The `signIn` function in render props is provided for API consistency but should not be used directly. */ const SignInButton = forwardRef( - ({className, style, ...rest}: SignInButtonProps, ref: Ref): ReactElement => { + ( + {className, style, children, preferences, onClick, ...rest}: SignInButtonProps, + ref: Ref, + ): ReactElement => { + const {signIn, signInUrl} = useAsgardeo(); + const router = useRouter(); + const {t} = useTranslation(preferences?.i18n); + + const [isLoading, setIsLoading] = useState(false); + + const handleOnClick = async (e: MouseEvent): Promise => { + try { + setIsLoading(true); + + // If a custom `signInUrl` is provided, use it for navigation. + if (signInUrl) { + router.push(signInUrl); + } else { + await signIn(); + } + + if (onClick) { + onClick(e); + } + } catch (error) { + throw new AsgardeoRuntimeError( + `Sign in failed: ${error instanceof Error ? error.message : String(error)}`, + 'SignInButton-handleSignIn-RuntimeError-001', + 'next', + 'Something went wrong while trying to sign in. Please try again later.', + ); + } finally { + setIsLoading(false); + } + }; + return ( -
- - + + {children ?? t('elements.buttons.signIn')} + ); }, ); diff --git a/packages/nextjs/src/client/components/actions/SignOutButton/SignOutButton.tsx b/packages/nextjs/src/client/components/actions/SignOutButton/SignOutButton.tsx index 5954c0f2..7d85d835 100644 --- a/packages/nextjs/src/client/components/actions/SignOutButton/SignOutButton.tsx +++ b/packages/nextjs/src/client/components/actions/SignOutButton/SignOutButton.tsx @@ -18,9 +18,10 @@ 'use client'; -import {FC, forwardRef, PropsWithChildren, ReactElement, Ref} from 'react'; -import InternalAuthAPIRoutesConfig from '../../../../configs/InternalAuthAPIRoutesConfig'; -import {BaseSignOutButton, BaseSignOutButtonProps} from '@asgardeo/react'; +import {FC, forwardRef, PropsWithChildren, ReactElement, Ref, useState, MouseEvent} from 'react'; +import {BaseSignOutButton, BaseSignOutButtonProps, useTranslation} from '@asgardeo/react'; +import {AsgardeoRuntimeError} from '@asgardeo/node'; +import useAsgardeo from '../../../../client/contexts/Asgardeo/useAsgardeo'; /** * Interface for SignInButton component props. @@ -44,15 +45,45 @@ export type SignOutButtonProps = BaseSignOutButtonProps; * } * ``` */ -const SignOutButton: FC> = forwardRef< - HTMLButtonElement, - PropsWithChildren ->( - ({className, style, ...rest}: PropsWithChildren, ref: Ref): ReactElement => ( -
- - - ), +const SignOutButton = forwardRef( + ({className, style, preferences, onClick, children, ...rest}: SignOutButtonProps, ref: Ref): ReactElement => { + const {signOut} = useAsgardeo(); + const {t} = useTranslation(preferences?.i18n); + + const [isLoading, setIsLoading] = useState(false); + + const handleOnClick = async (e: MouseEvent): Promise => { + try { + setIsLoading(true); + await signOut(); + + if (onClick) { + onClick(e); + } + } catch (error) { + throw new AsgardeoRuntimeError( + `Sign out failed: ${error instanceof Error ? error.message : String(error)}`, + 'SignOutButton-handleOnClick-RuntimeError-001', + 'next', + 'Something went wrong while trying to sign out. Please try again later.', + ); + } finally { + setIsLoading(false); + } + }; + + return ( + + {children ?? t('elements.buttons.signOut')} + + ); + }, ); export default SignOutButton; diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts index ba25b8df..807c287c 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts @@ -25,18 +25,23 @@ import {Context, createContext} from 'react'; /** * Props interface of {@link AsgardeoContext} */ -export type AsgardeoContextProps = Partial & { - user?: User | null; - isSignedIn?: boolean; - isLoading?: boolean; - signIn?: (payload: EmbeddedSignInFlowHandleRequestPayload, request: EmbeddedFlowExecuteRequestConfig) => void; - signOut?: () => void; -}; +export type AsgardeoContextProps = Partial; /** * Context object for managing the Authentication flow builder core context. */ -const AsgardeoContext: Context = createContext({}); +const AsgardeoContext: Context = createContext({ + signInUrl: undefined, + afterSignInUrl: undefined, + baseUrl: undefined, + isInitialized: false, + isLoading: true, + isSignedIn: false, + signIn: null, + signOut: null, + signUp: null, + user: null, +}); AsgardeoContext.displayName = 'AsgardeoContext'; diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index 950ff345..fb90dd94 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -19,25 +19,26 @@ 'use client'; import {EmbeddedFlowExecuteRequestConfig, EmbeddedSignInFlowHandleRequestPayload, User} from '@asgardeo/node'; -import {I18nProvider, FlowProvider, UserProvider, ThemeProvider} from '@asgardeo/react'; +import {I18nProvider, FlowProvider, UserProvider, ThemeProvider, AsgardeoProviderProps} from '@asgardeo/react'; import {FC, PropsWithChildren, useEffect, useMemo, useState} from 'react'; import {useRouter} from 'next/navigation'; -import AsgardeoContext from './AsgardeoContext'; -import InternalAuthAPIRoutesConfig from '../../../configs/InternalAuthAPIRoutesConfig'; +import AsgardeoContext, {AsgardeoContextProps} from './AsgardeoContext'; +import {getIsSignedInAction, getUserAction} from '../../../server/actions/authActions'; /** * Props interface of {@link AsgardeoClientProvider} */ -export type AsgardeoClientProviderProps = { - /** - * Preferences for theming, i18n, and other UI customizations. - */ - preferences?: any; +export type AsgardeoClientProviderProps = Partial> & Pick & { + signOut: AsgardeoContextProps['signOut']; + signIn: AsgardeoContextProps['signIn']; }; const AsgardeoClientProvider: FC> = ({ children, + signIn, + signOut, preferences, + signInUrl, }: PropsWithChildren) => { const router = useRouter(); const [isDarkMode, setIsDarkMode] = useState(false); @@ -58,16 +59,15 @@ const AsgardeoClientProvider: FC> try { setIsLoading(true); - const sessionResponse = await fetch(InternalAuthAPIRoutesConfig.session); - const sessionData = await sessionResponse.json(); - setIsSignedIn(sessionData.isSignedIn); + const sessionResult = await getIsSignedInAction(); - if (sessionData.isSignedIn) { - const userResponse = await fetch(InternalAuthAPIRoutesConfig.user); + setIsSignedIn(sessionResult.isSignedIn); - if (userResponse.ok) { - const userData = await userResponse.json(); - setUser(userData); + if (sessionResult.isSignedIn) { + const userResult = await getUserAction(); + + if (userResult.user) { + setUser(userResult.user); } } else { setUser(null); @@ -83,21 +83,45 @@ const AsgardeoClientProvider: FC> fetchUserData(); }, []); - const signIn = async (payload: EmbeddedSignInFlowHandleRequestPayload, request: EmbeddedFlowExecuteRequestConfig) => { - const response = await fetch(InternalAuthAPIRoutesConfig.signIn, { - method: 'POST', - body: JSON.stringify({ - payload, - request, - }), - }); - - if (response.redirected && response.url) { - router.push(response.url!); - return {redirected: true, location: response.url}; + const handleSignIn = async ( + payload: EmbeddedSignInFlowHandleRequestPayload, + request: EmbeddedFlowExecuteRequestConfig, + ) => { + try { + const result = await signIn(payload, request); + + if (result?.afterSignInUrl) { + router.push(result.afterSignInUrl); + return {redirected: true, location: result.afterSignInUrl}; + } + + if (result?.error) { + throw new Error(result.error); + } + + return result; + } catch (error) { + throw error; } + }; - return response.json(); + const handleSignOut = async () => { + try { + const result = await signOut(); + + if (result?.afterSignOutUrl) { + router.push(result.afterSignOutUrl); + return {redirected: true, location: result.afterSignOutUrl}; + } + + if (result?.error) { + throw new Error(result.error); + } + + return result; + } catch (error) { + throw error; + } }; const contextValue = useMemo( @@ -105,8 +129,9 @@ const AsgardeoClientProvider: FC> user, isSignedIn, isLoading, - signIn, - signOut: () => (window.location.href = InternalAuthAPIRoutesConfig.signOut), + signIn: handleSignIn, + signOut: handleSignOut, + signInUrl, }), [user, isSignedIn, isLoading], ); diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 4cbfbb73..056d69ed 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -19,6 +19,9 @@ export {default as AsgardeoProvider} from './server/AsgardeoProvider'; export * from './server/AsgardeoProvider'; +export {default as useAsgardeo} from './client/contexts/Asgardeo/useAsgardeo'; +export * from './client/contexts/Asgardeo/useAsgardeo'; + export {default as isSignedIn} from './server/actions/isSignedIn'; export {default as SignedIn} from './client/components/control/SignedIn/SignedIn'; @@ -40,3 +43,5 @@ export {default as User} from './client/components/presentation/User/User'; export type {UserProps} from './client/components/presentation/User/User'; export {default as AsgardeoNext} from './AsgardeoNextClient'; + +export {default as asgardeoMiddleware} from './middleware/asgardeoMiddleware'; diff --git a/packages/nextjs/src/middleware/asgardeoMiddleware.ts b/packages/nextjs/src/middleware/asgardeoMiddleware.ts new file mode 100644 index 00000000..abcbd3fd --- /dev/null +++ b/packages/nextjs/src/middleware/asgardeoMiddleware.ts @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2025, 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. + */ + +import {NextRequest, NextResponse} from 'next/server'; +import AsgardeoNextClient from '../AsgardeoNextClient'; +import {AsgardeoNextConfig} from '../models/config'; + +export interface AsgardeoMiddlewareOptions extends Partial { + debug?: boolean; +} + +type AsgardeoAuth = { + protect: (options?: {redirect?: string}) => Promise; + isSignedIn: () => Promise; + getUser: () => Promise; + redirectToSignIn: (afterSignInUrl?: string) => NextResponse; +}; + +type AsgardeoMiddlewareHandler = ( + auth: AsgardeoAuth, + req: NextRequest, +) => Promise | NextResponse | void; + +/** + * Asgardeo middleware that integrates authentication into your Next.js application. + * Similar to Clerk's clerkMiddleware pattern. + * + * @param handler - Optional handler function to customize middleware behavior + * @param options - Configuration options for the middleware + * @returns Next.js middleware function + * + * @example + * ```typescript + * // middleware.ts + * import { asgardeoMiddleware } from '@asgardeo/nextjs'; + * + * export default asgardeoMiddleware(); + * ``` + * + * @example + * ```typescript + * // With protection + * import { asgardeoMiddleware, createRouteMatcher } from '@asgardeo/nextjs'; + * + * const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']); + * + * export default asgardeoMiddleware(async (auth, req) => { + * if (isProtectedRoute(req)) { + * await auth.protect(); + * } + * }); + * ``` + */ +const asgardeoMiddleware = ( + handler?: AsgardeoMiddlewareHandler, + options?: AsgardeoMiddlewareOptions | ((req: NextRequest) => AsgardeoMiddlewareOptions), +): ((request: NextRequest) => Promise) => { + return async (request: NextRequest): Promise => { + // Resolve options - can be static or dynamic based on request + const resolvedOptions = typeof options === 'function' ? options(request) : options || {}; + + const asgardeoClient = AsgardeoNextClient.getInstance(); + + // // Initialize client if not already done + // if (!asgardeoClient.isInitialized && resolvedOptions) { + // asgardeoClient.initialize(resolvedOptions); + // } + + // // Debug logging + // if (resolvedOptions.debug) { + // console.log(`[Asgardeo Middleware] Processing request: ${request.nextUrl.pathname}`); + // } + + // // Handle auth API routes automatically + // if (request.nextUrl.pathname.startsWith('/api/auth/asgardeo')) { + // if (resolvedOptions.debug) { + // console.log(`[Asgardeo Middleware] Handling auth route: ${request.nextUrl.pathname}`); + // } + // return await asgardeoClient.handleAuthRequest(request); + // } + + // // Create auth object for the handler + // const auth: AsgardeoAuth = { + // protect: async (options?: {redirect?: string}) => { + // const isSignedIn = await asgardeoClient.isSignedIn(request); + // if (!isSignedIn) { + // const afterSignInUrl = options?.redirect || '/api/auth/asgardeo/signin'; + // return NextResponse.redirect(new URL(afterSignInUrl, request.url)); + // } + // }, + + // isSignedIn: async () => { + // return await asgardeoClient.isSignedIn(request); + // }, + + // getUser: async () => { + // return await asgardeoClient.getUser(request); + // }, + + // redirectToSignIn: (afterSignInUrl?: string) => { + // const signInUrl = afterSignInUrl || '/api/auth/asgardeo/signin'; + // return NextResponse.redirect(new URL(signInUrl, request.url)); + // }, + // }; + + // // Execute user-provided handler if present + // let handlerResponse: NextResponse | void; + // if (handler) { + // handlerResponse = await handler(auth, request); + // } + + // // If handler returned a response, use it + // if (handlerResponse) { + // return handlerResponse; + // } + + // // Otherwise, continue with default behavior + // const response = NextResponse.next(); + + // // Add authentication context to response headers + // const isSignedIn = await asgardeoClient.isSignedIn(request); + // if (isSignedIn) { + // response.headers.set('x-asgardeo-authenticated', 'true'); + // const user = await asgardeoClient.getUser(request); + // if (user?.sub) { + // response.headers.set('x-asgardeo-user-id', user.sub); + // } + // } + + // return response; + + return NextResponse.next(); + }; +}; + +export default asgardeoMiddleware; diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index 358d2b98..4e3ff230 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -17,32 +17,78 @@ */ import {FC, PropsWithChildren, ReactElement} from 'react'; +import {AsgardeoRuntimeError} from '@asgardeo/node'; import AsgardeoClientProvider, {AsgardeoClientProviderProps} from '../client/contexts/Asgardeo/AsgardeoProvider'; +import AsgardeoNextClient from '../AsgardeoNextClient'; +import {AsgardeoNextConfig} from '../models/config'; +import {signInAction, getUserAction, getIsSignedInAction} from './actions/authActions'; +import gerClientOrigin from './actions/gerClientOrigin'; +import signOutAction from './actions/signOutAction'; /** * Props interface of {@link AsgardeoServerProvider} */ -export type AsgardeoServerProviderProps = AsgardeoClientProviderProps; +export type AsgardeoServerProviderProps = AsgardeoClientProviderProps & { + clientSecret?: string; +}; /** * Server-side provider component for Asgardeo authentication. * Wraps the client-side provider and handles server-side authentication logic. + * Uses the singleton AsgardeoNextClient instance for consistent authentication state. * * @param props - Props injected into the component. * * @example * ```tsx - * + * * * * ``` * * @returns AsgardeoServerProvider component. */ -const AsgardeoServerProvider: FC> = ({ +const AsgardeoServerProvider: FC> = async ({ children, -}: PropsWithChildren): ReactElement => ( - {children} -); + afterSignInUrl, + afterSignOutUrl, + ...config +}: PropsWithChildren): Promise => { + const asgardeoClient = AsgardeoNextClient.getInstance(); + console.log('Initializing Asgardeo client with config:', config); + + const origin = await gerClientOrigin(); + + try { + asgardeoClient.initialize({ + afterSignInUrl: afterSignInUrl ?? origin, + afterSignOutUrl: afterSignOutUrl ?? origin, + ...config, + }); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to initialize Asgardeo client: ${error?.toString()}`, + 'next-ConfigurationError-001', + 'next', + 'An error occurred while initializing the Asgardeo client. Please check your configuration.', + ); + } + + const configuration = await asgardeoClient.getConfiguration(); + console.log('Asgardeo client initialized with configuration:', configuration); + + return ( + + {children} + + ); +}; export default AsgardeoServerProvider; diff --git a/packages/nextjs/src/server/actions/authActions.ts b/packages/nextjs/src/server/actions/authActions.ts new file mode 100644 index 00000000..9e969122 --- /dev/null +++ b/packages/nextjs/src/server/actions/authActions.ts @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2025, 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 server'; + +import {redirect} from 'next/navigation'; +import {cookies} from 'next/headers'; +import { + CookieConfig, + generateSessionId, + EmbeddedSignInFlowStatus, + EmbeddedSignInFlowHandleRequestPayload, + EmbeddedFlowExecuteRequestConfig, +} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; +import deleteSessionId from './deleteSessionId'; + +/** + * Server action for signing in a user. + * Handles the embedded sign-in flow and manages session cookies. + * + * @param payload - The embedded sign-in flow payload + * @param request - The embedded flow execute request config + * @returns Promise that resolves when sign-in is complete + */ +export async function signInAction( + payload?: EmbeddedSignInFlowHandleRequestPayload, + request?: EmbeddedFlowExecuteRequestConfig, +): Promise<{success: boolean; afterSignInUrl?: string; error?: string}> { + console.log('[AsgardeoNextClient] signInAction called with payload:', payload); + try { + const client = AsgardeoNextClient.getInstance(); + const cookieStore = await cookies(); + + // Get or generate session ID + let userId: string | undefined = cookieStore.get(CookieConfig.SESSION_COOKIE_NAME)?.value; + + if (!userId) { + userId = generateSessionId(); + cookieStore.set(CookieConfig.SESSION_COOKIE_NAME, userId, { + httpOnly: CookieConfig.DEFAULT_HTTP_ONLY, + maxAge: CookieConfig.DEFAULT_MAX_AGE, + sameSite: CookieConfig.DEFAULT_SAME_SITE, + secure: CookieConfig.DEFAULT_SECURE, + }); + } + + // If no payload provided, redirect to sign-in URL + if (!payload) { + const afterSignInUrl = await client.getSignInUrl(userId); + + return {success: true, afterSignInUrl: String(afterSignInUrl)}; + } else { + // Handle embedded sign-in flow + const response: any = await client.signIn(payload, request!, userId); + + if (response.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { + // Complete the sign-in process + await client.signIn( + { + code: response?.authData?.code, + session_state: response?.authData?.session_state, + state: response?.authData?.state, + } as any, + {}, + userId, + ); + + const afterSignInUrl = await (await client.getStorageManager()).getConfigDataParameter('afterSignInUrl'); + + return {success: true, afterSignInUrl: String(afterSignInUrl)}; + } + + return {success: true}; + } + } catch (error) { + return {success: false, error: 'Sign-in failed'}; + } +} + +/** + * Server action to get the current user. + * Returns the user profile if signed in. + */ +export async function getUserAction() { + try { + const client = AsgardeoNextClient.getInstance(); + const user = await client.getUser(); + return {user, error: null}; + } catch (error) { + console.error('[AsgardeoNextClient] Failed to get user:', error); + return {user: null, error: 'Failed to get user'}; + } +} + +/** + * Server action to check if user is signed in. + */ +export async function getIsSignedInAction() { + try { + const cookieStore = await cookies(); + const sessionId = cookieStore.get(CookieConfig.SESSION_COOKIE_NAME)?.value; + return {isSignedIn: !!sessionId, error: null}; + } catch (error) { + console.error('[AsgardeoNextClient] Failed to check session:', error); + return {isSignedIn: false, error: 'Failed to check session'}; + } +} diff --git a/packages/nextjs/src/server/actions/gerClientOrigin.ts b/packages/nextjs/src/server/actions/gerClientOrigin.ts new file mode 100644 index 00000000..b3f8e74e --- /dev/null +++ b/packages/nextjs/src/server/actions/gerClientOrigin.ts @@ -0,0 +1,12 @@ +'use server'; + +import {headers} from 'next/headers'; + +const gerClientOrigin = async () => { + const headersList = await headers(); + const host = headersList.get('host'); + const protocol = headersList.get('x-forwarded-proto') ?? 'http'; + return `${protocol}://${host}`; +}; + +export default gerClientOrigin; diff --git a/packages/nextjs/src/server/actions/handleGetSignIn.ts b/packages/nextjs/src/server/actions/handleGetSignIn.ts new file mode 100644 index 00000000..bd81aa9f --- /dev/null +++ b/packages/nextjs/src/server/actions/handleGetSignIn.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025, 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. + */ + +import {NextRequest, NextResponse} from 'next/server'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Handles GET sign-in requests and OAuth callbacks. + * + * @param req - The Next.js request object + * @returns NextResponse with appropriate redirect or continuation + */ +export async function handleGetSignIn(req: NextRequest): Promise { + try { + const client = AsgardeoNextClient.getInstance(); + const {searchParams} = req.nextUrl; + + if (searchParams.get('code')) { + // Handle OAuth callback + await client.signIn(); + + const cleanUrl: URL = new URL(req.url); + cleanUrl.searchParams.delete('code'); + cleanUrl.searchParams.delete('state'); + cleanUrl.searchParams.delete('session_state'); + + return NextResponse.redirect(cleanUrl.toString()); + } + + // Regular GET sign-in request + await client.signIn(); + return NextResponse.next(); + } catch (error) { + console.error('[AsgardeoNextClient] Sign-in failed:', error); + return NextResponse.json({error: 'Sign-in failed'}, {status: 500}); + } +} + +export default handleGetSignIn; diff --git a/packages/nextjs/src/server/actions/handlePostSignIn.ts b/packages/nextjs/src/server/actions/handlePostSignIn.ts new file mode 100644 index 00000000..bc722f08 --- /dev/null +++ b/packages/nextjs/src/server/actions/handlePostSignIn.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2025, 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. + */ + +import {NextRequest, NextResponse} from 'next/server'; +import {CookieConfig, generateSessionId, EmbeddedSignInFlowStatus} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; +import deleteSessionId from './deleteSessionId'; + +/** + * Handles POST sign-in requests for embedded sign-in flow. + * + * @param req - The Next.js request object + * @returns NextResponse with sign-in result or redirect + */ +export async function handlePostSignIn(req: NextRequest): Promise { + try { + const client = AsgardeoNextClient.getInstance(); + + // Get session ID from cookies directly since we're in middleware context + let userId: string | undefined = req.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value; + + // Generate session ID if not present + if (!userId) { + userId = generateSessionId(); + } + + const signInUrl: URL = new URL(await client.getSignInUrlWithConfig({response_mode: 'direct'}, userId)); + const {pathname: urlPathname, origin, searchParams: urlSearchParams} = signInUrl; + + console.log('[AsgardeoNextClient] Sign-in URL:', signInUrl.toString()); + console.log('[AsgardeoNextClient] Search Params:', Object.fromEntries(urlSearchParams.entries())); + + const body = await req.json(); + console.log('[AsgardeoNextClient] Sign-in request:', body); + + const {payload, request} = body; + + const response: any = await client.signIn( + payload, + { + url: request?.url ?? `${origin}${urlPathname}`, + payload: request?.payload ?? Object.fromEntries(urlSearchParams.entries()), + }, + userId, + ); + + // Clean the response to remove any non-serializable properties + const cleanResponse = response ? JSON.parse(JSON.stringify(response)) : {success: true}; + + // Create response with session cookie + const nextResponse = NextResponse.json(cleanResponse); + + // Set session cookie if it was generated + if (!req.cookies.get(CookieConfig.SESSION_COOKIE_NAME)) { + nextResponse.cookies.set(CookieConfig.SESSION_COOKIE_NAME, userId, { + httpOnly: CookieConfig.DEFAULT_HTTP_ONLY, + maxAge: CookieConfig.DEFAULT_MAX_AGE, + sameSite: CookieConfig.DEFAULT_SAME_SITE, + secure: CookieConfig.DEFAULT_SECURE, + }); + } + + if (response.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { + const res = await client.signIn( + { + code: response?.authData?.code, + session_state: response?.authData?.session_state, + state: response?.authData?.state, + } as any, + {}, + userId, + (afterSignInUrl: string) => null, + ); + + const afterSignInUrl: string = await (await client.getStorageManager()).getConfigDataParameter('afterSignInUrl'); + + return NextResponse.redirect(afterSignInUrl, 303); + } + + return nextResponse; + } catch (error) { + console.error('[AsgardeoNextClient] Failed to initialize embedded sign-in flow:', error); + return NextResponse.json({error: 'Failed to initialize sign-in flow'}, {status: 500}); + } +} + +export default handlePostSignIn; diff --git a/packages/nextjs/src/server/actions/handleSessionRequest.ts b/packages/nextjs/src/server/actions/handleSessionRequest.ts new file mode 100644 index 00000000..16470880 --- /dev/null +++ b/packages/nextjs/src/server/actions/handleSessionRequest.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025, 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. + */ + +import {NextRequest, NextResponse} from 'next/server'; +import getIsSignedIn from './isSignedIn'; + +/** + * Handles session status requests. + * + * @param req - The Next.js request object + * @returns NextResponse with session status + */ +export async function handleSessionRequest(req: NextRequest): Promise { + try { + const isSignedIn: boolean = await getIsSignedIn(); + + return NextResponse.json({isSignedIn}); + } catch (error) { + return NextResponse.json({error: 'Failed to check session'}, {status: 500}); + } +} + +export default handleSessionRequest; diff --git a/packages/nextjs/src/server/actions/handleUserRequest.ts b/packages/nextjs/src/server/actions/handleUserRequest.ts new file mode 100644 index 00000000..82e08055 --- /dev/null +++ b/packages/nextjs/src/server/actions/handleUserRequest.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025, 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. + */ + +import {NextRequest, NextResponse} from 'next/server'; +import {User} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Handles user profile requests. + * + * @param req - The Next.js request object + * @returns NextResponse with user profile data + */ +export async function handleUserRequest(req: NextRequest): Promise { + try { + const client = AsgardeoNextClient.getInstance(); + const user: User = await client.getUser(); + + console.log('[AsgardeoNextClient] User fetched successfully:', user); + + return NextResponse.json({user}); + } catch (error) { + console.error('[AsgardeoNextClient] Failed to get user:', error); + return NextResponse.json({error: 'Failed to get user'}, {status: 500}); + } +} + +export default handleUserRequest; diff --git a/packages/nextjs/src/server/actions/signOutAction.ts b/packages/nextjs/src/server/actions/signOutAction.ts new file mode 100644 index 00000000..5aaa73ee --- /dev/null +++ b/packages/nextjs/src/server/actions/signOutAction.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025, 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 server'; + +import {NextRequest, NextResponse} from 'next/server'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; +import deleteSessionId from './deleteSessionId'; + +const signOutAction = async (): Promise<{success: boolean; afterSignOutUrl?: string; error?: unknown}> => { + try { + const client = AsgardeoNextClient.getInstance(); + const afterSignOutUrl: string = await client.signOut(); + + await deleteSessionId(); + + return {success: true, afterSignOutUrl}; + } catch (error) { + return {success: false, error}; + } +}; + +export default signOutAction; diff --git a/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts b/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts index 1eeff153..7c162519 100644 --- a/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts +++ b/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts @@ -19,13 +19,17 @@ import {AsgardeoNextConfig} from '../models/config'; const decorateConfigWithNextEnv = (config: AsgardeoNextConfig): AsgardeoNextConfig => { - const {baseUrl, clientId, clientSecret, ...rest} = config; + const {baseUrl, clientId, clientSecret, signInUrl, signUpUrl, afterSignInUrl, afterSignOutUrl, ...rest} = config; return { ...rest, baseUrl: baseUrl || (process.env['NEXT_PUBLIC_ASGARDEO_BASE_URL'] as string), clientId: clientId || (process.env['NEXT_PUBLIC_ASGARDEO_CLIENT_ID'] as string), clientSecret: clientSecret || (process.env['ASGARDEO_CLIENT_SECRET'] as string), + afterSignInUrl: afterSignInUrl || (process.env['NEXT_PUBLIC_ASGARDEO_AFTER_SIGN_IN_URL'] as string), + signInUrl: signInUrl || (process.env['NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL'] as string), + afterSignOutUrl: afterSignOutUrl || (process.env['NEXT_PUBLIC_ASGARDEO_AFTER_SIGN_OUT_URL'] as string), + signUpUrl: signUpUrl || (process.env['NEXT_PUBLIC_ASGARDEO_SIGN_UP_URL'] as string), }; }; diff --git a/packages/node/src/AsgardeoNodeClient.ts b/packages/node/src/AsgardeoNodeClient.ts index 470eefb3..ec6d6583 100644 --- a/packages/node/src/AsgardeoNodeClient.ts +++ b/packages/node/src/AsgardeoNodeClient.ts @@ -23,7 +23,7 @@ import {SignOutOptions} from '@asgardeo/javascript/dist/models/client'; /** * Base class for implementing Asgardeo in Node.js based applications. * This class provides the core functionality for managing user authentication and sessions. - * + *getConfigData * @typeParam T - Configuration type that extends AsgardeoNodeConfig. */ abstract class AsgardeoNodeClient extends AsgardeoJavaScriptClient {} diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 5e8e60be..b20393e6 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -38,7 +38,7 @@ import { } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; import getMeOrganizations from './api/scim2/getMeOrganizations'; -import getMeProfile from './api/scim2/getMeProfile'; +import getScim2Me from './api/getScim2Me'; import getSchemas from './api/scim2/getSchemas'; import {AsgardeoReactConfig} from './models/config'; @@ -67,7 +67,7 @@ class AsgardeoReactClient e const configData = await this.asgardeo.getConfigData(); const baseUrl = configData?.baseUrl; - const profile = await getMeProfile({url: `${baseUrl}/scim2/Me`}); + const profile = await getScim2Me({baseUrl}); const schemas = await getSchemas({url: `${baseUrl}/scim2/Schemas`}); return generateUserProfile(profile, flattenUserSchema(schemas)); @@ -81,7 +81,7 @@ class AsgardeoReactClient e const configData = await this.asgardeo.getConfigData(); const baseUrl = configData?.baseUrl; - const profile = await getMeProfile({url: `${baseUrl}/scim2/Me`}); + const profile = await getScim2Me({baseUrl}); const schemas = await getSchemas({url: `${baseUrl}/scim2/Schemas`}); const processedSchemas = flattenUserSchema(schemas); @@ -185,6 +185,10 @@ class AsgardeoReactClient e return this.asgardeo.isSignedIn(); } + override getConfiguration(): T { + return this.asgardeo.getConfigData() as unknown as T; + } + override signIn( options?: SignInOptions, sessionId?: string, @@ -210,11 +214,11 @@ class AsgardeoReactClient e return this.asgardeo.signIn(arg1 as any) as unknown as Promise; } - override signOut(options?: SignOutOptions, afterSignOut?: (redirectUrl: string) => void): Promise; + override signOut(options?: SignOutOptions, afterSignOut?: (afterSignOutUrl: string) => void): Promise; override signOut( options?: SignOutOptions, sessionId?: string, - afterSignOut?: (redirectUrl: string) => void, + afterSignOut?: (afterSignOutUrl: string) => void, ): Promise; override async signOut(...args: any[]): Promise { if (args[1] && typeof args[1] !== 'function') { diff --git a/packages/react/src/api/getScim2Me.ts b/packages/react/src/api/getScim2Me.ts new file mode 100644 index 00000000..d5c5bb4c --- /dev/null +++ b/packages/react/src/api/getScim2Me.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2025, 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. + */ + +import { + User, + AsgardeoAPIError, + HttpInstance, + AsgardeoSPAClient, + HttpRequestConfig, + getScim2Me as baseGetScim2Me, + GetScim2MeConfig as BaseGetScim2MeConfig +} from '@asgardeo/browser'; + +const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Configuration for the getScim2Me request (React-specific) + */ +export interface GetScim2MeConfig extends Omit { + /** + * Optional custom fetcher function. If not provided, the Asgardeo SPA client's httpClient will be used + * which is a wrapper around axios http.request + */ + fetcher?: (url: string, config: RequestInit) => Promise; +} + +/** + * Retrieves the user profile information from the specified SCIM2 /Me endpoint. + * This function uses the Asgardeo SPA client's httpClient by default, but allows for custom fetchers. + * + * @param requestConfig - Request configuration object. + * @returns A promise that resolves with the user profile information. + * @example + * ```typescript + * // Using default Asgardeo SPA client httpClient + * try { + * const userProfile = await getScim2Me({ + * url: "https://api.asgardeo.io/t//scim2/Me", + * }); + * console.log(userProfile); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get user profile:', error.message); + * } + * } + * ``` + * + * @example + * ```typescript + * // Using custom fetcher + * try { + * const userProfile = await getScim2Me({ + * url: "https://api.asgardeo.io/t//scim2/Me", + * fetcher: customFetchFunction + * }); + * console.log(userProfile); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get user profile:', error.message); + * } + * } + * ``` + */ +const getScim2Me = async ({fetcher, ...requestConfig}: GetScim2MeConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const response = await httpClient({ + url, + method: config.method || 'GET', + headers: config.headers as Record, + } as HttpRequestConfig); + + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText || '', + json: () => Promise.resolve(response.data), + text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)), + } as Response; + }; + + return baseGetScim2Me({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default getScim2Me; diff --git a/packages/react/src/api/scim2/getMeProfile.ts b/packages/react/src/api/scim2/getMeProfile.ts index a0620ee3..e69de29b 100644 --- a/packages/react/src/api/scim2/getMeProfile.ts +++ b/packages/react/src/api/scim2/getMeProfile.ts @@ -1,79 +0,0 @@ -/** - * Copyright (c) 2025, 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. - */ - -import {User, AsgardeoAPIError, HttpInstance, AsgardeoSPAClient, HttpRequestConfig} from '@asgardeo/browser'; - -const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); - -/** - * Retrieves the user profile information from the specified selfcare profile endpoint. - * - * @param requestConfig - Request configuration object. - * @returns A promise that resolves with the user profile information. - * @example - * ```typescript - * try { - * const userProfile = await getUserProfile({ - * url: "https://api.asgardeo.io/t//scim2/Me", - * }); - * console.log(userProfile); - * } catch (error) { - * if (error instanceof AsgardeoAPIError) { - * console.error('Failed to get user profile:', error.message); - * } - * } - * ``` - */ -const getMeProfile = async ({url, ...requestConfig}: Partial): Promise => { - try { - new URL(url); - } catch (error) { - throw new AsgardeoAPIError( - 'Invalid endpoint URL provided', - 'getMeProfile-ValidationError-001', - 'javascript', - 400, - 'Invalid Request', - ); - } - - const response: any = await httpClient({ - url, - method: 'GET', - headers: { - 'Content-Type': 'application/scim+json', - Accept: 'application/json', - }, - } as HttpRequestConfig); - - if (!response.data) { - const errorText = await response.text(); - - throw new AsgardeoAPIError( - `Failed to fetch user profile: ${errorText}`, - 'getMeProfile-ResponseError-001', - 'javascript', - response.status, - response.statusText, - ); - } - - return response.data; -}; - -export default getMeProfile; diff --git a/packages/react/src/components/actions/SignInButton/SignInButton.tsx b/packages/react/src/components/actions/SignInButton/SignInButton.tsx index cb3268cc..dcd7b5e9 100644 --- a/packages/react/src/components/actions/SignInButton/SignInButton.tsx +++ b/packages/react/src/components/actions/SignInButton/SignInButton.tsx @@ -71,7 +71,7 @@ const SignInButton: ForwardRefExoticComponent(({children, onClick, preferences, ...rest}: SignInButtonProps, ref: Ref): ReactElement => { - const {signIn} = useAsgardeo(); + const {signIn, signInUrl} = useAsgardeo(); const {t} = useTranslation(preferences?.i18n); const [isLoading, setIsLoading] = useState(false); @@ -80,7 +80,14 @@ const SignInButton: ForwardRefExoticComponent): Promise => { try { setIsLoading(true); + await signOut(); if (onClick) { @@ -87,7 +88,7 @@ const SignOutButton: ForwardRefExoticComponent): Promise => { try { setIsLoading(true); + await signUp(); if (onClick) { @@ -88,7 +89,7 @@ const SignUpButton: ForwardRefExoticComponent> = ({ return <>{children}; }; -AsgardeoLoading.displayName = 'Loading'; +AsgardeoLoading.displayName = 'AsgardeoLoading'; export default AsgardeoLoading; diff --git a/packages/react/src/components/control/SignedIn.tsx b/packages/react/src/components/control/SignedIn.tsx index 298e7725..76c9502c 100644 --- a/packages/react/src/components/control/SignedIn.tsx +++ b/packages/react/src/components/control/SignedIn.tsx @@ -32,6 +32,8 @@ export interface SignedInProps { /** * A component that only renders its children when the user is signed in. * + * @remarks This component is only supported in browser based React applications (CSR). + * * @example * ```tsx * import { SignedIn } from '@asgardeo/auth-react'; diff --git a/packages/react/src/components/control/SignedOut.tsx b/packages/react/src/components/control/SignedOut.tsx index 61a9a14c..af89c52b 100644 --- a/packages/react/src/components/control/SignedOut.tsx +++ b/packages/react/src/components/control/SignedOut.tsx @@ -32,6 +32,8 @@ export interface SignedOutProps { /** * A component that only renders its children when the user is signed out. * + * @remarks This component is only supported in browser based React applications (CSR). + * * @example * ```tsx * import { SignedOut } from '@asgardeo/auth-react'; diff --git a/packages/react/src/components/factories/FieldFactory.tsx b/packages/react/src/components/factories/FieldFactory.tsx index bd005583..0bf2c90d 100644 --- a/packages/react/src/components/factories/FieldFactory.tsx +++ b/packages/react/src/components/factories/FieldFactory.tsx @@ -30,9 +30,12 @@ import {FieldType} from '@asgardeo/browser'; * Interface for field configuration. */ export interface FieldConfig { + /** + * The name of the field. + */ name: string; /** - * The field type based on EmbeddedSignInFlowAuthenticatorParamType. + * The field type. */ type: FieldType; /** @@ -77,24 +80,6 @@ export interface FieldConfig { placeholder?: string; } -/** - * Utility function to parse multi-valued string into array - */ -export const parseMultiValuedString = (value: string): string[] => { - if (!value || value.trim() === '') return []; - return value - .split(',') - .map(item => item.trim()) - .filter(item => item.length > 0); -}; - -/** - * Utility function to format array into multi-valued string - */ -export const formatMultiValuedString = (values: string[]): string => { - return values.join(', '); -}; - /** * Utility function to validate field values based on type */ @@ -104,12 +89,10 @@ export const validateFieldValue = ( required: boolean = false, touched: boolean = false, ): string | null => { - // Only show required field errors if the field has been touched if (required && touched && (!value || value.trim() === '')) { return 'This field is required'; } - // If not required and empty, no validation needed if (!value || value.trim() === '') { return null; } @@ -161,7 +144,6 @@ export const createField = (config: FieldConfig): ReactElement => { placeholder, } = config; - // Auto-validate the field value const validationError = error || validateFieldValue(value, type, required, touched); const commonProps = { @@ -237,7 +219,7 @@ export const createField = (config: FieldConfig): ReactElement => { /** * React component wrapper for the field factory. */ -export const FieldFactory: FC = props => { +export const FieldFactory: FC = (props: FieldConfig): ReactElement => { return createField(props); }; diff --git a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx index 2884cf52..1dfce0d7 100644 --- a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx +++ b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx @@ -19,11 +19,9 @@ import {withVendorCSSClassPrefix} from '@asgardeo/browser'; import clsx from 'clsx'; import {ChangeEvent, CSSProperties, FC, ReactElement, ReactNode, useMemo, useState} from 'react'; - import {CreateOrganizationPayload} from '../../../api/scim2/createOrganization'; import useTheme from '../../../contexts/Theme/useTheme'; import useTranslation from '../../../hooks/useTranslation'; -import {Avatar} from '../../primitives/Avatar/Avatar'; import Button from '../../primitives/Button/Button'; import {Dialog, DialogContent, DialogHeading} from '../../primitives/Popover/Popover'; import FormControl from '../../primitives/FormControl/FormControl'; @@ -181,8 +179,6 @@ export const BaseCreateOrganization: FC = ({ const styles = useStyles(); const {theme} = useTheme(); const {t} = useTranslation(); - const [avatarUrl, setAvatarUrl] = useState(''); - const [avatarFile, setAvatarFile] = useState(null); const [formData, setFormData] = useState({ description: '', handle: '', @@ -227,44 +223,6 @@ export const BaseCreateOrganization: FC = ({ } }; - const handleAvatarUpload = (event: ChangeEvent): void => { - const file = event.target.files?.[0]; - if (file) { - // Validate file type - if (!file.type.startsWith('image/')) { - setFormErrors(prev => ({ - ...prev, - avatar: 'Please select a valid image file', - })); - return; - } - - // Validate file size (max 2MB) - if (file.size > 2 * 1024 * 1024) { - setFormErrors(prev => ({ - ...prev, - avatar: 'Image size must be less than 2MB', - })); - return; - } - - setAvatarFile(file); - - // Create preview URL - const reader = new FileReader(); - reader.onload = e => { - setAvatarUrl(e.target?.result as string); - }; - reader.readAsDataURL(file); - - // Clear any previous avatar errors - setFormErrors(prev => ({ - ...prev, - avatar: undefined, - })); - } - }; - const handleNameChange = (value: string): void => { handleInputChange('name', value); @@ -310,14 +268,6 @@ export const BaseCreateOrganization: FC = ({ } }; - const defaultRenderHeader = (): ReactElement => ( -
- - {t('organization.create.title')} - -
- ); - const containerStyle = { ...styles.root, ...(cardLayout ? styles.card : {}), diff --git a/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx b/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx index b1ddbcfc..c0e46864 100644 --- a/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx @@ -252,6 +252,11 @@ export interface BaseSignInProps { */ onFlowChange?: (response: EmbeddedSignInFlowInitiateResponse | EmbeddedSignInFlowHandleResponse) => void; + /** + * Flag to determine the component is ready to be rendered. + */ + isLoading?: boolean; + /** * Function to initialize authentication flow. * @returns Promise resolving to the initial authentication response. @@ -328,6 +333,7 @@ const BaseSignIn: FC = props => ( const BaseSignInContent: FC = ({ afterSignInUrl, onInitialize, + isLoading: externalIsLoading, onSubmit, onSuccess, onError, @@ -343,15 +349,16 @@ const BaseSignInContent: FC = ({ const {t} = useTranslation(); const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages} = useFlow(); - const [isLoading, setIsLoading] = useState(false); + const [isSignInInitializationRequestLoading, setIsSignInInitializationRequestLoading] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [currentFlow, setCurrentFlow] = useState(null); const [currentAuthenticator, setCurrentAuthenticator] = useState(null); const [error, setError] = useState(null); const [messages, setMessages] = useState>([]); - // Ref to track if initialization has been attempted to prevent multiple calls - const initializationAttemptedRef = useRef(false); + const isLoading = externalIsLoading || isSignInInitializationRequestLoading; + + const reRenderCheckRef = useRef(false); const formFields: FormField[] = currentAuthenticator?.metadata?.params?.map(param => ({ @@ -598,7 +605,7 @@ const BaseSignInContent: FC = ({ return; } - setIsLoading(true); + setIsSignInInitializationRequestLoading(true); setError(null); setMessages([]); @@ -666,7 +673,7 @@ const BaseSignInContent: FC = ({ setError(errorMessage); onError?.(err as Error); } finally { - setIsLoading(false); + setIsSignInInitializationRequestLoading(false); } }; @@ -686,7 +693,7 @@ const BaseSignInContent: FC = ({ touchAllFields(); } - setIsLoading(true); + setIsSignInInitializationRequestLoading(true); setError(null); setMessages([]); @@ -955,7 +962,7 @@ const BaseSignInContent: FC = ({ setError(errorMessage); onError?.(err as Error); } finally { - setIsLoading(false); + setIsSignInInitializationRequestLoading(false); } }; @@ -1020,69 +1027,71 @@ const BaseSignInContent: FC = ({ const errorClasses = clsx([withVendorCSSClassPrefix('signin__error')], errorClassName); - const messageClasses = clsx([withVendorCSSClassPrefix('signin__messages')], messageClassName); + const messageClasses = clsx([withVendorCSSClassPrefix('signin__messages')], messageClassName); // Initialize the flow on component mount - // Initialize the flow on component mount useEffect(() => { - if (!isInitialized && !initializationAttemptedRef.current) { - initializationAttemptedRef.current = true; + if (isLoading) { + return; + } - // Inline initialization to avoid dependency issues - const performInitialization = async () => { - setIsLoading(true); - setError(null); + // React 18.x Strict.Mode has a new check for `Ensuring reusable state` to facilitate an upcoming react feature. + // https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state + // This will remount all the useEffects to ensure that there are no unexpected side effects. + // When react remounts the SignIn, it will send two authorize requests. + // https://github.com/reactwg/react-18/discussions/18#discussioncomment-795623 + if (reRenderCheckRef.current) { + return; + } - try { - const response = await onInitialize(); + reRenderCheckRef.current = true; - setCurrentFlow(response); - setIsInitialized(true); - onFlowChange?.(response); + (async () => { + setIsSignInInitializationRequestLoading(true); + setError(null); - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - onSuccess?.((response as any).authData || {}); - return; - } + try { + const response = await onInitialize(); - if (response?.nextStep?.authenticators?.length > 0) { - if ( - response.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - response.nextStep.authenticators.length > 1 - ) { - setCurrentAuthenticator(null); - } else { - const authenticator = response.nextStep.authenticators[0]; - setCurrentAuthenticator(authenticator); - setupFormFields(authenticator); - } - } + setCurrentFlow(response); + setIsInitialized(true); + onFlowChange?.(response); - if (response && 'nextStep' in response && response.nextStep && 'messages' in response.nextStep) { - const stepMessages = (response.nextStep as any).messages || []; - setMessages( - stepMessages.map((msg: any) => ({ - type: msg.type || 'INFO', - message: msg.message || '', - })), - ); - } - } catch (err) { - const errorMessage = err instanceof AsgardeoAPIError ? err.message : t('errors.sign.in.initialization'); - setError(errorMessage); - onError?.(err as Error); - } finally { - setIsLoading(false); + if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { + onSuccess?.((response as any).authData || {}); + return; } - }; - performInitialization(); - } + if (response?.nextStep?.authenticators?.length > 0) { + if ( + response.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && + response.nextStep.authenticators.length > 1 + ) { + setCurrentAuthenticator(null); + } else { + const authenticator = response.nextStep.authenticators[0]; + setCurrentAuthenticator(authenticator); + setupFormFields(authenticator); + } + } - // Cleanup function to reset initialization state on unmount - return () => { - initializationAttemptedRef.current = false; - }; - }, [isInitialized]); + if (response && 'nextStep' in response && response.nextStep && 'messages' in response.nextStep) { + const stepMessages = (response.nextStep as any).messages || []; + setMessages( + stepMessages.map((msg: any) => ({ + type: msg.type || 'INFO', + message: msg.message || '', + })), + ); + } + } catch (err) { + const errorMessage = err instanceof AsgardeoAPIError ? err.message : t('errors.sign.in.initialization'); + setError(errorMessage); + onError?.(err as Error); + } finally { + setIsSignInInitializationRequestLoading(false); + } + })(); + }, [isLoading]); if (!isInitialized && isLoading) { return ( diff --git a/packages/react/src/components/presentation/SignIn/SignIn.tsx b/packages/react/src/components/presentation/SignIn/SignIn.tsx index ad6cd147..49f96ff6 100644 --- a/packages/react/src/components/presentation/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/SignIn.tsx @@ -72,13 +72,14 @@ export interface SignInProps { * ``` */ const SignIn: FC = ({className, size = 'medium', variant = 'outlined'}: SignInProps) => { - const {signIn, afterSignInUrl} = useAsgardeo(); + const {signIn, afterSignInUrl, isInitialized, isLoading} = useAsgardeo(); /** * Initialize the authentication flow. */ - const handleInitialize = async (): Promise => - await signIn({response_mode: 'direct'}); + const handleInitialize = async (): Promise => { + return await signIn({response_mode: 'direct'}); + }; /** * Handle authentication steps. @@ -86,7 +87,9 @@ const SignIn: FC = ({className, size = 'medium', variant = 'outline const handleOnSubmit = async ( payload: EmbeddedSignInFlowHandleRequestPayload, request: Request, - ): Promise => await signIn(payload, request); + ): Promise => { + return await signIn(payload, request); + }; /** * Handle successful authentication and redirect with query params. @@ -107,6 +110,7 @@ const SignIn: FC = ({className, size = 'medium', variant = 'outline return ( = createContext({ - afterSignInUrl: '', - baseUrl: '', + signInUrl: undefined, + afterSignInUrl: undefined, + baseUrl: undefined, isInitialized: false, isLoading: true, isSignedIn: false, + organization: null, signIn: null, signOut: null, signUp: null, diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index 43a792ea..52fb7d85 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -50,6 +50,7 @@ const AsgardeoProvider: FC> = ({ children, scopes, preferences, + signInUrl, ...rest }: PropsWithChildren): ReactElement => { const reRenderCheckRef: RefObject = useRef(false); @@ -230,11 +231,13 @@ const AsgardeoProvider: FC> = ({ return ( { - if (!isLoading && !isAuthenticated) { - redirect('/'); - } - }, [isAuthenticated, isLoading]); - - if (isLoading) { - return
Loading...
; - } - - if (!isAuthenticated) { - return null; - } + const {organization, user} = useAsgardeo(); const stats = [ { @@ -40,7 +22,7 @@ export default function DashboardPage() { }, { name: 'Team Members', - value: currentOrg?.memberCount.toString() || '0', + value: '0', change: '+5.4%', changeType: 'positive' as const, icon: Users, @@ -122,8 +104,19 @@ export default function DashboardPage() {
-

Welcome back {user?.name}!

-

Here's what's happening with {currentOrg?.name} today.

+

+ Welcome back{' '} + + {user => ( + + {user?.givenName || user?.name?.givenName || user?.given_name}{' '} + {user?.name?.familyName || user?.familyName || user?.family_name} + + )} + + ! +

+

Here's what's happening with {organization?.orgHandle} today.

{/* Stats Grid */} diff --git a/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx b/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx index ea76317f..c909d6a7 100644 --- a/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx +++ b/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx @@ -1,5 +1,6 @@ import OrganizationSwitcher from './OrganizationSwitcher'; import UserDropdown from './UserDropdown'; +import {SignOutButton} from '@asgardeo/nextjs'; interface AuthenticatedActionsProps { className?: string; @@ -10,6 +11,7 @@ export default function AuthenticatedActions({className = ''}: AuthenticatedActi
+
); } diff --git a/samples/teamspace-nextjs/components/Header/PublicActions.tsx b/samples/teamspace-nextjs/components/Header/PublicActions.tsx index d96d3461..c11011d7 100644 --- a/samples/teamspace-nextjs/components/Header/PublicActions.tsx +++ b/samples/teamspace-nextjs/components/Header/PublicActions.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; import {Button} from '@/components/ui/button'; +import {SignInButton} from '@asgardeo/nextjs'; interface PublicActionsProps { className?: string; @@ -23,6 +24,7 @@ export default function PublicActions({className = '', showMobileActions = false return (
+ diff --git a/samples/teamspace-nextjs/middleware.ts b/samples/teamspace-nextjs/middleware.ts index 7d4f40f6..7f506e37 100644 --- a/samples/teamspace-nextjs/middleware.ts +++ b/samples/teamspace-nextjs/middleware.ts @@ -1,22 +1,12 @@ -import {AsgardeoNext} from '@asgardeo/nextjs'; -import {NextRequest} from 'next/server'; +import {asgardeoMiddleware} from '@asgardeo/nextjs'; -const asgardeo = new AsgardeoNext(); - -asgardeo.initialize({ - baseUrl: process.env.NEXT_PUBLIC_ASGARDEO_BASE_URL, - clientId: process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_ID, - clientSecret: process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_SECRET, - afterSignInUrl: 'http://localhost:3000/dashboard', -}); - -export async function middleware(request: NextRequest) { - return await asgardeo.middleware(request); -} +export default asgardeoMiddleware(); export const config = { matcher: [ + // Skip Next.js internals and all static files, unless found in search params '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + // Always run for API routes '/(api|trpc)(.*)', ], }; diff --git a/samples/teamspace-react/src/components/Header/PublicActions.tsx b/samples/teamspace-react/src/components/Header/PublicActions.tsx index 429cf887..385e50b3 100644 --- a/samples/teamspace-react/src/components/Header/PublicActions.tsx +++ b/samples/teamspace-react/src/components/Header/PublicActions.tsx @@ -16,21 +16,7 @@ export default function PublicActions({className = '', showMobileActions = false // Mobile menu actions return (
- - {({isLoading}) => ( - - )} - +
); } @@ -39,22 +25,7 @@ export default function PublicActions({className = '', showMobileActions = false
{/* Desktop CTA */}
- Sign In with Redirect - - {({isLoading}) => ( - - )} - + {({isLoading}) => (
{/* Stats Grid */}