Skip to content

feat: implement passkey-only authentication#17

Open
angristan wants to merge 1 commit intomainfrom
claude/passkey-only-auth-Tz1Bq
Open

feat: implement passkey-only authentication#17
angristan wants to merge 1 commit intomainfrom
claude/passkey-only-auth-Tz1Bq

Conversation

@angristan
Copy link
Owner

Replace password-based authentication with WebAuthn passkeys for
improved security:

  • Add web-auth/webauthn-lib dependency for WebAuthn support
  • Create passkeys table and Passkey model
  • Add passkey registration/authentication actions
  • Update setup flow to register passkey instead of password
  • Update login to authenticate via passkey
  • Add passkey management to settings page (add/delete passkeys)
  • Keep optional TOTP 2FA as additional security layer
  • Update PHPStan config to ignore WebAuthn named param false positives

Users now sign in using device biometrics (fingerprint, face) or
security keys instead of passwords.

Replace password-based authentication with WebAuthn passkeys for
improved security:

- Add web-auth/webauthn-lib dependency for WebAuthn support
- Create passkeys table and Passkey model
- Add passkey registration/authentication actions
- Update setup flow to register passkey instead of password
- Update login to authenticate via passkey
- Add passkey management to settings page (add/delete passkeys)
- Keep optional TOTP 2FA as additional security layer
- Update PHPStan config to ignore WebAuthn named param false positives

Users now sign in using device biometrics (fingerprint, face) or
security keys instead of passwords.
Copilot AI review requested due to automatic review settings January 7, 2026 11:20
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements passkey-only authentication to replace traditional password-based login, enhancing security through WebAuthn biometric authentication (fingerprint, face recognition) or security keys.

Key Changes

  • Replaced password authentication with WebAuthn passkeys using the web-auth/webauthn-lib library
  • Added passkey registration during setup flow and passkey management in settings
  • Maintained optional TOTP 2FA as an additional security layer

Reviewed changes

Copilot reviewed 18 out of 20 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
database/migrations/2026_01_06_000001_create_passkeys_table.php Creates passkeys table with credential storage
app/Models/Passkey.php New model for managing WebAuthn passkey credentials
routes/admin.php Added passkey authentication and management routes
resources/js/Pages/Setup.tsx Replaced password fields with passkey registration flow
resources/js/Pages/Auth/Login.tsx Implemented passkey-based authentication UI
resources/js/Pages/Settings/Index.tsx Added passkey management interface (add/delete)
app/Http/Controllers/Admin/SetupController.php Multi-step setup with passkey registration
app/Http/Controllers/Admin/AuthController.php Passkey verification endpoints for login
app/Http/Controllers/Admin/SettingsController.php Passkey CRUD operations
app/Actions/Admin/Passkey/*.php WebAuthn credential generation and verification logic
composer.json Added web-auth/webauthn-lib dependency
phpstan.neon Added ignores for WebAuthn library named parameters

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


// Find the passkey by credential ID
$credentialId = $publicKeyCredential->rawId;
$passkey = Passkey::whereRaw('credential_id = ?', [$credentialId])->first();
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whereRaw query with a placeholder parameter is used incorrectly. The credential_id is a binary field, and comparing it directly may not work as expected across different database engines. Consider using a proper Eloquent where clause or ensure the binary comparison is database-agnostic.

Suggested change
$passkey = Passkey::whereRaw('credential_id = ?', [$credentialId])->first();
$passkey = Passkey::where('credential_id', $credentialId)->first();

Copilot uses AI. Check for mistakes.
]);

return ['success' => true, 'passkey' => $passkey];
} catch (\Throwable $e) {
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block catches all Throwable exceptions and returns a generic error message. This broad exception handling may hide important debugging information. Consider logging the exception or providing more specific error messages based on the exception type to aid troubleshooting.

Suggested change
} catch (\Throwable $e) {
} catch (\Throwable $e) {
logger()->error('Failed to verify WebAuthn registration.', [
'exception' => $e,
]);

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +50
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}

// Helper to convert ArrayBuffer to base64
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}

// Get CSRF token from cookie
function getCsrfToken(): string {
const xsrfCookie = document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='));
if (xsrfCookie) {
return decodeURIComponent(xsrfCookie.split('=')[1]);
}
return (
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || ''
);
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper functions base64ToArrayBuffer, arrayBufferToBase64, and getCsrfToken are duplicated across three different files (Setup.tsx, Login.tsx, and Settings/Index.tsx). This code duplication violates the DRY principle and makes maintenance harder. Consider extracting these utility functions into a shared module that can be imported across all files.

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +124
} catch {
setErrors({ general: 'An error occurred' });
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the catch block at line 123 swallows the actual error and returns a generic message. This makes debugging difficult as the actual error information is lost. The error should be logged or at least include more context about what went wrong.

Suggested change
} catch {
setErrors({ general: 'An error occurred' });
} catch (error) {
console.error('Error while saving setup info:', error);
setErrors({
general: 'An error occurred while saving setup info. Please try again.',
});

Copilot uses AI. Check for mistakes.
}

// Expire after 2 minutes
if (! $challenge || now()->timestamp - $challengeAt > 120) {
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The challenge expiration time is hardcoded as 120 seconds in multiple locations (lines 85, 401). This magic number should be extracted into a named constant to improve maintainability and ensure consistency across the codebase.

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +129
public function storeInfo(Request $request): JsonResponse
{
$validated = $request->validate([
'site_name' => ['required', 'string', 'max:255'],
'site_url' => ['required', 'url', 'max:1024'],
'username' => ['required', 'string', 'min:3', 'max:50'],
'email' => ['required', 'email', 'max:255'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);

// Store in session for later
$request->session()->put('setup_info', $validated);

return response()->json(['success' => true]);
}

/**
* Generate passkey registration options (step 2).
*/
public function passkeyOptions(Request $request): JsonResponse
{
$setupInfo = $request->session()->get('setup_info');

if (! $setupInfo) {
return response()->json(['error' => 'Setup info not found. Please start over.'], 400);
}

// Temporarily set the required settings for passkey generation
Setting::setValue('site_name', $setupInfo['site_name']);
Setting::setValue('site_url', $setupInfo['site_url']);
Setting::setValue('admin_username', $setupInfo['username']);
Setting::setValue('admin_email', $setupInfo['email']);

$result = GenerateRegistrationOptions::run();

// Store challenge in session
$request->session()->put('setup_passkey_challenge', $result['challenge']);
$request->session()->put('setup_passkey_challenge_at', now()->timestamp);

return response()->json($result['options']);
}

