@poppykit/server 0.3.1 → 0.5.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/API.md +11 -3
- package/AUTH.md +120 -95
- package/CHANGELOG.md +18 -0
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +13 -10
- package/DELIVERY.md +21 -1
- package/INTEGRATIONS.md +72 -69
- package/OPERATIONS.md +142 -0
- package/README.md +14 -17
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +57 -2
- package/dist/adapter-contract.d.ts +18 -0
- package/dist/adapter-contract.js +90 -11
- package/dist/auth/authenticator.d.ts +3 -1
- package/dist/auth/authenticator.js +34 -29
- package/dist/auth/authority.d.ts +8 -2
- package/dist/auth/authority.js +7 -2
- package/dist/auth/session.d.ts +47 -0
- package/dist/auth/session.js +94 -0
- package/dist/browser.d.ts +15 -13
- package/dist/browser.js +80 -46
- package/dist/conversation/service.d.ts +18 -8
- package/dist/conversation/service.js +60 -28
- package/dist/feedback.d.ts +1 -1
- package/dist/feedback.js +5 -5
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +8 -4
- package/dist/network/discovery.d.ts +51 -22
- package/dist/network/discovery.js +159 -69
- package/dist/network/redirect.d.ts +3 -0
- package/dist/network/redirect.js +38 -0
- package/dist/network/tasks.d.ts +23 -0
- package/dist/network/tasks.js +45 -0
- package/dist/operations.d.ts +115 -0
- package/dist/operations.js +212 -0
- package/dist/postgres.d.ts +6 -0
- package/dist/postgres.js +92 -6
- package/dist/protocol/operations.d.ts +51 -0
- package/dist/protocol/operations.js +98 -0
- package/dist/protocol/session.d.ts +28 -0
- package/dist/protocol/session.js +93 -0
- package/dist/protocol/sse.d.ts +1 -0
- package/dist/protocol/sse.js +1 -1
- package/dist/protocol/wire.d.ts +20 -15
- package/dist/protocol/wire.js +70 -22
- package/dist/reference-auth/index.d.ts +17 -12
- package/dist/reference-auth/index.js +252 -386
- package/dist/reference-auth/store.d.ts +2 -1
- package/dist/sqlite.d.ts +11 -0
- package/dist/sqlite.js +104 -6
- package/examples/operations.mjs +18 -0
- package/examples/quickstart.mjs +10 -6
- package/migrations/RETIREMENT.md +77 -0
- package/package.json +5 -2
package/dist/adapter-contract.js
CHANGED
|
@@ -23,15 +23,72 @@ export function runIssuerAdapterContract(fixture) {
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
export function runSessionAdapterContract(fixture) {
|
|
27
|
+
test('Session adapter contract: admission counts, concurrent writers, detached snapshots and rollback', async () => {
|
|
28
|
+
const f = await fixture(), at = 1790900000000, client = 'https://agent.example/client', sid = 'session_a';
|
|
29
|
+
try {
|
|
30
|
+
const empty = await f.store.read(client, sid);
|
|
31
|
+
assert.equal(empty.record, null);
|
|
32
|
+
assert.equal((await f.second.read(client, sid)).generation, empty.generation);
|
|
33
|
+
await Promise.all(Array.from({ length: 8 }, (_, i) => (i % 2 ? f.store : f.second).transaction(client, sid, at, state => {
|
|
34
|
+
assert.equal(state.activeCount, state.record ? 1 : 0);
|
|
35
|
+
state.record ??= { instanceId: 'instance_a', clientId: client, sid, userId: 'user_a', createdAt: at, expiresAt: at + 1000, ended: false };
|
|
36
|
+
state.record.expiresAt++;
|
|
37
|
+
})));
|
|
38
|
+
const snapshot = await f.second.read(client, sid);
|
|
39
|
+
assert.equal(snapshot.record.expiresAt, at + 1008);
|
|
40
|
+
snapshot.record.userId = 'detached';
|
|
41
|
+
assert.equal((await f.store.read(client, sid)).record.userId, 'user_a');
|
|
42
|
+
await assert.rejects(f.store.transaction(client, sid, at, state => { state.record.userId = 'rollback'; throw new Error('rollback'); }), /rollback/);
|
|
43
|
+
await assert.rejects(f.store.transaction(client, sid, at, async (state) => { state.record.userId = 'async'; }));
|
|
44
|
+
assert.equal((await f.second.read(client, sid)).record.userId, 'user_a');
|
|
45
|
+
await f.second.transaction(client, 'absent', at, state => { assert.equal(state.activeCount, 1); assert.equal(state.earliestExpiry, at + 1008); });
|
|
46
|
+
await f.store.transaction(client, 'absent', at + 1008, state => { assert.equal(state.activeCount, 0); assert.equal(state.earliestExpiry, null); });
|
|
47
|
+
assert.equal((await f.store.read('https://other.example/client', sid)).record, null);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await f.close();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function runBrowserCookieAdapterContract(fixture) {
|
|
55
|
+
test('browser adapter contract: one authorization and consumption across handles, fixed detached cookies', async () => {
|
|
56
|
+
const f = await fixture(), at = 1790900000000, principal = { issuer: 'https://company.example', clientId: 'https://agent.example/client', userId: 'user_a', sessionId: 'session_a', sessionVersion: 1, scopes: ['read'], tokenExpiresAt: at + 1000 };
|
|
57
|
+
try {
|
|
58
|
+
await f.store.putJoin('code', { cookieHash: 'cookie', expiresAt: at + 1000 });
|
|
59
|
+
const authorized = await Promise.all([f.store.authorizeJoin('code', principal, at), f.second.authorizeJoin('code', principal, at)]);
|
|
60
|
+
assert.deepEqual(authorized.sort(), [false, true]);
|
|
61
|
+
const consumed = await Promise.all([f.store.consumeJoin('code'), f.second.consumeJoin('code')]);
|
|
62
|
+
assert.equal(consumed.filter(Boolean).length, 1);
|
|
63
|
+
assert.deepEqual(consumed.find(Boolean).principal, principal);
|
|
64
|
+
await f.store.putJoin('expired', { cookieHash: 'cookie', expiresAt: at - 1 });
|
|
65
|
+
assert.equal(await f.second.authorizeJoin('expired', principal, at), false);
|
|
66
|
+
assert.ok(await f.second.consumeJoin('expired'));
|
|
67
|
+
assert.equal(await f.store.consumeJoin('expired'), null);
|
|
68
|
+
const binding = { clientId: principal.clientId, userId: principal.userId, sessionId: principal.sessionId, principal, expiresAt: at + 1000 };
|
|
69
|
+
await f.store.put('browser', binding);
|
|
70
|
+
principal.scopes.push('write');
|
|
71
|
+
const cookie = await f.second.get('browser');
|
|
72
|
+
assert.deepEqual(cookie.principal.scopes, ['read']);
|
|
73
|
+
cookie.principal.scopes.push('admin');
|
|
74
|
+
assert.deepEqual((await f.store.get('browser')).principal.scopes, ['read']);
|
|
75
|
+
await f.second.remove('browser');
|
|
76
|
+
assert.equal(await f.store.get('browser'), null);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await f.close();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
26
83
|
const endpoint = 'https://contract.example.test/poppy/conversations';
|
|
27
84
|
const base = { issuer: 'https://auth.contract.test', clientId: 'https://agent.contract.test/client', sessionId: 's1', userId: 'u_1', scopes: [], tokenExpiresAt: 9e12, sessionVersion: 1 };
|
|
28
85
|
const principals = {
|
|
29
86
|
signedOut: base,
|
|
30
|
-
account1: { ...base, accountId: 'acct_1', scopes: ['
|
|
31
|
-
account2: { ...base, accountId: 'acct_2', scopes: ['
|
|
87
|
+
account1: { ...base, accountId: 'acct_1', scopes: ['orders:read'] },
|
|
88
|
+
account2: { ...base, accountId: 'acct_2', scopes: ['orders:read'] },
|
|
32
89
|
};
|
|
33
|
-
const message = { id: 'msg_1',
|
|
34
|
-
const reply = { id: 'msg_r1',
|
|
90
|
+
const message = { id: 'msg_1', author: { type: 'agent' }, text: 'Where is order 1042?' };
|
|
91
|
+
const reply = { id: 'msg_r1', author: { type: 'agent' }, text: 'Shipped.' };
|
|
35
92
|
function check(held, needed, action) {
|
|
36
93
|
if (!needed.every(scope => held.includes(scope)))
|
|
37
94
|
return Promise.reject(new AuthorityError('insufficient_scope'));
|
|
@@ -50,7 +107,7 @@ function serviceOn(store, clock) {
|
|
|
50
107
|
return principal;
|
|
51
108
|
},
|
|
52
109
|
};
|
|
53
|
-
return createConversationService({ endpoint, store, authenticator, policy, clock, leaseMs: 1000, supportedScopes: ['
|
|
110
|
+
return createConversationService({ historyScopes: ['orders:read'], endpoint, store, authenticator, policy, clock, leaseMs: 1000, supportedScopes: ['orders:read'] });
|
|
54
111
|
}
|
|
55
112
|
async function call(service, path, as, body, query = '') {
|
|
56
113
|
const response = await service.handle(new Request(`${endpoint}${path}${query}`, {
|
|
@@ -114,12 +171,12 @@ export function runConversationAdapterContract(fixture) {
|
|
|
114
171
|
const parentWork = (await a.claim());
|
|
115
172
|
const outcome = (promise) => promise.then(value => value.accountId, error => String(error.code));
|
|
116
173
|
const results = await Promise.all([
|
|
117
|
-
outcome(a.requireAccount(parentWork, ['
|
|
174
|
+
outcome(a.requireAccount(parentWork, ['orders:read'])),
|
|
118
175
|
(async () => {
|
|
119
|
-
const opened = await call(b, '', 'account2', { parent_conversation_id: parent, message: { id: 'msg_c',
|
|
176
|
+
const opened = await call(b, '', 'account2', { parent_conversation_id: parent, message: { id: 'msg_c', author: { type: 'human' }, text: 'Hi.' } });
|
|
120
177
|
if (opened.status !== 201)
|
|
121
178
|
return String(opened.body.error);
|
|
122
|
-
return outcome(b.claim().then(work => b.requireAccount(work, ['
|
|
179
|
+
return outcome(b.claim().then(work => b.requireAccount(work, ['orders:read'])));
|
|
123
180
|
})(),
|
|
124
181
|
]);
|
|
125
182
|
const winners = results.filter(result => result.startsWith('acct_'));
|
|
@@ -129,7 +186,7 @@ export function runConversationAdapterContract(fixture) {
|
|
|
129
186
|
assert.deepEqual(state.bindings[parent], { accountId: winners[0] });
|
|
130
187
|
const loser = winners[0] === 'acct_1' ? 'account2' : 'account1';
|
|
131
188
|
for (const id of Object.keys(state.conversations))
|
|
132
|
-
assert.deepEqual((await call(b, `/${id}/events`, loser)).body, { error: '
|
|
189
|
+
assert.deepEqual((await call(b, `/${id}/events`, loser)).body, { error: 'insufficient_scope' });
|
|
133
190
|
}));
|
|
134
191
|
test('adapter contract: lease reclaim on the other handle fences the old worker; output IDs dedupe', () => withFixture(async ({ store, second }) => {
|
|
135
192
|
const [a, b] = [serviceOn(store, clock), serviceOn(second, clock)];
|
|
@@ -155,8 +212,8 @@ export function runConversationAdapterContract(fixture) {
|
|
|
155
212
|
await a.reply(work, outputId, value);
|
|
156
213
|
await b.reply(work, outputId, value);
|
|
157
214
|
await assert.rejects(b.reply(work, outputId, { ...value, text: 'changed' }), { code: 'output_conflict' });
|
|
158
|
-
await a.requestAuthorization(work, `auth_${outputId}`, ['
|
|
159
|
-
await b.requestAuthorization(work, `auth_${outputId}`, ['
|
|
215
|
+
await a.requestAuthorization(work, `auth_${outputId}`, ['orders:read']);
|
|
216
|
+
await b.requestAuthorization(work, `auth_${outputId}`, ['orders:read']);
|
|
160
217
|
await a.requestUser(work, `user_${outputId}`, outputId);
|
|
161
218
|
await b.requestUser(work, `user_${outputId}`, outputId);
|
|
162
219
|
await assert.rejects(b.requestUser(work, `user_${outputId}`, 'changed'), { code: 'output_conflict' });
|
|
@@ -195,3 +252,25 @@ export function runConversationAdapterContract(fixture) {
|
|
|
195
252
|
assert.equal((await call(a, `/${id}/events`, 'signedOut')).status, 404);
|
|
196
253
|
}));
|
|
197
254
|
}
|
|
255
|
+
export function runBusinessOperationAdapterContract(fixture) {
|
|
256
|
+
test('business operation adapter contract: absent-row races, rollback, detached snapshots and version fences', async () => {
|
|
257
|
+
const f = await fixture();
|
|
258
|
+
try {
|
|
259
|
+
await Promise.all(Array.from({ length: 10 }, (_, n) => (n % 2 ? f.store : f.second).transaction('action', async (slot) => {
|
|
260
|
+
slot.record ??= { version: 1, id: 'action', owner: { companyId: 'https://company.example', clientId: 'https://agent.example/client', userId: 'user', sessionId: 'sid', sessionInstanceId: 'instance', sessionGeneration: 'generation' }, scopes: [], revisions: {} };
|
|
261
|
+
slot.record.scopes.push(String(n));
|
|
262
|
+
})));
|
|
263
|
+
const read = () => f.store.transaction('action', async (slot) => slot.record);
|
|
264
|
+
const record = await read();
|
|
265
|
+
assert.equal(record.scopes.length, 10);
|
|
266
|
+
record.scopes.length = 0;
|
|
267
|
+
await assert.rejects(f.second.transaction('action', async (slot) => { slot.record.scopes = []; throw Error('rollback'); }), /rollback/);
|
|
268
|
+
assert.equal((await read()).scopes.length, 10);
|
|
269
|
+
await assert.rejects(f.store.transaction('action', async (slot) => { Object.assign(slot.record, { version: 99 }); }), /binding|version/);
|
|
270
|
+
assert.equal((await read()).version, 1);
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await f.close();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExternalAuthorityAdapter, RequestAuthenticator, ResourceEndpoint } from './authority.js';
|
|
2
|
+
import type { SessionAuthority } from './session.js';
|
|
2
3
|
export declare function createRequestAuthenticator(options: {
|
|
3
|
-
authority
|
|
4
|
+
authority?: Pick<ExternalAuthorityAdapter, 'introspect'>;
|
|
5
|
+
sessions?: SessionAuthority;
|
|
4
6
|
endpoints: ResourceEndpoint[];
|
|
5
7
|
now: () => number;
|
|
6
8
|
}): RequestAuthenticator;
|
|
@@ -1,51 +1,56 @@
|
|
|
1
1
|
import { secureUrl } from '../network/transport.js';
|
|
2
2
|
import { AuthorityError } from './authority.js';
|
|
3
3
|
import { verifyDpopProof } from './dpop.js';
|
|
4
|
-
const DPOP_AUTHORIZATION = /^DPoP ([A-Za-z0-9\-._~+/]+=*)$/i;
|
|
5
|
-
// Only introspect is used here; the policy half of the adapter is consumed by the conversation service.
|
|
6
4
|
export function createRequestAuthenticator(options) {
|
|
7
5
|
for (const endpoint of options.endpoints) {
|
|
8
6
|
if (secureUrl(endpoint.origin).origin !== endpoint.origin || !endpoint.pathPrefix.startsWith('/'))
|
|
9
7
|
throw new TypeError('Invalid resource endpoint');
|
|
10
|
-
if (endpoint.mcp
|
|
11
|
-
|
|
8
|
+
if (endpoint.mcp) {
|
|
9
|
+
const resource = secureUrl(endpoint.resource ?? '');
|
|
10
|
+
if (resource.origin !== endpoint.origin || resource.pathname !== endpoint.pathPrefix || secureUrl(endpoint.mcp.metadataUrl).href !== endpoint.mcp.metadataUrl)
|
|
11
|
+
throw new TypeError('Invalid MCP resource');
|
|
12
|
+
}
|
|
12
13
|
}
|
|
13
14
|
return {
|
|
14
15
|
async authenticate(request) {
|
|
15
|
-
// The Request URL must already be the trusted public URL; forwarding headers are never read.
|
|
16
16
|
const url = new URL(request.url);
|
|
17
17
|
const endpoint = options.endpoints
|
|
18
18
|
.filter(item => url.origin === item.origin && (url.pathname === item.pathPrefix || (!item.mcp && url.pathname.startsWith(item.pathPrefix.endsWith('/') ? item.pathPrefix : `${item.pathPrefix}/`))))
|
|
19
19
|
.sort((a, b) => b.pathPrefix.length - a.pathPrefix.length)[0];
|
|
20
|
-
if (!endpoint)
|
|
20
|
+
if (!endpoint || (endpoint.mcp && request.url !== endpoint.resource))
|
|
21
|
+
throw new AuthorityError('invalid_token');
|
|
22
|
+
const authorization = request.headers.get('authorization');
|
|
23
|
+
if (authorization === null) {
|
|
24
|
+
if (!options.sessions)
|
|
25
|
+
throw new AuthorityError('invalid_session');
|
|
26
|
+
return options.sessions.authenticate(request);
|
|
27
|
+
}
|
|
28
|
+
const credentials = /^(Bearer|DPoP) ([A-Za-z0-9\-._~+/]+=*)$/i.exec(authorization);
|
|
29
|
+
if (!credentials || credentials[2].length > 4096 || !options.authority)
|
|
21
30
|
throw new AuthorityError('invalid_token');
|
|
22
|
-
|
|
31
|
+
const token = credentials[2], scheme = credentials[1].toLowerCase();
|
|
32
|
+
const { principal, dpopJkt } = await options.authority.introspect(token, endpoint.resource);
|
|
33
|
+
if (principal.tokenExpiresAt <= options.now())
|
|
23
34
|
throw new AuthorityError('invalid_token');
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
if (!bearer || bearer.length > 4096)
|
|
35
|
+
if (scheme === 'bearer') {
|
|
36
|
+
if (dpopJkt !== null)
|
|
27
37
|
throw new AuthorityError('invalid_token');
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
if (endpoint.mcp && !endpoint.mcp.dpop)
|
|
31
41
|
throw new AuthorityError('invalid_token');
|
|
32
|
-
|
|
42
|
+
const proof = await verifyDpopProof(request.headers.get('dpop'), { method: request.method, url: `${url.origin}${url.pathname}`, nowMs: options.now(), accessToken: token });
|
|
43
|
+
if (dpopJkt !== proof.jkt)
|
|
44
|
+
throw new AuthorityError('invalid_dpop_proof');
|
|
45
|
+
}
|
|
46
|
+
if (endpoint.mcp && !request.headers.has('poppy-session')) {
|
|
47
|
+
if (!principal.accountId)
|
|
33
48
|
throw new AuthorityError('invalid_token');
|
|
34
|
-
return principal;
|
|
49
|
+
return { ...principal, userId: '', sessionId: '', sessionVersion: 0 };
|
|
35
50
|
}
|
|
36
|
-
if (
|
|
37
|
-
throw new AuthorityError('
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
throw new AuthorityError('invalid_token');
|
|
41
|
-
const nowMs = options.now();
|
|
42
|
-
const proof = await verifyDpopProof(request.headers.get('dpop'), { method: request.method, url: `${url.origin}${url.pathname}`, nowMs, accessToken: token });
|
|
43
|
-
const { principal, dpopJkt } = await options.authority.introspect(token, endpoint.resource);
|
|
44
|
-
if (principal.tokenExpiresAt <= nowMs)
|
|
45
|
-
throw new AuthorityError('invalid_token');
|
|
46
|
-
if (dpopJkt !== proof.jkt)
|
|
47
|
-
throw new AuthorityError('invalid_dpop_proof');
|
|
48
|
-
return principal;
|
|
49
|
-
}
|
|
51
|
+
if (!options.sessions)
|
|
52
|
+
throw new AuthorityError('invalid_session');
|
|
53
|
+
return options.sessions.authenticate(request, { token, principal });
|
|
54
|
+
},
|
|
50
55
|
};
|
|
51
56
|
}
|
package/dist/auth/authority.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export interface Principal {
|
|
|
9
9
|
tokenExpiresAt: number;
|
|
10
10
|
sessionVersion: number;
|
|
11
11
|
issuerGeneration?: string;
|
|
12
|
+
agentId?: string;
|
|
13
|
+
sessionGeneration?: string;
|
|
14
|
+
sessionInstanceId?: string;
|
|
15
|
+
sessionExpiresAt?: number;
|
|
12
16
|
}
|
|
13
17
|
export type WorkRevocationPolicy = 'stop-account-work' | 'allow-accepted-work-to-finish';
|
|
14
18
|
export interface AcceptedAuthority {
|
|
@@ -44,12 +48,14 @@ export interface ExternalAuthorityAdapter {
|
|
|
44
48
|
}>;
|
|
45
49
|
policy: AuthorityPolicy;
|
|
46
50
|
}
|
|
47
|
-
export type AuthorityErrorCode = 'invalid_token' | 'invalid_dpop_proof' | 'use_dpop_nonce' | '
|
|
51
|
+
export type AuthorityErrorCode = 'invalid_token' | 'invalid_dpop_proof' | 'use_dpop_nonce' | 'invalid_session' | 'account_required' | 'rate_limited' | 'insufficient_scope';
|
|
48
52
|
export declare class AuthorityError extends Error {
|
|
49
53
|
readonly code: AuthorityErrorCode;
|
|
50
|
-
readonly status: 401 | 403;
|
|
54
|
+
readonly status: 400 | 401 | 403 | 429;
|
|
51
55
|
readonly headers: Headers;
|
|
52
56
|
constructor(code: AuthorityErrorCode, options?: {
|
|
53
57
|
scope?: string;
|
|
58
|
+
retryAfter?: number;
|
|
54
59
|
});
|
|
60
|
+
response(): Response;
|
|
55
61
|
}
|
package/dist/auth/authority.js
CHANGED
|
@@ -7,10 +7,15 @@ export class AuthorityError extends Error {
|
|
|
7
7
|
super(`Authorization failed: ${code}`);
|
|
8
8
|
this.code = code;
|
|
9
9
|
this.name = 'AuthorityError';
|
|
10
|
-
this.status = code === '
|
|
10
|
+
this.status = code === 'rate_limited' ? 429 : code === 'invalid_session' ? 400 : code === 'insufficient_scope' ? 403 : 401;
|
|
11
11
|
if (options.scope !== undefined && !/^[\x21\x23-\x5B\x5D-\x7E ]*$/.test(options.scope))
|
|
12
12
|
throw new TypeError('scope');
|
|
13
13
|
const scope = options.scope === undefined ? '' : `, scope="${options.scope}"`;
|
|
14
|
-
|
|
14
|
+
if (options.retryAfter !== undefined && (!Number.isSafeInteger(options.retryAfter) || options.retryAfter < 1))
|
|
15
|
+
throw new TypeError('retryAfter');
|
|
16
|
+
this.headers = code === 'rate_limited' ? new Headers({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Retry-After': String(options.retryAfter ?? 60) }) : code === 'invalid_session' ? new Headers({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) : new Headers({ 'WWW-Authenticate': code === 'account_required' ? `Bearer${scope ? ' ' + scope.slice(2) : ''}, DPoP${scope ? ' ' + scope.slice(2) : ''}` : `DPoP error="${code}"${scope}, algs="ES256", Bearer error="${code}"${scope}` });
|
|
17
|
+
}
|
|
18
|
+
response() {
|
|
19
|
+
return new Response(this.code === 'invalid_session' || this.code === 'rate_limited' ? JSON.stringify({ error: this.code }) : null, { status: this.status, headers: this.headers });
|
|
15
20
|
}
|
|
16
21
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AuthorityCoordinator, AuthorityPolicy, Principal } from './authority.js';
|
|
2
|
+
import type { MetadataResolver } from '../network/discovery.js';
|
|
3
|
+
export interface SessionRecord {
|
|
4
|
+
instanceId: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
sid: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
agentId?: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
ended: boolean;
|
|
12
|
+
accountId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SessionState {
|
|
15
|
+
version: 1;
|
|
16
|
+
generation: string;
|
|
17
|
+
record: SessionRecord | null;
|
|
18
|
+
}
|
|
19
|
+
export interface SessionAdmission extends SessionState {
|
|
20
|
+
activeCount: number;
|
|
21
|
+
earliestExpiry: number | null;
|
|
22
|
+
}
|
|
23
|
+
/** Serialize admission per client across processes; callbacks are synchronous, detached, and atomic. */
|
|
24
|
+
export interface SessionStore {
|
|
25
|
+
read(clientId: string, sid: string): Promise<SessionState>;
|
|
26
|
+
transaction<T>(clientId: string, sid: string, at: number, change: (state: SessionAdmission) => T): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
export interface SessionAuthority {
|
|
29
|
+
authenticate(request: Request, account?: {
|
|
30
|
+
token: string;
|
|
31
|
+
principal: Principal;
|
|
32
|
+
}): Promise<Principal>;
|
|
33
|
+
end(clientId: string, sid: string): Promise<void>;
|
|
34
|
+
policy: AuthorityPolicy;
|
|
35
|
+
}
|
|
36
|
+
/** Resources own Sessions; an OAuth issuer supplies only account authority. */
|
|
37
|
+
export declare function createSessionAuthority(options: {
|
|
38
|
+
companyId: string;
|
|
39
|
+
metadata: MetadataResolver;
|
|
40
|
+
store: SessionStore;
|
|
41
|
+
coordinator: AuthorityCoordinator;
|
|
42
|
+
now: () => number;
|
|
43
|
+
accountPolicy?: AuthorityPolicy;
|
|
44
|
+
lifetimeSeconds?: number;
|
|
45
|
+
maxSessionsPerClient?: number;
|
|
46
|
+
acceptClient?: (clientId: string) => Promise<boolean>;
|
|
47
|
+
}): SessionAuthority;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { AuthorityError } from './authority.js';
|
|
3
|
+
import { verifySessionJwt } from '../protocol/session.js';
|
|
4
|
+
import { secureUrl } from '../network/transport.js';
|
|
5
|
+
/** Resources own Sessions; an OAuth issuer supplies only account authority. */
|
|
6
|
+
export function createSessionAuthority(options) {
|
|
7
|
+
secureUrl(options.companyId);
|
|
8
|
+
const lifetime = options.lifetimeSeconds ?? 86400;
|
|
9
|
+
if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 31_536_000)
|
|
10
|
+
throw new TypeError('Session lifetime');
|
|
11
|
+
const maxSessions = options.maxSessionsPerClient ?? 10000;
|
|
12
|
+
if (!Number.isSafeInteger(maxSessions) || maxSessions < 1)
|
|
13
|
+
throw new TypeError('maxSessionsPerClient');
|
|
14
|
+
const invalid = () => new AuthorityError('invalid_session');
|
|
15
|
+
async function current(principal) {
|
|
16
|
+
const state = await options.store.read(principal.clientId, principal.sessionId), record = state.record;
|
|
17
|
+
if (principal.issuer !== options.companyId || principal.sessionGeneration !== state.generation || !record || record.ended || record.expiresAt <= options.now()
|
|
18
|
+
|| record.clientId !== principal.clientId || record.sid !== principal.sessionId || record.userId !== principal.userId || record.instanceId !== principal.sessionInstanceId || record.agentId !== principal.agentId)
|
|
19
|
+
throw invalid();
|
|
20
|
+
}
|
|
21
|
+
const policy = {
|
|
22
|
+
withCurrent: (principal, scopes, action) => options.coordinator.run(async () => {
|
|
23
|
+
await current(principal);
|
|
24
|
+
if (principal.tokenExpiresAt <= options.now())
|
|
25
|
+
throw new AuthorityError('invalid_token');
|
|
26
|
+
if (!scopes.every(scope => principal.scopes.includes(scope)))
|
|
27
|
+
throw new AuthorityError(principal.accountId ? 'insufficient_scope' : 'account_required', { scope: scopes.join(' ') });
|
|
28
|
+
if (principal.accountId) {
|
|
29
|
+
if (!options.accountPolicy)
|
|
30
|
+
throw new AuthorityError('invalid_token');
|
|
31
|
+
return options.accountPolicy.withCurrent(principal, scopes, action);
|
|
32
|
+
}
|
|
33
|
+
return action();
|
|
34
|
+
}),
|
|
35
|
+
withAccepted: (accepted, scopes, action) => options.coordinator.run(async () => {
|
|
36
|
+
const state = await options.store.read(accepted.principal.clientId, accepted.principal.sessionId);
|
|
37
|
+
if (accepted.principal.issuer !== options.companyId || state.generation !== accepted.principal.sessionGeneration)
|
|
38
|
+
throw invalid();
|
|
39
|
+
if (!scopes.every(scope => accepted.principal.scopes.includes(scope)))
|
|
40
|
+
throw new AuthorityError('insufficient_scope', { scope: scopes.join(' ') });
|
|
41
|
+
if (accepted.principal.accountId) {
|
|
42
|
+
if (!options.accountPolicy)
|
|
43
|
+
throw new AuthorityError('invalid_token');
|
|
44
|
+
return options.accountPolicy.withAccepted(accepted, scopes, action);
|
|
45
|
+
}
|
|
46
|
+
return action();
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
policy,
|
|
51
|
+
async authenticate(request, account) {
|
|
52
|
+
let claims;
|
|
53
|
+
try {
|
|
54
|
+
claims = await verifySessionJwt(request.headers.get('poppy-session'), {
|
|
55
|
+
companyId: options.companyId, method: request.method, url: request.url, nowMs: options.now(), metadata: options.metadata,
|
|
56
|
+
...(account ? { accessToken: account.token, tokenClientId: account.principal.clientId } : {}),
|
|
57
|
+
});
|
|
58
|
+
if (options.acceptClient && !await options.acceptClient(claims.iss))
|
|
59
|
+
throw invalid();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw invalid();
|
|
63
|
+
}
|
|
64
|
+
if (account && (account.principal.issuer !== options.companyId || account.principal.tokenExpiresAt <= options.now() || !account.principal.accountId))
|
|
65
|
+
throw new AuthorityError('invalid_token');
|
|
66
|
+
return options.coordinator.run(() => options.store.transaction(claims.iss, claims.sid, options.now(), state => {
|
|
67
|
+
const at = options.now();
|
|
68
|
+
if (claims.iat < at / 1000 - 60 || claims.delegationExpiresAt <= at)
|
|
69
|
+
throw invalid();
|
|
70
|
+
if (!Number.isSafeInteger(state.activeCount) || state.activeCount < 0)
|
|
71
|
+
throw new TypeError('Session admission count');
|
|
72
|
+
if (!state.record && state.activeCount >= maxSessions)
|
|
73
|
+
throw new AuthorityError('rate_limited', { retryAfter: Math.max(1, Math.ceil(((state.earliestExpiry ?? at + lifetime * 1000) - at) / 1000)) });
|
|
74
|
+
const record = state.record ??= { instanceId: randomUUID(), clientId: claims.iss, sid: claims.sid, userId: claims.sub,
|
|
75
|
+
...(claims.agent_id === undefined ? {} : { agentId: claims.agent_id }), createdAt: at, expiresAt: at + lifetime * 1000, ended: false };
|
|
76
|
+
if (record.clientId !== claims.iss || record.sid !== claims.sid || record.userId !== claims.sub || record.agentId !== claims.agent_id || record.ended || record.expiresAt <= at)
|
|
77
|
+
throw invalid();
|
|
78
|
+
record.instanceId ??= randomUUID();
|
|
79
|
+
if (account) {
|
|
80
|
+
if (record.accountId !== undefined && record.accountId !== account.principal.accountId)
|
|
81
|
+
throw invalid();
|
|
82
|
+
record.accountId = account.principal.accountId;
|
|
83
|
+
}
|
|
84
|
+
return { ...(account?.principal ?? { issuer: options.companyId, scopes: [], tokenExpiresAt: record.expiresAt }),
|
|
85
|
+
issuer: options.companyId, clientId: claims.iss, userId: claims.sub, sessionId: claims.sid,
|
|
86
|
+
...(claims.agent_id === undefined ? {} : { agentId: claims.agent_id }), sessionVersion: 1, sessionInstanceId: record.instanceId, sessionGeneration: state.generation, sessionExpiresAt: record.expiresAt };
|
|
87
|
+
}));
|
|
88
|
+
},
|
|
89
|
+
async end(clientId, sid) {
|
|
90
|
+
await options.coordinator.run(() => options.store.transaction(clientId, sid, options.now(), state => { if (state.record)
|
|
91
|
+
state.record.ended = true; }));
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { Principal, AuthorityPolicy } from './auth/authority.js';
|
|
1
|
+
import type { Principal, AuthorityPolicy, RequestAuthenticator } from './auth/authority.js';
|
|
3
2
|
export interface BrowserBinding {
|
|
4
3
|
clientId: string;
|
|
5
4
|
userId: string;
|
|
6
5
|
sessionId: string;
|
|
7
6
|
issuerGeneration?: string;
|
|
7
|
+
principal?: Principal;
|
|
8
8
|
}
|
|
9
|
-
export interface
|
|
10
|
-
|
|
9
|
+
export interface BrowserJoin {
|
|
10
|
+
cookieHash: string;
|
|
11
11
|
expiresAt: number;
|
|
12
|
-
|
|
13
|
-
export interface BrowserSessionAuthority {
|
|
14
|
-
join(binding: BrowserBinding, assertion: {
|
|
15
|
-
jti: string;
|
|
16
|
-
expiresAt: number;
|
|
17
|
-
}): Promise<BrowserSession>;
|
|
18
|
-
current(binding: BrowserBinding): Promise<BrowserSession>;
|
|
12
|
+
principal?: Principal;
|
|
19
13
|
}
|
|
20
14
|
export interface BrowserCookieStore {
|
|
21
15
|
put(hash: string, binding: BrowserBinding & {
|
|
@@ -24,21 +18,29 @@ export interface BrowserCookieStore {
|
|
|
24
18
|
get(hash: string): Promise<(BrowserBinding & {
|
|
25
19
|
expiresAt: number;
|
|
26
20
|
}) | null>;
|
|
21
|
+
remove(hash: string): Promise<void>;
|
|
22
|
+
putJoin(codeHash: string, binding: BrowserJoin): Promise<void>;
|
|
23
|
+
/** Atomically authorize an unexpired, never-authorized code. Do not consume it. */
|
|
24
|
+
authorizeJoin(codeHash: string, principal: Principal, at: number): Promise<boolean>;
|
|
25
|
+
/** Atomically remove and return, even when submission will fail. */
|
|
26
|
+
consumeJoin(codeHash: string): Promise<BrowserJoin | null>;
|
|
27
27
|
}
|
|
28
28
|
export declare function boundedText(request: Request, max: number): Promise<string>;
|
|
29
29
|
export declare function createBrowserSessions(options: {
|
|
30
30
|
endpoint: string;
|
|
31
31
|
domain: string;
|
|
32
|
-
|
|
33
|
-
authority: BrowserSessionAuthority;
|
|
32
|
+
authenticator: RequestAuthenticator;
|
|
34
33
|
cookies: BrowserCookieStore;
|
|
35
34
|
policy: AuthorityPolicy;
|
|
36
35
|
now: () => number;
|
|
36
|
+
/** Required for signed-in joins: clear the host's prior login without restoring it later. */
|
|
37
|
+
clearLogin?: (request: Request, headers: Headers) => Promise<void>;
|
|
37
38
|
cookieName?: string;
|
|
38
39
|
sameSite?: 'Lax' | 'None';
|
|
39
40
|
cookieLifetimeSeconds?: number;
|
|
40
41
|
}): {
|
|
41
42
|
authenticate: (request: Request) => Promise<Principal>;
|
|
42
43
|
withSession<T>(request: Request, scopes: readonly string[], action: (principal: Principal) => Promise<T>): Promise<T>;
|
|
44
|
+
authorize(request: Request): Promise<Response>;
|
|
43
45
|
join(request: Request): Promise<Response>;
|
|
44
46
|
};
|