@technomoron/apicore-server 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/api-module.cjs +34 -0
  3. package/dist/cjs/api-module.d.ts +45 -0
  4. package/dist/cjs/apicore-server.cjs +1561 -0
  5. package/dist/cjs/apicore-server.d.ts +288 -0
  6. package/dist/cjs/auth-api/auth-module.cjs +1248 -0
  7. package/dist/cjs/auth-api/auth-module.d.ts +116 -0
  8. package/dist/cjs/auth-api/compat-auth-storage.cjs +128 -0
  9. package/dist/cjs/auth-api/compat-auth-storage.d.ts +57 -0
  10. package/dist/cjs/auth-api/mem-auth-store.cjs +121 -0
  11. package/dist/cjs/auth-api/mem-auth-store.d.ts +68 -0
  12. package/dist/cjs/auth-api/module.cjs +25 -0
  13. package/dist/cjs/auth-api/module.d.ts +20 -0
  14. package/dist/cjs/auth-api/schemas.cjs +171 -0
  15. package/dist/cjs/auth-api/schemas.d.ts +21 -0
  16. package/dist/cjs/auth-api/sql-auth-store.cjs +179 -0
  17. package/dist/cjs/auth-api/sql-auth-store.d.ts +87 -0
  18. package/dist/cjs/auth-api/storage.cjs +102 -0
  19. package/dist/cjs/auth-api/storage.d.ts +38 -0
  20. package/dist/cjs/auth-api/types.cjs +2 -0
  21. package/dist/cjs/auth-api/types.d.ts +34 -0
  22. package/dist/cjs/auth-api/user-id.cjs +47 -0
  23. package/dist/cjs/auth-api/user-id.d.ts +5 -0
  24. package/dist/cjs/auth-cookie-options.cjs +66 -0
  25. package/dist/cjs/auth-cookie-options.d.ts +13 -0
  26. package/dist/cjs/base/client-info.cjs +285 -0
  27. package/dist/cjs/base/client-info.d.ts +27 -0
  28. package/dist/cjs/base/error-utils.cjs +50 -0
  29. package/dist/cjs/base/error-utils.d.ts +16 -0
  30. package/dist/cjs/base/request-utils.cjs +27 -0
  31. package/dist/cjs/base/request-utils.d.ts +8 -0
  32. package/dist/cjs/index.cjs +51 -0
  33. package/dist/cjs/index.d.ts +34 -0
  34. package/dist/cjs/limiter/auth-rate-limiter.cjs +35 -0
  35. package/dist/cjs/limiter/auth-rate-limiter.d.ts +12 -0
  36. package/dist/cjs/limiter/fixed-window.cjs +41 -0
  37. package/dist/cjs/limiter/fixed-window.d.ts +11 -0
  38. package/dist/cjs/oauth/base.cjs +7 -0
  39. package/dist/cjs/oauth/base.d.ts +17 -0
  40. package/dist/cjs/oauth/memory.cjs +135 -0
  41. package/dist/cjs/oauth/memory.d.ts +22 -0
  42. package/dist/cjs/oauth/models.cjs +47 -0
  43. package/dist/cjs/oauth/models.d.ts +50 -0
  44. package/dist/cjs/oauth/sequelize.cjs +159 -0
  45. package/dist/cjs/oauth/sequelize.d.ts +30 -0
  46. package/dist/cjs/oauth/types.cjs +3 -0
  47. package/dist/cjs/oauth/types.d.ts +51 -0
  48. package/dist/cjs/passkey/base.cjs +7 -0
  49. package/dist/cjs/passkey/base.d.ts +28 -0
  50. package/dist/cjs/passkey/config.cjs +26 -0
  51. package/dist/cjs/passkey/config.d.ts +2 -0
  52. package/dist/cjs/passkey/memory.cjs +123 -0
  53. package/dist/cjs/passkey/memory.d.ts +34 -0
  54. package/dist/cjs/passkey/models.cjs +142 -0
  55. package/dist/cjs/passkey/models.d.ts +34 -0
  56. package/dist/cjs/passkey/sequelize.cjs +126 -0
  57. package/dist/cjs/passkey/sequelize.d.ts +42 -0
  58. package/dist/cjs/passkey/service.cjs +413 -0
  59. package/dist/cjs/passkey/service.d.ts +21 -0
  60. package/dist/cjs/passkey/types.cjs +2 -0
  61. package/dist/cjs/passkey/types.d.ts +84 -0
  62. package/dist/cjs/sequelize-utils.cjs +56 -0
  63. package/dist/cjs/sequelize-utils.d.ts +8 -0
  64. package/dist/cjs/token/base.cjs +120 -0
  65. package/dist/cjs/token/base.d.ts +46 -0
  66. package/dist/cjs/token/memory.cjs +234 -0
  67. package/dist/cjs/token/memory.d.ts +29 -0
  68. package/dist/cjs/token/sequelize.cjs +400 -0
  69. package/dist/cjs/token/sequelize.d.ts +58 -0
  70. package/dist/cjs/token/types.cjs +2 -0
  71. package/dist/cjs/token/types.d.ts +34 -0
  72. package/dist/cjs/upload/memory.cjs +92 -0
  73. package/dist/cjs/upload/memory.d.ts +17 -0
  74. package/dist/cjs/upload/tus-module.cjs +270 -0
  75. package/dist/cjs/upload/tus-module.d.ts +38 -0
  76. package/dist/cjs/upload/types.cjs +2 -0
  77. package/dist/cjs/upload/types.d.ts +28 -0
  78. package/dist/cjs/user/base.cjs +53 -0
  79. package/dist/cjs/user/base.d.ts +36 -0
  80. package/dist/cjs/user/memory.cjs +194 -0
  81. package/dist/cjs/user/memory.d.ts +37 -0
  82. package/dist/cjs/user/sequelize.cjs +194 -0
  83. package/dist/cjs/user/sequelize.d.ts +46 -0
  84. package/dist/cjs/user/types.cjs +2 -0
  85. package/dist/cjs/user/types.d.ts +11 -0
  86. package/dist/esm/api-module.d.ts +45 -0
  87. package/dist/esm/api-module.js +30 -0
  88. package/dist/esm/apicore-server.d.ts +288 -0
  89. package/dist/esm/apicore-server.js +1552 -0
  90. package/dist/esm/auth-api/auth-module.d.ts +116 -0
  91. package/dist/esm/auth-api/auth-module.js +1246 -0
  92. package/dist/esm/auth-api/compat-auth-storage.d.ts +57 -0
  93. package/dist/esm/auth-api/compat-auth-storage.js +124 -0
  94. package/dist/esm/auth-api/mem-auth-store.d.ts +68 -0
  95. package/dist/esm/auth-api/mem-auth-store.js +117 -0
  96. package/dist/esm/auth-api/module.d.ts +20 -0
  97. package/dist/esm/auth-api/module.js +21 -0
  98. package/dist/esm/auth-api/schemas.d.ts +21 -0
  99. package/dist/esm/auth-api/schemas.js +168 -0
  100. package/dist/esm/auth-api/sql-auth-store.d.ts +87 -0
  101. package/dist/esm/auth-api/sql-auth-store.js +175 -0
  102. package/dist/esm/auth-api/storage.d.ts +38 -0
  103. package/dist/esm/auth-api/storage.js +98 -0
  104. package/dist/esm/auth-api/types.d.ts +34 -0
  105. package/dist/esm/auth-api/types.js +1 -0
  106. package/dist/esm/auth-api/user-id.d.ts +5 -0
  107. package/dist/esm/auth-api/user-id.js +41 -0
  108. package/dist/esm/auth-cookie-options.d.ts +13 -0
  109. package/dist/esm/auth-cookie-options.js +63 -0
  110. package/dist/esm/base/client-info.d.ts +27 -0
  111. package/dist/esm/base/client-info.js +282 -0
  112. package/dist/esm/base/error-utils.d.ts +16 -0
  113. package/dist/esm/base/error-utils.js +44 -0
  114. package/dist/esm/base/request-utils.d.ts +8 -0
  115. package/dist/esm/base/request-utils.js +23 -0
  116. package/dist/esm/index.d.ts +34 -0
  117. package/dist/esm/index.js +21 -0
  118. package/dist/esm/limiter/auth-rate-limiter.d.ts +12 -0
  119. package/dist/esm/limiter/auth-rate-limiter.js +32 -0
  120. package/dist/esm/limiter/fixed-window.d.ts +11 -0
  121. package/dist/esm/limiter/fixed-window.js +37 -0
  122. package/dist/esm/oauth/base.d.ts +17 -0
  123. package/dist/esm/oauth/base.js +3 -0
  124. package/dist/esm/oauth/memory.d.ts +22 -0
  125. package/dist/esm/oauth/memory.js +128 -0
  126. package/dist/esm/oauth/models.d.ts +50 -0
  127. package/dist/esm/oauth/models.js +38 -0
  128. package/dist/esm/oauth/sequelize.d.ts +30 -0
  129. package/dist/esm/oauth/sequelize.js +148 -0
  130. package/dist/esm/oauth/types.d.ts +51 -0
  131. package/dist/esm/oauth/types.js +2 -0
  132. package/dist/esm/passkey/base.d.ts +28 -0
  133. package/dist/esm/passkey/base.js +3 -0
  134. package/dist/esm/passkey/config.d.ts +2 -0
  135. package/dist/esm/passkey/config.js +23 -0
  136. package/dist/esm/passkey/memory.d.ts +34 -0
  137. package/dist/esm/passkey/memory.js +119 -0
  138. package/dist/esm/passkey/models.d.ts +34 -0
  139. package/dist/esm/passkey/models.js +135 -0
  140. package/dist/esm/passkey/sequelize.d.ts +42 -0
  141. package/dist/esm/passkey/sequelize.js +122 -0
  142. package/dist/esm/passkey/service.d.ts +21 -0
  143. package/dist/esm/passkey/service.js +376 -0
  144. package/dist/esm/passkey/types.d.ts +84 -0
  145. package/dist/esm/passkey/types.js +1 -0
  146. package/dist/esm/sequelize-utils.d.ts +8 -0
  147. package/dist/esm/sequelize-utils.js +47 -0
  148. package/dist/esm/token/base.d.ts +46 -0
  149. package/dist/esm/token/base.js +113 -0
  150. package/dist/esm/token/memory.d.ts +29 -0
  151. package/dist/esm/token/memory.js +230 -0
  152. package/dist/esm/token/sequelize.d.ts +58 -0
  153. package/dist/esm/token/sequelize.js +396 -0
  154. package/dist/esm/token/types.d.ts +34 -0
  155. package/dist/esm/token/types.js +1 -0
  156. package/dist/esm/upload/memory.d.ts +17 -0
  157. package/dist/esm/upload/memory.js +86 -0
  158. package/dist/esm/upload/tus-module.d.ts +38 -0
  159. package/dist/esm/upload/tus-module.js +266 -0
  160. package/dist/esm/upload/types.d.ts +28 -0
  161. package/dist/esm/upload/types.js +1 -0
  162. package/dist/esm/user/base.d.ts +36 -0
  163. package/dist/esm/user/base.js +46 -0
  164. package/dist/esm/user/memory.d.ts +37 -0
  165. package/dist/esm/user/memory.js +190 -0
  166. package/dist/esm/user/sequelize.d.ts +46 -0
  167. package/dist/esm/user/sequelize.js +188 -0
  168. package/dist/esm/user/types.d.ts +11 -0
  169. package/dist/esm/user/types.js +1 -0
  170. package/docs/swagger/openapi.json +2162 -0
  171. package/package.json +131 -0
