Add passkey authentication endpoints and storage.

Implement WebAuthn registration/authentication flow and related API and login test updates so CI can validate the new passkey auth paths.
This commit is contained in:
SvenBledt
2026-02-27 11:55:19 +01:00
parent 853301ef3e
commit 8ef567592a
13 changed files with 722 additions and 8 deletions

View File

@@ -38,6 +38,7 @@ use App\Jobs\Company\CreateCompanyToken;
use Illuminate\Support\Facades\Response;
use Laravel\Socialite\Facades\Socialite;
use App\Http\Requests\Login\LoginRequest;
use App\Services\Auth\Passkeys\PasskeyService;
use App\Libraries\OAuth\Providers\Google;
use Illuminate\Database\Eloquent\Builder;
use App\DataMapper\Analytics\LoginFailure;
@@ -108,7 +109,34 @@ class LoginController extends BaseController
->header('X-Api-Version', config('ninja.minimum_client_version'));
}
if ($this->attemptLogin($request)) {
$passkeyService = app(PasskeyService::class);
$passkeyPayload = $request->input('passkey_authentication');
$passkeyToken = $request->input('passkey_challenge_token');
$passwordlessPasskeyAttempt = !$request->filled('password') && $request->filled('passkey_challenge_token') && is_array($passkeyPayload);
if ($passwordlessPasskeyAttempt) {
$user = MultiDB::hasUser(['email' => $request->input('email')]);
if (!$user) {
return response()
->json(['message' => ctrans('texts.invalid_credentials')], 401)
->header('X-App-Version', config('ninja.app_version'))
->header('X-Api-Version', config('ninja.minimum_client_version'));
}
try {
$passkeyUser = $passkeyService->authenticate($user, (string) $passkeyToken, $passkeyPayload);
Auth::login($passkeyUser, false);
} catch (\Throwable $e) {
return response()
->json(['message' => ctrans('texts.invalid_credentials')], 422)
->header('X-App-Version', config('ninja.app_version'))
->header('X-Api-Version', config('ninja.minimum_client_version'));
}
}
if ($passwordlessPasskeyAttempt || $this->attemptLogin($request)) {
LightLogs::create(new LoginSuccess())
->increment()
->batch();
@@ -129,8 +157,26 @@ class LoginController extends BaseController
/** @var \App\Models\User $user */
$user = $this->guard()->user();
//2FA
if ($user->google_2fa_secret && $request->has('one_time_password')) {
$hasPasskeys = $user->passkey_credentials()->exists();
$hasOneTimePassword = $request->filled('one_time_password');
$hasPasskeyAssertion = is_array($passkeyPayload) && $request->filled('passkey_challenge_token');
$requiresSecondFactor = (bool) $user->google_2fa_secret || $hasPasskeys;
if ($requiresSecondFactor && !$passwordlessPasskeyAttempt && !$hasOneTimePassword && !$hasPasskeyAssertion) {
$passkeyOptions = $hasPasskeys ? $passkeyService->getAuthenticationOptions($user) : null;
return response()
->json([
'message' => ctrans('texts.invalid_one_time_password'),
'requires_second_factor' => true,
'passkey_options' => $passkeyOptions,
], 422)
->header('X-App-Version', config('ninja.app_version'))
->header('X-Api-Version', config('ninja.minimum_client_version'));
}
// TOTP fallback for existing users
if ($user->google_2fa_secret && $hasOneTimePassword) {
$google2fa = new Google2FA();
if (strlen($request->input('one_time_password')) == 0 || !$google2fa->verifyKey(decrypt($user->google_2fa_secret), $request->input('one_time_password'))) {
@@ -139,9 +185,24 @@ class LoginController extends BaseController
->header('X-App-Version', config('ninja.app_version'))
->header('X-Api-Version', config('ninja.minimum_client_version'));
}
} elseif (strlen($user->google_2fa_secret ?? '') > 2 && !$request->has('one_time_password')) {
} elseif ($requiresSecondFactor && !$passwordlessPasskeyAttempt && !$hasOneTimePassword && $hasPasskeyAssertion) {
try {
$passkeyService->authenticate($user, (string) $passkeyToken, $passkeyPayload);
} catch (\Throwable $e) {
return response()
->json(['message' => ctrans('texts.invalid_one_time_password')], 422)
->header('X-App-Version', config('ninja.app_version'))
->header('X-Api-Version', config('ninja.minimum_client_version'));
}
} elseif (strlen($user->google_2fa_secret ?? '') > 2 && !$hasOneTimePassword) {
$passkeyOptions = $hasPasskeys ? $passkeyService->getAuthenticationOptions($user) : null;
return response()
->json(['message' => ctrans('texts.invalid_one_time_password')], 422)
->json([
'message' => ctrans('texts.invalid_one_time_password'),
'requires_second_factor' => true,
'passkey_options' => $passkeyOptions,
], 422)
->header('X-App-Version', config('ninja.app_version'))
->header('X-Api-Version', config('ninja.minimum_client_version'));
}

View File

@@ -0,0 +1,126 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Libraries\MultiDB;
use App\Models\PasskeyCredential;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Services\Auth\Passkeys\PasskeyService;
class PasskeyController extends BaseController
{
public function __construct(private readonly PasskeyService $passkeyService)
{
parent::__construct();
}
public function index(): JsonResponse
{
/** @var \App\Models\User $user */
$user = auth()->user();
return response()->json([
'data' => $user->passkey_credentials
->map(fn (PasskeyCredential $credential) => [
'id' => $credential->hashed_id,
'name' => $credential->name,
'created_at' => (int) $credential->created_at,
'last_used_at' => $credential->last_used_at?->timestamp,
])
->values(),
]);
}
public function registrationOptions(Request $request): JsonResponse
{
/** @var \App\Models\User $user */
$user = auth()->user();
$data = $this->passkeyService->getRegistrationOptions(
$user,
$request->string('name')->toString() ?: null
);
return response()->json(['data' => $data]);
}
public function store(Request $request): JsonResponse
{
/** @var \App\Models\User $user */
$user = auth()->user();
$validated = $request->validate([
'challenge_token' => ['required', 'string'],
'credential' => ['required', 'array'],
'credential.clientDataJSON' => ['required', 'string'],
'credential.attestationObject' => ['required', 'string'],
'credential.transports' => ['nullable', 'array'],
'name' => ['nullable', 'string', 'max:255'],
]);
$credential = $this->passkeyService->registerCredential(
$user,
$validated['challenge_token'],
$validated['credential'],
$validated['name'] ?? null
);
return response()->json([
'data' => [
'id' => $credential->hashed_id,
'name' => $credential->name,
],
'message' => 'Passkey added',
]);
}
public function destroy(PasskeyCredential $passkey): JsonResponse
{
/** @var \App\Models\User $user */
$user = auth()->user();
if ($passkey->user_id !== $user->id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$passkey->is_deleted = true;
$passkey->save();
$passkey->delete();
return response()->json(['message' => 'Passkey removed']);
}
public function loginOptions(Request $request): JsonResponse
{
$validated = $request->validate([
'email' => ['required', 'email:rfc'],
]);
if (!MultiDB::userFindAndSetDb($validated['email'])) {
return response()->json(['message' => ctrans('texts.invalid_credentials')], 422);
}
$user = \App\Models\User::query()->where('email', $validated['email'])->first();
if (!$user) {
return response()->json(['message' => ctrans('texts.invalid_credentials')], 422);
}
$data = $this->passkeyService->getAuthenticationOptions($user, true);
return response()->json(['data' => $data]);
}
}

View File

@@ -44,7 +44,9 @@ class LoginRequest extends Request
return [
'email' => $email_rules,
'password' => 'required|max:1000',
'password' => 'required_without:passkey_challenge_token|max:1000',
'passkey_challenge_token' => 'nullable|string|max:255',
'passkey_authentication' => 'nullable|array',
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
class PasskeyCredential extends BaseModel
{
use SoftDeletes;
use Filterable;
protected $fillable = [
'name',
'transports',
'last_used_at',
];
protected $casts = [
'transports' => 'array',
'last_used_at' => 'datetime',
];
public function getEntityType()
{
return self::class;
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
public function account(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Account::class);
}
}

View File

@@ -541,6 +541,11 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(Webhook::class)->withTrashed();
}
public function passkey_credentials(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PasskeyCredential::class)->where('is_deleted', false);
}
/**
* Returns a comma separated list of user permissions.
*

View File

@@ -0,0 +1,217 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
declare(strict_types=1);
namespace App\Services\Auth\Passkeys;
use App\Models\PasskeyCredential;
use App\Models\User;
use Illuminate\Support\Str;
use lbuchs\WebAuthn\WebAuthn;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
class PasskeyService
{
private const CACHE_PREFIX = 'passkey:challenge:';
private const CACHE_TTL_SECONDS = 300;
public function getRegistrationOptions(User $user, ?string $displayName = null): array
{
$name = $displayName ?: trim($user->first_name . ' ' . $user->last_name);
$webAuthn = $this->makeWebAuthn();
$args = $webAuthn->getCreateArgs(
(string) $user->id,
$user->email,
$name ?: $user->email,
240,
true,
'preferred',
null
);
$token = $this->storeChallenge($webAuthn->getChallenge(), [
'action' => 'registration',
'user_id' => $user->id,
]);
return [
'publicKey' => $args,
'challenge_token' => $token,
];
}
public function registerCredential(User $user, string $challengeToken, array $payload, ?string $name = null): PasskeyCredential
{
$challengeData = $this->getChallenge($challengeToken, 'registration', $user->id);
$webAuthn = $this->makeWebAuthn();
$result = $webAuthn->processCreate(
$this->decodeBase64Input($payload['clientDataJSON'] ?? null),
$this->decodeBase64Input($payload['attestationObject'] ?? null),
$challengeData['challenge'],
false,
true,
false
);
$credential = PasskeyCredential::query()->updateOrCreate(
[
'account_id' => $user->account_id,
'user_id' => $user->id,
'credential_id' => base64_encode($result->credentialId),
],
[
'name' => $name ?: ctrans('texts.passkey'),
'credential_public_key' => base64_encode($result->credentialPublicKey),
'signature_counter' => (int) ($result->signatureCounter ?? 0),
'transports' => $payload['transports'] ?? null,
'is_deleted' => false,
]
);
return $credential;
}
public function getAuthenticationOptions(?User $user = null, bool $passwordless = false): array
{
$webAuthn = $this->makeWebAuthn();
$credentialIds = [];
if ($user) {
$credentialIds = $user->passkey_credentials
->pluck('credential_id')
->map(fn (string $value) => base64_decode($value))
->filter()
->values()
->toArray();
}
$args = $webAuthn->getGetArgs(
$credentialIds,
240,
true,
true,
true,
true,
true,
'preferred'
);
$token = $this->storeChallenge($webAuthn->getChallenge(), [
'action' => 'authentication',
'user_id' => $user?->id,
'passwordless' => $passwordless,
]);
return [
'publicKey' => $args,
'challenge_token' => $token,
];
}
public function authenticate(?User $user, string $challengeToken, array $payload): User
{
$challengeData = $this->getChallenge($challengeToken, 'authentication', $user?->id);
$credentialId = base64_encode($this->decodeBase64Input($payload['id'] ?? null));
$credentialQuery = PasskeyCredential::query()
->where('credential_id', $credentialId)
->where('is_deleted', false);
if ($user) {
$credentialQuery->where('user_id', $user->id);
}
/** @var PasskeyCredential|null $credential */
$credential = $credentialQuery->first();
if (!$credential) {
throw new \RuntimeException('Passkey credential not found.');
}
/** @var User $resolvedUser */
$resolvedUser = $user ?: User::query()->findOrFail($credential->user_id);
$webAuthn = $this->makeWebAuthn();
$webAuthn->processGet(
$this->decodeBase64Input($payload['clientDataJSON'] ?? null),
$this->decodeBase64Input($payload['authenticatorData'] ?? null),
$this->decodeBase64Input($payload['signature'] ?? null),
base64_decode($credential->credential_public_key),
$challengeData['challenge'],
null,
false
);
$credential->last_used_at = Carbon::now();
$credential->save();
return $resolvedUser;
}
private function makeWebAuthn(): WebAuthn
{
$rpId = parse_url(config('ninja.app_url'), PHP_URL_HOST) ?: request()->getHost();
return new WebAuthn(config('ninja.app_name'), $rpId, ['none', 'packed', 'fido-u2f', 'android-key', 'android-safetynet', 'apple', 'tpm']);
}
private function storeChallenge(string $challenge, array $meta): string
{
$token = Str::random(40);
Cache::put(self::CACHE_PREFIX . $token, array_merge($meta, [
'challenge' => $challenge,
]), self::CACHE_TTL_SECONDS);
return $token;
}
private function getChallenge(string $token, string $action, ?int $userId): array
{
/** @var array<string, mixed>|null $payload */
$payload = Cache::pull(self::CACHE_PREFIX . $token);
if (!$payload || ($payload['action'] ?? null) !== $action) {
throw new \RuntimeException('Invalid passkey challenge.');
}
if (!is_null($userId) && (int) ($payload['user_id'] ?? 0) !== $userId) {
throw new \RuntimeException('Passkey challenge does not match user.');
}
return $payload;
}
private function decodeBase64Input(?string $value): string
{
if (!$value) {
throw new \RuntimeException('Invalid passkey payload.');
}
$normalized = str_replace(['-', '_'], ['+', '/'], $value);
$padding = strlen($normalized) % 4;
if ($padding > 0) {
$normalized .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode($normalized, true);
if ($decoded === false) {
throw new \RuntimeException('Invalid base64 value.');
}
return $decoded;
}
}

View File

@@ -66,6 +66,8 @@ class UserTransformer extends EntityTransformer
'oauth_provider_id' => (string) $user->oauth_provider_id,
'last_confirmed_email_address' => (string) $user->last_confirmed_email_address ?: '',
'google_2fa_secret' => (bool) $user->google_2fa_secret,
'passkey_enabled' => $user->passkey_credentials()->exists(),
'passkey_count' => $user->passkey_credentials()->count(),
'has_password' => (bool) empty($user->password) ? false : true,
'oauth_user_token' => empty($user->oauth_user_token) ? '' : '***',
'verified_phone_number' => (bool) $user->verified_phone_number,

View File

@@ -78,6 +78,7 @@
"laravel/socialite": "^5",
"laravel/tinker": "^2.7",
"laravel/ui": "^4.0",
"lbuchs/webauthn": "^2.2",
"league/csv": "^9.6",
"league/flysystem-aws-s3-v3": "^3.0",
"league/fractal": "^0.20.0",

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('passkey_credentials')) {
return;
}
Schema::create('passkey_credentials', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('account_id')->index();
$table->unsignedInteger('user_id')->index();
$table->string('name')->nullable();
$table->text('credential_id');
$table->longText('credential_public_key');
$table->unsignedBigInteger('signature_counter')->default(0);
$table->json('transports')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->boolean('is_deleted')->default(false);
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'is_deleted']);
});
}
public function down(): void
{
Schema::dropIfExists('passkey_credentials');
}
};

