feat: add option to OIDC client to require re-authentication (#747)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Robert Mang
2025-08-22 08:56:40 +02:00
committed by GitHub
parent 7ab0fd3028
commit 0cb039d35d
22 changed files with 362 additions and 44 deletions

View File

@@ -48,8 +48,12 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.customClaimService = service.NewCustomClaimService(db)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
@@ -58,10 +62,5 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
}
return svc, nil
}

View File

@@ -350,6 +350,15 @@ func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest
}
type ReauthenticationRequiredError struct{}
func (e *ReauthenticationRequiredError) Error() string {
return "reauthentication required"
}
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
return http.StatusUnauthorized
}
type OpenSignupDisabledError struct{}
func (e *OpenSignupDisabledError) Error() string {

View File

@@ -25,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
@@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
cookie.AddAccessTokenCookie(c, 0, "")
c.Status(http.StatusNoContent)
}
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
var token string
// Try to create a reauthentication token with WebAuthn
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err == nil {
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
if err != nil {
_ = c.Error(err)
return
}
} else {
// If WebAuthn fails, try to create a reauthentication token with the access token
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
if err != nil {
_ = c.Error(err)
return
}
}
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
}

View File

@@ -7,6 +7,7 @@ type OidcClientMetaDataDto struct {
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
LaunchURL *string `json:"launchURL"`
RequiresReauthentication bool `json:"requiresReauthentication"`
}
type OidcClientDto struct {
@@ -34,6 +35,7 @@ type OidcClientCreateDto struct {
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
}
@@ -56,6 +58,7 @@ type AuthorizeOidcClientRequestDto struct {
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
}
type AuthorizeOidcClientResponseDto struct {

View File

@@ -25,6 +25,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
)
}
@@ -104,6 +105,20 @@ func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return nil
}
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
st := j.db.

View File

@@ -48,6 +48,7 @@ type OidcClient struct {
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
RequiresReauthentication bool
Credentials OidcClientCredentials
LaunchURL *string

View File

@@ -45,6 +45,15 @@ type PublicKeyCredentialRequestOptions struct {
Timeout time.Duration
}
type ReauthenticationToken struct {
Base
Token string
ExpiresAt datatype.DateTime
UserID string
User User
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type

View File

@@ -50,6 +50,7 @@ type OidcService struct {
appConfigService *AppConfigService
auditLogService *AuditLogService
customClaimService *CustomClaimService
webAuthnService *WebAuthnService
httpClient *http.Client
jwkCache *jwk.Cache
@@ -62,6 +63,7 @@ func NewOidcService(
appConfigService *AppConfigService,
auditLogService *AuditLogService,
customClaimService *CustomClaimService,
webAuthnService *WebAuthnService,
) (s *OidcService, err error) {
s = &OidcService{
db: db,
@@ -69,6 +71,7 @@ func NewOidcService(
appConfigService: appConfigService,
auditLogService: auditLogService,
customClaimService: customClaimService,
webAuthnService: webAuthnService,
}
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
@@ -123,6 +126,16 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err
}
if client.RequiresReauthentication {
if input.ReauthenticationToken == "" {
return "", "", &common.ReauthenticationRequiredError{}
}
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
if err != nil {
return "", "", err
}
}
// If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
@@ -714,6 +727,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.IsPublic = input.IsPublic
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication
client.LaunchURL = input.LaunchURL
// Credentials

View File

@@ -336,3 +336,136 @@ func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credenti
func (s *WebAuthnService) updateWebAuthnConfig() {
s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value
}
func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
token, err := s.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", fmt.Errorf("invalid access token: %w", err)
}
userID, ok := token.Subject()
if !ok {
return "", fmt.Errorf("access token does not contain user ID")
}
// Check if token is issued less than a minute ago
tokenExpiration, ok := token.IssuedAt()
if !ok || time.Since(tokenExpiration) > time.Minute {
return "", &common.ReauthenticationRequiredError{}
}
var user model.User
err = tx.
WithContext(ctx).
First(&user, "id = ?", userID).
Error
if err != nil {
return "", fmt.Errorf("failed to load user: %w", err)
}
reauthToken, err := s.createReauthenticationToken(ctx, tx, user.ID)
if err != nil {
return "", err
}
err = tx.Commit().Error
if err != nil {
return "", err
}
return reauthToken, nil
}
func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Retrieve and delete the session
var storedSession model.WebauthnSession
err := tx.
WithContext(ctx).
Clauses(clause.Returning{}).
Delete(&storedSession, "id = ? AND expires_at > ?", sessionID, datatype.DateTime(time.Now())).
Error
if err != nil {
return "", fmt.Errorf("failed to load WebAuthn session: %w", err)
}
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
}
// Validate the credential assertion
var user *model.User
_, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
innerErr := tx.
WithContext(ctx).
Preload("Credentials").
First(&user, "id = ?", string(userHandle)).
Error
if innerErr != nil {
return nil, innerErr
}
return user, nil
}, session, credentialAssertionData)
if err != nil || user == nil {
return "", err
}
// Create reauthentication token
token, err := s.createReauthenticationToken(ctx, tx, user.ID)
if err != nil {
return "", err
}
err = tx.Commit().Error
if err != nil {
return "", err
}
return token, nil
}
func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) error {
hashedToken := utils.CreateSha256Hash(token)
result := tx.WithContext(ctx).
Clauses(clause.Returning{}).
Delete(&model.ReauthenticationToken{}, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now()))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return &common.ReauthenticationRequiredError{}
}
return nil
}
func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) {
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
}
reauthToken := model.ReauthenticationToken{
Token: utils.CreateSha256Hash(token),
ExpiresAt: datatype.DateTime(time.Now().Add(3 * time.Minute)),
UserID: userID,
}
err = tx.WithContext(ctx).Create(&reauthToken).Error
if err != nil {
return "", err
}
return token, nil
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
DROP TABLE IF EXISTS reauthentication_tokens;

