@spfn/auth 0.3.0-beta.20 → 0.3.0-beta.22

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.
package/README.md CHANGED
@@ -165,6 +165,8 @@ real secret values out of band, never commit them.
165
165
  | `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `.env.server` | — | default `/signup/confirm`; the page in your app the emailed link opens |
166
166
  | `SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES` / `_SETUP_TTL_MINUTES` | `.env.server` | — | defaults `30` / `15` — see [Password reset](#password-reset-verified-email) |
167
167
  | `SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH` | `.env.server` | — | default `/password/reset`; the page in your app the emailed link opens |
168
+ | `SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES` | `.env.server` | — | default `30` — see [The sign-out-everywhere link](#the-sign-out-everywhere-link) |
169
+ | `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` | `.env.server` | — | default `/account/revoke-all`; the page in your app the link opens |
168
170
  | `SPFN_AUTH_LINK_MAIL_DELIVERY` | `.env.server` | — | `auto` (default) \| `inline` \| `queued`; who sends signup-link, reset and account-exists mail — see [Link mail delivery](#link-mail-delivery) |
169
171
  | `SPFN_AUTH_PASSKEY_RP_ID` / `_RP_NAME` / `_ORIGINS` | `.env.server` | — | relying party for passkeys; defaults derive from `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}` and are **checked at boot** — see [Passkeys](#passkeys-webauthn) |
170
172
  | `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | — | `preferred` (default) or `required`; `discouraged` refuses boot |
@@ -520,7 +522,8 @@ cut off anything they no longer recognise.
520
522
  ```typescript
521
523
  const { keys } = await authApi.listKeys.call({ body: {} });
522
524
  // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
523
- // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis? }]
525
+ // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis?,
526
+ // registeredIp?, registeredUserAgent? }]
524
527
 
525
528
  await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
526
529
  ```
@@ -582,6 +585,14 @@ Every path that registers a key (`register`, `login`, `rotateKey`, native OAuth)
582
585
  only — nothing is authorized by them — and both are absent on keys registered before they existed.
583
586
  Rotation carries the replaced key's label over unless the client sends a new one.
584
587
 
588
+ - **`registeredIp` and `registeredUserAgent` are where the device came from**, captured once from
589
+ the request that registered the key and never updated — a device that later signs requests from
590
+ another network still shows the address it appeared from, which is what makes an entry the owner
591
+ does not recognise recognisable. Both are absent when the request resolved neither and on keys
592
+ registered before the columns existed; the literal string `unknown` is never stored. They are
593
+ unauthenticated display material, spoofable on any request that does not come through a verified
594
+ proxy, so render them and decide nothing by them. Mobile contract 0.11.0.
595
+
585
596
  All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
