@shware/security 2.5.3 → 3.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 (225) hide show
  1. package/dist/auth/index.cjs +519 -561
  2. package/dist/auth/index.cjs.map +1 -1
  3. package/dist/auth/index.d.cts +104 -106
  4. package/dist/auth/index.d.cts.map +1 -0
  5. package/dist/auth/index.d.mts +109 -0
  6. package/dist/auth/index.d.mts.map +1 -0
  7. package/dist/auth/index.mjs +512 -536
  8. package/dist/auth/index.mjs.map +1 -1
  9. package/dist/auth/types.cjs +0 -19
  10. package/dist/auth/types.d.cts +44 -44
  11. package/dist/auth/types.d.cts.map +1 -0
  12. package/dist/auth/types.d.mts +50 -0
  13. package/dist/auth/types.d.mts.map +1 -0
  14. package/dist/auth/types.mjs +1 -1
  15. package/dist/core/index.cjs +18 -59
  16. package/dist/core/index.cjs.map +1 -1
  17. package/dist/core/index.d.cts +13 -24
  18. package/dist/core/index.d.cts.map +1 -0
  19. package/dist/core/index.d.mts +21 -0
  20. package/dist/core/index.d.mts.map +1 -0
  21. package/dist/core/index.mjs +17 -33
  22. package/dist/core/index.mjs.map +1 -1
  23. package/dist/email/schema.cjs +13 -38
  24. package/dist/email/schema.cjs.map +1 -1
  25. package/dist/email/schema.d.cts +11 -12
  26. package/dist/email/schema.d.cts.map +1 -0
  27. package/dist/email/schema.d.mts +12 -0
  28. package/dist/email/schema.d.mts.map +1 -0
  29. package/dist/email/schema.mjs +10 -11
  30. package/dist/email/schema.mjs.map +1 -1
  31. package/dist/index.cjs +18 -54
  32. package/dist/index.d.cts +10 -15
  33. package/dist/index.d.mts +10 -0
  34. package/dist/index.mjs +5 -17
  35. package/dist/oauth2/client.cjs +109 -145
  36. package/dist/oauth2/client.cjs.map +1 -1
  37. package/dist/oauth2/client.d.cts +55 -56
  38. package/dist/oauth2/client.d.cts.map +1 -0
  39. package/dist/oauth2/client.d.mts +58 -0
  40. package/dist/oauth2/client.d.mts.map +1 -0
  41. package/dist/oauth2/client.mjs +104 -116
  42. package/dist/oauth2/client.mjs.map +1 -1
  43. package/dist/oauth2/error.cjs +19 -44
  44. package/dist/oauth2/error.cjs.map +1 -1
  45. package/dist/oauth2/error.d.cts +8 -6
  46. package/dist/oauth2/error.d.cts.map +1 -0
  47. package/dist/oauth2/{error.d.ts → error.d.mts} +8 -6
  48. package/dist/oauth2/error.d.mts.map +1 -0
  49. package/dist/oauth2/error.mjs +18 -19
  50. package/dist/oauth2/error.mjs.map +1 -1
  51. package/dist/oauth2/provider/apple.cjs +75 -88
  52. package/dist/oauth2/provider/apple.cjs.map +1 -1
  53. package/dist/oauth2/provider/apple.d.cts +31 -30
  54. package/dist/oauth2/provider/apple.d.cts.map +1 -0
  55. package/dist/oauth2/provider/apple.d.mts +42 -0
  56. package/dist/oauth2/provider/apple.d.mts.map +1 -0
  57. package/dist/oauth2/provider/apple.mjs +71 -60
  58. package/dist/oauth2/provider/apple.mjs.map +1 -1
  59. package/dist/oauth2/provider/common.cjs +86 -109
  60. package/dist/oauth2/provider/common.cjs.map +1 -1
  61. package/dist/oauth2/provider/common.d.cts +19 -18
  62. package/dist/oauth2/provider/common.d.cts.map +1 -0
  63. package/dist/oauth2/provider/common.d.mts +30 -0
  64. package/dist/oauth2/provider/common.d.mts.map +1 -0
  65. package/dist/oauth2/provider/common.mjs +80 -79
  66. package/dist/oauth2/provider/common.mjs.map +1 -1
  67. package/dist/oauth2/provider/facebook.cjs +93 -109
  68. package/dist/oauth2/provider/facebook.cjs.map +1 -1
  69. package/dist/oauth2/provider/facebook.d.cts +29 -28
  70. package/dist/oauth2/provider/facebook.d.cts.map +1 -0
  71. package/dist/oauth2/provider/facebook.d.mts +45 -0
  72. package/dist/oauth2/provider/facebook.d.mts.map +1 -0
  73. package/dist/oauth2/provider/facebook.mjs +89 -81
  74. package/dist/oauth2/provider/facebook.mjs.map +1 -1
  75. package/dist/oauth2/provider/github.cjs +64 -91
  76. package/dist/oauth2/provider/github.cjs.map +1 -1
  77. package/dist/oauth2/provider/github.d.cts +52 -51
  78. package/dist/oauth2/provider/github.d.cts.map +1 -0
  79. package/dist/oauth2/provider/github.d.mts +60 -0
  80. package/dist/oauth2/provider/github.d.mts.map +1 -0
  81. package/dist/oauth2/provider/github.mjs +60 -63
  82. package/dist/oauth2/provider/github.mjs.map +1 -1
  83. package/dist/oauth2/provider/google.cjs +105 -122
  84. package/dist/oauth2/provider/google.cjs.map +1 -1
  85. package/dist/oauth2/provider/google.d.cts +34 -33
  86. package/dist/oauth2/provider/google.d.cts.map +1 -0
  87. package/dist/oauth2/provider/google.d.mts +44 -0
  88. package/dist/oauth2/provider/google.d.mts.map +1 -0
  89. package/dist/oauth2/provider/google.mjs +101 -94
  90. package/dist/oauth2/provider/google.mjs.map +1 -1
  91. package/dist/oauth2/provider/index.cjs +13 -46
  92. package/dist/oauth2/provider/index.d.cts +7 -7
  93. package/dist/oauth2/provider/index.d.mts +7 -0
  94. package/dist/oauth2/provider/index.mjs +1 -10
  95. package/dist/oauth2/provider/wechat.cjs +77 -99
  96. package/dist/oauth2/provider/wechat.cjs.map +1 -1
  97. package/dist/oauth2/provider/wechat.d.cts +28 -27
  98. package/dist/oauth2/provider/wechat.d.cts.map +1 -0
  99. package/dist/oauth2/provider/wechat.d.mts +39 -0
  100. package/dist/oauth2/provider/wechat.d.mts.map +1 -0
  101. package/dist/oauth2/provider/wechat.mjs +74 -72
  102. package/dist/oauth2/provider/wechat.mjs.map +1 -1
  103. package/dist/oauth2/provider/x.cjs +102 -125
  104. package/dist/oauth2/provider/x.cjs.map +1 -1
  105. package/dist/oauth2/provider/x.d.cts +51 -50
  106. package/dist/oauth2/provider/x.d.cts.map +1 -0
  107. package/dist/oauth2/provider/x.d.mts +69 -0
  108. package/dist/oauth2/provider/x.d.mts.map +1 -0
  109. package/dist/oauth2/provider/x.mjs +99 -103
  110. package/dist/oauth2/provider/x.mjs.map +1 -1
  111. package/dist/oauth2/types.cjs +12 -37
  112. package/dist/oauth2/types.cjs.map +1 -1
  113. package/dist/oauth2/types.d.cts +176 -180
  114. package/dist/oauth2/types.d.cts.map +1 -0
  115. package/dist/oauth2/types.d.mts +207 -0
  116. package/dist/oauth2/types.d.mts.map +1 -0
  117. package/dist/oauth2/types.mjs +12 -13
  118. package/dist/oauth2/types.mjs.map +1 -1
  119. package/dist/session/common.cjs +17 -43
  120. package/dist/session/common.cjs.map +1 -1
  121. package/dist/session/common.d.cts +4 -4
  122. package/dist/session/common.d.cts.map +1 -0
  123. package/dist/session/{common.d.ts → common.d.mts} +4 -4
  124. package/dist/session/common.d.mts.map +1 -0
  125. package/dist/session/common.mjs +14 -16
  126. package/dist/session/common.mjs.map +1 -1
  127. package/dist/session/db.cjs +88 -123
  128. package/dist/session/db.cjs.map +1 -1
  129. package/dist/session/db.d.cts +36 -36
  130. package/dist/session/db.d.cts.map +1 -0
  131. package/dist/session/db.d.mts +44 -0
  132. package/dist/session/db.d.mts.map +1 -0
  133. package/dist/session/db.mjs +84 -95
  134. package/dist/session/db.mjs.map +1 -1
  135. package/dist/session/map-session.cjs +71 -103
  136. package/dist/session/map-session.cjs.map +1 -1
  137. package/dist/session/map-session.d.cts +24 -24
  138. package/dist/session/map-session.d.cts.map +1 -0
  139. package/dist/session/map-session.d.mts +27 -0
  140. package/dist/session/map-session.d.mts.map +1 -0
  141. package/dist/session/map-session.mjs +70 -78
  142. package/dist/session/map-session.mjs.map +1 -1
  143. package/dist/session/redis.cjs +270 -319
  144. package/dist/session/redis.cjs.map +1 -1
  145. package/dist/session/redis.d.cts +51 -51
  146. package/dist/session/redis.d.cts.map +1 -0
  147. package/dist/session/redis.d.mts +58 -0
  148. package/dist/session/redis.d.mts.map +1 -0
  149. package/dist/session/redis.mjs +266 -291
  150. package/dist/session/redis.mjs.map +1 -1
  151. package/dist/session/types.cjs +0 -19
  152. package/dist/session/types.d.cts +34 -33
  153. package/dist/session/types.d.cts.map +1 -0
  154. package/dist/session/types.d.mts +45 -0
  155. package/dist/session/types.d.mts.map +1 -0
  156. package/dist/session/types.mjs +1 -1
  157. package/dist/utils/http.cjs +31 -55
  158. package/dist/utils/http.cjs.map +1 -1
  159. package/dist/utils/http.d.cts +7 -10
  160. package/dist/utils/http.d.cts.map +1 -0
  161. package/dist/utils/{http.d.ts → http.d.mts} +7 -10
  162. package/dist/utils/http.d.mts.map +1 -0
  163. package/dist/utils/http.mjs +24 -24
  164. package/dist/utils/http.mjs.map +1 -1
  165. package/dist/utils/oauth2.cjs +6 -30
  166. package/dist/utils/oauth2.cjs.map +1 -1
  167. package/dist/utils/oauth2.d.cts +3 -1
  168. package/dist/utils/oauth2.d.cts.map +1 -0
  169. package/dist/utils/oauth2.d.mts +5 -0
  170. package/dist/utils/oauth2.d.mts.map +1 -0
  171. package/dist/utils/oauth2.mjs +5 -5
  172. package/dist/utils/oauth2.mjs.map +1 -1
  173. package/dist/utils/timing.cjs +49 -70
  174. package/dist/utils/timing.cjs.map +1 -1
  175. package/dist/utils/timing.d.cts +10 -8
  176. package/dist/utils/timing.d.cts.map +1 -0
  177. package/dist/utils/timing.d.mts +15 -0
  178. package/dist/utils/timing.d.mts.map +1 -0
  179. package/dist/utils/timing.mjs +48 -45
  180. package/dist/utils/timing.mjs.map +1 -1
  181. package/dist/utils/valid.cjs +50 -71
  182. package/dist/utils/valid.cjs.map +1 -1
  183. package/dist/utils/valid.d.cts +17 -16
  184. package/dist/utils/valid.d.cts.map +1 -0
  185. package/dist/utils/valid.d.mts +28 -0
  186. package/dist/utils/valid.d.mts.map +1 -0
  187. package/dist/utils/valid.mjs +47 -44
  188. package/dist/utils/valid.mjs.map +1 -1
  189. package/package.json +22 -12
  190. package/dist/auth/index.d.ts +0 -111
  191. package/dist/auth/types.cjs.map +0 -1
  192. package/dist/auth/types.d.ts +0 -50
  193. package/dist/auth/types.mjs.map +0 -1
  194. package/dist/core/index.d.ts +0 -32
  195. package/dist/email/schema.d.ts +0 -13
  196. package/dist/index.cjs.map +0 -1
  197. package/dist/index.d.ts +0 -15
  198. package/dist/index.mjs.map +0 -1
  199. package/dist/oauth2/client.d.ts +0 -59
  200. package/dist/oauth2/provider/apple.d.ts +0 -41
  201. package/dist/oauth2/provider/common.d.ts +0 -29
  202. package/dist/oauth2/provider/facebook.d.ts +0 -44
  203. package/dist/oauth2/provider/github.d.ts +0 -59
  204. package/dist/oauth2/provider/google.d.ts +0 -43
  205. package/dist/oauth2/provider/index.cjs.map +0 -1
  206. package/dist/oauth2/provider/index.d.ts +0 -7
  207. package/dist/oauth2/provider/index.mjs.map +0 -1
  208. package/dist/oauth2/provider/wechat.d.ts +0 -38
  209. package/dist/oauth2/provider/x.d.ts +0 -68
  210. package/dist/oauth2/types.d.ts +0 -211
  211. package/dist/session/__tests__/redis.test.cjs +0 -247
  212. package/dist/session/__tests__/redis.test.cjs.map +0 -1
  213. package/dist/session/__tests__/redis.test.d.cts +0 -2
  214. package/dist/session/__tests__/redis.test.d.ts +0 -2
  215. package/dist/session/__tests__/redis.test.mjs +0 -252
  216. package/dist/session/__tests__/redis.test.mjs.map +0 -1
  217. package/dist/session/db.d.ts +0 -44
  218. package/dist/session/map-session.d.ts +0 -27
  219. package/dist/session/redis.d.ts +0 -58
  220. package/dist/session/types.cjs.map +0 -1
  221. package/dist/session/types.d.ts +0 -44
  222. package/dist/session/types.mjs.map +0 -1
  223. package/dist/utils/oauth2.d.ts +0 -3
  224. package/dist/utils/timing.d.ts +0 -13
  225. package/dist/utils/valid.d.ts +0 -27
