@shware/security 7.1.0 → 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.
Files changed (212) hide show
  1. package/README.md +108 -30
  2. package/dist/auth/attributes.cjs +5 -5
  3. package/dist/auth/attributes.cjs.map +1 -1
  4. package/dist/auth/attributes.d.cts +3 -5
  5. package/dist/auth/attributes.d.cts.map +1 -1
  6. package/dist/auth/attributes.d.mts +3 -5
  7. package/dist/auth/attributes.d.mts.map +1 -1
  8. package/dist/auth/attributes.mjs +5 -5
  9. package/dist/auth/attributes.mjs.map +1 -1
  10. package/dist/auth/desktop-codes.cjs +57 -0
  11. package/dist/auth/desktop-codes.cjs.map +1 -0
  12. package/dist/auth/desktop-codes.d.cts +24 -0
  13. package/dist/auth/desktop-codes.d.cts.map +1 -0
  14. package/dist/auth/desktop-codes.d.mts +24 -0
  15. package/dist/auth/desktop-codes.d.mts.map +1 -0
  16. package/dist/auth/desktop-codes.mjs +55 -0
  17. package/dist/auth/desktop-codes.mjs.map +1 -0
  18. package/dist/auth/email-codes.cjs +77 -0
  19. package/dist/auth/email-codes.cjs.map +1 -0
  20. package/dist/auth/email-codes.d.cts +39 -0
  21. package/dist/auth/email-codes.d.cts.map +1 -0
  22. package/dist/auth/email-codes.d.mts +39 -0
  23. package/dist/auth/email-codes.d.mts.map +1 -0
  24. package/dist/auth/email-codes.mjs +75 -0
  25. package/dist/auth/email-codes.mjs.map +1 -0
  26. package/dist/auth/error.cjs +47 -0
  27. package/dist/auth/error.cjs.map +1 -0
  28. package/dist/auth/error.d.cts +88 -0
  29. package/dist/auth/error.d.cts.map +1 -0
  30. package/dist/auth/error.d.mts +88 -0
  31. package/dist/auth/error.d.mts.map +1 -0
  32. package/dist/auth/error.mjs +45 -0
  33. package/dist/auth/error.mjs.map +1 -0
  34. package/dist/auth/index.cjs +245 -385
  35. package/dist/auth/index.cjs.map +1 -1
  36. package/dist/auth/index.d.cts +66 -57
  37. package/dist/auth/index.d.cts.map +1 -1
  38. package/dist/auth/index.d.mts +66 -57
  39. package/dist/auth/index.d.mts.map +1 -1
  40. package/dist/auth/index.mjs +248 -388
  41. package/dist/auth/index.mjs.map +1 -1
  42. package/dist/auth/respond.cjs +99 -0
  43. package/dist/auth/respond.cjs.map +1 -0
  44. package/dist/auth/respond.d.cts +39 -0
  45. package/dist/auth/respond.d.cts.map +1 -0
  46. package/dist/auth/respond.d.mts +39 -0
  47. package/dist/auth/respond.d.mts.map +1 -0
  48. package/dist/auth/respond.mjs +92 -0
  49. package/dist/auth/respond.mjs.map +1 -0
  50. package/dist/auth/types.d.cts +27 -13
  51. package/dist/auth/types.d.cts.map +1 -1
  52. package/dist/auth/types.d.mts +27 -13
  53. package/dist/auth/types.d.mts.map +1 -1
  54. package/dist/core/index.cjs +1 -2
  55. package/dist/core/index.d.cts +0 -2
  56. package/dist/core/index.d.cts.map +1 -1
  57. package/dist/core/index.d.mts +0 -2
  58. package/dist/core/index.d.mts.map +1 -1
  59. package/dist/core/index.mjs +1 -2
  60. package/dist/email/schema.cjs +1 -2
  61. package/dist/email/schema.d.cts +0 -2
  62. package/dist/email/schema.d.cts.map +1 -1
  63. package/dist/email/schema.d.mts +0 -2
  64. package/dist/email/schema.d.mts.map +1 -1
  65. package/dist/email/schema.mjs +1 -2
  66. package/dist/index.cjs +5 -0
  67. package/dist/index.d.cts +4 -1
  68. package/dist/index.d.mts +4 -1
  69. package/dist/index.mjs +3 -1
  70. package/dist/oauth2/client.cjs +1 -2
  71. package/dist/oauth2/client.d.cts +0 -2
  72. package/dist/oauth2/client.d.cts.map +1 -1
  73. package/dist/oauth2/client.d.mts +0 -2
  74. package/dist/oauth2/client.d.mts.map +1 -1
  75. package/dist/oauth2/client.mjs +1 -2
  76. package/dist/oauth2/error.cjs +1 -9
  77. package/dist/oauth2/error.cjs.map +1 -1
  78. package/dist/oauth2/error.d.cts +0 -5
  79. package/dist/oauth2/error.d.cts.map +1 -1
  80. package/dist/oauth2/error.d.mts +0 -5
  81. package/dist/oauth2/error.d.mts.map +1 -1
  82. package/dist/oauth2/error.mjs +2 -9
  83. package/dist/oauth2/error.mjs.map +1 -1
  84. package/dist/oauth2/name.cjs +1 -2
  85. package/dist/oauth2/name.d.cts +0 -2
  86. package/dist/oauth2/name.d.cts.map +1 -1
  87. package/dist/oauth2/name.d.mts +0 -2
  88. package/dist/oauth2/name.d.mts.map +1 -1
  89. package/dist/oauth2/name.mjs +1 -2
  90. package/dist/oauth2/provider/apple-client-secret.cjs +1 -2
  91. package/dist/oauth2/provider/apple-client-secret.d.cts +0 -2
  92. package/dist/oauth2/provider/apple-client-secret.d.cts.map +1 -1
  93. package/dist/oauth2/provider/apple-client-secret.d.mts +0 -2
  94. package/dist/oauth2/provider/apple-client-secret.d.mts.map +1 -1
  95. package/dist/oauth2/provider/apple-client-secret.mjs +1 -2
  96. package/dist/oauth2/provider/apple.cjs +1 -2
  97. package/dist/oauth2/provider/apple.d.cts +0 -2
  98. package/dist/oauth2/provider/apple.d.cts.map +1 -1
  99. package/dist/oauth2/provider/apple.d.mts +0 -2
  100. package/dist/oauth2/provider/apple.d.mts.map +1 -1
  101. package/dist/oauth2/provider/apple.mjs +1 -2
  102. package/dist/oauth2/provider/common.cjs +2 -3
  103. package/dist/oauth2/provider/common.cjs.map +1 -1
  104. package/dist/oauth2/provider/common.d.cts +0 -2
  105. package/dist/oauth2/provider/common.d.cts.map +1 -1
  106. package/dist/oauth2/provider/common.d.mts +0 -2
  107. package/dist/oauth2/provider/common.d.mts.map +1 -1
  108. package/dist/oauth2/provider/common.mjs +2 -3
  109. package/dist/oauth2/provider/common.mjs.map +1 -1
  110. package/dist/oauth2/provider/facebook.cjs +3 -6
  111. package/dist/oauth2/provider/facebook.cjs.map +1 -1
  112. package/dist/oauth2/provider/facebook.d.cts +0 -3
  113. package/dist/oauth2/provider/facebook.d.cts.map +1 -1
  114. package/dist/oauth2/provider/facebook.d.mts +0 -3
  115. package/dist/oauth2/provider/facebook.d.mts.map +1 -1
  116. package/dist/oauth2/provider/facebook.mjs +3 -6
  117. package/dist/oauth2/provider/facebook.mjs.map +1 -1
  118. package/dist/oauth2/provider/github.cjs +1 -2
  119. package/dist/oauth2/provider/github.d.cts +0 -2
  120. package/dist/oauth2/provider/github.d.cts.map +1 -1
  121. package/dist/oauth2/provider/github.d.mts +0 -2
  122. package/dist/oauth2/provider/github.d.mts.map +1 -1
  123. package/dist/oauth2/provider/github.mjs +1 -2
  124. package/dist/oauth2/provider/google.cjs +1 -2
  125. package/dist/oauth2/provider/google.d.cts +0 -2
  126. package/dist/oauth2/provider/google.d.cts.map +1 -1
  127. package/dist/oauth2/provider/google.d.mts +0 -2
  128. package/dist/oauth2/provider/google.d.mts.map +1 -1
  129. package/dist/oauth2/provider/google.mjs +1 -2
  130. package/dist/oauth2/provider/wechat.cjs +1 -2
  131. package/dist/oauth2/provider/wechat.d.cts +0 -2
  132. package/dist/oauth2/provider/wechat.d.cts.map +1 -1
  133. package/dist/oauth2/provider/wechat.d.mts +0 -2
  134. package/dist/oauth2/provider/wechat.d.mts.map +1 -1
  135. package/dist/oauth2/provider/wechat.mjs +1 -2
  136. package/dist/oauth2/provider/x.cjs +7 -6
  137. package/dist/oauth2/provider/x.cjs.map +1 -1
  138. package/dist/oauth2/provider/x.d.cts +3 -4
  139. package/dist/oauth2/provider/x.d.cts.map +1 -1
  140. package/dist/oauth2/provider/x.d.mts +3 -4
  141. package/dist/oauth2/provider/x.d.mts.map +1 -1
  142. package/dist/oauth2/provider/x.mjs +7 -6
  143. package/dist/oauth2/provider/x.mjs.map +1 -1
  144. package/dist/oauth2/types.cjs +1 -2
  145. package/dist/oauth2/types.cjs.map +1 -1
  146. package/dist/oauth2/types.d.cts +5 -2
  147. package/dist/oauth2/types.d.cts.map +1 -1
  148. package/dist/oauth2/types.d.mts +5 -2
  149. package/dist/oauth2/types.d.mts.map +1 -1
  150. package/dist/oauth2/types.mjs +1 -2
  151. package/dist/oauth2/types.mjs.map +1 -1
  152. package/dist/session/common.cjs +11 -15
  153. package/dist/session/common.cjs.map +1 -1
  154. package/dist/session/common.d.cts +7 -5
  155. package/dist/session/common.d.cts.map +1 -1
  156. package/dist/session/common.d.mts +7 -5
  157. package/dist/session/common.d.mts.map +1 -1
  158. package/dist/session/common.mjs +11 -14
  159. package/dist/session/common.mjs.map +1 -1
  160. package/dist/session/db.cjs +3 -4
  161. package/dist/session/db.cjs.map +1 -1
  162. package/dist/session/db.d.cts +0 -2
  163. package/dist/session/db.d.cts.map +1 -1
  164. package/dist/session/db.d.mts +0 -2
  165. package/dist/session/db.d.mts.map +1 -1
  166. package/dist/session/db.mjs +4 -5
  167. package/dist/session/db.mjs.map +1 -1
  168. package/dist/session/map-session.cjs +1 -2
  169. package/dist/session/map-session.d.cts +0 -2
  170. package/dist/session/map-session.d.cts.map +1 -1
  171. package/dist/session/map-session.d.mts +0 -2
  172. package/dist/session/map-session.d.mts.map +1 -1
  173. package/dist/session/map-session.mjs +1 -2
  174. package/dist/session/redis.cjs +7 -10
  175. package/dist/session/redis.cjs.map +1 -1
  176. package/dist/session/redis.d.cts +0 -2
  177. package/dist/session/redis.d.cts.map +1 -1
  178. package/dist/session/redis.d.mts +0 -2
  179. package/dist/session/redis.d.mts.map +1 -1
  180. package/dist/session/redis.mjs +8 -11
  181. package/dist/session/redis.mjs.map +1 -1
  182. package/dist/session/types.d.cts +0 -14
  183. package/dist/session/types.d.cts.map +1 -1
  184. package/dist/session/types.d.mts +0 -14
  185. package/dist/session/types.d.mts.map +1 -1
  186. package/dist/utils/http.cjs +1 -2
  187. package/dist/utils/http.d.cts +0 -2
  188. package/dist/utils/http.d.cts.map +1 -1
  189. package/dist/utils/http.d.mts +0 -2
  190. package/dist/utils/http.d.mts.map +1 -1
  191. package/dist/utils/http.mjs +1 -2
  192. package/dist/utils/oauth2.cjs +1 -2
  193. package/dist/utils/oauth2.d.cts +0 -2
  194. package/dist/utils/oauth2.d.cts.map +1 -1
  195. package/dist/utils/oauth2.d.mts +0 -2
  196. package/dist/utils/oauth2.d.mts.map +1 -1
  197. package/dist/utils/oauth2.mjs +1 -2
  198. package/dist/utils/timing.cjs +1 -2
  199. package/dist/utils/timing.d.cts +0 -2
  200. package/dist/utils/timing.d.cts.map +1 -1
  201. package/dist/utils/timing.d.mts +0 -2
  202. package/dist/utils/timing.d.mts.map +1 -1
  203. package/dist/utils/timing.mjs +1 -2
  204. package/dist/utils/valid.cjs +35 -54
  205. package/dist/utils/valid.cjs.map +1 -1
  206. package/dist/utils/valid.d.cts +8 -25
  207. package/dist/utils/valid.d.cts.map +1 -1
  208. package/dist/utils/valid.d.mts +8 -25
  209. package/dist/utils/valid.d.mts.map +1 -1
  210. package/dist/utils/valid.mjs +33 -52
  211. package/dist/utils/valid.mjs.map +1 -1
  212. package/package.json +4 -4
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) that
7
- use the Spring Session key layout, so sessions can be shared with a Spring backend.
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` and types |
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 is read from the cookie or from an `Authorization: Bearer <session id>` header.
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
- Errors thrown by providers during the token exchange are `OAuth2Error` (`status`, `error`), which
188
- you can map to a 401 in your error handler:
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 { OAuth2Error } from '@shware/security';
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 = 'spring:session')`: Spring Session's Redis
205
- indexed layout (`<namespace>:sessions:<id>` hashes, a principal index set, and an expirations
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 invalid_request` when it is not 43-128
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
- `401 invalid_request` (`nonce missing` / `nonce mismatch`). The nonce takes the place of Google's
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`), all `400 INVALID_ARGUMENT` unless noted:
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`: a code was sent to this email within `resendCooldown`. |
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
- `sendPhoneVerificationCode` and `loginPhone` exist but currently answer `501 not_implemented`.
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` |
@@ -1,6 +1,7 @@
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
- //#region src/auth/attributes.ts
4
+ // src/auth/attributes.ts
4
5
  /** Upper bound on serialized attributes: they ride in a session or a request body. */
