@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 +107 -3
- package/dist/client-proof.js +5 -3
- package/dist/client-proof.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +28 -2
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +19 -2
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-BD4tnASp.d.ts → machine-principals-B7N8gux0.d.ts} +355 -7
- package/dist/server.d.ts +506 -268
- package/dist/server.js +1405 -950
- package/dist/server.js.map +1 -1
- package/migrations/20260918142743_smart_cassandra_nova/migration.sql +2 -0
- package/migrations/20260918142743_smart_cassandra_nova/snapshot.json +5297 -0
- package/migrations/20260918143800_fat_princess_powerful/migration.sql +17 -0
- package/migrations/20260918143800_fat_princess_powerful/snapshot.json +5523 -0
- package/package.json +1 -1
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
|
-
|
|
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`,
|
package/dist/client-proof.js
CHANGED
|
@@ -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.
|
|
1400
|
+
var CONTRACT_VERSION = "0.11.0";
|
|
1401
1401
|
var CONTRACT_MAJOR = 0;
|
|
1402
|
-
var CONTRACT_SUPPORTED_RANGE = ">=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
|
{
|