@opentdf/sdk 0.13.0-beta.119 → 0.13.0-beta.123
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 +60 -10
- package/dist/cjs/src/access/access-rpc.js +6 -5
- package/dist/cjs/src/access.js +18 -5
- package/dist/cjs/src/auth/interceptors.js +186 -0
- package/dist/cjs/src/auth/oidc.js +5 -3
- package/dist/cjs/src/index.js +6 -2
- package/dist/cjs/src/opentdf.js +40 -32
- package/dist/cjs/src/platform.js +3 -46
- package/dist/cjs/src/policy/api.js +9 -5
- package/dist/cjs/src/policy/discovery.js +10 -9
- package/dist/cjs/tdf3/src/client/index.js +35 -17
- package/dist/cjs/tdf3/src/tdf.js +8 -7
- package/dist/types/src/access/access-rpc.d.ts +3 -3
- package/dist/types/src/access/access-rpc.d.ts.map +1 -1
- package/dist/types/src/access.d.ts +3 -3
- package/dist/types/src/access.d.ts.map +1 -1
- package/dist/types/src/auth/interceptors.d.ts +99 -0
- package/dist/types/src/auth/interceptors.d.ts.map +1 -0
- package/dist/types/src/auth/oidc.d.ts +1 -1
- package/dist/types/src/auth/oidc.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/opentdf.d.ts +18 -15
- package/dist/types/src/opentdf.d.ts.map +1 -1
- package/dist/types/src/platform.d.ts +6 -3
- package/dist/types/src/platform.d.ts.map +1 -1
- package/dist/types/src/policy/api.d.ts +3 -3
- package/dist/types/src/policy/api.d.ts.map +1 -1
- package/dist/types/src/policy/discovery.d.ts +5 -5
- package/dist/types/src/policy/discovery.d.ts.map +1 -1
- package/dist/types/tdf3/src/client/index.d.ts +10 -1
- package/dist/types/tdf3/src/client/index.d.ts.map +1 -1
- package/dist/types/tdf3/src/tdf.d.ts +5 -2
- package/dist/types/tdf3/src/tdf.d.ts.map +1 -1
- package/dist/web/src/access/access-rpc.js +6 -5
- package/dist/web/src/access.js +18 -5
- package/dist/web/src/auth/interceptors.js +142 -0
- package/dist/web/src/auth/oidc.js +5 -3
- package/dist/web/src/index.js +2 -1
- package/dist/web/src/opentdf.js +40 -32
- package/dist/web/src/platform.js +3 -46
- package/dist/web/src/policy/api.js +9 -5
- package/dist/web/src/policy/discovery.js +10 -9
- package/dist/web/tdf3/src/client/index.js +35 -17
- package/dist/web/tdf3/src/tdf.js +8 -7
- package/package.json +1 -1
- package/src/access/access-rpc.ts +5 -5
- package/src/access.ts +29 -13
- package/src/auth/interceptors.ts +197 -0
- package/src/auth/oidc.ts +5 -3
- package/src/index.ts +10 -0
- package/src/opentdf.ts +54 -34
- package/src/platform.ts +8 -52
- package/src/policy/api.ts +8 -5
- package/src/policy/discovery.ts +9 -9
- package/tdf3/src/client/index.ts +46 -17
- package/tdf3/src/tdf.ts +14 -11
package/src/access.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type AuthConfig, resolveAuthConfig } from './auth/interceptors.js';
|
|
2
2
|
import { RewrapResponse } from './platform/kas/kas_pb.js';
|
|
3
3
|
import { getPlatformUrlFromKasEndpoint, validateSecureUrl } from './utils.js';
|
|
4
4
|
import { base64 } from './encodings/index.js';
|
|
@@ -37,19 +37,28 @@ export type RewrapRequest = {
|
|
|
37
37
|
export async function fetchWrappedKey(
|
|
38
38
|
url: string,
|
|
39
39
|
signedRequestToken: string,
|
|
40
|
-
|
|
40
|
+
auth: AuthConfig,
|
|
41
41
|
fulfillableObligationFQNs: string[]
|
|
42
42
|
): Promise<RewrapResponse> {
|
|
43
43
|
const platformUrl = getPlatformUrlFromKasEndpoint(url);
|
|
44
|
+
const { interceptors, authProvider } = resolveAuthConfig(auth);
|
|
45
|
+
|
|
46
|
+
const rpcCall = () =>
|
|
47
|
+
fetchWrappedKeysRpc(
|
|
48
|
+
platformUrl,
|
|
49
|
+
signedRequestToken,
|
|
50
|
+
{ interceptors },
|
|
51
|
+
rewrapAdditionalContextHeader(fulfillableObligationFQNs)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// When no AuthProvider is available, skip the legacy fallback so the real
|
|
55
|
+
// RPC error propagates instead of being masked by tryPromisesUntilFirstSuccess.
|
|
56
|
+
if (!authProvider) {
|
|
57
|
+
return await rpcCall();
|
|
58
|
+
}
|
|
44
59
|
|
|
45
60
|
return await tryPromisesUntilFirstSuccess(
|
|
46
|
-
|
|
47
|
-
fetchWrappedKeysRpc(
|
|
48
|
-
platformUrl,
|
|
49
|
-
signedRequestToken,
|
|
50
|
-
authProvider,
|
|
51
|
-
rewrapAdditionalContextHeader(fulfillableObligationFQNs)
|
|
52
|
-
),
|
|
61
|
+
rpcCall,
|
|
53
62
|
// We intentionally do not provide the rewrap additional context to legacy requests destined for older platforms.
|
|
54
63
|
// Platforms new enough to have knowledge of obligations will be handling RPC requests successfully.
|
|
55
64
|
() =>
|
|
@@ -164,11 +173,18 @@ export type KasPublicKeyInfo = {
|
|
|
164
173
|
*/
|
|
165
174
|
export async function fetchKeyAccessServers(
|
|
166
175
|
platformUrl: string,
|
|
167
|
-
|
|
176
|
+
auth: AuthConfig
|
|
168
177
|
): Promise<OriginAllowList> {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
178
|
+
const { interceptors, authProvider } = resolveAuthConfig(auth);
|
|
179
|
+
|
|
180
|
+
const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, { interceptors });
|
|
181
|
+
|
|
182
|
+
if (!authProvider) {
|
|
183
|
+
return await rpcCall();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return await tryPromisesUntilFirstSuccess(rpcCall, () =>
|
|
187
|
+
fetchKeyAccessServersLegacy(platformUrl, authProvider)
|
|
172
188
|
);
|
|
173
189
|
}
|
|
174
190
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { type Interceptor } from '@connectrpc/connect';
|
|
2
|
+
export type { Interceptor } from '@connectrpc/connect';
|
|
3
|
+
import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js';
|
|
4
|
+
import * as DefaultCryptoService from '../../tdf3/src/crypto/index.js';
|
|
5
|
+
import DPoP from './dpop.js';
|
|
6
|
+
import { type AuthProvider } from './auth.js';
|
|
7
|
+
import { base64 } from '../encodings/index.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A function that returns a valid access token string.
|
|
11
|
+
* Called per-request; implementations should handle caching/refresh internally.
|
|
12
|
+
*/
|
|
13
|
+
export type TokenProvider = () => Promise<string>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options for creating a DPoP-aware auth interceptor.
|
|
17
|
+
*/
|
|
18
|
+
export type DPoPInterceptorOptions = {
|
|
19
|
+
/** Function that returns a valid access token (may cache/refresh internally). */
|
|
20
|
+
tokenProvider: TokenProvider;
|
|
21
|
+
/** DPoP signing key pair. If omitted, one is generated automatically. */
|
|
22
|
+
dpopKeys?: KeyPair | Promise<KeyPair>;
|
|
23
|
+
/** CryptoService for signing. Defaults to DefaultCryptoService. */
|
|
24
|
+
cryptoService?: CryptoService;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A DPoP interceptor that also exposes the resolved signing key pair.
|
|
29
|
+
* TDF encrypt/decrypt needs these keys for request body signing (reqSignature).
|
|
30
|
+
*/
|
|
31
|
+
export type DPoPInterceptor = Interceptor & {
|
|
32
|
+
/** The resolved DPoP key pair, for use in TDF request token signing. */
|
|
33
|
+
readonly dpopKeys: Promise<KeyPair>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a simple bearer-token interceptor.
|
|
38
|
+
* Calls `tokenProvider()` per-request and sets the `Authorization` header.
|
|
39
|
+
*
|
|
40
|
+
* @param tokenProvider Function returning a valid access token.
|
|
41
|
+
* @returns A Connect RPC Interceptor.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* const opentdf = new OpenTDF({
|
|
46
|
+
* interceptors: [authTokenInterceptor(() => myAuth.getAccessToken())],
|
|
47
|
+
* platformUrl: '/api',
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function authTokenInterceptor(tokenProvider: TokenProvider): Interceptor {
|
|
52
|
+
return (next) => async (req) => {
|
|
53
|
+
const token = await tokenProvider();
|
|
54
|
+
req.header.set('Authorization', `Bearer ${token}`);
|
|
55
|
+
return next(req);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a DPoP-aware auth interceptor.
|
|
61
|
+
* Per-request: gets token, generates DPoP proof JWT, sets Authorization + DPoP + X-VirtruPubKey headers.
|
|
62
|
+
* Exposes `dpopKeys` for TDF request body signing.
|
|
63
|
+
*
|
|
64
|
+
* @param options DPoP interceptor configuration.
|
|
65
|
+
* @returns A DPoP interceptor with an exposed `dpopKeys` promise.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* const dpopInterceptor = authTokenDPoPInterceptor({
|
|
70
|
+
* tokenProvider: () => myAuth.getAccessToken(),
|
|
71
|
+
* });
|
|
72
|
+
* const opentdf = new OpenTDF({
|
|
73
|
+
* interceptors: [dpopInterceptor],
|
|
74
|
+
* dpopKeys: dpopInterceptor.dpopKeys,
|
|
75
|
+
* platformUrl: '/api',
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPInterceptor {
|
|
80
|
+
const cryptoService = options.cryptoService ?? DefaultCryptoService;
|
|
81
|
+
const dpopKeysPromise: Promise<KeyPair> = options.dpopKeys
|
|
82
|
+
? Promise.resolve(options.dpopKeys)
|
|
83
|
+
: cryptoService.generateSigningKeyPair();
|
|
84
|
+
|
|
85
|
+
const interceptor: Interceptor = (next) => async (req) => {
|
|
86
|
+
const [token, keys] = await Promise.all([options.tokenProvider(), dpopKeysPromise]);
|
|
87
|
+
|
|
88
|
+
const url = new URL(req.url);
|
|
89
|
+
const httpUri = `${url.origin}${url.pathname}`;
|
|
90
|
+
|
|
91
|
+
// Generate DPoP proof JWT for this request
|
|
92
|
+
const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST');
|
|
93
|
+
|
|
94
|
+
// Export public key PEM for X-VirtruPubKey header
|
|
95
|
+
const publicKeyPem = await cryptoService.exportPublicKeyPem(keys.publicKey);
|
|
96
|
+
|
|
97
|
+
req.header.set('Authorization', `Bearer ${token}`);
|
|
98
|
+
req.header.set('DPoP', dpopProof);
|
|
99
|
+
req.header.set('X-VirtruPubKey', base64.encode(publicKeyPem));
|
|
100
|
+
|
|
101
|
+
return next(req);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Attach dpopKeys to the interceptor function
|
|
105
|
+
const dpopInterceptor = interceptor as DPoPInterceptor;
|
|
106
|
+
Object.defineProperty(dpopInterceptor, 'dpopKeys', {
|
|
107
|
+
value: dpopKeysPromise,
|
|
108
|
+
writable: false,
|
|
109
|
+
enumerable: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return dpopInterceptor;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates an interceptor that bridges an existing AuthProvider to the Interceptor pattern.
|
|
117
|
+
* Use this for backwards compatibility when migrating from AuthProvider to interceptors.
|
|
118
|
+
*
|
|
119
|
+
* @param authProvider The legacy AuthProvider to bridge.
|
|
120
|
+
* @returns A Connect RPC Interceptor.
|
|
121
|
+
*/
|
|
122
|
+
export function authProviderInterceptor(authProvider: AuthProvider): Interceptor {
|
|
123
|
+
return (next) => async (req) => {
|
|
124
|
+
const url = new URL(req.url);
|
|
125
|
+
const pathOnly = url.pathname;
|
|
126
|
+
// Signs only the path of the url in the request
|
|
127
|
+
let token;
|
|
128
|
+
try {
|
|
129
|
+
token = await authProvider.withCreds({
|
|
130
|
+
url: pathOnly,
|
|
131
|
+
method: 'POST',
|
|
132
|
+
// Start with any headers Connect already has
|
|
133
|
+
headers: {
|
|
134
|
+
...Object.fromEntries(req.header.entries()),
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
if (msg.includes('public key') || msg.includes('updateClientPublicKey')) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
'PlatformClient: DPoP key binding is not complete. ' +
|
|
143
|
+
'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' +
|
|
144
|
+
'`await client.ready` before constructing PlatformClient. ' +
|
|
145
|
+
`Original error: ${msg}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
Object.entries(token.headers).forEach(([key, value]) => {
|
|
152
|
+
req.header.set(key, value);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return await next(req);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Auth configuration: either a legacy AuthProvider or an object with interceptors.
|
|
161
|
+
*/
|
|
162
|
+
export type AuthConfig = AuthProvider | { interceptors: Interceptor[] };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Type guard for AuthConfig with interceptors.
|
|
166
|
+
*/
|
|
167
|
+
export function isInterceptorConfig(auth: AuthConfig): auth is { interceptors: Interceptor[] } {
|
|
168
|
+
return 'interceptors' in auth && Array.isArray((auth as { interceptors: unknown }).interceptors);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolves an AuthConfig into interceptors for use with PlatformClient.
|
|
173
|
+
* If the config is an AuthProvider, it is bridged via authProviderInterceptor.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveInterceptors(auth: AuthConfig): Interceptor[] {
|
|
176
|
+
if (isInterceptorConfig(auth)) {
|
|
177
|
+
return auth.interceptors;
|
|
178
|
+
}
|
|
179
|
+
return [authProviderInterceptor(auth)];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolves an AuthConfig into both interceptors and an optional AuthProvider.
|
|
184
|
+
* The AuthProvider is available for legacy code paths that need withCreds().
|
|
185
|
+
*/
|
|
186
|
+
export function resolveAuthConfig(auth: AuthConfig): {
|
|
187
|
+
interceptors: Interceptor[];
|
|
188
|
+
authProvider?: AuthProvider;
|
|
189
|
+
} {
|
|
190
|
+
if (isInterceptorConfig(auth)) {
|
|
191
|
+
return { interceptors: auth.interceptors };
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
interceptors: [authProviderInterceptor(auth)],
|
|
195
|
+
authProvider: auth,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/auth/oidc.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarat
|
|
|
11
11
|
export type CommonCredentials = {
|
|
12
12
|
/** The OIDC client ID used for token issuance and exchange flows */
|
|
13
13
|
clientId: string;
|
|
14
|
-
/** The endpoint of the OIDC IdP to authenticate against, ex. 'https://
|
|
14
|
+
/** The endpoint of the OIDC IdP to authenticate against, ex. 'https://keycloak.opentdf.local/auth' */
|
|
15
15
|
oidcOrigin: string;
|
|
16
16
|
oidcTokenEndpoint?: string;
|
|
17
17
|
oidcUserInfoEndpoint?: string;
|
|
@@ -176,6 +176,8 @@ export class AccessToken {
|
|
|
176
176
|
}
|
|
177
177
|
// Export opaque public key to PEM format for header
|
|
178
178
|
const publicKeyPem = await this.cryptoService.exportPublicKeyPem(this.signingKey.publicKey);
|
|
179
|
+
// TODO: Rename to X-OpenTDF-PubKey; requires coordinated change with
|
|
180
|
+
// platform Keycloak mapper (lib/fixtures/keycloak.go `client.publickey`).
|
|
179
181
|
headers['X-VirtruPubKey'] = base64.encode(publicKeyPem);
|
|
180
182
|
headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST');
|
|
181
183
|
}
|
|
@@ -300,9 +302,9 @@ export class AccessToken {
|
|
|
300
302
|
}
|
|
301
303
|
|
|
302
304
|
async withCreds(httpReq: HttpRequest): Promise<HttpRequest> {
|
|
303
|
-
if (!this.signingKey) {
|
|
305
|
+
if (this.config.dpopEnabled && !this.signingKey) {
|
|
304
306
|
throw new ConfigurationError(
|
|
305
|
-
'Client public key was not set via `updateClientPublicKey` or passed in via constructor
|
|
307
|
+
'Client public key was not set via `updateClientPublicKey` or passed in via constructor; required when DPoP is enabled'
|
|
306
308
|
);
|
|
307
309
|
}
|
|
308
310
|
const accessToken = (this.currentAccessToken ??= await this.get());
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export { type AuthProvider, type HttpMethod, HttpRequest, withHeaders } from './auth/auth.js';
|
|
2
2
|
export * as AuthProviders from './auth/providers.js';
|
|
3
|
+
export {
|
|
4
|
+
authTokenInterceptor,
|
|
5
|
+
authTokenDPoPInterceptor,
|
|
6
|
+
authProviderInterceptor,
|
|
7
|
+
type AuthConfig,
|
|
8
|
+
type DPoPInterceptor,
|
|
9
|
+
type DPoPInterceptorOptions,
|
|
10
|
+
type Interceptor,
|
|
11
|
+
type TokenProvider,
|
|
12
|
+
} from './auth/interceptors.js';
|
|
3
13
|
export { attributeFQNsAsValues } from './policy/api.js';
|
|
4
14
|
export {
|
|
5
15
|
listAttributes,
|
package/src/opentdf.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AuthProvider } from './auth/providers.js';
|
|
2
|
+
import { type Interceptor } from '@connectrpc/connect';
|
|
2
3
|
import { ConfigurationError, InvalidFileError } from './errors.js';
|
|
3
4
|
export { Client as TDF3Client } from '../tdf3/src/client/index.js';
|
|
4
5
|
import { Chunker, fromSource, sourceToStream, type Source } from './seekable.js';
|
|
@@ -164,8 +165,17 @@ export type OpenTDFOptions = {
|
|
|
164
165
|
/** Platform URL. */
|
|
165
166
|
platformUrl?: string;
|
|
166
167
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Connect RPC interceptors for authentication. Preferred over authProvider.
|
|
170
|
+
* Use `authTokenInterceptor()` or `authTokenDPoPInterceptor()` to create interceptors.
|
|
171
|
+
*/
|
|
172
|
+
interceptors?: Interceptor[];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Auth provider for connections to the policy service and KASes.
|
|
176
|
+
* @deprecated since 0.14.0. Use `interceptors` with `authTokenInterceptor()` or `authTokenDPoPInterceptor()` instead.
|
|
177
|
+
*/
|
|
178
|
+
authProvider?: AuthProvider;
|
|
169
179
|
|
|
170
180
|
/** Default settings for 'encrypt' type requests. */
|
|
171
181
|
defaultCreateOptions?: Omit<CreateOptions, 'source'>;
|
|
@@ -236,18 +246,10 @@ export type TDFReader = {
|
|
|
236
246
|
* It also requires a platform URL to be set, which is used to fetch key access servers and policies.
|
|
237
247
|
* @example
|
|
238
248
|
* ```
|
|
239
|
-
* import {
|
|
240
|
-
*
|
|
241
|
-
* const oidcCredentials: RefreshTokenCredentials = {
|
|
242
|
-
* clientId: keycloakClientId,
|
|
243
|
-
* exchange: 'refresh',
|
|
244
|
-
* refreshToken: refreshToken,
|
|
245
|
-
* oidcOrigin: keycloakUrl,
|
|
246
|
-
* };
|
|
247
|
-
* const authProvider = await AuthProviders.refreshAuthProvider(oidcCredentials);
|
|
249
|
+
* import { authTokenInterceptor, OpenTDF } from '@opentdf/sdk';
|
|
248
250
|
*
|
|
249
251
|
* const client = new OpenTDF({
|
|
250
|
-
*
|
|
252
|
+
* interceptors: [authTokenInterceptor(() => `${myAuth.token.accessToken}`)],
|
|
251
253
|
* platformUrl: 'https://platform.example.com',
|
|
252
254
|
* });
|
|
253
255
|
*
|
|
@@ -264,8 +266,10 @@ export class OpenTDF {
|
|
|
264
266
|
readonly platformUrl: string;
|
|
265
267
|
/** The policy service endpoint */
|
|
266
268
|
readonly policyEndpoint: string;
|
|
267
|
-
/** The auth provider for the OpenTDF instance. */
|
|
268
|
-
readonly authProvider
|
|
269
|
+
/** The auth provider for the OpenTDF instance (deprecated, use interceptors). */
|
|
270
|
+
readonly authProvider?: AuthProvider;
|
|
271
|
+
/** Connect RPC interceptors for authentication. */
|
|
272
|
+
readonly interceptors?: Interceptor[];
|
|
269
273
|
/** If DPoP is enabled for this instance. */
|
|
270
274
|
readonly dpopEnabled: boolean;
|
|
271
275
|
/** Default options for creating TDF objects. */
|
|
@@ -283,6 +287,7 @@ export class OpenTDF {
|
|
|
283
287
|
|
|
284
288
|
constructor({
|
|
285
289
|
authProvider,
|
|
290
|
+
interceptors,
|
|
286
291
|
dpopKeys,
|
|
287
292
|
defaultCreateOptions,
|
|
288
293
|
defaultReadOptions,
|
|
@@ -291,7 +296,11 @@ export class OpenTDF {
|
|
|
291
296
|
platformUrl,
|
|
292
297
|
cryptoService,
|
|
293
298
|
}: OpenTDFOptions) {
|
|
299
|
+
if (!authProvider && !interceptors?.length) {
|
|
300
|
+
throw new ConfigurationError('Either authProvider or interceptors must be provided.');
|
|
301
|
+
}
|
|
294
302
|
this.authProvider = authProvider;
|
|
303
|
+
this.interceptors = interceptors;
|
|
295
304
|
this.defaultCreateOptions = defaultCreateOptions || {};
|
|
296
305
|
this.defaultReadOptions = defaultReadOptions || {};
|
|
297
306
|
this.dpopEnabled = !disableDPoP;
|
|
@@ -308,6 +317,7 @@ export class OpenTDF {
|
|
|
308
317
|
this.dpopKeys = dpopKeys ?? this.cryptoService.generateSigningKeyPair();
|
|
309
318
|
this.tdf3Client = new TDF3Client({
|
|
310
319
|
authProvider,
|
|
320
|
+
interceptors,
|
|
311
321
|
dpopEnabled: this.dpopEnabled,
|
|
312
322
|
dpopKeys: this.dpopEnabled ? this.dpopKeys : undefined,
|
|
313
323
|
kasEndpoint: this.platformUrl || 'https://disallow.all.invalid',
|
|
@@ -315,21 +325,31 @@ export class OpenTDF {
|
|
|
315
325
|
policyEndpoint,
|
|
316
326
|
cryptoService: this.cryptoService,
|
|
317
327
|
});
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
328
|
+
|
|
329
|
+
if (interceptors?.length && !authProvider) {
|
|
330
|
+
// Interceptor path: no updateClientPublicKey needed.
|
|
331
|
+
// DPoP key binding is handled by the interceptor itself.
|
|
332
|
+
this.ready = Promise.resolve();
|
|
333
|
+
} else if (authProvider) {
|
|
334
|
+
// Legacy AuthProvider path: eagerly bind DPoP keys to the auth provider
|
|
335
|
+
// so PlatformClient can make gRPC calls without waiting for a TDF
|
|
336
|
+
// operation first.
|
|
337
|
+
// Note: TDF3Client.createSessionKeys() also calls updateClientPublicKey
|
|
338
|
+
// with the same keys, but the duplicate call is benign —
|
|
339
|
+
// refreshTokenClaimsWithClientPubkeyIfNeeded short-circuits when
|
|
340
|
+
// the signing key hasn't changed.
|
|
341
|
+
this.ready = this.dpopEnabled
|
|
342
|
+
? this.dpopKeys.then((keys) => authProvider.updateClientPublicKey(keys))
|
|
343
|
+
: Promise.resolve();
|
|
344
|
+
// Prevent unhandled rejection if caller doesn't await ready.
|
|
345
|
+
// The error will still surface via TDF3Client's own key binding
|
|
346
|
+
// when encrypt/decrypt is called.
|
|
347
|
+
this.ready.catch((err) => {
|
|
348
|
+
console.warn('OpenTDF: DPoP key binding failed during initialization:', err);
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
this.ready = Promise.resolve();
|
|
352
|
+
}
|
|
333
353
|
}
|
|
334
354
|
|
|
335
355
|
/** Creates a new TDF stream. */
|
|
@@ -485,9 +505,9 @@ class ZTDFReader {
|
|
|
485
505
|
|
|
486
506
|
const dpopKeys = await this.client.dpopKeys;
|
|
487
507
|
|
|
488
|
-
const {
|
|
489
|
-
if (!
|
|
490
|
-
throw new ConfigurationError('authProvider
|
|
508
|
+
const { auth, cryptoService } = this.client;
|
|
509
|
+
if (!auth) {
|
|
510
|
+
throw new ConfigurationError('authProvider or interceptors are required');
|
|
491
511
|
}
|
|
492
512
|
|
|
493
513
|
let allowList: OriginAllowList | undefined;
|
|
@@ -498,14 +518,14 @@ class ZTDFReader {
|
|
|
498
518
|
this.opts.ignoreAllowlist
|
|
499
519
|
);
|
|
500
520
|
} else if (this.opts.platformUrl) {
|
|
501
|
-
allowList = await fetchKeyAccessServers(this.opts.platformUrl,
|
|
521
|
+
allowList = await fetchKeyAccessServers(this.opts.platformUrl, auth);
|
|
502
522
|
}
|
|
503
523
|
|
|
504
524
|
const overview = await this.overview;
|
|
505
525
|
const oldStream = await decryptStreamFrom(
|
|
506
526
|
{
|
|
507
527
|
allowList,
|
|
508
|
-
|
|
528
|
+
auth,
|
|
509
529
|
chunker: this.source,
|
|
510
530
|
concurrencyLimit: 1,
|
|
511
531
|
cryptoService,
|
package/src/platform.ts
CHANGED
|
@@ -3,7 +3,8 @@ export * as platformConnectWeb from '@connectrpc/connect-web';
|
|
|
3
3
|
export * as platformConnect from '@connectrpc/connect';
|
|
4
4
|
|
|
5
5
|
import { createConnectTransport } from '@connectrpc/connect-web';
|
|
6
|
-
import { AuthProvider } from '../tdf3/index.js';
|
|
6
|
+
import type { AuthProvider } from '../tdf3/index.js';
|
|
7
|
+
import { authProviderInterceptor } from './auth/interceptors.js';
|
|
7
8
|
|
|
8
9
|
import { Client, createClient, Interceptor } from '@connectrpc/connect';
|
|
9
10
|
import { WellKnownService } from './platform/wellknownconfiguration/wellknown_configuration_pb.js';
|
|
@@ -44,9 +45,12 @@ export interface PlatformServicesV2 {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export interface PlatformClientOptions {
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Authentication provider for generating auth interceptor.
|
|
50
|
+
* @deprecated since 0.14.0. Use `interceptors` with `authTokenInterceptor()` or `authTokenDPoPInterceptor()` instead.
|
|
51
|
+
*/
|
|
48
52
|
authProvider?: AuthProvider;
|
|
49
|
-
/** Array of
|
|
53
|
+
/** Array of interceptors to apply to rpc requests. Preferred over authProvider. */
|
|
50
54
|
interceptors?: Interceptor[];
|
|
51
55
|
/** Base URL of the platform API. */
|
|
52
56
|
platformUrl: string;
|
|
@@ -85,8 +89,7 @@ export class PlatformClient {
|
|
|
85
89
|
const interceptors: Interceptor[] = [];
|
|
86
90
|
|
|
87
91
|
if (options.authProvider) {
|
|
88
|
-
|
|
89
|
-
interceptors.push(authInterceptor);
|
|
92
|
+
interceptors.push(authProviderInterceptor(options.authProvider));
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
if (options.interceptors?.length) {
|
|
@@ -120,50 +123,3 @@ export class PlatformClient {
|
|
|
120
123
|
};
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Creates an interceptor that adds authentication headers to outgoing requests.
|
|
126
|
-
*
|
|
127
|
-
* This function uses the provided `AuthProvider` to generate authentication credentials
|
|
128
|
-
* for each request. The `AuthProvider` is expected to implement a `withCreds` method
|
|
129
|
-
* that returns an object containing authentication headers. These headers are then
|
|
130
|
-
* added to the request before it is sent to the server.
|
|
131
|
-
*
|
|
132
|
-
*/
|
|
133
|
-
function createAuthInterceptor(authProvider: AuthProvider): Interceptor {
|
|
134
|
-
const authInterceptor: Interceptor = (next) => async (req) => {
|
|
135
|
-
const url = new URL(req.url);
|
|
136
|
-
const pathOnly = url.pathname;
|
|
137
|
-
// Signs only the path of the url in the request
|
|
138
|
-
let token;
|
|
139
|
-
try {
|
|
140
|
-
token = await authProvider.withCreds({
|
|
141
|
-
url: pathOnly,
|
|
142
|
-
method: 'POST',
|
|
143
|
-
// Start with any headers Connect already has
|
|
144
|
-
headers: {
|
|
145
|
-
...Object.fromEntries(req.header.entries()),
|
|
146
|
-
'Content-Type': 'application/json',
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
} catch (err) {
|
|
150
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
151
|
-
if (msg.includes('public key') || msg.includes('updateClientPublicKey')) {
|
|
152
|
-
throw new Error(
|
|
153
|
-
'PlatformClient: DPoP key binding is not complete. ' +
|
|
154
|
-
'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' +
|
|
155
|
-
'`await client.ready` before constructing PlatformClient. ' +
|
|
156
|
-
`Original error: ${msg}`
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
throw err;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
Object.entries(token.headers).forEach(([key, value]) => {
|
|
163
|
-
req.header.set(key, value);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
return await next(req);
|
|
167
|
-
};
|
|
168
|
-
return authInterceptor;
|
|
169
|
-
}
|
package/src/policy/api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NetworkError } from '../errors.js';
|
|
2
|
-
import {
|
|
2
|
+
import { type AuthConfig, resolveInterceptors } from '../auth/interceptors.js';
|
|
3
3
|
import { extractRpcErrorMessage, getPlatformUrlFromKasEndpoint } from '../utils.js';
|
|
4
4
|
import { PlatformClient } from '../platform.js';
|
|
5
5
|
import { Value } from './attributes.js';
|
|
@@ -12,11 +12,11 @@ import { ValueSchema } from '../platform/policy/objects_pb.js';
|
|
|
12
12
|
// TODO KAS: go over web-sdk and remove policyEndpoint that is only defined to be used here
|
|
13
13
|
export async function attributeFQNsAsValues(
|
|
14
14
|
platformUrl: string,
|
|
15
|
-
|
|
15
|
+
auth: AuthConfig,
|
|
16
16
|
...fqns: string[]
|
|
17
17
|
): Promise<Value[]> {
|
|
18
18
|
platformUrl = getPlatformUrlFromKasEndpoint(platformUrl);
|
|
19
|
-
const platform = new PlatformClient({
|
|
19
|
+
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
|
|
20
20
|
|
|
21
21
|
let response: GetAttributeValuesByFqnsResponse;
|
|
22
22
|
try {
|
|
@@ -52,7 +52,7 @@ export async function attributeFQNsAsValues(
|
|
|
52
52
|
// Get root certificates from a namespace
|
|
53
53
|
export async function getRootCertsFromNamespace(
|
|
54
54
|
platformUrl: string,
|
|
55
|
-
|
|
55
|
+
auth?: AuthConfig,
|
|
56
56
|
namespaceId?: string,
|
|
57
57
|
fqn?: string
|
|
58
58
|
): Promise<Certificate[]> {
|
|
@@ -63,7 +63,10 @@ export async function getRootCertsFromNamespace(
|
|
|
63
63
|
throw new Error('Either namespaceId or fqn must be provided');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
const platform = new PlatformClient({
|
|
66
|
+
const platform = new PlatformClient({
|
|
67
|
+
...(auth ? { interceptors: resolveInterceptors(auth) } : {}),
|
|
68
|
+
platformUrl,
|
|
69
|
+
});
|
|
67
70
|
|
|
68
71
|
let response: GetNamespaceResponse;
|
|
69
72
|
try {
|
package/src/policy/discovery.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ConnectError, Code } from '@connectrpc/connect';
|
|
2
2
|
import { AttributeNotFoundError, ConfigurationError, NetworkError } from '../errors.js';
|
|
3
|
-
import { type
|
|
3
|
+
import { type AuthConfig, resolveInterceptors } from '../auth/interceptors.js';
|
|
4
4
|
import { extractRpcErrorMessage, validateSecureUrl } from '../utils.js';
|
|
5
5
|
import { PlatformClient } from '../platform.js';
|
|
6
6
|
import type { Attribute } from '../platform/policy/objects_pb.js';
|
|
@@ -49,13 +49,13 @@ const ATTRIBUTE_FQN_RE = /^https?:\/\/[a-zA-Z0-9._~%-]+\/attr\/[a-zA-Z0-9._~%-]+
|
|
|
49
49
|
*/
|
|
50
50
|
export async function listAttributes(
|
|
51
51
|
platformUrl: string,
|
|
52
|
-
|
|
52
|
+
auth: AuthConfig,
|
|
53
53
|
namespace?: string
|
|
54
54
|
): Promise<Attribute[]> {
|
|
55
55
|
if (!validateSecureUrl(platformUrl)) {
|
|
56
56
|
throw new ConfigurationError('platformUrl must use HTTPS protocol');
|
|
57
57
|
}
|
|
58
|
-
const platform = new PlatformClient({
|
|
58
|
+
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
|
|
59
59
|
const result: Attribute[] = [];
|
|
60
60
|
let nextOffset = 0;
|
|
61
61
|
|
|
@@ -106,7 +106,7 @@ export async function listAttributes(
|
|
|
106
106
|
*/
|
|
107
107
|
export async function validateAttributes(
|
|
108
108
|
platformUrl: string,
|
|
109
|
-
|
|
109
|
+
auth: AuthConfig,
|
|
110
110
|
fqns: string[]
|
|
111
111
|
): Promise<void> {
|
|
112
112
|
if (!fqns || fqns.length === 0) {
|
|
@@ -129,7 +129,7 @@ export async function validateAttributes(
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
const platform = new PlatformClient({
|
|
132
|
+
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
|
|
133
133
|
let resp;
|
|
134
134
|
try {
|
|
135
135
|
resp = await platform.v1.attributes.getAttributeValuesByFqns({ fqns });
|
|
@@ -159,7 +159,7 @@ export async function validateAttributes(
|
|
|
159
159
|
*/
|
|
160
160
|
export async function attributeExists(
|
|
161
161
|
platformUrl: string,
|
|
162
|
-
|
|
162
|
+
auth: AuthConfig,
|
|
163
163
|
attributeFqn: string
|
|
164
164
|
): Promise<boolean> {
|
|
165
165
|
if (!validateSecureUrl(platformUrl)) {
|
|
@@ -170,7 +170,7 @@ export async function attributeExists(
|
|
|
170
170
|
throw new ConfigurationError('invalid attribute FQN format');
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
const platform = new PlatformClient({
|
|
173
|
+
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
|
|
174
174
|
try {
|
|
175
175
|
await platform.v1.attributes.getAttribute({
|
|
176
176
|
identifier: { case: 'fqn', value: attributeFqn },
|
|
@@ -199,7 +199,7 @@ export async function attributeExists(
|
|
|
199
199
|
*/
|
|
200
200
|
export async function attributeValueExists(
|
|
201
201
|
platformUrl: string,
|
|
202
|
-
|
|
202
|
+
auth: AuthConfig,
|
|
203
203
|
valueFqn: string
|
|
204
204
|
): Promise<boolean> {
|
|
205
205
|
if (!validateSecureUrl(platformUrl)) {
|
|
@@ -210,7 +210,7 @@ export async function attributeValueExists(
|
|
|
210
210
|
throw new ConfigurationError('invalid attribute value FQN format');
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
const platform = new PlatformClient({
|
|
213
|
+
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
|
|
214
214
|
let resp;
|
|
215
215
|
try {
|
|
216
216
|
resp = await platform.v1.attributes.getAttributeValuesByFqns({ fqns: [valueFqn] });
|