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