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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/javascript/src/api/__tests__/getUsersMeMeta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {describe, it, expect, vi} from 'vitest';
import getUsersMeMeta from '../getUsersMeMeta';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';

describe('getUsersMeMeta', () => {
it('fetches user schema metadata successfully with custom fetcher', async () => {
const mockSchema = {
schema: {
givenName: {
displayName: 'First Name',
type: 'STRING',
required: true,
},
},
};

const mockFetcher = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockSchema,
} as Response);

const result = await getUsersMeMeta({
baseUrl: 'https://api.example.com',
fetcher: mockFetcher,
});

expect(mockFetcher).toHaveBeenCalledWith(
'https://api.example.com/users/me/meta',
expect.objectContaining({
method: 'GET',
}),
);
expect(result).toEqual(mockSchema);
});

it('throws ThunderIDAPIError for invalid URL', async () => {
await expect(
getUsersMeMeta({
baseUrl: 'invalid-url',
fetcher: vi.fn(),
}),
).rejects.toThrow(ThunderIDAPIError);
});

it('throws ThunderIDAPIError when server returns non-ok response', async () => {
const mockFetcher = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: async () => 'Server error',
} as Response);

await expect(
getUsersMeMeta({
baseUrl: 'https://api.example.com',
fetcher: mockFetcher,
}),
).rejects.toThrow(ThunderIDAPIError);
});

it('handles network failure', async () => {
const mockFetcher = vi.fn().mockRejectedValue(new Error('Network error'));

await expect(
getUsersMeMeta({
baseUrl: 'https://api.example.com',
fetcher: mockFetcher,
}),
).rejects.toThrow(ThunderIDAPIError);
});
});
122 changes: 122 additions & 0 deletions packages/javascript/src/api/getUsersMeMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import ThunderIDAPIError from '../errors/ThunderIDAPIError';

/**
* Attribute schema metadata returned by GET /users/me/meta
*/
export interface AttributeSchema {
credential?: boolean;
description?: string;
displayName?: string;
mutability?: string;
readOnly?: boolean;
regex?: string;
required?: boolean;
subAttributes?: AttributeSchema[];
type?: string;
unique?: boolean;
}

/**
* Configuration for the getUsersMeMeta request
*/
export interface GetUsersMeMetaConfig extends Omit<RequestInit, 'method' | 'headers'> {
/**
* 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<Response>;
/**
* Custom HTTP headers as a plain object.
*/
headers?: Record<string, string>;
/**
* The absolute API endpoint.
*/
url?: string;
}

/**
* Response structure for GET /users/me/meta
*/
export interface UsersMeMetaResponse {
schema?: Record<string, AttributeSchema>;
}

/**
* Retrieves the user schema metadata from the specified /users/me/meta endpoint.
*
* @param config - Request configuration object.
* @returns A promise that resolves with the user schema metadata.
*/
const getUsersMeMeta = async ({
baseUrl,
fetcher,
url,
...requestConfig
}: GetUsersMeMetaConfig): Promise<UsersMeMetaResponse> => {
try {
// eslint-disable-next-line no-new
new URL((url ?? baseUrl)!);
Comment thread
janithjay marked this conversation as resolved.
} catch (error) {
throw new ThunderIDAPIError(
`Invalid URL provided. ${error instanceof Error ? error.message : String(error)}`,
'getUsersMeMeta-ValidationError-001',
'javascript',
400,
'The provided `url` or `baseUrl` path does not adhere to the URL schema.',
);
}

const fetchFn: typeof fetch = fetcher ?? fetch;
const resolvedUrl: string = url ?? `${baseUrl?.replace(/\/$/, '')}/users/me/meta`;

const requestInit: RequestInit = {
...requestConfig,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...requestConfig.headers,
},
method: 'GET',
};

try {
const response: Response = await fetchFn(resolvedUrl, requestInit);

if (!response?.ok) {
const errorText: string = await response.text();

throw new ThunderIDAPIError(
errorText,
'getUsersMeMeta-ResponseError-001',
'javascript',
response.status,
response.statusText,
'Failed to fetch user schema metadata',
);
}

return (await response.json()) as UsersMeMetaResponse;
} catch (error) {
if (error instanceof ThunderIDAPIError) {
throw error;
}

throw new ThunderIDAPIError(
`Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`,
'getUsersMeMeta-NetworkError-001',
'javascript',
0,
'Network Error',
);
}
};

