@xkova/sdk 0.1.0-alpha.6 → 0.1.0-alpha.8
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/README.md +174 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +59 -1
- package/dist/generated/types.d.ts +8253 -5270
- package/dist/held-approval.d.ts +41 -0
- package/dist/held-approval.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/member-bff.d.ts +46 -2
- package/dist/member-bff.js +59 -2
- package/dist/member-resources.d.ts +7 -0
- package/dist/request-signing.js +6 -0
- package/dist/resources/account-holders.d.ts +7 -0
- package/dist/resources/branding.d.ts +10 -0
- package/dist/resources/branding.js +2 -0
- package/dist/resources/escrows.d.ts +3 -0
- package/dist/resources/payment-requests.d.ts +4 -0
- package/dist/resources/tenant.d.ts +15 -0
- package/dist/resources/tenant.js +6 -1
- package/dist/resources/tokenization.d.ts +15 -0
- package/dist/resources/treasury.d.ts +404 -0
- package/dist/resources/treasury.js +115 -0
- package/dist/resources/wallets.d.ts +2 -0
- package/dist/resources/workforce-wallets.d.ts +34 -0
- package/dist/resources/workforce-wallets.js +19 -0
- package/dist/workforce-bff.d.ts +202 -0
- package/dist/workforce-bff.js +462 -0
- package/examples/postgres-checkpoint-store.mjs +116 -0
- package/examples/postgres-checkpoints.sql +9 -0
- package/llms.txt +5 -0
- package/package.json +7 -2
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { type XKOVAClientOptions } from './client.js';
|
|
2
|
+
import { WorkforceWalletsResource } from './resources/workforce-wallets.js';
|
|
3
|
+
export { serializeXKOVAMemberSessionCookie as serializeXKOVAWorkforceSessionCookie, serializeXKOVAMemberFlowCookie as serializeXKOVAWorkforceFlowCookie, serializeClearedXKOVAMemberCookie as serializeClearedXKOVAWorkforceCookie, readXKOVAMemberCookie as readXKOVAWorkforceCookie, readXKOVAMemberCallbackFlow as readXKOVAWorkforceCallbackFlow, xkovaMemberFlowCookieName as xkovaWorkforceFlowCookieName, createXKOVAMemberCsrfToken as createXKOVAWorkforceCsrfToken, verifyXKOVAMemberCsrfToken as verifyXKOVAWorkforceCsrfToken, assertXKOVAMemberRequestOrigin as assertXKOVAWorkforceRequestOrigin, } from './member-bff.js';
|
|
4
|
+
export interface XKOVAWorkforceBffClientOptions extends XKOVAClientOptions {
|
|
5
|
+
/** Exact nonsecret workforce registration selector. This is not a member selector. */
|
|
6
|
+
readonly workforceKey: string;
|
|
7
|
+
}
|
|
8
|
+
export interface XKOVAWorkforceSession {
|
|
9
|
+
readonly session_id: string;
|
|
10
|
+
readonly application_id: string;
|
|
11
|
+
readonly staff_id: string;
|
|
12
|
+
readonly person_id: string;
|
|
13
|
+
readonly tenant_id: string;
|
|
14
|
+
readonly workspace_id: string;
|
|
15
|
+
readonly provider_namespace: string;
|
|
16
|
+
readonly provider_subject: string;
|
|
17
|
+
readonly roles: readonly string[];
|
|
18
|
+
readonly scopes: readonly string[];
|
|
19
|
+
readonly resource_grants: {
|
|
20
|
+
readonly treasury_wallet_ids: readonly string[];
|
|
21
|
+
readonly customer_token_ids: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
readonly action_authorization_enabled: boolean;
|
|
24
|
+
readonly expires_at: string;
|
|
25
|
+
readonly absolute_expires_at: string;
|
|
26
|
+
}
|
|
27
|
+
export interface XKOVAWorkforceSessionMaterial {
|
|
28
|
+
readonly session_token: string;
|
|
29
|
+
readonly refresh_token: string;
|
|
30
|
+
readonly refresh_attempt_token: string;
|
|
31
|
+
readonly session: XKOVAWorkforceSession;
|
|
32
|
+
}
|
|
33
|
+
export interface XKOVAWorkforceActionReview {
|
|
34
|
+
readonly operation: string;
|
|
35
|
+
readonly proof_method: string;
|
|
36
|
+
readonly review: Readonly<Record<string, unknown>>;
|
|
37
|
+
readonly review_digest: string;
|
|
38
|
+
readonly expires_at: string;
|
|
39
|
+
}
|
|
40
|
+
export interface XKOVAWorkforceActionChallenge {
|
|
41
|
+
readonly nonce: string;
|
|
42
|
+
readonly issuer: string;
|
|
43
|
+
readonly audience: string;
|
|
44
|
+
readonly authorization_endpoint: string;
|
|
45
|
+
readonly max_auth_age_seconds: number;
|
|
46
|
+
readonly required_acr_values: readonly string[];
|
|
47
|
+
readonly required_amr_values: readonly string[];
|
|
48
|
+
readonly expires_at: string;
|
|
49
|
+
readonly review: Readonly<Record<string, unknown>>;
|
|
50
|
+
readonly review_digest: string;
|
|
51
|
+
}
|
|
52
|
+
/** Confidential, one-use authority for the unchanged reviewed request only. */
|
|
53
|
+
export interface XKOVAWorkforceActionGrant {
|
|
54
|
+
readonly grant_token: string;
|
|
55
|
+
readonly expires_at: string;
|
|
56
|
+
}
|
|
57
|
+
export interface StoredXKOVAWorkforceSession<T = unknown> {
|
|
58
|
+
readonly clientFingerprint: string;
|
|
59
|
+
readonly authorizationCodeHash: string;
|
|
60
|
+
readonly material: XKOVAWorkforceSessionMaterial;
|
|
61
|
+
readonly applicationExpiresAt: number;
|
|
62
|
+
readonly applicationData: T;
|
|
63
|
+
/** Persist before remote logout. It permanently blocks refresh and resource resolution. */
|
|
64
|
+
readonly logoutPending?: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Encrypt credentials and proofs at rest. All writes commit before resolving,
|
|
68
|
+
* independently of later callback errors. replace is exact-existing-only.
|
|
69
|
+
* runExclusive serializes the same record across every BFF replica. It is not
|
|
70
|
+
* a surrounding rollback transaction. Use separate transaction/session stores.
|
|
71
|
+
*/
|
|
72
|
+
export interface XKOVAWorkforceStore<T> {
|
|
73
|
+
put(reference: string, value: T): Promise<void>;
|
|
74
|
+
get(reference: string): Promise<T | null>;
|
|
75
|
+
replace(reference: string, value: T): Promise<void>;
|
|
76
|
+
delete(reference: string): Promise<void>;
|
|
77
|
+
runExclusive<R>(reference: string, work: () => Promise<R>): Promise<R>;
|
|
78
|
+
}
|
|
79
|
+
export interface XKOVAWorkforceAuthorizationTransaction<T = unknown> {
|
|
80
|
+
readonly clientFingerprint: string;
|
|
81
|
+
readonly status: 'pending' | 'completed';
|
|
82
|
+
readonly redirectUri: string;
|
|
83
|
+
readonly codeVerifier: string;
|
|
84
|
+
readonly codeChallenge: string;
|
|
85
|
+
readonly exchangeAttemptId: string;
|
|
86
|
+
readonly sessionReference: string;
|
|
87
|
+
readonly expiresAt: number;
|
|
88
|
+
readonly transactionId?: string;
|
|
89
|
+
readonly nonce?: string;
|
|
90
|
+
readonly issuer?: string;
|
|
91
|
+
readonly audience?: string;
|
|
92
|
+
/** Written before the one-use provider call. An unknown result is never redeemed again. */
|
|
93
|
+
readonly providerExchangeStarted?: {
|
|
94
|
+
readonly codeHash: string;
|
|
95
|
+
readonly applicationData: T;
|
|
96
|
+
};
|
|
97
|
+
readonly stagedProofToken?: string;
|
|
98
|
+
}
|
|
99
|
+
export declare class XKOVAWorkforceBffError extends Error {
|
|
100
|
+
readonly code: 'transaction_unavailable' | 'binding_mismatch' | 'provider_exchange_outcome_unknown' | 'session_unavailable';
|
|
101
|
+
constructor(code: 'transaction_unavailable' | 'binding_mismatch' | 'provider_exchange_outcome_unknown' | 'session_unavailable');
|
|
102
|
+
}
|
|
103
|
+
/** Confidential workforce protocol. It neither enrolls a subject nor creates local staff authority. */
|
|
104
|
+
export declare class XKOVAWorkforceBffClient {
|
|
105
|
+
private readonly options;
|
|
106
|
+
private readonly client;
|
|
107
|
+
private readonly fingerprint;
|
|
108
|
+
constructor(options: XKOVAWorkforceBffClientOptions);
|
|
109
|
+
beginAuthorization<T>(input: {
|
|
110
|
+
browserOrigin: string;
|
|
111
|
+
redirectUri: string;
|
|
112
|
+
transactionStore: XKOVAWorkforceStore<XKOVAWorkforceAuthorizationTransaction<T>>;
|
|
113
|
+
}): Promise<{
|
|
114
|
+
state: string;
|
|
115
|
+
nonce: string;
|
|
116
|
+
redirectUri: string;
|
|
117
|
+
codeChallenge: string;
|
|
118
|
+
codeChallengeMethod: "S256";
|
|
119
|
+
issuer: string;
|
|
120
|
+
audience: string;
|
|
121
|
+
authorizationEndpoint: string;
|
|
122
|
+
maxAuthAgeSeconds: number;
|
|
123
|
+
requiredAcrValues: string[];
|
|
124
|
+
expiresAt: string;
|
|
125
|
+
}>;
|
|
126
|
+
completeAuthorizationCallback<T>(input: {
|
|
127
|
+
callbackUrl: string;
|
|
128
|
+
/** Read from the genuine initiating browser's HttpOnly flow cookie. */
|
|
129
|
+
expectedState: string;
|
|
130
|
+
transactionStore: XKOVAWorkforceStore<XKOVAWorkforceAuthorizationTransaction<T>>;
|
|
131
|
+
sessionStore: XKOVAWorkforceStore<StoredXKOVAWorkforceSession<T>>;
|
|
132
|
+
applicationData: T;
|
|
133
|
+
/** Perform the provider's real code exchange and signature/nonce checks server-side. */
|
|
134
|
+
exchangeProviderCode: (input: {
|
|
135
|
+
code: string;
|
|
136
|
+
codeVerifier: string;
|
|
137
|
+
redirectUri: string;
|
|
138
|
+
expectedNonce: string;
|
|
139
|
+
issuer: string;
|
|
140
|
+
audience: string;
|
|
141
|
+
}) => Promise<{
|
|
142
|
+
proofToken: string;
|
|
143
|
+
}>;
|
|
144
|
+
}): Promise<{
|
|
145
|
+
reference: string;
|
|
146
|
+
session: StoredXKOVAWorkforceSession<T>;
|
|
147
|
+
}>;
|
|
148
|
+
getSession(sessionToken: string): Promise<XKOVAWorkforceSession>;
|
|
149
|
+
resolveSession<T>(input: {
|
|
150
|
+
reference: string;
|
|
151
|
+
sessionStore: XKOVAWorkforceStore<StoredXKOVAWorkforceSession<T>>;
|
|
152
|
+
}): Promise<StoredXKOVAWorkforceSession<T> | null>;
|
|
153
|
+
/** Request-scoped resources only. Check the browser's Origin/CSRF before invoking writes. */
|
|
154
|
+
resolveResources<T>(input: {
|
|
155
|
+
reference: string;
|
|
156
|
+
sessionStore: XKOVAWorkforceStore<StoredXKOVAWorkforceSession<T>>;
|
|
157
|
+
browserOrigin: string;
|
|
158
|
+
/** Keep the API-issued grant on the server and repeat only its frozen request. */
|
|
159
|
+
actionGrant?: XKOVAWorkforceActionGrant;
|
|
160
|
+
}): Promise<{
|
|
161
|
+
session: StoredXKOVAWorkforceSession<T>;
|
|
162
|
+
resources: {
|
|
163
|
+
treasury: import("./resources/treasury.js").TreasuryResource;
|
|
164
|
+
tokenization: import("./resources/index.js").TokenizationResource;
|
|
165
|
+
wallets: WorkforceWalletsResource;
|
|
166
|
+
};
|
|
167
|
+
} | null>;
|
|
168
|
+
/** Review the locator returned by human_authorization_required. Never send it to a third-party host. */
|
|
169
|
+
inspectAction(input: {
|
|
170
|
+
authorizationToken: string;
|
|
171
|
+
sessionToken: string;
|
|
172
|
+
browserOrigin: string;
|
|
173
|
+
}): Promise<XKOVAWorkforceActionReview>;
|
|
174
|
+
/** Starts a real provider ceremony after the user reviews the exact request. No action is executed. */
|
|
175
|
+
beginActionProof(input: {
|
|
176
|
+
authorizationToken: string;
|
|
177
|
+
sessionToken: string;
|
|
178
|
+
browserOrigin: string;
|
|
179
|
+
}): Promise<XKOVAWorkforceActionChallenge>;
|
|
180
|
+
/** Submit the real, freshly verified provider token. A lost response is never automatically retried. */
|
|
181
|
+
completeActionProof(input: {
|
|
182
|
+
authorizationToken: string;
|
|
183
|
+
sessionToken: string;
|
|
184
|
+
browserOrigin: string;
|
|
185
|
+
nonce: string;
|
|
186
|
+
proofToken: string;
|
|
187
|
+
}): Promise<XKOVAWorkforceActionGrant>;
|
|
188
|
+
logoutSession<T>(input: {
|
|
189
|
+
reference: string;
|
|
190
|
+
sessionStore: XKOVAWorkforceStore<StoredXKOVAWorkforceSession<T>>;
|
|
191
|
+
}): Promise<void>;
|
|
192
|
+
private assertSession;
|
|
193
|
+
private postJson;
|
|
194
|
+
private getJson;
|
|
195
|
+
}
|
|
196
|
+
/** Advisory browser UI data only; provider identity, credentials and grants stay confidential. */
|
|
197
|
+
export declare function projectXKOVAWorkforceSession(value: StoredXKOVAWorkforceSession): {
|
|
198
|
+
signedIn: true;
|
|
199
|
+
staffId: string;
|
|
200
|
+
roles: string[];
|
|
201
|
+
expiresAt: string;
|
|
202
|
+
};
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { XKOVAClient } from './client.js';
|
|
3
|
+
import { XKOVAApiError } from './errors.js';
|
|
4
|
+
import { isAllowedProtocol } from './url.js';
|
|
5
|
+
import { WorkforceWalletsResource } from './resources/workforce-wallets.js';
|
|
6
|
+
// These helpers operate only on a customer's cookie/reference and CSRF proof.
|
|
7
|
+
// Reusing their cryptography does not reuse member identity or session authority.
|
|
8
|
+
export { serializeXKOVAMemberSessionCookie as serializeXKOVAWorkforceSessionCookie, serializeXKOVAMemberFlowCookie as serializeXKOVAWorkforceFlowCookie, serializeClearedXKOVAMemberCookie as serializeClearedXKOVAWorkforceCookie, readXKOVAMemberCookie as readXKOVAWorkforceCookie, readXKOVAMemberCallbackFlow as readXKOVAWorkforceCallbackFlow, xkovaMemberFlowCookieName as xkovaWorkforceFlowCookieName, createXKOVAMemberCsrfToken as createXKOVAWorkforceCsrfToken, verifyXKOVAMemberCsrfToken as verifyXKOVAWorkforceCsrfToken, assertXKOVAMemberRequestOrigin as assertXKOVAWorkforceRequestOrigin, } from './member-bff.js';
|
|
9
|
+
export class XKOVAWorkforceBffError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code) {
|
|
12
|
+
super('The workforce sign-in or session cannot safely continue. Start sign-in again.');
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = 'XKOVAWorkforceBffError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Confidential workforce protocol. It neither enrolls a subject nor creates local staff authority. */
|
|
18
|
+
export class XKOVAWorkforceBffClient {
|
|
19
|
+
options;
|
|
20
|
+
client;
|
|
21
|
+
fingerprint;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
if (!/^xkova_wf_[A-Za-z0-9_-]{43}$/.test(options.workforceKey)) {
|
|
25
|
+
throw new TypeError('Invalid workforce application selector');
|
|
26
|
+
}
|
|
27
|
+
this.client = XKOVAClient.forTenant(options);
|
|
28
|
+
const origin = new URL(options.baseUrl).origin;
|
|
29
|
+
this.fingerprint = digest(JSON.stringify([origin, options.apiKey, options.workforceKey]));
|
|
30
|
+
}
|
|
31
|
+
async beginAuthorization(input) {
|
|
32
|
+
const origin = new URL(input.browserOrigin);
|
|
33
|
+
const callback = exactCallback(input.redirectUri);
|
|
34
|
+
if (origin.origin !== input.browserOrigin ||
|
|
35
|
+
!isAllowedProtocol(origin) ||
|
|
36
|
+
callback.origin !== origin.origin) {
|
|
37
|
+
throw new TypeError('Workforce callback must belong to the exact application origin');
|
|
38
|
+
}
|
|
39
|
+
const state = random(), codeVerifier = random();
|
|
40
|
+
const transaction = {
|
|
41
|
+
clientFingerprint: this.fingerprint,
|
|
42
|
+
status: 'pending',
|
|
43
|
+
redirectUri: callback.href,
|
|
44
|
+
codeVerifier,
|
|
45
|
+
codeChallenge: digest(codeVerifier),
|
|
46
|
+
exchangeAttemptId: random(),
|
|
47
|
+
sessionReference: random(),
|
|
48
|
+
expiresAt: Date.now() + 300_000,
|
|
49
|
+
};
|
|
50
|
+
const key = digest(state);
|
|
51
|
+
await input.transactionStore.put(key, transaction);
|
|
52
|
+
const result = object(await this.postJson('/workforce-auth/transactions', {
|
|
53
|
+
browser_origin: origin.origin,
|
|
54
|
+
redirect_uri: callback.href,
|
|
55
|
+
code_challenge: transaction.codeChallenge,
|
|
56
|
+
code_challenge_method: 'S256',
|
|
57
|
+
}, transaction.exchangeAttemptId));
|
|
58
|
+
const expiresAt = timestamp(result, 'expires_at');
|
|
59
|
+
if (expiresAt <= Date.now() || expiresAt > transaction.expiresAt + 5_000)
|
|
60
|
+
throw new Error('Invalid workforce transaction lifetime');
|
|
61
|
+
const issuer = text(result, 'issuer');
|
|
62
|
+
secureUrl(issuer);
|
|
63
|
+
const authorizationEndpoint = secureUrl(text(result, 'authorization_endpoint')).href;
|
|
64
|
+
const audience = text(result, 'audience'), nonce = text(result, 'nonce');
|
|
65
|
+
opaque(nonce);
|
|
66
|
+
const maxAuthAgeSeconds = result.max_auth_age_seconds;
|
|
67
|
+
if (!Number.isSafeInteger(maxAuthAgeSeconds) || Number(maxAuthAgeSeconds) < 1)
|
|
68
|
+
throw new Error('Invalid workforce authentication policy');
|
|
69
|
+
const requiredAcrValues = strings(result.required_acr_values);
|
|
70
|
+
const bound = {
|
|
71
|
+
...transaction,
|
|
72
|
+
transactionId: text(result, 'transaction_id'),
|
|
73
|
+
nonce,
|
|
74
|
+
issuer,
|
|
75
|
+
audience,
|
|
76
|
+
expiresAt,
|
|
77
|
+
};
|
|
78
|
+
await input.transactionStore.replace(key, bound);
|
|
79
|
+
return {
|
|
80
|
+
state,
|
|
81
|
+
nonce,
|
|
82
|
+
redirectUri: callback.href,
|
|
83
|
+
codeChallenge: transaction.codeChallenge,
|
|
84
|
+
codeChallengeMethod: 'S256',
|
|
85
|
+
issuer,
|
|
86
|
+
audience,
|
|
87
|
+
authorizationEndpoint,
|
|
88
|
+
maxAuthAgeSeconds: Number(maxAuthAgeSeconds),
|
|
89
|
+
requiredAcrValues,
|
|
90
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async completeAuthorizationCallback(input) {
|
|
94
|
+
const callback = secureUrl(input.callbackUrl), states = callback.searchParams.getAll('state');
|
|
95
|
+
opaque(input.expectedState);
|
|
96
|
+
if (states.length !== 1 ||
|
|
97
|
+
!equal(states[0], input.expectedState) ||
|
|
98
|
+
callback.searchParams.has('error')) {
|
|
99
|
+
throw new XKOVAWorkforceBffError('binding_mismatch');
|
|
100
|
+
}
|
|
101
|
+
const codes = callback.searchParams.getAll('code');
|
|
102
|
+
if (codes.length !== 1 || !codes[0] || codes[0].length > 8192)
|
|
103
|
+
throw new XKOVAWorkforceBffError('binding_mismatch');
|
|
104
|
+
const code = codes[0], codeHash = digest(code), key = digest(input.expectedState);
|
|
105
|
+
return input.transactionStore.runExclusive(key, async () => {
|
|
106
|
+
const initial = await input.transactionStore.get(key);
|
|
107
|
+
if (!initial ||
|
|
108
|
+
initial.expiresAt <= Date.now() ||
|
|
109
|
+
initial.clientFingerprint !== this.fingerprint ||
|
|
110
|
+
callback.origin + callback.pathname !== initial.redirectUri) {
|
|
111
|
+
throw new XKOVAWorkforceBffError('transaction_unavailable');
|
|
112
|
+
}
|
|
113
|
+
if (initial.providerExchangeStarted &&
|
|
114
|
+
initial.providerExchangeStarted.codeHash !== codeHash) {
|
|
115
|
+
throw new XKOVAWorkforceBffError('binding_mismatch');
|
|
116
|
+
}
|
|
117
|
+
const reference = initial.sessionReference;
|
|
118
|
+
return input.sessionStore.runExclusive(reference, async () => {
|
|
119
|
+
let transaction = initial;
|
|
120
|
+
if (transaction.stagedProofToken && !transaction.providerExchangeStarted) {
|
|
121
|
+
throw new XKOVAWorkforceBffError('transaction_unavailable');
|
|
122
|
+
}
|
|
123
|
+
const existing = await input.sessionStore.get(reference);
|
|
124
|
+
if (existing) {
|
|
125
|
+
this.assertSession(existing);
|
|
126
|
+
if (existing.authorizationCodeHash !== codeHash || existing.logoutPending) {
|
|
127
|
+
throw new XKOVAWorkforceBffError('session_unavailable');
|
|
128
|
+
}
|
|
129
|
+
await input.transactionStore.replace(key, { ...transaction, status: 'completed' });
|
|
130
|
+
return { reference, session: existing };
|
|
131
|
+
}
|
|
132
|
+
if (transaction.status === 'completed' ||
|
|
133
|
+
!transaction.transactionId ||
|
|
134
|
+
!transaction.nonce ||
|
|
135
|
+
!transaction.issuer ||
|
|
136
|
+
!transaction.audience)
|
|
137
|
+
throw new XKOVAWorkforceBffError('session_unavailable');
|
|
138
|
+
if (!transaction.stagedProofToken) {
|
|
139
|
+
if (transaction.providerExchangeStarted)
|
|
140
|
+
throw new XKOVAWorkforceBffError('provider_exchange_outcome_unknown');
|
|
141
|
+
// Freeze application data before consuming a code. A later retry cannot replace it.
|
|
142
|
+
transaction = {
|
|
143
|
+
...transaction,
|
|
144
|
+
providerExchangeStarted: {
|
|
145
|
+
codeHash,
|
|
146
|
+
applicationData: JSON.parse(JSON.stringify({ value: input.applicationData })).value,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
await input.transactionStore.replace(key, transaction);
|
|
150
|
+
const proof = await input.exchangeProviderCode({
|
|
151
|
+
code,
|
|
152
|
+
codeVerifier: transaction.codeVerifier,
|
|
153
|
+
redirectUri: transaction.redirectUri,
|
|
154
|
+
expectedNonce: transaction.nonce,
|
|
155
|
+
issuer: transaction.issuer,
|
|
156
|
+
audience: transaction.audience,
|
|
157
|
+
});
|
|
158
|
+
if (typeof proof.proofToken !== 'string' ||
|
|
159
|
+
!proof.proofToken ||
|
|
160
|
+
proof.proofToken.length > 65536) {
|
|
161
|
+
throw new Error('Invalid workforce provider proof result');
|
|
162
|
+
}
|
|
163
|
+
transaction = { ...transaction, stagedProofToken: proof.proofToken };
|
|
164
|
+
await input.transactionStore.replace(key, transaction);
|
|
165
|
+
}
|
|
166
|
+
const material = parseMaterial(await this.postJson('/workforce-auth/exchange', {
|
|
167
|
+
transaction_id: transaction.transactionId,
|
|
168
|
+
nonce: transaction.nonce,
|
|
169
|
+
proof_token: transaction.stagedProofToken,
|
|
170
|
+
exchange_attempt_id: transaction.exchangeAttemptId,
|
|
171
|
+
}, transaction.exchangeAttemptId));
|
|
172
|
+
const stored = {
|
|
173
|
+
clientFingerprint: this.fingerprint,
|
|
174
|
+
authorizationCodeHash: codeHash,
|
|
175
|
+
material,
|
|
176
|
+
applicationExpiresAt: timestamp(material.session, 'absolute_expires_at'),
|
|
177
|
+
applicationData: transaction.providerExchangeStarted.applicationData,
|
|
178
|
+
};
|
|
179
|
+
await input.sessionStore.put(reference, stored);
|
|
180
|
+
await input.transactionStore.replace(key, { ...transaction, status: 'completed' });
|
|
181
|
+
return { reference, session: stored };
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async getSession(sessionToken) {
|
|
186
|
+
credential(sessionToken, 'xwfs');
|
|
187
|
+
return parseSession(await this.getJson('/workforce-auth/session', sessionToken));
|
|
188
|
+
}
|
|
189
|
+
async resolveSession(input) {
|
|
190
|
+
opaque(input.reference);
|
|
191
|
+
return input.sessionStore.runExclusive(input.reference, async () => {
|
|
192
|
+
let stored = await input.sessionStore.get(input.reference);
|
|
193
|
+
if (!stored || stored.logoutPending)
|
|
194
|
+
return null;
|
|
195
|
+
this.assertSession(stored);
|
|
196
|
+
if (stored.applicationExpiresAt <= Date.now())
|
|
197
|
+
return null;
|
|
198
|
+
try {
|
|
199
|
+
if (timestamp(stored.material.session, 'expires_at') <= Date.now() + 30_000) {
|
|
200
|
+
const material = parseMaterial(await this.postJson('/workforce-auth/refresh', {
|
|
201
|
+
refresh_token: stored.material.refresh_token,
|
|
202
|
+
refresh_attempt_token: stored.material.refresh_attempt_token,
|
|
203
|
+
}));
|
|
204
|
+
assertSameHuman(stored.material.session, material.session);
|
|
205
|
+
stored = { ...stored, material };
|
|
206
|
+
await input.sessionStore.replace(input.reference, stored);
|
|
207
|
+
}
|
|
208
|
+
const session = await this.getSession(stored.material.session_token);
|
|
209
|
+
assertSameHuman(stored.material.session, session);
|
|
210
|
+
stored = { ...stored, material: { ...stored.material, session } };
|
|
211
|
+
await input.sessionStore.replace(input.reference, stored);
|
|
212
|
+
return stored;
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (error instanceof XKOVAApiError && (error.status === 401 || error.status === 403)) {
|
|
216
|
+
await input.sessionStore.replace(input.reference, { ...stored, logoutPending: true });
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** Request-scoped resources only. Check the browser's Origin/CSRF before invoking writes. */
|
|
224
|
+
async resolveResources(input) {
|
|
225
|
+
const session = await this.resolveSession(input);
|
|
226
|
+
if (!session)
|
|
227
|
+
return null;
|
|
228
|
+
if (input.actionGrant) {
|
|
229
|
+
opaque(input.actionGrant.grant_token);
|
|
230
|
+
if (timestamp(input.actionGrant, 'expires_at') <= Date.now())
|
|
231
|
+
throw new XKOVAWorkforceBffError('session_unavailable');
|
|
232
|
+
}
|
|
233
|
+
const bound = XKOVAClient.forWorkforceSession(this.options, {
|
|
234
|
+
workforceKey: this.options.workforceKey,
|
|
235
|
+
sessionToken: session.material.session_token,
|
|
236
|
+
browserOrigin: input.browserOrigin,
|
|
237
|
+
...(input.actionGrant && { humanAuthorization: input.actionGrant.grant_token }),
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
session,
|
|
241
|
+
resources: {
|
|
242
|
+
treasury: bound.treasury,
|
|
243
|
+
tokenization: bound.tokenization,
|
|
244
|
+
wallets: new WorkforceWalletsResource(bound),
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Review the locator returned by human_authorization_required. Never send it to a third-party host. */
|
|
249
|
+
async inspectAction(input) {
|
|
250
|
+
opaque(input.authorizationToken);
|
|
251
|
+
const raw = object(await this.getJson(`/workforce-auth/actions/${encodeURIComponent(input.authorizationToken)}`, input.sessionToken, input.browserOrigin));
|
|
252
|
+
text(raw, 'operation');
|
|
253
|
+
text(raw, 'proof_method');
|
|
254
|
+
object(raw.review);
|
|
255
|
+
validateReview(raw);
|
|
256
|
+
return raw;
|
|
257
|
+
}
|
|
258
|
+
/** Starts a real provider ceremony after the user reviews the exact request. No action is executed. */
|
|
259
|
+
async beginActionProof(input) {
|
|
260
|
+
opaque(input.authorizationToken);
|
|
261
|
+
const raw = object(await this.postJson(`/workforce-auth/actions/${encodeURIComponent(input.authorizationToken)}/begin`, undefined, undefined, input));
|
|
262
|
+
if (text(raw, 'nonce') !== input.authorizationToken)
|
|
263
|
+
throw new XKOVAWorkforceBffError('binding_mismatch');
|
|
264
|
+
secureUrl(text(raw, 'issuer'));
|
|
265
|
+
secureUrl(text(raw, 'authorization_endpoint'));
|
|
266
|
+
text(raw, 'audience');
|
|
267
|
+
const maxAge = raw.max_auth_age_seconds;
|
|
268
|
+
if (!Number.isSafeInteger(maxAge) || Number(maxAge) < 1 || Number(maxAge) > 300)
|
|
269
|
+
throw new Error('Invalid workforce action policy');
|
|
270
|
+
strings(raw.required_acr_values);
|
|
271
|
+
strings(raw.required_amr_values);
|
|
272
|
+
object(raw.review);
|
|
273
|
+
validateReview(raw);
|
|
274
|
+
return raw;
|
|
275
|
+
}
|
|
276
|
+
/** Submit the real, freshly verified provider token. A lost response is never automatically retried. */
|
|
277
|
+
async completeActionProof(input) {
|
|
278
|
+
opaque(input.authorizationToken);
|
|
279
|
+
if (input.nonce !== input.authorizationToken)
|
|
280
|
+
throw new XKOVAWorkforceBffError('binding_mismatch');
|
|
281
|
+
if (!input.proofToken || input.proofToken.length > 16384)
|
|
282
|
+
throw new TypeError('Invalid workforce action proof');
|
|
283
|
+
const raw = object(await this.postJson(`/workforce-auth/actions/${encodeURIComponent(input.authorizationToken)}/complete`, { nonce: input.nonce, proof_token: input.proofToken }, undefined, input));
|
|
284
|
+
opaque(text(raw, 'grant_token'));
|
|
285
|
+
if (timestamp(raw, 'expires_at') <= Date.now())
|
|
286
|
+
throw new XKOVAWorkforceBffError('session_unavailable');
|
|
287
|
+
return raw;
|
|
288
|
+
}
|
|
289
|
+
async logoutSession(input) {
|
|
290
|
+
opaque(input.reference);
|
|
291
|
+
await input.sessionStore.runExclusive(input.reference, async () => {
|
|
292
|
+
const stored = await input.sessionStore.get(input.reference);
|
|
293
|
+
if (!stored)
|
|
294
|
+
return;
|
|
295
|
+
this.assertSession(stored);
|
|
296
|
+
if (!stored.logoutPending)
|
|
297
|
+
await input.sessionStore.replace(input.reference, { ...stored, logoutPending: true });
|
|
298
|
+
await this.postJson('/workforce-auth/sign-out', {
|
|
299
|
+
session_token: stored.material.session_token,
|
|
300
|
+
});
|
|
301
|
+
await input.sessionStore.delete(input.reference);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
assertSession(session) {
|
|
305
|
+
if (session.clientFingerprint !== this.fingerprint)
|
|
306
|
+
throw new XKOVAWorkforceBffError('binding_mismatch');
|
|
307
|
+
}
|
|
308
|
+
postJson(path, body, idempotencyKey, context) {
|
|
309
|
+
const client = context
|
|
310
|
+
? XKOVAClient.forWorkforceSession(this.options, {
|
|
311
|
+
...context,
|
|
312
|
+
workforceKey: this.options.workforceKey,
|
|
313
|
+
})
|
|
314
|
+
: this.client;
|
|
315
|
+
return client.requestJson({
|
|
316
|
+
method: 'POST',
|
|
317
|
+
path,
|
|
318
|
+
...(body !== undefined && { body }),
|
|
319
|
+
headers: { 'X-XKOVA-Workforce-Key': this.options.workforceKey },
|
|
320
|
+
...(idempotencyKey !== undefined && { idempotencyKey }),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
getJson(path, sessionToken, browserOrigin) {
|
|
324
|
+
const client = browserOrigin
|
|
325
|
+
? XKOVAClient.forWorkforceSession(this.options, {
|
|
326
|
+
sessionToken,
|
|
327
|
+
browserOrigin,
|
|
328
|
+
workforceKey: this.options.workforceKey,
|
|
329
|
+
})
|
|
330
|
+
: this.client;
|
|
331
|
+
return client.requestJson({
|
|
332
|
+
method: 'GET',
|
|
333
|
+
path,
|
|
334
|
+
headers: {
|
|
335
|
+
'X-XKOVA-Workforce-Key': this.options.workforceKey,
|
|
336
|
+
'X-Workforce-Session': sessionToken,
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** Advisory browser UI data only; provider identity, credentials and grants stay confidential. */
|
|
342
|
+
export function projectXKOVAWorkforceSession(value) {
|
|
343
|
+
return {
|
|
344
|
+
signedIn: true,
|
|
345
|
+
staffId: value.material.session.staff_id,
|
|
346
|
+
roles: [...value.material.session.roles],
|
|
347
|
+
expiresAt: value.material.session.expires_at,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function parseMaterial(value) {
|
|
351
|
+
const raw = object(value);
|
|
352
|
+
const sessionToken = text(raw, 'session_token'), refreshToken = text(raw, 'refresh_token'), attempt = text(raw, 'refresh_attempt_token');
|
|
353
|
+
credential(sessionToken, 'xwfs');
|
|
354
|
+
credential(refreshToken, 'xwfr');
|
|
355
|
+
credential(attempt, 'xwfa');
|
|
356
|
+
return {
|
|
357
|
+
session_token: sessionToken,
|
|
358
|
+
refresh_token: refreshToken,
|
|
359
|
+
refresh_attempt_token: attempt,
|
|
360
|
+
session: parseSession(raw.session),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function parseSession(value) {
|
|
364
|
+
const raw = object(value), grants = object(raw.resource_grants);
|
|
365
|
+
for (const name of ['session_id', 'application_id', 'person_id', 'tenant_id', 'workspace_id']) {
|
|
366
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text(raw, name))) {
|
|
367
|
+
throw new Error('Invalid workforce session identity');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!/^stf_[a-f0-9]{32}$/i.test(text(raw, 'staff_id')))
|
|
371
|
+
throw new Error('Invalid workforce staff identity');
|
|
372
|
+
const expires = timestamp(raw, 'expires_at'), absolute = timestamp(raw, 'absolute_expires_at');
|
|
373
|
+
if (expires > absolute || typeof raw.action_authorization_enabled !== 'boolean')
|
|
374
|
+
throw new Error('Invalid workforce session policy');
|
|
375
|
+
text(raw, 'provider_namespace');
|
|
376
|
+
text(raw, 'provider_subject');
|
|
377
|
+
strings(raw.roles);
|
|
378
|
+
strings(raw.scopes);
|
|
379
|
+
strings(grants.treasury_wallet_ids);
|
|
380
|
+
strings(grants.customer_token_ids);
|
|
381
|
+
if (!strings(grants.treasury_wallet_ids).every((id) => /^vlt_[a-f0-9]{32}$/i.test(id)) ||
|
|
382
|
+
!strings(grants.customer_token_ids).every((id) => /^ctk_[a-f0-9]{32}$/i.test(id))) {
|
|
383
|
+
throw new Error('Invalid workforce resource identity');
|
|
384
|
+
}
|
|
385
|
+
return raw;
|
|
386
|
+
}
|
|
387
|
+
function assertSameHuman(before, after) {
|
|
388
|
+
for (const name of [
|
|
389
|
+
'session_id',
|
|
390
|
+
'application_id',
|
|
391
|
+
'staff_id',
|
|
392
|
+
'person_id',
|
|
393
|
+
'tenant_id',
|
|
394
|
+
'workspace_id',
|
|
395
|
+
'provider_namespace',
|
|
396
|
+
'provider_subject',
|
|
397
|
+
]) {
|
|
398
|
+
if (before[name] !== after[name])
|
|
399
|
+
throw new XKOVAWorkforceBffError('binding_mismatch');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function random() {
|
|
403
|
+
return randomBytes(32).toString('base64url');
|
|
404
|
+
}
|
|
405
|
+
function digest(value) {
|
|
406
|
+
return createHash('sha256').update(value).digest('base64url');
|
|
407
|
+
}
|
|
408
|
+
function opaque(value) {
|
|
409
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(value))
|
|
410
|
+
throw new TypeError('Invalid opaque workforce reference');
|
|
411
|
+
}
|
|
412
|
+
function credential(value, prefix) {
|
|
413
|
+
if (!new RegExp(`^${prefix}_[A-Za-z0-9_-]{43}$`).test(value))
|
|
414
|
+
throw new TypeError('Invalid workforce credential');
|
|
415
|
+
}
|
|
416
|
+
function equal(a, b) {
|
|
417
|
+
const left = Buffer.from(a), right = Buffer.from(b);
|
|
418
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
419
|
+
}
|
|
420
|
+
function secureUrl(value) {
|
|
421
|
+
const url = new URL(value);
|
|
422
|
+
if (!isAllowedProtocol(url) || url.username || url.password || url.hash)
|
|
423
|
+
throw new TypeError('Invalid workforce URL');
|
|
424
|
+
return url;
|
|
425
|
+
}
|
|
426
|
+
function exactCallback(value) {
|
|
427
|
+
const url = secureUrl(value);
|
|
428
|
+
if (url.search)
|
|
429
|
+
throw new TypeError('Workforce callback cannot contain a query');
|
|
430
|
+
return url;
|
|
431
|
+
}
|
|
432
|
+
function object(value) {
|
|
433
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
434
|
+
throw new Error('Invalid workforce API response');
|
|
435
|
+
return value;
|
|
436
|
+
}
|
|
437
|
+
function text(value, key) {
|
|
438
|
+
const field = value[key];
|
|
439
|
+
if (typeof field !== 'string' || !field || field.length > 65536)
|
|
440
|
+
throw new Error('Invalid workforce API response field');
|
|
441
|
+
return field;
|
|
442
|
+
}
|
|
443
|
+
function strings(value) {
|
|
444
|
+
if (!Array.isArray(value) ||
|
|
445
|
+
value.length > 1000 ||
|
|
446
|
+
!value.every((item) => typeof item === 'string' && item.length > 0 && item.length <= 512)) {
|
|
447
|
+
throw new Error('Invalid workforce API response list');
|
|
448
|
+
}
|
|
449
|
+
return value;
|
|
450
|
+
}
|
|
451
|
+
function timestamp(value, key) {
|
|
452
|
+
const result = Date.parse(text(value, key));
|
|
453
|
+
if (!Number.isFinite(result))
|
|
454
|
+
throw new Error('Invalid workforce API expiry');
|
|
455
|
+
return result;
|
|
456
|
+
}
|
|
457
|
+
function validateReview(raw) {
|
|
458
|
+
if (!/^[a-f0-9]{64}$/i.test(text(raw, 'review_digest')) ||
|
|
459
|
+
timestamp(raw, 'expires_at') <= Date.now()) {
|
|
460
|
+
throw new XKOVAWorkforceBffError('session_unavailable');
|
|
461
|
+
}
|
|
462
|
+
}
|