@byline/client 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/server/admin-context.d.ts +6 -38
- package/dist/server/admin-context.js +23 -110
- package/dist/server/admin-context.test.node.js +29 -134
- package/dist/server/request-context-contract.test.node.js +1 -0
- package/dist/server/request-context-factories.test.node.js +1 -1
- package/package.json +9 -9
|
@@ -6,45 +6,13 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Verification-only request context. Renewal belongs to the explicit host endpoint.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Flow, in order:
|
|
18
|
-
*
|
|
19
|
-
* 1. Read the access cookie.
|
|
20
|
-
* 2. If present, try `sessionProvider.verifyAccessToken`. On success,
|
|
21
|
-
* return the context — no DB write, no cookie churn.
|
|
22
|
-
* 3. On verify failure (or missing access cookie), read the refresh
|
|
23
|
-
* cookie. If present, call `sessionProvider.refreshSession` — this
|
|
24
|
-
* rotates the refresh token atomically and issues a new access
|
|
25
|
-
* token. Write both fresh cookies to the response.
|
|
26
|
-
* 4. Verify the new access token (populate the actor) and return the
|
|
27
|
-
* context.
|
|
28
|
-
* 5. Any failure along the way clears both cookies so the browser
|
|
29
|
-
* stops sending a session the server has already rejected, and
|
|
30
|
-
* throws `ERR_UNAUTHENTICATED`.
|
|
31
|
-
*
|
|
32
|
-
* Burns a refresh-token rotation only when the access token actually
|
|
33
|
-
* fails verification — not on every request. The "one session" UX is
|
|
34
|
-
* the consequence of this helper working invisibly behind each call.
|
|
35
|
-
*
|
|
36
|
-
* Resolution is memoized per request (`oncePerRequest`): every call within
|
|
37
|
-
* one logical request returns the same `RequestContext` instance — same
|
|
38
|
-
* actor snapshot, same `requestId`. Two invariants depend on this:
|
|
39
|
-
*
|
|
40
|
-
* - The read-authorization layer binds each `ReadContext` to one
|
|
41
|
-
* immutable authority token that includes `requestId`; a fresh id per
|
|
42
|
-
* call makes any two reads sharing a `ReadContext` (the admin tree
|
|
43
|
-
* view, hook-threaded nested reads) throw
|
|
44
|
-
* 'ReadContext cannot be reused across request authorities'.
|
|
45
|
-
* - The refresh path rotates the refresh token; a second unmemoized call
|
|
46
|
-
* in the same request would re-run the dance against the
|
|
47
|
-
* already-rotated cookie and sign the admin out.
|
|
11
|
+
* oncePerRequest preserves the same context, actor snapshot, and requestId for
|
|
12
|
+
* every call in one logical request. The read-authorization layer binds each
|
|
13
|
+
* ReadContext to one authority token including requestId. Generating a new id
|
|
14
|
+
* per call would make nested reads sharing a ReadContext throw:
|
|
15
|
+
* "ReadContext cannot be reused across request authorities".
|
|
48
16
|
*/
|
|
49
17
|
import { type RequestContext } from '@byline/auth';
|
|
50
18
|
export declare function getAdminRequestContext(): Promise<RequestContext>;
|
|
@@ -6,120 +6,33 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Verification-only request context. Renewal belongs to the explicit host endpoint.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Flow, in order:
|
|
18
|
-
*
|
|
19
|
-
* 1. Read the access cookie.
|
|
20
|
-
* 2. If present, try `sessionProvider.verifyAccessToken`. On success,
|
|
21
|
-
* return the context — no DB write, no cookie churn.
|
|
22
|
-
* 3. On verify failure (or missing access cookie), read the refresh
|
|
23
|
-
* cookie. If present, call `sessionProvider.refreshSession` — this
|
|
24
|
-
* rotates the refresh token atomically and issues a new access
|
|
25
|
-
* token. Write both fresh cookies to the response.
|
|
26
|
-
* 4. Verify the new access token (populate the actor) and return the
|
|
27
|
-
* context.
|
|
28
|
-
* 5. Any failure along the way clears both cookies so the browser
|
|
29
|
-
* stops sending a session the server has already rejected, and
|
|
30
|
-
* throws `ERR_UNAUTHENTICATED`.
|
|
31
|
-
*
|
|
32
|
-
* Burns a refresh-token rotation only when the access token actually
|
|
33
|
-
* fails verification — not on every request. The "one session" UX is
|
|
34
|
-
* the consequence of this helper working invisibly behind each call.
|
|
35
|
-
*
|
|
36
|
-
* Resolution is memoized per request (`oncePerRequest`): every call within
|
|
37
|
-
* one logical request returns the same `RequestContext` instance — same
|
|
38
|
-
* actor snapshot, same `requestId`. Two invariants depend on this:
|
|
39
|
-
*
|
|
40
|
-
* - The read-authorization layer binds each `ReadContext` to one
|
|
41
|
-
* immutable authority token that includes `requestId`; a fresh id per
|
|
42
|
-
* call makes any two reads sharing a `ReadContext` (the admin tree
|
|
43
|
-
* view, hook-threaded nested reads) throw
|
|
44
|
-
* 'ReadContext cannot be reused across request authorities'.
|
|
45
|
-
* - The refresh path rotates the refresh token; a second unmemoized call
|
|
46
|
-
* in the same request would re-run the dance against the
|
|
47
|
-
* already-rotated cookie and sign the admin out.
|
|
11
|
+
* oncePerRequest preserves the same context, actor snapshot, and requestId for
|
|
12
|
+
* every call in one logical request. The read-authorization layer binds each
|
|
13
|
+
* ReadContext to one authority token including requestId. Generating a new id
|
|
14
|
+
* per call would make nested reads sharing a ReadContext throw:
|
|
15
|
+
* "ReadContext cannot be reused across request authorities".
|
|
48
16
|
*/
|
|
49
|
-
import { ERR_UNAUTHENTICATED } from '@byline/auth';
|
|
17
|
+
import { ERR_ACCESS_EXPIRED, ERR_UNAUTHENTICATED } from '@byline/auth';
|
|
50
18
|
import { getServerConfig } from '@byline/core';
|
|
51
19
|
import { v7 as uuidv7 } from 'uuid';
|
|
52
20
|
import { oncePerRequest } from './request-scope.js';
|
|
53
|
-
import {
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
async function resolveAdminRequestContext() {
|
|
66
|
-
const provider = requireSessionProvider();
|
|
67
|
-
const accessToken = readAccessTokenCookie();
|
|
68
|
-
// Happy path: valid access token.
|
|
69
|
-
if (accessToken) {
|
|
70
|
-
try {
|
|
71
|
-
const { actor } = await provider.verifyAccessToken(accessToken);
|
|
72
|
-
return {
|
|
73
|
-
actor,
|
|
74
|
-
requestId: uuidv7(),
|
|
75
|
-
readMode: 'any',
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
// Fall through to refresh — we'll burn a rotation only when the
|
|
80
|
-
// access token genuinely can't verify.
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Refresh path: swap the refresh cookie for a fresh token pair.
|
|
84
|
-
const refreshToken = readRefreshTokenCookie();
|
|
85
|
-
if (!refreshToken) {
|
|
86
|
-
// Only emit cookie clears when the browser actually sent a stale access
|
|
87
|
-
// cookie — otherwise the response carries no Set-Cookie at all, which
|
|
88
|
-
// lets shared caches (Cloudflare) cache public pages for anonymous
|
|
89
|
-
// visitors. Set-Cookie on the response is a hard bypass signal for CDNs.
|
|
90
|
-
if (accessToken) {
|
|
91
|
-
clearSessionCookies();
|
|
21
|
+
import { readAccessTokenCookie, readRefreshTokenCookie } from './session-cookies.js';
|
|
22
|
+
export function getAdminRequestContext() {
|
|
23
|
+
return oncePerRequest('byline:admin-request-context', async () => {
|
|
24
|
+
const provider = getServerConfig().sessionProvider;
|
|
25
|
+
if (!provider)
|
|
26
|
+
throw new Error('no sessionProvider configured');
|
|
27
|
+
const access = readAccessTokenCookie();
|
|
28
|
+
if (!access) {
|
|
29
|
+
if (readRefreshTokenCookie())
|
|
30
|
+
throw ERR_ACCESS_EXPIRED({ message: 'access credential absent; explicit renewal required' });
|
|
31
|
+
throw ERR_UNAUTHENTICATED({ message: 'no admin session' });
|
|
92
32
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
clearSessionCookies();
|
|
101
|
-
throw ERR_UNAUTHENTICATED({
|
|
102
|
-
message: 'admin session could not be refreshed',
|
|
103
|
-
cause: err,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
// Write the new cookies so subsequent requests skip the refresh path.
|
|
107
|
-
setSessionCookies(refreshed);
|
|
108
|
-
// Verify the freshly-minted access token to extract the actor.
|
|
109
|
-
let verified;
|
|
110
|
-
try {
|
|
111
|
-
verified = await provider.verifyAccessToken(refreshed.accessToken);
|
|
112
|
-
}
|
|
113
|
-
catch (err) {
|
|
114
|
-
clearSessionCookies();
|
|
115
|
-
throw ERR_UNAUTHENTICATED({
|
|
116
|
-
message: 'refreshed access token did not verify',
|
|
117
|
-
cause: err,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
return {
|
|
121
|
-
actor: verified.actor,
|
|
122
|
-
requestId: uuidv7(),
|
|
123
|
-
readMode: 'any',
|
|
124
|
-
};
|
|
33
|
+
const { actor, sessionId } = await provider.verifyAccessToken(access);
|
|
34
|
+
if (!sessionId)
|
|
35
|
+
throw new Error('session provider omitted login identity');
|
|
36
|
+
return { actor, sessionId, requestId: uuidv7(), readMode: 'any' };
|
|
37
|
+
});
|
|
125
38
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `HostRequestBridge` and mocks the config's `sessionProvider` so the
|
|
11
11
|
* branches can be exercised without a live Postgres or JWT machinery.
|
|
12
12
|
*/
|
|
13
|
-
import { AdminAuth, AuthError, AuthErrorCodes } from '@byline/auth';
|
|
13
|
+
import { AdminAuth, AuthError, AuthErrorCodes, ERR_ACCESS_EXPIRED, ERR_REVOKED_TOKEN, } from '@byline/auth';
|
|
14
14
|
import { registerHostRequestBridge } from '@byline/core';
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
16
|
const mocks = vi.hoisted(() => ({
|
|
@@ -67,51 +67,30 @@ describe('getAdminRequestContext', () => {
|
|
|
67
67
|
byline_refresh_token: 'some-refresh',
|
|
68
68
|
});
|
|
69
69
|
const actor = stubActor();
|
|
70
|
-
verifyAccessToken.mockResolvedValueOnce({ actor });
|
|
70
|
+
verifyAccessToken.mockResolvedValueOnce({ actor, sessionId: 'test-login' });
|
|
71
71
|
const ctx = await getAdminRequestContext();
|
|
72
72
|
expect(ctx.actor).toBe(actor);
|
|
73
73
|
expect(ctx.readMode).toBe('any');
|
|
74
74
|
expect(refreshSession).not.toHaveBeenCalled();
|
|
75
75
|
expect(setCookie).not.toHaveBeenCalled();
|
|
76
76
|
});
|
|
77
|
-
it(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
verifyAccessToken.mockRejectedValueOnce(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
88
|
-
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
89
|
-
});
|
|
90
|
-
const ctx = await getAdminRequestContext();
|
|
91
|
-
expect(ctx.actor).toBe(actor);
|
|
92
|
-
expect(refreshSession).toHaveBeenCalledWith({ refreshToken: 'valid-refresh' });
|
|
93
|
-
// Both access + refresh cookies should have been written with maxAge > 0.
|
|
94
|
-
expect(setCookie).toHaveBeenCalledTimes(2);
|
|
95
|
-
const accessCall = setCookie.mock.calls.find((c) => c[0] === 'byline_access_token');
|
|
96
|
-
expect(accessCall?.[1]).toBe('fresh-access');
|
|
97
|
-
expect(accessCall?.[2]?.maxAge).toBeGreaterThan(0);
|
|
77
|
+
it.each([
|
|
78
|
+
ERR_ACCESS_EXPIRED({ message: 'expired' }),
|
|
79
|
+
ERR_REVOKED_TOKEN({ message: 'revoked' }),
|
|
80
|
+
new Error('database unavailable'),
|
|
81
|
+
])('never renews or writes cookies after verification failure: %s', async (error) => {
|
|
82
|
+
cookiesReturn({ byline_access_token: 'old', byline_refresh_token: 'retained' });
|
|
83
|
+
verifyAccessToken.mockRejectedValueOnce(error);
|
|
84
|
+
await expect(getAdminRequestContext()).rejects.toBe(error);
|
|
85
|
+
expect(refreshSession).not.toHaveBeenCalled();
|
|
86
|
+
expect(setCookie).not.toHaveBeenCalled();
|
|
98
87
|
});
|
|
99
|
-
it('
|
|
100
|
-
cookiesReturn({ byline_refresh_token: '
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
106
|
-
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
107
|
-
});
|
|
108
|
-
verifyAccessToken.mockResolvedValueOnce({ actor });
|
|
109
|
-
const ctx = await getAdminRequestContext();
|
|
110
|
-
expect(ctx.actor).toBe(actor);
|
|
111
|
-
// The access-token path was skipped (no cookie) so verifyAccessToken ran
|
|
112
|
-
// only for the freshly-issued token.
|
|
113
|
-
expect(verifyAccessToken).toHaveBeenCalledTimes(1);
|
|
114
|
-
expect(verifyAccessToken).toHaveBeenCalledWith('fresh-access');
|
|
88
|
+
it('reports explicit renewal required when only the persistent refresh cookie survives', async () => {
|
|
89
|
+
cookiesReturn({ byline_refresh_token: 'retained' });
|
|
90
|
+
await expect(getAdminRequestContext()).rejects.toMatchObject({ code: 'ERR_ACCESS_EXPIRED' });
|
|
91
|
+
expect(verifyAccessToken).not.toHaveBeenCalled();
|
|
92
|
+
expect(refreshSession).not.toHaveBeenCalled();
|
|
93
|
+
expect(setCookie).not.toHaveBeenCalled();
|
|
115
94
|
});
|
|
116
95
|
it('throws ERR_UNAUTHENTICATED without emitting Set-Cookie when no cookies are sent', async () => {
|
|
117
96
|
cookiesReturn({});
|
|
@@ -128,63 +107,6 @@ describe('getAdminRequestContext', () => {
|
|
|
128
107
|
// response is a hard bypass signal for CDNs.
|
|
129
108
|
expect(setCookie).not.toHaveBeenCalled();
|
|
130
109
|
});
|
|
131
|
-
it('clears the stale access cookie when only an access token was sent', async () => {
|
|
132
|
-
cookiesReturn({ byline_access_token: 'stale' });
|
|
133
|
-
verifyAccessToken.mockRejectedValueOnce(new Error('expired'));
|
|
134
|
-
try {
|
|
135
|
-
await getAdminRequestContext();
|
|
136
|
-
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
137
|
-
}
|
|
138
|
-
catch (err) {
|
|
139
|
-
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
140
|
-
}
|
|
141
|
-
const clears = setCookie.mock.calls.filter((c) => c[2]?.maxAge === 0);
|
|
142
|
-
const clearedNames = new Set(clears.map((c) => c[0]));
|
|
143
|
-
expect(clearedNames.has('byline_access_token')).toBe(true);
|
|
144
|
-
expect(clearedNames.has('byline_refresh_token')).toBe(true);
|
|
145
|
-
});
|
|
146
|
-
it('clears cookies and throws when refresh itself fails', async () => {
|
|
147
|
-
cookiesReturn({
|
|
148
|
-
byline_access_token: 'stale',
|
|
149
|
-
byline_refresh_token: 'bad-refresh',
|
|
150
|
-
});
|
|
151
|
-
verifyAccessToken.mockRejectedValueOnce(new Error('expired'));
|
|
152
|
-
refreshSession.mockRejectedValueOnce(new Error('revoked'));
|
|
153
|
-
try {
|
|
154
|
-
await getAdminRequestContext();
|
|
155
|
-
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
156
|
-
}
|
|
157
|
-
catch (err) {
|
|
158
|
-
expect(err).toBeInstanceOf(AuthError);
|
|
159
|
-
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
160
|
-
}
|
|
161
|
-
const clears = setCookie.mock.calls.filter((c) => c[2]?.maxAge === 0);
|
|
162
|
-
expect(clears.length).toBe(2);
|
|
163
|
-
});
|
|
164
|
-
it('clears cookies and throws when the refreshed access token fails to verify', async () => {
|
|
165
|
-
cookiesReturn({
|
|
166
|
-
byline_access_token: 'stale',
|
|
167
|
-
byline_refresh_token: 'valid',
|
|
168
|
-
});
|
|
169
|
-
verifyAccessToken
|
|
170
|
-
.mockRejectedValueOnce(new Error('expired'))
|
|
171
|
-
.mockRejectedValueOnce(new Error('still bad'));
|
|
172
|
-
refreshSession.mockResolvedValueOnce({
|
|
173
|
-
accessToken: 'fresh-but-somehow-bad',
|
|
174
|
-
refreshToken: 'fresh-refresh',
|
|
175
|
-
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
176
|
-
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
177
|
-
});
|
|
178
|
-
try {
|
|
179
|
-
await getAdminRequestContext();
|
|
180
|
-
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
184
|
-
}
|
|
185
|
-
const clears = setCookie.mock.calls.filter((c) => c[2]?.maxAge === 0);
|
|
186
|
-
expect(clears.length).toBe(2);
|
|
187
|
-
});
|
|
188
110
|
describe('per-request memoization', () => {
|
|
189
111
|
it('returns the same context instance — and requestId — for every call in one request', async () => {
|
|
190
112
|
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
@@ -192,7 +114,7 @@ describe('getAdminRequestContext', () => {
|
|
|
192
114
|
byline_access_token: 'valid-access',
|
|
193
115
|
byline_refresh_token: 'some-refresh',
|
|
194
116
|
});
|
|
195
|
-
verifyAccessToken.mockResolvedValue({ actor: stubActor() });
|
|
117
|
+
verifyAccessToken.mockResolvedValue({ actor: stubActor(), sessionId: 'test-login' });
|
|
196
118
|
const first = await getAdminRequestContext();
|
|
197
119
|
const second = await getAdminRequestContext();
|
|
198
120
|
// Reads sharing a ReadContext bind one request authority; the token
|
|
@@ -206,7 +128,7 @@ describe('getAdminRequestContext', () => {
|
|
|
206
128
|
byline_access_token: 'valid-access',
|
|
207
129
|
byline_refresh_token: 'some-refresh',
|
|
208
130
|
});
|
|
209
|
-
verifyAccessToken.mockResolvedValue({ actor: stubActor() });
|
|
131
|
+
verifyAccessToken.mockResolvedValue({ actor: stubActor(), sessionId: 'test-login' });
|
|
210
132
|
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
211
133
|
const first = await getAdminRequestContext();
|
|
212
134
|
bridge.getRequest.mockReturnValue({ id: 'request-b' });
|
|
@@ -215,42 +137,15 @@ describe('getAdminRequestContext', () => {
|
|
|
215
137
|
expect(second.requestId).not.toBe(first.requestId);
|
|
216
138
|
expect(verifyAccessToken).toHaveBeenCalledTimes(2);
|
|
217
139
|
});
|
|
218
|
-
it('
|
|
219
|
-
bridge.getRequest.mockReturnValue({
|
|
220
|
-
cookiesReturn({
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
accessToken: 'fresh-access',
|
|
228
|
-
refreshToken: 'fresh-refresh',
|
|
229
|
-
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
230
|
-
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
231
|
-
});
|
|
232
|
-
const first = await getAdminRequestContext();
|
|
233
|
-
// A second unmemoized call would re-run the refresh dance against the
|
|
234
|
-
// request's stale cookie — now already rotated — and sign the admin out.
|
|
235
|
-
const second = await getAdminRequestContext();
|
|
236
|
-
expect(second).toBe(first);
|
|
237
|
-
expect(refreshSession).toHaveBeenCalledTimes(1);
|
|
238
|
-
});
|
|
239
|
-
it('memoizes the unauthenticated rejection within one request', async () => {
|
|
240
|
-
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
241
|
-
cookiesReturn({
|
|
242
|
-
byline_access_token: 'stale',
|
|
243
|
-
byline_refresh_token: 'bad-refresh',
|
|
244
|
-
});
|
|
245
|
-
verifyAccessToken.mockRejectedValue(new Error('expired'));
|
|
246
|
-
refreshSession.mockRejectedValue(new Error('revoked'));
|
|
247
|
-
await expect(getAdminRequestContext()).rejects.toMatchObject({
|
|
248
|
-
code: AuthErrorCodes.UNAUTHENTICATED,
|
|
249
|
-
});
|
|
250
|
-
await expect(getAdminRequestContext()).rejects.toMatchObject({
|
|
251
|
-
code: AuthErrorCodes.UNAUTHENTICATED,
|
|
252
|
-
});
|
|
253
|
-
expect(refreshSession).toHaveBeenCalledTimes(1);
|
|
140
|
+
it('memoizes expiry without any rotation or cookie write', async () => {
|
|
141
|
+
bridge.getRequest.mockReturnValue({});
|
|
142
|
+
cookiesReturn({ byline_access_token: 'expired', byline_refresh_token: 'retained' });
|
|
143
|
+
verifyAccessToken.mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' }));
|
|
144
|
+
await expect(getAdminRequestContext()).rejects.toMatchObject({ code: 'ERR_ACCESS_EXPIRED' });
|
|
145
|
+
await expect(getAdminRequestContext()).rejects.toMatchObject({ code: 'ERR_ACCESS_EXPIRED' });
|
|
146
|
+
expect(verifyAccessToken).toHaveBeenCalledOnce();
|
|
147
|
+
expect(refreshSession).not.toHaveBeenCalled();
|
|
148
|
+
expect(setCookie).not.toHaveBeenCalled();
|
|
254
149
|
});
|
|
255
150
|
});
|
|
256
151
|
});
|
|
@@ -73,6 +73,7 @@ describe('request factory ↔ client authority contract', () => {
|
|
|
73
73
|
bridge.getRequest.mockReturnValue(undefined);
|
|
74
74
|
bridge.getCookie.mockImplementation((name) => name === 'byline_access_token' ? 'valid-access' : undefined);
|
|
75
75
|
mocks.verifyAccessToken.mockResolvedValue({
|
|
76
|
+
sessionId: 'test-login',
|
|
76
77
|
actor: new AdminAuth({ id: 'admin-1', abilities: ['collections.posts.read'] }),
|
|
77
78
|
});
|
|
78
79
|
});
|
|
@@ -84,7 +84,7 @@ describe('request-context factories', () => {
|
|
|
84
84
|
bridge.getRequest.mockReturnValue({ id: 'request-a' });
|
|
85
85
|
cookiesReturn({ byline_preview: '1', byline_access_token: 'valid-access' });
|
|
86
86
|
const actor = new AdminAuth({ id: 'admin-1', abilities: [] });
|
|
87
|
-
mocks.verifyAccessToken.mockResolvedValue({ actor });
|
|
87
|
+
mocks.verifyAccessToken.mockResolvedValue({ actor, sessionId: 'test-login' });
|
|
88
88
|
const first = await resolveViewerRequestContext();
|
|
89
89
|
const second = await resolveViewerRequestContext();
|
|
90
90
|
expect(second).toBe(first);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/client",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "5.
|
|
5
|
+
"version": "5.1.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -44,21 +44,21 @@
|
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"uuid": "^14.0.2",
|
|
47
|
-
"@byline/auth": "5.
|
|
48
|
-
"@byline/core": "5.
|
|
47
|
+
"@byline/auth": "5.1.0",
|
|
48
|
+
"@byline/core": "5.1.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@biomejs/biome": "2.5.
|
|
52
|
-
"@types/node": "^26.
|
|
51
|
+
"@biomejs/biome": "2.5.12",
|
|
52
|
+
"@types/node": "^26.5.0",
|
|
53
53
|
"chokidar": "^5.0.0",
|
|
54
54
|
"chokidar-cli": "^3.0.0",
|
|
55
55
|
"dotenv": "^17.4.2",
|
|
56
56
|
"npm-run-all": "^4.1.5",
|
|
57
|
-
"tsc-alias": "^1.9.
|
|
57
|
+
"tsc-alias": "^1.9.4",
|
|
58
58
|
"typescript": "^7.0.2",
|
|
59
|
-
"vitest": "^
|
|
60
|
-
"@byline/
|
|
61
|
-
"@byline/
|
|
59
|
+
"vitest": "^5.0.0",
|
|
60
|
+
"@byline/db-postgres": "5.1.0",
|
|
61
|
+
"@byline/search-postgres": "5.1.0"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public",
|