View File

@@ -99,6 +99,16 @@
type: string
example: '123456'
readOnly: true
passkey_enabled:
description: 'Boolean flag if passkey auth is enabled for this user'
type: boolean
example: true
readOnly: true
passkey_count:
description: 'The number of registered passkeys for this user'
type: integer
example: 2
readOnly: true
company_user:
$ref: '#/components/schemas/CompanyUserRef'
type: object
@@ -203,6 +213,16 @@
type: string
example: '123456'
readOnly: true
passkey_enabled:
description: 'Boolean flag if passkey auth is enabled for this user'
type: boolean
example: true
readOnly: true
passkey_count:
description: 'The number of registered passkeys for this user'
type: integer
example: 2
readOnly: true
oauth_user_refresh_token:
description: 'OAuth refresh token for the user'
type: string

View File

@@ -151,6 +151,122 @@ paths:
default:
$ref: "#/components/responses/default"
/api/v1/passkeys/login/options:
post:
tags:
- auth
summary: "Passkey login options"
operationId: postPasskeyLoginOptions
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
email:
type: string
example: "demo@invoiceninja.com"
required:
- email
responses:
200:
description: "Returns passkey authentication options and challenge token"
401:
$ref: "#/components/responses/401"
422:
$ref: "#/components/responses/422"
default:
$ref: "#/components/responses/default"
/api/v1/settings/passkeys:
get:
tags:
- settings
summary: "List passkeys"
operationId: getSettingsPasskeys
parameters:
- $ref: "#/components/parameters/X-API-TOKEN"
responses:
200:
description: "Returns passkeys for the current user"
401:
$ref: "#/components/responses/401"
default:
$ref: "#/components/responses/default"
post:
tags:
- settings
summary: "Create passkey"
operationId: postSettingsPasskeys
parameters:
- $ref: "#/components/parameters/X-API-TOKEN"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
challenge_token:
type: string
credential:
type: object
additionalProperties: true
name:
type: string
required:
- challenge_token
- credential
responses:
200:
description: "Passkey added"
401:
$ref: "#/components/responses/401"
422:
$ref: "#/components/responses/422"
default:
$ref: "#/components/responses/default"
/api/v1/settings/passkeys/options:
post:
tags:
- settings
summary: "Passkey registration options"
operationId: postSettingsPasskeysOptions
parameters:
- $ref: "#/components/parameters/X-API-TOKEN"
responses:
200:
description: "Returns passkey registration options and challenge token"
401:
$ref: "#/components/responses/401"
default:
$ref: "#/components/responses/default"
/api/v1/settings/passkeys/{passkey}:
delete:
tags:
- settings
summary: "Delete passkey"
operationId: deleteSettingsPasskey
parameters:
- $ref: "#/components/parameters/X-API-TOKEN"
- name: passkey
in: path
required: true
schema:
type: string
responses:
200:
description: "Passkey removed"
401:
$ref: "#/components/responses/401"
403:
$ref: "#/components/responses/403"
default:
$ref: "#/components/responses/default"
/api/v1/login:
post:
x-codeSamples:
@@ -201,16 +317,23 @@ paths:
type: string
example: "demo@invoiceninja.com"
password:
description: "The user password. Must meet minimum criteria ~ > 6 characters"
description: "The user password. Optional when performing passkey passwordless login."
type: string
example: "Password0"
one_time_password:
description: "The one time password if 2FA is enabled"
type: string
example: "123456"
passkey_challenge_token:
description: "Challenge token returned by passkey login options or second-factor challenge."
type: string
example: "f8f0cf1f4f6c4de5b6d6c5d4a0f8e5b2b89e1f1a"
passkey_authentication:
description: "WebAuthn assertion payload."
type: object
additionalProperties: true
required:
- email
- password
type: object
responses:
200:

