create-nevela 0.3.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +3 -1
  2. package/banner.mjs +50 -0
  3. package/index.mjs +144 -27
  4. package/net.mjs +55 -0
  5. package/nevela.mjs +8 -0
  6. package/package.json +5 -2
  7. package/packagist.mjs +14 -21
  8. package/selfupdate.mjs +9 -12
  9. package/template/laravel/composer.json +2 -1
  10. package/template/laravel/config/nevela.php +66 -1
  11. package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
  12. package/template/laravel/src/Auth/Account.php +140 -0
  13. package/template/laravel/src/Auth/AuthMail.php +77 -0
  14. package/template/laravel/src/Auth/Challenges.php +135 -0
  15. package/template/laravel/src/Auth/Passkeys.php +206 -0
  16. package/template/laravel/src/Auth/Totp.php +106 -0
  17. package/template/laravel/src/Auth/Web.php +75 -0
  18. package/template/laravel/src/Console/GenerateCommand.php +1 -0
  19. package/template/laravel/src/Console/UpdateCommand.php +56 -4
  20. package/template/laravel/src/Generator/ResourceGenerator.php +60 -0
  21. package/template/laravel/src/Http/Auth/AccountController.php +252 -0
  22. package/template/laravel/src/Http/Auth/Answers.php +33 -0
  23. package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
  24. package/template/laravel/src/Http/Auth/SignInController.php +302 -0
  25. package/template/laravel/src/Http/TokenController.php +19 -37
  26. package/template/laravel/src/Http/UploadController.php +24 -12
  27. package/template/laravel/src/Models/Passkey.php +38 -0
  28. package/template/laravel/src/Models/TwoFactor.php +64 -0
  29. package/template/laravel/src/Nevela.php +1 -1
  30. package/template/laravel/src/NevelaServiceProvider.php +72 -4
  31. package/template/web/app/api/[...path]/route.ts +5 -1
  32. package/template/web/app/api/auth/[...action]/route.ts +196 -0
  33. package/template/web/app/auth-actions.ts +4 -43
  34. package/template/web/app/dashboard/account/layout.tsx +29 -0
  35. package/template/web/app/dashboard/account/page.tsx +19 -0
  36. package/template/web/app/dashboard/account/password/page.tsx +10 -0
  37. package/template/web/app/dashboard/account/security/page.tsx +19 -0
  38. package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
  39. package/template/web/app/dashboard/layout.tsx +2 -1
  40. package/template/web/app/forgot-password/page.tsx +22 -0
  41. package/template/web/app/reset-password/page.tsx +31 -0
  42. package/template/web/app/sign-in/page.tsx +34 -4
  43. package/template/web/app/sign-up/page.tsx +43 -0
  44. package/template/web/app/two-factor/page.tsx +34 -0
  45. package/template/web/app/verify-email/page.tsx +33 -0
  46. package/template/web/components/account/account-nav.tsx +39 -0
  47. package/template/web/components/account/sections.tsx +573 -0
  48. package/template/web/components/auth/otp-input.tsx +95 -0
  49. package/template/web/components/auth/password-field.tsx +116 -0
  50. package/template/web/components/auth/password-forms.tsx +76 -0
  51. package/template/web/components/auth/provider-icons.tsx +39 -0
  52. package/template/web/components/auth/sign-in-flow.tsx +307 -0
  53. package/template/web/components/auth/sign-up-form.tsx +93 -0
  54. package/template/web/components/auth/social-buttons.tsx +56 -0
  55. package/template/web/components/auth/two-factor-form.tsx +144 -0
  56. package/template/web/components/auth/use-resend.ts +44 -0
  57. package/template/web/components/auth/verify-email-form.tsx +147 -0
  58. package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
  59. package/template/web/components/dashboard/user-menu.tsx +33 -1
  60. package/template/web/lib/auth-client.ts +279 -0
  61. package/template/web/lib/auth-config.ts +43 -0
  62. package/template/web/lib/laravel.ts +26 -1
  63. package/template/web/lib/password-rules.ts +167 -0
  64. package/template/web/lib/second-step.ts +35 -0
  65. package/template/web/lib/session.ts +12 -4
  66. package/template/web/package.json +3 -2
  67. package/tool.mjs +45 -0
  68. package/template/web/components/auth/sign-in-form.tsx +0 -50
