@shware/security 7.1.1 → 8.0.0
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 +108 -30
- package/dist/auth/attributes.cjs +4 -3
- package/dist/auth/attributes.cjs.map +1 -1
- package/dist/auth/attributes.d.cts +3 -3
- package/dist/auth/attributes.d.cts.map +1 -1
- package/dist/auth/attributes.d.mts +3 -3
- package/dist/auth/attributes.d.mts.map +1 -1
- package/dist/auth/attributes.mjs +4 -3
- package/dist/auth/attributes.mjs.map +1 -1
- package/dist/auth/desktop-codes.cjs +57 -0
- package/dist/auth/desktop-codes.cjs.map +1 -0
- package/dist/auth/desktop-codes.d.cts +24 -0
- package/dist/auth/desktop-codes.d.cts.map +1 -0
- package/dist/auth/desktop-codes.d.mts +24 -0
- package/dist/auth/desktop-codes.d.mts.map +1 -0
- package/dist/auth/desktop-codes.mjs +55 -0
- package/dist/auth/desktop-codes.mjs.map +1 -0
- package/dist/auth/email-codes.cjs +77 -0
- package/dist/auth/email-codes.cjs.map +1 -0
- package/dist/auth/email-codes.d.cts +39 -0
- package/dist/auth/email-codes.d.cts.map +1 -0
- package/dist/auth/email-codes.d.mts +39 -0
- package/dist/auth/email-codes.d.mts.map +1 -0
- package/dist/auth/email-codes.mjs +75 -0
- package/dist/auth/email-codes.mjs.map +1 -0
- package/dist/auth/error.cjs +47 -0
- package/dist/auth/error.cjs.map +1 -0
- package/dist/auth/error.d.cts +88 -0
- package/dist/auth/error.d.cts.map +1 -0
- package/dist/auth/error.d.mts +88 -0
- package/dist/auth/error.d.mts.map +1 -0
- package/dist/auth/error.mjs +45 -0
- package/dist/auth/error.mjs.map +1 -0
- package/dist/auth/index.cjs +244 -383
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.d.cts +66 -55
- package/dist/auth/index.d.cts.map +1 -1
- package/dist/auth/index.d.mts +66 -55
- package/dist/auth/index.d.mts.map +1 -1
- package/dist/auth/index.mjs +247 -386
- package/dist/auth/index.mjs.map +1 -1
- package/dist/auth/respond.cjs +99 -0
- package/dist/auth/respond.cjs.map +1 -0
- package/dist/auth/respond.d.cts +39 -0
- package/dist/auth/respond.d.cts.map +1 -0
- package/dist/auth/respond.d.mts +39 -0
- package/dist/auth/respond.d.mts.map +1 -0
- package/dist/auth/respond.mjs +92 -0
- package/dist/auth/respond.mjs.map +1 -0
- package/dist/auth/types.d.cts +27 -11
- package/dist/auth/types.d.cts.map +1 -1
- package/dist/auth/types.d.mts +27 -11
- package/dist/auth/types.d.mts.map +1 -1
- package/dist/index.cjs +5 -0
- package/dist/index.d.cts +4 -1
- package/dist/index.d.mts +4 -1
- package/dist/index.mjs +3 -1
- package/dist/oauth2/error.cjs +0 -7
- package/dist/oauth2/error.cjs.map +1 -1
- package/dist/oauth2/error.d.cts +0 -3
- package/dist/oauth2/error.d.cts.map +1 -1
- package/dist/oauth2/error.d.mts +0 -3
- package/dist/oauth2/error.d.mts.map +1 -1
- package/dist/oauth2/error.mjs +1 -7
- package/dist/oauth2/error.mjs.map +1 -1
- package/dist/oauth2/provider/common.cjs +1 -1
- package/dist/oauth2/provider/common.cjs.map +1 -1
- package/dist/oauth2/provider/common.mjs +1 -1
- package/dist/oauth2/provider/common.mjs.map +1 -1
- package/dist/oauth2/provider/facebook.cjs +2 -4
- package/dist/oauth2/provider/facebook.cjs.map +1 -1
- package/dist/oauth2/provider/facebook.d.cts +0 -1
- package/dist/oauth2/provider/facebook.d.cts.map +1 -1
- package/dist/oauth2/provider/facebook.d.mts +0 -1
- package/dist/oauth2/provider/facebook.d.mts.map +1 -1
- package/dist/oauth2/provider/facebook.mjs +2 -4
- package/dist/oauth2/provider/facebook.mjs.map +1 -1
- package/dist/oauth2/provider/x.cjs +6 -4
- package/dist/oauth2/provider/x.cjs.map +1 -1
- package/dist/oauth2/provider/x.d.cts +3 -2
- package/dist/oauth2/provider/x.d.cts.map +1 -1
- package/dist/oauth2/provider/x.d.mts +3 -2
- package/dist/oauth2/provider/x.d.mts.map +1 -1
- package/dist/oauth2/provider/x.mjs +6 -4
- package/dist/oauth2/provider/x.mjs.map +1 -1
- package/dist/oauth2/types.cjs.map +1 -1
- package/dist/oauth2/types.d.cts +5 -0
- package/dist/oauth2/types.d.cts.map +1 -1
- package/dist/oauth2/types.d.mts +5 -0
- package/dist/oauth2/types.d.mts.map +1 -1
- package/dist/oauth2/types.mjs.map +1 -1
- package/dist/session/common.cjs +10 -13
- package/dist/session/common.cjs.map +1 -1
- package/dist/session/common.d.cts +7 -3
- package/dist/session/common.d.cts.map +1 -1
- package/dist/session/common.d.mts +7 -3
- package/dist/session/common.d.mts.map +1 -1
- package/dist/session/common.mjs +10 -12
- package/dist/session/common.mjs.map +1 -1
- package/dist/session/db.cjs +2 -2
- package/dist/session/db.cjs.map +1 -1
- package/dist/session/db.mjs +3 -3
- package/dist/session/db.mjs.map +1 -1
- package/dist/session/redis.cjs +6 -8
- package/dist/session/redis.cjs.map +1 -1
- package/dist/session/redis.d.cts.map +1 -1
- package/dist/session/redis.d.mts.map +1 -1
- package/dist/session/redis.mjs +7 -9
- package/dist/session/redis.mjs.map +1 -1
- package/dist/session/types.d.cts +0 -12
- package/dist/session/types.d.cts.map +1 -1
- package/dist/session/types.d.mts +0 -12
- package/dist/session/types.d.mts.map +1 -1
- package/dist/utils/valid.cjs +34 -52
- package/dist/utils/valid.cjs.map +1 -1
- package/dist/utils/valid.d.cts +8 -23
- package/dist/utils/valid.d.cts.map +1 -1
- package/dist/utils/valid.d.mts +8 -23
- package/dist/utils/valid.d.mts.map +1 -1
- package/dist/utils/valid.mjs +32 -50
- package/dist/utils/valid.mjs.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
Session-based authentication for any server that speaks the Fetch API (`Request` → `Response`):
|
|
4
4
|
Hono, Cloudflare Workers, Next.js route handlers and similar. It ships an `Auth` class with
|
|
5
5
|
handlers for CSRF, logout, OAuth2 (redirect, native, Google One Tap), email verification-code
|
|
6
|
-
sign-in and desktop sign-in, backed by pluggable session stores (Redis or your own database)
|
|
7
|
-
|
|
6
|
+
sign-in and desktop sign-in, backed by pluggable session stores (Redis or your own database),
|
|
7
|
+
indexed by principal so a user's sessions can be listed and revoked.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -17,10 +17,10 @@ use the Redis repositories.
|
|
|
17
17
|
|
|
18
18
|
## Entry points
|
|
19
19
|
|
|
20
|
-
| Import path | Contents
|
|
21
|
-
| ---------------------------------- |
|
|
22
|
-
| `@shware/security` | `Auth`, `PATH`, session/KV repositories, `OAuth2Client`, `OAuth2Error`, `PROVIDERS
|
|
23
|
-
| `@shware/security/oauth2/provider` | Built-in OAuth2 providers (`apple`, `facebook`, `github`, `google`, `wechat`, `x`) and `createAppleClientSecret`
|
|
20
|
+
| Import path | Contents |
|
|
21
|
+
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| `@shware/security` | `Auth`, `PATH`, `AuthenticationError`, session/KV repositories, `OAuth2Client`, `OAuth2Error`, `PROVIDERS`, types |
|
|
23
|
+
| `@shware/security/oauth2/provider` | Built-in OAuth2 providers (`apple`, `facebook`, `github`, `google`, `wechat`, `x`) and `createAppleClientSecret` |
|
|
24
24
|
|
|
25
25
|
## Quick start (Hono)
|
|
26
26
|
|
|
@@ -151,6 +151,47 @@ export async function onAuthorized(
|
|
|
151
151
|
`PROVIDERS` is the list of `Provider` values (`APPLE`, `GITHUB`, `GOOGLE`, ..., `EMAIL`, `PHONE`),
|
|
152
152
|
handy for a database enum.
|
|
153
153
|
|
|
154
|
+
To refuse a sign-in the handler could complete but will not, throw an `AuthenticationError` with a
|
|
155
|
+
reason and, when the client needs it to act, metadata (string fields only, since it travels as URL
|
|
156
|
+
parameters and JSON). The built-in reasons cover the account states every host meets:
|
|
157
|
+
|
|
158
|
+
| Reason | Metadata | When |
|
|
159
|
+
| -------------------- | ---------------------- | ----------------------------------------------------------- |
|
|
160
|
+
| `ACCOUNT_DISABLED` | | Banned, or closed by its owner. |
|
|
161
|
+
| `ACCOUNT_LOCKED` | `{ until?: string }` | Locked for now; `until` is when it opens again, when known. |
|
|
162
|
+
| `EMAIL_ALREADY_USED` | `{ provider?: string}` | The address belongs to another account; sign in with that. |
|
|
163
|
+
| `EMAIL_NOT_VERIFIED` | `{ provider?: string}` | Only verified addresses are accepted and this one is not. |
|
|
164
|
+
| `SIGNUP_DISABLED` | | No new accounts: registration closed or by invitation. |
|
|
165
|
+
|
|
166
|
+
Add your own by augmenting `AuthenticationReasons`, each with the shape of its metadata; the
|
|
167
|
+
constructor accepts exactly the built-in and declared reasons, and requires the metadata when one
|
|
168
|
+
of its fields is required:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { AuthenticationError } from '@shware/security';
|
|
172
|
+
|
|
173
|
+
declare module '@shware/security' {
|
|
174
|
+
interface AuthenticationReasons {
|
|
175
|
+
CLOSED_BETA: { invite?: string };
|
|
176
|
+
REGION_BLOCKED: { region: string }; // required: the throw must pass it
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw new AuthenticationError('EMAIL_ALREADY_USED', {
|
|
181
|
+
metadata: { provider: 'GOOGLE' },
|
|
182
|
+
message: 'Email already used by another account',
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`AuthenticationReason` is the union of all reasons, for the client-side switch;
|
|
187
|
+
`AUTHENTICATION_REASONS` lists the built-in ones at runtime, for a message table.
|
|
188
|
+
|
|
189
|
+
`Auth` answers it in the shape of the route: the redirect flow lands on `errorUri` with
|
|
190
|
+
`error=access_denied&reason=EMAIL_ALREADY_USED&provider=GOOGLE`; the JSON routes (native, One
|
|
191
|
+
Tap, email) answer `403` with a google.rpc error body whose `ErrorInfo` holds the `reason` and
|
|
192
|
+
`metadata` (what `@shware/http`'s `getErrorInfo` reads). Anything else the handler throws is a
|
|
193
|
+
server fault: `server_error` on the redirect, rethrown to your error handler on the JSON routes.
|
|
194
|
+
|
|
154
195
|
## `Auth` options
|
|
155
196
|
|
|
156
197
|
| Option | Type | Notes |
|
|
@@ -164,7 +205,13 @@ handy for a database enum.
|
|
|
164
205
|
| `verificationCode` | `{ maxAttempts?, resendCooldown? }` | Email code limits: guesses per code (default 5), seconds between sends (default 60). |
|
|
165
206
|
| `timing` | `boolean` | Adds a `Server-Timing` header to OAuth2 responses. |
|
|
166
207
|
|
|
167
|
-
The session id
|
|
208
|
+
The session id lives in the cookie. For clients without a cookie jar (a CLI, a desktop shell),
|
|
209
|
+
`session: { header: true }` also carries it in `X-Auth-Token`, as Spring Session does: every
|
|
210
|
+
response that sets the cookie answers `X-Auth-Token: <id>`, and a request without the cookie is
|
|
211
|
+
read from the same header. Off by default: it exposes the id to same-origin scripts, which the
|
|
212
|
+
cookie's `HttpOnly` keeps it from, and a cross-origin client needs the header in
|
|
213
|
+
`Access-Control-Expose-Headers`. `Authorization` is never read: an API token is a different
|
|
214
|
+
credential from a session and is not handled here.
|
|
168
215
|
`logout` clears the cookie with the same attributes it was set with (`domain`, `path`, …), so a
|
|
169
216
|
cookie set with `Domain=.example.com` is actually removed.
|
|
170
217
|
|
|
@@ -184,16 +231,15 @@ export async function getUserId(c: Context) {
|
|
|
184
231
|
}
|
|
185
232
|
```
|
|
186
233
|
|
|
187
|
-
|
|
188
|
-
|
|
234
|
+
The JSON routes answer every failure they know themselves (see "Errors" below), so your error
|
|
235
|
+
handler only sees genuine server faults: what `onAuthorized` or a session store threw besides an
|
|
236
|
+
`AuthenticationError`. A flow of your own that calls `Auth` directly (`verifyEmailCode`) can throw
|
|
237
|
+
the same errors; `errorResponse(error)` gives the response the routes would, or `null`:
|
|
189
238
|
|
|
190
239
|
```ts
|
|
191
|
-
import {
|
|
240
|
+
import { errorResponse } from '@shware/security';
|
|
192
241
|
|
|
193
|
-
app.onError((error, c) =>
|
|
194
|
-
if (error instanceof OAuth2Error) return c.json({ error: error.error }, 401);
|
|
195
|
-
throw error;
|
|
196
|
-
});
|
|
242
|
+
app.onError((error, c) => errorResponse(error) ?? yourErrorHandler(error, c));
|
|
197
243
|
```
|
|
198
244
|
|
|
199
245
|
## Session repositories
|
|
@@ -201,9 +247,8 @@ app.onError((error, c) => {
|
|
|
201
247
|
All repositories implement `SessionRepository` and index sessions by principal name
|
|
202
248
|
(`PRINCIPAL_NAME_INDEX_NAME`), so `kick` and `listSessions` work.
|
|
203
249
|
|
|
204
|
-
- `RedisIndexedSessionRepository(redis, namespace = '
|
|
205
|
-
|
|
206
|
-
sorted set). Takes an `ioredis` client.
|
|
250
|
+
- `RedisIndexedSessionRepository(redis, namespace = 'app:session')`: `<namespace>:sessions:<id>`
|
|
251
|
+
hashes, a principal index set, and an expirations sorted set. Takes an `ioredis` client.
|
|
207
252
|
- `DBIndexedSessionRepository(adapter)`: for any database; you implement `DBAdapter`
|
|
208
253
|
(`save`, `deleteById`, `findById`, `findByPrincipalName`, `cleanupExpiredSessions`).
|
|
209
254
|
- `RedisKVRepository(redis)`: `KVRepository` on Redis. Implement `KVRepository`
|
|
@@ -335,6 +380,8 @@ Every failure of the callback lands on `errorUri` — never a raw JSON error in
|
|
|
335
380
|
`console.warn` and reported by code only: `invalid_grant` (an expired or reused code),
|
|
336
381
|
`access_denied` or `temporarily_unavailable` as is, any other as `server_error`. Its message
|
|
337
382
|
stays in the logs, since it can name a client id or quote a provider's response;
|
|
383
|
+
- an `AuthenticationError` thrown by `onAuthorized` as `access_denied` with its `reason` and
|
|
384
|
+
metadata entries as parameters (logged with `console.warn`; see "The `onAuthorized` handler");
|
|
338
385
|
- anything else thrown by the exchange, `onAuthorized` or the session store, as `server_error`
|
|
339
386
|
(logged with `console.error`).
|
|
340
387
|
|
|
@@ -413,7 +460,7 @@ Android any app can register the same scheme and receive the code. The app:
|
|
|
413
460
|
4. posts `code_verifier` with the `code` to `/login/oauth2/native/:provider`.
|
|
414
461
|
|
|
415
462
|
`loginOAuth2Native` hands `code_verifier` to the provider's token exchange (Google refuses the
|
|
416
|
-
code without the matching verifier) and answers `400
|
|
463
|
+
code without the matching verifier) and answers `400 INVALID_CODE_VERIFIER` when it is not 43-128
|
|
417
464
|
unreserved characters. Providers whose token endpoint takes no verifier (Apple) leave it out.
|
|
418
465
|
Without `code_verifier` the exchange runs as before. With expo-crypto and expo-web-browser:
|
|
419
466
|
|
|
@@ -470,7 +517,7 @@ A `credential` must carry a nonce: fetch one from `GET /oauth2/nonce/google` (`o
|
|
|
470
517
|
10 minutes) right before `prompt` and pass it on, so Google writes it into the ID token's `nonce`
|
|
471
518
|
claim. The login consumes the nonce, so a leaked ID token cannot be replayed for another session; a
|
|
472
519
|
credential without one, or with one that was not issued or was already used, is answered
|
|
473
|
-
`
|
|
520
|
+
`403 NONCE_MISSING` / `403 NONCE_MISMATCH`. The nonce takes the place of Google's
|
|
474
521
|
`g_csrf_token` double-submit cookie, which FedCM mode no longer sets. A `code` (the popup fallback)
|
|
475
522
|
is single use already and needs no nonce.
|
|
476
523
|
|
|
@@ -515,6 +562,15 @@ app.post(PATH.LOGIN_EMAIL, (c) => auth.loginEmail(c.req.raw, onAuthorized));
|
|
|
515
562
|
```
|
|
516
563
|
|
|
517
564
|
- `POST /auth/sendEmailVerificationCode` with `{ email, turnstileToken }`.
|
|
565
|
+
- `auth.verifyEmailCode(email, code)` spends a mailed code without signing in, for a host flow that
|
|
566
|
+
only needs proof of the address (a signed-in user verifying or changing their email). Same
|
|
567
|
+
storage and limits as `loginEmail`; a wrong code throws the `AuthenticationError` `loginEmail`
|
|
568
|
+
would answer with (`INVALID_VERIFICATION_CODE`, `VERIFICATION_CODE_ATTEMPTS_EXCEEDED`), so
|
|
569
|
+
letting it propagate to an error handler that maps `AuthenticationError` answers the same 403.
|
|
570
|
+
- `claims.email_verified` is set only by providers that state it (Google, Apple, GitHub, and X
|
|
571
|
+
when it returns `confirmed_email`); Facebook leaves it unset. Trust an address only when it is
|
|
572
|
+
`true`. X hands out the address only with the `users.email` scope (in the default scope) and
|
|
573
|
+
"Request email from users" enabled on the app in the X developer console.
|
|
518
574
|
- `POST /login/email` with `{ email, verificationCode }`. On success `onAuthorized` is called with
|
|
519
575
|
`provider: 'EMAIL'`, claims `{ sub: email, email, email_verified: true }` and no `token`.
|
|
520
576
|
|
|
@@ -524,20 +580,44 @@ resend cooldown, and the claims' `sub` and `email`. The local part is lowercased
|
|
|
524
580
|
mailboxes are allowed by RFC 5321 but practically unused, and honouring them would let one mailbox
|
|
525
581
|
hold several accounts.
|
|
526
582
|
|
|
527
|
-
Error reasons (`google.rpc.ErrorInfo
|
|
583
|
+
Error reasons (`google.rpc.ErrorInfo`, see "Errors"):
|
|
528
584
|
|
|
529
|
-
| Reason | When |
|
|
530
|
-
| ------------------------------------- | --------------------------------------------------------------------------------- |
|
|
531
|
-
| `TURNSTILE_VERIFICATION_FAILED` | The Turnstile token was rejected. |
|
|
532
|
-
| `VERIFICATION_CODE_RESEND_TOO_SOON` | `429 RESOURCE_EXHAUSTED
|
|
533
|
-
| `EMAIL_DELIVERY_FAILED` | The sender threw; the cooldown is released, so the user can retry at once. |
|
|
534
|
-
| `INVALID_VERIFICATION_CODE` | Wrong code, or no code is pending for this email. |
|
|
535
|
-
| `VERIFICATION_CODE_ATTEMPTS_EXCEEDED` | More than `maxAttempts` guesses at this code; it is discarded, ask for a new one. |
|
|
585
|
+
| Reason | Status | When |
|
|
586
|
+
| ------------------------------------- | ------------------------ | --------------------------------------------------------------------------------- |
|
|
587
|
+
| `TURNSTILE_VERIFICATION_FAILED` | `400 INVALID_ARGUMENT` | The Turnstile token was rejected. |
|
|
588
|
+
| `VERIFICATION_CODE_RESEND_TOO_SOON` | `429 RESOURCE_EXHAUSTED` | A code was sent to this email within `resendCooldown`. |
|
|
589
|
+
| `EMAIL_DELIVERY_FAILED` | `503 UNAVAILABLE` | The sender threw; the cooldown is released, so the user can retry at once. |
|
|
590
|
+
| `INVALID_VERIFICATION_CODE` | `403 PERMISSION_DENIED` | Wrong code, or no code is pending for this email. |
|
|
591
|
+
| `VERIFICATION_CODE_ATTEMPTS_EXCEEDED` | `403 PERMISSION_DENIED` | More than `maxAttempts` guesses at this code; it is discarded, ask for a new one. |
|
|
536
592
|
|
|
537
593
|
Every login attempt against a pending code is counted before the code is compared, so parallel
|
|
538
594
|
requests cannot exceed `maxAttempts` guesses either. A new code starts with a fresh budget.
|
|
539
595
|
|
|
540
|
-
|
|
596
|
+
## Errors
|
|
597
|
+
|
|
598
|
+
The JSON routes (native, One Tap, email, desktop) answer every failure with a google.rpc error
|
|
599
|
+
body, the shape `@shware/http`'s `getErrorInfo` reads: `{ error: { code, status, message, details:
|
|
600
|
+
[{ '@type': '…google.rpc.ErrorInfo', reason, metadata? }] } }`. Branch on `reason`; the status
|
|
601
|
+
tells the kind:
|
|
602
|
+
|
|
603
|
+
| Status | What |
|
|
604
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
605
|
+
| `400 INVALID_ARGUMENT` | The request itself: a body that is not JSON (`INVALID_JSON`), a wrong content type, a field that failed validation (`INVALID_ARGUMENT` with a `BadRequest` detail naming it), `INVALID_ATTRIBUTES`, `INVALID_CODE_VERIFIER`. |
|
|
606
|
+
| `401 UNAUTHENTICATED` | `desktopAuthorize` without a signed-in session. |
|
|
607
|
+
| `403 PERMISSION_DENIED` | An `AuthenticationError`: the host refused the sign-in (see "The `onAuthorized` handler"), or the SDK did (`INVALID_VERIFICATION_CODE`, `VERIFICATION_CODE_ATTEMPTS_EXCEEDED`, `NONCE_MISSING`, `NONCE_MISMATCH`, `INVALID_DESKTOP_CODE`). |
|
|
608
|
+
| `429 RESOURCE_EXHAUSTED` | `VERIFICATION_CODE_RESEND_TOO_SOON`. |
|
|
609
|
+
| `503 UNAVAILABLE` | `EMAIL_DELIVERY_FAILED`, or the provider is down. |
|
|
610
|
+
|
|
611
|
+
An `OAuth2Error` from the provider client is answered by what it means, with `OAUTH2_<error>` as
|
|
612
|
+
the reason: `OAUTH2_INVALID_REQUEST` and the other malformed-request codes as `400`,
|
|
613
|
+
`OAUTH2_INVALID_GRANT` (a code or id token that is expired, used, or for another client or
|
|
614
|
+
issuer), `OAUTH2_INVALID_CLIENT`, `OAUTH2_INVALID_TOKEN`, `OAUTH2_ACCESS_DENIED` as `403`,
|
|
615
|
+
`OAUTH2_SERVER_ERROR` and `OAUTH2_TEMPORARILY_UNAVAILABLE` as `503`. Its message stays in the logs:
|
|
616
|
+
it can name a client id or quote the provider's response.
|
|
617
|
+
|
|
618
|
+
Anything else thrown (by `onAuthorized`, a session store, a sender) propagates to your error
|
|
619
|
+
handler as the server fault it is. The redirect flow is the exception to all of this: a page, not
|
|
620
|
+
a client, reads its outcome, so it lands on `errorUri` with `error=…` (see "Redirect flow").
|
|
541
621
|
|
|
542
622
|
## Routes (`PATH`)
|
|
543
623
|
|
|
@@ -556,9 +636,7 @@ its own constant.
|
|
|
556
636
|
| `LOGIN_OAUTH2_NATIVE` | `/login/oauth2/native/:provider` | `loginOAuth2Native` |
|
|
557
637
|
| `LOGIN_OAUTH2_ONETAP` | `/login/oauth2/onetap/google` | `loginOAuth2Onetap` |
|
|
558
638
|
| `LOGIN_EMAIL` | `/login/email` | `loginEmail` |
|
|
559
|
-
| `LOGIN_PHONE` | `/login/phone` | `loginPhone` (not implemented) |
|
|
560
639
|
| `SEND_EMAIL_VERIFICATION_CODE` | `/auth/sendEmailVerificationCode` | `sendEmailVerificationCode` |
|
|
561
|
-
| `SEND_PHONE_VERIFICATION_CODE` | `/auth/sendPhoneVerificationCode` | `sendPhoneVerificationCode` (not implemented) |
|
|
562
640
|
| `DESKTOP_AUTHORIZE` | `/auth/desktop/authorize` | `desktopAuthorize` |
|
|
563
641
|
| `DESKTOP_EXCHANGE` | `/auth/desktop/exchange` | `desktopExchange` |
|
|
564
642
|
| `CLEANUP_EXPIRED_SESSIONS` | `/sessions/expired/cleanup` | `cleanupExpiredSessions` |
|
package/dist/auth/attributes.cjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_auth_respond = require("./respond.cjs");
|
|
2
3
|
let zod_mini = require("zod/mini");
|
|
3
4
|
// src/auth/attributes.ts
|
|
4
5
|
/** Upper bound on serialized attributes: they ride in a session or a request body. */
|
|
@@ -13,13 +14,13 @@ function attributesSize(attributes) {
|
|
|
13
14
|
*/
|
|
14
15
|
const attributesSchema = (0, zod_mini.optional)((0, zod_mini.record)((0, zod_mini.string)(), (0, zod_mini.string)()).check((0, zod_mini.refine)((value) => attributesSize(value) <= MAX_ATTRIBUTES_BYTES, { message: `attributes must be at most ${MAX_ATTRIBUTES_BYTES} bytes` })));
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* The optional `attributes` of a login body (native): `{}` when absent, a 400 when present but
|
|
18
|
+
* not strings-to-strings within `MAX_ATTRIBUTES_BYTES`.
|
|
18
19
|
*/
|
|
19
20
|
function bodyAttributes(body) {
|
|
20
21
|
const value = typeof body === "object" && body !== null && "attributes" in body ? body.attributes : void 0;
|
|
21
22
|
const parsed = attributesSchema.safeParse(value);
|
|
22
|
-
if (!parsed.success)
|
|
23
|
+
if (!parsed.success) throw require_auth_respond.invalidArgument("INVALID_ATTRIBUTES", `attributes must map strings to strings, at most ${MAX_ATTRIBUTES_BYTES} bytes`);
|
|
23
24
|
return parsed.data ?? {};
|
|
24
25
|
}
|
|
25
26
|
exports.MAX_ATTRIBUTES_BYTES = MAX_ATTRIBUTES_BYTES;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attributes.cjs","names":["optional","record","string","refine"],"sources":["../../src/auth/attributes.ts"],"sourcesContent":["import { optional, record, refine, string } from 'zod/mini';\n\n/** Upper bound on serialized attributes: they ride in a session or a request body. */\nexport const MAX_ATTRIBUTES_BYTES = 2048;\n\nexport function attributesSize(attributes: Record<string, string>) {\n return new TextEncoder().encode(JSON.stringify(attributes)).length;\n}\n\n/**\n * The optional `attributes` field of a login body (native, One Tap, email): string values only,\n * at most `MAX_ATTRIBUTES_BYTES` serialized. Sent by the client itself, so a bad one is the\n * client's error (400), unlike oversized attributes passed to `oauth2Authorization` by the host.\n */\nexport const attributesSchema = optional(\n record(string(), string()).check(\n refine((value) => attributesSize(value) <= MAX_ATTRIBUTES_BYTES, {\n message: `attributes must be at most ${MAX_ATTRIBUTES_BYTES} bytes`,\n })\n )\n);\n\n/**\n *
|
|
1
|
+
{"version":3,"file":"attributes.cjs","names":["optional","record","string","refine","invalidArgument"],"sources":["../../src/auth/attributes.ts"],"sourcesContent":["import { optional, record, refine, string } from 'zod/mini';\nimport { invalidArgument } from './respond';\n\n/** Upper bound on serialized attributes: they ride in a session or a request body. */\nexport const MAX_ATTRIBUTES_BYTES = 2048;\n\nexport function attributesSize(attributes: Record<string, string>) {\n return new TextEncoder().encode(JSON.stringify(attributes)).length;\n}\n\n/**\n * The optional `attributes` field of a login body (native, One Tap, email): string values only,\n * at most `MAX_ATTRIBUTES_BYTES` serialized. Sent by the client itself, so a bad one is the\n * client's error (400), unlike oversized attributes passed to `oauth2Authorization` by the host.\n */\nexport const attributesSchema = optional(\n record(string(), string()).check(\n refine((value) => attributesSize(value) <= MAX_ATTRIBUTES_BYTES, {\n message: `attributes must be at most ${MAX_ATTRIBUTES_BYTES} bytes`,\n })\n )\n);\n\n/**\n * The optional `attributes` of a login body (native): `{}` when absent, a 400 when present but\n * not strings-to-strings within `MAX_ATTRIBUTES_BYTES`.\n */\nexport function bodyAttributes(body: unknown): Record<string, string> {\n const value =\n typeof body === 'object' && body !== null && 'attributes' in body ? body.attributes : undefined;\n const parsed = attributesSchema.safeParse(value);\n if (!parsed.success) {\n throw invalidArgument(\n 'INVALID_ATTRIBUTES',\n `attributes must map strings to strings, at most ${MAX_ATTRIBUTES_BYTES} bytes`\n );\n }\n return parsed.data ?? {};\n}\n"],"mappings":";;;;;AAIA,MAAa,uBAAuB;AAEpC,SAAgB,eAAe,YAAoC;CACjE,OAAO,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,UAAU,UAAU,CAAC,CAAC,CAAC;AAC9D;;;;;;AAOA,MAAa,oBAAA,GAAmBA,SAAAA,SAAAA,EAAAA,GAC9BC,SAAAA,OAAAA,EAAAA,GAAOC,SAAAA,OAAAA,CAAO,IAAA,GAAGA,SAAAA,OAAAA,CAAO,CAAC,CAAC,CAAC,OAAA,GACzBC,SAAAA,OAAAA,EAAQ,UAAU,eAAe,KAAK,KAAK,sBAAsB,EAC/D,SAAS,8BAA8B,qBAAqB,QAC9D,CAAC,CACH,CACF;;;;;AAMA,SAAgB,eAAe,MAAuC;CACpE,MAAM,QACJ,OAAO,SAAS,YAAY,SAAS,QAAQ,gBAAgB,OAAO,KAAK,aAAa,KAAA;CACxF,MAAM,SAAS,iBAAiB,UAAU,KAAK;CAC/C,IAAI,CAAC,OAAO,SACV,MAAMC,qBAAAA,gBACJ,sBACA,mDAAmD,qBAAqB,OAC1E;CAEF,OAAO,OAAO,QAAQ,CAAC;AACzB"}
|
|
@@ -8,8 +8,8 @@ export declare function attributesSize(attributes: Record<string, string>): numb
|
|
|
8
8
|
*/
|
|
9
9
|
export declare const attributesSchema: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniRecord<import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniString<string>>>;
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The optional `attributes` of a login body (native): `{}` when absent, a 400 when present but
|
|
12
|
+
* not strings-to-strings within `MAX_ATTRIBUTES_BYTES`.
|
|
13
13
|
*/
|
|
14
|
-
export declare function bodyAttributes(body: unknown): Record<string, string
|
|
14
|
+
export declare function bodyAttributes(body: unknown): Record<string, string>;
|
|
15
15
|
//# sourceMappingURL=attributes.d.cts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attributes.d.cts","names":[],"sources":["../../src/auth/attributes.ts"],"mappings":";
|
|
1
|
+
{"version":3,"file":"attributes.d.cts","names":[],"sources":["../../src/auth/attributes.ts"],"mappings":";qBAIa;wBAEG,eAAe,YAAY;;;;;;qBAS9B,qCAAgB,mCAAA,iCAAA,0CAAA;;;;;wBAYb,eAAe,gBAAgB"}
|
|
@@ -8,8 +8,8 @@ export declare function attributesSize(attributes: Record<string, string>): numb
|
|
|
8
8
|
*/
|
|
9
9
|
export declare const attributesSchema: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniRecord<import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniString<string>>>;
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The optional `attributes` of a login body (native): `{}` when absent, a 400 when present but
|
|
12
|
+
* not strings-to-strings within `MAX_ATTRIBUTES_BYTES`.
|
|
13
13
|
*/
|
|
14
|
-
export declare function bodyAttributes(body: unknown): Record<string, string
|
|
14
|
+
export declare function bodyAttributes(body: unknown): Record<string, string>;
|
|
15
15
|
//# sourceMappingURL=attributes.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attributes.d.mts","names":[],"sources":["../../src/auth/attributes.ts"],"mappings":";
|
|
1
|
+
{"version":3,"file":"attributes.d.mts","names":[],"sources":["../../src/auth/attributes.ts"],"mappings":";qBAIa;wBAEG,eAAe,YAAY;;;;;;qBAS9B,qCAAgB,mCAAA,iCAAA,0CAAA;;;;;wBAYb,eAAe,gBAAgB"}
|
package/dist/auth/attributes.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { invalidArgument } from "./respond.mjs";
|
|
1
2
|
import { optional, record, refine, string } from "zod/mini";
|
|
2
3
|
// src/auth/attributes.ts
|
|
3
4
|
/** Upper bound on serialized attributes: they ride in a session or a request body. */
|
|
@@ -12,13 +13,13 @@ function attributesSize(attributes) {
|
|
|
12
13
|
*/
|
|
13
14
|
const attributesSchema = optional(record(string(), string()).check(refine((value) => attributesSize(value) <= MAX_ATTRIBUTES_BYTES, { message: `attributes must be at most ${MAX_ATTRIBUTES_BYTES} bytes` })));
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* The optional `attributes` of a login body (native): `{}` when absent, a 400 when present but
|
|
17
|
+
* not strings-to-strings within `MAX_ATTRIBUTES_BYTES`.
|
|
17
18
|
*/
|
|
18
19
|
function bodyAttributes(body) {
|
|
19
20
|
const value = typeof body === "object" && body !== null && "attributes" in body ? body.attributes : void 0;
|
|
20
21
|
const parsed = attributesSchema.safeParse(value);
|
|
21
|
-
if (!parsed.success)
|
|
22
|
+
if (!parsed.success) throw invalidArgument("INVALID_ATTRIBUTES", `attributes must map strings to strings, at most ${MAX_ATTRIBUTES_BYTES} bytes`);
|
|
22
23
|
return parsed.data ?? {};
|
|
23
24
|
}
|
|
24
25
|
export { MAX_ATTRIBUTES_BYTES, attributesSchema, attributesSize, bodyAttributes };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attributes.mjs","names":[],"sources":["../../src/auth/attributes.ts"],"sourcesContent":["import { optional, record, refine, string } from 'zod/mini';\n\n/** Upper bound on serialized attributes: they ride in a session or a request body. */\nexport const MAX_ATTRIBUTES_BYTES = 2048;\n\nexport function attributesSize(attributes: Record<string, string>) {\n return new TextEncoder().encode(JSON.stringify(attributes)).length;\n}\n\n/**\n * The optional `attributes` field of a login body (native, One Tap, email): string values only,\n * at most `MAX_ATTRIBUTES_BYTES` serialized. Sent by the client itself, so a bad one is the\n * client's error (400), unlike oversized attributes passed to `oauth2Authorization` by the host.\n */\nexport const attributesSchema = optional(\n record(string(), string()).check(\n refine((value) => attributesSize(value) <= MAX_ATTRIBUTES_BYTES, {\n message: `attributes must be at most ${MAX_ATTRIBUTES_BYTES} bytes`,\n })\n )\n);\n\n/**\n *
|
|
1
|
+
{"version":3,"file":"attributes.mjs","names":[],"sources":["../../src/auth/attributes.ts"],"sourcesContent":["import { optional, record, refine, string } from 'zod/mini';\nimport { invalidArgument } from './respond';\n\n/** Upper bound on serialized attributes: they ride in a session or a request body. */\nexport const MAX_ATTRIBUTES_BYTES = 2048;\n\nexport function attributesSize(attributes: Record<string, string>) {\n return new TextEncoder().encode(JSON.stringify(attributes)).length;\n}\n\n/**\n * The optional `attributes` field of a login body (native, One Tap, email): string values only,\n * at most `MAX_ATTRIBUTES_BYTES` serialized. Sent by the client itself, so a bad one is the\n * client's error (400), unlike oversized attributes passed to `oauth2Authorization` by the host.\n */\nexport const attributesSchema = optional(\n record(string(), string()).check(\n refine((value) => attributesSize(value) <= MAX_ATTRIBUTES_BYTES, {\n message: `attributes must be at most ${MAX_ATTRIBUTES_BYTES} bytes`,\n })\n )\n);\n\n/**\n * The optional `attributes` of a login body (native): `{}` when absent, a 400 when present but\n * not strings-to-strings within `MAX_ATTRIBUTES_BYTES`.\n */\nexport function bodyAttributes(body: unknown): Record<string, string> {\n const value =\n typeof body === 'object' && body !== null && 'attributes' in body ? body.attributes : undefined;\n const parsed = attributesSchema.safeParse(value);\n if (!parsed.success) {\n throw invalidArgument(\n 'INVALID_ATTRIBUTES',\n `attributes must map strings to strings, at most ${MAX_ATTRIBUTES_BYTES} bytes`\n );\n }\n return parsed.data ?? {};\n}\n"],"mappings":";;;;AAIA,MAAa,uBAAuB;AAEpC,SAAgB,eAAe,YAAoC;CACjE,OAAO,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,UAAU,UAAU,CAAC,CAAC,CAAC;AAC9D;;;;;;AAOA,MAAa,mBAAmB,SAC9B,OAAO,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,MACzB,QAAQ,UAAU,eAAe,KAAK,KAAK,sBAAsB,EAC/D,SAAS,8BAA8B,qBAAqB,QAC9D,CAAC,CACH,CACF;;;;;AAMA,SAAgB,eAAe,MAAuC;CACpE,MAAM,QACJ,OAAO,SAAS,YAAY,SAAS,QAAQ,gBAAgB,OAAO,KAAK,aAAa,KAAA;CACxF,MAAM,SAAS,iBAAiB,UAAU,KAAK;CAC/C,IAAI,CAAC,OAAO,SACV,MAAM,gBACJ,sBACA,mDAAmD,qBAAqB,OAC1E;CAEF,OAAO,OAAO,QAAQ,CAAC;AACzB"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_auth_error = require("./error.cjs");
|
|
3
|
+
let node_crypto = require("node:crypto");
|
|
4
|
+
// src/auth/desktop-codes.ts
|
|
5
|
+
/** Seconds a desktop sign-in code may be redeemed within. */
|
|
6
|
+
const DESKTOP_CODE_TTL = 300;
|
|
7
|
+
/**
|
|
8
|
+
* The single-use codes of the desktop sign-in handshake (RFC 8252 loopback + PKCE): a signed-in
|
|
9
|
+
* web session issues one pinned to the desktop's `code_challenge`, the desktop redeems it with the
|
|
10
|
+
* matching `code_verifier`, and nothing else ever travels over the loopback.
|
|
11
|
+
*/
|
|
12
|
+
var DesktopCodes = class {
|
|
13
|
+
kv;
|
|
14
|
+
constructor(kv) {
|
|
15
|
+
this.kv = kv;
|
|
16
|
+
}
|
|
17
|
+
key(code) {
|
|
18
|
+
return `desktop:code:${code}`;
|
|
19
|
+
}
|
|
20
|
+
async issue(principal, codeChallenge) {
|
|
21
|
+
const code = (0, node_crypto.randomUUID)();
|
|
22
|
+
const record = {
|
|
23
|
+
principal,
|
|
24
|
+
code_challenge: codeChallenge
|
|
25
|
+
};
|
|
26
|
+
await this.kv.setItem(this.key(code), JSON.stringify(record), 300);
|
|
27
|
+
return code;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The principal a code was issued for, consuming the code; `INVALID_DESKTOP_CODE` for one
|
|
31
|
+
* that is unknown, expired, already used, or whose verifier does not hash to its challenge. A
|
|
32
|
+
* verifier mismatch does not burn the code: that would let an attacker who races a network
|
|
33
|
+
* retry brick a legitimate exchange, and guessing the 256-bit verifier within the TTL is
|
|
34
|
+
* infeasible.
|
|
35
|
+
*/
|
|
36
|
+
async redeem(code, codeVerifier) {
|
|
37
|
+
const key = this.key(code);
|
|
38
|
+
const raw = await this.kv.getItem(key);
|
|
39
|
+
if (typeof raw !== "string") throw new require_auth_error.AuthenticationError("INVALID_DESKTOP_CODE");
|
|
40
|
+
let record;
|
|
41
|
+
try {
|
|
42
|
+
record = JSON.parse(raw);
|
|
43
|
+
} catch {
|
|
44
|
+
await this.kv.removeItem(key);
|
|
45
|
+
throw new require_auth_error.AuthenticationError("INVALID_DESKTOP_CODE");
|
|
46
|
+
}
|
|
47
|
+
const computed = (0, node_crypto.createHash)("sha256").update(codeVerifier).digest();
|
|
48
|
+
const stored = Buffer.from(record.code_challenge, "base64url");
|
|
49
|
+
if (stored.length !== computed.length || !(0, node_crypto.timingSafeEqual)(computed, stored)) throw new require_auth_error.AuthenticationError("INVALID_DESKTOP_CODE");
|
|
50
|
+
await this.kv.removeItem(key);
|
|
51
|
+
return record.principal;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
exports.DESKTOP_CODE_TTL = DESKTOP_CODE_TTL;
|
|
55
|
+
exports.DesktopCodes = DesktopCodes;
|
|
56
|
+
|
|
57
|
+
//# sourceMappingURL=desktop-codes.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"desktop-codes.cjs","names":["randomUUID","AuthenticationError","createHash","timingSafeEqual"],"sources":["../../src/auth/desktop-codes.ts"],"sourcesContent":["import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';\nimport type { Principal } from '../core/index';\nimport type { KVRepository } from '../session/types';\nimport { AuthenticationError } from './error';\n\n/** Seconds a desktop sign-in code may be redeemed within. */\nexport const DESKTOP_CODE_TTL = 5 * 60;\n\ninterface DesktopCodeRecord {\n /** Who the web session that issued the code is signed in as. */\n principal: Principal;\n /** The S256 `code_challenge` the desktop sent; the verifier it redeems with must hash to it. */\n code_challenge: string;\n}\n\n/**\n * The single-use codes of the desktop sign-in handshake (RFC 8252 loopback + PKCE): a signed-in\n * web session issues one pinned to the desktop's `code_challenge`, the desktop redeems it with the\n * matching `code_verifier`, and nothing else ever travels over the loopback.\n */\nexport class DesktopCodes {\n private readonly kv: KVRepository;\n\n constructor(kv: KVRepository) {\n this.kv = kv;\n }\n\n private key(code: string) {\n return `desktop:code:${code}`;\n }\n\n async issue(principal: Principal, codeChallenge: string): Promise<string> {\n const code = randomUUID();\n const record: DesktopCodeRecord = { principal, code_challenge: codeChallenge };\n await this.kv.setItem(this.key(code), JSON.stringify(record), DESKTOP_CODE_TTL);\n return code;\n }\n\n /**\n * The principal a code was issued for, consuming the code; `INVALID_DESKTOP_CODE` for one\n * that is unknown, expired, already used, or whose verifier does not hash to its challenge. A\n * verifier mismatch does not burn the code: that would let an attacker who races a network\n * retry brick a legitimate exchange, and guessing the 256-bit verifier within the TTL is\n * infeasible.\n */\n async redeem(code: string, codeVerifier: string): Promise<Principal> {\n const key = this.key(code);\n const raw = await this.kv.getItem(key);\n if (typeof raw !== 'string') throw new AuthenticationError('INVALID_DESKTOP_CODE');\n let record: DesktopCodeRecord;\n try {\n record = JSON.parse(raw) as DesktopCodeRecord;\n } catch {\n await this.kv.removeItem(key);\n throw new AuthenticationError('INVALID_DESKTOP_CODE');\n }\n const computed = createHash('sha256').update(codeVerifier).digest();\n const stored = Buffer.from(record.code_challenge, 'base64url');\n if (stored.length !== computed.length || !timingSafeEqual(computed, stored)) {\n throw new AuthenticationError('INVALID_DESKTOP_CODE');\n }\n // Removed before the caller creates a session, so a concurrent duplicate exchange (a retry\n // after a timeout) sees the code as consumed even if the session save races it.\n await this.kv.removeItem(key);\n return record.principal;\n }\n}\n"],"mappings":";;;;;AAMA,MAAa,mBAAmB;;;;;;AAchC,IAAa,eAAb,MAA0B;CACxB;CAEA,YAAY,IAAkB;EAC5B,KAAK,KAAK;CACZ;CAEA,IAAY,MAAc;EACxB,OAAO,gBAAgB;CACzB;CAEA,MAAM,MAAM,WAAsB,eAAwC;EACxE,MAAM,QAAA,GAAOA,YAAAA,WAAAA,CAAW;EACxB,MAAM,SAA4B;GAAE;GAAW,gBAAgB;EAAc;EAC7E,MAAM,KAAK,GAAG,QAAQ,KAAK,IAAI,IAAI,GAAG,KAAK,UAAU,MAAM,GAAA,GAAmB;EAC9E,OAAO;CACT;;;;;;;;CASA,MAAM,OAAO,MAAc,cAA0C;EACnE,MAAM,MAAM,KAAK,IAAI,IAAI;EACzB,MAAM,MAAM,MAAM,KAAK,GAAG,QAAQ,GAAG;EACrC,IAAI,OAAO,QAAQ,UAAU,MAAM,IAAIC,mBAAAA,oBAAoB,sBAAsB;EACjF,IAAI;EACJ,IAAI;GACF,SAAS,KAAK,MAAM,GAAG;EACzB,QAAQ;GACN,MAAM,KAAK,GAAG,WAAW,GAAG;GAC5B,MAAM,IAAIA,mBAAAA,oBAAoB,sBAAsB;EACtD;EACA,MAAM,YAAA,GAAWC,YAAAA,WAAAA,CAAW,QAAQ,CAAC,CAAC,OAAO,YAAY,CAAC,CAAC,OAAO;EAClE,MAAM,SAAS,OAAO,KAAK,OAAO,gBAAgB,WAAW;EAC7D,IAAI,OAAO,WAAW,SAAS,UAAU,EAAA,GAACC,YAAAA,gBAAAA,CAAgB,UAAU,MAAM,GACxE,MAAM,IAAIF,mBAAAA,oBAAoB,sBAAsB;EAItD,MAAM,KAAK,GAAG,WAAW,GAAG;EAC5B,OAAO,OAAO;CAChB;AACF"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Principal } from "../core/index.cjs";
|
|
2
|
+
import { KVRepository } from "../session/types.cjs";
|
|
3
|
+
/** Seconds a desktop sign-in code may be redeemed within. */
|
|
4
|
+
export declare const DESKTOP_CODE_TTL: number;
|
|
5
|
+
/**
|
|
6
|
+
* The single-use codes of the desktop sign-in handshake (RFC 8252 loopback + PKCE): a signed-in
|
|
7
|
+
* web session issues one pinned to the desktop's `code_challenge`, the desktop redeems it with the
|
|
8
|
+
* matching `code_verifier`, and nothing else ever travels over the loopback.
|
|
9
|
+
*/
|
|
10
|
+
export declare class DesktopCodes {
|
|
11
|
+
private readonly kv;
|
|
12
|
+
constructor(kv: KVRepository);
|
|
13
|
+
private key;
|
|
14
|
+
issue(principal: Principal, codeChallenge: string): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* The principal a code was issued for, consuming the code; `INVALID_DESKTOP_CODE` for one
|
|
17
|
+
* that is unknown, expired, already used, or whose verifier does not hash to its challenge. A
|
|
18
|
+
* verifier mismatch does not burn the code: that would let an attacker who races a network
|
|
19
|
+
* retry brick a legitimate exchange, and guessing the 256-bit verifier within the TTL is
|
|
20
|
+
* infeasible.
|
|
21
|
+
*/
|
|
22
|
+
redeem(code: string, codeVerifier: string): Promise<Principal>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=desktop-codes.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"desktop-codes.d.cts","names":[],"sources":["../../src/auth/desktop-codes.ts"],"mappings":";;;qBAMa;;;;;;qBAcA;mBACM;EAEjB,YAAY,IAAI;UAIR;EAIF,MAAM,WAAW,WAAW,wBAAwB;;;;;;;;EAcpD,OAAO,cAAc,uBAAuB,QAAQ"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Principal } from "../core/index.mjs";
|
|
2
|
+
import { KVRepository } from "../session/types.mjs";
|
|
3
|
+
/** Seconds a desktop sign-in code may be redeemed within. */
|
|
4
|
+
export declare const DESKTOP_CODE_TTL: number;
|
|
5
|
+
/**
|
|
6
|
+
* The single-use codes of the desktop sign-in handshake (RFC 8252 loopback + PKCE): a signed-in
|
|
7
|
+
* web session issues one pinned to the desktop's `code_challenge`, the desktop redeems it with the
|
|
8
|
+
* matching `code_verifier`, and nothing else ever travels over the loopback.
|
|
9
|
+
*/
|
|
10
|
+
export declare class DesktopCodes {
|
|
11
|
+
private readonly kv;
|
|
12
|
+
constructor(kv: KVRepository);
|
|
13
|
+
private key;
|
|
14
|
+
issue(principal: Principal, codeChallenge: string): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* The principal a code was issued for, consuming the code; `INVALID_DESKTOP_CODE` for one
|
|
17
|
+
* that is unknown, expired, already used, or whose verifier does not hash to its challenge. A
|
|
18
|
+
* verifier mismatch does not burn the code: that would let an attacker who races a network
|
|
19
|
+
* retry brick a legitimate exchange, and guessing the 256-bit verifier within the TTL is
|
|
20
|
+
* infeasible.
|
|
21
|
+
*/
|
|
22
|
+
redeem(code: string, codeVerifier: string): Promise<Principal>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=desktop-codes.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"desktop-codes.d.mts","names":[],"sources":["../../src/auth/desktop-codes.ts"],"mappings":";;;qBAMa;;;;;;qBAcA;mBACM;EAEjB,YAAY,IAAI;UAIR;EAIF,MAAM,WAAW,WAAW,wBAAwB;;;;;;;;EAcpD,OAAO,cAAc,uBAAuB,QAAQ"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { AuthenticationError } from "./error.mjs";
|
|
2
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
// src/auth/desktop-codes.ts
|
|
4
|
+
/** Seconds a desktop sign-in code may be redeemed within. */
|
|
5
|
+
const DESKTOP_CODE_TTL = 300;
|
|
6
|
+
/**
|
|
7
|
+
* The single-use codes of the desktop sign-in handshake (RFC 8252 loopback + PKCE): a signed-in
|
|
8
|
+
* web session issues one pinned to the desktop's `code_challenge`, the desktop redeems it with the
|
|
9
|
+
* matching `code_verifier`, and nothing else ever travels over the loopback.
|
|
10
|
+
*/
|
|
11
|
+
var DesktopCodes = class {
|
|
12
|
+
kv;
|
|
13
|
+
constructor(kv) {
|
|
14
|
+
this.kv = kv;
|
|
15
|
+
}
|
|
16
|
+
key(code) {
|
|
17
|
+
return `desktop:code:${code}`;
|
|
18
|
+
}
|
|
19
|
+
async issue(principal, codeChallenge) {
|
|
20
|
+
const code = randomUUID();
|
|
21
|
+
const record = {
|
|
22
|
+
principal,
|
|
23
|
+
code_challenge: codeChallenge
|
|
24
|
+
};
|
|
25
|
+
await this.kv.setItem(this.key(code), JSON.stringify(record), 300);
|
|
26
|
+
return code;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The principal a code was issued for, consuming the code; `INVALID_DESKTOP_CODE` for one
|
|
30
|
+
* that is unknown, expired, already used, or whose verifier does not hash to its challenge. A
|
|
31
|
+
* verifier mismatch does not burn the code: that would let an attacker who races a network
|
|
32
|
+
* retry brick a legitimate exchange, and guessing the 256-bit verifier within the TTL is
|
|
33
|
+
* infeasible.
|
|
34
|
+
*/
|
|
35
|
+
async redeem(code, codeVerifier) {
|
|
36
|
+
const key = this.key(code);
|
|
37
|
+
const raw = await this.kv.getItem(key);
|
|
38
|
+
if (typeof raw !== "string") throw new AuthenticationError("INVALID_DESKTOP_CODE");
|
|
39
|
+
let record;
|
|
40
|
+
try {
|
|
41
|
+
record = JSON.parse(raw);
|
|
42
|
+
} catch {
|
|
43
|
+
await this.kv.removeItem(key);
|
|
44
|
+
throw new AuthenticationError("INVALID_DESKTOP_CODE");
|
|
45
|
+
}
|
|
46
|
+
const computed = createHash("sha256").update(codeVerifier).digest();
|
|
47
|
+
const stored = Buffer.from(record.code_challenge, "base64url");
|
|
48
|
+
if (stored.length !== computed.length || !timingSafeEqual(computed, stored)) throw new AuthenticationError("INVALID_DESKTOP_CODE");
|
|
49
|
+
await this.kv.removeItem(key);
|
|
50
|
+
return record.principal;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
export { DESKTOP_CODE_TTL, DesktopCodes };
|
|
54
|
+
|
|
55
|
+
//# sourceMappingURL=desktop-codes.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"desktop-codes.mjs","names":[],"sources":["../../src/auth/desktop-codes.ts"],"sourcesContent":["import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';\nimport type { Principal } from '../core/index';\nimport type { KVRepository } from '../session/types';\nimport { AuthenticationError } from './error';\n\n/** Seconds a desktop sign-in code may be redeemed within. */\nexport const DESKTOP_CODE_TTL = 5 * 60;\n\ninterface DesktopCodeRecord {\n /** Who the web session that issued the code is signed in as. */\n principal: Principal;\n /** The S256 `code_challenge` the desktop sent; the verifier it redeems with must hash to it. */\n code_challenge: string;\n}\n\n/**\n * The single-use codes of the desktop sign-in handshake (RFC 8252 loopback + PKCE): a signed-in\n * web session issues one pinned to the desktop's `code_challenge`, the desktop redeems it with the\n * matching `code_verifier`, and nothing else ever travels over the loopback.\n */\nexport class DesktopCodes {\n private readonly kv: KVRepository;\n\n constructor(kv: KVRepository) {\n this.kv = kv;\n }\n\n private key(code: string) {\n return `desktop:code:${code}`;\n }\n\n async issue(principal: Principal, codeChallenge: string): Promise<string> {\n const code = randomUUID();\n const record: DesktopCodeRecord = { principal, code_challenge: codeChallenge };\n await this.kv.setItem(this.key(code), JSON.stringify(record), DESKTOP_CODE_TTL);\n return code;\n }\n\n /**\n * The principal a code was issued for, consuming the code; `INVALID_DESKTOP_CODE` for one\n * that is unknown, expired, already used, or whose verifier does not hash to its challenge. A\n * verifier mismatch does not burn the code: that would let an attacker who races a network\n * retry brick a legitimate exchange, and guessing the 256-bit verifier within the TTL is\n * infeasible.\n */\n async redeem(code: string, codeVerifier: string): Promise<Principal> {\n const key = this.key(code);\n const raw = await this.kv.getItem(key);\n if (typeof raw !== 'string') throw new AuthenticationError('INVALID_DESKTOP_CODE');\n let record: DesktopCodeRecord;\n try {\n record = JSON.parse(raw) as DesktopCodeRecord;\n } catch {\n await this.kv.removeItem(key);\n throw new AuthenticationError('INVALID_DESKTOP_CODE');\n }\n const computed = createHash('sha256').update(codeVerifier).digest();\n const stored = Buffer.from(record.code_challenge, 'base64url');\n if (stored.length !== computed.length || !timingSafeEqual(computed, stored)) {\n throw new AuthenticationError('INVALID_DESKTOP_CODE');\n }\n // Removed before the caller creates a session, so a concurrent duplicate exchange (a retry\n // after a timeout) sees the code as consumed even if the session save races it.\n await this.kv.removeItem(key);\n return record.principal;\n }\n}\n"],"mappings":";;;;AAMA,MAAa,mBAAmB;;;;;;AAchC,IAAa,eAAb,MAA0B;CACxB;CAEA,YAAY,IAAkB;EAC5B,KAAK,KAAK;CACZ;CAEA,IAAY,MAAc;EACxB,OAAO,gBAAgB;CACzB;CAEA,MAAM,MAAM,WAAsB,eAAwC;EACxE,MAAM,OAAO,WAAW;EACxB,MAAM,SAA4B;GAAE;GAAW,gBAAgB;EAAc;EAC7E,MAAM,KAAK,GAAG,QAAQ,KAAK,IAAI,IAAI,GAAG,KAAK,UAAU,MAAM,GAAA,GAAmB;EAC9E,OAAO;CACT;;;;;;;;CASA,MAAM,OAAO,MAAc,cAA0C;EACnE,MAAM,MAAM,KAAK,IAAI,IAAI;EACzB,MAAM,MAAM,MAAM,KAAK,GAAG,QAAQ,GAAG;EACrC,IAAI,OAAO,QAAQ,UAAU,MAAM,IAAI,oBAAoB,sBAAsB;EACjF,IAAI;EACJ,IAAI;GACF,SAAS,KAAK,MAAM,GAAG;EACzB,QAAQ;GACN,MAAM,KAAK,GAAG,WAAW,GAAG;GAC5B,MAAM,IAAI,oBAAoB,sBAAsB;EACtD;EACA,MAAM,WAAW,WAAW,QAAQ,CAAC,CAAC,OAAO,YAAY,CAAC,CAAC,OAAO;EAClE,MAAM,SAAS,OAAO,KAAK,OAAO,gBAAgB,WAAW;EAC7D,IAAI,OAAO,WAAW,SAAS,UAAU,CAAC,gBAAgB,UAAU,MAAM,GACxE,MAAM,IAAI,oBAAoB,sBAAsB;EAItD,MAAM,KAAK,GAAG,WAAW,GAAG;EAC5B,OAAO,OAAO;CAChB;AACF"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_auth_error = require("./error.cjs");
|
|
3
|
+
const require_auth_respond = require("./respond.cjs");
|
|
4
|
+
let node_crypto = require("node:crypto");
|
|
5
|
+
// src/auth/email-codes.ts
|
|
6
|
+
/** Seconds an email verification code, and its attempt counter, stay valid. */
|
|
7
|
+
const EMAIL_VERIFICATION_CODE_TTL = 600;
|
|
8
|
+
/**
|
|
9
|
+
* The six-digit codes mailed to prove an address, in the KV store: one pending code per email,
|
|
10
|
+
* its guess counter, and the resend cooldown. `send` and `verify` are the whole protocol, so the
|
|
11
|
+
* login route and a host flow that only needs proof of the address (verifying or changing a
|
|
12
|
+
* signed-in user's email) share it.
|
|
13
|
+
*/
|
|
14
|
+
var EmailVerificationCodes = class {
|
|
15
|
+
kv;
|
|
16
|
+
maxAttempts;
|
|
17
|
+
resendCooldown;
|
|
18
|
+
constructor(kv, options) {
|
|
19
|
+
this.kv = kv;
|
|
20
|
+
this.maxAttempts = options.maxAttempts;
|
|
21
|
+
this.resendCooldown = options.resendCooldown;
|
|
22
|
+
}
|
|
23
|
+
codeKey(email) {
|
|
24
|
+
return `email:verification:${email}`;
|
|
25
|
+
}
|
|
26
|
+
attemptsKey(email) {
|
|
27
|
+
return `email:verification-attempts:${email}`;
|
|
28
|
+
}
|
|
29
|
+
cooldownKey(email) {
|
|
30
|
+
return `email:verification-cooldown:${email}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Mails a fresh code, at most once per `resendCooldown`. The cooldown is claimed before sending,
|
|
34
|
+
* so of concurrent requests exactly one sends. A failed delivery releases it again: no code was
|
|
35
|
+
* stored, so an immediate retry gains an attacker nothing, while keeping it would lock the user
|
|
36
|
+
* out of signing in for the whole cooldown.
|
|
37
|
+
*/
|
|
38
|
+
async send(email, sender) {
|
|
39
|
+
const cooldownKey = this.cooldownKey(email);
|
|
40
|
+
if (await this.kv.increment(cooldownKey, this.resendCooldown) > 1) throw require_auth_respond.resourceExhausted("VERIFICATION_CODE_RESEND_TOO_SOON", "A code was sent recently");
|
|
41
|
+
const verificationCode = (0, node_crypto.randomInt)(1e6).toString().padStart(6, "0");
|
|
42
|
+
try {
|
|
43
|
+
await sender({
|
|
44
|
+
email,
|
|
45
|
+
verificationCode
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error("Failed to send email verification code", error);
|
|
49
|
+
await this.kv.removeItem(cooldownKey);
|
|
50
|
+
throw require_auth_respond.unavailable("EMAIL_DELIVERY_FAILED", "The verification email could not be sent");
|
|
51
|
+
}
|
|
52
|
+
await this.kv.removeItem(this.attemptsKey(email));
|
|
53
|
+
await this.kv.setItem(this.codeKey(email), verificationCode, 600);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Spends the pending code if `verificationCode` is it; otherwise throws `AuthenticationError`:
|
|
57
|
+
* `INVALID_VERIFICATION_CODE` (wrong, or none pending), or `VERIFICATION_CODE_ATTEMPTS_EXCEEDED`
|
|
58
|
+
* once more than `maxAttempts` guesses were made, which discards the code.
|
|
59
|
+
*/
|
|
60
|
+
async verify(email, verificationCode) {
|
|
61
|
+
const codeKey = this.codeKey(email);
|
|
62
|
+
const attemptsKey = this.attemptsKey(email);
|
|
63
|
+
const stored = await this.kv.getItem(codeKey);
|
|
64
|
+
if (typeof stored !== "string") throw new require_auth_error.AuthenticationError("INVALID_VERIFICATION_CODE");
|
|
65
|
+
if (await this.kv.increment(attemptsKey, 600) > this.maxAttempts) {
|
|
66
|
+
await this.kv.removeItem(codeKey);
|
|
67
|
+
throw new require_auth_error.AuthenticationError("VERIFICATION_CODE_ATTEMPTS_EXCEEDED");
|
|
68
|
+
}
|
|
69
|
+
if (stored.length !== verificationCode.length || !(0, node_crypto.timingSafeEqual)(Buffer.from(stored), Buffer.from(verificationCode))) throw new require_auth_error.AuthenticationError("INVALID_VERIFICATION_CODE");
|
|
70
|
+
await this.kv.removeItem(codeKey);
|
|
71
|
+
await this.kv.removeItem(attemptsKey);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
exports.EMAIL_VERIFICATION_CODE_TTL = EMAIL_VERIFICATION_CODE_TTL;
|
|
75
|
+
exports.EmailVerificationCodes = EmailVerificationCodes;
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=email-codes.cjs.map
|