@@ -1,545 +1,521 @@
1
- // src/auth/index.ts
2
- import { createHash, randomInt, randomUUID, timingSafeEqual } from "crypto";
3
- import { invariant } from "@shware/utils";
4
- import { Provider } from "../core/index.mjs";
5
1
  import { loginEmailSchema, sendEmailVerificationCodeSchema } from "../email/schema.mjs";
6
2
  import { OAuth2Client, googleOneTapSchema, oauth2RedirectQuerySchema } from "../oauth2/client.mjs";
7
- import { google } from "../oauth2/provider/index.mjs";
3
+ import { google } from "../oauth2/provider/google.mjs";
4
+ import "../oauth2/provider/index.mjs";
8
5
  import { PRINCIPAL_NAME_INDEX_NAME } from "../session/common.mjs";
9
- import {
10
- deleteCookie,
11
- getCookie,
12
- param,
13
- query,
14
- setCookie
15
- } from "../utils/http.mjs";
6
+ import { deleteCookie, getCookie, param, query, setCookie } from "../utils/http.mjs";
16
7
  import { timing } from "../utils/timing.mjs";
17
8
  import { invalidArgument, valid, verifyTurnstileToken } from "../utils/valid.mjs";
18
- var PKCE_FORMAT = /^[A-Za-z0-9_-]{43,128}$/;
19
- var PATH = {
20
- CSRF: "/csrf",
21
- LOGOUT: "/logout",
22
- LOGGED: "/logged",
23
- OAUTH2_STATE: "/oauth2/state/:registrationId",
24
- OAUTH2_NONCE: "/oauth2/nonce/:registrationId",
25
- OAUTH2_AUTHORIZATION: "/oauth2/authorization/:registrationId",
26
- LOGIN_OAUTH2_CODE: "/login/oauth2/code/:registrationId",
27
- LOGIN_OAUTH2_NATIVE: "/login/oauth2/native/:registrationId",
28
- LOGIN_OAUTH2_ONETAP: "/login/oauth2/onetap/google",
29
- LOGIN_EMAIL: "/login/email",
30
- LOGIN_PHONE: "/login/phone",
31
- SEND_EMAIL_VERIFICATION_CODE: "/auth/sendEmailVerificationCode",
32
- SEND_PHONE_VERIFICATION_CODE: "/auth/sendPhoneVerificationCode",
33
- // Desktop / native app sign-in via web loopback (RFC 8252 + OAuth 2.1 Auth Code Flow):
34
- // 1. user already authenticated with cookie session → POST DESKTOP_AUTHORIZE → { code }
35
- // 2. web redirects to desktop's loopback callback with the code
36
- // 3. desktop renderer POSTs DESKTOP_EXCHANGE with the code → server returns
37
- // Set-Cookie carrying a fresh session, single-use code is invalidated
38
- // The two-step flow keeps the actual session id off the redirect URL — only a
39
- // 5-minute single-use code transits via the loopback.
40
- DESKTOP_AUTHORIZE: "/auth/desktop/authorize",
41
- DESKTOP_EXCHANGE: "/auth/desktop/exchange",
42
- CLEANUP_EXPIRED_SESSIONS: "/sessions/expired/cleanup"
9
+ import { createHash, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
10
+ import { invariant } from "@shware/utils";
11
+ //#region src/auth/index.ts
12
+ const PKCE_FORMAT = /^[A-Za-z0-9_-]{43,128}$/;
13
+ const PATH = {
14
+ CSRF: "/csrf",
15
+ LOGOUT: "/logout",
16
+ LOGGED: "/logged",
17
+ OAUTH2_STATE: "/oauth2/state/:registrationId",
18
+ OAUTH2_NONCE: "/oauth2/nonce/:registrationId",
19
+ OAUTH2_AUTHORIZATION: "/oauth2/authorization/:registrationId",
20
+ LOGIN_OAUTH2_CODE: "/login/oauth2/code/:registrationId",
21
+ LOGIN_OAUTH2_NATIVE: "/login/oauth2/native/:registrationId",
22
+ LOGIN_OAUTH2_ONETAP: "/login/oauth2/onetap/google",
23
+ LOGIN_EMAIL: "/login/email",
24
+ LOGIN_PHONE: "/login/phone",
25
+ SEND_EMAIL_VERIFICATION_CODE: "/auth/sendEmailVerificationCode",
26
+ SEND_PHONE_VERIFICATION_CODE: "/auth/sendPhoneVerificationCode",
27
+ DESKTOP_AUTHORIZE: "/auth/desktop/authorize",
28
+ DESKTOP_EXCHANGE: "/auth/desktop/exchange",
29
+ CLEANUP_EXPIRED_SESSIONS: "/sessions/expired/cleanup"
43
30
  };
44
31
  var Auth = class {
45
- timing;
46
- cookieName;
47
- cookieOptions;
48
- kv;
49
- repository;
50
- turnstileSecretKey;
51
- oauth2Client;
52
- ATTR_OAUTH2_AUTHORIZATION_REQUEST = "oauth2AuthorizationRequest";
53
- constructor({
54
- sessionRepository,
55
- kvRepository,
56
- turnstileSecretKey,
57
- oauth2,
58
- cookie,
59
- timing: timing2
60
- }) {
61
- this.timing = timing2 ?? false;
62
- this.kv = kvRepository;
63
- this.repository = sessionRepository;
64
- this.turnstileSecretKey = turnstileSecretKey;
65
- const { name, ...cookieOptions } = cookie ?? {};
66
- this.cookieName = name ?? "SESSION";
67
- this.cookieOptions = {
68
- path: "/",
69
- sameSite: "none",
70
- secure: true,
71
- httpOnly: true,
72
- ...cookieOptions
73
- };
74
- this.oauth2Client = oauth2?.client ? new OAuth2Client(oauth2.client) : null;
75
- }
76
- getSessionId(request) {
77
- let sessionId;
78
- const header = request.headers.get("Authorization");
79
- if (header?.startsWith("Bearer ")) {
80
- sessionId = header.replace("Bearer ", "");
81
- }
82
- if (!sessionId) {
83
- sessionId = getCookie(request, this.cookieName);
84
- }
85
- return sessionId;
86
- }
87
- csrf = async (_request) => {
88
- const token = randomUUID();
89
- const response = Response.json({
90
- token,
91
- parameterName: "_csrf",
92
- headerName: "X-XSRF-TOKEN"
93
- });
94
- setCookie(response, "XSRF-TOKEN", token, {
95
- path: "/",
96
- secure: true,
97
- httpOnly: false,
98
- sameSite: "none",
99
- domain: this.cookieOptions.domain
100
- });
101
- return response;
102
- };
103
- logout = async (request) => {
104
- const sessionId = this.getSessionId(request);
105
- if (sessionId) await this.repository.deleteById(sessionId);
106
- const response = Response.json({});
107
- deleteCookie(response, this.cookieName);
108
- return response;
109
- };
110
- logged = async (request, onLogged) => {
111
- const sessionId = this.getSessionId(request);
112
- if (!sessionId) return Response.json({ data: false });
113
- const session = await this.repository.findById(sessionId);
114
- if (!session) return Response.json({ data: false });
115
- const name = session.getAttribute(PRINCIPAL_NAME_INDEX_NAME);
116
- session.setLastAccessedTime(Date.now());
117
- await this.repository.save(session);
118
- if (name !== null) await onLogged?.({ name: String(name) });
119
- const response = Response.json({ data: name !== null });
120
- const maxAge = session.getMaxInactiveInterval();
121
- setCookie(response, this.cookieName, session.getId(), { ...this.cookieOptions, maxAge });
122
- return response;
123
- };
124
- createPkceParameters() {
125
- const code_verifier = randomUUID();
126
- const code_challenge = createHash("sha256").update(code_verifier).digest().toString("base64url");
127
- return { code_verifier, code_challenge, code_challenge_method: "S256" };
128
- }
129
- getOauth2StateKey(state) {
130
- return `oauth2:state:${state}`;
131
- }
132
- getOauth2NonceKey(nonce) {
133
- return `oauth2:nonce:${nonce}`;
134
- }
135
- getEmailVerificationCodeKey(email) {
136
- return `email:verification:${email}`;
137
- }
138
- getPhoneVerificationCodeKey(phone) {
139
- return `phone:verification:${phone}`;
140
- }
141
- getDesktopCodeKey(code) {
142
- return `desktop:code:${code}`;
143
- }
144
- oauth2State = async (request) => {
145
- const { registrationId } = param(request, PATH.OAUTH2_STATE);
146
- const state = randomUUID();
147
- await this.kv.setItem(this.getOauth2StateKey(state), registrationId, 10 * 60);
148
- return Response.json({ state });
149
- };
150
- oauth2Nonce = async (request) => {
151
- const { registrationId } = param(request, PATH.OAUTH2_NONCE);
152
- const nonce = randomUUID();
153
- await this.kv.setItem(this.getOauth2NonceKey(nonce), registrationId, 10 * 60);
154
- return Response.json({ nonce });
155
- };
156
- oauth2Authorization = async (request) => {
157
- invariant(this.oauth2Client, "oauth2Client is not initialized");
158
- const { mark, setTiming } = timing({ enabled: this.timing });
159
- const { registrationId } = param(request, PATH.OAUTH2_AUTHORIZATION);
160
- const state = randomUUID();
161
- const pkce = this.createPkceParameters();
162
- const uri = await this.oauth2Client.createAuthorizationUri({ registrationId, state, pkce });
163
- const sessionId = this.getSessionId(request);
164
- const session = sessionId ? await this.repository.findById(sessionId) ?? this.repository.createSession() : this.repository.createSession();
165
- mark("fetch_session");
166
- const authorizationRequest = {
167
- state,
168
- registrationId,
169
- authorizationRequestUri: uri.href,
170
- additionalParameters: pkce
171
- };
172
- const value = JSON.stringify(authorizationRequest);
173
- session.setAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST, value);
174
- await this.repository.save(session);
175
- mark("save_session");
176
- const response = new Response(null, { status: 302, headers: { location: uri.href } });
177
- const maxAge = session.getMaxInactiveInterval();
178
- setCookie(response, this.cookieName, session.getId(), { ...this.cookieOptions, maxAge });
179
- setTiming(response);
180
- return response;
181
- };
182
- redirect = (error, description) => {
183
- invariant(this.oauth2Client, "oauth2Client is not initialized");
184
- const uri = new URL(this.oauth2Client.errorUri);
185
- uri.searchParams.set("error", error);
186
- if (description) uri.searchParams.set("error_description", description);
187
- return Response.redirect(uri.href, 302);
188
- };
189
- loginOAuth2Code = async (request, handler) => {
190
- invariant(this.oauth2Client, "oauth2Client is not initialized");
191
- const { mark, setTiming } = timing({ enabled: this.timing });
192
- const sessionId = this.getSessionId(request);
193
- const session = sessionId ? await this.repository.findById(sessionId) : null;
194
- if (!session) {
195
- return this.redirect("invalid_request", "session not found");
196
- }
197
- mark("load_session");
198
- const json = session.getAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST);
199
- if (!json) {
200
- return this.redirect("invalid_request", "authorization request not found");
201
- }
202
- const cached = JSON.parse(json);
203
- const { registrationId } = param(request, PATH.LOGIN_OAUTH2_CODE);
204
- let data;
205
- if (request.method === "POST" && request.headers.get("content-type")?.toLowerCase() === "application/x-www-form-urlencoded") {
206
- const params = new URLSearchParams(await request.text());
207
- data = Object.fromEntries(params.entries());
208
- } else {
209
- data = query(request);
210
- }
211
- const parsed = oauth2RedirectQuerySchema.safeParse(data);
212
- if (!parsed.success) {
213
- return this.redirect("invalid_request", "invalid redirect query");
214
- }
215
- if (!parsed.data.code || !parsed.data.state) {
216
- return this.redirect("invalid_request", "invalid redirect query");
217
- }
218
- if (parsed.data.state !== cached.state) {
219
- return this.redirect("invalid_request", "redirect state mismatch");
220
- }
221
- if (registrationId !== cached.registrationId) {
222
- return this.redirect("invalid_request", "redirect registration mismatch");
223
- }
224
- mark("validate_session");
225
- const { code } = parsed.data;
226
- const token = await this.oauth2Client.exchangeAuthorizationCode({
227
- registrationId,
228
- code,
229
- pkce: cached.additionalParameters
230
- });
231
- const userInfo = await this.oauth2Client.getUserInfo({ registrationId, token });
232
- mark("exchange_code");
233
- const principal = await handler(request, registrationId, userInfo, token);
234
- mark("save_principal");
235
- session.setLastAccessedTime(Date.now());
236
- session.removeAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST);
237
- session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
238
- await this.repository.save(session);
239
- mark("update_session");
240
- const uri = new URL(this.oauth2Client.successUri);
241
- uri.searchParams.set("status", "success");
242
- uri.searchParams.set("registrationId", registrationId);
243
- const response = new Response(null, { status: 302, headers: { location: uri.href } });
244
- const maxAge = session.getMaxInactiveInterval();
245
- setCookie(response, this.cookieName, session.getId(), { ...this.cookieOptions, maxAge });
246
- setTiming(response);
247
- return response;
248
- };
249
- loginOAuth2Native = async (request, handler) => {
250
- invariant(this.oauth2Client, "oauth2Client is not initialized");
251
- const { mark, setTiming } = timing({ enabled: this.timing });
252
- const { registrationId } = param(request, PATH.LOGIN_OAUTH2_NATIVE);
253
- const credentials = await request.json();
254
- const { userInfo, token } = await this.oauth2Client.loginOAuth2Native({
255
- registrationId,
256
- credentials
257
- });
258
- mark("login_oauth2_native");
259
- const { nonce } = userInfo.data;
260
- if (nonce) {
261
- const value = await this.kv.getItem(this.getOauth2NonceKey(nonce));
262
- if (value !== registrationId) {
263
- const json = { error: "invalid_request", error_description: "nonce mismatch" };
264
- return Response.json(json, { status: 400 });
265
- }
266
- await this.kv.removeItem(this.getOauth2NonceKey(nonce));
267
- mark("check_nonce");
268
- }
269
- const principal = await handler(request, registrationId, userInfo, token);
270
- mark("save_principal");
271
- const session = this.repository.createSession();
272
- session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
273
- await this.repository.save(session);
274
- const response = new Response(JSON.stringify(principal), {
275
- status: 200,
276
- headers: { "Content-Type": "application/json" }
277
- });
278
- const maxAge = session.getMaxInactiveInterval();
279
- setCookie(response, this.cookieName, session.getId(), { ...this.cookieOptions, maxAge });
280
- setTiming(response);
281
- return response;
282
- };
283
- loginOAuth2Onetap = async (request, handler, registrationId = "google") => {
284
- invariant(this.oauth2Client, "oauth2Client is not initialized");
285
- const { mark, setTiming } = timing({ enabled: this.timing });
286
- let body;
287
- if (request.headers.get("Content-Type")?.includes("application/x-www-form-urlencoded")) {
288
- const text = await request.text();
289
- body = Object.fromEntries(new URLSearchParams(text));
290
- } else if (request.headers.get("Content-Type")?.includes("application/json")) {
291
- body = await request.json();
292
- } else {
293
- const json = { error: "invalid_request", error_description: "invalid content type" };
294
- return Response.json(json, { status: 400 });
295
- }
296
- const parsed = googleOneTapSchema.parse(body);
297
- let principal;
298
- if (parsed.credential) {
299
- const { token, userInfo } = await google.getTokenInfo(parsed.credential);
300
- principal = await handler(request, registrationId, userInfo, token);
301
- } else if (parsed.code) {
302
- const token = await this.oauth2Client.exchangeAuthorizationCode({
303
- registrationId,
304
- code: parsed.code
305
- });
306
- const userInfo = await this.oauth2Client.getUserInfo({ registrationId, token });
307
- principal = await handler(request, registrationId, userInfo, token);
308
- } else {
309
- throw new Error("invalid request");
310
- }
311
- mark("save_principal");
312
- const session = this.repository.createSession();
313
- session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
314
- await this.repository.save(session);
315
- const response = new Response(JSON.stringify(principal), {
316
- status: 200,
317
- headers: { "Content-Type": "application/json" }
318
- });
319
- const maxAge = session.getMaxInactiveInterval();
320
- setCookie(response, this.cookieName, session.getId(), { ...this.cookieOptions, maxAge });
321
- setTiming(response);
322
- return response;
323
- };
324
- sendEmailVerificationCode = async (request, sender) => {
325
- invariant(this.turnstileSecretKey, "turnstileSecretKey is not configured");
326
- const { data, error } = valid(sendEmailVerificationCodeSchema, await request.json());
327
- if (error) return Response.json(error, { status: error.error.code });
328
- const { email, turnstileToken } = data;
329
- const result = await verifyTurnstileToken(this.turnstileSecretKey, turnstileToken);
330
- if (result.error) return Response.json(result.error, { status: result.error.error.code });
331
- try {
332
- const verificationCode = randomInt(1e6).toString().padStart(6, "0");
333
- await sender({ email, verificationCode });
334
- await this.kv.setItem(this.getEmailVerificationCodeKey(email), verificationCode, 10 * 60);
335
- return Response.json({});
336
- } catch (e) {
337
- console.error("Failed to send email verification code", e);
338
- return Response.json(invalidArgument("EMAIL_DELIVERY_FAILED"), { status: 400 });
339
- }
340
- };
341
- loginEmail = async (request, handler) => {
342
- const { data, error } = valid(loginEmailSchema, await request.json());
343
- if (error) return Response.json(error, { status: error.error.code });
344
- const { email, verificationCode } = data;
345
- const key = this.getEmailVerificationCodeKey(email);
346
- const stored = await this.kv.getItem(key);
347
- if (typeof stored !== "string" || stored.length !== verificationCode.length || !timingSafeEqual(Buffer.from(stored), Buffer.from(verificationCode))) {
348
- return Response.json(invalidArgument("INVALID_VERIFICATION_CODE"), { status: 400 });
349
- }
350
- await this.kv.removeItem(key);
351
- const claims = { sub: email, email, email_verified: true };
352
- const principal = await handler(request, Provider.EMAIL, { claims, data: {} });
353
- const session = this.repository.createSession();
354
- session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
355
- await this.repository.save(session);
356
- const response = new Response(JSON.stringify(principal), {
357
- status: 200,
358
- headers: { "Content-Type": "application/json" }
359
- });
360
- const maxAge = session.getMaxInactiveInterval();
361
- setCookie(response, this.cookieName, session.getId(), { ...this.cookieOptions, maxAge });
362
- return response;
363
- };
364
- /**
365
- * Step 1 of the desktop / native app sign-in handshake. The caller already
366
- * completed normal sign-in on the web (so a cookie session exists); this
367
- * issues a 5-minute single-use code that the web page hands off to the
368
- * desktop client via a loopback redirect.
369
- *
370
- * PKCE (RFC 7636, S256 only): the desktop generates a random `code_verifier`
371
- * before opening the browser, derives `code_challenge = base64url(sha256(verifier))`,
372
- * and threads the challenge through the URL → web page → here. We pin
373
- * the challenge to the code in KV; `desktopExchange` enforces that the
374
- * caller knows the matching verifier. This blocks an attacker who only
375
- * intercepts the auth code (browser history, malicious extension, packet
376
- * capture on loopback) from exchanging it.
377
- *
378
- * Requires an authenticated cookie session — anonymous callers get 401.
379
- * Throttling / abuse protection on this endpoint is the API layer's job
380
- * (rate-limit per principal); the SDK doesn't gate it because the
381
- * upstream sign-in flow already passed CAPTCHA / OAuth / verification.
382
- */
383
- desktopAuthorize = async (request) => {
384
- const principal = await this.getPrincipal(request);
385
- if (!principal) {
386
- const body2 = {
387
- error: {
388
- code: 401,
389
- status: "UNAUTHENTICATED",
390
- message: "sign in first",
391
- details: []
392
- }
393
- };
394
- return Response.json(body2, { status: 401 });
395
- }
396
- let body;
397
- try {
398
- body = await request.json();
399
- } catch {
400
- return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
401
- }
402
- const codeChallenge = typeof body.code_challenge === "string" ? body.code_challenge : null;
403
- if (!codeChallenge || body.code_challenge_method !== "S256" || !PKCE_FORMAT.test(codeChallenge)) {
404
- return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
405
- }
406
- const code = randomUUID();
407
- const record = { name: principal.name, cc: codeChallenge };
408
- await this.kv.setItem(this.getDesktopCodeKey(code), JSON.stringify(record), 5 * 60);
409
- return Response.json({ code });
410
- };
411
- /**
412
- * Step 2 of the desktop / native app sign-in handshake. The desktop
413
- * client hands back the auth code it received via the loopback callback,
414
- * and we mint a fresh session for it — independent of the web cookie
415
- * session that originally `authorize`'d the code, so logging out on
416
- * either side doesn't cascade to the other.
417
- *
418
- * Cookie attributes: defaults to `SameSite=None; Secure` so the cookie
419
- * survives cross-site fetches from the desktop renderer's `file://` /
420
- * `app://` origin. The configured base `cookieOptions` provide
421
- * everything else (`domain`, `path`, `httpOnly`, `maxAge`); the override
422
- * is targeted at the SameSite/Secure pair only. In a non-HTTPS dev
423
- * backend this combination is rejected by Chromium — see the desktop
424
- * sign-in PRD for workarounds.
425
- *
426
- * No CSRF check needed — the request carries no cookie, the auth code
427
- * itself is the credential, and KV's single-use removal blocks replay.
428
- */
429
- desktopExchange = async (request) => {
430
- let body;
431
- try {
432
- body = await request.json();
433
- } catch {
434
- return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
435
- }
436
- const code = typeof body.code === "string" ? body.code : null;
437
- const verifier = typeof body.code_verifier === "string" ? body.code_verifier : null;
438
- if (!code || !verifier || !PKCE_FORMAT.test(verifier)) {
439
- return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
440
- }
441
- const key = this.getDesktopCodeKey(code);
442
- const raw = await this.kv.getItem(key);
443
- if (typeof raw !== "string") {
444
- return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
445
- }
446
- let record;
447
- try {
448
- record = JSON.parse(raw);
449
- } catch {
450
- await this.kv.removeItem(key);
451
- return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
452
- }
453
- const computed = createHash("sha256").update(verifier).digest();
454
- const stored = Buffer.from(record.cc, "base64url");
455
- if (stored.length !== computed.length || !timingSafeEqual(computed, stored)) {
456
- return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
457
- }
458
- await this.kv.removeItem(key);
459
- const session = this.repository.createSession();
460
- session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, record.name);
461
- session.setAttribute("client_type", "desktop");
462
- await this.repository.save(session);
463
- const response = Response.json({ ok: true });
464
- const maxAge = session.getMaxInactiveInterval();
465
- setCookie(response, this.cookieName, session.getId(), {
466
- ...this.cookieOptions,
467
- // Override sameSite/secure for desktop: the renderer's origin
468
- // (file:// / app://) is cross-site to the API, so SameSite=Lax/Strict
469
- // would block the cookie on subsequent fetches.
470
- sameSite: "none",
471
- secure: true,
472
- maxAge
473
- });
474
- return response;
475
- };
476
- sendPhoneVerificationCode = async (_request, _sender) => {
477
- return Response.json({ error: "not_implemented" }, { status: 501 });
478
- };
479
- loginPhone = async (_request, _handler) => {
480
- return Response.json({ error: "not_implemented" }, { status: 501 });
481
- };
482
- kick = async (principal) => {
483
- const sessions = await this.repository.findByPrincipalName(principal.name);
484
- for (const sessionId of sessions.keys()) {
485
- await this.repository.deleteById(sessionId);
486
- }
487
- };
488
- isAuthenticated = async (request) => {
489
- const sessionId = this.getSessionId(request);
490
- if (!sessionId) return false;
491
- const session = await this.repository.findById(sessionId);
492
- if (!session) return false;
493
- const principalName = session.getAttribute(PRINCIPAL_NAME_INDEX_NAME);
494
- if (!principalName) return false;
495
- return true;
496
- };
497
- getSession = async (request, create) => {
498
- const sessionId = this.getSessionId(request);
499
- if (!sessionId) {
500
- if (create) {
501
- const session2 = this.repository.createSession();
502
- await this.repository.save(session2);
503
- return session2;
504
- } else {
505
- return null;
506
- }
507
- }
508
- const session = await this.repository.findById(sessionId);
509
- if (!session) {
510
- if (create) {
511
- const session2 = this.repository.createSession();
512
- await this.repository.save(session2);
513
- return session2;
514
- } else {
515
- return null;
516
- }
517
- }
518
- return session;
519
- };
520
- deleteSession = async (sessionId) => {
521
- await this.repository.deleteById(sessionId);
522
- };
523
- getPrincipal = async (request) => {
524
- const sessionId = this.getSessionId(request);
525
- if (!sessionId) return null;
526
- const session = await this.repository.findById(sessionId);
527
- if (!session) return null;
528
- const name = session.getAttribute(PRINCIPAL_NAME_INDEX_NAME);
529
- if (!name) return null;
530
- return { name: String(name) };
531
- };
532
- listSessions = async (principal) => {
533
- const sessions = await this.repository.findByPrincipalName(principal.name);
534
- return Array.from(sessions.values());
535
- };
536
- cleanupExpiredSessions = async (cleanupCount) => {
537
- await this.repository.cleanupExpiredSessions(cleanupCount);
538
- return Response.json({});
539
- };
540
- };
541
- export {
542
- Auth,
543
- PATH
32
+ timing;
33
+ cookieName;
34
+ cookieOptions;
35
+ kv;
36
+ repository;
37
+ turnstileSecretKey;
38
+ oauth2Client;
39
+ ATTR_OAUTH2_AUTHORIZATION_REQUEST = "oauth2AuthorizationRequest";
40
+ constructor({ sessionRepository, kvRepository, turnstileSecretKey, oauth2, cookie, timing }) {
41
+ this.timing = timing ?? false;
42
+ this.kv = kvRepository;
43
+ this.repository = sessionRepository;
44
+ this.turnstileSecretKey = turnstileSecretKey;
45
+ const { name, ...cookieOptions } = cookie ?? {};
46
+ this.cookieName = name ?? "SESSION";
47
+ this.cookieOptions = {
48
+ path: "/",
49
+ sameSite: "none",
50
+ secure: true,
51
+ httpOnly: true,
52
+ ...cookieOptions
53
+ };
54
+ this.oauth2Client = oauth2?.client ? new OAuth2Client(oauth2.client) : null;
55
+ }
56
+ getSessionId(request) {
57
+ let sessionId;
58
+ const header = request.headers.get("Authorization");
59
+ if (header?.startsWith("Bearer ")) sessionId = header.replace("Bearer ", "");
60
+ if (!sessionId) sessionId = getCookie(request, this.cookieName);
61
+ return sessionId;
62
+ }
63
+ csrf = async (_request) => {
64
+ const token = randomUUID();
65
+ const response = Response.json({
66
+ token,
67
+ parameterName: "_csrf",
68
+ headerName: "X-XSRF-TOKEN"
69
+ });
70
+ setCookie(response, "XSRF-TOKEN", token, {
71
+ path: "/",
72
+ secure: true,
73
+ httpOnly: false,
74
+ sameSite: "none",
75
+ domain: this.cookieOptions.domain
76
+ });
77
+ return response;
78
+ };
79
+ logout = async (request) => {
80
+ const sessionId = this.getSessionId(request);
81
+ if (sessionId) await this.repository.deleteById(sessionId);
82
+ const response = Response.json({});
83
+ deleteCookie(response, this.cookieName);
84
+ return response;
85
+ };
86
+ logged = async (request, onLogged) => {
87
+ const sessionId = this.getSessionId(request);
88
+ if (!sessionId) return Response.json({ data: false });
89
+ const session = await this.repository.findById(sessionId);
90
+ if (!session) return Response.json({ data: false });
91
+ const name = session.getAttribute(PRINCIPAL_NAME_INDEX_NAME);
92
+ session.setLastAccessedTime(Date.now());
93
+ await this.repository.save(session);
94
+ if (name !== null) await onLogged?.({ name: String(name) });
95
+ const response = Response.json({ data: name !== null });
96
+ const maxAge = session.getMaxInactiveInterval();
97
+ setCookie(response, this.cookieName, session.getId(), {
98
+ ...this.cookieOptions,
99
+ maxAge
100
+ });
101
+ return response;
102
+ };
103
+ createPkceParameters() {
104
+ const code_verifier = randomUUID();
105
+ return {
106
+ code_verifier,
107
+ code_challenge: createHash("sha256").update(code_verifier).digest().toString("base64url"),
108
+ code_challenge_method: "S256"
109
+ };
110
+ }
111
+ getOauth2StateKey(state) {
112
+ return `oauth2:state:${state}`;
113
+ }
114
+ getOauth2NonceKey(nonce) {
115
+ return `oauth2:nonce:${nonce}`;
116
+ }
117
+ getEmailVerificationCodeKey(email) {
118
+ return `email:verification:${email}`;
119
+ }
120
+ getPhoneVerificationCodeKey(phone) {
121
+ return `phone:verification:${phone}`;
122
+ }
123
+ getDesktopCodeKey(code) {
124
+ return `desktop:code:${code}`;
125
+ }
126
+ oauth2State = async (request) => {
127
+ const { registrationId } = param(request, PATH.OAUTH2_STATE);
128
+ const state = randomUUID();
129
+ await this.kv.setItem(this.getOauth2StateKey(state), registrationId, 600);
130
+ return Response.json({ state });
131
+ };
132
+ oauth2Nonce = async (request) => {
133
+ const { registrationId } = param(request, PATH.OAUTH2_NONCE);
134
+ const nonce = randomUUID();
135
+ await this.kv.setItem(this.getOauth2NonceKey(nonce), registrationId, 600);
136
+ return Response.json({ nonce });
137
+ };
138
+ oauth2Authorization = async (request) => {
139
+ invariant(this.oauth2Client, "oauth2Client is not initialized");
140
+ const { mark, setTiming } = timing({ enabled: this.timing });
141
+ const { registrationId } = param(request, PATH.OAUTH2_AUTHORIZATION);
142
+ const state = randomUUID();
143
+ const pkce = this.createPkceParameters();
144
+ const uri = await this.oauth2Client.createAuthorizationUri({
145
+ registrationId,
146
+ state,
147
+ pkce
148
+ });
149
+ const sessionId = this.getSessionId(request);
150
+ const session = sessionId ? await this.repository.findById(sessionId) ?? this.repository.createSession() : this.repository.createSession();
151
+ mark("fetch_session");
152
+ const authorizationRequest = {
153
+ state,
154
+ registrationId,
155
+ authorizationRequestUri: uri.href,
156
+ additionalParameters: pkce
157
+ };
158
+ const value = JSON.stringify(authorizationRequest);
159
+ session.setAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST, value);
160
+ await this.repository.save(session);
161
+ mark("save_session");
162
+ const response = new Response(null, {
163
+ status: 302,
164
+ headers: { location: uri.href }
165
+ });
166
+ const maxAge = session.getMaxInactiveInterval();
167
+ setCookie(response, this.cookieName, session.getId(), {
168
+ ...this.cookieOptions,
169
+ maxAge
170
+ });
171
+ setTiming(response);
172
+ return response;
173
+ };
174
+ redirect = (error, description) => {
175
+ invariant(this.oauth2Client, "oauth2Client is not initialized");
176
+ const uri = new URL(this.oauth2Client.errorUri);
177
+ uri.searchParams.set("error", error);
178
+ if (description) uri.searchParams.set("error_description", description);
179
+ return Response.redirect(uri.href, 302);
180
+ };
181
+ loginOAuth2Code = async (request, handler) => {
182
+ invariant(this.oauth2Client, "oauth2Client is not initialized");
183
+ const { mark, setTiming } = timing({ enabled: this.timing });
184
+ const sessionId = this.getSessionId(request);
185
+ const session = sessionId ? await this.repository.findById(sessionId) : null;
186
+ if (!session) return this.redirect("invalid_request", "session not found");
187
+ mark("load_session");
188
+ const json = session.getAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST);
189
+ if (!json) return this.redirect("invalid_request", "authorization request not found");
190
+ const cached = JSON.parse(json);
191
+ const { registrationId } = param(request, PATH.LOGIN_OAUTH2_CODE);
192
+ let data;
193
+ if (request.method === "POST" && request.headers.get("content-type")?.toLowerCase() === "application/x-www-form-urlencoded") {
194
+ const params = new URLSearchParams(await request.text());
195
+ data = Object.fromEntries(params.entries());
196
+ } else data = query(request);
197
+ const parsed = oauth2RedirectQuerySchema.safeParse(data);
198
+ if (!parsed.success) return this.redirect("invalid_request", "invalid redirect query");
199
+ if (!parsed.data.code || !parsed.data.state) return this.redirect("invalid_request", "invalid redirect query");
200
+ if (parsed.data.state !== cached.state) return this.redirect("invalid_request", "redirect state mismatch");
201
+ if (registrationId !== cached.registrationId) return this.redirect("invalid_request", "redirect registration mismatch");
202
+ mark("validate_session");
203
+ const { code } = parsed.data;
204
+ const token = await this.oauth2Client.exchangeAuthorizationCode({
205
+ registrationId,
206
+ code,
207
+ pkce: cached.additionalParameters
208
+ });
209
+ const userInfo = await this.oauth2Client.getUserInfo({
210
+ registrationId,
211
+ token
212
+ });
213
+ mark("exchange_code");
214
+ const principal = await handler(request, registrationId, userInfo, token);
215
+ mark("save_principal");
216
+ session.setLastAccessedTime(Date.now());
217
+ session.removeAttribute(this.ATTR_OAUTH2_AUTHORIZATION_REQUEST);
218
+ session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
219
+ await this.repository.save(session);
220
+ mark("update_session");
221
+ const uri = new URL(this.oauth2Client.successUri);
222
+ uri.searchParams.set("status", "success");
223
+ uri.searchParams.set("registrationId", registrationId);
224
+ const response = new Response(null, {
225
+ status: 302,
226
+ headers: { location: uri.href }
227
+ });
228
+ const maxAge = session.getMaxInactiveInterval();
229
+ setCookie(response, this.cookieName, session.getId(), {
230
+ ...this.cookieOptions,
231
+ maxAge
232
+ });
233
+ setTiming(response);
234
+ return response;
235
+ };
236
+ loginOAuth2Native = async (request, handler) => {
237
+ invariant(this.oauth2Client, "oauth2Client is not initialized");
238
+ const { mark, setTiming } = timing({ enabled: this.timing });
239
+ const { registrationId } = param(request, PATH.LOGIN_OAUTH2_NATIVE);
240
+ const credentials = await request.json();
241
+ const { userInfo, token } = await this.oauth2Client.loginOAuth2Native({
242
+ registrationId,
243
+ credentials
244
+ });
245
+ mark("login_oauth2_native");
246
+ const { nonce } = userInfo.data;
247
+ if (nonce) {
248
+ if (await this.kv.getItem(this.getOauth2NonceKey(nonce)) !== registrationId) return Response.json({
249
+ error: "invalid_request",
250
+ error_description: "nonce mismatch"
251
+ }, { status: 400 });
252
+ await this.kv.removeItem(this.getOauth2NonceKey(nonce));
253
+ mark("check_nonce");
254
+ }
255
+ const principal = await handler(request, registrationId, userInfo, token);
256
+ mark("save_principal");
257
+ const session = this.repository.createSession();
258
+ session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
259
+ await this.repository.save(session);
260
+ const response = new Response(JSON.stringify(principal), {
261
+ status: 200,
262
+ headers: { "Content-Type": "application/json" }
263
+ });
264
+ const maxAge = session.getMaxInactiveInterval();
265
+ setCookie(response, this.cookieName, session.getId(), {
266
+ ...this.cookieOptions,
267
+ maxAge
268
+ });
269
+ setTiming(response);
270
+ return response;
271
+ };
272
+ loginOAuth2Onetap = async (request, handler, registrationId = "google") => {
273
+ invariant(this.oauth2Client, "oauth2Client is not initialized");
274
+ const { mark, setTiming } = timing({ enabled: this.timing });
275
+ let body;
276
+ if (request.headers.get("Content-Type")?.includes("application/x-www-form-urlencoded")) {
277
+ const text = await request.text();
278
+ body = Object.fromEntries(new URLSearchParams(text));
279
+ } else if (request.headers.get("Content-Type")?.includes("application/json")) body = await request.json();
280
+ else return Response.json({
281
+ error: "invalid_request",
282
+ error_description: "invalid content type"
283
+ }, { status: 400 });
284
+ const parsed = googleOneTapSchema.parse(body);
285
+ let principal;
286
+ if (parsed.credential) {
287
+ const { token, userInfo } = await google.getTokenInfo(parsed.credential);
288
+ principal = await handler(request, registrationId, userInfo, token);
289
+ } else if (parsed.code) {
290
+ const token = await this.oauth2Client.exchangeAuthorizationCode({
291
+ registrationId,
292
+ code: parsed.code
293
+ });
294
+ principal = await handler(request, registrationId, await this.oauth2Client.getUserInfo({
295
+ registrationId,
296
+ token
297
+ }), token);
298
+ } else throw new Error("invalid request");
299
+ mark("save_principal");
300
+ const session = this.repository.createSession();
301
+ session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
302
+ await this.repository.save(session);
303
+ const response = new Response(JSON.stringify(principal), {
304
+ status: 200,
305
+ headers: { "Content-Type": "application/json" }
306
+ });
307
+ const maxAge = session.getMaxInactiveInterval();
308
+ setCookie(response, this.cookieName, session.getId(), {
309
+ ...this.cookieOptions,
310
+ maxAge
311
+ });
312
+ setTiming(response);
313
+ return response;
314
+ };
315
+ sendEmailVerificationCode = async (request, sender) => {
316
+ invariant(this.turnstileSecretKey, "turnstileSecretKey is not configured");
317
+ const { data, error } = valid(sendEmailVerificationCodeSchema, await request.json());
318
+ if (error) return Response.json(error, { status: error.error.code });
319
+ const { email, turnstileToken } = data;
320
+ const result = await verifyTurnstileToken(this.turnstileSecretKey, turnstileToken);
321
+ if (result.error) return Response.json(result.error, { status: result.error.error.code });
322
+ try {
323
+ const verificationCode = randomInt(1e6).toString().padStart(6, "0");
324
+ await sender({
325
+ email,
326
+ verificationCode
327
+ });
328
+ await this.kv.setItem(this.getEmailVerificationCodeKey(email), verificationCode, 600);
329
+ return Response.json({});
330
+ } catch (e) {
331
+ console.error("Failed to send email verification code", e);
332
+ return Response.json(invalidArgument("EMAIL_DELIVERY_FAILED"), { status: 400 });
333
+ }
334
+ };
335
+ loginEmail = async (request, handler) => {
336
+ const { data, error } = valid(loginEmailSchema, await request.json());
337
+ if (error) return Response.json(error, { status: error.error.code });
338
+ const { email, verificationCode } = data;
339
+ const key = this.getEmailVerificationCodeKey(email);
340
+ const stored = await this.kv.getItem(key);
341
+ if (typeof stored !== "string" || stored.length !== verificationCode.length || !timingSafeEqual(Buffer.from(stored), Buffer.from(verificationCode))) return Response.json(invalidArgument("INVALID_VERIFICATION_CODE"), { status: 400 });
342
+ await this.kv.removeItem(key);
343
+ const principal = await handler(request, "EMAIL", {
344
+ claims: {
345
+ sub: email,
346
+ email,
347
+ email_verified: true
348
+ },
349
+ data: {}
350
+ });
351
+ const session = this.repository.createSession();
352
+ session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal.name);
353
+ await this.repository.save(session);
354
+ const response = new Response(JSON.stringify(principal), {
355
+ status: 200,
356
+ headers: { "Content-Type": "application/json" }
357
+ });
358
+ const maxAge = session.getMaxInactiveInterval();
359
+ setCookie(response, this.cookieName, session.getId(), {
360
+ ...this.cookieOptions,
361
+ maxAge
362
+ });
363
+ return response;
364
+ };
365
+ /**
366
+ * Step 1 of the desktop / native app sign-in handshake. The caller already
367
+ * completed normal sign-in on the web (so a cookie session exists); this
368
+ * issues a 5-minute single-use code that the web page hands off to the
369
+ * desktop client via a loopback redirect.
370
+ *
371
+ * PKCE (RFC 7636, S256 only): the desktop generates a random `code_verifier`
372
+ * before opening the browser, derives `code_challenge = base64url(sha256(verifier))`,
373
+ * and threads the challenge through the URL → web page → here. We pin
374
+ * the challenge to the code in KV; `desktopExchange` enforces that the
375
+ * caller knows the matching verifier. This blocks an attacker who only
376
+ * intercepts the auth code (browser history, malicious extension, packet
377
+ * capture on loopback) from exchanging it.
378
+ *
379
+ * Requires an authenticated cookie session — anonymous callers get 401.
380
+ * Throttling / abuse protection on this endpoint is the API layer's job
381
+ * (rate-limit per principal); the SDK doesn't gate it because the
382
+ * upstream sign-in flow already passed CAPTCHA / OAuth / verification.
383
+ */
384
+ desktopAuthorize = async (request) => {
385
+ const principal = await this.getPrincipal(request);
386
+ if (!principal) return Response.json({ error: {
387
+ code: 401,
388
+ status: "UNAUTHENTICATED",
389
+ message: "sign in first",
390
+ details: []
391
+ } }, { status: 401 });
392
+ let body;
393
+ try {
394
+ body = await request.json();
395
+ } catch {
396
+ return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
397
+ }
398
+ const codeChallenge = typeof body.code_challenge === "string" ? body.code_challenge : null;
399
+ if (!codeChallenge || body.code_challenge_method !== "S256" || !PKCE_FORMAT.test(codeChallenge)) return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
400
+ const code = randomUUID();
401
+ const record = {
402
+ name: principal.name,
403
+ cc: codeChallenge
404
+ };
405
+ await this.kv.setItem(this.getDesktopCodeKey(code), JSON.stringify(record), 300);
406
+ return Response.json({ code });
407
+ };
408
+ /**
409
+ * Step 2 of the desktop / native app sign-in handshake. The desktop
410
+ * client hands back the auth code it received via the loopback callback,
411
+ * and we mint a fresh session for it — independent of the web cookie
412
+ * session that originally `authorize`'d the code, so logging out on
413
+ * either side doesn't cascade to the other.
414
+ *
415
+ * Cookie attributes: defaults to `SameSite=None; Secure` so the cookie
416
+ * survives cross-site fetches from the desktop renderer's `file://` /
417
+ * `app://` origin. The configured base `cookieOptions` provide
418
+ * everything else (`domain`, `path`, `httpOnly`, `maxAge`); the override
419
+ * is targeted at the SameSite/Secure pair only. In a non-HTTPS dev
420
+ * backend this combination is rejected by Chromium — see the desktop
421
+ * sign-in PRD for workarounds.
422
+ *
423
+ * No CSRF check needed — the request carries no cookie, the auth code
424
+ * itself is the credential, and KV's single-use removal blocks replay.
425
+ */
426
+ desktopExchange = async (request) => {
427
+ let body;
428
+ try {
429
+ body = await request.json();
430
+ } catch {
431
+ return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
432
+ }
433
+ const code = typeof body.code === "string" ? body.code : null;
434
+ const verifier = typeof body.code_verifier === "string" ? body.code_verifier : null;
435
+ if (!code || !verifier || !PKCE_FORMAT.test(verifier)) return Response.json(invalidArgument("INVALID_ARGUMENT"), { status: 400 });
436
+ const key = this.getDesktopCodeKey(code);
437
+ const raw = await this.kv.getItem(key);
438
+ if (typeof raw !== "string") return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
439
+ let record;
440
+ try {
441
+ record = JSON.parse(raw);
442
+ } catch {
443
+ await this.kv.removeItem(key);
444
+ return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
445
+ }
446
+ const computed = createHash("sha256").update(verifier).digest();
447
+ const stored = Buffer.from(record.cc, "base64url");
448
+ if (stored.length !== computed.length || !timingSafeEqual(computed, stored)) return Response.json(invalidArgument("INVALID_DESKTOP_CODE"), { status: 400 });
449
+ await this.kv.removeItem(key);
450
+ const session = this.repository.createSession();
451
+ session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, record.name);
452
+ session.setAttribute("client_type", "desktop");
453
+ await this.repository.save(session);
454
+ const response = Response.json({ ok: true });
455
+ const maxAge = session.getMaxInactiveInterval();
456
+ setCookie(response, this.cookieName, session.getId(), {
457
+ ...this.cookieOptions,
458
+ sameSite: "none",
459
+ secure: true,
460
+ maxAge
461
+ });
462
+ return response;
463
+ };
464
+ sendPhoneVerificationCode = async (_request, _sender) => {
465
+ return Response.json({ error: "not_implemented" }, { status: 501 });
466
+ };
467
+ loginPhone = async (_request, _handler) => {
468
+ return Response.json({ error: "not_implemented" }, { status: 501 });
469
+ };
470
+ kick = async (principal) => {
471
+ const sessions = await this.repository.findByPrincipalName(principal.name);
472
+ for (const sessionId of sessions.keys()) await this.repository.deleteById(sessionId);
473
+ };
474
+ isAuthenticated = async (request) => {
475
+ const sessionId = this.getSessionId(request);
476
+ if (!sessionId) return false;
477
+ const session = await this.repository.findById(sessionId);
478
+ if (!session) return false;
479
+ if (!session.getAttribute(PRINCIPAL_NAME_INDEX_NAME)) return false;
480
+ return true;
481
+ };
482
+ getSession = async (request, create) => {
483
+ const sessionId = this.getSessionId(request);
484
+ if (!sessionId) if (create) {
485
+ const session = this.repository.createSession();
486
+ await this.repository.save(session);
487
+ return session;
488
+ } else return null;
489
+ const session = await this.repository.findById(sessionId);
490
+ if (!session) if (create) {
491
+ const session = this.repository.createSession();
492
+ await this.repository.save(session);
493
+ return session;
494
+ } else return null;
495
+ return session;
496
+ };
497
+ deleteSession = async (sessionId) => {
498
+ await this.repository.deleteById(sessionId);
499
+ };
500
+ getPrincipal = async (request) => {
501
+ const sessionId = this.getSessionId(request);
502
+ if (!sessionId) return null;
503
+ const session = await this.repository.findById(sessionId);
504
+ if (!session) return null;
505
+ const name = session.getAttribute(PRINCIPAL_NAME_INDEX_NAME);
506
+ if (!name) return null;
507
+ return { name: String(name) };
508
+ };
509
+ listSessions = async (principal) => {
510
+ const sessions = await this.repository.findByPrincipalName(principal.name);
511
+ return Array.from(sessions.values());
512
+ };
513
+ cleanupExpiredSessions = async (cleanupCount) => {
514
+ await this.repository.cleanupExpiredSessions(cleanupCount);
515
+ return Response.json({});
516
+ };
544
517
  };
518
+ //#endregion
519
+ export { Auth, PATH };
520
+
545
521
  //# sourceMappingURL=index.mjs.map