diff --git a/.changeset/silly-words-pump.md b/.changeset/silly-words-pump.md new file mode 100644 index 00000000..840cfb23 --- /dev/null +++ b/.changeset/silly-words-pump.md @@ -0,0 +1,7 @@ +--- +'@asgardeo/javascript': minor +'@asgardeo/react': minor +'@asgardeo/i18n': minor +--- + +add passkey support diff --git a/packages/i18n/src/models/i18n.ts b/packages/i18n/src/models/i18n.ts index 43f22a59..fffddbaa 100644 --- a/packages/i18n/src/models/i18n.ts +++ b/packages/i18n/src/models/i18n.ts @@ -99,6 +99,12 @@ export interface I18nTranslations { 'username.password.heading': string; 'username.password.subheading': string; + /* Passkeys */ + 'passkey.button.use': string; + 'passkey.signin.heading': string; + 'passkey.register.heading': string; + 'passkey.register.description': string; + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/en-US.ts b/packages/i18n/src/translations/en-US.ts index 512cbcce..dd66aafa 100644 --- a/packages/i18n/src/translations/en-US.ts +++ b/packages/i18n/src/translations/en-US.ts @@ -102,6 +102,12 @@ const translations: I18nTranslations = { 'username.password.subheading': 'Enter your username and password to continue.', 'username.password.buttons.submit.text': 'Continue', + /* Passkeys */ + 'passkey.button.use': 'Sign in with Passkey', + 'passkey.signin.heading': 'Sign in with Passkey', + 'passkey.register.heading': 'Register Passkey', + 'passkey.register.description': 'Create a passkey to securely sign in to your account without a password.', + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/fr-FR.ts b/packages/i18n/src/translations/fr-FR.ts index afbe0d98..6e3af031 100644 --- a/packages/i18n/src/translations/fr-FR.ts +++ b/packages/i18n/src/translations/fr-FR.ts @@ -102,6 +102,13 @@ const translations: I18nTranslations = { 'username.password.heading': 'Se connecter', 'username.password.subheading': "Entrez votre nom d'utilisateur et votre mot de passe pour continuer.", + /* Passkeys */ + 'passkey.button.use': "Se connecter avec une clé d'accès", + 'passkey.signin.heading': "Se connecter avec une clé d'accès", + 'passkey.register.heading': "Enregistrer une clé d'accès", + 'passkey.register.description': + "Créez une clé d'accès pour vous connecter en toute sécurité à votre compte sans mot de passe.", + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/hi-IN.ts b/packages/i18n/src/translations/hi-IN.ts index 0a3d78b4..14c09a14 100644 --- a/packages/i18n/src/translations/hi-IN.ts +++ b/packages/i18n/src/translations/hi-IN.ts @@ -101,6 +101,12 @@ const translations: I18nTranslations = { 'username.password.heading': 'साइन इन', 'username.password.subheading': 'अपना उपयोगकर्ता नाम और पासवर्ड दर्ज करें।', + /* Passkeys */ + 'passkey.button.use': 'Passkey के साथ साइन इन करें', + 'passkey.signin.heading': 'Passkey के साथ साइन इन करें', + 'passkey.register.heading': 'Passkey पंजीकृत करें', + 'passkey.register.description': 'बिना पासवर्ड के अपने खाते में सुरक्षित रूप से साइन इन करने के लिए एक Passkey बनाएं।', + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/ja-JP.ts b/packages/i18n/src/translations/ja-JP.ts index 2ed9ad1c..f87bb4b9 100644 --- a/packages/i18n/src/translations/ja-JP.ts +++ b/packages/i18n/src/translations/ja-JP.ts @@ -102,6 +102,12 @@ const translations: I18nTranslations = { 'username.password.heading': 'ログイン', 'username.password.subheading': 'ユーザー名とパスワードを入力してください。', + /* Passkeys */ + 'passkey.button.use': 'パスキーでサインイン', + 'passkey.signin.heading': 'パスキーでサインイン', + 'passkey.register.heading': 'パスキーを登録', + 'passkey.register.description': 'パスワードなしでアカウントに安全にサインインするためのパスキーを作成します。', + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/pt-BR.ts b/packages/i18n/src/translations/pt-BR.ts index 45f6a87e..637843ff 100644 --- a/packages/i18n/src/translations/pt-BR.ts +++ b/packages/i18n/src/translations/pt-BR.ts @@ -102,6 +102,12 @@ const translations: I18nTranslations = { 'username.password.heading': 'Entrar', 'username.password.subheading': 'Digite seu usuário e senha para continuar.', + /* Passkeys */ + 'passkey.button.use': 'Entrar com Passkey', + 'passkey.signin.heading': 'Entrar com Passkey', + 'passkey.register.heading': 'Registrar Passkey', + 'passkey.register.description': 'Crie uma passkey para entrar em sua conta com segurança sem uma senha.', + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/pt-PT.ts b/packages/i18n/src/translations/pt-PT.ts index 1e4d61bf..c37c7f34 100644 --- a/packages/i18n/src/translations/pt-PT.ts +++ b/packages/i18n/src/translations/pt-PT.ts @@ -102,6 +102,12 @@ const translations: I18nTranslations = { 'username.password.heading': 'Iniciar Sessão', 'username.password.subheading': 'Introduza o seu utilizador e palavra-passe para continuar.', + /* Passkeys */ + 'passkey.button.use': 'Iniciar sessão com Passkey', + 'passkey.signin.heading': 'Iniciar sessão com Passkey', + 'passkey.register.heading': 'Registar Passkey', + 'passkey.register.description': 'Crie uma passkey para iniciar sessão na sua conta com segurança sem palavra-passe.', + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/si-LK.ts b/packages/i18n/src/translations/si-LK.ts index 2b99adc5..aaf76e67 100644 --- a/packages/i18n/src/translations/si-LK.ts +++ b/packages/i18n/src/translations/si-LK.ts @@ -102,6 +102,12 @@ const translations: I18nTranslations = { 'username.password.heading': 'ලොග් වෙන්න', 'username.password.subheading': 'ඉදිරියට යාමට ඔබේ පරිශීලක නාමය සහ මුරපදය ඇතුළත් කරන්න.', + /* Passkeys */ + 'passkey.button.use': 'Passkey මගින් ඇතුල් වන්න', + 'passkey.signin.heading': 'Passkey මගින් ඇතුල් වන්න', + 'passkey.register.heading': 'Passkey ලියාපදිංචි කරන්න', + 'passkey.register.description': 'මුරපදයක් නොමැතිව ඔබේ ගිණුමට ආරක්ෂිතව ඇතුල් වීමට passkey එකක් සාදන්න.', + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/ta-IN.ts b/packages/i18n/src/translations/ta-IN.ts index ea90af0c..286adcaf 100644 --- a/packages/i18n/src/translations/ta-IN.ts +++ b/packages/i18n/src/translations/ta-IN.ts @@ -102,6 +102,12 @@ const translations: I18nTranslations = { 'username.password.heading': 'உள்நுழை', 'username.password.subheading': 'தொடர உங்கள் பயனர்பெயர் மற்றும் கடவுச்சொல்லை உள்ளிடவும்.', + /* Passkeys */ + 'passkey.button.use': 'Passkey மூலம் உள்நுழையவும்', + 'passkey.signin.heading': 'Passkey மூலம் உள்நுழையவும்', + 'passkey.register.heading': 'Passkey-ஐ பதிவு செய்யவும்', + 'passkey.register.description': 'கடவுச்சொல் இல்லாமல் பாதுகாப்பாக உள்நுழைய ஒரு passkey-ஐ உருவாக்கவும்.', + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/te-IN.ts b/packages/i18n/src/translations/te-IN.ts index c651b864..74aa3734 100644 --- a/packages/i18n/src/translations/te-IN.ts +++ b/packages/i18n/src/translations/te-IN.ts @@ -102,6 +102,13 @@ const translations: I18nTranslations = { 'username.password.heading': 'సైన్ ఇన్ చేయండి', 'username.password.subheading': 'మీ యూజర్ పేరు మరియు పాస్‌వర్డ్ ఇవ్వండి.', + /* Passkeys */ + 'passkey.button.use': 'Passkey తో సైన్ ఇన్ చేయండి', + 'passkey.signin.heading': 'Passkey తో సైన్ ఇన్ చేయండి', + 'passkey.register.heading': 'Passkey ని నమోదు చేయండి', + 'passkey.register.description': + 'పాస్‌వర్డ్ లేకుండా మీ ఖాతాలోకి సురక్షితంగా సైన్ ఇన్ చేయడానికి Passkey ని సృష్టించండి.', + /* |---------------------------------------------------------------| */ /* | User Profile | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 3432985f..d678a230 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -47,6 +47,7 @@ export enum EmbeddedFlowResponseType { export interface EmbeddedSignUpFlowData { components?: EmbeddedFlowComponent[]; redirectURL?: string; + additionalData?: Record; } export interface EmbeddedFlowComponent { diff --git a/packages/javascript/src/models/v2/embedded-flow-v2.ts b/packages/javascript/src/models/v2/embedded-flow-v2.ts index 18c6a863..9bae6ddf 100644 --- a/packages/javascript/src/models/v2/embedded-flow-v2.ts +++ b/packages/javascript/src/models/v2/embedded-flow-v2.ts @@ -358,6 +358,12 @@ export interface EmbeddedFlowResponseData { * Optional redirect URL for flow completion or external authentication. */ redirectURL?: string; + + /** + * Additional data dictionary for dynamic flow response properties. + * Can be used to pass custom data like passkey challenges, server alerts, etc. + */ + additionalData?: Record; } /** diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx index dbaa69bc..49865958 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx @@ -30,6 +30,7 @@ import { } from '@asgardeo/browser'; import {normalizeFlowResponse} from '../../../../../utils/v2/flowTransformer'; import useTranslation from '../../../../../hooks/useTranslation'; +import { handlePasskeyAuthentication, handlePasskeyRegistration } from '../../../../../utils/v2/passkey'; /** * Render props function parameters @@ -104,6 +105,18 @@ export type SignInProps = { children?: (props: SignInRenderProps) => ReactNode; }; +/** + * State for tracking passkey registration + */ +interface PasskeyState { + isActive: boolean; + challenge: string | null; + creationOptions: string | null; + flowId: string | null; + actionId: string | null; + error: Error | null; +} + /** * A component-driven SignIn component that provides authentication flow with pre-built styling. * This component handles the flow API calls for authentication and delegates UI logic to BaseSignIn. @@ -175,9 +188,17 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError const [isFlowInitialized, setIsFlowInitialized] = useState(false); const [flowError, setFlowError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [passkeyState, setPasskeyState] = useState({ + isActive: false, + challenge: null, + creationOptions: null, + flowId: null, + actionId: null, + error: null, + }); const initializationAttemptedRef = useRef(false); const oauthCodeProcessedRef = useRef(false); - + const passkeyProcessedRef = useRef(false); /** * Sets flowId between sessionStorage and state. * This ensures both are always in sync. @@ -444,6 +465,28 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError return; } + if (response.data?.additionalData?.['passkeyChallenge'] || response.data?.additionalData?.['passkeyCreationOptions']) { + const passkeyChallenge = response.data.additionalData['passkeyChallenge']; + const passkeyCreationOptions = response.data.additionalData['passkeyCreationOptions']; + const effectiveFlowIdForPasskey = response.flowId || effectiveFlowId; + + // Reset passkey processed ref to allow processing + passkeyProcessedRef.current = false; + + // Set passkey state to trigger the passkey + setPasskeyState({ + isActive: true, + challenge: passkeyChallenge, + creationOptions: passkeyCreationOptions, + flowId: effectiveFlowIdForPasskey, + actionId: 'submit', + error: null, + }); + setIsSubmitting(false); + + return; + } + const {flowId, components, ...rest} = normalizeFlowResponse(response, t, { resolveTranslations: !children, }); @@ -564,6 +607,67 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFlowInitialized, currentFlowId, isInitialized, isLoading, isSubmitting, signIn]); + + /** + * Handle passkey authentication/registration when passkey state becomes active. + * This effect auto-triggers the browser passkey popup and submits the result. + */ + useEffect(() => { + if (!passkeyState.isActive || (!passkeyState.challenge && !passkeyState.creationOptions) || !passkeyState.flowId) { + return; + } + + // Prevent re-processing + if (passkeyProcessedRef.current) { + return; + } + passkeyProcessedRef.current = true; + + const performPasskeyProcess = async () => { + let inputs: Record; + + if (passkeyState.challenge) { + const passkeyResponse = await handlePasskeyAuthentication(passkeyState.challenge!); + const passkeyResponseObj = JSON.parse(passkeyResponse); + + inputs = { + credentialId: passkeyResponseObj.id, + authenticatorData: passkeyResponseObj.response.authenticatorData, + clientDataJSON: passkeyResponseObj.response.clientDataJSON, + signature: passkeyResponseObj.response.signature, + userHandle: passkeyResponseObj.response.userHandle, + }; + } else if (passkeyState.creationOptions) { + const passkeyResponse = await handlePasskeyRegistration(passkeyState.creationOptions!); + const passkeyResponseObj = JSON.parse(passkeyResponse); + + inputs = { + credentialId: passkeyResponseObj.id, + clientDataJSON: passkeyResponseObj.response.clientDataJSON, + attestationObject: passkeyResponseObj.response.attestationObject, + }; + } else { + throw new Error('No passkey challenge or creation options available'); + } + + await handleSubmit({ + flowId: passkeyState.flowId!, + inputs, + }); + }; + + performPasskeyProcess() + .then(() => { + setPasskeyState({ isActive: false, challenge: null, creationOptions: null, flowId: null, actionId: null, error: null }); + }) + .catch((error) => { + setPasskeyState(prev => ({ ...prev, isActive: false, error: error as Error })); + setFlowError(error as Error); + onError?.(error as Error); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [passkeyState.isActive, passkeyState.challenge, passkeyState.creationOptions, passkeyState.flowId]); + if (children) { const renderProps: SignInRenderProps = { initialize: initializeFlow, diff --git a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx index fd91d162..633015e1 100644 --- a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx @@ -41,6 +41,18 @@ import Spinner from '../../../../primitives/Spinner/Spinner'; import Typography from '../../../../primitives/Typography/Typography'; import useStyles from '../BaseSignUp.styles'; import getAuthComponentHeadings from '../../../../../utils/v2/getAuthComponentHeadings'; +import { handlePasskeyRegistration } from '../../../../../utils/v2/passkey'; + +/** + * State for tracking passkey registration + */ +interface PasskeyState { + isActive: boolean; + creationOptions: string | null; + flowId: string | null; + actionId: string | null; + error: Error | null; +} /** * Render props for custom UI rendering @@ -298,8 +310,16 @@ const BaseSignUpContent: FC = ({ const [isFlowInitialized, setIsFlowInitialized] = useState(false); const [currentFlow, setCurrentFlow] = useState(null); const [apiError, setApiError] = useState(null); + const [passkeyState, setPasskeyState] = useState({ + isActive: false, + creationOptions: null, + flowId: null, + actionId: null, + error: null, + }); const initializationAttemptedRef = useRef(false); + const passkeyProcessedRef = useRef(false); /** * Normalize flow response to ensure component-driven format @@ -510,6 +530,24 @@ const BaseSignUpContent: FC = ({ return; } + if (response.data?.additionalData?.['passkeyCreationOptions']) { + const passkeyCreationOptions = response.data.additionalData['passkeyCreationOptions']; + const effectiveFlowIdForPasskey = response.flowId || currentFlow?.flowId; + + // Reset passkey processed ref to allow processing + passkeyProcessedRef.current = false; + + // Set passkey state to trigger the passkey + setPasskeyState({ + isActive: true, + creationOptions: passkeyCreationOptions, + flowId: effectiveFlowIdForPasskey, + actionId: component.id || 'submit', + error: null, + }); + setIsLoading(false); + return; + } setCurrentFlow(response); setupFormFields(response); } @@ -521,6 +559,65 @@ const BaseSignUpContent: FC = ({ } }; + /** + * Handle passkey registration when passkey state becomes active. + * This effect auto-triggers the browser passkey popup and submits the result. + */ + useEffect(() => { + if (!passkeyState.isActive || !passkeyState.creationOptions || !passkeyState.flowId) { + return; + } + + // Prevent re-processing + if (passkeyProcessedRef.current) { + return; + } + passkeyProcessedRef.current = true; + + const performPasskeyRegistration = async () => { + const passkeyResponse = await handlePasskeyRegistration(passkeyState.creationOptions!); + const passkeyResponseObj = JSON.parse(passkeyResponse); + + const inputs = { + credentialId: passkeyResponseObj.id, + clientDataJSON: passkeyResponseObj.response.clientDataJSON, + attestationObject: passkeyResponseObj.response.attestationObject, + }; + + // After successful registration, submit the result to the server + const payload: EmbeddedFlowExecuteRequestPayload = { + flowId: passkeyState.flowId as string, + flowType: (currentFlow as any)?.flowType || 'REGISTRATION', + actionId: passkeyState.actionId || 'submit', + inputs: inputs, + } as any; + + const nextResponse = await onSubmit(payload); + const processedResponse = normalizeFlowResponseLocal(nextResponse); + onFlowChange?.(processedResponse); + + if (processedResponse.flowStatus === EmbeddedFlowStatus.Complete) { + onComplete?.(processedResponse); + return; + } else { + setCurrentFlow(processedResponse); + setupFormFields(processedResponse); + return; + } + }; + + performPasskeyRegistration() + .then(() => { + setPasskeyState({ isActive: false, creationOptions: null, flowId: null, actionId: null, error: null }); + }) + .catch((error) => { + setPasskeyState(prev => ({ ...prev, isActive: false, error: error as Error })); + handleError(error); + onError?.(error as Error); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [passkeyState.isActive, passkeyState.creationOptions, passkeyState.flowId]); + /** * Check if the response contains a redirection URL and perform the redirect if necessary. * @param response - The sign-up response diff --git a/packages/react/src/utils/v2/passkey.ts b/packages/react/src/utils/v2/passkey.ts new file mode 100644 index 00000000..a6b5e59a --- /dev/null +++ b/packages/react/src/utils/v2/passkey.ts @@ -0,0 +1,199 @@ +/** + * 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 {AsgardeoRuntimeError, arrayBufferToBase64url, base64urlToArrayBuffer} from '@asgardeo/browser'; + +/** + * Handles WebAuthn/Passkey registration flow for browser environments. + * + * @param challengeData - JSON stringified challenge data containing WebAuthn creation options. + * @returns Promise that resolves to a JSON string containing the WebAuthn registration response. + */ +export const handlePasskeyRegistration = async (challengeData: string): Promise => { + if (!window.navigator.credentials || !window.navigator.credentials.create) { + throw new AsgardeoRuntimeError( + 'WebAuthn is not supported in this browser.', + 'browser-webauthn-not-supported', + 'browser', + 'WebAuthn/Passkey registration requires a browser that supports the Web Authentication API.', + ); + } + + try { + const creationOptions = JSON.parse(challengeData); + + // Decode challenge and user ID from base64url to BufferSource + const publicKey = { + ...creationOptions, + challenge: base64urlToArrayBuffer(creationOptions.challenge), + user: { + ...creationOptions.user, + id: base64urlToArrayBuffer(creationOptions.user.id), + }, + ...(creationOptions.excludeCredentials && { + excludeCredentials: creationOptions.excludeCredentials.map((cred: any) => ({ + ...cred, + id: base64urlToArrayBuffer(cred.id), + })), + }), + }; + + const credential = (await navigator.credentials.create({ + publicKey, + })) as PublicKeyCredential; + + if (!credential) { + throw new AsgardeoRuntimeError( + 'No credential returned from WebAuthn registration.', + 'browser-webauthn-no-credential', + 'browser', + 'The WebAuthn registration ceremony completed but did not return a valid credential.', + ); + } + + const response = credential.response as AuthenticatorAttestationResponse; + + // Encode response fields back to base64url + const registrationResponse = { + id: credential.id, + rawId: arrayBufferToBase64url(credential.rawId), + type: credential.type, + response: { + attestationObject: arrayBufferToBase64url(response.attestationObject), + clientDataJSON: arrayBufferToBase64url(response.clientDataJSON), + ...(response.getTransports && { + transports: response.getTransports(), + }), + }, + ...(credential.authenticatorAttachment && { + authenticatorAttachment: credential.authenticatorAttachment, + }), + }; + + return JSON.stringify(registrationResponse); + } catch (error) { + if (error instanceof AsgardeoRuntimeError) { + throw error; + } + + if (error instanceof Error) { + // Map specific WebAuthn errors to AsgardeoRuntimeError/friendly messages as needed + throw new AsgardeoRuntimeError( + `Passkey registration failed: ${error.message}`, + 'browser-webauthn-registration-error', + 'browser', + `WebAuthn registration failed with error: ${error.name}`, + ); + } + + throw new AsgardeoRuntimeError( + 'Passkey registration failed due to an unexpected error.', + 'browser-webauthn-unexpected-error', + 'browser', + 'An unexpected error occurred during WebAuthn registration.', + ); + } +}; + +/** + * Handles WebAuthn/Passkey authentication flow for browser environments. + * + * @param challengeData - JSON stringified challenge data containing WebAuthn request options. + * @returns Promise that resolves to a JSON string containing the WebAuthn authentication response. + */ +export const handlePasskeyAuthentication = async (challengeData: string): Promise => { + if (!window.navigator.credentials || !window.navigator.credentials.get) { + throw new AsgardeoRuntimeError( + 'WebAuthn is not supported in this browser.', + 'browser-webauthn-not-supported', + 'browser', + 'WebAuthn/Passkey authentication requires a browser that supports the Web Authentication API.', + ); + } + + try { + const requestOptions = JSON.parse(challengeData); + + // Decode challenge and allowed credentials from base64url to BufferSource + const publicKey = { + ...requestOptions, + challenge: base64urlToArrayBuffer(requestOptions.challenge), + ...(requestOptions.allowCredentials && { + allowCredentials: requestOptions.allowCredentials.map((cred: any) => ({ + ...cred, + id: base64urlToArrayBuffer(cred.id), + })), + }), + }; + + const credential = (await navigator.credentials.get({ + publicKey, + })) as PublicKeyCredential; + + if (!credential) { + throw new AsgardeoRuntimeError( + 'No credential returned from WebAuthn authentication.', + 'browser-webauthn-no-credential', + 'browser', + 'The WebAuthn authentication ceremony completed but did not return a valid credential.', + ); + } + + const response = credential.response as AuthenticatorAssertionResponse; + + // Encode response fields back to base64url + const authenticationResponse = { + id: credential.id, + rawId: arrayBufferToBase64url(credential.rawId), + type: credential.type, + response: { + authenticatorData: arrayBufferToBase64url(response.authenticatorData), + clientDataJSON: arrayBufferToBase64url(response.clientDataJSON), + signature: arrayBufferToBase64url(response.signature), + ...(response.userHandle && { + userHandle: arrayBufferToBase64url(response.userHandle), + }), + }, + ...(credential.authenticatorAttachment && { + authenticatorAttachment: credential.authenticatorAttachment, + }), + }; + + return JSON.stringify(authenticationResponse); + } catch (error) { + if (error instanceof AsgardeoRuntimeError) { + throw error; + } + + if (error instanceof Error) { + throw new AsgardeoRuntimeError( + `Passkey authentication failed: ${error.message}`, + 'browser-webauthn-authentication-error', + 'browser', + `WebAuthn authentication failed with error: ${error.name}`, + ); + } + + throw new AsgardeoRuntimeError( + 'Passkey authentication failed due to an unexpected error.', + 'browser-webauthn-unexpected-error', + 'browser', + 'An unexpected error occurred during WebAuthn authentication.', + ); + } +};