Skip to content

Commit c600c29

Browse files
authored
Merge pull request #213 from brionmario/thunder
feat: add `asagrdeo/thunder` support ⚡️
2 parents 45faf54 + b411539 commit c600c29

File tree

75 files changed

+2661
-367
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

75 files changed

+2661
-367
lines changed

.changeset/khaki-ends-begin.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@asgardeo/javascript': patch
3+
'@asgardeo/browser': patch
4+
'@asgardeo/nextjs': patch
5+
'@asgardeo/react': patch
6+
'@asgardeo/i18n': patch
7+
---
8+
9+
Add `asagrdeo/thunder` support

packages/browser/src/__legacy__/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,4 +1157,12 @@ export class AsgardeoSPAClient {
11571157

11581158
return;
11591159
}
1160+
1161+
/**
1162+
* This method clears the session information from the storage.
1163+
* @param sessionId - The session ID of the session to be cleared. If not provided, the current session will be cleared.
1164+
*/
1165+
public clearSession(sessionId?: string): void {
1166+
AsgardeoAuthClient.clearSession(sessionId);
1167+
}
11601168
}

packages/browser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,5 @@ export {
6161
} from './theme/themeDetection';
6262
export {default as getActiveTheme} from './theme/getActiveTheme';
6363

64+
export {default as handleWebAuthnAuthentication} from './utils/handleWebAuthnAuthentication';
6465
export {default as http} from './utils/http';
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {arrayBufferToBase64url, base64urlToArrayBuffer, AsgardeoRuntimeError} from '@asgardeo/javascript';
20+
21+
/**
22+
* Handles WebAuthn/Passkey authentication flow for browser environments.
23+
*
24+
* This function processes a WebAuthn challenge, performs the authentication ceremony,
25+
* and returns the authentication response that can be sent to the server for verification.
26+
*
27+
* The function handles various aspects of WebAuthn authentication including:
28+
* - Browser compatibility checks for WebAuthn support
29+
* - HTTPS requirement validation (except for localhost development)
30+
* - Relying Party ID validation and domain compatibility
31+
* - Challenge data decoding and credential request options processing
32+
* - User authentication ceremony via navigator.credentials.get()
33+
* - Response formatting for server consumption
34+
*
35+
* @param challengeData - Base64-encoded challenge data containing WebAuthn request options.
36+
* This data typically includes the challenge, RP ID, allowed credentials,
37+
* user verification requirements, and other authentication parameters.
38+
*
39+
* @returns Promise that resolves to a JSON string containing the WebAuthn authentication response.
40+
* The response includes the credential ID, authenticator data, client data JSON,
41+
* signature, and optional user handle that can be verified by the server.
42+
*
43+
* @throws {AsgardeoRuntimeError} When WebAuthn is not supported in the current browser
44+
* @throws {AsgardeoRuntimeError} When the page is not served over HTTPS (except localhost)
45+
* @throws {AsgardeoRuntimeError} When the user cancels or times out the authentication
46+
* @throws {AsgardeoRuntimeError} When there's a domain/RP ID mismatch
47+
* @throws {AsgardeoRuntimeError} When no valid passkey is found for the account
48+
* @throws {AsgardeoRuntimeError} When WebAuthn is not supported on the device/browser
49+
* @throws {AsgardeoRuntimeError} When there's a network error during authentication
50+
* @throws {AsgardeoRuntimeError} For any other authentication failures
51+
*
52+
* @example
53+
* ```typescript
54+
* try {
55+
* const challengeData = 'eyJwdWJsaWNLZXlDcmVkZW50aWFsUmVxdWVzdE9wdGlvbnMiOi4uLn0=';
56+
* const authResponse = await handleWebAuthnAuthentication(challengeData);
57+
*
58+
* // Send the response to your server for verification
59+
* const result = await fetch('/api/verify-webauthn', {
60+
* method: 'POST',
61+
* headers: { 'Content-Type': 'application/json' },
62+
* body: authResponse
63+
* });
64+
* } catch (error) {
65+
* if (error instanceof AsgardeoRuntimeError) {
66+
* console.error('WebAuthn authentication failed:', error.message);
67+
* }
68+
* }
69+
* ```
70+
*
71+
* @example
72+
* ```typescript
73+
* // Usage in an authentication flow
74+
* const authenticateWithPasskey = async (challengeFromServer: string) => {
75+
* try {
76+
* const response = await handleWebAuthnAuthentication(challengeFromServer);
77+
* return JSON.parse(response);
78+
* } catch (error) {
79+
* // Handle specific error cases
80+
* if (error instanceof AsgardeoRuntimeError) {
81+
* switch (error.code) {
82+
* case 'browser-webauthn-not-supported':
83+
* showFallbackAuth();
84+
* break;
85+
* case 'browser-webauthn-user-cancelled':
86+
* showRetryOption();
87+
* break;
88+
* default:
89+
* showGenericError();
90+
* }
91+
* }
92+
* }
93+
* };
94+
* ```
95+
*
96+
* @see {@link https://webauthn.guide/} - WebAuthn specification guide
97+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API} - MDN WebAuthn API documentation
98+
*/
99+
const handleWebAuthnAuthentication = async (challengeData: string): Promise<string> => {
100+
if (!window.navigator.credentials || !window.navigator.credentials.get) {
101+
throw new AsgardeoRuntimeError(
102+
'WebAuthn is not supported in this browser. Please use a modern browser or try a different authentication method.',
103+
'browser-webauthn-not-supported',
104+
'browser',
105+
'WebAuthn/Passkey authentication requires a browser that supports the Web Authentication API.',
106+
);
107+
}
108+
109+
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') {
110+
throw new AsgardeoRuntimeError(
111+
'Passkey authentication requires a secure connection (HTTPS). Please ensure you are accessing this site over HTTPS.',
112+
'browser-webauthn-insecure-connection',
113+
'browser',
114+
'WebAuthn authentication requires HTTPS for security reasons, except when running on localhost for development.',
115+
);
116+
}
117+
118+
try {
119+
const decodedChallenge = JSON.parse(atob(challengeData));
120+
const {publicKeyCredentialRequestOptions} = decodedChallenge;
121+
122+
const currentDomain = window.location.hostname;
123+
const challengeRpId = publicKeyCredentialRequestOptions.rpId;
124+
125+
let rpIdToUse = challengeRpId;
126+
127+
if (challengeRpId && !currentDomain.endsWith(challengeRpId) && challengeRpId !== currentDomain) {
128+
console.warn(`RP ID mismatch detected. Challenge RP ID: ${challengeRpId}, Current domain: ${currentDomain}`);
129+
rpIdToUse = currentDomain;
130+
}
131+
132+
const adjustedOptions = {
133+
...publicKeyCredentialRequestOptions,
134+
rpId: rpIdToUse,
135+
challenge: base64urlToArrayBuffer(publicKeyCredentialRequestOptions.challenge),
136+
...(publicKeyCredentialRequestOptions.userVerification && {
137+
userVerification: publicKeyCredentialRequestOptions.userVerification,
138+
}),
139+
...(publicKeyCredentialRequestOptions.allowCredentials && {
140+
allowCredentials: publicKeyCredentialRequestOptions.allowCredentials.map((cred: any) => ({
141+
...cred,
142+
id: base64urlToArrayBuffer(cred.id),
143+
})),
144+
}),
145+
};
146+
147+
const credential = (await navigator.credentials.get({
148+
publicKey: adjustedOptions,
149+
})) as PublicKeyCredential;
150+
151+
if (!credential) {
152+
throw new AsgardeoRuntimeError(
153+
'No credential returned from WebAuthn authentication',
154+
'browser-webauthn-no-credential',
155+
'browser',
156+
'The WebAuthn authentication ceremony completed but did not return a valid credential.',
157+
);
158+
}
159+
160+
const authData = credential.response as AuthenticatorAssertionResponse;
161+
162+
const tokenResponse = {
163+
requestId: decodedChallenge.requestId,
164+
credential: {
165+
id: credential.id,
166+
rawId: arrayBufferToBase64url(credential.rawId),
167+
response: {
168+
authenticatorData: arrayBufferToBase64url(authData.authenticatorData),
169+
clientDataJSON: arrayBufferToBase64url(authData.clientDataJSON),
170+
signature: arrayBufferToBase64url(authData.signature),
171+
...(authData.userHandle && {
172+
userHandle: arrayBufferToBase64url(authData.userHandle),
173+
}),
174+
},
175+
type: credential.type,
176+
},
177+
};
178+
179+
return JSON.stringify(tokenResponse);
180+
} catch (error) {
181+
console.error('WebAuthn authentication failed:', error);
182+
183+
if (error instanceof AsgardeoRuntimeError) {
184+
throw error;
185+
}
186+
187+
if (error instanceof Error) {
188+
switch (error.name) {
189+
case 'NotAllowedError':
190+
throw new AsgardeoRuntimeError(
191+
'Passkey authentication was cancelled or timed out. Please try again.',
192+
'browser-webauthn-user-cancelled',
193+
'browser',
194+
'The user cancelled the WebAuthn authentication request or the request timed out.',
195+
);
196+
197+
case 'SecurityError':
198+
if (error.message.includes('relying party ID') || error.message.includes('RP ID')) {
199+
throw new AsgardeoRuntimeError(
200+
'Domain mismatch error. The passkey was registered for a different domain. Please contact support or try a different authentication method.',
201+
'browser-webauthn-domain-mismatch',
202+
'browser',
203+
'The WebAuthn relying party ID does not match the current domain.',
204+
);
205+
}
206+
throw new AsgardeoRuntimeError(
207+
'Passkey authentication failed due to a security error. Please ensure you are using HTTPS and that your browser supports passkeys.',
208+
'browser-webauthn-security-error',
209+
'browser',
210+
'A security error occurred during WebAuthn authentication.',
211+
);
212+
213+
case 'InvalidStateError':
214+
throw new AsgardeoRuntimeError(
215+
'No valid passkey found for this account. Please register a passkey first or use a different authentication method.',
216+
'browser-webauthn-no-passkey',
217+
'browser',
218+
'No registered passkey credentials were found for the current user account.',
219+
);
220+
221+
case 'NotSupportedError':
222+
throw new AsgardeoRuntimeError(
223+
'Passkey authentication is not supported on this device or browser. Please use a different authentication method.',
224+
'browser-webauthn-not-supported',
225+
'browser',
226+
'WebAuthn is not supported on the current device or browser configuration.',
227+
);
228+
229+
case 'NetworkError':
230+
throw new AsgardeoRuntimeError(
231+
'Network error during passkey authentication. Please check your connection and try again.',
232+
'browser-webauthn-network-error',
233+
'browser',
234+
'A network error occurred while communicating with the authenticator.',
235+
);
236+
237+
case 'UnknownError':
238+
throw new AsgardeoRuntimeError(
239+
'An unknown error occurred during passkey authentication. Please try again or use a different authentication method.',
240+
'browser-webauthn-unknown-error',
241+
'browser',
242+
'An unidentified error occurred during the WebAuthn authentication process.',
243+
);
244+
245+
default:
246+
throw new AsgardeoRuntimeError(
247+
`Passkey authentication failed: ${error.message}`,
248+
'browser-webauthn-general-error',
249+
'browser',
250+
`WebAuthn authentication failed with error: ${error.name}`,
251+
);
252+
}
253+
}
254+
255+
throw new AsgardeoRuntimeError(
256+
'Passkey authentication failed due to an unexpected error.',
257+
'browser-webauthn-unexpected-error',
258+
'browser',
259+
'An unexpected error occurred during WebAuthn authentication.',
260+
);
261+
}
262+
};
263+
264+
export default handleWebAuthnAuthentication;