@@ -0,0 +1,66 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ // What two-factor sign-in, passkeys, the profile picture and the list of devices need.
8
+ // Columns are only added where they are missing, so an app that already has an "avatar"
9
+ // column, say, keeps its own.
10
+ return new class extends Migration
11
+ {
12
+ public function up(): void
13
+ {
14
+ if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'avatar')) {
15
+ Schema::table('users', function (Blueprint $table) {
16
+ // The key of an uploaded image (see nevela_uploads), not a URL.
17
+ $table->string('avatar', 512)->nullable();
18
+ });
19
+ }
20
+
21
+ // A device is a Sanctum token; these say which browser and where it signed in from.
22
+ if (Schema::hasTable('personal_access_tokens')) {
23
+ Schema::table('personal_access_tokens', function (Blueprint $table) {
24
+ if (! Schema::hasColumn('personal_access_tokens', 'ip_address')) {
25
+ $table->string('ip_address', 45)->nullable();
26
+ }
27
+ if (! Schema::hasColumn('personal_access_tokens', 'user_agent')) {
28
+ $table->string('user_agent', 512)->nullable();
29
+ }
30
+ });
31
+ }
32
+
33
+ Schema::create('nevela_two_factor', function (Blueprint $table) {
34
+ $table->uuid('id')->primary();
35
+ $table->string('user_id')->unique();
36
+ // The authenticator app's secret, encrypted. Null when only email codes are used.
37
+ $table->text('secret')->nullable();
38
+ $table->timestamp('totp_confirmed_at')->nullable();
39
+ // The 30-second step of the last code accepted, so the same code isn't taken twice.
40
+ $table->unsignedBigInteger('totp_last_step')->nullable();
41
+ // Hashes of the backup codes not used yet, encrypted.
42
+ $table->text('backup_codes')->nullable();
43
+ $table->timestamp('enabled_at')->nullable();
44
+ $table->timestamps();
45
+ });
46
+
47
+ Schema::create('nevela_passkeys', function (Blueprint $table) {
48
+ $table->uuid('id')->primary();
49
+ $table->string('user_id')->index();
50
+ $table->string('name')->nullable();
51
+ $table->string('credential_id', 1024)->unique();
52
+ $table->text('public_key');
53
+ $table->unsignedBigInteger('counter')->default(0);
54
+ $table->json('transports')->nullable();
55
+ $table->string('aaguid')->nullable();
56
+ $table->timestamp('last_used_at')->nullable();
57
+ $table->timestamps();
58
+ });
59
+ }
60
+
61
+ public function down(): void
62
+ {
63
+ Schema::dropIfExists('nevela_passkeys');
64
+ Schema::dropIfExists('nevela_two_factor');
65
+ }
66
+ };
@@ -0,0 +1,140 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Auth;
4
+
5
+ use Illuminate\Http\Request;
6
+ use Illuminate\Support\Facades\DB;
7
+ use Illuminate\Support\Facades\Schema;
8
+ use Illuminate\Validation\Rules\Password;
9
+ use Nevela\Laravel\Media\Uploads;
10
+ use Nevela\Laravel\Models\TwoFactor;
11
+ use RuntimeException;
12
+
13
+ /**
14
+ * The signed-in person, as the API describes them, and the tokens that are their devices.
15
+ */
16
+ final class Account
17
+ {
18
+ /** The app's user model. */
19
+ public static function model(): string
20
+ {
21
+ return (string) config('auth.providers.users.model');
22
+ }
23
+
24
+ public static function findByEmail(string $email): mixed
25
+ {
26
+ return self::model()::query()->whereRaw('lower(email) = ?', [mb_strtolower(trim($email))])->first();
27
+ }
28
+
29
+ /**
30
+ * What a client needs to know about a user.
31
+ *
32
+ * @return array<string, mixed>
33
+ */
34
+ public static function describe(mixed $user): array
35
+ {
36
+ $avatar = $user->avatar ?? null;
37
+ $file = is_string($avatar) && $avatar !== '' ? Uploads::ref($avatar) : null;
38
+
39
+ return [
40
+ 'id' => (string) $user->getAuthIdentifier(),
41
+ 'name' => $user->name ?? null,
42
+ 'email' => $user->email ?? null,
43
+ 'role' => $user->role ?? null,
44
+ 'emailVerified' => ($user->email_verified_at ?? null) !== null,
45
+ 'twoFactorEnabled' => self::twoFactor($user)?->enabled_at !== null,
46
+ // The key is what is stored; "image" is the small rendition, ready for an <img>.
47
+ 'avatar' => $file ? $avatar : null,
48
+ 'avatarFile' => $file,
49
+ 'image' => $file ? ($file['renditions']->thumb['url'] ?? $file['url']) : null,
50
+ ];
51
+ }
52
+
53
+ public static function twoFactor(mixed $user): ?TwoFactor
54
+ {
55
+ return TwoFactor::query()->where('user_id', (string) $user->getAuthIdentifier())->first();
56
+ }
57
+
58
+ /**
59
+ * Sign someone in: a new Sanctum token, remembered with the browser it was made for.
60
+ *
61
+ * @return array{token: string, user: array<string, mixed>}
62
+ */
63
+ public static function signIn(mixed $user, Request $request): array
64
+ {
65
+ if (! method_exists($user, 'createToken')) {
66
+ throw new RuntimeException('Add Laravel\Sanctum\HasApiTokens to '.self::model().' to issue Nevela tokens.');
67
+ }
68
+ $name = (string) ($request->input('deviceName') ?: config('nevela.auth.token_name', 'nevela-web'));
69
+ $token = $user->createToken(mb_substr($name, 0, 255));
70
+ self::remember($token->accessToken->getKey(), $request);
71
+
72
+ return ['token' => $token->plainTextToken, 'user' => self::describe($user)];
73
+ }
74
+
75
+ /**
76
+ * The browser's address and name. The dashboard's server makes the call to Laravel,
77
+ * so it passes these on from the person's own request; a direct API client is itself.
78
+ */
79
+ private static function remember(mixed $tokenId, Request $request): void
80
+ {
81
+ if (! Schema::hasColumn('personal_access_tokens', 'user_agent')) {
82
+ return; // the migration hasn't been run yet; signing in still works
83
+ }
84
+ $dashboard = self::fromDashboard($request);
85
+ $ip = $dashboard ? (string) $request->header('X-Nevela-Ip', '') : '';
86
+ $agent = $dashboard ? (string) $request->header('X-Nevela-User-Agent', '') : '';
87
+ DB::table('personal_access_tokens')->where('id', $tokenId)->update([
88
+ 'ip_address' => filter_var($ip, FILTER_VALIDATE_IP) ? $ip : $request->ip(),
89
+ 'user_agent' => mb_substr($agent !== '' ? $agent : (string) $request->userAgent(), 0, 512) ?: null,
90
+ ]);
91
+ }
92
+
93
+ /**
94
+ * Whether a request comes from the dashboard's server, and so may speak for a browser.
95
+ *
96
+ * Anyone can call the API directly and send the same headers, so they are believed only
97
+ * with the secret the dashboard and Laravel share (NEVELA_PROXY_SECRET in both). Without
98
+ * one configured they are believed from this machine while the app is in development,
99
+ * and otherwise not at all: a device then shows the address the request really came from.
100
+ */
101
+ public static function fromDashboard(Request $request): bool
102
+ {
103
+ $secret = config('nevela.auth.proxy_secret');
104
+ if (is_string($secret) && $secret !== '') {
105
+ return hash_equals($secret, (string) $request->header('X-Nevela-Proxy-Secret', ''));
106
+ }
107
+
108
+ return app()->environment('local') && in_array($request->ip(), ['127.0.0.1', '::1'], true);
109
+ }
110
+
111
+ /**
112
+ * The rules a new password has to meet. Length is what is required; the dashboard
113
+ * shows the rest as advice. A password found in a breach is refused, because it is
114
+ * in the lists attackers try first however strong it looks.
115
+ *
116
+ * @return list<mixed>
117
+ */
118
+ public static function passwordRules(): array
119
+ {
120
+ $rule = Password::min(8);
121
+ if (config('nevela.auth.check_breached_passwords', true) && ! app()->runningUnitTests()) {
122
+ $rule = $rule->uncompromised();
123
+ }
124
+
125
+ return ['required', 'string', 'max:128', $rule];
126
+ }
127
+
128
+ /** Sign every device out, except the one asking when `$except` is given. */
129
+ public static function revokeTokens(mixed $user, mixed $except = null): int
130
+ {
131
+ $query = DB::table('personal_access_tokens')
132
+ ->where('tokenable_type', $user->getMorphClass())
133
+ ->where('tokenable_id', $user->getAuthIdentifier());
134
+ if ($except !== null) {
135
+ $query->where('id', '!=', $except);
136
+ }
137
+
138
+ return $query->delete();
139
+ }
140
+ }
@@ -0,0 +1,77 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Auth;
4
+
5
+ use Illuminate\Notifications\Messages\MailMessage;
6
+ use Illuminate\Notifications\Notification;
7
+ use Illuminate\Support\Facades\Notification as Notifications;
8
+ use Throwable;
9
+
10
+ /**
11
+ * The emails signing in needs: codes, links, and notices that something changed.
12
+ *
13
+ * Sent with whatever mailer the Laravel app is configured with. In development that is
14
+ * usually the log, where nobody looks, so the code or link is also printed to the
15
+ * server's output: with `nevela dev` it appears in the terminal as it is "sent".
16
+ */
17
+ final class AuthMail
18
+ {
19
+ /**
20
+ * @param list<string> $lines Paragraphs, in order
21
+ * @param array{0: string, 1: string}|null $action A button: [label, url]
22
+ * @param string|null $secret The code or link, for the development console
23
+ */
24
+ /**
25
+ * What was sent while tests run, so a test can read the code it was "emailed".
26
+ *
27
+ * @var list<array{email: string, subject: string, lines: list<string>, action: array{0: string, 1: string}|null, secret: string|null}>
28
+ */
29
+ public static array $outbox = [];
30
+
31
+ public static function send(string $email, string $subject, array $lines, ?array $action = null, ?string $secret = null): void
32
+ {
33
+ if (app()->runningUnitTests()) {
34
+ self::$outbox[] = compact('email', 'subject', 'lines', 'action', 'secret');
35
+ }
36
+ if (app()->environment('local') && $secret !== null) {
37
+ // The dev server shows its error output; nothing else of the request is printed there.
38
+ @file_put_contents('php://stderr', "Nevela mail to {$email}: {$subject}\n {$secret}\n");
39
+ }
40
+
41
+ try {
42
+ Notifications::route('mail', $email)->notify(new class($subject, $lines, $action) extends Notification
43
+ {
44
+ /** @param list<string> $lines @param array{0: string, 1: string}|null $action */
45
+ public function __construct(private string $subject, private array $lines, private ?array $action) {}
46
+
47
+ /** @return list<string> */
48
+ public function via(object $notifiable): array
49
+ {
50
+ return ['mail'];
51
+ }
52
+
53
+ public function toMail(object $notifiable): MailMessage
54
+ {
55
+ $message = (new MailMessage)->subject($this->subject);
56
+ foreach ($this->lines as $line) {
57
+ $message->line($line);
58
+ }
59
+ if ($this->action !== null) {
60
+ $message->action($this->action[0], $this->action[1]);
61
+ }
62
+
63
+ return $message->line("If you didn't ask for this, you can ignore this email.");
64
+ }
65
+ });
66
+ } catch (Throwable $e) {
67
+ // A mail server being down must not tell the visitor anything, or break sign-in
68
+ // for an address that may not even exist. It is reported for the developer.
69
+ report($e);
70
+ }
71
+ }
72
+
73
+ public static function app(): string
74
+ {
75
+ return (string) (config('nevela.auth.issuer') ?: config('app.name', 'Nevela'));
76
+ }
77
+ }
@@ -0,0 +1,135 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Auth;
4
+
5
+ use Illuminate\Support\Facades\Cache;
6
+ use Illuminate\Support\Facades\RateLimiter;
7
+ use Illuminate\Support\Str;
8
+
9
+ /**
10
+ * Things that are valid once and briefly: a sign-in waiting for its second step, an
11
+ * emailed code, a passkey ceremony's challenge, a sign-in link.
12
+ *
13
+ * Kept in the cache, under a key made from a hash of the id, so reading the cache doesn't
14
+ * give anyone a usable id. Codes are kept as hashes for the same reason.
15
+ */
16
+ final class Challenges
17
+ {
18
+ /** How many wrong codes are tolerated before the challenge is thrown away. */
19
+ public const ATTEMPTS = 5;
20
+
21
+ /**
22
+ * @param array<string, mixed> $data
23
+ * @return string The id to hand to the client
24
+ */
25
+ public static function start(string $type, array $data, int $seconds): string
26
+ {
27
+ $id = Str::random(48);
28
+ Cache::put(self::key($type, $id), $data + ['attempts' => 0, 'expires' => time() + $seconds], $seconds);
29
+
30
+ return $id;
31
+ }
32
+
33
+ /**
34
+ * The same, under an id the caller chooses: an emailed code is found again by the
35
+ * address it was sent to.
36
+ *
37
+ * @param array<string, mixed> $data
38
+ */
39
+ public static function put(string $type, string $id, array $data, int $seconds): void
40
+ {
41
+ Cache::put(self::key($type, $id), $data + ['attempts' => 0, 'expires' => time() + $seconds], $seconds);
42
+ }
43
+
44
+ /** @return array<string, mixed>|null */
45
+ public static function get(string $type, ?string $id): ?array
46
+ {
47
+ if ($id === null || $id === '') {
48
+ return null;
49
+ }
50
+ $data = Cache::get(self::key($type, $id));
51
+
52
+ return is_array($data) && ($data['expires'] ?? 0) >= time() ? $data : null;
53
+ }
54
+
55
+ /** Read it and make sure it can't be read again. @return array<string, mixed>|null */
56
+ public static function take(string $type, ?string $id): ?array
57
+ {
58
+ $data = self::get($type, $id);
59
+ if ($data !== null) {
60
+ Cache::forget(self::key($type, (string) $id));
61
+ }
62
+
63
+ return $data;
64
+ }
65
+
66
+ /** @param array<string, mixed> $changes */
67
+ public static function update(string $type, string $id, array $changes): void
68
+ {
69
+ $data = self::get($type, $id);
70
+ if ($data !== null) {
71
+ Cache::put(self::key($type, $id), $changes + $data, max(1, $data['expires'] - time()));
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Count a wrong answer. Returns false when that was one too many, and the challenge
77
+ * is gone: whoever is guessing has to start again.
78
+ */
79
+ public static function miss(string $type, string $id): bool
80
+ {
81
+ $data = self::get($type, $id);
82
+ if ($data === null) {
83
+ return false;
84
+ }
85
+ if ($data['attempts'] + 1 >= self::ATTEMPTS) {
86
+ Cache::forget(self::key($type, $id));
87
+
88
+ return false;
89
+ }
90
+ self::update($type, $id, ['attempts' => $data['attempts'] + 1]);
91
+
92
+ return true;
93
+ }
94
+
95
+ public static function forget(string $type, string $id): void
96
+ {
97
+ Cache::forget(self::key($type, $id));
98
+ }
99
+
100
+ /**
101
+ * Whether another emailed code may be sent for this purpose to this person.
102
+ *
103
+ * A six-digit code allows five wrong guesses, so the number of codes is what limits
104
+ * guessing: at six codes an hour, trying every code takes several years, where
105
+ * unlimited codes would take months. A person who really needs a seventh waits.
106
+ */
107
+ public static function mayIssue(string $type, string $who, int $perHour = 6): bool
108
+ {
109
+ $key = 'nevela:auth:issued:'.$type.':'.hash('sha256', $who);
110
+ if (RateLimiter::tooManyAttempts($key, $perHour)) {
111
+ return false;
112
+ }
113
+ RateLimiter::hit($key, 3600);
114
+
115
+ return true;
116
+ }
117
+
118
+ /** A six-digit code, and the hash of it to keep. @return array{0: string, 1: string} */
119
+ public static function code(): array
120
+ {
121
+ $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
122
+
123
+ return [$code, self::hash($code)];
124
+ }
125
+
126
+ public static function hash(string $value): string
127
+ {
128
+ return hash_hmac('sha256', preg_replace('/\s+/', '', $value) ?? '', (string) config('app.key'));
129
+ }
130
+
131
+ private static function key(string $type, string $id): string
132
+ {
133
+ return 'nevela:auth:'.$type.':'.hash('sha256', $id);
134
+ }
135
+ }
@@ -0,0 +1,206 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Auth;
4
+
5
+ use Illuminate\Http\Request;
6
+ use lbuchs\WebAuthn\Binary\ByteBuffer;
7
+ use lbuchs\WebAuthn\WebAuthn;
8
+ use Nevela\Laravel\Models\Passkey;
9
+ use RuntimeException;
10
+ use Throwable;
11
+
12
+ /**
13
+ * Passkeys (WebAuthn): creating one, and signing in with one.
14
+ *
15
+ * Each is a ceremony in two halves. Laravel hands out a random challenge; the person's
16
+ * device signs it with a key that never leaves the device; Laravel checks the signature
17
+ * against the public key it was given when the passkey was made. The parsing and the
18
+ * cryptography are lbuchs/webauthn's. What is checked here on top of it:
19
+ *
20
+ * - the challenge is one this app issued, minutes ago, and is used once,
21
+ * - the page that ran the ceremony is the dashboard, compared exactly (the library
22
+ * accepts any host that merely ends with the right name), and
23
+ * - the passkey belongs to the person it is being used for.
24
+ */
25
+ final class Passkeys
26
+ {
27
+ /** How long someone has to complete the prompt on their device. */
28
+ private const SECONDS = 300;
29
+
30
+ private static function server(Request $request): WebAuthn
31
+ {
32
+ // "none": no proof of the device's make is asked for. It isn't needed to sign
33
+ // someone in, and asking makes browsers show an extra consent prompt.
34
+ return new WebAuthn(AuthMail::app(), Web::host($request), ['none'], true);
35
+ }
36
+
37
+ /**
38
+ * Options for `navigator.credentials.create()`.
39
+ *
40
+ * @return array{challenge: string, options: mixed}
41
+ */
42
+ public static function creationOptions(mixed $user, Request $request): array
43
+ {
44
+ $server = self::server($request);
45
+ $existing = Passkey::query()->where('user_id', (string) $user->getAuthIdentifier())->pluck('credential_id')
46
+ ->map(fn (string $id) => new ByteBuffer(self::decode($id)))->all();
47
+
48
+ // A discoverable credential ("resident key"), so signing in doesn't need an email first.
49
+ $options = $server->getCreateArgs(
50
+ (string) $user->getAuthIdentifier(),
51
+ (string) $user->email,
52
+ (string) ($user->name ?: $user->email),
53
+ self::SECONDS,
54
+ true,
55
+ 'preferred',
56
+ null,
57
+ $existing,
58
+ );
59
+
60
+ return [
61
+ 'challenge' => Challenges::start('passkey-create', [
62
+ 'user' => (string) $user->getAuthIdentifier(),
63
+ 'value' => self::encode($server->getChallenge()->getBinaryString()),
64
+ 'origin' => Web::origin($request),
65
+ ], self::SECONDS),
66
+ 'options' => $options->publicKey,
67
+ ];
68
+ }
69
+
70
+ /**
71
+ * @param array<string, mixed> $response What the browser's credential produced
72
+ *
73
+ * @throws RuntimeException with a message for the person
74
+ */
75
+ public static function create(mixed $user, string $challengeId, array $response, ?string $name, Request $request): Passkey
76
+ {
77
+ $challenge = Challenges::take('passkey-create', $challengeId);
78
+ if ($challenge === null || $challenge['user'] !== (string) $user->getAuthIdentifier()) {
79
+ throw new RuntimeException('That took too long. Try adding the passkey again.');
80
+ }
81
+ $clientData = self::decode((string) ($response['clientDataJSON'] ?? ''));
82
+ self::assertOrigin($clientData, $challenge['origin']);
83
+
84
+ try {
85
+ $data = self::server($request)->processCreate(
86
+ $clientData,
87
+ self::decode((string) ($response['attestationObject'] ?? '')),
88
+ self::decode($challenge['value']),
89
+ false,
90
+ true,
91
+ false,
92
+ );
93
+ } catch (Throwable $e) {
94
+ report($e);
95
+ throw new RuntimeException("That passkey couldn't be checked. Try again.");
96
+ }
97
+
98
+ $credentialId = self::encode($data->credentialId);
99
+ if (Passkey::query()->where('credential_id', $credentialId)->exists()) {
100
+ throw new RuntimeException('That passkey is already added.');
101
+ }
102
+ $transports = array_values(array_filter((array) ($response['transports'] ?? []), fn ($t) => is_string($t) && preg_match('/^[a-z-]{2,20}$/', $t)));
103
+
104
+ return Passkey::create([
105
+ 'user_id' => (string) $user->getAuthIdentifier(),
106
+ 'name' => $name !== null && trim($name) !== '' ? mb_substr(trim($name), 0, 80) : null,
107
+ 'credential_id' => $credentialId,
108
+ 'public_key' => $data->credentialPublicKey,
109
+ 'counter' => (int) ($data->signatureCounter ?? 0),
110
+ 'transports' => $transports ?: null,
111
+ 'aaguid' => is_string($data->AAGUID ?? null) ? bin2hex($data->AAGUID) : null,
112
+ ]);
113
+ }
114
+
115
+ /**
116
+ * Options for `navigator.credentials.get()`. No list of allowed passkeys: the device
117
+ * offers the ones it has for this site, which is what lets someone sign in without
118
+ * typing an email, and tells a visitor nothing about who has an account.
119
+ *
120
+ * @return array{challenge: string, options: mixed}
121
+ */
122
+ public static function requestOptions(Request $request): array
123
+ {
124
+ $server = self::server($request);
125
+ $options = $server->getGetArgs([], self::SECONDS, true, true, true, true, true, 'preferred');
126
+
127
+ return [
128
+ 'challenge' => Challenges::start('passkey-get', [
129
+ 'value' => self::encode($server->getChallenge()->getBinaryString()),
130
+ 'origin' => Web::origin($request),
131
+ ], self::SECONDS),
132
+ 'options' => $options->publicKey,
133
+ ];
134
+ }
135
+
136
+ /**
137
+ * Check a sign-in and return whose passkey it was.
138
+ *
139
+ * @param array<string, mixed> $response
140
+ *
141
+ * @throws RuntimeException with a message for the person
142
+ */
143
+ public static function verify(string $challengeId, array $response, Request $request): Passkey
144
+ {
145
+ $failed = "That passkey didn't work. Try again, or sign in another way.";
146
+ $challenge = Challenges::take('passkey-get', $challengeId);
147
+ if ($challenge === null) {
148
+ throw new RuntimeException('That took too long. Try the passkey again.');
149
+ }
150
+ $passkey = Passkey::query()->where('credential_id', (string) ($response['id'] ?? ''))->first();
151
+ if ($passkey === null) {
152
+ throw new RuntimeException("This passkey isn't registered here any more. Sign in another way.");
153
+ }
154
+ $clientData = self::decode((string) ($response['clientDataJSON'] ?? ''));
155
+ self::assertOrigin($clientData, $challenge['origin']);
156
+
157
+ // The device says whose passkey it is. It has to agree with our record.
158
+ $handle = (string) ($response['userHandle'] ?? '');
159
+ if ($handle !== '' && ! hash_equals($passkey->user_id, self::decode($handle))) {
160
+ throw new RuntimeException($failed);
161
+ }
162
+
163
+ $server = self::server($request);
164
+ try {
165
+ $server->processGet(
166
+ $clientData,
167
+ self::decode((string) ($response['authenticatorData'] ?? '')),
168
+ self::decode((string) ($response['signature'] ?? '')),
169
+ $passkey->public_key,
170
+ self::decode($challenge['value']),
171
+ // A counter that goes backwards means the key was copied. Passkeys synced
172
+ // between someone's devices always report 0, which is not checked.
173
+ $passkey->counter > 0 ? $passkey->counter : null,
174
+ false,
175
+ true,
176
+ );
177
+ } catch (Throwable $e) {
178
+ report($e);
179
+ throw new RuntimeException($failed);
180
+ }
181
+
182
+ $passkey->forceFill(['counter' => (int) ($server->getSignatureCounter() ?? $passkey->counter), 'last_used_at' => now()])->save();
183
+
184
+ return $passkey;
185
+ }
186
+
187
+ /** The page that ran the ceremony has to be the dashboard, exactly. */
188
+ private static function assertOrigin(string $clientDataJson, string $expected): void
189
+ {
190
+ $clientData = json_decode($clientDataJson, true);
191
+ $origin = is_array($clientData) ? (string) ($clientData['origin'] ?? '') : '';
192
+ if ($origin === '' || rtrim(strtolower($origin), '/') !== $expected) {
193
+ throw new RuntimeException("This passkey request didn't come from the app's own address.");
194
+ }
195
+ }
196
+
197
+ public static function encode(string $binary): string
198
+ {
199
+ return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
200
+ }
201
+
202
+ public static function decode(string $text): string
203
+ {
204
+ return (string) base64_decode(strtr($text, '-_', '+/'), false);
205
+ }
206
+ }