586
597
  `auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
587
598
  rotation.
@@ -594,6 +605,105 @@ is still active is the one
594
605
  exception: it stays a no-op success, so repeated logins from the same device keep working, and an
595
606
  expired-but-active key has its expiry extended by the sign-in that proved the identity again.
596
607
 
608
+ ### The sign-out-everywhere link
609
+
610
+ The key operations above all need a session, which is exactly what an owner who no longer trusts
611
+ the device in front of them does not want to use. `createRevokeAllLink` mints a one-time link your
612
+ app mails to the address the account has already proved; opening it signs every device out with no
613
+ session at all.
614
+
615
+ ```typescript
616
+ import { createRevokeAllLink } from '@spfn/auth/server';
617
+
618
+ const { url, expiresAt } = await createRevokeAllLink(userId); // default TTL 30 minutes
619
+ const short = await createRevokeAllLink(userId, { ttlMinutes: 10 });
620
+ ```
621
+
622
+ **The link opens a page in your app** (`SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH`, default
623
+ `/account/revoke-all`), not an API route — the same shape the signup and reset links use. That page
624
+ ships with the package: mount it in one route file and you are done.
625
+
626
+ ```typescript
627
+ // app/account/revoke-all/route.ts
628
+ import { createRevokeAllPageHandlers } from '@spfn/auth/nextjs/server';
629
+
630
+ export const { GET, POST } = createRevokeAllPageHandlers();
631
+ ```
632
+
633
+ `GET` reads the token out of the query string, calls `confirmRevokeAllLink` and draws the expiry,
634
+ the device count and one button; `POST` calls `consumeRevokeAllLink` and reports the count it
635
+ signed out. Every answer carries `Cache-Control: no-store` and
636
+ `Content-Security-Policy: frame-ancestors 'none'`, the token appears in a hidden field and the API
637
+ body and nowhere else, and every 404 is the same screen with no reason on it. Pass
638
+ `render: (view: RevokeAllPageView) => string` to own the body at all three stages
639
+ (`confirm` / `done` / `invalid`) while the handler keeps the status, the headers and the fields.
640
+
641
+ - **There is no session on this page, so the CSRF token is not derived from one.** `GET` mints 32
642
+ random bytes, sets them in a cookie scoped to the page's own path (`HttpOnly`, `Secure` in
643
+ production, `SameSite=Strict`, 15 minutes) and mirrors them into the form; `POST` compares the
644
+ two before calling the API and expires the cookie afterwards. A custom `render` must echo
645
+ `view.fields` and `view.csrfToken` back as hidden inputs, or the form it draws cannot be
646
+ submitted.
647
+
648
+ **An app that wants its own page** can call the two endpoints directly instead — they are public,
649
+ and this is what the handlers above do:
650
+
651
+ ```typescript
652
+ 'use client';
653
+
654
+ const token = useSearchParams().get('token');
655
+
656
+ // Describing the link changes nothing at all, so a mail scanner that prefetches
657
+ // the page has not signed anybody out.
658
+ const { expiresAt, activeKeyCount } = await authApi.confirmRevokeAllLink.call({ body: { token } });
659
+
660
+ // The button.
661
+ const { revokedCount } = await authApi.consumeRevokeAllLink.call({ body: { token } });
662
+ ```
663
+
664
+ - **Every refusal is the same 404** (`RevokeAllLinkError`), with the same body: unknown, expired,
665
+ already spent, superseded by a newer link, issued against a key generation that has since moved,
666
+ or belonging to an account that is not active. Telling those apart would tell whoever holds a
667
+ random value that it named something real. 404 rather than the 401 the [password reset
668
+ link](#password-reset-verified-email) answers with, because there is no credential here to have
669
+ been wrong: the mailbox is the proof, and what arrives either names an outstanding link or names
670
+ nothing.
671
+ - **The token travels in the request body, never in a path segment.** The request logger records
672
+ the path of every request, and so does whatever proxy sits in front of it.
673
+ - **Your obligation, which this package cannot enforce:** the returned `url` carries the plaintext
674
+ token, because this flow sends no mail of its own. Do not log it, do not persist it, do not put
675
+ it in a job payload — hand it to the mail template and let it go. The package's other two links
676
+ are minted inside the worker that sends them precisely so no caller ever holds one; this one
677
+ cannot be.
678
+ - **It is one-time and generation-bound.** Consuming it is a single statement, so two clicks
679
+ produce one sign-out and one 404. It also dies the moment anything else ends the account's key
680
+ generation — a completed password reset, a password change, a deletion request, or the
681
+ `revokeAllKeys` route in either mode.
682
+ - **It does not change the password.** Send it alongside a password reset link: this one ends the
683
+ sessions, that one ends the credential that started them.
684
+ - **Issuing again supersedes.** A second link retires the first, so asking twice does not leave a
685
+ spare capability in the mailbox.
686
+ - **`ttlMinutes` must be a positive whole number.** Zero or negative is a `ValidationError` and
687
+ writes no row; an unknown `userId` is refused explicitly rather than surfacing as a foreign-key
688
+ 500.
689
+ - **Rate limited 10/minute per address** across both endpoints, on one counter — valid and invalid
690
+ tokens are not counted separately, which would be a way to tell them apart.
691
+ - **Expired and spent rows are swept** by `auth.revoke-all-token-purge` (daily 06:00), part of
692
+ `authJobRouter`: a week after expiry, a day after being spent or superseded.
693
+
694
+ **Settings.**
695
+
696
+ | Variable | Default | Meaning |
697
+ |----------|---------|---------|
698
+ | `SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES` | `30` | how long the link works |
699
+ | `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` | `/account/revoke-all` | the page in your app the link opens |
700
+
701
+ The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the other
702
+ two links use. Only the SHA-256 of the token is stored, in `spfn_auth.key_revoke_all_tokens`.
703
+
704
+ Neither route is in the mobile contract: both are answered for a browser on a page in your app,
705
+ with no session and no client proof, and a generated mobile client has a session by definition.
706
+
597
707
  ### Passkeys (WebAuthn)
598
708
 
599
709
  A passkey is an **optional additional credential** on an account, alongside a password and a
@@ -1545,7 +1655,7 @@ control, not authorization.
1545
1655
  analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
1546
1656
 
1547
1657
  ```typescript
