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

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,81 @@ 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
+ reads the token out of the query string and makes two calls: one to render, one when the owner
625
+ presses the button.
626
+
627
+ ```typescript
628
+ 'use client';
629
+
630
+ const token = useSearchParams().get('token');
631
+
632
+ // Describing the link changes nothing at all, so a mail scanner that prefetches
633
+ // the page has not signed anybody out.
634
+ const { expiresAt, activeKeyCount } = await authApi.confirmRevokeAllLink.call({ body: { token } });
635
+
636
+ // The button.
637
+ const { revokedCount } = await authApi.consumeRevokeAllLink.call({ body: { token } });
638
+ ```
639
+
640
+ - **Every refusal is the same 404** (`RevokeAllLinkError`), with the same body: unknown, expired,
641
+ already spent, superseded by a newer link, issued against a key generation that has since moved,
642
+ or belonging to an account that is not active. Telling those apart would tell whoever holds a
643
+ random value that it named something real. 404 rather than the 401 the [password reset
644
+ link](#password-reset-verified-email) answers with, because there is no credential here to have
645
+ been wrong: the mailbox is the proof, and what arrives either names an outstanding link or names
646
+ nothing.
647
+ - **The token travels in the request body, never in a path segment.** The request logger records
648
+ the path of every request, and so does whatever proxy sits in front of it.
649
+ - **Your obligation, which this package cannot enforce:** the returned `url` carries the plaintext
650
+ token, because this flow sends no mail of its own. Do not log it, do not persist it, do not put
651
+ it in a job payload — hand it to the mail template and let it go. The package's other two links
652
+ are minted inside the worker that sends them precisely so no caller ever holds one; this one
653
+ cannot be.
654
+ - **It is one-time and generation-bound.** Consuming it is a single statement, so two clicks
655
+ produce one sign-out and one 404. It also dies the moment anything else ends the account's key
656
+ generation — a completed password reset, a password change, a deletion request, or the
657
+ `revokeAllKeys` route in either mode.
658
+ - **It does not change the password.** Send it alongside a password reset link: this one ends the
659
+ sessions, that one ends the credential that started them.
660
+ - **Issuing again supersedes.** A second link retires the first, so asking twice does not leave a
661
+ spare capability in the mailbox.
662
+ - **`ttlMinutes` must be a positive whole number.** Zero or negative is a `ValidationError` and
663
+ writes no row; an unknown `userId` is refused explicitly rather than surfacing as a foreign-key
664
+ 500.
665
+ - **Rate limited 10/minute per address** across both endpoints, on one counter — valid and invalid
666
+ tokens are not counted separately, which would be a way to tell them apart.
667
+ - **Expired and spent rows are swept** by `auth.revoke-all-token-purge` (daily 06:00), part of
668
+ `authJobRouter`: a week after expiry, a day after being spent or superseded.
669
+
670
+ **Settings.**
671
+
672
+ | Variable | Default | Meaning |
673
+ |----------|---------|---------|
674
+ | `SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES` | `30` | how long the link works |
675
+ | `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` | `/account/revoke-all` | the page in your app the link opens |
676
+
677
+ The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the other
678
+ two links use. Only the SHA-256 of the token is stored, in `spfn_auth.key_revoke_all_tokens`.
679
+
680
+ Neither route is in the mobile contract: both are answered for a browser on a page in your app,
681
+ with no session and no client proof, and a generated mobile client has a session by definition.
682
+
597
683
  ### Passkeys (WebAuthn)
598
684
 
599
685
  A passkey is an **optional additional credential** on an account, alongside a password and a
@@ -1545,7 +1631,7 @@ control, not authorization.
1545
1631
  analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
1546
1632
 
1547
1633
  ```typescript
1548
- import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
1634
+ import { authLoginEvent, authRegisterEvent, authDeviceRegisteredEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
1549
1635
 
1550
1636
  authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
1551
1637
  {
@@ -1568,7 +1654,25 @@ and a passkey has to be enrolled from a session that already exists, so neither
1568
1654
  *change*, which is made from a session that already proved itself: this one is made by
1569
1655
  whoever opened a link in a mailbox, so it is the notice to send the owner.
1570
1656
 
1571
- Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayload`, `InvitationCreatedPayload`,
1657
+ `authDeviceRegisteredEvent` (`auth.device.registered`) fires after commit whenever a device key is
1658
+ registered on an account, on every channel that registers one — `channel` says which: `register`,
1659
+ `signup-link`, `invitation`, `password`, `oauth`, `oauth-native`, `device-code`, `password-reset`
1660
+ or `passkey`. It carries `userId`, `keyId`, `algorithm`, a 12-character `fingerprintPrefix`,
1661
+ `createdAtMillis`, and whatever the registration knew about the device: `deviceName?`, `platform?`,
1662
+ `ip?` and `userAgent?` — the web OAuth callback has neither label, because the sealed state does
1663
+ not carry them. Subscribe to tell the owner a device was added: a login event says a session began
1664
+ and not what it began on, so a stolen password used on a new machine was silent until this event.
1665
+ Send it with a [sign-out-everywhere link](#the-sign-out-everywhere-link), which is the action the
1666
+ notice should offer.
1667
+
1668
+ Key **rotation** is deliberately not announced — replacing the key of a device that is already
1669
+ signed in is not a new device, and a notice for it would teach the owner to ignore the ones that
1670
+ matter. A login that names an `oldKeyId` is only a rotation when that key was actually revoked: an
1671
+ `oldKeyId` naming somebody else's key, an already-revoked one or nothing at all registers a new
1672
+ device and fires the event. `ip` and `userAgent` are unauthenticated and display-only.
1673
+
1674
+ Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayload`,
1675
+ `AuthDeviceRegisteredPayload`, `InvitationCreatedPayload`,
1572
1676
  `InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
1573
1677
  `AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
1574
1678
  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
  {