View File

@@ -13,6 +13,7 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SNSController;
use App\Http\Controllers\BaseController;
use App\Http\Controllers\PasskeyController;
use App\Http\Controllers\PingController;
use App\Http\Controllers\SmtpController;
use App\Http\Controllers\TaskController;
@@ -141,6 +142,7 @@ Route::group(['middleware' => ['throttle:api', 'api_secret_check']], function ()
Route::group(['middleware' => ['throttle:login', 'api_secret_check', 'email_db']], function () {
Route::post('api/v1/login', [LoginController::class, 'apiLogin'])->name('login.submit');
Route::post('api/v1/reset_password', [ForgotPasswordController::class, 'sendResetLinkEmail']);
Route::post('api/v1/passkeys/login/options', [PasskeyController::class, 'loginOptions'])->name('passkeys.login.options');
});
Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','locale'], 'prefix' => 'api/v1', 'as' => 'api.'], function () {
@@ -420,6 +422,10 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
Route::get('settings/enable_two_factor', [TwoFactorController::class, 'setupTwoFactor']);
Route::post('settings/enable_two_factor', [TwoFactorController::class, 'enableTwoFactor']);
Route::post('settings/disable_two_factor', [TwoFactorController::class, 'disableTwoFactor']);
Route::get('settings/passkeys', [PasskeyController::class, 'index'])->name('passkeys.index');
Route::post('settings/passkeys/options', [PasskeyController::class, 'registrationOptions'])->name('passkeys.options');
Route::post('settings/passkeys', [PasskeyController::class, 'store'])->name('passkeys.store');
Route::delete('settings/passkeys/{passkey}', [PasskeyController::class, 'destroy'])->name('passkeys.destroy');
Route::post('verify', [TwilioController::class, 'generate'])->name('verify.generate')->middleware('throttle:daily-verify');
Route::post('verify/confirm', [TwilioController::class, 'confirm'])->name('verify.confirm')->middleware('throttle:daily-verify');

View File

@@ -16,10 +16,13 @@ use App\DataMapper\CompanySettings;
use App\Models\Account;
use App\Models\Company;
use App\Models\CompanyToken;
use App\Models\PasskeyCredential;
use App\Models\User;
use App\Services\Auth\Passkeys\PasskeyService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\ValidationException;
use Mockery;
use Tests\TestCase;
/**
@@ -199,4 +202,69 @@ class LoginTest extends TestCase
$response->assertStatus(200);
}
public function testApiLoginRequiresSecondFactorWhenPasskeyExists()
{
Account::all()->each(function ($account) {
$account->delete();
});
$account = Account::factory()->create();
$user = User::factory()->create([
'account_id' => $account->id,
'email' => 'passkey@example.com',
'password' => \Hash::make('123456'),
]);
$company = Company::factory()->create([
'account_id' => $account->id,
]);
$account->default_company_id = $company->id;
$account->save();
CompanyToken::query()->create([
'user_id' => $user->id,
'company_id' => $company->id,
'account_id' => $account->id,
'name' => $user->first_name.' '.$user->last_name,
'token' => \Illuminate\Support\Str::random(64),
'is_system' => true,
]);
$user->companies()->attach($company->id, [
'account_id' => $account->id,
'is_owner' => 1,
'notifications' => CompanySettings::notificationDefaults(),
'is_admin' => 1,
]);
PasskeyCredential::query()->create([
'account_id' => $account->id,
'user_id' => $user->id,
'name' => 'MacBook',
'credential_id' => base64_encode('test-credential'),
'credential_public_key' => base64_encode('test-public-key'),
'signature_counter' => 0,
'is_deleted' => false,
]);
$passkeyService = Mockery::mock(PasskeyService::class);
$passkeyService->shouldReceive('getAuthenticationOptions')->once()->andReturn([
'publicKey' => ['challenge' => 'abc'],
'challenge_token' => 'challenge-token',
]);
$this->app->instance(PasskeyService::class, $passkeyService);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
])->postJson('/api/v1/login', [
'email' => 'passkey@example.com',
'password' => '123456',
]);
$response->assertStatus(422);
$response->assertJsonPath('requires_second_factor', true);
$response->assertJsonPath('passkey_options.challenge_token', 'challenge-token');
}
}