@opentdf/sdk 0.13.0 → 0.14.0-beta.134
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/auth/token-providers.js +247 -0
- package/dist/cjs/src/index.js +16 -2
- package/dist/cjs/src/opentdf.js +40 -32
- package/dist/cjs/src/platform/authorization/entity-identifiers.js +88 -0
- 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/src/version.js +1 -1
- 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/auth/token-providers.d.ts +100 -0
- package/dist/types/src/auth/token-providers.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +3 -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/authorization/entity-identifiers.d.ts +41 -0
- package/dist/types/src/platform/authorization/entity-identifiers.d.ts.map +1 -0
- 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/src/version.d.ts +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/auth/token-providers.js +242 -0
- package/dist/web/src/index.js +4 -1
- package/dist/web/src/opentdf.js +40 -32
- package/dist/web/src/platform/authorization/entity-identifiers.js +81 -0
- 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/src/version.js +1 -1
- 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/auth/token-providers.ts +303 -0
- package/src/index.ts +25 -0
- package/src/opentdf.ts +54 -34
- package/src/platform/authorization/entity-identifiers.ts +102 -0
- package/src/platform.ts +8 -52
- package/src/policy/api.ts +8 -5
- package/src/policy/discovery.ts +9 -9
- package/src/version.ts +1 -1
- package/tdf3/src/client/index.ts +46 -17
- package/tdf3/src/tdf.ts +14 -11
package/package.json
CHANGED
package/src/access/access-rpc.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
OriginAllowList,
|
|
7
7
|
} from '../access.js';
|
|
8
8
|
|
|
9
|
-
import { type
|
|
9
|
+
import { type AuthConfig, resolveInterceptors } from '../auth/interceptors.js';
|
|
10
10
|
import {
|
|
11
11
|
ConfigurationError,
|
|
12
12
|
InvalidFileError,
|
|
@@ -37,11 +37,11 @@ import { ConnectError, Code } from '@connectrpc/connect';
|
|
|
37
37
|
export async function fetchWrappedKey(
|
|
38
38
|
url: string,
|
|
39
39
|
signedRequestToken: string,
|
|
40
|
-
|
|
40
|
+
auth: AuthConfig,
|
|
41
41
|
rewrapAdditionalContextHeader?: string
|
|
42
42
|
): Promise<RewrapResponse> {
|
|
43
43
|
const platformUrl = getPlatformUrlFromKasEndpoint(url);
|
|
44
|
-
const platform = new PlatformClient({
|
|
44
|
+
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
|
|
45
45
|
const options: CallOptions = {};
|
|
46
46
|
if (rewrapAdditionalContextHeader) {
|
|
47
47
|
options.headers = {
|
|
@@ -121,11 +121,11 @@ export function handleRpcRewrapErrorString(
|
|
|
121
121
|
|
|
122
122
|
export async function fetchKeyAccessServers(
|
|
123
123
|
platformUrl: string,
|
|
124
|
-
|
|
124
|
+
auth: AuthConfig
|
|
125
125
|
): Promise<OriginAllowList> {
|
|
126
126
|
let nextOffset = 0;
|
|
127
127
|
const allServers = [];
|
|
128
|
-
const platform = new PlatformClient({
|
|
128
|
+
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
|
|
129
129
|
|
|
130
130
|
do {
|
|
131
131
|
let response: ListKeyAccessServersResponse;
|
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());
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { type TokenProvider } from './interceptors.js';
|
|
2
|
+
import { ConfigurationError, TdfError } from '../errors.js';
|
|
3
|
+
import { rstrip } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for client credentials token provider.
|
|
7
|
+
*
|
|
8
|
+
* **Not for browser use.** Client secrets must not be exposed in client-side code.
|
|
9
|
+
* Use this only in server-side (Node.js/Deno) environments.
|
|
10
|
+
*/
|
|
11
|
+
export type ClientCredentialsTokenProviderOptions = {
|
|
12
|
+
/** OIDC client ID. */
|
|
13
|
+
clientId: string;
|
|
14
|
+
/** OIDC client secret. */
|
|
15
|
+
clientSecret: string;
|
|
16
|
+
/** OIDC IdP origin, e.g. 'http://localhost:8080/auth/realms/opentdf'. */
|
|
17
|
+
oidcOrigin: string;
|
|
18
|
+
/** Override the token endpoint (defaults to `${oidcOrigin}/protocol/openid-connect/token`). */
|
|
19
|
+
oidcTokenEndpoint?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Options for refresh token provider.
|
|
24
|
+
*/
|
|
25
|
+
export type RefreshTokenProviderOptions = {
|
|
26
|
+
/** OIDC client ID. */
|
|
27
|
+
clientId: string;
|
|
28
|
+
/** Refresh token obtained from a prior login flow. */
|
|
29
|
+
refreshToken: string;
|
|
30
|
+
/** OIDC IdP origin, e.g. 'http://localhost:8080/auth/realms/opentdf'. */
|
|
31
|
+
oidcOrigin: string;
|
|
32
|
+
/** Override the token endpoint. */
|
|
33
|
+
oidcTokenEndpoint?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Options for external JWT token provider (RFC 8693 token exchange).
|
|
38
|
+
*/
|
|
39
|
+
export type ExternalJwtTokenProviderOptions = {
|
|
40
|
+
/** OIDC client ID. */
|
|
41
|
+
clientId: string;
|
|
42
|
+
/** External JWT to exchange. */
|
|
43
|
+
externalJwt: string;
|
|
44
|
+
/** OIDC IdP origin, e.g. 'http://localhost:8080/auth/realms/opentdf'. */
|
|
45
|
+
oidcOrigin: string;
|
|
46
|
+
/** Override the token endpoint. */
|
|
47
|
+
oidcTokenEndpoint?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type TokenResponse = {
|
|
51
|
+
access_token: string;
|
|
52
|
+
refresh_token?: string;
|
|
53
|
+
expires_in?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function resolveTokenEndpoint(oidcOrigin: string, override?: string): string {
|
|
57
|
+
if (override?.trim()) return override;
|
|
58
|
+
const base = oidcOrigin?.trim();
|
|
59
|
+
if (!base) {
|
|
60
|
+
throw new ConfigurationError('oidcOrigin or oidcTokenEndpoint is required');
|
|
61
|
+
}
|
|
62
|
+
return `${rstrip(base, '/')}/protocol/openid-connect/token`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decode a JWT's exp claim without verifying the signature.
|
|
67
|
+
* Returns the expiration time in seconds since epoch, or undefined if not present.
|
|
68
|
+
*/
|
|
69
|
+
function getJwtExpiration(token: string): number | undefined {
|
|
70
|
+
try {
|
|
71
|
+
const parts = token.split('.');
|
|
72
|
+
if (parts.length !== 3) return undefined;
|
|
73
|
+
// Base64url decode the payload
|
|
74
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
75
|
+
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
|
|
76
|
+
const decoded = JSON.parse(atob(padded));
|
|
77
|
+
return typeof decoded.exp === 'number' ? decoded.exp : undefined;
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Compute the absolute expiry (seconds since epoch) for a token response.
|
|
85
|
+
* Prefers `expires_in` from the token response, falls back to the JWT `exp` claim.
|
|
86
|
+
*/
|
|
87
|
+
function resolveTokenExpiry(accessToken: string, expiresIn?: number): number | undefined {
|
|
88
|
+
if (typeof expiresIn === 'number') {
|
|
89
|
+
return Date.now() / 1000 + expiresIn;
|
|
90
|
+
}
|
|
91
|
+
return getJwtExpiration(accessToken);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isTokenExpired(expiry: number | undefined, bufferSeconds = 30): boolean {
|
|
95
|
+
if (expiry === undefined) return true;
|
|
96
|
+
return Date.now() / 1000 >= expiry - bufferSeconds;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchToken(
|
|
100
|
+
tokenEndpoint: string,
|
|
101
|
+
body: Record<string, string>
|
|
102
|
+
): Promise<TokenResponse> {
|
|
103
|
+
const response = await fetch(tokenEndpoint, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
107
|
+
Accept: 'application/json',
|
|
108
|
+
},
|
|
109
|
+
body: new URLSearchParams(body).toString(),
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const text = await response.text();
|
|
113
|
+
throw new TdfError(
|
|
114
|
+
`Token request failed: POST [${tokenEndpoint}] => ${response.status} ${response.statusText}: ${text}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return (await response.json()) as TokenResponse;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Creates a TokenProvider that obtains tokens via the OAuth2 client credentials grant.
|
|
122
|
+
* Tokens are cached and automatically refreshed when expired.
|
|
123
|
+
*
|
|
124
|
+
* **Not for browser use.** Client secrets must not be exposed in client-side code.
|
|
125
|
+
* Use this only in server-side (Node.js/Deno) environments.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* const client = new OpenTDF({
|
|
130
|
+
* interceptors: [authTokenInterceptor(clientCredentialsTokenProvider({
|
|
131
|
+
* clientId: 'opentdf',
|
|
132
|
+
* clientSecret: 'secret',
|
|
133
|
+
* oidcOrigin: 'http://localhost:8080/auth/realms/opentdf',
|
|
134
|
+
* }))],
|
|
135
|
+
* platformUrl: 'http://localhost:8080',
|
|
136
|
+
* });
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export function clientCredentialsTokenProvider(
|
|
140
|
+
options: ClientCredentialsTokenProviderOptions
|
|
141
|
+
): TokenProvider {
|
|
142
|
+
if (!options.clientId || !options.clientSecret) {
|
|
143
|
+
throw new ConfigurationError('clientId and clientSecret are required');
|
|
144
|
+
}
|
|
145
|
+
const tokenEndpoint = resolveTokenEndpoint(options.oidcOrigin, options.oidcTokenEndpoint);
|
|
146
|
+
let cachedToken: string | undefined;
|
|
147
|
+
let cachedExpiry: number | undefined;
|
|
148
|
+
let inFlight: Promise<string> | undefined;
|
|
149
|
+
|
|
150
|
+
return async () => {
|
|
151
|
+
if (cachedToken && !isTokenExpired(cachedExpiry)) {
|
|
152
|
+
return cachedToken;
|
|
153
|
+
}
|
|
154
|
+
if (!inFlight) {
|
|
155
|
+
inFlight = (async () => {
|
|
156
|
+
try {
|
|
157
|
+
const resp = await fetchToken(tokenEndpoint, {
|
|
158
|
+
grant_type: 'client_credentials',
|
|
159
|
+
client_id: options.clientId,
|
|
160
|
+
client_secret: options.clientSecret,
|
|
161
|
+
});
|
|
162
|
+
cachedToken = resp.access_token;
|
|
163
|
+
cachedExpiry = resolveTokenExpiry(resp.access_token, resp.expires_in);
|
|
164
|
+
return cachedToken;
|
|
165
|
+
} finally {
|
|
166
|
+
inFlight = undefined;
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
}
|
|
170
|
+
return inFlight;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Creates a TokenProvider that uses a refresh token to obtain access tokens.
|
|
176
|
+
* On the first call, exchanges the refresh token. Subsequent calls use the
|
|
177
|
+
* latest refresh token from the IdP response.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* const client = new OpenTDF({
|
|
182
|
+
* interceptors: [authTokenInterceptor(refreshTokenProvider({
|
|
183
|
+
* clientId: 'my-app',
|
|
184
|
+
* refreshToken: 'refresh-token-from-login',
|
|
185
|
+
* oidcOrigin: 'http://localhost:8080/auth/realms/opentdf',
|
|
186
|
+
* }))],
|
|
187
|
+
* platformUrl: 'http://localhost:8080',
|
|
188
|
+
* });
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
export function refreshTokenProvider(options: RefreshTokenProviderOptions): TokenProvider {
|
|
192
|
+
if (!options.clientId || !options.refreshToken) {
|
|
193
|
+
throw new ConfigurationError('clientId and refreshToken are required');
|
|
194
|
+
}
|
|
195
|
+
const tokenEndpoint = resolveTokenEndpoint(options.oidcOrigin, options.oidcTokenEndpoint);
|
|
196
|
+
let currentRefreshToken = options.refreshToken;
|
|
197
|
+
let cachedToken: string | undefined;
|
|
198
|
+
let cachedExpiry: number | undefined;
|
|
199
|
+
let inFlight: Promise<string> | undefined;
|
|
200
|
+
|
|
201
|
+
return async () => {
|
|
202
|
+
if (cachedToken && !isTokenExpired(cachedExpiry)) {
|
|
203
|
+
return cachedToken;
|
|
204
|
+
}
|
|
205
|
+
if (!inFlight) {
|
|
206
|
+
inFlight = (async () => {
|
|
207
|
+
try {
|
|
208
|
+
const resp = await fetchToken(tokenEndpoint, {
|
|
209
|
+
grant_type: 'refresh_token',
|
|
210
|
+
refresh_token: currentRefreshToken,
|
|
211
|
+
client_id: options.clientId,
|
|
212
|
+
});
|
|
213
|
+
cachedToken = resp.access_token;
|
|
214
|
+
cachedExpiry = resolveTokenExpiry(resp.access_token, resp.expires_in);
|
|
215
|
+
if (resp.refresh_token) {
|
|
216
|
+
currentRefreshToken = resp.refresh_token;
|
|
217
|
+
}
|
|
218
|
+
return cachedToken;
|
|
219
|
+
} finally {
|
|
220
|
+
inFlight = undefined;
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
}
|
|
224
|
+
return inFlight;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Creates a TokenProvider that exchanges an external JWT for a platform token
|
|
230
|
+
* via RFC 8693 token exchange. After the initial exchange, uses the refresh
|
|
231
|
+
* token for subsequent calls.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* const client = new OpenTDF({
|
|
236
|
+
* interceptors: [authTokenInterceptor(externalJwtTokenProvider({
|
|
237
|
+
* clientId: 'my-app',
|
|
238
|
+
* externalJwt: 'eyJhbGciOi...',
|
|
239
|
+
* oidcOrigin: 'http://localhost:8080/auth/realms/opentdf',
|
|
240
|
+
* }))],
|
|
241
|
+
* platformUrl: 'http://localhost:8080',
|
|
242
|
+
* });
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
export function externalJwtTokenProvider(options: ExternalJwtTokenProviderOptions): TokenProvider {
|
|
246
|
+
if (!options.clientId || !options.externalJwt) {
|
|
247
|
+
throw new ConfigurationError('clientId and externalJwt are required');
|
|
248
|
+
}
|
|
249
|
+
const tokenEndpoint = resolveTokenEndpoint(options.oidcOrigin, options.oidcTokenEndpoint);
|
|
250
|
+
let cachedToken: string | undefined;
|
|
251
|
+
let cachedExpiry: number | undefined;
|
|
252
|
+
let currentRefreshToken: string | undefined;
|
|
253
|
+
let initialExchangeDone = false;
|
|
254
|
+
let inFlight: Promise<string> | undefined;
|
|
255
|
+
|
|
256
|
+
return async () => {
|
|
257
|
+
if (cachedToken && !isTokenExpired(cachedExpiry)) {
|
|
258
|
+
return cachedToken;
|
|
259
|
+
}
|
|
260
|
+
if (!inFlight) {
|
|
261
|
+
inFlight = (async () => {
|
|
262
|
+
try {
|
|
263
|
+
let resp: TokenResponse;
|
|
264
|
+
if (!initialExchangeDone) {
|
|
265
|
+
resp = await fetchToken(tokenEndpoint, {
|
|
266
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
267
|
+
subject_token: options.externalJwt,
|
|
268
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
|
|
269
|
+
audience: options.clientId,
|
|
270
|
+
client_id: options.clientId,
|
|
271
|
+
});
|
|
272
|
+
initialExchangeDone = true;
|
|
273
|
+
} else if (currentRefreshToken) {
|
|
274
|
+
resp = await fetchToken(tokenEndpoint, {
|
|
275
|
+
grant_type: 'refresh_token',
|
|
276
|
+
refresh_token: currentRefreshToken,
|
|
277
|
+
client_id: options.clientId,
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
// Re-exchange the original JWT if no refresh token available
|
|
281
|
+
resp = await fetchToken(tokenEndpoint, {
|
|
282
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
283
|
+
subject_token: options.externalJwt,
|
|
284
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
|
|
285
|
+
audience: options.clientId,
|
|
286
|
+
client_id: options.clientId,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
cachedToken = resp.access_token;
|
|
291
|
+
cachedExpiry = resolveTokenExpiry(resp.access_token, resp.expires_in);
|
|
292
|
+
if (resp.refresh_token) {
|
|
293
|
+
currentRefreshToken = resp.refresh_token;
|
|
294
|
+
}
|
|
295
|
+
return cachedToken;
|
|
296
|
+
} finally {
|
|
297
|
+
inFlight = undefined;
|
|
298
|
+
}
|
|
299
|
+
})();
|
|
300
|
+
}
|
|
301
|
+
return inFlight;
|
|
302
|
+
};
|
|
303
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
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';
|
|
13
|
+
export {
|
|
14
|
+
clientCredentialsTokenProvider,
|
|
15
|
+
refreshTokenProvider,
|
|
16
|
+
externalJwtTokenProvider,
|
|
17
|
+
type ClientCredentialsTokenProviderOptions,
|
|
18
|
+
type RefreshTokenProviderOptions,
|
|
19
|
+
type ExternalJwtTokenProviderOptions,
|
|
20
|
+
} from './auth/token-providers.js';
|
|
3
21
|
export { attributeFQNsAsValues } from './policy/api.js';
|
|
22
|
+
export {
|
|
23
|
+
forEmail,
|
|
24
|
+
forClientId,
|
|
25
|
+
forUserName,
|
|
26
|
+
forToken,
|
|
27
|
+
withRequestToken,
|
|
28
|
+
} from './platform/authorization/entity-identifiers.js';
|
|
4
29
|
export {
|
|
5
30
|
listAttributes,
|
|
6
31
|
validateAttributes,
|