@poppykit/client 0.4.1 → 0.6.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 +12 -3
- package/AUTH.md +97 -65
- package/CHANGELOG.md +18 -0
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +13 -10
- package/DELIVERY.md +30 -1
- package/INTEGRATIONS.md +63 -21
- package/OPERATIONS.md +120 -0
- package/PROVIDER.md +24 -14
- package/README.md +7 -13
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +24 -5
- package/dist/adapter-contract.d.ts +6 -0
- package/dist/adapter-contract.js +31 -1
- package/dist/auth/challenge.d.ts +2 -0
- package/dist/auth/challenge.js +8 -8
- package/dist/auth/client-auth.d.ts +29 -13
- package/dist/auth/client-auth.js +498 -318
- package/dist/auth/sqlite-store.js +27 -4
- package/dist/auth/store.d.ts +6 -1
- package/dist/conversation.d.ts +6 -0
- package/dist/conversation.js +28 -14
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -1
- package/dist/integrations.d.ts +16 -4
- package/dist/integrations.js +30 -38
- 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 +103 -0
- package/dist/operations.js +261 -0
- package/dist/postgres.d.ts +11 -0
- package/dist/postgres.js +52 -2
- 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/provider-postgres.js +3 -3
- package/dist/provider-storage.js +4 -4
- package/dist/provider.d.ts +26 -10
- package/dist/provider.js +103 -32
- package/dist/sqlite.d.ts +12 -0
- package/dist/sqlite.js +42 -0
- package/examples/operations.mjs +25 -0
- package/examples/quickstart.mjs +2 -1
- package/package.json +5 -2
package/dist/protocol/wire.d.ts
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
|
+
import type { BusinessOperation, OperationApproval } from './operations.js';
|
|
1
2
|
export type Json = null | boolean | number | string | Json[] | {
|
|
2
3
|
[key: string]: Json;
|
|
3
4
|
};
|
|
4
5
|
export type JsonObject = {
|
|
5
6
|
[key: string]: Json;
|
|
6
7
|
};
|
|
7
|
-
export type
|
|
8
|
+
export type AuthorType = 'agent' | 'human';
|
|
9
|
+
export interface Author {
|
|
10
|
+
type: AuthorType;
|
|
11
|
+
id?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
image?: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
8
16
|
export type Status = 'working' | 'idle' | 'queued' | 'closed';
|
|
9
17
|
export interface Message {
|
|
10
18
|
id: string;
|
|
11
|
-
|
|
19
|
+
author: Author;
|
|
12
20
|
text?: string;
|
|
13
21
|
data?: JsonObject;
|
|
14
22
|
context?: JsonObject;
|
|
23
|
+
operation?: BusinessOperation;
|
|
24
|
+
approval?: OperationApproval;
|
|
15
25
|
[key: string]: unknown;
|
|
16
26
|
}
|
|
17
27
|
export interface RecordedMessage extends Message {
|
|
@@ -25,7 +35,7 @@ export interface ConversationRequest {
|
|
|
25
35
|
export interface ConversationState {
|
|
26
36
|
conversation_id: string;
|
|
27
37
|
status: Status;
|
|
28
|
-
responder:
|
|
38
|
+
responder: AuthorType;
|
|
29
39
|
[key: string]: unknown;
|
|
30
40
|
}
|
|
31
41
|
export interface ConversationEvent {
|
|
@@ -40,11 +50,7 @@ export type KnownEvent = (ConversationEvent & {
|
|
|
40
50
|
}) | (ConversationEvent & {
|
|
41
51
|
type: 'state';
|
|
42
52
|
status: Status;
|
|
43
|
-
responder:
|
|
44
|
-
}) | (ConversationEvent & {
|
|
45
|
-
type: 'authorization';
|
|
46
|
-
error: 'sign_in_required';
|
|
47
|
-
scope?: string;
|
|
53
|
+
responder: AuthorType;
|
|
48
54
|
}) | (ConversationEvent & {
|
|
49
55
|
type: 'authorization';
|
|
50
56
|
error: 'insufficient_scope';
|
|
@@ -64,10 +70,8 @@ export interface EventPage extends ConversationState {
|
|
|
64
70
|
export interface TokenResponse {
|
|
65
71
|
access_token: string;
|
|
66
72
|
token_type: 'DPoP' | 'Bearer';
|
|
67
|
-
expires_in
|
|
68
|
-
scope
|
|
69
|
-
session_id: string;
|
|
70
|
-
signed_in: boolean;
|
|
73
|
+
expires_in?: number;
|
|
74
|
+
scope?: string;
|
|
71
75
|
refresh_token?: string;
|
|
72
76
|
refresh_token_expires_in?: number;
|
|
73
77
|
[key: string]: unknown;
|
|
@@ -92,11 +96,12 @@ export declare function id(value: unknown, field: string): string;
|
|
|
92
96
|
export declare function parseMessage(value: unknown, options?: {
|
|
93
97
|
recorded?: boolean;
|
|
94
98
|
company?: boolean;
|
|
99
|
+
operations?: boolean;
|
|
95
100
|
}): Message | RecordedMessage;
|
|
96
|
-
export declare function parseConversationRequest(value: unknown): ConversationRequest;
|
|
101
|
+
export declare function parseConversationRequest(value: unknown, operations?: boolean): ConversationRequest;
|
|
97
102
|
export declare function parseConversationState(value: unknown): ConversationState;
|
|
98
|
-
export declare function parseEvent(value: unknown): ConversationEvent;
|
|
99
|
-
export declare function parseEventPage(value: unknown, requestCursor?: string): EventPage;
|
|
103
|
+
export declare function parseEvent(value: unknown, operations?: boolean): ConversationEvent;
|
|
104
|
+
export declare function parseEventPage(value: unknown, requestCursor?: string, operations?: boolean): EventPage;
|
|
100
105
|
export declare function parseTokenResponse(value: unknown): TokenResponse;
|
|
101
106
|
export declare function parseProtocolError(value: unknown): ProtocolErrorBody;
|
|
102
107
|
export declare function parseReadOptions(url: URL, maxWaitSeconds: number): {
|
package/dist/protocol/wire.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseBusinessOperation, parseOperationApproval } from './operations.js';
|
|
1
2
|
export class ValidationError extends Error {
|
|
2
3
|
field;
|
|
3
4
|
constructor(field) {
|
|
@@ -50,8 +51,38 @@ function positive(value, field) {
|
|
|
50
51
|
export function parseMessage(value, options = {}) {
|
|
51
52
|
const message = { ...object(value, 'message') };
|
|
52
53
|
id(message.id, 'message.id');
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const author = object(message.author, 'message.author');
|
|
55
|
+
member(author.type, ['agent', 'human'], 'message.author.type');
|
|
56
|
+
if ('sender' in message)
|
|
57
|
+
throw new ValidationError('message.sender');
|
|
58
|
+
for (const key of ['id', 'name', 'image'])
|
|
59
|
+
if (key in author) {
|
|
60
|
+
if (!options.company && !(options.recorded && message.role === 'company'))
|
|
61
|
+
throw new ValidationError('message.author.' + key);
|
|
62
|
+
string(author[key], 'message.author.' + key);
|
|
63
|
+
}
|
|
64
|
+
if ('image' in author) {
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(author.image);
|
|
67
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
68
|
+
throw 0;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new ValidationError('message.author.image');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const company = options.company || (options.recorded && message.role === 'company');
|
|
75
|
+
if ('operation' in message) {
|
|
76
|
+
if (!company || !options.operations)
|
|
77
|
+
throw new ValidationError('message.operation');
|
|
78
|
+
message.operation = parseBusinessOperation(message.operation);
|
|
79
|
+
}
|
|
80
|
+
if ('approval' in message) {
|
|
81
|
+
if (company || !options.operations)
|
|
82
|
+
throw new ValidationError('message.approval');
|
|
83
|
+
message.approval = parseOperationApproval(message.approval);
|
|
84
|
+
}
|
|
85
|
+
if (!['text', 'data', 'context', ...(options.operations ? [company ? 'operation' : 'approval'] : [])].some(key => key in message))
|
|
55
86
|
throw new ValidationError('message.content');
|
|
56
87
|
if ('text' in message)
|
|
57
88
|
string(message.text, 'message.text', false);
|
|
@@ -90,20 +121,24 @@ export function parseMessage(value, options = {}) {
|
|
|
90
121
|
}
|
|
91
122
|
return message;
|
|
92
123
|
}
|
|
93
|
-
export function parseConversationRequest(value) {
|
|
124
|
+
export function parseConversationRequest(value, operations = false) {
|
|
94
125
|
const request = object(value);
|
|
95
126
|
if ('parent_conversation_id' in request)
|
|
96
127
|
id(request.parent_conversation_id, 'parent_conversation_id');
|
|
97
|
-
return { ...request, message: parseMessage(request.message) };
|
|
128
|
+
return { ...request, message: parseMessage(request.message, { operations }) };
|
|
98
129
|
}
|
|
99
130
|
export function parseConversationState(value) {
|
|
100
131
|
const state = object(value);
|
|
101
132
|
id(state.conversation_id, 'conversation_id');
|
|
102
|
-
|
|
103
|
-
|
|
133
|
+
string(state.status, 'status');
|
|
134
|
+
string(state.responder, 'responder');
|
|
135
|
+
if (!['working', 'idle', 'queued', 'closed'].includes(state.status))
|
|
136
|
+
state.status = 'working';
|
|
137
|
+
if (!['agent', 'human'].includes(state.responder))
|
|
138
|
+
state.responder = 'human';
|
|
104
139
|
return state;
|
|
105
140
|
}
|
|
106
|
-
export function parseEvent(value) {
|
|
141
|
+
export function parseEvent(value, operations = false) {
|
|
107
142
|
const event = object(value, 'event');
|
|
108
143
|
id(event.id, 'event.id');
|
|
109
144
|
string(event.type, 'event.type');
|
|
@@ -119,16 +154,18 @@ export function parseEvent(value) {
|
|
|
119
154
|
throw new ValidationError('event.created_at');
|
|
120
155
|
switch (event.type) {
|
|
121
156
|
case 'message':
|
|
122
|
-
parseMessage(event.message, { recorded: true });
|
|
157
|
+
parseMessage(event.message, { recorded: true, operations });
|
|
123
158
|
break;
|
|
124
|
-
case 'state':
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
case 'state': {
|
|
160
|
+
const state = parseConversationState({ conversation_id: 'state', status: event.status, responder: event.responder });
|
|
161
|
+
event.status = state.status;
|
|
162
|
+
event.responder = state.responder;
|
|
127
163
|
break;
|
|
164
|
+
}
|
|
128
165
|
case 'authorization':
|
|
129
|
-
member(event.error, ['
|
|
130
|
-
if (event.
|
|
131
|
-
|
|
166
|
+
member(event.error, ['insufficient_scope'], 'event.error');
|
|
167
|
+
if (!/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(string(event.scope, 'event.scope')))
|
|
168
|
+
throw new ValidationError('event.scope');
|
|
132
169
|
break;
|
|
133
170
|
case 'user_requested':
|
|
134
171
|
string(event.reason, 'event.reason', false);
|
|
@@ -140,11 +177,11 @@ export function parseEvent(value) {
|
|
|
140
177
|
}
|
|
141
178
|
return event;
|
|
142
179
|
}
|
|
143
|
-
export function parseEventPage(value, requestCursor) {
|
|
180
|
+
export function parseEventPage(value, requestCursor, operations = false) {
|
|
144
181
|
const page = parseConversationState(value);
|
|
145
182
|
if (!Array.isArray(page.events))
|
|
146
183
|
throw new ValidationError('events');
|
|
147
|
-
page.events.forEach(parseEvent);
|
|
184
|
+
page.events.forEach(event => parseEvent(event, operations));
|
|
148
185
|
member(page.has_more, [true, false], 'has_more');
|
|
149
186
|
if (page.cursor === null) {
|
|
150
187
|
if (requestCursor !== undefined || page.events.length || page.has_more)
|
|
@@ -153,7 +190,7 @@ export function parseEventPage(value, requestCursor) {
|
|
|
153
190
|
else {
|
|
154
191
|
string(page.cursor, 'cursor');
|
|
155
192
|
const expected = page.events.length ? page.events.at(-1).id : requestCursor;
|
|
156
|
-
if (expected
|
|
193
|
+
if (expected === undefined || page.cursor !== expected)
|
|
157
194
|
throw new ValidationError('cursor');
|
|
158
195
|
}
|
|
159
196
|
return page;
|
|
@@ -161,11 +198,17 @@ export function parseEventPage(value, requestCursor) {
|
|
|
161
198
|
export function parseTokenResponse(value) {
|
|
162
199
|
const token = object(value);
|
|
163
200
|
string(token.access_token, 'access_token');
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
201
|
+
if ('scope' in token) {
|
|
202
|
+
const scope = string(token.scope, 'scope', false);
|
|
203
|
+
if (scope !== '' && (!/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(scope) || new Set(scope.split(' ')).size !== scope.split(' ').length))
|
|
204
|
+
throw new ValidationError('scope');
|
|
205
|
+
}
|
|
206
|
+
const type = string(token.token_type, 'token_type').toLowerCase();
|
|
207
|
+
if (type !== 'bearer' && type !== 'dpop')
|
|
208
|
+
throw new ValidationError('token_type');
|
|
209
|
+
token.token_type = type === 'bearer' ? 'Bearer' : 'DPoP';
|
|
210
|
+
if ('expires_in' in token && (!Number.isSafeInteger(token.expires_in) || token.expires_in < 0))
|
|
211
|
+
throw new ValidationError('expires_in');
|
|
169
212
|
if ('refresh_token' in token)
|
|
170
213
|
string(token.refresh_token, 'refresh_token');
|
|
171
214
|
if ('refresh_token_expires_in' in token)
|
|
@@ -215,6 +258,11 @@ export function parseAssertionClaims(value) {
|
|
|
215
258
|
const claims = object(value, 'claims');
|
|
216
259
|
for (const key of ['iss', 'sub', 'aud', 'jti'])
|
|
217
260
|
string(claims[key], key);
|
|
261
|
+
if ('agent_id' in claims) {
|
|
262
|
+
string(claims.agent_id, 'agent_id');
|
|
263
|
+
if (claims.agent_id.length > 256)
|
|
264
|
+
throw new ValidationError('agent_id');
|
|
265
|
+
}
|
|
218
266
|
// 128 random bits is at least 16 bytes; cap length since jti lands in the replay store. Encoding is not constrained.
|
|
219
267
|
if (claims.jti.length < 16 || claims.jti.length > 256)
|
|
220
268
|
throw new ValidationError('jti');
|
|
@@ -42,15 +42,15 @@ export class PostgresProviderStore {
|
|
|
42
42
|
await lock('user', key);
|
|
43
43
|
await one('user', key);
|
|
44
44
|
}
|
|
45
|
-
if (selection.kind === 'begin' || selection.kind === 'revoke') {
|
|
45
|
+
if (selection.kind === 'begin' || selection.kind === 'delegate' || selection.kind === 'revoke') {
|
|
46
46
|
const operations = await this.db.query("SELECT id,body FROM poppy_provider.records WHERE ns=$1 AND kind='operation' AND split_part(id,':',1)=$2 ORDER BY id LIMIT 1001", [this.namespace, enrollmentId]);
|
|
47
47
|
for (const row of operations)
|
|
48
48
|
rows.set(JSON.stringify(['operation', row.id]), JSON.parse(row.body));
|
|
49
49
|
if (operations.length > 1000)
|
|
50
50
|
throw new Error('provider aggregate exceeds retained operation limit');
|
|
51
|
-
if (selection.kind === 'begin') {
|
|
51
|
+
if (selection.kind === 'begin' || selection.kind === 'delegate') {
|
|
52
52
|
const states = new Set();
|
|
53
|
-
if (selection.stateHash)
|
|
53
|
+
if ('stateHash' in selection && selection.stateHash)
|
|
54
54
|
states.add(selection.stateHash);
|
|
55
55
|
for (const row of operations) {
|
|
56
56
|
const hash = JSON.parse(row.body).binding?.stateHash;
|
package/dist/provider-storage.js
CHANGED
|
@@ -14,8 +14,8 @@ export function scopedProviderTransaction(selection, raw) {
|
|
|
14
14
|
const states = new Set();
|
|
15
15
|
if (selection.kind === 'callback')
|
|
16
16
|
states.add(selection.stateHash);
|
|
17
|
-
if (selection.kind === 'begin') {
|
|
18
|
-
if (selection.stateHash)
|
|
17
|
+
if (selection.kind === 'begin' || selection.kind === 'delegate') {
|
|
18
|
+
if ('stateHash' in selection && selection.stateHash)
|
|
19
19
|
states.add(selection.stateHash);
|
|
20
20
|
for (const row of raw.list('operation', `${enrollmentId}:`, 1001))
|
|
21
21
|
if (row.value.binding?.stateHash)
|
|
@@ -34,7 +34,7 @@ export function scopedProviderTransaction(selection, raw) {
|
|
|
34
34
|
else {
|
|
35
35
|
allowed = (kind === 'enrollment' && id === enrollmentId) || (kind === 'user' && id === userKey) || (kind === 'state' && states.has(id));
|
|
36
36
|
if (kind === 'operation')
|
|
37
|
-
allowed = (selection.kind === 'begin' || selection.kind === 'revoke') ? id.startsWith(`${enrollmentId}:`) : id === `${enrollmentId}:${operationId}`;
|
|
37
|
+
allowed = (selection.kind === 'begin' || selection.kind === 'delegate' || selection.kind === 'revoke') ? id.startsWith(`${enrollmentId}:`) : id === `${enrollmentId}:${operationId}`;
|
|
38
38
|
}
|
|
39
39
|
if (!allowed)
|
|
40
40
|
throw new TypeError('provider access outside selection');
|
|
@@ -44,7 +44,7 @@ export function scopedProviderTransaction(selection, raw) {
|
|
|
44
44
|
put(kind, id, value) { check(kind, id, true); raw.put(kind, id, value); },
|
|
45
45
|
remove(kind, id) { check(kind, id); raw.remove(kind, id); },
|
|
46
46
|
list(kind, prefix, limit) {
|
|
47
|
-
if (!['begin', 'revoke'].includes(selection.kind) || kind !== 'operation' || prefix !== `${enrollmentId}:` || !Number.isSafeInteger(limit) || limit < 1 || limit > 1001)
|
|
47
|
+
if (!['begin', 'delegate', 'revoke'].includes(selection.kind) || kind !== 'operation' || prefix !== `${enrollmentId}:` || !Number.isSafeInteger(limit) || limit < 1 || limit > 1001)
|
|
48
48
|
throw new TypeError('provider list outside selection');
|
|
49
49
|
return raw.list(kind, prefix, limit);
|
|
50
50
|
},
|
package/dist/provider.d.ts
CHANGED
|
@@ -23,6 +23,14 @@ export type ProviderSelection = {
|
|
|
23
23
|
kind: 'operation';
|
|
24
24
|
enrollmentId: string;
|
|
25
25
|
operationId: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'delegate';
|
|
28
|
+
enrollmentId: string;
|
|
29
|
+
operationId: string;
|
|
30
|
+
at: number;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'metadata';
|
|
33
|
+
enrollmentId: string;
|
|
26
34
|
} | {
|
|
27
35
|
kind: 'begin';
|
|
28
36
|
enrollmentId: string;
|
|
@@ -64,6 +72,10 @@ export interface ProviderOptions {
|
|
|
64
72
|
store: ProviderStore;
|
|
65
73
|
cipher: ResultCipher;
|
|
66
74
|
metadata: MetadataResolver;
|
|
75
|
+
/** Publish only for enrollments whose agent has all Operations channels, trusted consent and durable recovery configured. */
|
|
76
|
+
operations?: {
|
|
77
|
+
ready(enrollmentId: string): Promise<boolean>;
|
|
78
|
+
};
|
|
67
79
|
signer: IdentitySigner;
|
|
68
80
|
/** Publish new keys before use and retain retired public keys through assertion expiry + skew. */
|
|
69
81
|
publishedKeys?: () => Promise<Record<string, any>[]>;
|
|
@@ -72,13 +84,6 @@ export interface ProviderOptions {
|
|
|
72
84
|
subject: string;
|
|
73
85
|
allowedCompanies: string[];
|
|
74
86
|
} | null>;
|
|
75
|
-
/** Required for browser assertions: independently prove session belongs to this client AND derived user. */
|
|
76
|
-
verifySession?(binding: {
|
|
77
|
-
clientId: string;
|
|
78
|
-
userId: string;
|
|
79
|
-
issuer: string;
|
|
80
|
-
sessionId: string;
|
|
81
|
-
}): Promise<boolean>;
|
|
82
87
|
now?: () => number;
|
|
83
88
|
maxEnrollments?: number;
|
|
84
89
|
}
|
|
@@ -90,7 +95,7 @@ export declare function createProvider(options: ProviderOptions): {
|
|
|
90
95
|
callbackUrl: string;
|
|
91
96
|
}>;
|
|
92
97
|
identity(credential: string, input: Row): Promise<{
|
|
93
|
-
clientId:
|
|
98
|
+
clientId: any;
|
|
94
99
|
userId: string;
|
|
95
100
|
}>;
|
|
96
101
|
begin(credential: string, input: Row): Promise<{
|
|
@@ -100,6 +105,10 @@ export declare function createProvider(options: ProviderOptions): {
|
|
|
100
105
|
assertion: string;
|
|
101
106
|
expiresAt: number;
|
|
102
107
|
}>;
|
|
108
|
+
delegate(credential: string, input: Row): Promise<{
|
|
109
|
+
delegation: string;
|
|
110
|
+
expiresAt: number;
|
|
111
|
+
}>;
|
|
103
112
|
callback(callbackUrl: string): Promise<{
|
|
104
113
|
status: any;
|
|
105
114
|
}>;
|
|
@@ -134,16 +143,23 @@ export declare function createProviderClient(options: {
|
|
|
134
143
|
assertion: string;
|
|
135
144
|
expiresAt: number;
|
|
136
145
|
}>;
|
|
146
|
+
delegate: (request: {
|
|
147
|
+
operationId: string;
|
|
148
|
+
jwk: import("./protocol/wire.js").JsonObject;
|
|
149
|
+
}) => Promise<{
|
|
150
|
+
delegation: string;
|
|
151
|
+
expiresAt: number;
|
|
152
|
+
}>;
|
|
137
153
|
beginDirect: (input: {
|
|
138
154
|
operationId: string;
|
|
139
155
|
issuer: string;
|
|
140
|
-
|
|
156
|
+
sessionKey: string | null;
|
|
141
157
|
state: string;
|
|
142
158
|
}) => Promise<Row>;
|
|
143
159
|
takeResult: (input: {
|
|
144
160
|
operationId: string;
|
|
145
161
|
issuer: string;
|
|
146
|
-
|
|
162
|
+
sessionKey: string | null;
|
|
147
163
|
}) => Promise<Row>;
|
|
148
164
|
cancel: (operationId: string) => Promise<Row>;
|
|
149
165
|
revokeEnrollment: () => Promise<Row>;
|
package/dist/provider.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { importJWK } from 'jose';
|
|
2
|
+
import { parseJson } from './protocol/wire.js';
|
|
1
3
|
/** Optional PoppyKit provider v1. Hosting, human authentication and key custody belong to the operator. */
|
|
2
4
|
import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
3
5
|
import { secureUrl } from './network/transport.js';
|
|
6
|
+
import { companyIdentifier } from './network/discovery.js';
|
|
4
7
|
/** Explicit migration aid for existing local synchronous stores; unsuitable for remote I/O. */
|
|
5
8
|
export function adaptLegacyProviderStore(store) {
|
|
6
9
|
return { async transaction(_selection, action) {
|
|
@@ -45,7 +48,6 @@ function https(value) { try {
|
|
|
45
48
|
catch {
|
|
46
49
|
return fail('binding_conflict');
|
|
47
50
|
} }
|
|
48
|
-
function onDomain(value, domain) { const host = https(value).hostname; return host === domain || host.endsWith(`.${domain}`); }
|
|
49
51
|
function publicKey(value) {
|
|
50
52
|
if (value.kty !== 'EC' || value.crv !== 'P-256' || typeof value.x !== 'string' || typeof value.y !== 'string' || 'd' in value || 'k' in value)
|
|
51
53
|
fail('unavailable');
|
|
@@ -54,6 +56,7 @@ function publicKey(value) {
|
|
|
54
56
|
export function createProvider(options) {
|
|
55
57
|
const { store, cipher } = options, now = options.now ?? Date.now;
|
|
56
58
|
const client = https(options.clientId);
|
|
59
|
+
const enrolledClientId = (enrollmentId) => { const url = new URL(client); url.pathname = url.pathname.replace(/\/$/, '') + '/enrollments/' + enrollmentId; return url.href; };
|
|
57
60
|
if ([options.callbackUrl, options.jwksUri].some(value => https(value).hostname !== client.hostname) || https(options.callbackUrl).search)
|
|
58
61
|
throw new Error('provider metadata endpoints must share client domain; callback cannot contain query');
|
|
59
62
|
function auth(tx, credential, installationId) {
|
|
@@ -67,6 +70,8 @@ export function createProvider(options) {
|
|
|
67
70
|
const row = tx.get('operation', `${enrollment.enrollmentId}:${operationId}`);
|
|
68
71
|
if (!row)
|
|
69
72
|
fail('binding_conflict');
|
|
73
|
+
if (row.binding.purpose === 'direct' && row.binding.sessionKey !== null && (typeof row.binding.sessionKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(row.binding.sessionKey)))
|
|
74
|
+
fail('binding_conflict');
|
|
70
75
|
if (row.expiresAt <= now())
|
|
71
76
|
fail('expired');
|
|
72
77
|
if (row.status === 'canceled')
|
|
@@ -77,7 +82,7 @@ export function createProvider(options) {
|
|
|
77
82
|
}
|
|
78
83
|
async function verified(domain, issuer) {
|
|
79
84
|
const metadata = await options.metadata.resolveCompany(domain);
|
|
80
|
-
if (metadata
|
|
85
|
+
if (companyIdentifier(metadata) !== issuer)
|
|
81
86
|
fail('binding_conflict');
|
|
82
87
|
return metadata;
|
|
83
88
|
}
|
|
@@ -121,9 +126,9 @@ export function createProvider(options) {
|
|
|
121
126
|
tx.put('capacity', 'enrollments', { count: count + 1 });
|
|
122
127
|
tx.put('visitor', subjectKey, { id: visitor });
|
|
123
128
|
const enrollmentId = id(), secret = id();
|
|
124
|
-
tx.put('enrollment', enrollmentId, { installationId, visitor, verifier: hash(secret), allowedCompanies: [...new Set(human.allowedCompanies)], revoked: false, starts: [] });
|
|
129
|
+
tx.put('enrollment', enrollmentId, { clientId: enrolledClientId(enrollmentId), installationId, visitor, verifier: hash(secret), allowedCompanies: [...new Set(human.allowedCompanies)], revoked: false, starts: [] });
|
|
125
130
|
tx.put('enrollmentRequest', key, { enrollmentId });
|
|
126
|
-
return { enrollmentId, credential: `${enrollmentId}.${secret}`, clientId:
|
|
131
|
+
return { enrollmentId, credential: `${enrollmentId}.${secret}`, clientId: enrolledClientId(enrollmentId), callbackUrl: options.callbackUrl };
|
|
127
132
|
});
|
|
128
133
|
},
|
|
129
134
|
async identity(credential, input) {
|
|
@@ -134,41 +139,33 @@ export function createProvider(options) {
|
|
|
134
139
|
const enrollment = auth(tx, credential, text(input.installationId));
|
|
135
140
|
if (!enrollment.allowedCompanies.includes(metadata.document.organization.domain))
|
|
136
141
|
fail('unauthorized');
|
|
137
|
-
return { clientId: options.clientId, userId: user(tx, enrollment.visitor, input.issuer) };
|
|
142
|
+
return { clientId: enrollment.clientId ?? options.clientId, userId: user(tx, enrollment.visitor, input.issuer) };
|
|
138
143
|
});
|
|
139
144
|
},
|
|
140
145
|
async begin(credential, input) {
|
|
141
|
-
exact(input, ['version', 'installationId', 'operationId', 'domain', 'issuer', 'purpose', 'sessionId', '
|
|
142
|
-
if (!['client-authentication', '
|
|
146
|
+
exact(input, ['version', 'installationId', 'operationId', 'domain', 'issuer', 'purpose', 'sessionId', 'sessionKey', 'state']);
|
|
147
|
+
if (!['client-authentication', 'direct'].includes(input.purpose))
|
|
143
148
|
fail('binding_conflict');
|
|
144
149
|
text(input.operationId);
|
|
145
150
|
if (input.sessionId !== undefined)
|
|
146
151
|
text(input.sessionId);
|
|
147
152
|
await authorizeCompany(credential, input);
|
|
148
|
-
if (input.purpose === 'client-authentication' &&
|
|
149
|
-
fail('binding_conflict');
|
|
150
|
-
if (input.purpose !== 'browser' && input.returnTo !== undefined)
|
|
153
|
+
if (input.purpose === 'client-authentication' && input.sessionId !== undefined)
|
|
151
154
|
fail('binding_conflict');
|
|
152
155
|
if (input.purpose !== 'direct' && input.state !== undefined)
|
|
153
156
|
fail('binding_conflict');
|
|
154
|
-
if (input.purpose === 'direct' && (typeof input.state !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.state) ||
|
|
157
|
+
if (input.purpose === 'direct' && (typeof input.state !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.state) || (input.sessionKey !== null && (typeof input.sessionKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.sessionKey)))))
|
|
155
158
|
fail('binding_conflict');
|
|
156
|
-
if (input.purpose
|
|
159
|
+
if (input.purpose !== 'direct' && input.sessionKey !== undefined)
|
|
157
160
|
fail('binding_conflict');
|
|
158
|
-
if (input.purpose === 'browser')
|
|
159
|
-
text(input.returnTo);
|
|
160
161
|
const metadata = await verified(text(input.domain), text(input.issuer));
|
|
161
|
-
if (input.purpose === 'browser' && (!onDomain(input.returnTo, metadata.document.organization.domain) || !metadata.document.web?.browser_session_endpoint))
|
|
162
|
-
fail('binding_conflict');
|
|
163
162
|
const identity = await store.transaction({ kind: 'identity', enrollmentId: enrollmentId(credential), issuer: input.issuer }, tx => {
|
|
164
163
|
const enrollment = auth(tx, credential, text(input.installationId));
|
|
165
164
|
if (!enrollment.allowedCompanies.includes(metadata.document.organization.domain))
|
|
166
165
|
fail('unauthorized');
|
|
167
166
|
return { enrollment, userId: user(tx, enrollment.visitor, input.issuer) };
|
|
168
167
|
});
|
|
169
|
-
|
|
170
|
-
fail('unauthorized');
|
|
171
|
-
const binding = { issuer: input.issuer, domain: metadata.document.organization.domain, purpose: input.purpose, sessionId: input.sessionId ?? null, returnTo: input.returnTo ?? null, stateHash: input.state ? hash(input.state) : null, userId: identity.userId, browserEndpoint: metadata.document.web?.browser_session_endpoint ?? null };
|
|
168
|
+
const binding = { issuer: input.issuer, domain: metadata.document.organization.domain, purpose: input.purpose, sessionKey: input.sessionKey ?? null, sessionId: input.sessionId ?? null, stateHash: input.state ? hash(input.state) : null, userId: identity.userId };
|
|
172
169
|
return store.transaction({ kind: 'begin', enrollmentId: enrollmentId(credential), operationId: input.operationId, issuer: input.issuer, ...(binding.stateHash ? { stateHash: binding.stateHash } : {}), at: now() }, tx => {
|
|
173
170
|
const enrollment = auth(tx, credential, text(input.installationId)), key = `${enrollment.enrollmentId}:${text(input.operationId)}`, existing = tx.get('operation', key);
|
|
174
171
|
if (existing) {
|
|
@@ -203,19 +200,17 @@ export function createProvider(options) {
|
|
|
203
200
|
exact(input, ['version', 'installationId', 'operationId', 'purpose']);
|
|
204
201
|
const snapshot = await store.transaction(selectedOperation(credential, input), tx => {
|
|
205
202
|
const enrollment = auth(tx, credential, text(input.installationId)), op = operation(tx, enrollment, text(input.operationId));
|
|
206
|
-
if (op.binding.purpose !== input.purpose ||
|
|
203
|
+
if (op.binding.purpose !== input.purpose || input.purpose !== 'client-authentication')
|
|
207
204
|
fail('binding_conflict');
|
|
208
205
|
throttle(tx, enrollment);
|
|
209
206
|
return { enrollment, op };
|
|
210
207
|
});
|
|
211
|
-
const b = snapshot.op.binding
|
|
212
|
-
|
|
213
|
-
fail('binding_conflict');
|
|
214
|
-
if (b.purpose === 'browser' && !await options.verifySession?.({ clientId: options.clientId, userId: b.userId, issuer: b.issuer, sessionId: b.sessionId }))
|
|
215
|
-
fail('unauthorized');
|
|
208
|
+
const b = snapshot.op.binding;
|
|
209
|
+
await verified(b.domain, b.issuer);
|
|
216
210
|
const iat = Math.floor(now() / 1000), exp = iat + 60;
|
|
217
|
-
const
|
|
218
|
-
const
|
|
211
|
+
const signingClientId = snapshot.enrollment.clientId ?? options.clientId;
|
|
212
|
+
const claims = { iss: signingClientId, sub: signingClientId, aud: b.issuer, iat, exp, jti: id() };
|
|
213
|
+
const header = { alg: 'ES256', kid: options.signer.kid, typ: 'client-authentication+jwt' };
|
|
219
214
|
const unsigned = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
|
|
220
215
|
const signature = await options.signer.sign(new TextEncoder().encode(unsigned));
|
|
221
216
|
if (signature.length !== 64)
|
|
@@ -224,6 +219,64 @@ export function createProvider(options) {
|
|
|
224
219
|
await store.transaction(selectedOperation(credential, input), tx => { const enrollment = auth(tx, credential, input.installationId), op = operation(tx, enrollment, input.operationId); tx.put('operation', `${enrollment.enrollmentId}:${input.operationId}`, { ...op, status: 'signed' }); });
|
|
225
220
|
return { assertion: `${unsigned}.${Buffer.from(signature).toString('base64url')}`, expiresAt: exp };
|
|
226
221
|
},
|
|
222
|
+
async delegate(credential, input) {
|
|
223
|
+
exact(input, ['version', 'installationId', 'operationId', 'jwk']);
|
|
224
|
+
const source = object(input.jwk);
|
|
225
|
+
if (source.kty !== 'EC' || source.crv !== 'P-256' || typeof source.x !== 'string' || typeof source.y !== 'string' || ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'].some(name => name in source))
|
|
226
|
+
fail('binding_conflict');
|
|
227
|
+
const jwk = { kty: 'EC', crv: 'P-256', x: source.x, y: source.y };
|
|
228
|
+
try {
|
|
229
|
+
await importJWK(jwk, 'ES256');
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
fail('binding_conflict');
|
|
233
|
+
}
|
|
234
|
+
const operationId = text(input.operationId), installationId = text(input.installationId);
|
|
235
|
+
const selection = { kind: 'delegate', enrollmentId: enrollmentId(credential), operationId, at: now() };
|
|
236
|
+
const claim = await store.transaction(selection, tx => {
|
|
237
|
+
const enrollment = auth(tx, credential, installationId);
|
|
238
|
+
if (!enrollment.clientId || enrollment.allowedCompanies.length !== 1)
|
|
239
|
+
fail('binding_conflict'); // Legacy shared-client enrollments cannot release delegated authority.
|
|
240
|
+
const key = `${enrollment.enrollmentId}:${operationId}`, existing = tx.get('operation', key);
|
|
241
|
+
const binding = { purpose: 'delegation', jwk };
|
|
242
|
+
if (existing) {
|
|
243
|
+
if (!same(existing.binding, binding) || existing.expiresAt <= now() || existing.status === 'canceled')
|
|
244
|
+
fail('binding_conflict');
|
|
245
|
+
return { clientId: enrollment.clientId, existing: existing.result ? cipher.open(existing.result) : null, expiresAt: existing.expiresAt };
|
|
246
|
+
}
|
|
247
|
+
throttle(tx, enrollment);
|
|
248
|
+
const rows = tx.list('operation', `${enrollment.enrollmentId}:`, 1001);
|
|
249
|
+
for (const row of rows)
|
|
250
|
+
if (row.value.expiresAt + 30000 <= now()) {
|
|
251
|
+
tx.remove('operation', row.id);
|
|
252
|
+
if (row.value.binding.stateHash)
|
|
253
|
+
tx.remove('state', row.value.binding.stateHash);
|
|
254
|
+
}
|
|
255
|
+
if (rows.filter(row => row.value.expiresAt + 30000 > now()).length >= 1000)
|
|
256
|
+
throw new ProviderError('rate_limited', now() + 60000);
|
|
257
|
+
const expiresAt = (Math.floor(now() / 1800000) * 1800 + 3600) * 1000;
|
|
258
|
+
tx.put('operation', key, { binding, expiresAt, status: 'pending', result: null, resultHash: null });
|
|
259
|
+
return { clientId: enrollment.clientId, existing: null, expiresAt };
|
|
260
|
+
});
|
|
261
|
+
if (claim.existing)
|
|
262
|
+
return { delegation: claim.existing, expiresAt: Math.floor(claim.expiresAt / 1000) };
|
|
263
|
+
const exp = Math.floor(claim.expiresAt / 1000), iat = exp - 3600;
|
|
264
|
+
const header = { alg: 'ES256', typ: 'pap-delegation+jwt', kid: options.signer.kid };
|
|
265
|
+
const payload = { iss: claim.clientId, cnf: { jwk }, iat, exp, jti: id() };
|
|
266
|
+
const unsigned = Buffer.from(JSON.stringify(header)).toString('base64url') + '.' + Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
267
|
+
const signature = await options.signer.sign(new TextEncoder().encode(unsigned));
|
|
268
|
+
if (!(signature instanceof Uint8Array) || signature.length !== 64)
|
|
269
|
+
fail('unavailable');
|
|
270
|
+
const delegation = unsigned + '.' + Buffer.from(signature).toString('base64url');
|
|
271
|
+
return store.transaction({ kind: 'operation', enrollmentId: enrollmentId(credential), operationId }, tx => {
|
|
272
|
+
const enrollment = auth(tx, credential, installationId), op = operation(tx, enrollment, operationId);
|
|
273
|
+
if (op.binding.purpose !== 'delegation' || !same(op.binding.jwk, jwk))
|
|
274
|
+
fail('binding_conflict');
|
|
275
|
+
const result = op.result ? cipher.open(op.result) : delegation;
|
|
276
|
+
tx.put('operation', `${enrollment.enrollmentId}:${operationId}`, { ...op, status: 'signed', result: cipher.seal(result) });
|
|
277
|
+
return { delegation: result, expiresAt: exp };
|
|
278
|
+
});
|
|
279
|
+
},
|
|
227
280
|
async callback(callbackUrl) {
|
|
228
281
|
const url = new URL(callbackUrl), base = new URL(options.callbackUrl);
|
|
229
282
|
if (url.origin !== base.origin || url.pathname !== base.pathname || url.username || url.password || url.hash || ['state', 'iss', 'code', 'error', 'error_description', 'error_uri'].some(key => url.searchParams.getAll(key).length > 1))
|
|
@@ -252,10 +305,10 @@ export function createProvider(options) {
|
|
|
252
305
|
});
|
|
253
306
|
},
|
|
254
307
|
async takeResult(credential, input) {
|
|
255
|
-
exact(input, ['version', 'installationId', 'operationId', 'issuer', 'sessionId']);
|
|
308
|
+
exact(input, ['version', 'installationId', 'operationId', 'issuer', 'sessionId', 'sessionKey']);
|
|
256
309
|
return store.transaction(selectedOperation(credential, input), tx => {
|
|
257
310
|
const enrollment = auth(tx, credential, text(input.installationId)), op = operation(tx, enrollment, text(input.operationId));
|
|
258
|
-
if (op.binding.issuer !== input.issuer || op.binding.
|
|
311
|
+
if (op.binding.issuer !== input.issuer || op.binding.sessionKey !== input.sessionKey || op.binding.purpose !== 'direct')
|
|
259
312
|
fail('binding_conflict');
|
|
260
313
|
if (op.status !== 'ready')
|
|
261
314
|
return { status: 'pending' };
|
|
@@ -289,6 +342,18 @@ export function createProvider(options) {
|
|
|
289
342
|
try {
|
|
290
343
|
if (request.url === options.clientId && request.method === 'GET')
|
|
291
344
|
return Response.json({ client_id: options.clientId, client_name: options.clientName, jwks_uri: options.jwksUri, redirect_uris: [options.callbackUrl], token_endpoint_auth_method: 'private_key_jwt' }, { headers });
|
|
345
|
+
if (request.method === 'GET') {
|
|
346
|
+
const prefix = client.pathname.replace(/\/$/, '') + '/enrollments/', requested = new URL(request.url);
|
|
347
|
+
if (requested.origin === client.origin && requested.pathname.startsWith(prefix)) {
|
|
348
|
+
const enrollmentId = requested.pathname.slice(prefix.length);
|
|
349
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(enrollmentId) || request.url !== enrolledClientId(enrollmentId))
|
|
350
|
+
return new Response(null, { status: 404 });
|
|
351
|
+
const enrollment = await store.transaction({ kind: 'metadata', enrollmentId }, tx => tx.get('enrollment', enrollmentId));
|
|
352
|
+
if (!enrollment || enrollment.revoked || enrollment.clientId !== request.url)
|
|
353
|
+
return new Response(null, { status: 404 });
|
|
354
|
+
return Response.json({ client_id: request.url, client_name: options.clientName, jwks_uri: options.jwksUri, redirect_uris: [options.callbackUrl], token_endpoint_auth_method: 'private_key_jwt', ...(await options.operations?.ready(enrollmentId) ? { extensions: { operations: { version: '2' } } } : {}) }, { headers });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
292
357
|
if (request.url === options.jwksUri && request.method === 'GET')
|
|
293
358
|
return Response.json({ keys: (options.publishedKeys ? await options.publishedKeys() : [{ ...await options.signer.publicJwk(), kid: options.signer.kid }]).map(publicKey) }, { headers });
|
|
294
359
|
const url = new URL(request.url);
|
|
@@ -320,11 +385,11 @@ export function createProvider(options) {
|
|
|
320
385
|
finally {
|
|
321
386
|
await reader.cancel().catch(() => { });
|
|
322
387
|
}
|
|
323
|
-
const input = object(
|
|
388
|
+
const input = object(parseJson(body));
|
|
324
389
|
if (input.version !== 1)
|
|
325
390
|
fail('binding_conflict');
|
|
326
391
|
const action = url.pathname.slice(url.pathname.lastIndexOf('/') + 1), credential = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '';
|
|
327
|
-
const allowed = ['enroll', 'identity', 'begin', 'assert', 'takeResult', 'cancel', 'revokeEnrollment'];
|
|
392
|
+
const allowed = ['enroll', 'identity', 'begin', 'assert', 'delegate', 'takeResult', 'cancel', 'revokeEnrollment'];
|
|
328
393
|
if (!allowed.includes(action))
|
|
329
394
|
fail('binding_conflict');
|
|
330
395
|
const result = action === 'enroll' ? await api.enroll(request, input) : await api[action](credential, input);
|
|
@@ -362,7 +427,13 @@ export function createProviderClient(options) {
|
|
|
362
427
|
const value = await call('assert', { operationId: request.operationId, purpose: request.purpose });
|
|
363
428
|
return { assertion: text(value.assertion), expiresAt: value.expiresAt };
|
|
364
429
|
};
|
|
365
|
-
|
|
430
|
+
const delegate = async (request) => {
|
|
431
|
+
const value = await call('delegate', request);
|
|
432
|
+
if (typeof value.expiresAt !== 'number')
|
|
433
|
+
throw new ProviderError('invalid_response');
|
|
434
|
+
return { delegation: text(value.delegation), expiresAt: value.expiresAt };
|
|
435
|
+
};
|
|
436
|
+
return { identity, assert, delegate, beginDirect: (input) => call('begin', { ...input, domain: options.domain, purpose: 'direct' }),
|
|
366
437
|
takeResult: (input) => call('takeResult', input),
|
|
367
438
|
cancel: (operationId) => call('cancel', { operationId }), revokeEnrollment: () => call('revokeEnrollment', {}) };
|
|
368
439
|
}
|