View File

@@ -0,0 +1,11 @@
ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE reauthentication_tokens (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token);

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
DROP INDEX IF EXISTS idx_reauthentication_tokens_token;
DROP TABLE IF EXISTS reauthentication_tokens;

View File

@@ -0,0 +1,11 @@
ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE reauthentication_tokens (
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token);

View File

@@ -276,6 +276,8 @@
"public_clients_description": "Öffentliche Clients haben kein Client-Geheimnis und verwenden stattdessen PKCE. Aktiviere dies, wenn dein Client eine SPA oder mobile App ist.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Der Public Key Code Exchange (öffentlicher Schlüsselaustausch) ist eine Sicherheitsfunktion, um CSRF Angriffe und Angriffe zum Abfangen von Autorisierungscodes zu verhindern.",
"requires_reauthentication": "Erfordert erneute Authentifizierung",
"requires_users_to_authenticate_again_on_each_authorization": "Erfordert eine neue Authentifizierung bei jeder Autorisierung, auch wenn der Benutzer bereits angemeldet ist",
"name_logo": "{name} Logo",
"change_logo": "Logo ändern",
"upload_logo": "Logo hochladen",

View File

@@ -276,6 +276,8 @@
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"requires_reauthentication": "Requires Re-Authentication",
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",

View File

@@ -19,7 +19,8 @@ class OidcService extends APIService {
callbackURL: string,
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: string
codeChallengeMethod?: string,
reauthenticationToken?: string
) {
const res = await this.api.post('/oidc/authorize', {
scope,
@@ -27,7 +28,8 @@ class OidcService extends APIService {
callbackURL,
clientId,
codeChallenge,
codeChallengeMethod
codeChallengeMethod,
reauthenticationToken
});
return res.data as AuthorizeResponse;

View File

@@ -37,6 +37,11 @@ class WebAuthnService extends APIService {
async updateCredentialName(id: string, name: string) {
await this.api.patch(`/webauthn/credentials/${id}`, { name });
}
async reauthenticate(body?: AuthenticationResponseJSON) {
const res = await this.api.post('/webauthn/reauthenticate', body);
return res.data.reauthenticationToken as string;
}
}
export default WebAuthnService;

View File

@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
id: string;
name: string;
hasLogo: boolean;
requiresReauthentication: boolean;
launchURL?: string;
};
@@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & {
logoutCallbackURLs: string[];
isPublic: boolean;
pkceEnabled: boolean;
requiresReauthentication: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
};

View File

@@ -11,7 +11,7 @@
import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
import { startAuthentication } from '@simplewebauthn/browser';
import { startAuthentication, type AuthenticationResponseJSON } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import type { PageProps } from './$types';
@@ -29,6 +29,7 @@
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
let authorizationConfirmed = $state(false);
let userSignedInAt: Date | undefined;
onMount(() => {
if ($userStore) {
@@ -38,13 +39,16 @@
async function authorize() {
isLoading = true;
let authResponse: AuthenticationResponseJSON | undefined;
try {
// Get access token if not signed in
if (!$userStore?.id) {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
await userStore.setUser(user);
userStore.setUser(user);
userSignedInAt = new Date();
}
if (!authorizationConfirmed) {
@@ -56,8 +60,28 @@
}
}
let reauthToken: string | undefined;
if (client?.requiresReauthentication) {
let authResponse;
const signedInRecently =
userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000;
if (!signedInRecently) {
const loginOptions = await webauthnService.getLoginOptions();
authResponse = await startAuthentication({ optionsJSON: loginOptions });
}
reauthToken = await webauthnService.reauthenticate(authResponse);
}
await oidService
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.authorize(
client!.id,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod,
reauthToken
)
.then(async ({ code, callbackURL, issuer }) => {
onSuccess(code, callbackURL, issuer);
});

View File

@@ -36,7 +36,8 @@
[m.userinfo_url()]: `https://${page.url.hostname}/api/oidc/userinfo`,
[m.logout_url()]: `https://${page.url.hostname}/api/oidc/end-session`,
[m.certificate_url()]: `https://${page.url.hostname}/.well-known/jwks.json`,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled()
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -49,6 +50,9 @@
client.isPublic = updatedClient.isPublic;
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
setupDetails[m.requires_reauthentication()] = updatedClient.requiresReauthentication
? m.enabled()
: m.disabled();
await Promise.all([dataPromise, imagePromise])
.then(() => {
@@ -120,14 +124,14 @@
<Card.Content>
<div class="flex flex-col">
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{m.client_id()}</Label>
<Label class="mb-0 w-50">{m.client_id()}</Label>
<CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard>
</div>
{#if !client.isPublic}
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{m.client_secret()}</Label>
<Label class="mb-0 w-50">{m.client_secret()}</Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -154,7 +158,7 @@
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{key}</Label>
<Label class="mb-0 w-50">{key}</Label>
<CopyToClipboard {value}>
<span class="text-muted-foreground text-sm">{value}</span>
</CopyToClipboard>

View File

@@ -39,6 +39,7 @@
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false,
requiresReauthentication: existingClient?.requiresReauthentication || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
@@ -51,6 +52,7 @@
logoutCallbackURLs: z.array(z.string().nonempty()),
isPublic: z.boolean(),
pkceEnabled: z.boolean(),
requiresReauthentication: z.boolean(),
launchURL: optionalUrl,
credentials: z.object({
federatedIdentities: z.array(
@@ -147,6 +149,12 @@
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
bind:checked={$inputs.pkceEnabled.value}
/>
<SwitchWithLabel
id="requires-reauthentication"
label={m.requires_reauthentication()}
description={m.requires_users_to_authenticate_again_on_each_authorization()}
bind:checked={$inputs.requiresReauthentication.value}
/>
</div>
<div class="mt-8">
<Label for="logo">{m.logo()}</Label>

View File

@@ -594,3 +594,30 @@ test('Authorize existing client with federated identity', async ({ page }) => {
expect(res.expires_in).not.toBeNull;
expect(res.token_type).toBe('Bearer');
});
test('Forces reauthentication when client requires it', async ({ page, request }) => {
let webauthnStartCalled = false;
await page.route('/api/webauthn/login/start', async (route) => {
webauthnStartCalled = true;
await route.continue();
});
await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, {
data: { ...oidcClients.nextcloud, requiresReauthentication: true }
});
await (await passkeyUtil.init(page)).addPasskey();
const urlParams = createUrlParams(oidcClients.nextcloud);
await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByTestId('scopes')).not.toBeVisible();
await page.waitForURL(oidcClients.nextcloud.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
throw e;
}
});
expect(webauthnStartCalled).toBe(true);
});