5
6
  const MAX_ATTRIBUTES_BYTES = 2048;
6
7
  function attributesSize(attributes) {
@@ -13,16 +14,15 @@ 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
- * Reads `attributes` off a login body without trusting its shape: `{}` when absent, `null` when
17
- * present but invalid.
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) return null;
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
- //#endregion
26
26
  exports.MAX_ATTRIBUTES_BYTES = MAX_ATTRIBUTES_BYTES;
27
27
  exports.attributesSchema = attributesSchema;
28
28
  exports.attributesSize = attributesSize;
@@ -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 * Reads `attributes` off a login body without trusting its shape: `{}` when absent, `null` when\n * present but invalid.\n */\nexport function bodyAttributes(body: unknown): Record<string, string> | null {\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) return null;\n return parsed.data ?? {};\n}\n"],"mappings":";;;;AAGA,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,MAA8C;CAC3E,MAAM,QACJ,OAAO,SAAS,YAAY,SAAS,QAAQ,gBAAgB,OAAO,KAAK,aAAa,KAAA;CACxF,MAAM,SAAS,iBAAiB,UAAU,KAAK;CAC/C,IAAI,CAAC,OAAO,SAAS,OAAO;CAC5B,OAAO,OAAO,QAAQ,CAAC;AACzB"}
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"}
@@ -1,4 +1,3 @@
1
- //#region src/auth/attributes.d.ts
2
1
  /** Upper bound on serialized attributes: they ride in a session or a request body. */
