@technomoron/api-server-base 1.1.13 → 2.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/api-server-base.cjs +181 -74
- package/dist/cjs/api-server-base.d.ts +66 -29
- package/dist/cjs/auth-api/auth-module.d.ts +96 -0
- package/dist/cjs/auth-api/auth-module.js +1032 -0
- package/dist/cjs/auth-api/compat-auth-storage.d.ts +55 -0
- package/dist/cjs/auth-api/compat-auth-storage.js +116 -0
- package/dist/cjs/auth-api/mem-auth-store.d.ts +66 -0
- package/dist/cjs/auth-api/mem-auth-store.js +135 -0
- package/dist/cjs/{auth-module.d.ts → auth-api/module.d.ts} +7 -7
- package/dist/cjs/{auth-module.cjs → auth-api/module.js} +1 -1
- package/dist/cjs/auth-api/sql-auth-store.d.ts +75 -0
- package/dist/cjs/auth-api/sql-auth-store.js +166 -0
- package/dist/cjs/auth-api/storage.d.ts +36 -0
- package/dist/cjs/{auth-storage.cjs → auth-api/storage.js} +2 -2
- package/dist/cjs/auth-api/types.d.ts +29 -0
- package/dist/cjs/auth-api/types.js +2 -0
- package/dist/cjs/index.cjs +41 -7
- package/dist/cjs/index.d.ts +29 -5
- package/dist/cjs/oauth/base.d.ts +10 -0
- package/dist/cjs/oauth/base.js +6 -0
- package/dist/cjs/oauth/memory.d.ts +16 -0
- package/dist/cjs/oauth/memory.js +99 -0
- package/dist/cjs/oauth/models.d.ts +45 -0
- package/dist/cjs/oauth/models.js +58 -0
- package/dist/cjs/oauth/sequelize.d.ts +68 -0
- package/dist/cjs/oauth/sequelize.js +210 -0
- package/dist/cjs/oauth/types.d.ts +50 -0
- package/dist/cjs/oauth/types.js +3 -0
- package/dist/cjs/passkey/base.d.ts +15 -0
- package/dist/cjs/passkey/base.js +6 -0
- package/dist/cjs/passkey/memory.d.ts +26 -0
- package/dist/cjs/passkey/memory.js +82 -0
- package/dist/cjs/passkey/models.d.ts +25 -0
- package/dist/cjs/passkey/models.js +115 -0
- package/dist/cjs/passkey/sequelize.d.ts +54 -0
- package/dist/cjs/passkey/sequelize.js +211 -0
- package/dist/cjs/passkey/service.d.ts +17 -0
- package/dist/cjs/passkey/service.js +221 -0
- package/dist/cjs/passkey/types.d.ts +75 -0
- package/dist/cjs/passkey/types.js +2 -0
- package/dist/cjs/token/base.d.ts +38 -0
- package/dist/cjs/token/base.js +114 -0
- package/dist/cjs/token/memory.d.ts +19 -0
- package/dist/cjs/token/memory.js +149 -0
- package/dist/cjs/token/sequelize.d.ts +58 -0
- package/dist/cjs/token/sequelize.js +404 -0
- package/dist/cjs/token/types.d.ts +27 -0
- package/dist/cjs/token/types.js +2 -0
- package/dist/cjs/user/base.d.ts +26 -0
- package/dist/cjs/user/base.js +45 -0
- package/dist/cjs/user/memory.d.ts +35 -0
- package/dist/cjs/user/memory.js +173 -0
- package/dist/cjs/user/sequelize.d.ts +41 -0
- package/dist/cjs/user/sequelize.js +182 -0
- package/dist/cjs/user/types.d.ts +11 -0
- package/dist/cjs/user/types.js +2 -0
- package/dist/esm/api-server-base.d.ts +66 -29
- package/dist/esm/api-server-base.js +179 -72
- package/dist/esm/auth-api/auth-module.d.ts +96 -0
- package/dist/esm/auth-api/auth-module.js +1030 -0
- package/dist/esm/auth-api/compat-auth-storage.d.ts +55 -0
- package/dist/esm/auth-api/compat-auth-storage.js +112 -0
- package/dist/esm/auth-api/mem-auth-store.d.ts +66 -0
- package/dist/esm/auth-api/mem-auth-store.js +131 -0
- package/dist/esm/{auth-module.d.ts → auth-api/module.d.ts} +7 -7
- package/dist/esm/{auth-module.js → auth-api/module.js} +1 -1
- package/dist/esm/auth-api/sql-auth-store.d.ts +75 -0
- package/dist/esm/auth-api/sql-auth-store.js +162 -0
- package/dist/esm/auth-api/storage.d.ts +36 -0
- package/dist/esm/{auth-storage.js → auth-api/storage.js} +2 -2
- package/dist/esm/auth-api/types.d.ts +29 -0
- package/dist/esm/auth-api/types.js +1 -0
- package/dist/esm/index.d.ts +29 -5
- package/dist/esm/index.js +19 -2
- package/dist/esm/oauth/base.d.ts +10 -0
- package/dist/esm/oauth/base.js +2 -0
- package/dist/esm/oauth/memory.d.ts +16 -0
- package/dist/esm/oauth/memory.js +92 -0
- package/dist/esm/oauth/models.d.ts +45 -0
- package/dist/esm/oauth/models.js +51 -0
- package/dist/esm/oauth/sequelize.d.ts +68 -0
- package/dist/esm/oauth/sequelize.js +199 -0
- package/dist/esm/oauth/types.d.ts +50 -0
- package/dist/esm/oauth/types.js +2 -0
- package/dist/esm/passkey/base.d.ts +15 -0
- package/dist/esm/passkey/base.js +2 -0
- package/dist/esm/passkey/memory.d.ts +26 -0
- package/dist/esm/passkey/memory.js +78 -0
- package/dist/esm/passkey/models.d.ts +25 -0
- package/dist/esm/passkey/models.js +108 -0
- package/dist/esm/passkey/sequelize.d.ts +54 -0
- package/dist/esm/passkey/sequelize.js +207 -0
- package/dist/esm/passkey/service.d.ts +17 -0
- package/dist/esm/passkey/service.js +217 -0
- package/dist/esm/passkey/types.d.ts +75 -0
- package/dist/esm/passkey/types.js +1 -0
- package/dist/esm/token/base.d.ts +38 -0
- package/dist/esm/token/base.js +107 -0
- package/dist/esm/token/memory.d.ts +19 -0
- package/dist/esm/token/memory.js +145 -0
- package/dist/esm/token/sequelize.d.ts +58 -0
- package/dist/esm/token/sequelize.js +400 -0
- package/dist/esm/token/types.d.ts +27 -0
- package/dist/esm/token/types.js +1 -0
- package/dist/esm/user/base.d.ts +26 -0
- package/dist/esm/user/base.js +38 -0
- package/dist/esm/user/memory.d.ts +35 -0
- package/dist/esm/user/memory.js +169 -0
- package/dist/esm/user/sequelize.d.ts +41 -0
- package/dist/esm/user/sequelize.js +176 -0
- package/dist/esm/user/types.d.ts +11 -0
- package/dist/esm/user/types.js +1 -0
- package/package.json +11 -3
- package/dist/cjs/auth-storage.d.ts +0 -133
- package/dist/esm/auth-storage.d.ts +0 -133
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { ApiError } from '../api-server-base.js';
|
|
3
|
+
import { BaseAuthModule } from './module.js';
|
|
4
|
+
function isAuthIdentifier(value) {
|
|
5
|
+
return typeof value === 'string' || typeof value === 'number';
|
|
6
|
+
}
|
|
7
|
+
function toStringOrNull(value) {
|
|
8
|
+
if (typeof value === 'string') {
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
function toScopeArray(scope) {
|
|
15
|
+
if (typeof scope === 'string') {
|
|
16
|
+
return scope.split(/\s+/).filter((entry) => entry.length > 0);
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(scope) && scope.every((entry) => typeof entry === 'string')) {
|
|
19
|
+
return scope.filter((entry) => entry.length > 0);
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function base64UrlEncode(buffer) {
|
|
24
|
+
return buffer.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
25
|
+
}
|
|
26
|
+
function sha256Base64Url(value) {
|
|
27
|
+
const hash = createHash('sha256').update(value).digest();
|
|
28
|
+
return base64UrlEncode(hash);
|
|
29
|
+
}
|
|
30
|
+
class AuthModule extends BaseAuthModule {
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
super({ namespace: options.namespace ?? AuthModule.defaultNamespace });
|
|
33
|
+
this.defaultDomain = options.defaultDomain;
|
|
34
|
+
this.canImpersonateHook = options.canImpersonate;
|
|
35
|
+
}
|
|
36
|
+
get storage() {
|
|
37
|
+
return this.server.getAuthStorage();
|
|
38
|
+
}
|
|
39
|
+
async canImpersonate(apiReq, realUser, targetUser) {
|
|
40
|
+
const realUserId = this.storage.getUserId(realUser);
|
|
41
|
+
const effectiveUserId = this.storage.getUserId(targetUser);
|
|
42
|
+
if (this.canImpersonateHook) {
|
|
43
|
+
const allowed = await this.canImpersonateHook({
|
|
44
|
+
apiReq,
|
|
45
|
+
realUser,
|
|
46
|
+
realUserId,
|
|
47
|
+
targetUser,
|
|
48
|
+
effectiveUserId
|
|
49
|
+
});
|
|
50
|
+
if (allowed) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const storageWithHook = this.storage;
|
|
55
|
+
if (typeof storageWithHook.canImpersonate === 'function') {
|
|
56
|
+
const allowed = await storageWithHook.canImpersonate({ realUserId, effectiveUserId });
|
|
57
|
+
return !!allowed;
|
|
58
|
+
}
|
|
59
|
+
return realUserId === effectiveUserId;
|
|
60
|
+
}
|
|
61
|
+
async ensureImpersonationAllowed(apiReq, realUser, targetUser) {
|
|
62
|
+
const permitted = await this.canImpersonate(apiReq, realUser, targetUser);
|
|
63
|
+
if (!permitted) {
|
|
64
|
+
throw new ApiError({ code: 403, message: 'Impersonation is not permitted' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
buildTokenPayload(user, metadata = {}) {
|
|
68
|
+
return {
|
|
69
|
+
...metadata,
|
|
70
|
+
uid: String(this.storage.getUserId(user))
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
buildTokenMetadata(metadata = {}) {
|
|
74
|
+
const scope = metadata.scope;
|
|
75
|
+
return {
|
|
76
|
+
domain: metadata.domain ?? this.defaultDomain ?? '',
|
|
77
|
+
fingerprint: metadata.fingerprint ?? metadata.clientId ?? '',
|
|
78
|
+
label: metadata.label ?? (Array.isArray(scope) ? scope.join(' ') : typeof scope === 'string' ? scope : ''),
|
|
79
|
+
clientId: metadata.clientId,
|
|
80
|
+
ruid: metadata.ruid,
|
|
81
|
+
scope: metadata.scope,
|
|
82
|
+
browser: metadata.browser ?? '',
|
|
83
|
+
device: metadata.device ?? '',
|
|
84
|
+
ip: metadata.ip ?? '',
|
|
85
|
+
os: metadata.os ?? '',
|
|
86
|
+
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(metadata.refreshTtlSeconds),
|
|
87
|
+
sessionCookie: typeof metadata.sessionCookie === 'boolean' ? metadata.sessionCookie : undefined,
|
|
88
|
+
loginType: metadata.loginType
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
enrichTokenMetadata(apiReq, metadata = {}) {
|
|
92
|
+
const enriched = { ...metadata };
|
|
93
|
+
const clientInfo = apiReq.getClientInfo();
|
|
94
|
+
if (!enriched.ip && clientInfo.ip) {
|
|
95
|
+
enriched.ip = clientInfo.ip;
|
|
96
|
+
}
|
|
97
|
+
if (!enriched.browser && clientInfo.browser) {
|
|
98
|
+
enriched.browser = clientInfo.browser;
|
|
99
|
+
}
|
|
100
|
+
if (!enriched.os && clientInfo.os) {
|
|
101
|
+
enriched.os = clientInfo.os;
|
|
102
|
+
}
|
|
103
|
+
if (!enriched.device && clientInfo.device) {
|
|
104
|
+
enriched.device = clientInfo.device;
|
|
105
|
+
}
|
|
106
|
+
return enriched;
|
|
107
|
+
}
|
|
108
|
+
sessionRefreshTtlSeconds() {
|
|
109
|
+
return Math.max(1, this.server.config.sessionRefreshExpiry ?? 24 * 60 * 60);
|
|
110
|
+
}
|
|
111
|
+
normalizeRefreshTtlSeconds(value) {
|
|
112
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
113
|
+
return Math.floor(value);
|
|
114
|
+
}
|
|
115
|
+
if (typeof value === 'string') {
|
|
116
|
+
const trimmed = value.trim();
|
|
117
|
+
if (!trimmed) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const parsed = Number(trimmed);
|
|
121
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
122
|
+
return Math.floor(parsed);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
resolveSessionPreferences(candidate) {
|
|
128
|
+
if (candidate === undefined || candidate === null) {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
if (typeof candidate === 'boolean') {
|
|
132
|
+
return candidate ? {} : { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
|
|
133
|
+
}
|
|
134
|
+
if (typeof candidate === 'number') {
|
|
135
|
+
const ttl = this.normalizeRefreshTtlSeconds(candidate);
|
|
136
|
+
return ttl ? { sessionCookie: false, refreshTtlSeconds: ttl } : {};
|
|
137
|
+
}
|
|
138
|
+
if (typeof candidate === 'string') {
|
|
139
|
+
const trimmed = candidate.trim();
|
|
140
|
+
if (!trimmed) {
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
if (/^(true|yes|1)$/i.test(trimmed)) {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
if (/^(false|no|0)$/i.test(trimmed)) {
|
|
147
|
+
return { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
|
|
148
|
+
}
|
|
149
|
+
const ttl = this.normalizeRefreshTtlSeconds(trimmed);
|
|
150
|
+
return ttl ? { sessionCookie: false, refreshTtlSeconds: ttl } : {};
|
|
151
|
+
}
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
mergeSessionPreferences(...prefs) {
|
|
155
|
+
const merged = {};
|
|
156
|
+
for (const pref of prefs) {
|
|
157
|
+
if (!pref) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (merged.sessionCookie === undefined && pref.sessionCookie !== undefined) {
|
|
161
|
+
merged.sessionCookie = pref.sessionCookie;
|
|
162
|
+
}
|
|
163
|
+
if (merged.refreshTtlSeconds === undefined && typeof pref.refreshTtlSeconds === 'number') {
|
|
164
|
+
merged.refreshTtlSeconds = pref.refreshTtlSeconds;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return merged;
|
|
168
|
+
}
|
|
169
|
+
sessionPrefsFromRecord(record) {
|
|
170
|
+
if (!record) {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
const carrier = record;
|
|
174
|
+
const prefs = {};
|
|
175
|
+
if (typeof carrier.sessionCookie === 'boolean') {
|
|
176
|
+
prefs.sessionCookie = carrier.sessionCookie;
|
|
177
|
+
}
|
|
178
|
+
const ttl = this.normalizeRefreshTtlSeconds(carrier.refreshTtlSeconds);
|
|
179
|
+
if (ttl !== undefined) {
|
|
180
|
+
prefs.refreshTtlSeconds = ttl;
|
|
181
|
+
}
|
|
182
|
+
return prefs;
|
|
183
|
+
}
|
|
184
|
+
cookieOptions(apiReq) {
|
|
185
|
+
const conf = this.server.config;
|
|
186
|
+
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
187
|
+
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
188
|
+
const origin = typeof referer === 'string' ? referer : '';
|
|
189
|
+
const isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
|
|
190
|
+
const isLocalhost = origin.includes('localhost');
|
|
191
|
+
const options = {
|
|
192
|
+
httpOnly: true,
|
|
193
|
+
secure: true,
|
|
194
|
+
sameSite: 'strict',
|
|
195
|
+
domain: conf.cookieDomain || undefined,
|
|
196
|
+
path: '/',
|
|
197
|
+
maxAge: undefined
|
|
198
|
+
};
|
|
199
|
+
if (conf.devMode) {
|
|
200
|
+
options.secure = isHttps;
|
|
201
|
+
options.httpOnly = false;
|
|
202
|
+
options.sameSite = 'lax';
|
|
203
|
+
if (isLocalhost) {
|
|
204
|
+
options.domain = undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return options;
|
|
208
|
+
}
|
|
209
|
+
setJwtCookies(apiReq, tokens, preferences = {}) {
|
|
210
|
+
const conf = this.server.config;
|
|
211
|
+
const options = this.cookieOptions(apiReq);
|
|
212
|
+
const sessionCookie = preferences.sessionCookie ?? false;
|
|
213
|
+
const accessMaxAge = Math.max(1, conf.accessExpiry) * 1000;
|
|
214
|
+
const refreshSeconds = Math.max(1, preferences.refreshTtlSeconds ?? conf.refreshExpiry);
|
|
215
|
+
const refreshMaxAge = refreshSeconds * 1000;
|
|
216
|
+
const accessOptions = sessionCookie ? options : { ...options, maxAge: accessMaxAge };
|
|
217
|
+
const refreshOptions = sessionCookie ? options : { ...options, maxAge: refreshMaxAge };
|
|
218
|
+
if (tokens.accessToken) {
|
|
219
|
+
apiReq.res.cookie(conf.accessCookie, tokens.accessToken, accessOptions);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
apiReq.res.clearCookie(conf.accessCookie, options);
|
|
223
|
+
}
|
|
224
|
+
if (tokens.refreshToken) {
|
|
225
|
+
apiReq.res.cookie(conf.refreshCookie, tokens.refreshToken, refreshOptions);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
apiReq.res.clearCookie(conf.refreshCookie, options);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async issueTokens(apiReq, user, metadata = {}) {
|
|
232
|
+
const conf = this.server.config;
|
|
233
|
+
const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
|
|
234
|
+
const payload = this.buildTokenPayload(user, enrichedMetadata);
|
|
235
|
+
const access = this.server.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
|
|
236
|
+
if (!access.success || !access.token) {
|
|
237
|
+
throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
238
|
+
}
|
|
239
|
+
const refresh = this.server.jwtSign(payload, conf.refreshSecret, conf.refreshExpiry);
|
|
240
|
+
if (!refresh.success || !refresh.token) {
|
|
241
|
+
throw new ApiError({ code: 500, message: refresh.error ?? 'Unable to sign refresh token' });
|
|
242
|
+
}
|
|
243
|
+
const meta = this.buildTokenMetadata(enrichedMetadata);
|
|
244
|
+
const wantsSessionCookie = meta.sessionCookie === true;
|
|
245
|
+
const customRefreshTtl = typeof meta.refreshTtlSeconds === 'number' ? meta.refreshTtlSeconds : undefined;
|
|
246
|
+
const sessionRefreshTtl = this.sessionRefreshTtlSeconds();
|
|
247
|
+
const defaultRefreshTtl = Math.max(1, conf.refreshExpiry);
|
|
248
|
+
const refreshLifetimeSeconds = wantsSessionCookie
|
|
249
|
+
? Math.max(1, customRefreshTtl ?? sessionRefreshTtl)
|
|
250
|
+
: Math.max(1, customRefreshTtl ?? defaultRefreshTtl);
|
|
251
|
+
const storedRefreshTtlSeconds = wantsSessionCookie
|
|
252
|
+
? Math.max(1, customRefreshTtl ?? sessionRefreshTtl)
|
|
253
|
+
: customRefreshTtl;
|
|
254
|
+
meta.refreshTtlSeconds =
|
|
255
|
+
typeof storedRefreshTtlSeconds === 'number' && storedRefreshTtlSeconds > 0
|
|
256
|
+
? storedRefreshTtlSeconds
|
|
257
|
+
: undefined;
|
|
258
|
+
meta.sessionCookie = wantsSessionCookie;
|
|
259
|
+
const expiresAt = metadata.expires ?? new Date(Date.now() + refreshLifetimeSeconds * 1000);
|
|
260
|
+
const issuedAt = new Date();
|
|
261
|
+
const lastSeenAt = issuedAt;
|
|
262
|
+
await this.storage.storeToken({
|
|
263
|
+
accessToken: access.token,
|
|
264
|
+
refreshToken: refresh.token,
|
|
265
|
+
userId: payload.uid,
|
|
266
|
+
ruid: meta.ruid,
|
|
267
|
+
domain: meta.domain,
|
|
268
|
+
fingerprint: meta.fingerprint,
|
|
269
|
+
label: meta.label,
|
|
270
|
+
browser: meta.browser,
|
|
271
|
+
device: meta.device,
|
|
272
|
+
ip: meta.ip,
|
|
273
|
+
os: meta.os,
|
|
274
|
+
clientId: meta.clientId,
|
|
275
|
+
scope: meta.scope,
|
|
276
|
+
loginType: meta.loginType,
|
|
277
|
+
refreshTtlSeconds: meta.refreshTtlSeconds,
|
|
278
|
+
expires: expiresAt,
|
|
279
|
+
issuedAt,
|
|
280
|
+
lastSeenAt
|
|
281
|
+
});
|
|
282
|
+
this.setJwtCookies(apiReq, {
|
|
283
|
+
accessToken: access.token,
|
|
284
|
+
refreshToken: refresh.token
|
|
285
|
+
}, {
|
|
286
|
+
sessionCookie: wantsSessionCookie,
|
|
287
|
+
refreshTtlSeconds: refreshLifetimeSeconds
|
|
288
|
+
});
|
|
289
|
+
return {
|
|
290
|
+
accessToken: access.token,
|
|
291
|
+
refreshToken: refresh.token
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
assertAuthReady() {
|
|
295
|
+
const conf = this.server.config;
|
|
296
|
+
if (!conf.accessSecret || !conf.refreshSecret) {
|
|
297
|
+
throw new ApiError({ code: 500, message: 'Auth secrets are not configured' });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
parseLoginBody(apiReq) {
|
|
301
|
+
const body = (apiReq.req.body ?? {});
|
|
302
|
+
const login = toStringOrNull(body.login);
|
|
303
|
+
const password = toStringOrNull(body.password);
|
|
304
|
+
const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
|
|
305
|
+
if (!login || !password) {
|
|
306
|
+
const errors = {};
|
|
307
|
+
if (!login) {
|
|
308
|
+
errors.login = 'Login is required';
|
|
309
|
+
}
|
|
310
|
+
if (!password) {
|
|
311
|
+
errors.password = 'Password is required';
|
|
312
|
+
}
|
|
313
|
+
throw new ApiError({ code: 400, message: 'Missing credentials', errors });
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
login,
|
|
317
|
+
password,
|
|
318
|
+
domain: toStringOrNull(body.domain) ?? undefined,
|
|
319
|
+
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
320
|
+
label: toStringOrNull(body.label) ?? undefined,
|
|
321
|
+
browser: toStringOrNull(body.browser) ?? undefined,
|
|
322
|
+
device: toStringOrNull(body.device) ?? undefined,
|
|
323
|
+
ip: toStringOrNull(body.ip) ?? undefined,
|
|
324
|
+
os: toStringOrNull(body.os) ?? undefined,
|
|
325
|
+
loginType: 'credentials',
|
|
326
|
+
...sessionPrefs
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
parseImpersonationRequest(apiReq) {
|
|
330
|
+
const body = (apiReq.req.body ?? {});
|
|
331
|
+
const targetIdentifier = this.resolveImpersonationIdentifier(body);
|
|
332
|
+
const metadata = this.buildImpersonationMetadata(body);
|
|
333
|
+
return { targetIdentifier, metadata };
|
|
334
|
+
}
|
|
335
|
+
resolveImpersonationIdentifier(body) {
|
|
336
|
+
if (isAuthIdentifier(body.userId)) {
|
|
337
|
+
return body.userId;
|
|
338
|
+
}
|
|
339
|
+
const login = toStringOrNull(body.login);
|
|
340
|
+
if (login) {
|
|
341
|
+
return login;
|
|
342
|
+
}
|
|
343
|
+
throw new ApiError({ code: 400, message: 'userId or login is required' });
|
|
344
|
+
}
|
|
345
|
+
buildImpersonationMetadata(body) {
|
|
346
|
+
const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
|
|
347
|
+
return {
|
|
348
|
+
domain: toStringOrNull(body.domain) ?? undefined,
|
|
349
|
+
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
350
|
+
label: toStringOrNull(body.label) ?? undefined,
|
|
351
|
+
browser: toStringOrNull(body.browser) ?? undefined,
|
|
352
|
+
device: toStringOrNull(body.device) ?? undefined,
|
|
353
|
+
ip: toStringOrNull(body.ip) ?? undefined,
|
|
354
|
+
os: toStringOrNull(body.os) ?? undefined,
|
|
355
|
+
clientId: toStringOrNull(body.clientId) ?? undefined,
|
|
356
|
+
scope: toScopeArray(body.scope),
|
|
357
|
+
loginType: toStringOrNull(body.loginType) ?? undefined,
|
|
358
|
+
...sessionPrefs
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async getUserOrThrow(identifier, errorMessage) {
|
|
362
|
+
const user = await this.storage.getUser(identifier);
|
|
363
|
+
if (!user) {
|
|
364
|
+
throw new ApiError({ code: 403, message: errorMessage });
|
|
365
|
+
}
|
|
366
|
+
return user;
|
|
367
|
+
}
|
|
368
|
+
getRealUserIdentifier(apiReq) {
|
|
369
|
+
const candidate = typeof apiReq.getRealUid === 'function'
|
|
370
|
+
? apiReq.getRealUid()
|
|
371
|
+
: (apiReq.realUid ?? null);
|
|
372
|
+
if (!isAuthIdentifier(candidate)) {
|
|
373
|
+
throw new ApiError({ code: 401, message: 'Authentication required' });
|
|
374
|
+
}
|
|
375
|
+
return candidate;
|
|
376
|
+
}
|
|
377
|
+
async resolveActorContext(apiReq) {
|
|
378
|
+
const realIdentifier = this.getRealUserIdentifier(apiReq);
|
|
379
|
+
const user = await this.getUserOrThrow(realIdentifier, 'Authenticated user not found');
|
|
380
|
+
return { user, userId: this.storage.getUserId(user) };
|
|
381
|
+
}
|
|
382
|
+
extractRefreshToken(apiReq, body) {
|
|
383
|
+
const conf = this.server.config;
|
|
384
|
+
if (typeof body.refreshToken === 'string') {
|
|
385
|
+
return body.refreshToken;
|
|
386
|
+
}
|
|
387
|
+
const logoutBody = body;
|
|
388
|
+
if (typeof logoutBody.token === 'string') {
|
|
389
|
+
return logoutBody.token;
|
|
390
|
+
}
|
|
391
|
+
const fromCookie = apiReq.req.cookies?.[conf.refreshCookie];
|
|
392
|
+
return typeof fromCookie === 'string' && fromCookie.length > 0 ? fromCookie : null;
|
|
393
|
+
}
|
|
394
|
+
normalizeScope(scope) {
|
|
395
|
+
if (typeof scope === 'string') {
|
|
396
|
+
return scope;
|
|
397
|
+
}
|
|
398
|
+
if (Array.isArray(scope) && scope.every((entry) => typeof entry === 'string')) {
|
|
399
|
+
return scope;
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
async postLogin(apiReq) {
|
|
404
|
+
this.assertAuthReady();
|
|
405
|
+
const { login, password, ...metadata } = this.parseLoginBody(apiReq);
|
|
406
|
+
const user = await this.storage.getUser(login);
|
|
407
|
+
if (!user) {
|
|
408
|
+
throw new ApiError({
|
|
409
|
+
code: 400,
|
|
410
|
+
message: 'Invalid credentials',
|
|
411
|
+
errors: { login: 'Unknown user' }
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const hash = this.storage.getUserPasswordHash(user);
|
|
415
|
+
const verified = await this.storage.verifyPassword(password, hash);
|
|
416
|
+
if (!verified) {
|
|
417
|
+
throw new ApiError({
|
|
418
|
+
code: 400,
|
|
419
|
+
message: 'Invalid credentials',
|
|
420
|
+
errors: { password: 'Wrong password' }
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const pair = await this.issueTokens(apiReq, user, metadata);
|
|
424
|
+
const publicUser = this.storage.filterUser(user);
|
|
425
|
+
return [200, { ...pair, user: publicUser }];
|
|
426
|
+
}
|
|
427
|
+
async postRefresh(apiReq) {
|
|
428
|
+
this.assertAuthReady();
|
|
429
|
+
const body = (apiReq.req.body ?? {});
|
|
430
|
+
const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
|
|
431
|
+
const providedToken = this.extractRefreshToken(apiReq, body);
|
|
432
|
+
if (!providedToken) {
|
|
433
|
+
throw new ApiError({ code: 401, message: 'Missing refresh token' });
|
|
434
|
+
}
|
|
435
|
+
const stored = await this.storage.getToken({ refreshToken: providedToken });
|
|
436
|
+
if (!stored) {
|
|
437
|
+
throw new ApiError({ code: 401, message: 'Invalid refresh token' });
|
|
438
|
+
}
|
|
439
|
+
const verify = this.server.jwtVerify(providedToken, this.server.config.refreshSecret);
|
|
440
|
+
if (!verify.success || !verify.data) {
|
|
441
|
+
const expired = 'expired' in verify && verify.expired;
|
|
442
|
+
throw new ApiError({
|
|
443
|
+
code: expired ? 403 : 401,
|
|
444
|
+
message: verify.error ?? 'Unable to verify refresh token'
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
|
|
448
|
+
const metadata = {
|
|
449
|
+
domain: stored.domain,
|
|
450
|
+
fingerprint: stored.fingerprint,
|
|
451
|
+
label: stored.label,
|
|
452
|
+
clientId: stored.clientId,
|
|
453
|
+
scope: stored.scope,
|
|
454
|
+
browser: stored.browser,
|
|
455
|
+
device: stored.device,
|
|
456
|
+
ip: stored.ip,
|
|
457
|
+
os: stored.os,
|
|
458
|
+
loginType: stored.loginType,
|
|
459
|
+
refreshTtlSeconds: sessionPrefs.refreshTtlSeconds ?? stored.refreshTtlSeconds,
|
|
460
|
+
sessionCookie: sessionPrefs.sessionCookie ?? stored.sessionCookie
|
|
461
|
+
};
|
|
462
|
+
const pair = await this.issueTokens(apiReq, user, metadata);
|
|
463
|
+
const publicUser = this.storage.filterUser(user);
|
|
464
|
+
return [200, { ...pair, user: publicUser }];
|
|
465
|
+
}
|
|
466
|
+
async postLogout(apiReq) {
|
|
467
|
+
const body = (apiReq.req.body ?? {});
|
|
468
|
+
const refreshToken = this.extractRefreshToken(apiReq, body);
|
|
469
|
+
if (!refreshToken) {
|
|
470
|
+
throw new ApiError({ code: 401, message: 'Not logged in' });
|
|
471
|
+
}
|
|
472
|
+
this.setJwtCookies(apiReq, { accessToken: null, refreshToken: null });
|
|
473
|
+
const revoked = await this.storage.deleteToken({ refreshToken });
|
|
474
|
+
return [200, { revoked }];
|
|
475
|
+
}
|
|
476
|
+
async postWhoAmI(apiReq) {
|
|
477
|
+
const body = (apiReq.req.body ?? {});
|
|
478
|
+
const refreshToken = this.extractRefreshToken(apiReq, body);
|
|
479
|
+
if (!refreshToken) {
|
|
480
|
+
throw new ApiError({ code: 401, message: 'Missing refresh token' });
|
|
481
|
+
}
|
|
482
|
+
const stored = await this.storage.getToken({ refreshToken });
|
|
483
|
+
if (!stored) {
|
|
484
|
+
throw new ApiError({ code: 401, message: 'Invalid refresh token' });
|
|
485
|
+
}
|
|
486
|
+
const verify = this.server.jwtVerify(refreshToken, this.server.config.refreshSecret);
|
|
487
|
+
if (!verify.success || !verify.data) {
|
|
488
|
+
const expired = 'expired' in verify && verify.expired;
|
|
489
|
+
throw new ApiError({
|
|
490
|
+
code: expired ? 403 : 401,
|
|
491
|
+
message: verify.error ?? 'Unable to verify refresh token'
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
|
|
495
|
+
const conf = this.server.config;
|
|
496
|
+
const hasAccessToken = typeof apiReq.req.headers.authorization === 'string' ||
|
|
497
|
+
(typeof apiReq.req.cookies?.[conf.accessCookie] === 'string' &&
|
|
498
|
+
apiReq.req.cookies[conf.accessCookie].trim().length > 0);
|
|
499
|
+
const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
|
|
500
|
+
if (shouldRefresh) {
|
|
501
|
+
const access = this.server.jwtSign(this.buildTokenPayload(user, stored), conf.accessSecret, conf.accessExpiry);
|
|
502
|
+
if (!access.success || !access.token) {
|
|
503
|
+
throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
504
|
+
}
|
|
505
|
+
const cookiePrefs = this.mergeSessionPreferences({
|
|
506
|
+
sessionCookie: stored.sessionCookie,
|
|
507
|
+
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
|
|
508
|
+
});
|
|
509
|
+
const refreshTtlForCookie = cookiePrefs.sessionCookie
|
|
510
|
+
? (cookiePrefs.refreshTtlSeconds ?? this.sessionRefreshTtlSeconds())
|
|
511
|
+
: cookiePrefs.refreshTtlSeconds;
|
|
512
|
+
this.setJwtCookies(apiReq, { accessToken: access.token, refreshToken }, { sessionCookie: cookiePrefs.sessionCookie ?? false, refreshTtlSeconds: refreshTtlForCookie });
|
|
513
|
+
}
|
|
514
|
+
return [200, this.storage.filterUser(user)];
|
|
515
|
+
}
|
|
516
|
+
async postPasskeyChallenge(apiReq) {
|
|
517
|
+
if (typeof this.storage.createPasskeyChallenge !== 'function') {
|
|
518
|
+
throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
|
|
519
|
+
}
|
|
520
|
+
const body = (apiReq.req.body ?? {});
|
|
521
|
+
const action = toStringOrNull(body.action);
|
|
522
|
+
if (action !== 'register' && action !== 'authenticate') {
|
|
523
|
+
throw new ApiError({ code: 400, message: 'Passkey action must be "register" or "authenticate"' });
|
|
524
|
+
}
|
|
525
|
+
const params = {
|
|
526
|
+
action,
|
|
527
|
+
login: toStringOrNull(body.login) ?? undefined,
|
|
528
|
+
userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
|
|
529
|
+
userAgent: toStringOrNull(body.userAgent) ?? undefined,
|
|
530
|
+
domain: toStringOrNull(body.domain) ?? undefined,
|
|
531
|
+
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
532
|
+
label: toStringOrNull(body.label) ?? undefined,
|
|
533
|
+
browser: toStringOrNull(body.browser) ?? undefined,
|
|
534
|
+
device: toStringOrNull(body.device) ?? undefined,
|
|
535
|
+
ip: toStringOrNull(body.ip) ?? undefined,
|
|
536
|
+
os: toStringOrNull(body.os) ?? undefined
|
|
537
|
+
};
|
|
538
|
+
const challenge = await this.storage.createPasskeyChallenge(params);
|
|
539
|
+
return [200, challenge];
|
|
540
|
+
}
|
|
541
|
+
async postPasskeyVerify(apiReq) {
|
|
542
|
+
if (typeof this.storage.verifyPasskeyResponse !== 'function') {
|
|
543
|
+
throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
|
|
544
|
+
}
|
|
545
|
+
const body = (apiReq.req.body ?? {});
|
|
546
|
+
const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
|
|
547
|
+
const expectedChallenge = toStringOrNull(body.expectedChallenge);
|
|
548
|
+
const response = body.response;
|
|
549
|
+
if (!expectedChallenge || typeof response !== 'object' || response === null) {
|
|
550
|
+
throw new ApiError({ code: 400, message: 'Malformed passkey verification payload' });
|
|
551
|
+
}
|
|
552
|
+
const params = {
|
|
553
|
+
expectedChallenge,
|
|
554
|
+
response: response,
|
|
555
|
+
login: toStringOrNull(body.login) ?? undefined,
|
|
556
|
+
userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
|
|
557
|
+
domain: toStringOrNull(body.domain) ?? undefined,
|
|
558
|
+
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
559
|
+
label: toStringOrNull(body.label) ?? undefined,
|
|
560
|
+
browser: toStringOrNull(body.browser) ?? undefined,
|
|
561
|
+
device: toStringOrNull(body.device) ?? undefined,
|
|
562
|
+
ip: toStringOrNull(body.ip) ?? undefined,
|
|
563
|
+
os: toStringOrNull(body.os) ?? undefined,
|
|
564
|
+
...sessionPrefs
|
|
565
|
+
};
|
|
566
|
+
const result = await this.storage.verifyPasskeyResponse(params);
|
|
567
|
+
if (!result.verified) {
|
|
568
|
+
throw new ApiError({ code: 401, message: 'Passkey verification failed' });
|
|
569
|
+
}
|
|
570
|
+
const user = await this.getUserFromPasskey(result, params);
|
|
571
|
+
if (result.tokens?.accessToken && result.tokens.refreshToken) {
|
|
572
|
+
const storagePrefs = this.sessionPrefsFromRecord(result);
|
|
573
|
+
const cookiePrefs = this.mergeSessionPreferences(sessionPrefs, storagePrefs);
|
|
574
|
+
const refreshTtlForCookie = cookiePrefs.sessionCookie
|
|
575
|
+
? (cookiePrefs.refreshTtlSeconds ?? this.sessionRefreshTtlSeconds())
|
|
576
|
+
: cookiePrefs.refreshTtlSeconds;
|
|
577
|
+
this.setJwtCookies(apiReq, { accessToken: result.tokens.accessToken, refreshToken: result.tokens.refreshToken }, { sessionCookie: cookiePrefs.sessionCookie ?? false, refreshTtlSeconds: refreshTtlForCookie });
|
|
578
|
+
const publicUser = this.storage.filterUser(user);
|
|
579
|
+
return [200, { ...result.tokens, user: publicUser }];
|
|
580
|
+
}
|
|
581
|
+
const extras = result;
|
|
582
|
+
const extrasPrefs = this.sessionPrefsFromRecord(extras);
|
|
583
|
+
const metadataPrefs = this.mergeSessionPreferences(sessionPrefs, extrasPrefs);
|
|
584
|
+
const metadata = {
|
|
585
|
+
domain: toStringOrNull(extras.domain) ?? params.domain,
|
|
586
|
+
fingerprint: toStringOrNull(extras.fingerprint) ?? params.fingerprint,
|
|
587
|
+
label: toStringOrNull(extras.label) ?? params.label,
|
|
588
|
+
browser: toStringOrNull(extras.browser) ?? params.browser,
|
|
589
|
+
device: toStringOrNull(extras.device) ?? params.device,
|
|
590
|
+
ip: toStringOrNull(extras.ip) ?? params.ip,
|
|
591
|
+
os: toStringOrNull(extras.os) ?? params.os,
|
|
592
|
+
loginType: 'passkey',
|
|
593
|
+
...metadataPrefs
|
|
594
|
+
};
|
|
595
|
+
const tokens = await this.issueTokens(apiReq, user, metadata);
|
|
596
|
+
const publicUser = this.storage.filterUser(user);
|
|
597
|
+
return [200, { ...tokens, user: publicUser }];
|
|
598
|
+
}
|
|
599
|
+
async postImpersonation(apiReq) {
|
|
600
|
+
this.assertAuthReady();
|
|
601
|
+
const { targetIdentifier, metadata } = this.parseImpersonationRequest(apiReq);
|
|
602
|
+
const actor = await this.resolveActorContext(apiReq);
|
|
603
|
+
const targetUser = await this.getUserOrThrow(targetIdentifier, 'Target user not found');
|
|
604
|
+
await this.ensureImpersonationAllowed(apiReq, actor.user, targetUser);
|
|
605
|
+
const impersonationMetadata = {
|
|
606
|
+
...metadata,
|
|
607
|
+
ruid: String(actor.userId),
|
|
608
|
+
loginType: metadata.loginType ?? 'impersonation'
|
|
609
|
+
};
|
|
610
|
+
const tokens = await this.issueTokens(apiReq, targetUser, impersonationMetadata);
|
|
611
|
+
const publicUser = this.storage.filterUser(targetUser);
|
|
612
|
+
return [200, { ...tokens, user: publicUser }];
|
|
613
|
+
}
|
|
614
|
+
async deleteImpersonation(apiReq) {
|
|
615
|
+
this.assertAuthReady();
|
|
616
|
+
const actor = await this.resolveActorContext(apiReq);
|
|
617
|
+
const metadata = this.buildImpersonationMetadata((apiReq.req.body ?? {}));
|
|
618
|
+
metadata.loginType = metadata.loginType ?? 'impersonation-end';
|
|
619
|
+
const tokens = await this.issueTokens(apiReq, actor.user, metadata);
|
|
620
|
+
const publicUser = this.storage.filterUser(actor.user);
|
|
621
|
+
return [200, { ...tokens, user: publicUser }];
|
|
622
|
+
}
|
|
623
|
+
async getUserFromPasskey(result, params) {
|
|
624
|
+
if (result.userId !== undefined) {
|
|
625
|
+
return this.getUserOrThrow(result.userId, 'User not found for passkey verification');
|
|
626
|
+
}
|
|
627
|
+
if (result.login) {
|
|
628
|
+
return this.getUserOrThrow(result.login, 'User not found for passkey verification');
|
|
629
|
+
}
|
|
630
|
+
if (params.userId !== undefined) {
|
|
631
|
+
return this.getUserOrThrow(params.userId, 'User not found for passkey verification');
|
|
632
|
+
}
|
|
633
|
+
if (params.login) {
|
|
634
|
+
return this.getUserOrThrow(params.login, 'User not found for passkey verification');
|
|
635
|
+
}
|
|
636
|
+
throw new ApiError({ code: 500, message: 'Passkey response missing user reference' });
|
|
637
|
+
}
|
|
638
|
+
async postOAuthStart(apiReq) {
|
|
639
|
+
if (typeof this.server.initiateOAuth !== 'function') {
|
|
640
|
+
throw new ApiError({ code: 501, message: 'External OAuth support is not configured' });
|
|
641
|
+
}
|
|
642
|
+
const params = {
|
|
643
|
+
provider: apiReq.req.params?.provider,
|
|
644
|
+
redirectUri: toStringOrNull(apiReq.req.body?.redirectUri) ?? undefined,
|
|
645
|
+
scope: this.normalizeScope(apiReq.req.body?.scope),
|
|
646
|
+
state: toStringOrNull(apiReq.req.body?.state) ?? undefined,
|
|
647
|
+
extras: typeof apiReq.req.body?.extras === 'object'
|
|
648
|
+
? apiReq.req.body.extras
|
|
649
|
+
: undefined
|
|
650
|
+
};
|
|
651
|
+
if (!params.provider) {
|
|
652
|
+
throw new ApiError({ code: 400, message: 'OAuth provider is required' });
|
|
653
|
+
}
|
|
654
|
+
const result = await this.server.initiateOAuth(params);
|
|
655
|
+
return [200, result];
|
|
656
|
+
}
|
|
657
|
+
async postOAuthCallback(apiReq) {
|
|
658
|
+
if (typeof this.server.completeOAuth !== 'function') {
|
|
659
|
+
throw new ApiError({ code: 501, message: 'External OAuth support is not configured' });
|
|
660
|
+
}
|
|
661
|
+
const params = {
|
|
662
|
+
provider: apiReq.req.params?.provider,
|
|
663
|
+
query: apiReq.req.query,
|
|
664
|
+
body: (apiReq.req.body ?? {})
|
|
665
|
+
};
|
|
666
|
+
if (!params.provider) {
|
|
667
|
+
throw new ApiError({ code: 400, message: 'OAuth provider is required' });
|
|
668
|
+
}
|
|
669
|
+
const result = await this.server.completeOAuth(params);
|
|
670
|
+
if (result.tokens?.accessToken && result.tokens.refreshToken) {
|
|
671
|
+
this.setJwtCookies(apiReq, {
|
|
672
|
+
accessToken: result.tokens.accessToken,
|
|
673
|
+
refreshToken: result.tokens.refreshToken
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return [200, result];
|
|
677
|
+
}
|
|
678
|
+
async postOAuthAuthorize(apiReq) {
|
|
679
|
+
if (typeof this.storage.getClient !== 'function' || typeof this.storage.createAuthCode !== 'function') {
|
|
680
|
+
throw new ApiError({ code: 501, message: 'OAuth authorization storage is not configured' });
|
|
681
|
+
}
|
|
682
|
+
const body = (apiReq.req.body ?? {});
|
|
683
|
+
const clientId = toStringOrNull(body.clientId);
|
|
684
|
+
const redirectUri = toStringOrNull(body.redirectUri);
|
|
685
|
+
const scope = toScopeArray(body.scope) ?? [];
|
|
686
|
+
const state = toStringOrNull(body.state) ?? undefined;
|
|
687
|
+
const codeChallenge = toStringOrNull(body.codeChallenge) ?? undefined;
|
|
688
|
+
const codeChallengeMethod = toStringOrNull(body.codeChallengeMethod) ?? undefined;
|
|
689
|
+
if (!clientId) {
|
|
690
|
+
throw new ApiError({ code: 400, message: 'clientId is required' });
|
|
691
|
+
}
|
|
692
|
+
if (!redirectUri) {
|
|
693
|
+
throw new ApiError({ code: 400, message: 'redirectUri is required' });
|
|
694
|
+
}
|
|
695
|
+
const client = await this.storage.getClient(clientId);
|
|
696
|
+
if (!client) {
|
|
697
|
+
throw new ApiError({ code: 400, message: 'Unknown client_id' });
|
|
698
|
+
}
|
|
699
|
+
this.assertRedirectUriAllowed(client, redirectUri);
|
|
700
|
+
const resolvedScope = this.resolveScope(client, scope);
|
|
701
|
+
const user = await this.resolveUserForOAuth(apiReq, body);
|
|
702
|
+
const codeRecord = await this.storage.createAuthCode({
|
|
703
|
+
clientId,
|
|
704
|
+
userId: this.storage.getUserId(user),
|
|
705
|
+
redirectUri,
|
|
706
|
+
scope: resolvedScope,
|
|
707
|
+
codeChallenge,
|
|
708
|
+
codeChallengeMethod: codeChallengeMethod === 'S256' ? 'S256' : codeChallengeMethod === 'plain' ? 'plain' : undefined,
|
|
709
|
+
expiresInSeconds: 300
|
|
710
|
+
});
|
|
711
|
+
const redirect = new URL(redirectUri);
|
|
712
|
+
redirect.searchParams.set('code', codeRecord.code);
|
|
713
|
+
if (state) {
|
|
714
|
+
redirect.searchParams.set('state', state);
|
|
715
|
+
}
|
|
716
|
+
return [200, { code: codeRecord.code, redirectUri: redirect.toString(), state }];
|
|
717
|
+
}
|
|
718
|
+
async postOAuthToken(apiReq) {
|
|
719
|
+
if (typeof this.storage.getClient !== 'function' || typeof this.storage.consumeAuthCode !== 'function') {
|
|
720
|
+
throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
|
|
721
|
+
}
|
|
722
|
+
const body = (apiReq.req.body ?? {});
|
|
723
|
+
const grantType = toStringOrNull(body.grant_type);
|
|
724
|
+
if (!grantType) {
|
|
725
|
+
throw new ApiError({ code: 400, message: 'grant_type is required' });
|
|
726
|
+
}
|
|
727
|
+
const { client, clientSecretProvided } = await this.resolveClientAuthentication(apiReq, body);
|
|
728
|
+
switch (grantType) {
|
|
729
|
+
case 'authorization_code':
|
|
730
|
+
return this.handleAuthorizationCodeGrant(apiReq, body, client, clientSecretProvided);
|
|
731
|
+
case 'refresh_token':
|
|
732
|
+
return this.handleRefreshTokenGrant(apiReq, body, client);
|
|
733
|
+
default:
|
|
734
|
+
throw new ApiError({ code: 400, message: `Unsupported grant_type: ${grantType}` });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async handleAuthorizationCodeGrant(apiReq, body, client, clientSecretProvided) {
|
|
738
|
+
const code = toStringOrNull(body.code);
|
|
739
|
+
const redirectUri = toStringOrNull(body.redirect_uri);
|
|
740
|
+
const codeVerifier = toStringOrNull(body.code_verifier);
|
|
741
|
+
const consumeAuthCode = this.storage.consumeAuthCode?.bind(this.storage);
|
|
742
|
+
if (!consumeAuthCode) {
|
|
743
|
+
throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
|
|
744
|
+
}
|
|
745
|
+
if (!code) {
|
|
746
|
+
throw new ApiError({ code: 400, message: 'code is required for authorization_code grant' });
|
|
747
|
+
}
|
|
748
|
+
if (!redirectUri) {
|
|
749
|
+
throw new ApiError({ code: 400, message: 'redirect_uri is required for authorization_code grant' });
|
|
750
|
+
}
|
|
751
|
+
this.assertRedirectUriAllowed(client, redirectUri);
|
|
752
|
+
const record = await consumeAuthCode(code, client.clientId);
|
|
753
|
+
if (!record) {
|
|
754
|
+
throw new ApiError({ code: 400, message: 'Invalid or expired authorization code' });
|
|
755
|
+
}
|
|
756
|
+
if (record.expiresAt.getTime() < Date.now()) {
|
|
757
|
+
throw new ApiError({ code: 400, message: 'Authorization code expired' });
|
|
758
|
+
}
|
|
759
|
+
if (record.redirectUri !== redirectUri) {
|
|
760
|
+
throw new ApiError({ code: 400, message: 'redirect_uri mismatch' });
|
|
761
|
+
}
|
|
762
|
+
if (record.codeChallenge) {
|
|
763
|
+
if (!codeVerifier) {
|
|
764
|
+
throw new ApiError({ code: 400, message: 'code_verifier is required for this authorization code' });
|
|
765
|
+
}
|
|
766
|
+
if (record.codeChallengeMethod === 'S256') {
|
|
767
|
+
if (sha256Base64Url(codeVerifier) !== record.codeChallenge) {
|
|
768
|
+
throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
else if (record.codeChallengeMethod === 'plain') {
|
|
772
|
+
if (codeVerifier !== record.codeChallenge) {
|
|
773
|
+
throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
else if (!clientSecretProvided && client.clientSecret) {
|
|
778
|
+
throw new ApiError({ code: 400, message: 'Client authentication required when no PKCE challenge present' });
|
|
779
|
+
}
|
|
780
|
+
const user = await this.getUserOrThrow(record.userId, 'User not found');
|
|
781
|
+
const resolvedScope = Array.isArray(record.scope) ? record.scope : [];
|
|
782
|
+
const tokens = await this.issueTokens(apiReq, user, {
|
|
783
|
+
clientId: client.clientId,
|
|
784
|
+
scope: resolvedScope,
|
|
785
|
+
label: resolvedScope.join(' '),
|
|
786
|
+
loginType: 'oauth'
|
|
787
|
+
});
|
|
788
|
+
this.clearOAuthCookies(apiReq);
|
|
789
|
+
return [200, this.buildTokenResponse(tokens, client, resolvedScope)];
|
|
790
|
+
}
|
|
791
|
+
async handleRefreshTokenGrant(apiReq, body, client) {
|
|
792
|
+
const refreshToken = toStringOrNull(body.refresh_token);
|
|
793
|
+
if (!refreshToken) {
|
|
794
|
+
throw new ApiError({ code: 400, message: 'refresh_token is required for refresh_token grant' });
|
|
795
|
+
}
|
|
796
|
+
const stored = await this.storage.getToken({ refreshToken, clientId: client.clientId });
|
|
797
|
+
if (!stored) {
|
|
798
|
+
throw new ApiError({ code: 400, message: 'Invalid refresh_token' });
|
|
799
|
+
}
|
|
800
|
+
const verify = this.server.jwtVerify(refreshToken, this.server.config.refreshSecret);
|
|
801
|
+
if (!verify.success || !verify.data) {
|
|
802
|
+
const expired = 'expired' in verify && verify.expired;
|
|
803
|
+
throw new ApiError({
|
|
804
|
+
code: expired ? 403 : 401,
|
|
805
|
+
message: verify.error ?? 'Unable to verify refresh token'
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
if (stored.clientId && stored.clientId !== client.clientId) {
|
|
809
|
+
throw new ApiError({ code: 400, message: 'Refresh token issued to another client' });
|
|
810
|
+
}
|
|
811
|
+
const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
|
|
812
|
+
const tokens = await this.issueTokens(apiReq, user, {
|
|
813
|
+
clientId: client.clientId,
|
|
814
|
+
scope: stored.scope,
|
|
815
|
+
label: Array.isArray(stored.scope) ? stored.scope.join(' ') : stored.label,
|
|
816
|
+
fingerprint: stored.fingerprint,
|
|
817
|
+
loginType: stored.loginType ?? 'oauth'
|
|
818
|
+
});
|
|
819
|
+
this.clearOAuthCookies(apiReq);
|
|
820
|
+
const scope = Array.isArray(stored.scope)
|
|
821
|
+
? stored.scope
|
|
822
|
+
: typeof stored.scope === 'string'
|
|
823
|
+
? stored.scope.split(/\s+/).filter((entry) => entry.length > 0)
|
|
824
|
+
: [];
|
|
825
|
+
return [200, this.buildTokenResponse(tokens, client, scope)];
|
|
826
|
+
}
|
|
827
|
+
clearOAuthCookies(apiReq) {
|
|
828
|
+
this.setJwtCookies(apiReq, { accessToken: null, refreshToken: null });
|
|
829
|
+
}
|
|
830
|
+
buildTokenResponse(tokens, client, scope) {
|
|
831
|
+
const conf = this.server.config;
|
|
832
|
+
return {
|
|
833
|
+
access_token: tokens.accessToken,
|
|
834
|
+
refresh_token: tokens.refreshToken,
|
|
835
|
+
token_type: 'Bearer',
|
|
836
|
+
expires_in: conf.accessExpiry,
|
|
837
|
+
scope: scope.join(' '),
|
|
838
|
+
client_id: client.clientId
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
resolveScope(client, requested) {
|
|
842
|
+
const allowed = Array.isArray(client.scope) ? client.scope : [];
|
|
843
|
+
if (allowed.length === 0) {
|
|
844
|
+
return requested.length > 0 ? requested : [];
|
|
845
|
+
}
|
|
846
|
+
if (requested.length === 0) {
|
|
847
|
+
return allowed;
|
|
848
|
+
}
|
|
849
|
+
const filtered = requested.filter((entry) => allowed.includes(entry));
|
|
850
|
+
if (filtered.length === 0) {
|
|
851
|
+
throw new ApiError({ code: 400, message: 'Requested scope is not permitted for this client' });
|
|
852
|
+
}
|
|
853
|
+
return filtered;
|
|
854
|
+
}
|
|
855
|
+
async resolveClientAuthentication(apiReq, body) {
|
|
856
|
+
if (typeof this.storage.getClient !== 'function') {
|
|
857
|
+
throw new ApiError({ code: 501, message: 'OAuth client storage is not configured' });
|
|
858
|
+
}
|
|
859
|
+
let clientId = null;
|
|
860
|
+
let clientSecret = null;
|
|
861
|
+
let secretProvided = false;
|
|
862
|
+
const header = apiReq.req.headers.authorization;
|
|
863
|
+
if (typeof header === 'string' && header.startsWith('Basic ')) {
|
|
864
|
+
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
|
865
|
+
const idx = decoded.indexOf(':');
|
|
866
|
+
if (idx === -1) {
|
|
867
|
+
throw new ApiError({ code: 400, message: 'Invalid basic authorization header' });
|
|
868
|
+
}
|
|
869
|
+
clientId = decoded.slice(0, idx);
|
|
870
|
+
clientSecret = decoded.slice(idx + 1);
|
|
871
|
+
secretProvided = true;
|
|
872
|
+
}
|
|
873
|
+
if (!clientId) {
|
|
874
|
+
clientId = toStringOrNull(body.client_id);
|
|
875
|
+
}
|
|
876
|
+
if (clientSecret === null && typeof body.client_secret === 'string') {
|
|
877
|
+
clientSecret = body.client_secret;
|
|
878
|
+
secretProvided = true;
|
|
879
|
+
}
|
|
880
|
+
if (!clientId) {
|
|
881
|
+
throw new ApiError({ code: 400, message: 'client_id is required' });
|
|
882
|
+
}
|
|
883
|
+
const client = await this.storage.getClient(clientId);
|
|
884
|
+
if (!client) {
|
|
885
|
+
throw new ApiError({ code: 400, message: 'Unknown client_id' });
|
|
886
|
+
}
|
|
887
|
+
const requiresSecret = !!client.clientSecret;
|
|
888
|
+
if (requiresSecret) {
|
|
889
|
+
if (!secretProvided) {
|
|
890
|
+
throw new ApiError({ code: 400, message: 'Client authentication is required' });
|
|
891
|
+
}
|
|
892
|
+
let valid = false;
|
|
893
|
+
if (this.storage.verifyClientSecret) {
|
|
894
|
+
const verifySecret = this.storage.verifyClientSecret.bind(this.storage);
|
|
895
|
+
valid = await verifySecret(client, clientSecret);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
valid = client.clientSecret === clientSecret;
|
|
899
|
+
}
|
|
900
|
+
if (!valid) {
|
|
901
|
+
throw new ApiError({ code: 401, message: 'Invalid client credentials' });
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return { client, clientSecretProvided: secretProvided };
|
|
905
|
+
}
|
|
906
|
+
assertRedirectUriAllowed(client, redirectUri) {
|
|
907
|
+
if (client.redirectUris.length === 0) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (!client.redirectUris.includes(redirectUri)) {
|
|
911
|
+
throw new ApiError({ code: 400, message: 'redirect_uri not registered for client' });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
async resolveUserForOAuth(apiReq, body) {
|
|
915
|
+
const refreshToken = this.extractRefreshToken(apiReq, body);
|
|
916
|
+
if (refreshToken) {
|
|
917
|
+
const stored = await this.storage.getToken({ refreshToken });
|
|
918
|
+
if (stored) {
|
|
919
|
+
return this.getUserOrThrow(stored.userId, 'User not found for authorization');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const login = toStringOrNull(body.login);
|
|
923
|
+
const password = toStringOrNull(body.password);
|
|
924
|
+
if (login && password) {
|
|
925
|
+
const user = await this.storage.getUser(login);
|
|
926
|
+
if (!user) {
|
|
927
|
+
throw new ApiError({ code: 400, message: 'Invalid credentials', errors: { login: 'Unknown user' } });
|
|
928
|
+
}
|
|
929
|
+
const hash = this.storage.getUserPasswordHash(user);
|
|
930
|
+
const verified = await this.storage.verifyPassword(password, hash);
|
|
931
|
+
if (!verified) {
|
|
932
|
+
throw new ApiError({
|
|
933
|
+
code: 400,
|
|
934
|
+
message: 'Invalid credentials',
|
|
935
|
+
errors: { password: 'Wrong password' }
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return user;
|
|
939
|
+
}
|
|
940
|
+
throw new ApiError({ code: 401, message: 'Authorization requires user authentication' });
|
|
941
|
+
}
|
|
942
|
+
defineRoutes() {
|
|
943
|
+
const routes = [
|
|
944
|
+
{
|
|
945
|
+
method: 'post',
|
|
946
|
+
path: '/v1/login',
|
|
947
|
+
handler: (req) => this.postLogin(req),
|
|
948
|
+
auth: { type: 'none', req: 'any' }
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
method: 'post',
|
|
952
|
+
path: '/v1/refresh',
|
|
953
|
+
handler: (req) => this.postRefresh(req),
|
|
954
|
+
auth: { type: 'none', req: 'any' }
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
method: 'post',
|
|
958
|
+
path: '/v1/logout',
|
|
959
|
+
handler: (req) => this.postLogout(req),
|
|
960
|
+
auth: { type: 'maybe', req: 'any' }
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
method: 'post',
|
|
964
|
+
path: '/v1/whoami',
|
|
965
|
+
handler: (req) => this.postWhoAmI(req),
|
|
966
|
+
auth: { type: 'maybe', req: 'any' }
|
|
967
|
+
},
|
|
968
|
+
{
|
|
969
|
+
method: 'post',
|
|
970
|
+
path: '/v1/impersonations',
|
|
971
|
+
handler: (req) => this.postImpersonation(req),
|
|
972
|
+
auth: { type: 'strict', req: 'any' }
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
method: 'delete',
|
|
976
|
+
path: '/v1/impersonations',
|
|
977
|
+
handler: (req) => this.deleteImpersonation(req),
|
|
978
|
+
auth: { type: 'strict', req: 'any' }
|
|
979
|
+
}
|
|
980
|
+
];
|
|
981
|
+
const storage = this.storage;
|
|
982
|
+
const passkeysSupported = typeof storage.createPasskeyChallenge === 'function' && typeof storage.verifyPasskeyResponse === 'function';
|
|
983
|
+
if (passkeysSupported) {
|
|
984
|
+
routes.push({
|
|
985
|
+
method: 'post',
|
|
986
|
+
path: '/v1/passkeys/challenge',
|
|
987
|
+
handler: (req) => this.postPasskeyChallenge(req),
|
|
988
|
+
auth: { type: 'none', req: 'any' }
|
|
989
|
+
}, {
|
|
990
|
+
method: 'post',
|
|
991
|
+
path: '/v1/passkeys/verify',
|
|
992
|
+
handler: (req) => this.postPasskeyVerify(req),
|
|
993
|
+
auth: { type: 'none', req: 'any' }
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
const externalOAuthSupported = typeof this.server.initiateOAuth === 'function' && typeof this.server.completeOAuth === 'function';
|
|
997
|
+
if (externalOAuthSupported) {
|
|
998
|
+
routes.push({
|
|
999
|
+
method: 'post',
|
|
1000
|
+
path: '/v1/oauth2/:provider/start',
|
|
1001
|
+
handler: (req) => this.postOAuthStart(req),
|
|
1002
|
+
auth: { type: 'none', req: 'any' }
|
|
1003
|
+
}, {
|
|
1004
|
+
method: 'post',
|
|
1005
|
+
path: '/v1/oauth2/:provider/callback',
|
|
1006
|
+
handler: (req) => this.postOAuthCallback(req),
|
|
1007
|
+
auth: { type: 'none', req: 'any' }
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
const oauthStorageSupported = typeof storage.getClient === 'function' &&
|
|
1011
|
+
typeof storage.createAuthCode === 'function' &&
|
|
1012
|
+
typeof storage.consumeAuthCode === 'function';
|
|
1013
|
+
if (oauthStorageSupported) {
|
|
1014
|
+
routes.push({
|
|
1015
|
+
method: 'post',
|
|
1016
|
+
path: '/v1/oauth2/authorize',
|
|
1017
|
+
handler: (req) => this.postOAuthAuthorize(req),
|
|
1018
|
+
auth: { type: 'maybe', req: 'any' }
|
|
1019
|
+
}, {
|
|
1020
|
+
method: 'post',
|
|
1021
|
+
path: '/v1/oauth2/token',
|
|
1022
|
+
handler: (req) => this.postOAuthToken(req),
|
|
1023
|
+
auth: { type: 'none', req: 'any' }
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
return routes;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
AuthModule.defaultNamespace = '/auth';
|
|
1030
|
+
export default AuthModule;
|