@@ -0,0 +1,1246 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { isoBase64URL } from '@simplewebauthn/server/helpers';
3
+ import { ApiError } from '../apicore-server.js';
4
+ import { buildAuthCookieOptions } from '../auth-cookie-options.js';
5
+ import { BaseAuthModule } from './module.js';
6
+ import { loginBodySchema, refreshBodySchema, logoutBodySchema, whoamiBodySchema, passkeyChallengeBodySchema, passkeyVerifyBodySchema, passkeyCredentialParamsSchema, impersonateBodySchema, deleteImpersonationQuerySchema, oauthProviderParamsSchema, oauthStartBodySchema, oauthAuthorizeBodySchema, oauthTokenBodySchema } from './schemas.js';
7
+ import { BaseAuthAdapter } from './storage.js';
8
+ function isAuthIdentifier(value) {
9
+ if (typeof value === 'string')
10
+ return value.length > 0;
11
+ if (typeof value === 'number')
12
+ return Number.isFinite(value);
13
+ return false;
14
+ }
15
+ function toStringOrNull(value) {
16
+ if (typeof value === 'string') {
17
+ const trimmed = value.trim();
18
+ return trimmed.length > 0 ? trimmed : null;
19
+ }
20
+ return null;
21
+ }
22
+ function toScopeArray(scope) {
23
+ if (typeof scope === 'string') {
24
+ return scope.split(/\s+/).filter((entry) => entry.length > 0);
25
+ }
26
+ if (Array.isArray(scope) && scope.every((entry) => typeof entry === 'string')) {
27
+ return scope.filter((entry) => entry.length > 0);
28
+ }
29
+ return undefined;
30
+ }
31
+ function base64UrlEncode(buffer) {
32
+ return buffer.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
33
+ }
34
+ function sha256Base64Url(value) {
35
+ const hash = createHash('sha256').update(value).digest();
36
+ return base64UrlEncode(hash);
37
+ }
38
+ class AuthModule extends BaseAuthModule {
39
+ get server() {
40
+ return super.server;
41
+ }
42
+ set server(value) {
43
+ super.server = value;
44
+ }
45
+ constructor(options = {}) {
46
+ super({ namespace: options.namespace ?? AuthModule.defaultNamespace });
47
+ this.defaultDomain = options.defaultDomain;
48
+ this.canImpersonateHook = options.canImpersonate;
49
+ this.rateLimitHook = options.rateLimit;
50
+ this.allowInsecurePkcePlain = options.allowInsecurePkcePlain ?? false;
51
+ }
52
+ get storage() {
53
+ return this.server.getAuthStorage();
54
+ }
55
+ async canImpersonate(apiReq, realUser, targetUser) {
56
+ const realUserId = this.storage.getUserId(realUser);
57
+ const effectiveUserId = this.storage.getUserId(targetUser);
58
+ if (this.canImpersonateHook) {
59
+ const allowed = await this.canImpersonateHook({
60
+ apiReq,
61
+ realUser,
62
+ realUserId,
63
+ targetUser,
64
+ effectiveUserId
65
+ });
66
+ if (allowed === true)
67
+ return true;
68
+ if (allowed === false)
69
+ return false;
70
+ }
71
+ const storageWithHook = this.storage;
72
+ if (typeof storageWithHook.canImpersonate === 'function') {
73
+ const allowed = await storageWithHook.canImpersonate({ realUserId, effectiveUserId });
74
+ return !!allowed;
75
+ }
76
+ return String(realUserId) === String(effectiveUserId);
77
+ }
78
+ async ensureImpersonationAllowed(apiReq, realUser, targetUser) {
79
+ const permitted = await this.canImpersonate(apiReq, realUser, targetUser);
80
+ if (!permitted) {
81
+ throw new ApiError({ code: 403, message: 'Impersonation is not permitted' });
82
+ }
83
+ }
84
+ buildTokenPayload(user, metadata = {}) {
85
+ return {
86
+ ...metadata,
87
+ uid: String(this.storage.getUserId(user))
88
+ };
89
+ }
90
+ buildTokenMetadata(metadata = {}) {
91
+ const scope = metadata.scope;
92
+ const domain = metadata.domain ?? this.defaultDomain ?? '';
93
+ let fingerprint = metadata.fingerprint ?? metadata.clientId ?? '';
94
+ if (typeof fingerprint === 'string') {
95
+ fingerprint = fingerprint.trim();
96
+ }
97
+ else {
98
+ fingerprint = '';
99
+ }
100
+ // Avoid every client sharing the empty-string fingerprint which collapses sessions into one bucket.
101
+ if (!fingerprint) {
102
+ fingerprint = `srv-${randomUUID()}`;
103
+ }
104
+ return {
105
+ domain,
106
+ fingerprint,
107
+ label: metadata.label ?? (Array.isArray(scope) ? scope.join(' ') : typeof scope === 'string' ? scope : ''),
108
+ clientId: metadata.clientId,
109
+ ruid: metadata.ruid,
110
+ scope: metadata.scope,
111
+ browser: metadata.browser ?? '',
112
+ device: metadata.device ?? '',
113
+ ip: metadata.ip ?? '',
114
+ os: metadata.os ?? '',
115
+ refreshTtlSeconds: this.normalizeRefreshTtlSeconds(metadata.refreshTtlSeconds),
116
+ sessionCookie: typeof metadata.sessionCookie === 'boolean' ? metadata.sessionCookie : undefined,
117
+ loginType: metadata.loginType
118
+ };
119
+ }
120
+ enrichTokenMetadata(apiReq, metadata = {}) {
121
+ const enriched = { ...metadata };
122
+ const clientInfo = apiReq.getClientInfo();
123
+ if (!enriched.ip && clientInfo.ip) {
124
+ enriched.ip = clientInfo.ip;
125
+ }
126
+ if (!enriched.browser && clientInfo.browser) {
127
+ enriched.browser = clientInfo.browser;
128
+ }
129
+ if (!enriched.os && clientInfo.os) {
130
+ enriched.os = clientInfo.os;
131
+ }
132
+ if (!enriched.device && clientInfo.device) {
133
+ enriched.device = clientInfo.device;
134
+ }
135
+ return enriched;
136
+ }
137
+ sessionRefreshTtlSeconds() {
138
+ return Math.max(1, this.server.config.sessionRefreshExpiry ?? 24 * 60 * 60);
139
+ }
140
+ normalizeRefreshTtlSeconds(value) {
141
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
142
+ return Math.floor(value);
143
+ }
144
+ if (typeof value === 'string') {
145
+ const trimmed = value.trim();
146
+ if (!trimmed) {
147
+ return undefined;
148
+ }
149
+ const parsed = Number(trimmed);
150
+ if (Number.isFinite(parsed) && parsed > 0) {
151
+ return Math.floor(parsed);
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+ resolveSessionPreferences(candidate) {
157
+ if (candidate === undefined || candidate === null) {
158
+ return {};
159
+ }
160
+ if (typeof candidate === 'boolean') {
161
+ return candidate ? {} : { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
162
+ }
163
+ if (typeof candidate === 'number') {
164
+ if (candidate === 0) {
165
+ return { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
166
+ }
167
+ const ttl = this.normalizeRefreshTtlSeconds(candidate);
168
+ return ttl ? { sessionCookie: false, refreshTtlSeconds: ttl } : {};
169
+ }
170
+ if (typeof candidate === 'string') {
171
+ const trimmed = candidate.trim();
172
+ if (!trimmed) {
173
+ return {};
174
+ }
175
+ if (/^(true|yes|1)$/i.test(trimmed)) {
176
+ return {};
177
+ }
178
+ if (/^(false|no|0)$/i.test(trimmed)) {
179
+ return { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
180
+ }
181
+ const ttl = this.normalizeRefreshTtlSeconds(trimmed);
182
+ return ttl ? { sessionCookie: false, refreshTtlSeconds: ttl } : {};
183
+ }
184
+ return {};
185
+ }
186
+ mergeSessionPreferences(...prefs) {
187
+ const merged = {};
188
+ for (const pref of prefs) {
189
+ if (!pref) {
190
+ continue;
191
+ }
192
+ if (merged.sessionCookie === undefined && pref.sessionCookie !== undefined) {
193
+ merged.sessionCookie = pref.sessionCookie;
194
+ }
195
+ if (merged.refreshTtlSeconds === undefined && typeof pref.refreshTtlSeconds === 'number') {
196
+ merged.refreshTtlSeconds = pref.refreshTtlSeconds;
197
+ }
198
+ }
199
+ return merged;
200
+ }
201
+ sessionPrefsFromRecord(record) {
202
+ if (!record) {
203
+ return {};
204
+ }
205
+ const carrier = record;
206
+ const prefs = {};
207
+ if (typeof carrier.sessionCookie === 'boolean') {
208
+ prefs.sessionCookie = carrier.sessionCookie;
209
+ }
210
+ const ttl = this.normalizeRefreshTtlSeconds(carrier.refreshTtlSeconds);
211
+ if (ttl !== undefined) {
212
+ prefs.refreshTtlSeconds = ttl;
213
+ }
214
+ return prefs;
215
+ }
216
+ validateCredentialId(apiReq) {
217
+ const paramId = toStringOrNull(apiReq.req.params?.credentialId);
218
+ const bodyId = toStringOrNull(apiReq.req.body?.credentialId);
219
+ const credentialId = paramId ?? bodyId;
220
+ if (!credentialId) {
221
+ throw new ApiError({ code: 400, message: 'credentialId is required' });
222
+ }
223
+ try {
224
+ isoBase64URL.toBuffer(credentialId);
225
+ }
226
+ catch {
227
+ throw new ApiError({ code: 400, message: 'Invalid credentialId' });
228
+ }
229
+ return credentialId;
230
+ }
231
+ normalizeCredentialId(value) {
232
+ if (Buffer.isBuffer(value)) {
233
+ return value;
234
+ }
235
+ try {
236
+ return Buffer.from(isoBase64URL.toBuffer(value));
237
+ }
238
+ catch {
239
+ try {
240
+ return Buffer.from(value, 'base64');
241
+ }
242
+ catch {
243
+ return Buffer.from(value);
244
+ }
245
+ }
246
+ }
247
+ toIsoDate(value) {
248
+ if (!value) {
249
+ return undefined;
250
+ }
251
+ const date = value instanceof Date ? value : new Date(value);
252
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
253
+ }
254
+ cookieOptions(apiReq) {
255
+ return buildAuthCookieOptions(this.server.config, apiReq.req);
256
+ }
257
+ setJwtCookies(apiReq, tokens, preferences = {}) {
258
+ const conf = this.server.config;
259
+ const options = this.cookieOptions(apiReq);
260
+ const sessionCookie = preferences.sessionCookie ?? false;
261
+ const accessMaxAge = Math.max(1, conf.accessExpiry) * 1000;
262
+ const refreshSeconds = Math.max(1, preferences.refreshTtlSeconds ?? conf.refreshExpiry);
263
+ const refreshMaxAge = refreshSeconds * 1000;
264
+ // When sessionCookie is true we omit maxAge so the browser deletes the
265
+ // cookie on close. The server-side JWT still has its own expiry, which
266
+ // limits exposure if the browser crashes without running its cleanup.
267
+ const accessOptions = sessionCookie ? options : { ...options, maxAge: accessMaxAge };
268
+ const refreshOptions = sessionCookie ? options : { ...options, maxAge: refreshMaxAge };
269
+ if (tokens.accessToken) {
270
+ apiReq.res.cookie(conf.accessCookie, tokens.accessToken, accessOptions);
271
+ }
272
+ else {
273
+ apiReq.res.clearCookie(conf.accessCookie, options);
274
+ }
275
+ if (tokens.refreshToken) {
276
+ apiReq.res.cookie(conf.refreshCookie, tokens.refreshToken, refreshOptions);
277
+ }
278
+ else {
279
+ apiReq.res.clearCookie(conf.refreshCookie, options);
280
+ }
281
+ }
282
+ async issueTokens(apiReq, user, metadata = {}) {
283
+ const conf = this.server.config;
284
+ const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
285
+ const payload = {
286
+ ...this.buildTokenPayload(user, enrichedMetadata),
287
+ jti: randomUUID()
288
+ };
289
+ const access = this.server.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
290
+ if (!access.success || !access.token) {
291
+ throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
292
+ }
293
+ const refresh = this.server.jwtSign(payload, conf.refreshSecret, conf.refreshExpiry);
294
+ if (!refresh.success || !refresh.token) {
295
+ throw new ApiError({ code: 500, message: refresh.error ?? 'Unable to sign refresh token' });
296
+ }
297
+ const meta = this.buildTokenMetadata(enrichedMetadata);
298
+ const wantsSessionCookie = meta.sessionCookie === true;
299
+ const customRefreshTtl = typeof meta.refreshTtlSeconds === 'number' ? meta.refreshTtlSeconds : undefined;
300
+ const sessionRefreshTtl = this.sessionRefreshTtlSeconds();
301
+ const defaultRefreshTtl = Math.max(1, conf.refreshExpiry);
302
+ const refreshLifetimeSeconds = wantsSessionCookie
303
+ ? Math.max(1, customRefreshTtl ?? sessionRefreshTtl)
304
+ : Math.max(1, customRefreshTtl ?? defaultRefreshTtl);
305
+ const storedRefreshTtlSeconds = wantsSessionCookie
306
+ ? Math.max(1, customRefreshTtl ?? sessionRefreshTtl)
307
+ : customRefreshTtl;
308
+ meta.refreshTtlSeconds =
309
+ typeof storedRefreshTtlSeconds === 'number' && storedRefreshTtlSeconds > 0
310
+ ? storedRefreshTtlSeconds
311
+ : undefined;
312
+ meta.sessionCookie = wantsSessionCookie;
313
+ const expiresAt = metadata.expires ?? new Date(Date.now() + refreshLifetimeSeconds * 1000);
314
+ const issuedAt = new Date();
315
+ const lastSeenAt = issuedAt;
316
+ await this.storage.storeToken({
317
+ accessToken: access.token,
318
+ refreshToken: refresh.token,
319
+ userId: payload.uid,
320
+ ruid: meta.ruid,
321
+ domain: meta.domain,
322
+ fingerprint: meta.fingerprint,
323
+ label: meta.label,
324
+ browser: meta.browser,
325
+ device: meta.device,
326
+ ip: meta.ip,
327
+ os: meta.os,
328
+ clientId: meta.clientId,
329
+ scope: meta.scope,
330
+ loginType: meta.loginType,
331
+ refreshTtlSeconds: meta.refreshTtlSeconds,
332
+ expires: expiresAt,
333
+ issuedAt,
334
+ lastSeenAt
335
+ });
336
+ this.setJwtCookies(apiReq, {
337
+ accessToken: access.token,
338
+ refreshToken: refresh.token
339
+ }, {
340
+ sessionCookie: wantsSessionCookie,
341
+ refreshTtlSeconds: refreshLifetimeSeconds
342
+ });
343
+ return {
344
+ accessToken: access.token,
345
+ refreshToken: refresh.token
346
+ };
347
+ }
348
+ assertAuthReady() {
349
+ const conf = this.server.config;
350
+ if (!conf.accessSecret || !conf.refreshSecret) {
351
+ throw new ApiError({ code: 500, message: 'Auth secrets are not configured' });
352
+ }
353
+ }
354
+ parseLoginBody(apiReq) {
355
+ const body = (apiReq.req.body ?? {});
356
+ // login and password are guaranteed present and non-empty by JSON Schema
357
+ const login = body.login;
358
+ const password = body.password;
359
+ const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
360
+ return {
361
+ login,
362
+ password,
363
+ domain: toStringOrNull(body.domain) ?? undefined,
364
+ fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
365
+ label: toStringOrNull(body.label) ?? undefined,
366
+ browser: toStringOrNull(body.browser) ?? undefined,
367
+ device: toStringOrNull(body.device) ?? undefined,
368
+ ip: toStringOrNull(body.ip) ?? undefined,
369
+ os: toStringOrNull(body.os) ?? undefined,
370
+ loginType: 'credentials',
371
+ ...sessionPrefs
372
+ };
373
+ }
374
+ parseImpersonationRequest(apiReq) {
375
+ const body = (apiReq.req.body ?? {});
376
+ const targetIdentifier = this.resolveImpersonationIdentifier(body);
377
+ const metadata = this.buildImpersonationMetadata(body);
378
+ return { targetIdentifier, metadata };
379
+ }
380
+ resolveImpersonationIdentifier(body) {
381
+ if (isAuthIdentifier(body.userId)) {
382
+ return body.userId;
383
+ }
384
+ const login = toStringOrNull(body.login);
385
+ if (login) {
386
+ return login;
387
+ }
388
+ throw new ApiError({ code: 400, message: 'userId or login is required' });
389
+ }
390
+ buildImpersonationMetadata(body) {
391
+ const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
392
+ return {
393
+ domain: toStringOrNull(body.domain) ?? undefined,
394
+ fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
395
+ label: toStringOrNull(body.label) ?? undefined,
396
+ browser: toStringOrNull(body.browser) ?? undefined,
397
+ device: toStringOrNull(body.device) ?? undefined,
398
+ ip: toStringOrNull(body.ip) ?? undefined,
399
+ os: toStringOrNull(body.os) ?? undefined,
400
+ clientId: toStringOrNull(body.clientId) ?? undefined,
401
+ scope: toScopeArray(body.scope),
402
+ loginType: toStringOrNull(body.loginType) ?? undefined,
403
+ ...sessionPrefs
404
+ };
405
+ }
406
+ async getUserOrThrow(identifier, errorMessage) {
407
+ const user = await this.storage.getUser(identifier);
408
+ if (!user) {
409
+ throw new ApiError({ code: 403, message: errorMessage });
410
+ }
411
+ return user;
412
+ }
413
+ getRealUserIdentifier(apiReq) {
414
+ const candidate = typeof apiReq.getRealUid === 'function'
415
+ ? apiReq.getRealUid()
416
+ : (apiReq.realUid ?? null);
417
+ if (!isAuthIdentifier(candidate)) {
418
+ throw new ApiError({ code: 401, message: 'Authentication required' });
419
+ }
420
+ return candidate;
421
+ }
422
+ async resolveActorContext(apiReq) {
423
+ const realIdentifier = this.getRealUserIdentifier(apiReq);
424
+ const user = await this.getUserOrThrow(realIdentifier, 'Authenticated user not found');
425
+ return { user, userId: this.storage.getUserId(user) };
426
+ }
427
+ extractRefreshToken(apiReq, body) {
428
+ const conf = this.server.config;
429
+ if (typeof body.refreshToken === 'string') {
430
+ return body.refreshToken;
431
+ }
432
+ const logoutBody = body;
433
+ if (typeof logoutBody.token === 'string') {
434
+ return logoutBody.token;
435
+ }
436
+ const fromCookie = apiReq.req.cookies?.[conf.refreshCookie];
437
+ return typeof fromCookie === 'string' && fromCookie.length > 0 ? fromCookie : null;
438
+ }
439
+ normalizeScope(scope) {
440
+ if (typeof scope === 'string') {
441
+ return scope;
442
+ }
443
+ if (Array.isArray(scope) && scope.every((entry) => typeof entry === 'string')) {
444
+ return scope;
445
+ }
446
+ return undefined;
447
+ }
448
+ async postLogin(apiReq) {
449
+ await this.applyRateLimit(apiReq, 'login');
450
+ this.assertAuthReady();
451
+ const { login, password, ...metadata } = this.parseLoginBody(apiReq);
452
+ const user = await this.storage.getUser(login);
453
+ const hash = user ? this.storage.getUserPasswordHash(user) : '';
454
+ // Reject users with no password hash (e.g. OAuth/passkey-only accounts) before
455
+ // calling verifyPassword, since bcrypt behaviour on an empty hash is undefined.
456
+ const verified = user && hash ? await this.storage.verifyPassword(password, hash) : false;
457
+ if (!user || !verified) {
458
+ throw new ApiError({
459
+ code: 400,
460
+ message: 'Invalid credentials'
461
+ });
462
+ }
463
+ const pair = await this.issueTokens(apiReq, user, metadata);
464
+ const publicUser = this.storage.filterUser(user);
465
+ return [200, { ...pair, user: publicUser }];
466
+ }
467
+ async postRefresh(apiReq) {
468
+ this.assertAuthReady();
469
+ const body = (apiReq.req.body ?? {});
470
+ const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
471
+ const providedToken = this.extractRefreshToken(apiReq, body);
472
+ if (!providedToken) {
473
+ throw new ApiError({ code: 401, message: 'Missing refresh token' });
474
+ }
475
+ const stored = await this.storage.getToken({ refreshToken: providedToken });
476
+ if (!stored) {
477
+ throw new ApiError({ code: 401, message: 'Invalid refresh token' });
478
+ }
479
+ const verify = this.server.jwtVerify(providedToken, this.server.config.refreshSecret);
480
+ if (!verify.success || !verify.data) {
481
+ const expired = 'expired' in verify && verify.expired;
482
+ throw new ApiError({
483
+ code: expired ? 403 : 401,
484
+ message: verify.error ?? 'Unable to verify refresh token'
485
+ });
486
+ }
487
+ // Delete the token immediately after verification to narrow the TOCTOU window.
488
+ // This must happen before the slower getUserOrThrow call.
489
+ const deleted = await this.storage.deleteToken({ refreshToken: providedToken });
490
+ if (deleted === 0) {
491
+ // Another concurrent request already consumed this refresh token.
492
+ throw new ApiError({ code: 401, message: 'Invalid refresh token' });
493
+ }
494
+ const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
495
+ const metadata = {
496
+ domain: stored.domain,
497
+ fingerprint: stored.fingerprint,
498
+ label: stored.label,
499
+ clientId: stored.clientId,
500
+ scope: stored.scope,
501
+ browser: stored.browser,
502
+ device: stored.device,
503
+ ip: stored.ip,
504
+ os: stored.os,
505
+ loginType: stored.loginType,
506
+ refreshTtlSeconds: sessionPrefs.refreshTtlSeconds ?? stored.refreshTtlSeconds,
507
+ sessionCookie: sessionPrefs.sessionCookie ?? stored.sessionCookie
508
+ };
509
+ const pair = await this.issueTokens(apiReq, user, metadata);
510
+ const publicUser = this.storage.filterUser(user);
511
+ return [200, { ...pair, user: publicUser }];
512
+ }
513
+ async postLogout(apiReq) {
514
+ const body = (apiReq.req.body ?? {});
515
+ const refreshToken = this.extractRefreshToken(apiReq, body);
516
+ if (!refreshToken) {
517
+ throw new ApiError({ code: 401, message: 'Not logged in' });
518
+ }
519
+ this.setJwtCookies(apiReq, { accessToken: null, refreshToken: null });
520
+ const revoked = await this.storage.deleteToken({ refreshToken });
521
+ return [200, { revoked }];
522
+ }
523
+ async postWhoAmI(apiReq) {
524
+ const body = (apiReq.req.body ?? {});
525
+ const refreshToken = this.extractRefreshToken(apiReq, body);
526
+ if (!refreshToken) {
527
+ throw new ApiError({ code: 401, message: 'Missing refresh token' });
528
+ }
529
+ const stored = await this.storage.getToken({ refreshToken });
530
+ if (!stored) {
531
+ throw new ApiError({ code: 401, message: 'Invalid refresh token' });
532
+ }
533
+ const verify = this.server.jwtVerify(refreshToken, this.server.config.refreshSecret);
534
+ if (!verify.success || !verify.data) {
535
+ const expired = 'expired' in verify && verify.expired;
536
+ throw new ApiError({
537
+ code: expired ? 403 : 401,
538
+ message: verify.error ?? 'Unable to verify refresh token'
539
+ });
540
+ }
541
+ const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
542
+ const conf = this.server.config;
543
+ const hasAccessToken = typeof apiReq.req.headers.authorization === 'string' ||
544
+ (typeof apiReq.req.cookies?.[conf.accessCookie] === 'string' &&
545
+ apiReq.req.cookies[conf.accessCookie].trim().length > 0);
546
+ const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
547
+ if (shouldRefresh) {
548
+ if (typeof this.storage.updateToken !== 'function' || !this.storageImplements('updateToken')) {
549
+ throw new ApiError({ code: 501, message: 'Token update storage is not configured' });
550
+ }
551
+ // Sign a new access token without embedding stored token secrets into the JWT payload.
552
+ const metadata = {
553
+ ruid: stored.ruid,
554
+ domain: stored.domain,
555
+ fingerprint: stored.fingerprint,
556
+ label: stored.label,
557
+ clientId: stored.clientId,
558
+ scope: stored.scope,
559
+ browser: stored.browser,
560
+ device: stored.device,
561
+ ip: stored.ip,
562
+ os: stored.os,
563
+ loginType: stored.loginType,
564
+ refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds),
565
+ sessionCookie: stored.sessionCookie
566
+ };
567
+ const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
568
+ const access = this.server.jwtSign(this.buildTokenPayload(user, enrichedMetadata), conf.accessSecret, conf.accessExpiry);
569
+ if (!access.success || !access.token) {
570
+ throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
571
+ }
572
+ const updated = await this.storage.updateToken({
573
+ refreshToken,
574
+ accessToken: access.token,
575
+ lastSeenAt: new Date()
576
+ });
577
+ if (!updated) {
578
+ throw new ApiError({ code: 500, message: 'Unable to persist refreshed access token' });
579
+ }
580
+ const cookiePrefs = this.mergeSessionPreferences({
581
+ sessionCookie: stored.sessionCookie,
582
+ refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
583
+ });
584
+ const refreshTtlForCookie = cookiePrefs.sessionCookie
585
+ ? (cookiePrefs.refreshTtlSeconds ?? this.sessionRefreshTtlSeconds())
586
+ : cookiePrefs.refreshTtlSeconds;
587
+ this.setJwtCookies(apiReq, { accessToken: access.token, refreshToken }, { sessionCookie: cookiePrefs.sessionCookie ?? false, refreshTtlSeconds: refreshTtlForCookie });
588
+ }
589
+ const tokenClaims = verify.data;
590
+ const effectiveUserId = this.storage.getUserId(user);
591
+ const effectiveId = String(effectiveUserId);
592
+ const rawRealId = stored.ruid ?? tokenClaims.ruid;
593
+ const normalizedRealId = rawRealId === undefined || rawRealId === null ? null : String(rawRealId).trim() || null;
594
+ const isImpersonating = normalizedRealId !== null && normalizedRealId !== effectiveId;
595
+ let realUser;
596
+ let realUserId;
597
+ if (isImpersonating && normalizedRealId !== null) {
598
+ const realUserEntity = await this.getUserOrThrow(normalizedRealId, 'Real user not found');
599
+ realUser = this.storage.filterUser(realUserEntity);
600
+ realUserId = this.storage.getUserId(realUserEntity);
601
+ }
602
+ return [
603
+ 200,
604
+ {
605
+ user: this.storage.filterUser(user),
606
+ isImpersonating,
607
+ realUser,
608
+ realUserId
609
+ }
610
+ ];
611
+ }
612
+ async postPasskeyChallenge(apiReq) {
613
+ await this.applyRateLimit(apiReq, 'passkey-challenge');
614
+ if (typeof this.storage.createPasskeyChallenge !== 'function') {
615
+ throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
616
+ }
617
+ const body = (apiReq.req.body ?? {});
618
+ // action is guaranteed to be 'register' | 'authenticate' by JSON Schema
619
+ const action = body.action;
620
+ const params = {
621
+ action: action,
622
+ login: toStringOrNull(body.login) ?? undefined,
623
+ userId: isAuthIdentifier(body.userId) ? body.userId : undefined
624
+ };
625
+ const challenge = await this.storage.createPasskeyChallenge(params);
626
+ return [200, challenge];
627
+ }
628
+ async postPasskeyVerify(apiReq) {
629
+ if (typeof this.storage.verifyPasskeyResponse !== 'function') {
630
+ throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
631
+ }
632
+ const body = (apiReq.req.body ?? {});
633
+ const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
634
+ // expectedChallenge (string) and response (object) are guaranteed by JSON Schema
635
+ const expectedChallenge = body.expectedChallenge;
636
+ const response = body.response;
637
+ const rawMetadata = {
638
+ domain: toStringOrNull(body.domain) ?? undefined,
639
+ fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
640
+ label: toStringOrNull(body.label) ?? undefined,
641
+ browser: toStringOrNull(body.browser) ?? undefined,
642
+ device: toStringOrNull(body.device) ?? undefined,
643
+ ip: toStringOrNull(body.ip) ?? undefined,
644
+ os: toStringOrNull(body.os) ?? undefined
645
+ };
646
+ const clientInfo = apiReq.getClientInfo();
647
+ const userAgent = toStringOrNull(body.userAgent) ?? (clientInfo.ua ? clientInfo.ua : null);
648
+ const requestMetadata = this.enrichTokenMetadata(apiReq, rawMetadata);
649
+ const params = {
650
+ expectedChallenge,
651
+ response: response,
652
+ login: toStringOrNull(body.login) ?? undefined,
653
+ userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
654
+ userAgent: userAgent ?? undefined,
655
+ ...requestMetadata,
656
+ ...sessionPrefs
657
+ };
658
+ const result = await this.storage.verifyPasskeyResponse(params);
659
+ if (!result.verified) {
660
+ throw new ApiError({ code: 401, message: 'Passkey verification failed' });
661
+ }
662
+ const user = await this.getUserFromPasskey(result, params);
663
+ if (result.tokens?.accessToken && result.tokens.refreshToken) {
664
+ const storagePrefs = this.sessionPrefsFromRecord(result);
665
+ const cookiePrefs = this.mergeSessionPreferences(sessionPrefs, storagePrefs);
666
+ const refreshTtlForCookie = cookiePrefs.sessionCookie
667
+ ? (cookiePrefs.refreshTtlSeconds ?? this.sessionRefreshTtlSeconds())
668
+ : cookiePrefs.refreshTtlSeconds;
669
+ this.setJwtCookies(apiReq, { accessToken: result.tokens.accessToken, refreshToken: result.tokens.refreshToken }, { sessionCookie: cookiePrefs.sessionCookie ?? false, refreshTtlSeconds: refreshTtlForCookie });
670
+ const publicUser = this.storage.filterUser(user);
671
+ return [200, { ...result.tokens, user: publicUser }];
672
+ }
673
+ const extras = result;
674
+ const extrasPrefs = this.sessionPrefsFromRecord(extras);
675
+ const metadataPrefs = this.mergeSessionPreferences(sessionPrefs, extrasPrefs);
676
+ const metadata = {
677
+ domain: toStringOrNull(extras.domain) ?? params.domain,
678
+ fingerprint: toStringOrNull(extras.fingerprint) ?? params.fingerprint,
679
+ label: toStringOrNull(extras.label) ?? params.label,
680
+ browser: toStringOrNull(extras.browser) ?? params.browser,
681
+ device: toStringOrNull(extras.device) ?? params.device,
682
+ ip: toStringOrNull(extras.ip) ?? params.ip,
683
+ os: toStringOrNull(extras.os) ?? params.os,
684
+ loginType: 'passkey',
685
+ ...metadataPrefs
686
+ };
687
+ const tokens = await this.issueTokens(apiReq, user, metadata);
688
+ const publicUser = this.storage.filterUser(user);
689
+ return [200, { ...tokens, user: publicUser }];
690
+ }
691
+ async getPasskeys(apiReq) {
692
+ if (typeof this.storage.listUserCredentials !== 'function') {
693
+ throw new ApiError({ code: 501, message: 'Passkey credential listing is not configured' });
694
+ }
695
+ const { userId } = await this.resolveActorContext(apiReq);
696
+ const credentials = await this.storage.listUserCredentials(userId);
697
+ const safeCredentials = credentials.map((credential) => {
698
+ const bufferId = this.normalizeCredentialId(credential.credentialId);
699
+ return {
700
+ id: isoBase64URL.fromBuffer(new Uint8Array(bufferId)),
701
+ transports: credential.transports,
702
+ backedUp: credential.backedUp,
703
+ deviceType: credential.deviceType,
704
+ createdAt: this.toIsoDate(credential.createdAt),
705
+ updatedAt: this.toIsoDate(credential.updatedAt)
706
+ };
707
+ });
708
+ return [200, { credentials: safeCredentials }];
709
+ }
710
+ async deletePasskey(apiReq) {
711
+ if (typeof this.storage.listUserCredentials !== 'function' ||
712
+ typeof this.storage.deletePasskeyCredential !== 'function') {
713
+ throw new ApiError({ code: 501, message: 'Passkey credential management is not configured' });
714
+ }
715
+ const { userId } = await this.resolveActorContext(apiReq);
716
+ const credentialId = this.validateCredentialId(apiReq);
717
+ const bufferId = Buffer.from(isoBase64URL.toBuffer(credentialId));
718
+ const credentials = await this.storage.listUserCredentials(userId);
719
+ const owns = credentials.some((credential) => {
720
+ const candidateId = this.normalizeCredentialId(credential.credentialId);
721
+ return isoBase64URL.fromBuffer(new Uint8Array(candidateId)) === credentialId;
722
+ });
723
+ if (!owns) {
724
+ throw new ApiError({ code: 404, message: 'Passkey not found' });
725
+ }
726
+ const deleted = await this.storage.deletePasskeyCredential(bufferId);
727
+ return [200, { deleted }];
728
+ }
729
+ async postImpersonation(apiReq) {
730
+ this.assertAuthReady();
731
+ const { targetIdentifier, metadata } = this.parseImpersonationRequest(apiReq);
732
+ const actor = await this.resolveActorContext(apiReq);
733
+ const targetUser = await this.getUserOrThrow(targetIdentifier, 'Target user not found');
734
+ await this.ensureImpersonationAllowed(apiReq, actor.user, targetUser);
735
+ const impersonationMetadata = {
736
+ ...metadata,
737
+ ruid: String(actor.userId),
738
+ loginType: metadata.loginType ?? 'impersonation'
739
+ };
740
+ const tokens = await this.issueTokens(apiReq, targetUser, impersonationMetadata);
741
+ const publicUser = this.storage.filterUser(targetUser);
742
+ return [200, { ...tokens, user: publicUser }];
743
+ }
744
+ async deleteImpersonation(apiReq) {
745
+ this.assertAuthReady();
746
+ if (!apiReq.isImpersonating()) {
747
+ throw new ApiError({ code: 400, message: 'Not currently impersonating' });
748
+ }
749
+ // Revoke the active impersonation refresh token before issuing new real-user tokens
750
+ // so that a captured impersonation token cannot be reused after impersonation ends.
751
+ const impersonationRefreshToken = this.extractRefreshToken(apiReq, {});
752
+ if (impersonationRefreshToken) {
753
+ await this.storage.deleteToken({ refreshToken: impersonationRefreshToken });
754
+ }
755
+ const actor = await this.resolveActorContext(apiReq);
756
+ const query = (apiReq.req.query ?? {});
757
+ const metadata = this.buildImpersonationMetadata(query);
758
+ metadata.loginType = metadata.loginType ?? 'impersonation-end';
759
+ const tokens = await this.issueTokens(apiReq, actor.user, metadata);
760
+ const publicUser = this.storage.filterUser(actor.user);
761
+ return [200, { ...tokens, user: publicUser }];
762
+ }
763
+ async getUserFromPasskey(result, params) {
764
+ if (result.userId !== undefined) {
765
+ return this.getUserOrThrow(result.userId, 'User not found for passkey verification');
766
+ }
767
+ if (result.login) {
768
+ return this.getUserOrThrow(result.login, 'User not found for passkey verification');
769
+ }
770
+ if (params.userId !== undefined) {
771
+ return this.getUserOrThrow(params.userId, 'User not found for passkey verification');
772
+ }
773
+ if (params.login) {
774
+ return this.getUserOrThrow(params.login, 'User not found for passkey verification');
775
+ }
776
+ throw new ApiError({ code: 500, message: 'Passkey response missing user reference' });
777
+ }
778
+ async postOAuthStart(apiReq) {
779
+ if (typeof this.server.initiateOAuth !== 'function') {
780
+ throw new ApiError({ code: 501, message: 'External OAuth support is not configured' });
781
+ }
782
+ const params = {
783
+ provider: apiReq.req.params?.provider,
784
+ redirectUri: toStringOrNull(apiReq.req.body?.redirectUri) ?? undefined,
785
+ scope: this.normalizeScope(apiReq.req.body?.scope),
786
+ state: toStringOrNull(apiReq.req.body?.state) ?? undefined,
787
+ extras: typeof apiReq.req.body?.extras === 'object'
788
+ ? apiReq.req.body.extras
789
+ : undefined
790
+ };
791
+ // provider is guaranteed present and non-empty by params schema
792
+ const result = await this.server.initiateOAuth(params);
793
+ return [200, result];
794
+ }
795
+ async postOAuthCallback(apiReq) {
796
+ if (typeof this.server.completeOAuth !== 'function') {
797
+ throw new ApiError({ code: 501, message: 'External OAuth support is not configured' });
798
+ }
799
+ const params = {
800
+ provider: apiReq.req.params?.provider,
801
+ query: apiReq.req.query,
802
+ body: (apiReq.req.body ?? {})
803
+ };
804
+ const result = await this.server.completeOAuth(params);
805
+ if (result.tokens?.accessToken && result.tokens.refreshToken) {
806
+ this.setJwtCookies(apiReq, {
807
+ accessToken: result.tokens.accessToken,
808
+ refreshToken: result.tokens.refreshToken
809
+ });
810
+ }
811
+ return [200, result];
812
+ }
813
+ async postOAuthAuthorize(apiReq) {
814
+ if (typeof this.storage.getClient !== 'function' || typeof this.storage.createAuthCode !== 'function') {
815
+ throw new ApiError({ code: 501, message: 'OAuth authorization storage is not configured' });
816
+ }
817
+ await this.applyRateLimit(apiReq, 'oauth-authorize');
818
+ const body = (apiReq.req.body ?? {});
819
+ // clientId and redirectUri are guaranteed present and non-empty by JSON Schema
820
+ const clientId = body.clientId;
821
+ const redirectUri = body.redirectUri;
822
+ const scope = toScopeArray(body.scope) ?? [];
823
+ const state = toStringOrNull(body.state) ?? undefined;
824
+ const codeChallenge = toStringOrNull(body.codeChallenge) ?? undefined;
825
+ const codeChallengeMethod = toStringOrNull(body.codeChallengeMethod) ?? undefined;
826
+ const resolvedCodeChallengeMethod = this.resolvePkceChallengeMethod(codeChallengeMethod);
827
+ const client = await this.storage.getClient(clientId);
828
+ if (!client) {
829
+ throw new ApiError({ code: 400, message: 'Unknown client_id' });
830
+ }
831
+ this.assertRedirectUriAllowed(client, redirectUri);
832
+ const resolvedScope = this.resolveScope(client, scope);
833
+ const user = await this.resolveUserForOAuth(apiReq, body);
834
+ const codeRecord = await this.storage.createAuthCode({
835
+ clientId,
836
+ userId: this.storage.getUserId(user),
837
+ redirectUri,
838
+ scope: resolvedScope,
839
+ codeChallenge,
840
+ codeChallengeMethod: resolvedCodeChallengeMethod,
841
+ expiresInSeconds: 300
842
+ });
843
+ const redirect = new URL(redirectUri);
844
+ redirect.searchParams.set('code', codeRecord.code);
845
+ if (state) {
846
+ redirect.searchParams.set('state', state);
847
+ }
848
+ return [200, { code: codeRecord.code, redirectUri: redirect.toString(), state }];
849
+ }
850
+ async postOAuthToken(apiReq) {
851
+ await this.applyRateLimit(apiReq, 'oauth-token');
852
+ if (typeof this.storage.getClient !== 'function' || typeof this.storage.consumeAuthCode !== 'function') {
853
+ throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
854
+ }
855
+ const body = (apiReq.req.body ?? {});
856
+ // grant_type is guaranteed to be 'authorization_code' | 'refresh_token' by JSON Schema
857
+ const grantType = body.grant_type;
858
+ const { client, clientSecretProvided } = await this.resolveClientAuthentication(apiReq, body);
859
+ switch (grantType) {
860
+ case 'authorization_code':
861
+ return this.handleAuthorizationCodeGrant(apiReq, body, client, clientSecretProvided);
862
+ case 'refresh_token':
863
+ return this.handleRefreshTokenGrant(apiReq, body, client);
864
+ default:
865
+ throw new ApiError({ code: 400, message: `Unsupported grant_type: ${grantType}` });
866
+ }
867
+ }
868
+ async handleAuthorizationCodeGrant(apiReq, body, client, clientSecretProvided) {
869
+ const code = toStringOrNull(body.code);
870
+ const redirectUri = toStringOrNull(body.redirect_uri);
871
+ const codeVerifier = toStringOrNull(body.code_verifier);
872
+ const consumeAuthCode = this.storage.consumeAuthCode?.bind(this.storage);
873
+ if (!consumeAuthCode) {
874
+ throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
875
+ }
876
+ if (!code) {
877
+ throw new ApiError({ code: 400, message: 'code is required for authorization_code grant' });
878
+ }
879
+ if (!redirectUri) {
880
+ throw new ApiError({ code: 400, message: 'redirect_uri is required for authorization_code grant' });
881
+ }
882
+ this.assertRedirectUriAllowed(client, redirectUri);
883
+ const record = await consumeAuthCode(code, client.clientId);
884
+ if (!record) {
885
+ throw new ApiError({ code: 400, message: 'Invalid or expired authorization code' });
886
+ }
887
+ if (record.expiresAt.getTime() < Date.now()) {
888
+ throw new ApiError({ code: 400, message: 'Authorization code expired' });
889
+ }
890
+ if (record.redirectUri !== redirectUri) {
891
+ throw new ApiError({ code: 400, message: 'redirect_uri mismatch' });
892
+ }
893
+ if (record.codeChallenge) {
894
+ if (!codeVerifier) {
895
+ throw new ApiError({ code: 400, message: 'code_verifier is required for this authorization code' });
896
+ }
897
+ if (record.codeChallengeMethod === 'S256') {
898
+ if (sha256Base64Url(codeVerifier) !== record.codeChallenge) {
899
+ throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
900
+ }
901
+ }
902
+ else if (record.codeChallengeMethod === 'plain') {
903
+ if (!this.allowInsecurePkcePlain) {
904
+ throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
905
+ }
906
+ if (codeVerifier !== record.codeChallenge) {
907
+ throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
908
+ }
909
+ }
910
+ }
911
+ else if (!clientSecretProvided && (client.hasSecret ?? false)) {
912
+ throw new ApiError({ code: 400, message: 'Client authentication required when no PKCE challenge present' });
913
+ }
914
+ const user = await this.getUserOrThrow(record.userId, 'User not found');
915
+ const resolvedScope = Array.isArray(record.scope) ? record.scope : [];
916
+ const tokens = await this.issueTokens(apiReq, user, {
917
+ clientId: client.clientId,
918
+ scope: resolvedScope,
919
+ label: resolvedScope.join(' '),
920
+ loginType: 'oauth'
921
+ });
922
+ this.clearOAuthCookies(apiReq);
923
+ return [200, this.buildTokenResponse(tokens, client, resolvedScope)];
924
+ }
925
+ async handleRefreshTokenGrant(apiReq, body, client) {
926
+ const refreshToken = toStringOrNull(body.refresh_token);
927
+ if (!refreshToken) {
928
+ throw new ApiError({ code: 400, message: 'refresh_token is required for refresh_token grant' });
929
+ }
930
+ const stored = await this.storage.getToken({ refreshToken, clientId: client.clientId });
931
+ if (!stored) {
932
+ throw new ApiError({ code: 400, message: 'Invalid refresh_token' });
933
+ }
934
+ const verify = this.server.jwtVerify(refreshToken, this.server.config.refreshSecret);
935
+ if (!verify.success || !verify.data) {
936
+ const expired = 'expired' in verify && verify.expired;
937
+ throw new ApiError({
938
+ code: expired ? 403 : 401,
939
+ message: verify.error ?? 'Unable to verify refresh token'
940
+ });
941
+ }
942
+ if (stored.clientId && stored.clientId !== client.clientId) {
943
+ throw new ApiError({ code: 400, message: 'Refresh token issued to another client' });
944
+ }
945
+ // Delete the token immediately after verification to narrow the TOCTOU window.
946
+ const deleted = await this.storage.deleteToken({ refreshToken });
947
+ if (deleted === 0) {
948
+ throw new ApiError({ code: 401, message: 'Invalid refresh token' });
949
+ }
950
+ const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
951
+ const tokens = await this.issueTokens(apiReq, user, {
952
+ clientId: client.clientId,
953
+ scope: stored.scope,
954
+ label: Array.isArray(stored.scope) ? stored.scope.join(' ') : stored.label,
955
+ fingerprint: stored.fingerprint,
956
+ loginType: stored.loginType ?? 'oauth'
957
+ });
958
+ this.clearOAuthCookies(apiReq);
959
+ const scope = Array.isArray(stored.scope) ? stored.scope : [];
960
+ return [200, this.buildTokenResponse(tokens, client, scope)];
961
+ }
962
+ clearOAuthCookies(apiReq) {
963
+ this.setJwtCookies(apiReq, { accessToken: null, refreshToken: null });
964
+ }
965
+ buildTokenResponse(tokens, client, scope) {
966
+ const conf = this.server.config;
967
+ return {
968
+ access_token: tokens.accessToken,
969
+ refresh_token: tokens.refreshToken,
970
+ token_type: 'Bearer',
971
+ expires_in: conf.accessExpiry,
972
+ scope: scope.join(' '),
973
+ client_id: client.clientId
974
+ };
975
+ }
976
+ resolveScope(client, requested) {
977
+ const allowed = Array.isArray(client.scope) ? client.scope : [];
978
+ if (allowed.length === 0) {
979
+ return requested.length > 0 ? requested : [];
980
+ }
981
+ if (requested.length === 0) {
982
+ return allowed;
983
+ }
984
+ const filtered = requested.filter((entry) => allowed.includes(entry));
985
+ if (filtered.length === 0) {
986
+ throw new ApiError({ code: 400, message: 'Requested scope is not permitted for this client' });
987
+ }
988
+ return filtered;
989
+ }
990
+ async resolveClientAuthentication(apiReq, body) {
991
+ if (typeof this.storage.getClient !== 'function') {
992
+ throw new ApiError({ code: 501, message: 'OAuth client storage is not configured' });
993
+ }
994
+ let clientId = null;
995
+ let clientSecret = null;
996
+ let secretProvided = false;
997
+ const header = apiReq.req.headers.authorization;
998
+ if (typeof header === 'string' && header.startsWith('Basic ')) {
999
+ const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
1000
+ const idx = decoded.indexOf(':');
1001
+ if (idx === -1) {
1002
+ throw new ApiError({ code: 400, message: 'Invalid basic authorization header' });
1003
+ }
1004
+ clientId = decoded.slice(0, idx);
1005
+ clientSecret = decoded.slice(idx + 1);
1006
+ secretProvided = true;
1007
+ }
1008
+ if (!clientId) {
1009
+ clientId = toStringOrNull(body.client_id);
1010
+ }
1011
+ if (clientSecret === null && typeof body.client_secret === 'string') {
1012
+ clientSecret = body.client_secret;
1013
+ secretProvided = true;
1014
+ }
1015
+ if (!clientId) {
1016
+ throw new ApiError({ code: 400, message: 'client_id is required' });
1017
+ }
1018
+ const client = await this.storage.getClient(clientId);
1019
+ if (!client) {
1020
+ throw new ApiError({ code: 400, message: 'Unknown client_id' });
1021
+ }
1022
+ const requiresSecret = client.hasSecret ?? false;
1023
+ if (requiresSecret) {
1024
+ if (!secretProvided) {
1025
+ throw new ApiError({ code: 400, message: 'Client authentication is required' });
1026
+ }
1027
+ const verifySecret = this.storage.verifyClientSecret;
1028
+ if (typeof verifySecret !== 'function' || !this.storageImplements('verifyClientSecret')) {
1029
+ throw new ApiError({ code: 501, message: 'OAuth client secret verification is not configured' });
1030
+ }
1031
+ const valid = await verifySecret.call(this.storage, client, clientSecret);
1032
+ if (!valid) {
1033
+ throw new ApiError({ code: 401, message: 'Invalid client credentials' });
1034
+ }
1035
+ }
1036
+ return { client, clientSecretProvided: secretProvided };
1037
+ }
1038
+ assertRedirectUriAllowed(client, redirectUri) {
1039
+ if (client.redirectUris.length === 0) {
1040
+ throw new ApiError({ code: 400, message: 'Client has no registered redirect URIs' });
1041
+ }
1042
+ if (!client.redirectUris.includes(redirectUri)) {
1043
+ throw new ApiError({ code: 400, message: 'redirect_uri not registered for client' });
1044
+ }
1045
+ }
1046
+ async resolveUserForOAuth(apiReq, body) {
1047
+ const refreshToken = this.extractRefreshToken(apiReq, body);
1048
+ if (refreshToken) {
1049
+ const verify = this.server.jwtVerify(refreshToken, this.server.config.refreshSecret);
1050
+ if (!verify.success || !verify.data) {
1051
+ throw new ApiError({ code: 401, message: 'Invalid or expired refresh token' });
1052
+ }
1053
+ const stored = await this.storage.getToken({ refreshToken });
1054
+ if (stored) {
1055
+ return this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found for authorization');
1056
+ }
1057
+ }
1058
+ const login = toStringOrNull(body.login);
1059
+ const password = toStringOrNull(body.password);
1060
+ if (login && password) {
1061
+ const user = await this.storage.getUser(login);
1062
+ const hash = user ? this.storage.getUserPasswordHash(user) : '';
1063
+ const verified = user && hash ? await this.storage.verifyPassword(password, hash) : false;
1064
+ if (!user || !verified) {
1065
+ throw new ApiError({ code: 400, message: 'Invalid credentials' });
1066
+ }
1067
+ return user;
1068
+ }
1069
+ throw new ApiError({ code: 401, message: 'Authorization requires user authentication' });
1070
+ }
1071
+ hasPasskeyService() {
1072
+ const storageHints = this.storage;
1073
+ if (storageHints.passkeyService || storageHints.passkeyStore) {
1074
+ return true;
1075
+ }
1076
+ if (storageHints.adapter?.passkeyService || storageHints.adapter?.passkeyStore) {
1077
+ return true;
1078
+ }
1079
+ return false;
1080
+ }
1081
+ hasOAuthStore() {
1082
+ const storageHints = this.storage;
1083
+ if (storageHints.oauthStore) {
1084
+ return true;
1085
+ }
1086
+ if (storageHints.adapter?.oauthStore) {
1087
+ return true;
1088
+ }
1089
+ return false;
1090
+ }
1091
+ storageImplements(key) {
1092
+ const candidate = this.storage[key];
1093
+ if (typeof candidate !== 'function') {
1094
+ return false;
1095
+ }
1096
+ const baseImpl = BaseAuthAdapter.prototype[key];
1097
+ return candidate !== baseImpl;
1098
+ }
1099
+ storageImplementsAll(keys) {
1100
+ return keys.every((key) => this.storageImplements(key));
1101
+ }
1102
+ async applyRateLimit(apiReq, endpoint) {
1103
+ if (!this.rateLimitHook) {
1104
+ return;
1105
+ }
1106
+ await this.rateLimitHook({ apiReq, endpoint });
1107
+ }
1108
+ resolvePkceChallengeMethod(value) {
1109
+ if (value === 'S256') {
1110
+ return 'S256';
1111
+ }
1112
+ if (value === 'plain') {
1113
+ if (!this.allowInsecurePkcePlain) {
1114
+ throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
1115
+ }
1116
+ return 'plain';
1117
+ }
1118
+ return undefined;
1119
+ }
1120
+ defineRoutes() {
1121
+ const routes = [];
1122
+ const coreAuthSupported = this.storageImplementsAll([
1123
+ 'getUser',
1124
+ 'getUserPasswordHash',
1125
+ 'getUserId',
1126
+ 'verifyPassword',
1127
+ 'filterUser',
1128
+ 'storeToken',
1129
+ 'getToken',
1130
+ 'deleteToken'
1131
+ ]);
1132
+ if (!coreAuthSupported) {
1133
+ return routes;
1134
+ }
1135
+ routes.push({
1136
+ method: 'post',
1137
+ path: '/v1/login',
1138
+ handler: (req) => this.postLogin(req),
1139
+ auth: { type: 'none', req: 'any' },
1140
+ schema: { body: loginBodySchema }
1141
+ }, {
1142
+ method: 'post',
1143
+ path: '/v1/refresh',
1144
+ handler: (req) => this.postRefresh(req),
1145
+ auth: { type: 'none', req: 'any' },
1146
+ schema: { body: refreshBodySchema }
1147
+ }, {
1148
+ method: 'post',
1149
+ path: '/v1/logout',
1150
+ handler: (req) => this.postLogout(req),
1151
+ auth: { type: 'maybe', req: 'any' },
1152
+ schema: { body: logoutBodySchema }
1153
+ }, {
1154
+ method: 'post',
1155
+ path: '/v1/whoami',
1156
+ handler: (req) => this.postWhoAmI(req),
1157
+ auth: { type: 'maybe', req: 'any' },
1158
+ schema: { body: whoamiBodySchema }
1159
+ }, {
1160
+ method: 'post',
1161
+ path: '/v1/impersonations',
1162
+ handler: (req) => this.postImpersonation(req),
1163
+ auth: { type: 'strict', req: 'any' },
1164
+ schema: { body: impersonateBodySchema }
1165
+ }, {
1166
+ method: 'delete',
1167
+ path: '/v1/impersonations',
1168
+ handler: (req) => this.deleteImpersonation(req),
1169
+ auth: { type: 'strict', req: 'any' },
1170
+ schema: { querystring: deleteImpersonationQuerySchema }
1171
+ });
1172
+ const passkeysSupported = this.hasPasskeyService() &&
1173
+ this.storageImplements('createPasskeyChallenge') &&
1174
+ this.storageImplements('verifyPasskeyResponse');
1175
+ const passkeyCredentialsSupported = passkeysSupported &&
1176
+ this.storageImplements('listUserCredentials') &&
1177
+ this.storageImplements('deletePasskeyCredential');
1178
+ if (passkeysSupported) {
1179
+ routes.push({
1180
+ method: 'post',
1181
+ path: '/v1/passkeys/challenge',
1182
+ handler: (req) => this.postPasskeyChallenge(req),
1183
+ auth: { type: 'none', req: 'any' },
1184
+ schema: { body: passkeyChallengeBodySchema }
1185
+ }, {
1186
+ method: 'post',
1187
+ path: '/v1/passkeys/verify',
1188
+ handler: (req) => this.postPasskeyVerify(req),
1189
+ auth: { type: 'none', req: 'any' },
1190
+ schema: { body: passkeyVerifyBodySchema }
1191
+ });
1192
+ if (passkeyCredentialsSupported) {
1193
+ routes.push({
1194
+ method: 'get',
1195
+ path: '/v1/passkeys',
1196
+ handler: (req) => this.getPasskeys(req),
1197
+ auth: { type: 'strict', req: 'any' }
1198
+ }, {
1199
+ method: 'delete',
1200
+ path: '/v1/passkeys/:credentialId',
1201
+ handler: (req) => this.deletePasskey(req),
1202
+ auth: { type: 'strict', req: 'any' },
1203
+ schema: { params: passkeyCredentialParamsSchema }
1204
+ });
1205
+ }
1206
+ }
1207
+ const externalOAuthSupported = typeof this.server.initiateOAuth === 'function' && typeof this.server.completeOAuth === 'function';
1208
+ if (externalOAuthSupported) {
1209
+ routes.push({
1210
+ method: 'post',
1211
+ path: '/v1/oauth2/:provider/start',
1212
+ handler: (req) => this.postOAuthStart(req),
1213
+ auth: { type: 'none', req: 'any' },
1214
+ schema: { body: oauthStartBodySchema, params: oauthProviderParamsSchema }
1215
+ }, {
1216
+ method: 'post',
1217
+ path: '/v1/oauth2/:provider/callback',
1218
+ handler: (req) => this.postOAuthCallback(req),
1219
+ auth: { type: 'none', req: 'any' },
1220
+ schema: { params: oauthProviderParamsSchema }
1221
+ });
1222
+ }
1223
+ const oauthStorageSupported = this.hasOAuthStore() &&
1224
+ this.storageImplements('getClient') &&
1225
+ this.storageImplements('createAuthCode') &&
1226
+ this.storageImplements('consumeAuthCode');
1227
+ if (oauthStorageSupported) {
1228
+ routes.push({
1229
+ method: 'post',
1230
+ path: '/v1/oauth2/authorize',
1231
+ handler: (req) => this.postOAuthAuthorize(req),
1232
+ auth: { type: 'maybe', req: 'any' },
1233
+ schema: { body: oauthAuthorizeBodySchema }
1234
+ }, {
1235
+ method: 'post',
1236
+ path: '/v1/oauth2/token',
1237
+ handler: (req) => this.postOAuthToken(req),
1238
+ auth: { type: 'none', req: 'any' },
1239
+ schema: { body: oauthTokenBodySchema }
1240
+ });
1241
+ }
1242
+ return routes;
1243
+ }
1244
+ }
1245
+ AuthModule.defaultNamespace = '/auth';
1246
+ export default AuthModule;