3
2
  export declare const MAX_ATTRIBUTES_BYTES = 2048;
4
3
  export declare function attributesSize(attributes: Record<string, string>): number;
@@ -9,9 +8,8 @@ export declare function attributesSize(attributes: Record<string, string>): numb
9
8
  */
10
9
  export declare const attributesSchema: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniRecord<import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniString<string>>>;
11
10
  /**
12
- * Reads `attributes` off a login body without trusting its shape: `{}` when absent, `null` when
13
- * present but invalid.
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`.
14
13
  */
15
- export declare function bodyAttributes(body: unknown): Record<string, string> | null;
16
- //#endregion
14
+ export declare function bodyAttributes(body: unknown): Record<string, string>;
17
15
  //# sourceMappingURL=attributes.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"attributes.d.cts","names":[],"sources":["../../src/auth/attributes.ts"],"mappings":";;qBAGa;wBAEG,eAAe,YAAY;;;;;;qBAS9B,qCAAgB,mCAAA,iCAAA,0CAAA;;;;;wBAYb,eAAe,gBAAgB"}
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"}
@@ -1,4 +1,3 @@
1
- //#region src/auth/attributes.d.ts
2
1
  /** Upper bound on serialized attributes: they ride in a session or a request body. */
3
2
  export declare const MAX_ATTRIBUTES_BYTES = 2048;
