Skip to content

Commit b3c8672

Browse files
update docs
1 parent 9e3442b commit b3c8672

9 files changed

Lines changed: 214 additions & 27 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- **BREAKING: Magic-link authentication rebuilt as stateful, single-use tokens** — replaces the stateless signed-URL flow. Opaque, high-entropy tokens are stored **hashed** (SHA-256) in the new `magic_link_tokens` table; only the plain token ever leaves the app (in the emailed URL).
12+
- **Single-use by deletion** — the token row is deleted on successful redemption, so a link can never be replayed. Generating a new link **invalidates the user's previous link** for that channel.
13+
- **Config-driven, extensible channels** — new `magic_link.channels` config (`web`, `mobile`, and any host-defined channel such as `desktop`, added by config with no code change). A channel with a `scheme`/`universal_link` is built as a deep link; otherwise `base_url` + `path`. Send accepts a `channel` parameter.
14+
- **Optional browser/device binding** (`magic_link.bind_to_browser`) and **confirmation-required flow** (`magic_link.require_confirmation`) to defeat email-scanner auto-consumption.
15+
- **Single route for login + confirmation**`GET|POST /neev/loginUsingLink` (API) and `GET|POST /login-link/verify` (Blade): GET opens the link (validate-only when confirmation is required), POST is the explicit confirm. New `GET|POST /neev/loginUsingLink/validate` checks a token without consuming it. Redemption routes are now rate-limited (`throttle:10,1`).
16+
- **New `magic_link` config block** — single engine (no driver switch): `expires_in` (default 10 min), `bind_to_browser`, `require_confirmation`, `channels`. The legacy `url_expiry_time` no longer governs magic links (still used by password-reset / email-verification links).
17+
- **API surface**`MagicLinkManager` service (inject it; no facade). `forWeb()`/`forMobile()`/`generate()` return an array (`url`, `token`, `expires_in`, `channel`, `model`); `validate()`/`consume()` return a `MagicLinkResult` whose status is a string constant (`MagicLinkResult::VALID`, `EXPIRED`, `INVALID`, `BINDING_MISMATCH`, `PENDING_CONFIRMATION`, `INACTIVE_USER`). Token mechanics (`generateToken`/`hashToken`/`findByToken`) live on the `MagicLinkToken` model.
18+
- **Removed the legacy web route** `GET /login/{id}` (`login.link`); the Blade flow now redeems at `GET|POST /login-link/verify` (`login.link.verify`).
19+
- Note: a valid magic link still completes login **without** enforcing MFA (unchanged, by design).
20+
21+
### Added
22+
- **Lifecycle events**`MagicLinkGenerated`, `MagicLinkConsumed`, `MagicLinkRejected` for auditing, notifications, and abuse detection.
23+
- **`neev:clean-magic-links` command** — deletes expired magic-link tokens; schedule alongside `neev:clean-login-attempts`.
24+
1025
## [0.5.0] - 2026-07-02
1126

1227
### Added

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,12 @@ curl -X POST https://yourapp.com/neev/login \
176176
### Magic Link (Passwordless)
177177

178178
```bash
179-
# Send login link
179+
# Send login link (single-use token; optional channel, default "web")
180180
curl -X POST https://yourapp.com/neev/sendLoginLink \
181-
-d '{"email": "john@example.com"}'
181+
-d '{"email": "john@example.com", "channel": "web"}'
182182

183-
# Login via link
184-
curl -X GET "https://yourapp.com/neev/loginUsingLink?id=1&signature=..."
183+
# Login via link (opaque token from the email URL)
184+
curl -X GET "https://yourapp.com/neev/loginUsingLink?token=THE_OPAQUE_TOKEN"
185185
```
186186