/**
* Complete setup with passkey registration (step 3).
*/
public function store(Request $request): JsonResponse
{
$setupInfo = $request->session()->get('setup_info');
$challenge = $request->session()->get('setup_passkey_challenge');
$challengeAt = $request->session()->get('setup_passkey_challenge_at', 0);

if (! $setupInfo) {
return response()->json(['error' => 'Setup info not found. Please start over.'], 400);
}

// Expire after 2 minutes
if (! $challenge || now()->timestamp - $challengeAt > 120) {
$request->session()->forget(['setup_passkey_challenge', 'setup_passkey_challenge_at']);

return response()->json(['error' => 'Challenge expired. Please try again.'], 400);
}

$validated = $request->validate([
'credential' => ['required', 'array'],
'passkey_name' => ['required', 'string', 'max:255'],
]);

// Verify and register the passkey
$result = VerifyRegistration::run(
$validated['credential'],
$challenge,
$validated['passkey_name']
);

if (! $result['success']) {
return response()->json(['error' => $result['message'] ?? 'Passkey registration failed'], 400);
}

// Complete setup (settings were already saved in passkeyOptions)
SetupAdmin::run(
$validated['username'],
$validated['email'],
$validated['password'],
$validated['site_name'],
$validated['site_url']
$setupInfo['username'],
$setupInfo['email'],
$setupInfo['site_name'],
$setupInfo['site_url']
);

// Clear session data
$request->session()->forget([
'setup_info',
'setup_passkey_challenge',
'setup_passkey_challenge_at',
]);

// Auto-login after setup
$request->session()->regenerate();
$request->session()->put('admin_authenticated', true);

return redirect()->route('admin.dashboard');
return response()->json([
'success' => true,
'redirect' => route('admin.dashboard'),
]);
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setup endpoints in SetupController (storeInfo, passkeyOptions, and store) can be invoked even after initial setup is complete, allowing an unauthenticated attacker to re-run the setup wizard and register a new admin passkey. Because these actions rely only on session state and do not re-check SetupAdmin::isComplete() or require existing admin authentication, an attacker can call /admin/setup/info/admin/setup/passkey/options/admin/setup from the public login page, complete a WebAuthn registration, and end up with admin_authenticated set to true and a valid admin passkey, bypassing any configured TOTP 2FA. You should hard-block all setup routes once setup_complete is true (e.g., by tightening RedirectIfSetupRequired to cover all admin.setup* routes and/or adding explicit guards in these methods) so that the setup flow cannot be used post-install to create additional admin credentials.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants