@byline/admin 5.0.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/forms/available-locales-widget.d.ts +1 -1
- package/dist/forms/document-actions.d.ts +1 -1
- package/dist/forms/form-renderer.d.ts +3 -3
- package/dist/forms/form-status-display.d.ts +1 -1
- 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/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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { type ReactNode } from 'react';
|
|
9
|
-
import type { BylineFieldServices } from './field-services-types';
|
|
9
|
+
import type { BylineFieldServices } from './field-services-types.js';
|
|
10
10
|
interface BylineFieldServicesProviderProps {
|
|
11
11
|
services: BylineFieldServices;
|
|
12
12
|
children: ReactNode;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile';
|
|
1
|
+
export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile.js';
|
|
2
2
|
/** A content locale to render a checkbox for. */
|
|
3
3
|
export interface AvailableLocalesWidgetLocale {
|
|
4
4
|
code: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PublishedVersionInfo } from './form-renderer';
|
|
1
|
+
import type { PublishedVersionInfo } from './form-renderer.js';
|
|
2
2
|
import type { ScheduledPublicationState } from './scheduled-publication-state.js';
|
|
3
3
|
/**
|
|
4
4
|
* Shape of a content-locale option as consumed by the Copy-to-Locale
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
import { type ReactNode } from 'react';
|
|
9
9
|
import type { Field, FormAdminConfig, WorkflowStatus } from '@byline/core';
|
|
10
10
|
import type { DocumentPatch } from '@byline/core/patches';
|
|
11
|
-
import { type DocumentActionsLocaleOption } from './document-actions';
|
|
12
|
-
import { type ScheduledPublicationInfo, type SchedulePublicationInput } from './scheduled-publication-control';
|
|
13
|
-
import type { UseNavigationGuard } from './navigation-guard';
|
|
11
|
+
import { type DocumentActionsLocaleOption } from './document-actions.js';
|
|
12
|
+
import { type ScheduledPublicationInfo, type SchedulePublicationInput } from './scheduled-publication-control.js';
|
|
13
|
+
import type { UseNavigationGuard } from './navigation-guard.js';
|
|
14
14
|
/** Metadata about a previously published version that is still live. */
|
|
15
15
|
export interface PublishedVersionInfo {
|
|
16
16
|
id: string;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import type { WorkflowStatus } from '@byline/core';
|
|
9
|
-
import type { PublishedVersionInfo } from './form-renderer';
|
|
9
|
+
import type { PublishedVersionInfo } from './form-renderer.js';
|
|
10
10
|
/**
|
|
11
11
|
* The status / metadata strip at the top of a document form — current
|
|
12
12
|
* workflow status (suppressed for single-status workflows), last-modified and
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* but only uploaded when the user clicks Save.
|
|
14
14
|
*/
|
|
15
15
|
import type { Field, StoredFileValue } from '@byline/core';
|
|
16
|
-
import type { UploadFieldFn } from '../fields/field-services-types';
|
|
17
|
-
import type { PendingUpload } from './form-context';
|
|
16
|
+
import type { UploadFieldFn } from '../fields/field-services-types.js';
|
|
17
|
+
import type { PendingUpload } from './form-context.js';
|
|
18
18
|
export interface UploadResult {
|
|
19
19
|
fieldPath: string;
|
|
20
20
|
success: boolean;
|
|
@@ -84,6 +84,24 @@ function ChangeAccountPassword({ account, onClose, onSuccess }) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
});
|
|
87
|
+
if (successMessage) return /*#__PURE__*/ jsxs("div", {
|
|
88
|
+
className: clsx('byline-account-change-password-wrap', change_password_module.wrap),
|
|
89
|
+
children: [
|
|
90
|
+
/*#__PURE__*/ jsx("div", {
|
|
91
|
+
role: "status",
|
|
92
|
+
children: /*#__PURE__*/ jsx(Alert, {
|
|
93
|
+
intent: "success",
|
|
94
|
+
children: successMessage
|
|
95
|
+
})
|
|
96
|
+
}),
|
|
97
|
+
/*#__PURE__*/ jsx(Button, {
|
|
98
|
+
type: "button",
|
|
99
|
+
intent: "primary",
|
|
100
|
+
onClick: ()=>window.location.reload(),
|
|
101
|
+
children: t('auth.signIn.title')
|
|
102
|
+
})
|
|
103
|
+
]
|
|
104
|
+
});
|
|
87
105
|
return /*#__PURE__*/ jsx("div", {
|
|
88
106
|
className: clsx('byline-account-change-password-wrap', change_password_module.wrap),
|
|
89
107
|
children: /*#__PURE__*/ jsxs("form", {
|
|
@@ -25,12 +25,8 @@ import type { AccountResponse, ChangeAccountPasswordRequest, UpdateAccountReques
|
|
|
25
25
|
* in the new hash. A hijacked session cannot use this flow to lock
|
|
26
26
|
* out the legitimate owner.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* valid until their 15-minute expiry, and other refresh tokens remain
|
|
31
|
-
* useable. A "sign out everywhere on password change" follow-up should
|
|
32
|
-
* call `RefreshTokensRepository.revokeAllExcept(adminUserId, currentJti)`
|
|
33
|
-
* once that lands.
|
|
28
|
+
* Native adapter password writes atomically advance the session generation and
|
|
29
|
+
* revoke every refresh session. External providers own their own revocation.
|
|
34
30
|
*/
|
|
35
31
|
export declare class AdminAccountService {
|
|
36
32
|
#private;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { toAdminUser } from "../admin-users/dto.js";
|
|
2
|
-
import { ERR_ADMIN_USER_EMAIL_IN_USE } from "../admin-users/errors.js";
|
|
2
|
+
import { ERR_ADMIN_USER_EMAIL_IN_USE, ERR_ADMIN_USER_VERSION_CONFLICT } from "../admin-users/errors.js";
|
|
3
3
|
import { hashPassword, verifyPassword } from "../auth/password.js";
|
|
4
4
|
import { ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD, ERR_ADMIN_ACCOUNT_NOT_FOUND } from "./errors.js";
|
|
5
5
|
function _check_private_redeclaration(obj, privateCollection) {
|
|
@@ -63,6 +63,7 @@ class AdminAccountService {
|
|
|
63
63
|
if (!withHash) throw ERR_ADMIN_ACCOUNT_NOT_FOUND();
|
|
64
64
|
const ok = await verifyPassword(request.currentPassword, withHash.password_hash);
|
|
65
65
|
if (!ok) throw ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD();
|
|
66
|
+
if (withHash.vid !== request.vid) throw ERR_ADMIN_USER_VERSION_CONFLICT();
|
|
66
67
|
const newHash = await hashPassword(request.newPassword);
|
|
67
68
|
const row = await _class_private_field_get(this, _repo).setPasswordHash(actorId, request.vid, newHash);
|
|
68
69
|
return toAdminUser(row);
|
|
@@ -65,6 +65,8 @@ export interface AdminUserRow {
|
|
|
65
65
|
*/
|
|
66
66
|
export interface AdminUserWithPasswordRow extends AdminUserRow {
|
|
67
67
|
password_hash: string;
|
|
68
|
+
/** Native session generation, independent of edit revisions. */
|
|
69
|
+
session_version: number;
|
|
68
70
|
}
|
|
69
71
|
export interface CreateAdminUserInput {
|
|
70
72
|
email: string;
|
|
@@ -143,16 +145,23 @@ export interface AdminUsersRepository {
|
|
|
143
145
|
* Content update with optimistic concurrency. Throws
|
|
144
146
|
* `AdminUsersError(VERSION_CONFLICT)` if the stored `vid` differs from
|
|
145
147
|
* `expectedVid`. Bumps `vid` on success and returns the fresh row.
|
|
148
|
+
* A false `is_enabled` patch must atomically advance the native session
|
|
149
|
+
* generation and revoke refresh sessions, under the account row lock.
|
|
146
150
|
*/
|
|
147
151
|
update(id: string, expectedVid: number, patch: UpdateAdminUserInput): Promise<AdminUserRow>;
|
|
148
152
|
/**
|
|
149
153
|
* Replace the stored password hash with optimistic concurrency.
|
|
150
154
|
* Version-gated on `expectedVid`. Caller supplies a pre-hashed PHC string.
|
|
155
|
+
* Atomically advance the native session generation and revoke every refresh
|
|
156
|
+
* session in the same transaction. Lock the account before refresh rows.
|
|
151
157
|
* Returns the updated row so callers holding the edit form can refresh
|
|
152
158
|
* their cached `vid` without a second round-trip.
|
|
153
159
|
*/
|
|
154
160
|
setPasswordHash(id: string, expectedVid: number, passwordHash: string): Promise<AdminUserRow>;
|
|
155
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Toggle enabled state. Disable must atomically advance the native session
|
|
163
|
+
* generation and revoke refresh sessions. Enable never resets the generation.
|
|
164
|
+
*/
|
|
156
165
|
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
157
166
|
/**
|
|
158
167
|
* Set the admin interface locale preference. Vid-less — user preference
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* Clerk, institutional SSO) should be shipped as separate packages
|
|
20
20
|
* against `@byline/auth` rather than added here.
|
|
21
21
|
*/
|
|
22
|
-
export { JwtSessionProvider, type JwtSessionProviderConfig } from './jwt-session-provider.js';
|
|
22
|
+
export { JwtSessionProvider, type JwtSessionProviderConfig, type NativeSessionEvent, } from './jwt-session-provider.js';
|
|
23
23
|
export { hashPassword, verifyPassword } from './password.js';
|
|
24
24
|
export { resolveActor } from './resolve-actor.js';
|
|
25
|
+
export { createPasswordSignInLimiter, type SignInLimiterOptions, type SignInRateLimitPolicy, type SignInRateLimitStore, type SignInSecurityEvent, } from './sign-in-rate-limiter.js';
|
|
26
|
+
export type { LoginSessionRow, LoginSessionsRepository } from './login-sessions-repository.js';
|
|
25
27
|
export type { IssueRefreshTokenInput, RefreshTokenRow, RefreshTokensRepository, } from './refresh-tokens-repository.js';
|
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import { type AdminAuth, type RefreshSessionArgs, type SessionProvider, type SessionProviderCapabilities, type SessionTokens, type SignInResult, type SignInWithPasswordArgs } from '@byline/auth';
|
|
8
|
+
import { type AdminAuth, type RefreshSessionArgs, type RevokeSessionArgs, type SessionProvider, type SessionProviderCapabilities, type SessionTokens, type SignInResult, type SignInWithPasswordArgs } from '@byline/auth';
|
|
9
9
|
import type { AdminStore } from '../../store.js';
|
|
10
|
+
export interface NativeSessionEvent {
|
|
11
|
+
type: 'refresh_attempted' | 'refresh_completed' | 'refresh_contested';
|
|
12
|
+
}
|
|
10
13
|
export interface JwtSessionProviderConfig {
|
|
14
|
+
/** Best-effort, token-free renewal telemetry. Errors never change authentication outcomes. */
|
|
15
|
+
onEvent?: (event: NativeSessionEvent) => void;
|
|
11
16
|
/**
|
|
12
17
|
* Adapter-backed admin repositories. Construct via the DB adapter's
|
|
13
18
|
* admin-store factory (e.g. `createAdminStore(db)` from
|
|
@@ -39,8 +44,9 @@ export declare class JwtSessionProvider implements SessionProvider {
|
|
|
39
44
|
signInWithPassword(args: SignInWithPasswordArgs): Promise<SignInResult>;
|
|
40
45
|
verifyAccessToken(token: string): Promise<{
|
|
41
46
|
actor: AdminAuth;
|
|
47
|
+
sessionId: string;
|
|
42
48
|
}>;
|
|
43
49
|
refreshSession(args: RefreshSessionArgs): Promise<SessionTokens>;
|
|
44
|
-
revokeSession(
|
|
50
|
+
revokeSession(args: RevokeSessionArgs): Promise<void>;
|
|
45
51
|
resolveActor(adminUserId: string): Promise<AdminAuth | null>;
|
|
46
52
|
}
|
|
@@ -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 { };
|