4
3
  export declare function attributesSize(attributes: Record<string, string>): number;
@@ -9,9 +8,8 @@ export declare function attributesSize(attributes: Record<string, string>): numb
9
8
  */
10
9
  export declare const attributesSchema: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniRecord<import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniString<string>>>;
11
10
  /**
12
- * Reads `attributes` off a login body without trusting its shape: `{}` when absent, `null` when
13
- * present but invalid.
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`.
14
13
  */
15
- export declare function bodyAttributes(body: unknown): Record<string, string> | null;
16
- //#endregion
14
+ export declare function bodyAttributes(body: unknown): Record<string, string>;
17
15
  //# sourceMappingURL=attributes.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"attributes.d.mts","names":[],"sources":["../../src/auth/attributes.ts"],"mappings":";;qBAGa;wBAEG,eAAe,YAAY;;;;;;qBAS9B,qCAAgB,mCAAA,iCAAA,0CAAA;;;;;wBAYb,eAAe,gBAAgB"}
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"}
@@ -1,5 +1,6 @@
1
+ import { invalidArgument } from "./respond.mjs";
1
2
  import { optional, record, refine, string } from "zod/mini";
2
- //#region src/auth/attributes.ts
3
+ // src/auth/attributes.ts
3
4
  /** Upper bound on serialized attributes: they ride in a session or a request body. */
4
5
  const MAX_ATTRIBUTES_BYTES = 2048;
5
6
  function attributesSize(attributes) {
@@ -12,16 +13,15 @@ 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
- * Reads `attributes` off a login body without trusting its shape: `{}` when absent, `null` when
16
- * present but invalid.
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) return null;
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
- //#endregion
25
25
  export { MAX_ATTRIBUTES_BYTES, attributesSchema, attributesSize, bodyAttributes };
26
26
 
27
27
  //# sourceMappingURL=attributes.mjs.map
@@ -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 * Reads `attributes` off a login body without trusting its shape: `{}` when absent, `null` when\n * present but invalid.\n */\nexport function bodyAttributes(body: unknown): Record<string, string> | null {\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) return null;\n return parsed.data ?? {};\n}\n"],"mappings":";;;AAGA,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,MAA8C;CAC3E,MAAM,QACJ,OAAO,SAAS,YAAY,SAAS,QAAQ,gBAAgB,OAAO,KAAK,aAAa,KAAA;CACxF,MAAM,SAAS,iBAAiB,UAAU,KAAK;CAC/C,IAAI,CAAC,OAAO,SAAS,OAAO;CAC5B,OAAO,OAAO,QAAQ,CAAC;AACzB"}
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"}