packages/i18n/src/models/i18n.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@ export interface I18nTranslations {
3333
'elements.buttons.microsoft': string;
3434
'elements.buttons.linkedin': string;
3535
'elements.buttons.ethereum': string;
36+
'elements.buttons.smsotp': string;
3637
'elements.buttons.multi.option': string;
3738
'elements.buttons.social': string;
3839

3940
/* Fields */
4041
'elements.fields.placeholder': string;
42+
'elements.fields.username': string;
43+
'elements.fields.password': string;
4144

4245
/* |---------------------------------------------------------------| */
4346
/* | Widgets | */

packages/i18n/src/translations/en-US.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ const translations: I18nTranslations = {
3636
'elements.buttons.microsoft': 'Continue with Microsoft',
3737
'elements.buttons.linkedin': 'Continue with LinkedIn',
3838
'elements.buttons.ethereum': 'Continue with Sign In Ethereum',
39+
'elements.buttons.smsotp': 'Continue with SMS OTP',
3940
'elements.buttons.multi.option': 'Continue with {connection}',
4041
'elements.buttons.social': 'Continue with {connection}',
4142

4243
/* Fields */
4344
'elements.fields.placeholder': 'Enter your {field}',
45+
'elements.fields.username': 'Username',
46+
'elements.fields.password': 'Password',
4447

4548
/* |---------------------------------------------------------------| */
4649
/* | Widgets | */

packages/i18n/src/translations/fr-FR.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ const translations: I18nTranslations = {
3636
'elements.buttons.microsoft': 'Continuer avec Microsoft',
3737
'elements.buttons.linkedin': 'Continuer with LinkedIn',
3838
'elements.buttons.ethereum': 'Continuer avec Sign In Ethereum',
39+
'elements.buttons.smsotp': 'Continuer avec SMS',
3940
'elements.buttons.multi.option': 'Continuer avec {connection}',
4041
'elements.buttons.social': 'Continuer avec {connection}',
4142

4243
/* Fields */
4344
'elements.fields.placeholder': 'Entrez votre {field}',
45+
'elements.fields.username': "Nom d'utilisateur",
46+
'elements.fields.password': 'Mot de passe',
4447

4548
/* |---------------------------------------------------------------| */
4649
/* | Widgets | */

packages/i18n/src/translations/hi-IN.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,14 @@ const translations: I18nTranslations = {
3535
'elements.buttons.microsoft': 'Microsoft के साथ जारी रखें',
3636
'elements.buttons.linkedin': 'LinkedIn के साथ जारी रखें',
3737
'elements.buttons.ethereum': 'Ethereum के साथ साइन इन करें',
38+
'elements.buttons.smsotp': 'SMS के साथ जारी रखें',
3839
'elements.buttons.multi.option': '{connection} के साथ जारी रखें',
3940
'elements.buttons.social': '{connection} के साथ जारी रखें',
4041

4142
/* Fields */
4243
'elements.fields.placeholder': '{field} दर्ज करें',
44+
'elements.fields.username': 'उपयोगकर्ता नाम',
45+
'elements.fields.password': 'पासवर्ड',
4346

4447
/* |---------------------------------------------------------------| */
4548
/* | Widgets | */

packages/i18n/src/translations/ja-JP.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ const translations: I18nTranslations = {
3636
'elements.buttons.microsoft': 'Microsoftで続行',
3737
'elements.buttons.linkedin': 'LinkedInで続行',
3838
'elements.buttons.ethereum': 'Ethereumでサインイン',
39+
'elements.buttons.smsotp': 'SMSで続行',
3940
'elements.buttons.multi.option': '{connection}で続行',
4041
'elements.buttons.social': '{connection}で続行',
4142

4243
/* Fields */
4344
'elements.fields.placeholder': '{field}を入力してください',
45+
'elements.fields.username': 'ユーザー名',
46+
'elements.fields.password': 'パスワード',
4447

4548
/* |---------------------------------------------------------------| */
4649
/* | Widgets | */

packages/i18n/src/translations/pt-BR.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ const translations: I18nTranslations = {
3636
'elements.buttons.microsoft': 'Entrar com Microsoft',
3737
'elements.buttons.linkedin': 'Entrar com LinkedIn',
3838
'elements.buttons.ethereum': 'Entrar com Ethereum',
39+
'elements.buttons.smsotp': 'Entrar com SMS',
3940
'elements.buttons.multi.option': 'Entrar com {connection}',
4041
'elements.buttons.social': 'Entrar com {connection}',
4142

4243
/* Fields */
4344
'elements.fields.placeholder': 'Digite seu {field}',
45+
'elements.fields.username': 'Nome de usuário',
46+
'elements.fields.password': 'Senha',
4447

4548
/* |---------------------------------------------------------------| */
4649
/* | Widgets | */

0 commit comments

Comments
 (0)