@shware/security 7.1.1 → 8.0.1
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 +113 -32
- 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 +249 -384
- 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 +252 -387
- 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 +17 -3
- package/dist/oauth2/provider/common.cjs.map +1 -1
- package/dist/oauth2/provider/common.d.cts.map +1 -1
- package/dist/oauth2/provider/common.d.mts.map +1 -1
- package/dist/oauth2/provider/common.mjs +17 -3
- 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/github.cjs +1 -1
- package/dist/oauth2/provider/github.cjs.map +1 -1
- package/dist/oauth2/provider/github.d.cts +1 -1
- package/dist/oauth2/provider/github.d.cts.map +1 -1
- package/dist/oauth2/provider/github.d.mts +1 -1
- package/dist/oauth2/provider/github.d.mts.map +1 -1
- package/dist/oauth2/provider/github.mjs +1 -1
- package/dist/oauth2/provider/github.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 +3 -3
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`
|
|
@@ -317,14 +362,17 @@ any app; it is sent with an `appsecret_proof`, so the client needs its `clientSe
|
|
|
317
362
|
### Web redirect flow
|
|
318
363
|
|
|
319
364
|
1. The browser opens `GET /oauth2/authorization/:provider`. `oauth2Authorization` stores the
|
|
320
|
-
state and PKCE verifier in the session and redirects to the provider.
|
|
365
|
+
state and PKCE verifier in the session and redirects to the provider. A browser without a
|
|
366
|
+
session gets one that lives 10 minutes, the life of the state, so an abandoned flow does not
|
|
367
|
+
keep a row for the default interval; a session it already carries keeps its own.
|
|
321
368
|
2. The provider calls back `GET|POST /login/oauth2/code/:provider` (Apple uses `form_post`,
|
|
322
369
|
so register both). `loginOAuth2Code` checks state, exchanges the code as the `web` client,
|
|
323
370
|
calls `onAuthorized` and redirects to `successUri` with `status=success&provider=<key>`, or to
|
|
324
371
|
`errorUri` with `error` and `error_description`.
|
|
325
372
|
3. On success the user is signed in on a new session id, not the one the flow started on, which
|
|
326
373
|
is deleted (session fixation protection). The attributes the host kept in that session move to
|
|
327
|
-
the new one; the single-use authorization request (state, PKCE verifier) does not.
|
|
374
|
+
the new one; the single-use authorization request (state, PKCE verifier) does not. The new
|
|
375
|
+
session has the repository's default inactive interval.
|
|
328
376
|
|
|
329
377
|
Every failure of the callback lands on `errorUri` — never a raw JSON error in the popup:
|
|
330
378
|
|
|
@@ -335,6 +383,8 @@ Every failure of the callback lands on `errorUri` — never a raw JSON error in
|
|
|
335
383
|
`console.warn` and reported by code only: `invalid_grant` (an expired or reused code),
|
|
336
384
|
`access_denied` or `temporarily_unavailable` as is, any other as `server_error`. Its message
|
|
337
385
|
stays in the logs, since it can name a client id or quote a provider's response;
|
|
386
|
+
- an `AuthenticationError` thrown by `onAuthorized` as `access_denied` with its `reason` and
|
|
387
|
+
metadata entries as parameters (logged with `console.warn`; see "The `onAuthorized` handler");
|
|
338
388
|
- anything else thrown by the exchange, `onAuthorized` or the session store, as `server_error`
|
|
339
389
|
(logged with `console.error`).
|
|
340
390
|
|
|
@@ -413,7 +463,7 @@ Android any app can register the same scheme and receive the code. The app:
|
|
|
413
463
|
4. posts `code_verifier` with the `code` to `/login/oauth2/native/:provider`.
|
|
414
464
|
|
|
415
465
|
`loginOAuth2Native` hands `code_verifier` to the provider's token exchange (Google refuses the
|
|
416
|
-
code without the matching verifier) and answers `400
|
|
466
|
+
code without the matching verifier) and answers `400 INVALID_CODE_VERIFIER` when it is not 43-128
|
|
417
467
|
unreserved characters. Providers whose token endpoint takes no verifier (Apple) leave it out.
|
|
418
468
|
Without `code_verifier` the exchange runs as before. With expo-crypto and expo-web-browser:
|
|
419
469
|
|
|
@@ -470,7 +520,7 @@ A `credential` must carry a nonce: fetch one from `GET /oauth2/nonce/google` (`o
|
|
|
470
520
|
10 minutes) right before `prompt` and pass it on, so Google writes it into the ID token's `nonce`
|
|
471
521
|
claim. The login consumes the nonce, so a leaked ID token cannot be replayed for another session; a
|
|
472
522
|
credential without one, or with one that was not issued or was already used, is answered
|
|
473
|
-
`
|
|
523
|
+
`403 NONCE_MISSING` / `403 NONCE_MISMATCH`. The nonce takes the place of Google's
|
|
474
524
|
`g_csrf_token` double-submit cookie, which FedCM mode no longer sets. A `code` (the popup fallback)
|
|
475
525
|
is single use already and needs no nonce.
|
|
476
526
|
|
|
@@ -515,6 +565,15 @@ app.post(PATH.LOGIN_EMAIL, (c) => auth.loginEmail(c.req.raw, onAuthorized));
|
|
|
515
565
|
```
|
|
516
566
|
|
|
517
567
|
- `POST /auth/sendEmailVerificationCode` with `{ email, turnstileToken }`.
|
|
568
|
+
- `auth.verifyEmailCode(email, code)` spends a mailed code without signing in, for a host flow that
|
|
569
|
+
only needs proof of the address (a signed-in user verifying or changing their email). Same
|
|
570
|
+
storage and limits as `loginEmail`; a wrong code throws the `AuthenticationError` `loginEmail`
|
|
571
|
+
would answer with (`INVALID_VERIFICATION_CODE`, `VERIFICATION_CODE_ATTEMPTS_EXCEEDED`), so
|
|
572
|
+
letting it propagate to an error handler that maps `AuthenticationError` answers the same 403.
|
|
573
|
+
- `claims.email_verified` is set only by providers that state it (Google, Apple, GitHub, and X
|
|
574
|
+
when it returns `confirmed_email`); Facebook leaves it unset. Trust an address only when it is
|
|
575
|
+
`true`. X hands out the address only with the `users.email` scope (in the default scope) and
|
|
576
|
+
"Request email from users" enabled on the app in the X developer console.
|
|
518
577
|
- `POST /login/email` with `{ email, verificationCode }`. On success `onAuthorized` is called with
|
|
519
578
|
`provider: 'EMAIL'`, claims `{ sub: email, email, email_verified: true }` and no `token`.
|
|
520
579
|
|
|
@@ -524,20 +583,44 @@ resend cooldown, and the claims' `sub` and `email`. The local part is lowercased
|
|
|
524
583
|
mailboxes are allowed by RFC 5321 but practically unused, and honouring them would let one mailbox
|
|
525
584
|
hold several accounts.
|
|
526
585
|
|
|
527
|
-
Error reasons (`google.rpc.ErrorInfo
|
|
586
|
+
Error reasons (`google.rpc.ErrorInfo`, see "Errors"):
|
|
528
587
|
|
|
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. |
|
|
588
|
+
| Reason | Status | When |
|
|
589
|
+
| ------------------------------------- | ------------------------ | --------------------------------------------------------------------------------- |
|
|
590
|
+
| `TURNSTILE_VERIFICATION_FAILED` | `400 INVALID_ARGUMENT` | The Turnstile token was rejected. |
|
|
591
|
+
| `VERIFICATION_CODE_RESEND_TOO_SOON` | `429 RESOURCE_EXHAUSTED` | A code was sent to this email within `resendCooldown`. |
|
|
592
|
+
| `EMAIL_DELIVERY_FAILED` | `503 UNAVAILABLE` | The sender threw; the cooldown is released, so the user can retry at once. |
|
|
593
|
+
| `INVALID_VERIFICATION_CODE` | `403 PERMISSION_DENIED` | Wrong code, or no code is pending for this email. |
|
|
594
|
+
| `VERIFICATION_CODE_ATTEMPTS_EXCEEDED` | `403 PERMISSION_DENIED` | More than `maxAttempts` guesses at this code; it is discarded, ask for a new one. |
|
|
536
595
|
|
|
537
596
|
Every login attempt against a pending code is counted before the code is compared, so parallel
|
|
538
597
|
requests cannot exceed `maxAttempts` guesses either. A new code starts with a fresh budget.
|
|
539
598
|
|
|
540
|
-
|
|
599
|
+
## Errors
|
|
600
|
+
|
|
601
|
+
The JSON routes (native, One Tap, email, desktop) answer every failure with a google.rpc error
|
|
602
|
+
body, the shape `@shware/http`'s `getErrorInfo` reads: `{ error: { code, status, message, details:
|
|
603
|
+
[{ '@type': '…google.rpc.ErrorInfo', reason, metadata? }] } }`. Branch on `reason`; the status
|
|
604
|
+
tells the kind:
|
|
605
|
+
|
|
606
|
+
| Status | What |
|
|
607
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
608
|
+
| `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`. |
|
|
609
|
+
| `401 UNAUTHENTICATED` | `desktopAuthorize` without a signed-in session. |
|
|
610
|
+
| `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`). |
|
|
611
|
+
| `429 RESOURCE_EXHAUSTED` | `VERIFICATION_CODE_RESEND_TOO_SOON`. |
|
|
612
|
+
| `503 UNAVAILABLE` | `EMAIL_DELIVERY_FAILED`, or the provider is down. |
|
|
613
|
+
|
|
614
|
+
An `OAuth2Error` from the provider client is answered by what it means, with `OAUTH2_<error>` as
|
|
615
|
+
the reason: `OAUTH2_INVALID_REQUEST` and the other malformed-request codes as `400`,
|
|
616
|
+
`OAUTH2_INVALID_GRANT` (a code or id token that is expired, used, or for another client or
|
|
617
|
+
issuer), `OAUTH2_INVALID_CLIENT`, `OAUTH2_INVALID_TOKEN`, `OAUTH2_ACCESS_DENIED` as `403`,
|
|
618
|
+
`OAUTH2_SERVER_ERROR` and `OAUTH2_TEMPORARILY_UNAVAILABLE` as `503`. Its message stays in the logs:
|
|
619
|
+
it can name a client id or quote the provider's response.
|
|
620
|
+
|
|
621
|
+
Anything else thrown (by `onAuthorized`, a session store, a sender) propagates to your error
|
|
622
|
+
handler as the server fault it is. The redirect flow is the exception to all of this: a page, not
|
|
623
|
+
a client, reads its outcome, so it lands on `errorUri` with `error=…` (see "Redirect flow").
|
|
541
624
|
|
|
542
625
|
## Routes (`PATH`)
|
|
543
626
|
|
|
@@ -556,9 +639,7 @@ its own constant.
|
|
|
556
639
|
| `LOGIN_OAUTH2_NATIVE` | `/login/oauth2/native/:provider` | `loginOAuth2Native` |
|
|
557
640
|
| `LOGIN_OAUTH2_ONETAP` | `/login/oauth2/onetap/google` | `loginOAuth2Onetap` |
|
|
558
641
|
| `LOGIN_EMAIL` | `/login/email` | `loginEmail` |
|
|
559
|
-
| `LOGIN_PHONE` | `/login/phone` | `loginPhone` (not implemented) |
|
|
560
642
|
| `SEND_EMAIL_VERIFICATION_CODE` | `/auth/sendEmailVerificationCode` | `sendEmailVerificationCode` |
|
|
561
|
-
| `SEND_PHONE_VERIFICATION_CODE` | `/auth/sendPhoneVerificationCode` | `sendPhoneVerificationCode` (not implemented) |
|
|
562
643
|
| `DESKTOP_AUTHORIZE` | `/auth/desktop/authorize` | `desktopAuthorize` |
|
|
563
644
|
| `DESKTOP_EXCHANGE` | `/auth/desktop/exchange` | `desktopExchange` |
|
|
564
645
|
| `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"}
|