Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/roundtrip/config-demo-idp.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,17 @@ kcadm.sh create clients -r opentdf \
-s serviceAccountsEnabled=false \
-s publicClient=true \
-s protocol=openid-connect \
-s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]'
-s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' \
-s 'attributes={"dpop.bound.access.tokens":"true"}'

kcadm.sh create clients -r opentdf \
-s clientId=testclient \
-s secret=secret \
-s enabled=true \
-s standardFlowEnabled=true \
-s serviceAccountsEnabled=true \
-s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]'
-s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' \
-s 'attributes={"dpop.bound.access.tokens":"true"}'

kcadm.sh create users -r opentdf -s username=user1 -s enabled=true -s firstName=Alice -s lastName=User
kcadm.sh set-password -r opentdf --username user1 --new-password testuser123
3 changes: 3 additions & 0 deletions .github/workflows/roundtrip/encrypt-decrypt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ _tdf3_test() {
--ignoreAllowList \
--oidcEndpoint http://localhost:65432/auth/realms/opentdf \
--auth testclient:secret \
--dpop \
--output sample.txt.tdf \
encrypt "${plain}" \
--containerType tdf3 \
Expand All @@ -28,6 +29,7 @@ _tdf3_test() {
--ignoreAllowList \
--oidcEndpoint http://localhost:65432/auth/realms/opentdf \
--auth testclient:secret \
--dpop \
--output sample_out.txt \
--containerType tdf3 \
decrypt sample.txt.tdf
Expand All @@ -50,6 +52,7 @@ _tdf3_inspect_test() {
--ignoreAllowList \
--oidcEndpoint http://localhost:65432/auth/realms/opentdf \
--auth testclient:secret \
--dpop \
--output sample-with-attrs.txt.tdf \
--attributes 'https://attr.io/attr/a/value/1,https://attr.io/attr/x/value/2' \
encrypt "${plain}" \
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/roundtrip/keycloak_data.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ realms:
serviceAccountsEnabled: true
clientAuthenticatorType: client-secret
secret: secret
attributes:
dpop.bound.access.tokens: "true"
protocolMappers:
- *customAudMapper
sa_realm_roles:
sa_realm_roles:
- opentdf-standard
- client:
clientID: tdf-entity-resolution
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/roundtrip/opentdf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ server:
public_client_id: 'opentdf-public'
audience: 'http://localhost:65432'
issuer: http://localhost:65432/auth/realms/opentdf
dpop:
# Make KAS answer the first DPoP-proofed request with 401 + DPoP-Nonce so
# the roundtrip actually walks the server-issued nonce retry, not just
# plain proof-of-possession. xtest can't cover this: its nonce cases only
# run when the shared `dpop-challenge` input is on, which also swaps in a
# platform config other SDKs aren't ready for. Left off, this PR's headline
# feature would ship with no CI coverage at all.
# Only `enforce` would reject bearer tokens outright; that stays off, so
# the non-DPoP paths in this job are unaffected.
require_nonce: true
policy:
## Dot notation is used to access nested claims (i.e. realm_access.roles)
# Claim that represents the user (i.e. email)
Expand Down
6 changes: 6 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ async function processAuth(
const requestLog: AuthProviders.HttpRequest[] = [];
return {
requestLog,
// Forward the wrapped provider's per-client DPoP-Nonce cache. Without this,
// the auth interceptor/transport fall back to the shared default cache while
// `withCreds` (delegated below) mints proofs from the wrapped provider's own
// cache — the two diverge and the DPoP-Nonce challenge retry never carries
// the server nonce (RFC 9449 §9).
nonceCache: actual.nonceCache,
updateClientPublicKey: async (signingKey: KeyPair) => {
actual.updateClientPublicKey(signingKey);
log('DEBUG', `updateClientPublicKey: [${signingKey?.publicKey}]`);
Expand Down
40 changes: 23 additions & 17 deletions lib/src/access.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { Code, ConnectError } from '@connectrpc/connect';
import { type AuthConfig, resolveAuthConfig } from './auth/interceptors.js';
import { RewrapResponse } from './platform/kas/kas_pb.js';
import {
extractRpcErrorMessage,
getPlatformUrlFromKasEndpoint,
validateSecureUrl,
} from './utils.js';
import { getPlatformUrlFromKasEndpoint, validateSecureUrl } from './utils.js';
import { base64 } from './encodings/index.js';
import {
KEY_ALGORITHMS,
Expand Down Expand Up @@ -52,13 +48,16 @@ export async function fetchWrappedKey(
fulfillableObligationFQNs: string[]
): Promise<RewrapResponse> {
const platformUrl = getPlatformUrlFromKasEndpoint(url);
const { interceptors, authProvider } = resolveAuthConfig(auth);
const { authProvider } = resolveAuthConfig(auth);

// Pass the original AuthConfig (not just its interceptors) so the RPC layer can
// recover the provider's per-client DPoP nonce cache and keep the transport's
// nonce capture and the interceptor's retry on the same instance (RFC 9449 §9).
const rpcCall = () =>
fetchWrappedKeysRpc(
platformUrl,
signedRequestToken,
{ interceptors },
auth,
rewrapAdditionalContextHeader(fulfillableObligationFQNs)
);

Expand All @@ -70,9 +69,9 @@ export async function fetchWrappedKey(

// Try the modern Connect-RPC rewrap first, falling back to the legacy REST
// rewrap only for non-auth failures (older, non-Connect platforms). A
// definitive KAS auth/validation answer (401/403/400) surfaces as-is via
// tryRpcThenLegacy rather than being masked by the legacy 404 on
// Connect-only platforms.
// definitive KAS auth/validation answer (401/403/400 — incl. a post-nonce-
// challenge 401, RFC 9449 §9) surfaces as-is via tryRpcThenLegacy rather than
// being masked by the legacy 404 on Connect-only platforms.
// We intentionally omit the rewrap additional context from legacy requests:
// platforms new enough to know about obligations handle RPC successfully.
return await tryRpcThenLegacy(
Expand Down Expand Up @@ -207,9 +206,11 @@ export async function fetchKeyAccessServers(
platformUrl: string,
auth: AuthConfig
): Promise<OriginAllowList> {
const { interceptors, authProvider } = resolveAuthConfig(auth);
const { authProvider } = resolveAuthConfig(auth);

const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, { interceptors });
// Pass the original AuthConfig so the RPC layer shares the provider's per-client
// DPoP nonce cache with the transport (see fetchWrappedKey).
const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, auth);

if (!authProvider) {
return await rpcCall();
Expand Down Expand Up @@ -248,7 +249,7 @@ export async function fetchKasPubKey(
} catch (e) {
// Base key is optional; fall back to the RPC/legacy public-key path. Log a
// one-line summary via errBrief (never the raw error object, which for Connect
// errors can carry response metadata).
// errors can carry response metadata including DPoP nonces).
console.log(`base key fetch failed, falling back to RPC/legacy public key: ${errBrief(e)}`);
}

Expand Down Expand Up @@ -327,10 +328,15 @@ async function tryRpcThenLegacy<T>(

/**
* A log-safe one-line summary of an error: its message (and Connect code), never
* the whole error object — Connect errors can carry response headers and
* metadata that should not be dumped to the console.
* the whole error object — Connect errors can carry response headers/metadata
* (including DPoP nonces) that should not be dumped to logs on the auth path.
*/
function errBrief(e: unknown): string {
const message = extractRpcErrorMessage(e);
return e instanceof ConnectError ? `${Code[e.code]}: ${message}` : message;
if (e instanceof ConnectError) {
return `${Code[e.code]}: ${e.message}`;
}
if (e instanceof Error) {
return e.message;
}
return String(e);
}
127 changes: 81 additions & 46 deletions lib/src/access/access-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../access.js';
import { type AuthProvider } from '../auth/auth.js';
import { type AuthProvider, type HttpRequest } from '../auth/auth.js';
import { defaultNonceCache, sendWithNonceRetry, toOrigin } from '../auth/dpop-nonce.js';
import {
ConfigurationError,
InvalidFileError,
Expand All @@ -10,6 +11,53 @@ import {
} from '../errors.js';
import { validateSecureUrl } from '../utils.js';

/** fetch() options shared by the authenticated legacy requests. */
type FetchInit = Omit<RequestInit, 'method' | 'headers' | 'body'>;

/**
* Signs `httpReq` via the AuthProvider, sends it, and handles a single
* DPoP-Nonce challenge (RFC 9449 §9): if a resource server rejects the request
* with a fresh `DPoP-Nonce` header, cache the nonce and retry once so
* `withCreds` can mint a proof carrying it. Non-DPoP providers and servers
* never emit a `DPoP-Nonce`, so they take the single-request path unchanged.
*
* The caller keeps ownership of status-code handling; this only owns transport
* and the nonce retry.
*/
async function fetchWithCredsAndNonceRetry(
authProvider: AuthProvider,
httpReq: HttpRequest,
init: FetchInit,
networkErrorMessage: string
): Promise<Response> {
const send = async (): Promise<Response> => {
const req = await authProvider.withCreds(httpReq);
try {
return await fetch(req.url, {
...init,
method: req.method,
headers: req.headers,
body: req.body as BodyInit,
});
} catch (e) {
throw new NetworkError(`${networkErrorMessage} [${req.url}]`, e);
}
};

// Non-absolute URLs have no origin; nonce caching is origin-keyed, so those pass through.
const origin = toOrigin(httpReq.url);
if (!origin) {
return send();
}

// Use the provider's per-client cache so the retry proof carries the nonce
// withCreds reads back (falls back to the shared default for custom providers).
// `send` re-signs through withCreds, which reads the refreshed nonce from that
// cache itself, so the nonce argument is unused here.
const nonceCache = authProvider.nonceCache ?? defaultNonceCache;
return sendWithNonceRetry(nonceCache, origin, 'legacy fetch', () => send());
}

export type RewrapRequest = {
signedRequestToken: string;
};
Expand All @@ -33,53 +81,43 @@ export async function fetchWrappedKey(
requestBody: RewrapRequest,
authProvider: AuthProvider
): Promise<RewrapResponseLegacy> {
const req = await authProvider.withCreds({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json',
const response = await fetchWithCredsAndNonceRetry(
authProvider,
{
url,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
body: JSON.stringify(requestBody),
});

let response: Response;

try {
response = await fetch(req.url, {
method: req.method,
{
mode: 'cors', // no-cors, *cors, same-origin
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include, *same-origin, omit
headers: req.headers,
redirect: 'follow', // manual, *follow, error
referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
body: req.body as BodyInit,
});
} catch (e) {
throw new NetworkError(`unable to fetch wrapped key from [${url}]`, e);
}
},
'unable to fetch wrapped key from'
);

if (!response.ok) {
switch (response.status) {
case 400:
throw new InvalidFileError(
`400 for [${req.url}]: rewrap bad request [${await response.text()}]`
`400 for [${url}]: rewrap bad request [${await response.text()}]`
);
case 401:
throw new UnauthenticatedError(`401 for [${req.url}]; rewrap auth failure`);
throw new UnauthenticatedError(`401 for [${url}]; rewrap auth failure`);
case 403:
throw new PermissionDeniedError(
`403 for [${req.url}]; rewrap permission denied: forbidden`
);
throw new PermissionDeniedError(`403 for [${url}]; rewrap permission denied: forbidden`);
default:
if (response.status >= 500) {
throw new ServiceError(
`${response.status} for [${req.url}]: rewrap failure due to service error [${await response.text()}]`
`${response.status} for [${url}]: rewrap failure due to service error [${await response.text()}]`
);
}
throw new NetworkError(
`${req.method} ${req.url} => ${response.status} ${response.statusText}`
);
throw new NetworkError(`POST ${url} => ${response.status} ${response.statusText}`);
}
}

Expand All @@ -93,32 +131,29 @@ export async function fetchKeyAccessServers(
let nextOffset = 0;
const allServers = [];
do {
const req = await authProvider.withCreds({
url: `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`,
method: 'GET',
headers: {
'Content-Type': 'application/json',
const requestUrl = `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`;
const response = await fetchWithCredsAndNonceRetry(
authProvider,
{
url: requestUrl,
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
},
});
let response: Response;
try {
response = await fetch(req.url, {
method: req.method,
headers: req.headers,
body: req.body as BodyInit,
{
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
} catch (e) {
throw new NetworkError(`unable to fetch kas list from [${req.url}]`, e);
}
},
'unable to fetch kas list from'
);
// if we get an error from the kas registry, throw an error
if (!response.ok) {
throw new ServiceError(
`unable to fetch kas list from [${req.url}], status: ${response.status}`
`unable to fetch kas list from [${requestUrl}], status: ${response.status}`
);
}
const { keyAccessServers = [], pagination = {} } = await response.json();
Expand Down
18 changes: 16 additions & 2 deletions lib/src/access/access-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '../access.js';

import { type AuthConfig, resolveInterceptors } from '../auth/interceptors.js';
import { isAuthProvider } from '../auth/auth.js';
import {
ConfigurationError,
InvalidFileError,
Expand Down Expand Up @@ -40,7 +41,14 @@ export async function fetchWrappedKey(
rewrapAdditionalContextHeader?: string
): Promise<RewrapResponse> {
const platformUrl = getPlatformUrlFromKasEndpoint(url);
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
// Share the provider's per-client nonce cache so the transport's nonce capture
// and the auth interceptor's retry read the same instance (RFC 9449 §9).
const nonceCache = isAuthProvider(auth) ? auth.nonceCache : undefined;
const platform = new PlatformClient({
interceptors: resolveInterceptors(auth),
platformUrl,
nonceCache,
});
const options: CallOptions = {};
if (rewrapAdditionalContextHeader) {
options.headers = {
Expand Down Expand Up @@ -127,7 +135,13 @@ export async function fetchKeyAccessServers(
): Promise<OriginAllowList> {
let nextOffset = 0;
const allServers = [];
const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl });
// Share the provider's per-client nonce cache (see fetchWrappedKey above).
const nonceCache = isAuthProvider(auth) ? auth.nonceCache : undefined;
const platform = new PlatformClient({
interceptors: resolveInterceptors(auth),
platformUrl,
nonceCache,
});

do {
let response: ListKeyAccessServersResponse;
Expand Down
Loading