1548
- import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
1658
+ import { authLoginEvent, authRegisterEvent, authDeviceRegisteredEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
1549
1659
 
1550
1660
  authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
1551
1661
  {
@@ -1568,7 +1678,25 @@ and a passkey has to be enrolled from a session that already exists, so neither
1568
1678
  *change*, which is made from a session that already proved itself: this one is made by
1569
1679
  whoever opened a link in a mailbox, so it is the notice to send the owner.
1570
1680
 
1571
- Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayload`, `InvitationCreatedPayload`,
1681
+ `authDeviceRegisteredEvent` (`auth.device.registered`) fires after commit whenever a device key is
1682
+ registered on an account, on every channel that registers one — `channel` says which: `register`,
1683
+ `signup-link`, `invitation`, `password`, `oauth`, `oauth-native`, `device-code`, `password-reset`
1684
+ or `passkey`. It carries `userId`, `keyId`, `algorithm`, a 12-character `fingerprintPrefix`,
1685
+ `createdAtMillis`, and whatever the registration knew about the device: `deviceName?`, `platform?`,
1686
+ `ip?` and `userAgent?` — the web OAuth callback has neither label, because the sealed state does
1687
+ not carry them. Subscribe to tell the owner a device was added: a login event says a session began
1688
+ and not what it began on, so a stolen password used on a new machine was silent until this event.
1689
+ Send it with a [sign-out-everywhere link](#the-sign-out-everywhere-link), which is the action the
1690
+ notice should offer.
1691
+
1692
+ Key **rotation** is deliberately not announced — replacing the key of a device that is already
1693
+ signed in is not a new device, and a notice for it would teach the owner to ignore the ones that
1694
+ matter. A login that names an `oldKeyId` is only a rotation when that key was actually revoked: an
1695
+ `oldKeyId` naming somebody else's key, an already-revoked one or nothing at all registers a new
1696
+ device and fires the event. `ip` and `userAgent` are unauthenticated and display-only.
1697
+
1698
+ Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayload`,
1699
+ `AuthDeviceRegisteredPayload`, `InvitationCreatedPayload`,
1572
1700
  `InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
1573
1701
  `AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
1574
1702
  disconnect, see the OAuth unlink-notify section), `PasskeyEnrolledPayload`,
@@ -1397,9 +1397,9 @@ function isAppKind(kind) {
1397
1397
  }
1398
1398
 
1399
1399
  // src/server/client-proof/contract-bundle.ts
1400
- var CONTRACT_VERSION = "0.10.1";
1400
+ var CONTRACT_VERSION = "0.11.0";
1401
1401
  var CONTRACT_MAJOR = 0;
1402
- var CONTRACT_SUPPORTED_RANGE = ">=0.10.0 <0.11.0";
1402
+ var CONTRACT_SUPPORTED_RANGE = ">=0.11.0 <0.12.0";
1403
1403
  function required(name, type) {
1404
1404
  return { name, type, optional: false };
1405
1405
  }
@@ -1578,7 +1578,9 @@ var CONTRACT_TYPES = [
1578
1578
  optional("expiresAtMillis", "integer"),
1579
1579
  required("isExpired", "boolean"),
1580
1580
  required("isActive", "boolean"),
1581
- optional("revokedAtMillis", "integer")
1581
+ optional("revokedAtMillis", "integer"),
1582
+ optional("registeredIp", "string"),
1583
+ optional("registeredUserAgent", "string")
1582
1584
  ]
1583
1585
  },
1584
1586
  {