@byline/admin 4.19.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fields/field-services-context.d.ts +1 -1
- package/dist/fields/field-services-types.d.ts +5 -2
- package/dist/forms/available-locales-widget.d.ts +3 -2
- package/dist/forms/available-locales-widget.js +4 -2
- package/dist/forms/document-actions.d.ts +3 -2
- package/dist/forms/document-actions.js +32 -14
- package/dist/forms/form-renderer.d.ts +12 -3
- package/dist/forms/form-renderer.js +98 -8
- package/dist/forms/form-renderer.module.js +1 -0
- package/dist/forms/form-renderer_module.css +12 -0
- package/dist/forms/form-status-display.d.ts +3 -2
- package/dist/forms/form-status-display.js +5 -2
- package/dist/forms/path-widget.d.ts +2 -1
- package/dist/forms/path-widget.js +7 -2
- package/dist/forms/scheduled-publication-control.d.ts +2 -1
- package/dist/forms/scheduled-publication-control.js +12 -8
- package/dist/forms/tree-placement-widget.d.ts +5 -1
- package/dist/forms/tree-placement-widget.js +14 -7
- package/dist/forms/upload-executor.d.ts +2 -2
- package/dist/modules/admin-account/components/change-password.js +18 -0
- package/dist/modules/admin-account/service.d.ts +2 -6
- package/dist/modules/admin-account/service.js +2 -1
- package/dist/modules/admin-users/repository.d.ts +10 -1
- package/dist/modules/auth/index.d.ts +3 -1
- package/dist/modules/auth/index.js +1 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
- package/dist/modules/auth/jwt-session-provider.js +222 -70
- package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
- package/dist/modules/auth/login-sessions-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
- package/dist/modules/auth/resolve-actor.d.ts +3 -0
- package/dist/modules/auth/resolve-actor.js +5 -2
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
- package/dist/store.d.ts +15 -2
- package/package.json +17 -17
- package/src/fields/field-services-types.ts +10 -2
- package/src/forms/available-locales-widget.tsx +5 -2
- package/src/forms/document-actions.tsx +55 -20
- package/src/forms/form-renderer-submit.test.tsx +131 -2
- package/src/forms/form-renderer.module.css +20 -0
- package/src/forms/form-renderer.tsx +122 -7
- package/src/forms/form-status-display.tsx +6 -1
- package/src/forms/path-widget.tsx +8 -3
- package/src/forms/scheduled-publication-control.tsx +16 -7
- package/src/forms/tree-placement-widget.tsx +34 -7
- package/src/modules/admin-account/components/change-password.test.tsx +72 -0
- package/src/modules/admin-account/components/change-password.tsx +15 -4
- package/src/modules/admin-account/service.ts +8 -7
- package/src/modules/admin-users/repository.ts +10 -1
- package/src/modules/auth/index.ts +13 -1
- package/src/modules/auth/jwt-session-provider.ts +276 -91
- package/src/modules/auth/login-sessions-repository.ts +25 -0
- package/src/modules/auth/refresh-tokens-repository.ts +12 -9
- package/src/modules/auth/resolve-actor.ts +10 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
- package/src/store.ts +22 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import { ERR_ACCOUNT_DISABLED, ERR_INVALID_CREDENTIALS, ERR_INVALID_TOKEN, ERR_REVOKED_TOKEN } from "@byline/auth";
|
|
3
|
-
import {
|
|
2
|
+
import { ERR_ACCESS_EXPIRED, ERR_ACCOUNT_DISABLED, ERR_INVALID_CREDENTIALS, ERR_INVALID_TOKEN, ERR_REVOKED_TOKEN, ERR_SESSION_CHANGED } from "@byline/auth";
|
|
3
|
+
import { passwordSignInSchema } from "@byline/core/validation";
|
|
4
|
+
import { SignJWT, compactVerify, jwtVerify } from "jose";
|
|
4
5
|
import { v7 } from "uuid";
|
|
5
6
|
import { verifyPassword } from "./password.js";
|
|
6
|
-
import { resolveActor } from "./resolve-actor.js";
|
|
7
|
+
import { resolveActor, resolveActorFromUser } from "./resolve-actor.js";
|
|
7
8
|
function _check_private_redeclaration(obj, privateCollection) {
|
|
8
9
|
if (privateCollection.has(obj)) throw new TypeError("Cannot initialize the same private elements twice on an object");
|
|
9
10
|
}
|
|
@@ -51,18 +52,19 @@ const CAPABILITIES = {
|
|
|
51
52
|
magicLink: false,
|
|
52
53
|
sso: false
|
|
53
54
|
};
|
|
54
|
-
var _store = /*#__PURE__*/ new WeakMap(), _signingKey = /*#__PURE__*/ new WeakMap(), _issuer = /*#__PURE__*/ new WeakMap(), _accessTtl = /*#__PURE__*/ new WeakMap(), _refreshTtl = /*#__PURE__*/ new WeakMap(), _now = /*#__PURE__*/ new WeakMap(), _issueTokens = /*#__PURE__*/ new WeakSet(), _signAccessToken = /*#__PURE__*/ new WeakSet();
|
|
55
|
+
var _onEvent = /*#__PURE__*/ new WeakMap(), _store = /*#__PURE__*/ new WeakMap(), _signingKey = /*#__PURE__*/ new WeakMap(), _issuer = /*#__PURE__*/ new WeakMap(), _accessTtl = /*#__PURE__*/ new WeakMap(), _refreshTtl = /*#__PURE__*/ new WeakMap(), _now = /*#__PURE__*/ new WeakMap(), _emit = /*#__PURE__*/ new WeakSet(), _observedLogins = /*#__PURE__*/ new WeakSet(), _issueTokens = /*#__PURE__*/ new WeakSet(), _signAccessToken = /*#__PURE__*/ new WeakSet();
|
|
55
56
|
class JwtSessionProvider {
|
|
56
57
|
async signInWithPassword(args) {
|
|
58
|
+
const credentials = passwordSignInSchema.parse(args);
|
|
57
59
|
const users = _class_private_field_get(this, _store).adminUsers;
|
|
58
|
-
const row = await users.getByEmailForSignIn(
|
|
60
|
+
const row = await users.getByEmailForSignIn(credentials.email);
|
|
59
61
|
if (!row) {
|
|
60
|
-
await verifyPassword(
|
|
62
|
+
await verifyPassword(credentials.password, DUMMY_HASH_FOR_TIMING);
|
|
61
63
|
throw ERR_INVALID_CREDENTIALS({
|
|
62
64
|
message: 'invalid credentials'
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
|
-
const ok = await verifyPassword(
|
|
67
|
+
const ok = await verifyPassword(credentials.password, row.password_hash);
|
|
66
68
|
if (!ok) {
|
|
67
69
|
await users.recordLoginFailure(row.id);
|
|
68
70
|
throw ERR_INVALID_CREDENTIALS({
|
|
@@ -72,101 +74,196 @@ class JwtSessionProvider {
|
|
|
72
74
|
if (!row.is_enabled) throw ERR_ACCOUNT_DISABLED({
|
|
73
75
|
message: 'account disabled'
|
|
74
76
|
});
|
|
75
|
-
await
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
const previous = await _class_private_method_get(this, _observedLogins, observedLogins).call(this, args);
|
|
78
|
+
const withLocks = (work)=>previous.length ? _class_private_field_get(this, _store).withSessionLocks([
|
|
79
|
+
row.id,
|
|
80
|
+
...previous.map((login)=>login.admin_user_id)
|
|
81
|
+
], work) : _class_private_field_get(this, _store).withSessionLock(row.id, (store)=>work(store));
|
|
82
|
+
return withLocks(async (store)=>{
|
|
83
|
+
const current = await store.adminUsers.getByIdForSignIn(row.id);
|
|
84
|
+
if (!current?.is_enabled) throw ERR_ACCOUNT_DISABLED({
|
|
85
|
+
message: 'account disabled'
|
|
86
|
+
});
|
|
87
|
+
if (current.password_hash !== row.password_hash || current.session_version !== row.session_version) throw ERR_INVALID_CREDENTIALS({
|
|
88
|
+
message: 'credentials changed during sign-in'
|
|
89
|
+
});
|
|
90
|
+
for (const observed of previous){
|
|
91
|
+
const login = await store.loginSessions.findById(observed.id);
|
|
92
|
+
if (login && login.admin_user_id === observed.admin_user_id) await store.loginSessions.revoke(login.id, _class_private_field_get(this, _now).call(this));
|
|
93
|
+
}
|
|
94
|
+
await store.adminUsers.recordLoginSuccess(row.id, args.ip ?? null);
|
|
95
|
+
const actor = await resolveActorFromUser(store, current);
|
|
96
|
+
if (!actor) throw ERR_ACCOUNT_DISABLED({
|
|
97
|
+
message: 'account disabled'
|
|
98
|
+
});
|
|
99
|
+
const tokens = await _class_private_method_get(this, _issueTokens, issueTokens).call(this, store, {
|
|
100
|
+
adminUserId: row.id,
|
|
101
|
+
sessionVersion: current.session_version,
|
|
102
|
+
ip: args.ip ?? null,
|
|
103
|
+
userAgent: args.userAgent ?? null
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
...tokens,
|
|
107
|
+
actor
|
|
108
|
+
};
|
|
84
109
|
});
|
|
85
|
-
return {
|
|
86
|
-
...tokens,
|
|
87
|
-
actor
|
|
88
|
-
};
|
|
89
110
|
}
|
|
90
111
|
async verifyAccessToken(token) {
|
|
91
112
|
let payload;
|
|
92
113
|
try {
|
|
93
114
|
const result = await jwtVerify(token, _class_private_field_get(this, _signingKey), {
|
|
94
|
-
issuer: _class_private_field_get(this, _issuer)
|
|
115
|
+
issuer: _class_private_field_get(this, _issuer),
|
|
116
|
+
algorithms: [
|
|
117
|
+
'HS256'
|
|
118
|
+
],
|
|
119
|
+
currentDate: _class_private_field_get(this, _now).call(this),
|
|
120
|
+
requiredClaims: [
|
|
121
|
+
'sub',
|
|
122
|
+
'iat',
|
|
123
|
+
'exp',
|
|
124
|
+
'jti',
|
|
125
|
+
'sv',
|
|
126
|
+
'sid'
|
|
127
|
+
]
|
|
95
128
|
});
|
|
96
129
|
payload = result.payload;
|
|
97
130
|
} catch (err) {
|
|
131
|
+
if (err instanceof Error && 'code' in err && 'ERR_JWT_EXPIRED' === err.code) throw ERR_ACCESS_EXPIRED({
|
|
132
|
+
message: 'access token expired'
|
|
133
|
+
});
|
|
98
134
|
throw ERR_INVALID_TOKEN({
|
|
99
135
|
message: 'access token verification failed',
|
|
100
136
|
cause: err
|
|
101
137
|
});
|
|
102
138
|
}
|
|
103
|
-
if ('access' !== payload.typ) throw ERR_INVALID_TOKEN({
|
|
139
|
+
if ('access' !== payload.typ || 'string' != typeof payload.sub || !validSid(payload.sid) || !Number.isSafeInteger(payload.sv) || payload.sv < 0) throw ERR_INVALID_TOKEN({
|
|
104
140
|
message: 'unexpected token type'
|
|
105
141
|
});
|
|
106
|
-
const
|
|
142
|
+
const current = await _class_private_field_get(this, _store).adminUsers.getByIdForSignIn(payload.sub);
|
|
143
|
+
if (!current?.is_enabled) throw ERR_ACCOUNT_DISABLED({
|
|
144
|
+
message: 'account disabled or deleted'
|
|
145
|
+
});
|
|
146
|
+
if (current.session_version !== payload.sv) throw ERR_REVOKED_TOKEN({
|
|
147
|
+
message: 'account session generation changed'
|
|
148
|
+
});
|
|
149
|
+
const login = await _class_private_field_get(this, _store).loginSessions.findById(payload.sid);
|
|
150
|
+
if (!login || login.admin_user_id !== payload.sub || login.session_version !== payload.sv || null != login.revoked_at || login.expires_at.getTime() <= _class_private_field_get(this, _now).call(this).getTime()) throw ERR_REVOKED_TOKEN({
|
|
151
|
+
message: 'login revoked or expired'
|
|
152
|
+
});
|
|
153
|
+
const actor = await resolveActorFromUser(_class_private_field_get(this, _store), current);
|
|
107
154
|
if (!actor) throw ERR_ACCOUNT_DISABLED({
|
|
108
155
|
message: 'account disabled or deleted'
|
|
109
156
|
});
|
|
110
157
|
return {
|
|
111
|
-
actor
|
|
158
|
+
actor,
|
|
159
|
+
sessionId: payload.sid
|
|
112
160
|
};
|
|
113
161
|
}
|
|
114
162
|
async refreshSession(args) {
|
|
115
|
-
|
|
163
|
+
_class_private_method_get(this, _emit, emit).call(this, 'refresh_attempted');
|
|
116
164
|
const hash = hashToken(args.refreshToken);
|
|
117
|
-
const
|
|
118
|
-
if (!
|
|
165
|
+
const observed = await _class_private_field_get(this, _store).refreshTokens.findByHash(hash);
|
|
166
|
+
if (!observed) throw ERR_INVALID_TOKEN({
|
|
119
167
|
message: 'refresh token not recognised'
|
|
120
168
|
});
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
169
|
+
const outcome = await _class_private_field_get(this, _store).withSessionLock(observed.admin_user_id, async (store, user)=>{
|
|
170
|
+
if (!user?.is_enabled) throw ERR_ACCOUNT_DISABLED({
|
|
171
|
+
message: 'account disabled or deleted'
|
|
172
|
+
});
|
|
173
|
+
const refreshTokens = store.refreshTokens;
|
|
174
|
+
const row = await refreshTokens.findByHash(hash);
|
|
175
|
+
if (!row) throw ERR_INVALID_TOKEN({
|
|
176
|
+
message: 'refresh token not recognised'
|
|
177
|
+
});
|
|
178
|
+
const now = _class_private_field_get(this, _now).call(this);
|
|
179
|
+
const login = row.sid ? await store.loginSessions.findById(row.sid) : null;
|
|
180
|
+
if (!login || login.admin_user_id !== user.id || login.session_version !== user.session_version || null != login.revoked_at) throw ERR_REVOKED_TOKEN({
|
|
181
|
+
message: 'login revoked'
|
|
182
|
+
});
|
|
183
|
+
if (args.expectedSessionId && args.expectedSessionId !== login.id) throw ERR_SESSION_CHANGED({
|
|
184
|
+
message: 'session changed before renewal'
|
|
185
|
+
});
|
|
186
|
+
if (null != row.revoked_at) {
|
|
187
|
+
if (null != row.rotated_to_id) {
|
|
188
|
+
await store.loginSessions.revoke(login.id, now);
|
|
189
|
+
await refreshTokens.revokeChain(row.id, now);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
contested: null != row.rotated_to_id,
|
|
193
|
+
error: ERR_REVOKED_TOKEN({
|
|
194
|
+
message: 'refresh token has been revoked'
|
|
195
|
+
})
|
|
196
|
+
};
|
|
128
197
|
}
|
|
129
|
-
throw ERR_REVOKED_TOKEN({
|
|
130
|
-
message: '
|
|
198
|
+
if (row.session_version !== user.session_version) throw ERR_REVOKED_TOKEN({
|
|
199
|
+
message: 'account session generation changed'
|
|
200
|
+
});
|
|
201
|
+
if (row.expires_at.getTime() <= now.getTime() || login.expires_at.getTime() <= now.getTime()) throw ERR_INVALID_TOKEN({
|
|
202
|
+
message: 'refresh token expired'
|
|
203
|
+
});
|
|
204
|
+
const newId = v7();
|
|
205
|
+
const newRefreshPlain = generateOpaqueToken();
|
|
206
|
+
const refreshExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _refreshTtl));
|
|
207
|
+
await refreshTokens.issue({
|
|
208
|
+
id: newId,
|
|
209
|
+
admin_user_id: row.admin_user_id,
|
|
210
|
+
token_hash: hashToken(newRefreshPlain),
|
|
211
|
+
session_version: user.session_version,
|
|
212
|
+
sid: login.id,
|
|
213
|
+
expires_at: refreshExpiresAt,
|
|
214
|
+
user_agent: args.userAgent ?? null,
|
|
215
|
+
ip: args.ip ?? null
|
|
131
216
|
});
|
|
217
|
+
await store.loginSessions.extend(login.id, refreshExpiresAt);
|
|
218
|
+
await refreshTokens.markRotated(row.id, newId, now);
|
|
219
|
+
const accessToken = await _class_private_method_get(this, _signAccessToken, signAccessToken).call(this, row.admin_user_id, user.session_version, login.id, now);
|
|
220
|
+
return {
|
|
221
|
+
tokens: {
|
|
222
|
+
sessionId: login.id,
|
|
223
|
+
accessToken,
|
|
224
|
+
refreshToken: newRefreshPlain,
|
|
225
|
+
accessTokenExpiresAt: new Date(now.getTime() + 1000 * _class_private_field_get(this, _accessTtl)),
|
|
226
|
+
refreshTokenExpiresAt: refreshExpiresAt
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
if ('error' in outcome) {
|
|
231
|
+
if (outcome.contested) _class_private_method_get(this, _emit, emit).call(this, 'refresh_contested');
|
|
232
|
+
throw outcome.error;
|
|
132
233
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
136
|
-
const newId = v7();
|
|
137
|
-
const newRefreshPlain = generateOpaqueToken();
|
|
138
|
-
const newRefreshHash = hashToken(newRefreshPlain);
|
|
139
|
-
const refreshExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _refreshTtl));
|
|
140
|
-
await refreshTokens.issue({
|
|
141
|
-
id: newId,
|
|
142
|
-
admin_user_id: row.admin_user_id,
|
|
143
|
-
token_hash: newRefreshHash,
|
|
144
|
-
expires_at: refreshExpiresAt,
|
|
145
|
-
user_agent: args.userAgent ?? null,
|
|
146
|
-
ip: args.ip ?? null
|
|
147
|
-
});
|
|
148
|
-
await refreshTokens.markRotated(row.id, newId, now);
|
|
149
|
-
const accessToken = await _class_private_method_get(this, _signAccessToken, signAccessToken).call(this, row.admin_user_id, now);
|
|
150
|
-
const accessExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _accessTtl));
|
|
151
|
-
return {
|
|
152
|
-
accessToken,
|
|
153
|
-
refreshToken: newRefreshPlain,
|
|
154
|
-
accessTokenExpiresAt: accessExpiresAt,
|
|
155
|
-
refreshTokenExpiresAt: refreshExpiresAt
|
|
156
|
-
};
|
|
234
|
+
_class_private_method_get(this, _emit, emit).call(this, 'refresh_completed');
|
|
235
|
+
return outcome.tokens;
|
|
157
236
|
}
|
|
158
|
-
async revokeSession(
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
237
|
+
async revokeSession(args) {
|
|
238
|
+
const logins = await _class_private_method_get(this, _observedLogins, observedLogins).call(this, {
|
|
239
|
+
previousAccessToken: args.accessToken,
|
|
240
|
+
previousRefreshToken: args.refreshToken
|
|
241
|
+
});
|
|
242
|
+
if (args.expectedSessionId && logins.some((login)=>login.id !== args.expectedSessionId)) throw ERR_SESSION_CHANGED({
|
|
243
|
+
message: 'session changed before logout'
|
|
244
|
+
});
|
|
245
|
+
for (const login of logins)await _class_private_field_get(this, _store).withSessionLock(login.admin_user_id, async (store)=>{
|
|
246
|
+
await store.loginSessions.revoke(login.id, _class_private_field_get(this, _now).call(this));
|
|
247
|
+
});
|
|
248
|
+
if (args.refreshToken) {
|
|
249
|
+
const row = await _class_private_field_get(this, _store).refreshTokens.findByHash(hashToken(args.refreshToken));
|
|
250
|
+
if (row) await _class_private_field_get(this, _store).withSessionLock(row.admin_user_id, async (store)=>{
|
|
251
|
+
await store.refreshTokens.revokeChain(row.id, _class_private_field_get(this, _now).call(this));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
163
254
|
}
|
|
164
255
|
async resolveActor(adminUserId) {
|
|
165
256
|
return resolveActor(_class_private_field_get(this, _store), adminUserId);
|
|
166
257
|
}
|
|
167
258
|
constructor(config){
|
|
259
|
+
_class_private_method_init(this, _emit);
|
|
260
|
+
_class_private_method_init(this, _observedLogins);
|
|
168
261
|
_class_private_method_init(this, _issueTokens);
|
|
169
262
|
_class_private_method_init(this, _signAccessToken);
|
|
263
|
+
_class_private_field_init(this, _onEvent, {
|
|
264
|
+
writable: true,
|
|
265
|
+
value: void 0
|
|
266
|
+
});
|
|
170
267
|
_class_private_field_init(this, _store, {
|
|
171
268
|
writable: true,
|
|
172
269
|
value: void 0
|
|
@@ -192,6 +289,7 @@ class JwtSessionProvider {
|
|
|
192
289
|
value: void 0
|
|
193
290
|
});
|
|
194
291
|
this.capabilities = CAPABILITIES;
|
|
292
|
+
_class_private_field_set(this, _onEvent, config.onEvent);
|
|
195
293
|
_class_private_field_set(this, _store, config.store);
|
|
196
294
|
_class_private_field_set(this, _signingKey, 'string' == typeof config.signingSecret ? new TextEncoder().encode(config.signingSecret) : config.signingSecret);
|
|
197
295
|
if (_class_private_field_get(this, _signingKey).byteLength < 32) throw new Error('JwtSessionProvider: signingSecret must carry at least 32 bytes of entropy (256 bits)');
|
|
@@ -201,34 +299,85 @@ class JwtSessionProvider {
|
|
|
201
299
|
_class_private_field_set(this, _now, config.now ?? (()=>new Date()));
|
|
202
300
|
}
|
|
203
301
|
}
|
|
204
|
-
|
|
302
|
+
function emit(type) {
|
|
303
|
+
var _this, _this1;
|
|
304
|
+
try {
|
|
305
|
+
null == (_this = _class_private_field_get(_this1 = this, _onEvent)) || _this.call(_this1, {
|
|
306
|
+
type
|
|
307
|
+
});
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
async function observedLogins(args) {
|
|
311
|
+
const ids = new Set();
|
|
312
|
+
if (args.previousAccessToken) {
|
|
313
|
+
let claims;
|
|
314
|
+
try {
|
|
315
|
+
const verified = await compactVerify(args.previousAccessToken, _class_private_field_get(this, _signingKey), {
|
|
316
|
+
algorithms: [
|
|
317
|
+
'HS256'
|
|
318
|
+
]
|
|
319
|
+
});
|
|
320
|
+
claims = JSON.parse(new TextDecoder().decode(verified.payload));
|
|
321
|
+
} catch {}
|
|
322
|
+
if (claims?.iss === _class_private_field_get(this, _issuer) && 'access' === claims.typ && validSid(claims.sid) && 'string' == typeof claims.sub) {
|
|
323
|
+
const login = await _class_private_field_get(this, _store).loginSessions.findById(claims.sid);
|
|
324
|
+
if (login?.admin_user_id === claims.sub) ids.add(login.id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (args.previousRefreshToken) {
|
|
328
|
+
const token = await _class_private_field_get(this, _store).refreshTokens.findByHash(hashToken(args.previousRefreshToken));
|
|
329
|
+
if (token?.sid) {
|
|
330
|
+
const login = await _class_private_field_get(this, _store).loginSessions.findById(token.sid);
|
|
331
|
+
if (login?.admin_user_id === token.admin_user_id) ids.add(login.id);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const result = [];
|
|
335
|
+
for (const id of ids){
|
|
336
|
+
const login = await _class_private_field_get(this, _store).loginSessions.findById(id);
|
|
337
|
+
if (login) result.push(login);
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
async function issueTokens(store, input) {
|
|
205
342
|
const now = _class_private_field_get(this, _now).call(this);
|
|
206
|
-
const refreshTokens =
|
|
207
|
-
const
|
|
343
|
+
const refreshTokens = store.refreshTokens;
|
|
344
|
+
const sessionId = randomUUID();
|
|
345
|
+
const accessToken = await _class_private_method_get(this, _signAccessToken, signAccessToken).call(this, input.adminUserId, input.sessionVersion, sessionId, now);
|
|
208
346
|
const accessExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _accessTtl));
|
|
209
347
|
const refreshPlain = generateOpaqueToken();
|
|
210
348
|
const refreshHash = hashToken(refreshPlain);
|
|
211
349
|
const refreshExpiresAt = new Date(now.getTime() + 1000 * _class_private_field_get(this, _refreshTtl));
|
|
350
|
+
await store.loginSessions.create({
|
|
351
|
+
id: sessionId,
|
|
352
|
+
admin_user_id: input.adminUserId,
|
|
353
|
+
session_version: input.sessionVersion,
|
|
354
|
+
expires_at: refreshExpiresAt
|
|
355
|
+
});
|
|
212
356
|
await refreshTokens.issue({
|
|
213
357
|
id: v7(),
|
|
214
358
|
admin_user_id: input.adminUserId,
|
|
215
359
|
token_hash: refreshHash,
|
|
360
|
+
session_version: input.sessionVersion,
|
|
361
|
+
sid: sessionId,
|
|
216
362
|
expires_at: refreshExpiresAt,
|
|
217
363
|
user_agent: input.userAgent,
|
|
218
364
|
ip: input.ip
|
|
219
365
|
});
|
|
220
366
|
return {
|
|
367
|
+
sessionId,
|
|
221
368
|
accessToken,
|
|
222
369
|
refreshToken: refreshPlain,
|
|
223
370
|
accessTokenExpiresAt: accessExpiresAt,
|
|
224
371
|
refreshTokenExpiresAt: refreshExpiresAt
|
|
225
372
|
};
|
|
226
373
|
}
|
|
227
|
-
async function signAccessToken(adminUserId, now) {
|
|
374
|
+
async function signAccessToken(adminUserId, sessionVersion, sessionId, now) {
|
|
228
375
|
const iat = Math.floor(now.getTime() / 1000);
|
|
229
376
|
const exp = iat + _class_private_field_get(this, _accessTtl);
|
|
230
377
|
return new SignJWT({
|
|
231
|
-
typ: 'access'
|
|
378
|
+
typ: 'access',
|
|
379
|
+
sv: sessionVersion,
|
|
380
|
+
sid: sessionId
|
|
232
381
|
}).setProtectedHeader({
|
|
233
382
|
alg: 'HS256',
|
|
234
383
|
typ: 'JWT'
|
|
@@ -241,4 +390,7 @@ function hashToken(token) {
|
|
|
241
390
|
return createHash('sha256').update(token).digest('hex');
|
|
242
391
|
}
|
|
243
392
|
const DUMMY_HASH_FOR_TIMING = '$argon2id$v=19$m=19456,t=2,p=1$c2lkZS1jaGFubmVsLW1pdGlnYXRpb24$0Hqf2vQKZqSfZZ4nJRr7K5IOjn9ngjzaQjV+yTG6iNY';
|
|
393
|
+
function validSid(value) {
|
|
394
|
+
return 'string' == typeof value && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
395
|
+
}
|
|
244
396
|
export { JwtSessionProvider };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/** Native JWT login membership. This is not a browser binding or a bearer credential store. */
|
|
9
|
+
export interface LoginSessionRow {
|
|
10
|
+
id: string;
|
|
11
|
+
admin_user_id: string;
|
|
12
|
+
session_version: number;
|
|
13
|
+
expires_at: Date;
|
|
14
|
+
revoked_at: Date | null;
|
|
15
|
+
}
|
|
16
|
+
export interface LoginSessionsRepository {
|
|
17
|
+
/** All writes run under the owning account lock, through its scoped store. */
|
|
18
|
+
create(input: Omit<LoginSessionRow, 'revoked_at'>): Promise<void>;
|
|
19
|
+
findById(id: string): Promise<LoginSessionRow | null>;
|
|
20
|
+
extend(id: string, expiresAt: Date): Promise<void>;
|
|
21
|
+
revoke(id: string, at?: Date): Promise<void>;
|
|
22
|
+
revokeAllForUser(adminUserId: string, at?: Date): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -19,6 +19,9 @@ export interface RefreshTokenRow {
|
|
|
19
19
|
id: string;
|
|
20
20
|
admin_user_id: string;
|
|
21
21
|
token_hash: string;
|
|
22
|
+
/** Null only for legacy rows, which cannot authorize native sessions. */
|
|
23
|
+
sid: string | null;
|
|
24
|
+
session_version: number;
|
|
22
25
|
issued_at: Date;
|
|
23
26
|
expires_at: Date;
|
|
24
27
|
revoked_at: Date | null;
|
|
@@ -28,9 +31,13 @@ export interface RefreshTokenRow {
|
|
|
28
31
|
ip: string | null;
|
|
29
32
|
}
|
|
30
33
|
export interface IssueRefreshTokenInput {
|
|
34
|
+
/** Omitted only by legacy fixtures/imports; never accepted for native renewal. */
|
|
35
|
+
sid?: string | null;
|
|
31
36
|
id: string;
|
|
32
37
|
admin_user_id: string;
|
|
33
38
|
token_hash: string;
|
|
39
|
+
/** Account generation observed under the native issuance lock. */
|
|
40
|
+
session_version: number;
|
|
34
41
|
expires_at: Date;
|
|
35
42
|
user_agent?: string | null;
|
|
36
43
|
ip?: string | null;
|
|
@@ -43,19 +50,15 @@ export interface RefreshTokensRepository {
|
|
|
43
50
|
/** Stamp `last_used_at` for observability. */
|
|
44
51
|
touch(id: string, at?: Date): Promise<void>;
|
|
45
52
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
53
|
+
* Revoke `oldId` and set its `rotated_to_id` to `newId`. This is not
|
|
54
|
+
* independently a compare-and-swap. Native callers must hold the account
|
|
55
|
+
* lock, reread the predecessor, and insert the successor before this write,
|
|
56
|
+
* all through the same `withSessionLock` transaction.
|
|
49
57
|
*/
|
|
50
58
|
markRotated(oldId: string, newId: string, at?: Date): Promise<void>;
|
|
51
59
|
/** Revoke a single token. Idempotent. */
|
|
52
60
|
revoke(id: string, at?: Date): Promise<void>;
|
|
53
|
-
/**
|
|
54
|
-
* Walk the rotation chain starting at `startId` and revoke every token
|
|
55
|
-
* in it. Called when a rotated token is replayed — indicates the chain
|
|
56
|
-
* has been compromised and every descendant is suspect. Returns the
|
|
57
|
-
* number of rows touched.
|
|
58
|
-
*/
|
|
61
|
+
/** Revoke all refresh rows sharing the start member's sid, without traversal. */
|
|
59
62
|
revokeChain(startId: string, at?: Date): Promise<number>;
|
|
60
63
|
/** Revoke every non-revoked token for a user. Used on password change / sign-out everywhere. */
|
|
61
64
|
revokeAllForUser(adminUserId: string, at?: Date): Promise<number>;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { AdminAuth } from '@byline/auth';
|
|
9
9
|
import type { AdminStore } from '../../store.js';
|
|
10
|
+
import type { AdminUserRow } from '../admin-users/repository.js';
|
|
10
11
|
/**
|
|
11
12
|
* Build an `AdminAuth` from a user id by reading the admin-users row and
|
|
12
13
|
* collecting the distinct abilities granted through every role the user
|
|
@@ -22,3 +23,5 @@ import type { AdminStore } from '../../store.js';
|
|
|
22
23
|
* `AdminStore` bundle; adapter-agnostic.
|
|
23
24
|
*/
|
|
24
25
|
export declare function resolveActor(store: AdminStore, adminUserId: string): Promise<AdminAuth | null>;
|
|
26
|
+
/** Internal helper: reuse the account snapshot already checked by native authentication. */
|
|
27
|
+
export declare function resolveActorFromUser(store: AdminStore, user: AdminUserRow | null): Promise<AdminAuth | null>;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { AdminAuth } from "@byline/auth";
|
|
2
2
|
async function resolveActor(store, adminUserId) {
|
|
3
3
|
const user = await store.adminUsers.getById(adminUserId);
|
|
4
|
+
return resolveActorFromUser(store, user);
|
|
5
|
+
}
|
|
6
|
+
async function resolveActorFromUser(store, user) {
|
|
4
7
|
if (!user) return null;
|
|
5
8
|
if (!user.is_enabled) return null;
|
|
6
|
-
const abilities = await store.adminPermissions.listAbilitiesForUser(
|
|
9
|
+
const abilities = await store.adminPermissions.listAbilitiesForUser(user.id);
|
|
7
10
|
return new AdminAuth({
|
|
8
11
|
id: user.id,
|
|
9
12
|
abilities,
|
|
10
13
|
isSuperAdmin: user.is_super_admin
|
|
11
14
|
});
|
|
12
15
|
}
|
|
13
|
-
export { resolveActor };
|
|
16
|
+
export { resolveActor, resolveActorFromUser };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { PasswordSignInLimiter } from '@byline/auth';
|
|
9
|
+
import type { RecurringTaskDefinition } from '@byline/core';
|
|
10
|
+
export interface SignInRateLimitStore {
|
|
11
|
+
/** Atomically increment, saturating at limit + 1; true only for the first limit calls. */
|
|
12
|
+
consume(key: string, limit: number, expiresAt: Date): Promise<boolean>;
|
|
13
|
+
/** Remove at most 100 expired counters and return the number removed. */
|
|
14
|
+
purgeExpired(before: Date): Promise<number>;
|
|
15
|
+
}
|
|
16
|
+
export interface SignInRateLimitPolicy {
|
|
17
|
+
account: {
|
|
18
|
+
limit: number;
|
|
19
|
+
windowSeconds: number;
|
|
20
|
+
};
|
|
21
|
+
ip: {
|
|
22
|
+
limit: number;
|
|
23
|
+
windowSeconds: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface SignInSecurityEvent {
|
|
27
|
+
type: 'admitted' | 'denied' | 'capacity' | 'store-error' | 'cleanup-error' | 'success' | 'failure';
|
|
28
|
+
scope?: 'account' | 'ip';
|
|
29
|
+
/** Stable, pseudonymous digests for cross-network correlation. Never passwords or raw IPs. */
|
|
30
|
+
account?: string;
|
|
31
|
+
network?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SignInLimiterOptions {
|
|
34
|
+
onEvent?: (event: SignInSecurityEvent) => void;
|
|
35
|
+
slots?: number;
|
|
36
|
+
maxQueue?: number;
|
|
37
|
+
queueTimeoutMs?: number;
|
|
38
|
+
/** Also bounds counter creation when the provider or a denial completes very quickly. */
|
|
39
|
+
minimumSlotMs?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Instantiate once per process. Acquire before consume and release in finally after verification.
|
|
43
|
+
* Network admission precedes account-plus-network admission. There is no account-wide lockout.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createPasswordSignInLimiter(store: SignInRateLimitStore, secret: string | Uint8Array, policy?: SignInRateLimitPolicy, now?: () => number, options?: SignInLimiterOptions): PasswordSignInLimiter & {
|
|
46
|
+
cleanupTask: RecurringTaskDefinition;
|
|
47
|
+
dispose(): void;
|
|
48
|
+
};
|