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