187187
### Passkey / WebAuthn
@@ -227,7 +227,8 @@ All API routes are prefixed with `/neev` — the prefix is configurable via `rou
227227
| POST | `/neev/register` | Register new user | No |
228228
| POST | `/neev/login` | Login with credentials | No |
229229
| POST | `/neev/sendLoginLink` | Send magic link | No |
230-
| GET | `/neev/loginUsingLink` | Login via magic link | No |
230+
| GET / POST | `/neev/loginUsingLink` | Redeem magic link (GET opens, POST confirms) | No |
231+
| GET / POST | `/neev/loginUsingLink/validate` | Validate a magic-link token without using it | No |
231232
| POST | `/neev/logout` | Logout current session | Yes |
232233
| POST | `/neev/logoutAll` | Logout all other sessions | Yes |
233234
| POST | `/neev/forgotPassword` | Send password reset link | No |
@@ -346,7 +347,7 @@ The Blade page routes below (everything except the OAuth/SSO endpoints) register
346347
| PUT | `/login` | `login.password` | Show password form |
347348
| POST | `/login` | - | Process login |
348349
| POST | `/login/link` | `login.link.send` | Send magic link |
349-
| GET | `/login/{id}` | `login.link` | Login via magic link |
350+
| GET / POST | `/login-link/verify` | `login.link.verify` | Redeem magic link (GET opens, POST confirms) |
350351
| GET | `/forgot-password` | `password.request` | Forgot password form |
351352
| POST | `/forgot-password` | `password.email` | Send reset link |
352353
| GET | `/update-password/{id}/{hash}` | `reset.request` | Reset password form |
@@ -669,7 +670,8 @@ Email verification is enforced by applying the opt-in `neev:verified-email` midd
669670
```php
670671
'login_token_expiry_minutes' => 1440, // Login access tokens
671672
'mfa_jwt_expiry_minutes' => 30, // Temporary MFA JWTs
672-
'url_expiry_time' => 60, // Magic links, reset links
673+
'url_expiry_time' => 60, // Password-reset & email-verification links
674+
'magic_link' => ['expires_in' => 10], // Magic links (single-use, stateful)
673675
'otp_expiry_time' => 15, // OTP codes
674676
```
675677

docs/api-reference.md

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ When `auth_state` is `mfa_required`, the token is a short-lived JWT (type `mfa`)
117117

118118
### Send Login Link
119119

120-
Send a magic link to the user's email for passwordless login.
120+
Send a magic link to the user's email for passwordless login. Issues a
121+
single-use token (stored hashed) and invalidates the user's previous link for
122+
the channel.
121123

122124
```http
123125
POST /neev/sendLoginLink
@@ -127,10 +129,14 @@ POST /neev/sendLoginLink
127129

128130
```json
129131
{
130-
"email": "john@example.com"
132+
"email": "john@example.com",
133+
"channel": "web"
131134
}
132135
```
133136

137+
`channel` is optional (default `web`); it must be a channel defined in
138+
`magic_link.channels`.
139+
134140
**Response:**
135141

136142
```json
@@ -139,27 +145,64 @@ POST /neev/sendLoginLink
139145
}
140146
```
141147

148+
Returns `401` for an unknown email.
149+
142150
---
143151

144152
### Login Using Link
145153

146-
Authenticate using a magic link.
154+
Redeem a magic link. Login and the optional confirmation step share this route:
155+
`GET` opens the link, `POST` is the explicit confirm. When
156+
`magic_link.require_confirmation` is on, a `GET` only validates and returns
157+
`{"auth_state": "confirmation_required"}` without logging in.
147158

148159
```http
149-
GET /neev/loginUsingLink?id={email_id}&signature={signature}&expires={timestamp}
160+
GET|POST /neev/loginUsingLink?token={token}
150161
```
151162

152-
**Response:**
163+
**Response (success):**
153164

154165
```json
155166
{
156167
"auth_state": "authenticated",
157168
"token": "1|abc123...",
158169
"expires_in": 1440,
170+
"mfa_options": null,
159171
"email_verified": true
160172
}
161173
```
162174

175+
- `403 { "message": "Invalid or expired verification link." }` — invalid, expired, replayed, or binding-mismatch token.
176+
- `422` with an `email` validation error — the account is deactivated.
177+
178+
The token is single-use: the row is deleted on success, so replays return `403`.
179+
180+
---
181+
182+
### Validate Login Link
183+
184+
Check a magic-link token **without** consuming it (e.g. to render a
185+
confirmation screen). Never authenticates.
186+
187+
```http
188+
GET|POST /neev/loginUsingLink/validate?token={token}
189+
```
190+
191+
**Response:**
192+
193+
```json
194+
{
195+
"status": "pending_confirmation",
196+
"valid": false,
197+
"requires_confirmation": true,
198+
"channel": "web",
199+
"email_verified": true
200+
}
201+
```
202+
203+
`status` is one of `valid`, `invalid`, `expired`, `binding_mismatch`,
204+
`pending_confirmation`, `inactive_user`.
205+
163206
---
164207

165208
### Logout