export default getUsersMeMeta;
2 changes: 2 additions & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type {
export {default as getUserInfo} from './api/getUserInfo';
export {default as getUsersMe} from './api/getUsersMe';
export type {GetUsersMeConfig} from './api/getUsersMe';
export {default as getUsersMeMeta} from './api/getUsersMeMeta';
export type {GetUsersMeMetaConfig, UsersMeMetaResponse, AttributeSchema} from './api/getUsersMeMeta';
export {default as updateMeProfile} from './api/updateMeProfile';
export type {UpdateMeProfileConfig} from './api/updateMeProfile';

Expand Down
6 changes: 6 additions & 0 deletions packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export interface BaseConfig<T = unknown> extends WithPreferences, WithExtensions
* flowExecute: "https://rs.example.com/flow/execute",
* flowMeta: "https://rs.example.com/flow/meta",
* usersMe: "https://rs.example.com/users/me",
* usersMeMeta: "https://rs.example.com/users/me/meta",
* }
*/
endpoints?: {
Expand Down Expand Up @@ -282,6 +283,11 @@ export interface BaseConfig<T = unknown> extends WithPreferences, WithExtensions
* If not provided, defaults to `{baseUrl}/users/me`.
*/
usersMe?: string;
/**
* The user profile schema metadata endpoint URL used to fetch profile schema attributes.
* If not provided, defaults to `{baseUrl}/users/me/meta`.
*/
usersMeMeta?: string;
/**
* The OpenID Connect discovery document URL.
* Defaults to `{baseUrl}/oauth2/token/.well-known/openid-configuration`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@ const createHelper = (config: any): AuthenticationHelper<any> => {

// Resource-server override keys in both camelCase (config form) and snake_case (metadata form);
// none of these should ever appear in the resolved OIDC provider metadata.
const RESOURCE_KEY_FORMS: string[] = ['flowExecute', 'flowMeta', 'usersMe', 'flow_execute', 'flow_meta', 'users_me'];
const RESOURCE_KEY_FORMS: string[] = [
'flowExecute',
'flowMeta',
'usersMe',
'usersMeMeta',
'flow_execute',
'flow_meta',
'users_me',
'users_me_meta',
];

describe('AuthenticationHelper resource-endpoint filtering', (): void => {
it('keeps resource-server endpoint overrides out of the OIDC provider metadata', async (): Promise<void> => {
Expand All @@ -32,6 +41,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => {
flowExecute: 'https://rs.example.com/flow/execute',
flowMeta: 'https://rs.example.com/flow/meta',
usersMe: 'https://rs.example.com/users/me',
usersMeMeta: 'https://rs.example.com/users/me/meta',
},
});

Expand All @@ -54,6 +64,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => {
flowExecute: 'https://rs.example.com/flow/execute',
flowMeta: 'https://rs.example.com/flow/meta',
usersMe: 'https://rs.example.com/users/me',
usersMeMeta: 'https://rs.example.com/users/me/meta',
},
});

Expand Down Expand Up @@ -83,6 +94,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => {
flowExecute: 'https://rs.example.com/flow/execute',
flowMeta: 'https://rs.example.com/flow/meta',
usersMe: 'https://rs.example.com/users/me',
usersMeMeta: 'https://rs.example.com/users/me/meta',
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ describe('resolveResourceEndpoint', (): void => {
flowExecute: 'https://rs.example.com/flow/execute',
flowMeta: 'https://rs.example.com/flow/meta',
usersMe: 'https://rs.example.com/users/me',
usersMeMeta: 'https://rs.example.com/users/me/meta',
},
};

expect(resolveResourceEndpoint('flowExecute', config)).toBe('https://rs.example.com/flow/execute');
expect(resolveResourceEndpoint('flowMeta', config)).toBe('https://rs.example.com/flow/meta');
expect(resolveResourceEndpoint('usersMe', config)).toBe('https://rs.example.com/users/me');
expect(resolveResourceEndpoint('usersMeMeta', config)).toBe('https://rs.example.com/users/me/meta');
});

it('prefers an explicit per-call URL over the config override', (): void => {
Expand All @@ -46,6 +48,6 @@ describe('resolveResourceEndpoint', (): void => {
});

it('exposes the resource endpoint keys for filtering OIDC metadata', (): void => {
expect([...RESOURCE_ENDPOINT_KEYS].sort()).toEqual(['flowExecute', 'flowMeta', 'usersMe']);
expect([...RESOURCE_ENDPOINT_KEYS].sort()).toEqual(['flowExecute', 'flowMeta', 'usersMe', 'usersMeMeta']);
});
});
9 changes: 7 additions & 2 deletions packages/javascript/src/utils/resolveResourceEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import {BaseConfig} from '../models/config';
* issuers), these overrides let the SDK send flow and user-management requests to the resource
* server while OAuth requests continue to target the authorization server.
*/
export type ResourceEndpointKey = 'flowExecute' | 'flowMeta' | 'usersMe';
export type ResourceEndpointKey = 'flowExecute' | 'flowMeta' | 'usersMe' | 'usersMeMeta';

/**
* The `config.endpoints` keys that address resource-server endpoints rather than OIDC/OAuth
* endpoints. Used to keep these overrides out of the resolved OIDC provider metadata.
*/
export const RESOURCE_ENDPOINT_KEYS: readonly ResourceEndpointKey[] = ['flowExecute', 'flowMeta', 'usersMe'];
export const RESOURCE_ENDPOINT_KEYS: readonly ResourceEndpointKey[] = [
'flowExecute',
'flowMeta',
'usersMe',
'usersMeMeta',
];

/**
* Minimal shape of the config needed to resolve a resource-server endpoint override.
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/src/ThunderIDNextClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ class ThunderIDNextClient<T extends ThunderIDNextConfig = ThunderIDNextConfig> e

return executeEmbeddedSignInFlow({
baseUrl: configData?.baseUrl,
flowSecret: arg2?.flowSecret,
flowSecret: configData?.clientSecret || arg2?.flowSecret,
payload: arg1,
url: resolveResourceEndpoint('flowExecute', configData, arg2?.url),
}) as unknown as Promise<EmbeddedSignInFlowResponse>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ const ThunderIDClientProvider: FC<PropsWithChildren<ThunderIDClientProviderProps
signUp: handleSignUp,
signUpUrl,
user,
userSchema: null,
Comment thread
janithjay marked this conversation as resolved.
vendor: getVendorPrefix(vendor),
}),
[
Expand Down
62 changes: 62 additions & 0 deletions packages/react/src/api/getUsersMeMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {
FetchHttpClient,
HttpRequestConfig,
HttpResponse,
getUsersMeMeta as baseGetUsersMeMeta,
GetUsersMeMetaConfig as BaseGetUsersMeMetaConfig,
UsersMeMetaResponse,
AttributeSchema,
} from '@thunderid/browser';

export type {AttributeSchema, UsersMeMetaResponse};

/**
* Configuration for the getUsersMeMeta request (React-specific)
*/
export interface GetUsersMeMetaConfig extends Omit<BaseGetUsersMeMetaConfig, 'fetcher'> {
/**
* Optional custom fetcher function. If not provided, the ThunderID SPA client's httpClient will be used
*/
fetcher?: (url: string, config: RequestInit) => Promise<Response>;
/**
* Optional instance ID for multi-instance support. Defaults to 0.
*/
instanceId?: number;
}

/**
* Retrieves the user schema metadata from the specified /users/me/meta endpoint.
* Uses ThunderID SPA client FetchHttpClient by default with multi-instance support.
*/
const getUsersMeMeta = async ({
fetcher,
instanceId = 0,
...requestConfig
}: GetUsersMeMetaConfig): Promise<UsersMeMetaResponse> => {
const defaultFetcher = async (url: string, config: RequestInit): Promise<Response> => {
const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId);
const response: HttpResponse<UsersMeMetaResponse> = await httpClient.request({
headers: config.headers as Record<string, string>,
method: config.method ?? 'GET',
url,
} as HttpRequestConfig);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
json: () => Promise.resolve(response.data),
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.statusText || '',
text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)),
} as Response;
};

return baseGetUsersMeMeta({
...requestConfig,
fetcher: fetcher ?? defaultFetcher,
});
};

export default getUsersMeMeta;
Loading
Loading