@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.
Files changed (54) hide show
  1. package/API.md +12 -3
  2. package/AUTH.md +97 -65
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +30 -1
  7. package/INTEGRATIONS.md +63 -21
  8. package/OPERATIONS.md +120 -0
  9. package/PROVIDER.md +24 -14
  10. package/README.md +7 -13
  11. package/RELEASING.md +2 -0
  12. package/SECURITY.md +2 -0
  13. package/STORAGE.md +24 -5
  14. package/dist/adapter-contract.d.ts +6 -0
  15. package/dist/adapter-contract.js +31 -1
  16. package/dist/auth/challenge.d.ts +2 -0
  17. package/dist/auth/challenge.js +8 -8
  18. package/dist/auth/client-auth.d.ts +29 -13
  19. package/dist/auth/client-auth.js +498 -318
  20. package/dist/auth/sqlite-store.js +27 -4
  21. package/dist/auth/store.d.ts +6 -1
  22. package/dist/conversation.d.ts +6 -0
  23. package/dist/conversation.js +28 -14
  24. package/dist/index.d.ts +6 -1
  25. package/dist/index.js +3 -1
  26. package/dist/integrations.d.ts +16 -4
  27. package/dist/integrations.js +30 -38
  28. package/dist/network/discovery.d.ts +51 -22
  29. package/dist/network/discovery.js +159 -69
  30. package/dist/network/redirect.d.ts +3 -0
  31. package/dist/network/redirect.js +38 -0
  32. package/dist/network/tasks.d.ts +23 -0
  33. package/dist/network/tasks.js +45 -0
  34. package/dist/operations.d.ts +103 -0
  35. package/dist/operations.js +261 -0
  36. package/dist/postgres.d.ts +11 -0
  37. package/dist/postgres.js +52 -2
  38. package/dist/protocol/operations.d.ts +51 -0
  39. package/dist/protocol/operations.js +98 -0
  40. package/dist/protocol/session.d.ts +28 -0
  41. package/dist/protocol/session.js +93 -0
  42. package/dist/protocol/sse.d.ts +1 -0
  43. package/dist/protocol/sse.js +1 -1
  44. package/dist/protocol/wire.d.ts +20 -15
  45. package/dist/protocol/wire.js +70 -22
  46. package/dist/provider-postgres.js +3 -3
  47. package/dist/provider-storage.js +4 -4
  48. package/dist/provider.d.ts +26 -10
  49. package/dist/provider.js +103 -32
  50. package/dist/sqlite.d.ts +12 -0
  51. package/dist/sqlite.js +42 -0
  52. package/examples/operations.mjs +25 -0
  53. package/examples/quickstart.mjs +2 -1
  54. package/package.json +5 -2
@@ -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 Sender = 'agent' | 'human';
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
- sender: Sender;
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: Sender;
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: Sender;
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: number;
68
- scope: string;
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): {
@@ -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
- member(message.sender, ['agent', 'human'], 'message.sender');
54
- if (!['text', 'data', 'context'].some(key => key in message))
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
- member(state.status, ['working', 'idle', 'queued', 'closed'], 'status');
103
- member(state.responder, ['agent', 'human'], 'responder');
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
- member(event.status, ['working', 'idle', 'queued', 'closed'], 'event.status');
126
- member(event.responder, ['agent', 'human'], 'event.responder');
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, ['sign_in_required', 'insufficient_scope'], 'event.error');
130
- if (event.error === 'insufficient_scope' || 'scope' in event)
131
- string(event.scope, 'event.scope', false);
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 !== undefined && page.cursor !== 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
- string(token.session_id, 'session_id');
165
- string(token.scope, 'scope', false);
166
- member(token.token_type, ['DPoP', 'Bearer'], 'token_type');
167
- member(token.signed_in, [true, false], 'signed_in');
168
- positive(token.expires_in, 'expires_in');
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;
@@ -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
  },
@@ -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: string;
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
- sessionId: string;
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
- sessionId: string;
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.authorization?.issuer !== issuer)
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: options.clientId, callbackUrl: options.callbackUrl };
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', 'returnTo', 'state']);
142
- if (!['client-authentication', 'session', 'browser', 'direct'].includes(input.purpose))
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' && (input.sessionId !== undefined || input.returnTo !== undefined))
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) || !input.sessionId))
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 === 'browser' && !input.sessionId)
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
- if (input.purpose === 'browser' && !await options.verifySession?.({ clientId: options.clientId, userId: identity.userId, issuer: input.issuer, sessionId: input.sessionId }))
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 || !['client-authentication', 'session', 'browser'].includes(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, metadata = await verified(b.domain, b.issuer);
212
- if (b.browserEndpoint !== (metadata.document.web?.browser_session_endpoint ?? null))
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 claims = { iss: options.clientId, sub: b.purpose === 'client-authentication' ? options.clientId : b.userId, aud: b.purpose === 'browser' ? b.browserEndpoint : b.issuer, iat, exp, jti: id(), ...(b.purpose === 'browser' ? { session_id: b.sessionId, return_to: b.returnTo } : {}) };
218
- const header = { alg: 'ES256', kid: options.signer.kid, typ: b.purpose === 'client-authentication' ? 'client-authentication+jwt' : b.purpose === 'browser' ? 'poppy-browser+jwt' : 'JWT' };
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.sessionId !== input.sessionId || op.binding.purpose !== 'direct')
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(JSON.parse(body));
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
- return { identity, assert, beginDirect: (input) => call('begin', { ...input, domain: options.domain, purpose: 'direct' }),
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
  }