docs/authentication.md

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,21 @@ Enforcement is opt-in: apply the `neev:password-not-expired` middleware alias (`
165165

166166
Passwordless login via secure email links. Always available — no config toggle.
167167

168+
Magic links are **stateful and single-use**: an opaque, high-entropy token is
169+
stored **hashed** in the `magic_link_tokens` table (only the plain token ever
170+
leaves the app, inside the emailed URL). On successful use the row is **deleted**,
171+
so a link can never be replayed, and issuing a new link **invalidates the user's
172+
previous link** for that channel.
173+
174+
> A valid magic link completes login **without** enforcing MFA, even if the user
175+
> has MFA enabled (by design — same posture as OAuth login). See [security.md](./security.md).
176+
168177
### Flow
169178

170-
1. User enters email on login page
171-
2. Clicks "Send Login Link"
172-
3. Receives email with secure link
173-
4. Clicks link to authenticate
174-
5. Automatically logged in
179+
1. User enters email and requests a link (`POST /neev/sendLoginLink`)
180+
2. Receives an email with a secure link containing an opaque `token`
181+
3. Opens the link → the token is validated and consumed
182+
4. Automatically logged in (web: session; API: a login token)
175183

176184
### API Example
177185

@@ -180,23 +188,56 @@ Passwordless login via secure email links. Always available — no config toggle
180188
```bash
181189
curl -X POST https://yourapp.com/neev/sendLoginLink \
182190
-H "Content-Type: application/json" \
183-
-d '{"email": "john@example.com"}'
191+
-d '{"email": "john@example.com", "channel": "web"}'
184192
```
185193

186194
**Use Link:**
187195

188196
```bash
189-
curl -X GET "https://yourapp.com/neev/loginUsingLink?id=1&signature=abc123&expires=1234567890"
197+
curl -X GET "https://yourapp.com/neev/loginUsingLink?token=THE_OPAQUE_TOKEN"
190198
```
191199

192-
### Link Expiry
200+
Login and the (optional) confirmation step share one route: `GET` opens the link,
201+
`POST` is the explicit confirm. `GET|POST /neev/loginUsingLink/validate` checks a
202+
token without consuming it. Redemption is rate-limited (`throttle:10,1`).
203+
204+
### Channels
193205

194-
Configure in `config/neev.php`:
206+
`sendLoginLink` accepts a `channel` (default `web`). Channels are config-driven —
207+
add your own (e.g. `desktop`) under `magic_link.channels` with no code change. A
208+
channel with a `scheme`/`universal_link` builds a deep link; otherwise a web URL.
209+
210+
### Configuration & options
211+
212+
Configured under `magic_link` in `config/neev.php`:
195213

196214
```php
197-
'url_expiry_time' => 60, // Minutes
215+
'magic_link' => [
216+
'expires_in' => 10, // minutes a link stays valid
217+
'bind_to_browser' => false, // restrict redemption to the originating browser/device
218+
'require_confirmation' => false, // require an explicit confirm step (defeats email scanners)
219+
'channels' => [ /* web, mobile, ... */ ],
220+
],
198221
```
199222

223+
See [configuration.md](./configuration.md#magic-link) for the full block.
224+
225+
### Calling it in code
226+
227+
Inject the `MagicLinkManager` service (there is no facade):
228+
229+
```php
230+
use Ssntpl\Neev\Services\MagicLink\MagicLinkManager;
231+
232+
$link = $magicLink->forWeb($user); // ['url', 'token', 'expires_in', 'channel', 'model']
233+
$result = $magicLink->consume($request);
234+
if ($result->isValid()) { /* $result->user is authenticated */ }
235+
```
236+
237+
Lifecycle events `MagicLinkGenerated`, `MagicLinkConsumed`, and `MagicLinkRejected`
238+
are fired for auditing/notifications. Prune expired tokens with
239+
`php artisan neev:clean-magic-links`.
240+
200241
---
201242

202243
## Passkey / WebAuthn Authentication

docs/cli-commands.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ Retention is controlled by `config('neev.mfa_pending_setup_retention_days')` (de
7878
$schedule->command('neev:clean-pending-mfa-setups')->daily();
7979
```
8080

81+
### `neev:clean-magic-links`
82+
83+
Delete expired magic-link tokens. (Consumed and superseded tokens are already
84+
deleted on use, so only expired rows can linger.)
85+
86+
```bash
87+
php artisan neev:clean-magic-links
88+
```
89+
90+
Schedule it alongside the other maintenance commands:
91+
92+
```php
93+
$schedule->command('neev:clean-magic-links')->daily();
94+
```
95+
8196
---
8297

8398
## Tenant / Team Provisioning

docs/configuration.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,52 @@ Secret key for signing MFA JWTs. Falls back to `APP_KEY` if not set.
198198
'url_expiry_time' => 60,
199199
```
200200

201-
Minutes before magic links and password reset links expire.
201+
Minutes before password-reset and email-verification links expire. (Magic links
202+
use their own `magic_link.expires_in` — see [Magic Link](#magic-link) below.)
203+
204+
### Magic Link
205+
206+
Stateful, single-use passwordless login. See
207+
[authentication.md](./authentication.md#magic-link-authentication) for the flow.
208+
209+
```php
210+
'magic_link' => [
211+
// Minutes a link stays valid (recommended 5–15).
212+
'expires_in' => env('NEEV_MAGIC_LINK_EXPIRY', 10),
213+
214+
// Restrict redemption to the browser/device that requested it.
215+
'bind_to_browser' => false,
216+
217+
// Require an explicit confirm step before consuming (defeats email scanners).
218+
'require_confirmation' => false,
219+
220+
// Channel-aware URL building. Add your own channels (e.g. 'desktop') here
221+
// with no code change. A channel with a 'scheme'/'universal_link' is built
222+
// as a deep link; otherwise a web URL from 'base_url' + 'path'.
223+
'channels' => [
224+
'web' => [
225+
'base_url' => env('APP_URL'), // point at your frontend for a decoupled SPA
226+
'path' => '/login-link',
227+
],
228+
'mobile' => [
229+
'scheme' => env('NEEV_MOBILE_SCHEME'), // e.g. myapp://login
230+
'universal_link' => env('NEEV_MOBILE_UNIVERSAL_LINK'),
231+
],
232+
],
233+
],
234+
```
235+
236+
| Key | Default | Purpose |
237+
|---|---|---|
238+
| `expires_in` | `10` | Minutes a link is valid. |
239+
| `bind_to_browser` | `false` | Only redeem from the originating browser/device. |
240+
| `require_confirmation` | `false` | Add an explicit confirm step. |
241+
| `channels` | web + mobile | Per-channel URL building (extensible). |
242+
243+
Notes:
244+
- Tokens are single-use (deleted on redemption); a new link invalidates the previous one.
245+
- A magic link does **not** enforce MFA (by design).
246+
- Prune expired rows with `php artisan neev:clean-magic-links`.
202247

203248
### OTP Expiry
204249

docs/db-schema.dbml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,5 +248,24 @@ Table tenant_auth_settings {
248248
updated_at timestamp
249249
}
250250

251+
Table magic_link_tokens {
252+
id bigint [pk, increment]
253+
user_id bigint [ref: > users.id]
254+
tenant_id bigint [null, ref: > tenants.id]
255+
token varchar [unique, note: 'SHA-256 hash of the opaque token']
256+
channel varchar [default: 'web', note: 'web / mobile / custom']
257+
meta_data varchar [null, note: 'JSON; holds the browser/device binding fingerprint']
258+
user_agent text [null]
259+
created_ip varchar [null]
260+
expires_at timestamp
261+
created_at timestamp
262+
updated_at timestamp
263+
264+
indexes {
265+
user_id
266+
(user_id, channel)
267+
}
268+
}
269+
251270
// Cross-table refs that can't be inline
252271
Ref: users.default_team_id > teams.id

docs/spa-authentication.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,13 @@ POST /neev/sendLoginLink
256256
{ "message": "Login link has been sent." }
257257
```
258258

259-
The emailed link points at your frontend (`{APP_URL}/login-link?id=…&signature=…&expires=…`). Your SPA route at `/login-link` forwards the query string to the API **via XHR** (the XHR carries your stateful `Origin`, which is what triggers the cookie):
259+
The emailed link points at your frontend (`{base_url}/login-link?token=…`, where
260+
`base_url` is `magic_link.channels.web.base_url` — set it to your frontend origin).
261+
Your SPA route at `/login-link` forwards the opaque `token` to the API **via XHR**
262+
(the XHR carries your stateful `Origin`, which is what triggers the cookie):
260263

261264
```http
262-
GET /neev/loginUsingLink?id={id}&expires={timestamp}&signature={signature}
265+
GET /neev/loginUsingLink?token={token}
263266
```
264267

265268
**Response** — cookie set:

docs/web-routes.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,12 @@ These routes are accessible without authentication.
4141

4242
| Method | Route | Name | Description |
4343
|--------|-------|------|-------------|
44-
| POST | `/login/link` | `login.link.send` | Send login link to email |
45-
| GET | `/login/{id}` | `login.link` | Login via magic link |
44+
| POST | `/login/link` | `login.link.send` | Send a single-use login link to email |
45+
| GET / POST | `/login-link/verify` | `login.link.verify` | Redeem the link (GET opens; POST confirms) |
46+
47+
The link is single-use and redeemed via `token`. When
48+
`magic_link.require_confirmation` is enabled, the `GET` shows a confirmation page
49+
and the user's `POST` completes the login (see [authentication.md](./authentication.md#magic-link-authentication)).
4650

4751
---
4852

0 commit comments

Comments
 (0)