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
76 changes: 58 additions & 18 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { CLIError, Level, log } from './logger.js';
import * as assertions from '@opentdf/sdk/assertions';
import { base64 } from '@opentdf/sdk/encodings';
import { type KeyPair } from '@opentdf/sdk/singlecontainer';
import { resolveDPoPFromArgs } from './dpop-helpers.js';

type AuthToProcess = {
auth?: string;
Expand Down Expand Up @@ -53,14 +54,10 @@ const parseJwtComplete = (jwt: string) => {
return { header: parseJwt(jwt, 0), payload: parseJwt(jwt) };
};

async function processAuth({
auth,
clientId,
clientSecret,
concurrencyLimit,
oidcEndpoint,
userId,
}: AuthToProcess): Promise<LoggedAuthProvider> {
async function processAuth(
{ auth, clientId, clientSecret, concurrencyLimit, oidcEndpoint, userId }: AuthToProcess,
dpopKeyPair?: KeyPair
): Promise<LoggedAuthProvider> {
log('DEBUG', 'Processing auth params');
if (!oidcEndpoint) {
throw new CLIError('CRITICAL', 'oidcEndpoint must be specified');
Expand All @@ -79,11 +76,19 @@ async function processAuth({
'Auth expects clientId and clientSecret, or combined auth param'
);
}
// Pass DPoP key into the provider config so the AccessToken is born with
// DPoP enabled (config.dpopEnabled + signingKey). Without this, the very
// first POST /token would go out without a DPoP proof — Keycloak clients
// with dpop_bound_access_tokens=true reject that with 400 invalid_request.
// Without a key, DPoP stays off so non-DPoP clients still get plain Bearer
// tokens that the platform will accept.
const actual = await AuthProviders.clientSecretAuthProvider({
clientId,
oidcOrigin: oidcEndpoint,
exchange: 'client',
clientSecret,
dpopEnabled: !!dpopKeyPair,
signingKey: dpopKeyPair,
});
if (concurrencyLimit !== 1) {
await actual.oidcAuth.get();
Expand Down Expand Up @@ -394,8 +399,14 @@ export const handleArgs = (args: string[]) => {
})
.option('dpop', {
group: 'Security:',
desc: 'Use DPoP for token binding',
type: 'boolean',
desc: 'Enable DPoP token binding. Optional value selects algorithm: ES256 (default), ES384, ES512, RS256. Use --dpop=ES512 to specify.',
type: 'string',
})
.option('dpopKey', {
alias: 'dpop-key',
group: 'Security:',
desc: 'Path to PEM-encoded PKCS8 private key for DPoP signing. Enables DPoP alone if --dpop is omitted.',
type: 'string',
})
.implies('auth', '--no-clientId')
.implies('auth', '--no-clientSecret')
Expand Down Expand Up @@ -513,6 +524,21 @@ export const handleArgs = (args: string[]) => {
description: 'output file',
})

.command(
'supports <feature>',
'Check if a feature is supported',
(yargs) => {
yargs.strict().positional('feature', {
describe: 'feature name to check',
type: 'string',
choices: ['dpop'],
});
},
async () => {
// yargs choices validation ensures feature is supported; return naturally exits 0
}
)

.command(
'inspect [file]',
'Inspect TDF and extract header information, without decrypting',
Expand Down Expand Up @@ -561,9 +587,11 @@ export const handleArgs = (args: string[]) => {
if (!argv.oidcEndpoint) {
throw new CLIError('CRITICAL', 'oidcEndpoint must be specified');
}
const authProvider = await processAuth(argv);
const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv);
const authProvider = await processAuth(argv, dpopKeyPair);
log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`);
const guessedPolicyEndpoint = guessPolicyUrl(argv);

const client = new OpenTDF({
authProvider,
defaultCreateOptions: {
Expand All @@ -574,7 +602,8 @@ export const handleArgs = (args: string[]) => {
ignoreAllowlist: ignoreAllowList,
noVerify: !!argv.noVerifyAssertions,
},
disableDPoP: !argv.dpop,
disableDPoP: !dpopEnabled,
dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined,
policyEndpoint: guessedPolicyEndpoint,
platformUrl: argv.platformUrl || guessedPolicyEndpoint,
});
Expand All @@ -600,14 +629,23 @@ export const handleArgs = (args: string[]) => {
console.assert(!accessToken, 'Multiple authorization headers found');
accessToken = parseJwt(lastRequest.headers[h].split(' ')[1]);
log('INFO', `Access Token: ${JSON.stringify(accessToken)}`);
if (argv.dpop) {
console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt');
if (dpopEnabled && !accessToken.cnf?.jkt) {
// A missing cnf.jkt means token binding silently didn't take
// effect; fail loudly rather than exit 0 with only a warning.
throw new CLIError(
'CRITICAL',
'DPoP requested but the access token is not bound (missing cnf.jkt)'
);
}
break;
}
}
console.assert(accessToken, 'No access_token found');
console.assert(!argv.dpop || dpopToken, 'DPoP requested but absent');
if (!accessToken) {
throw new CLIError('CRITICAL', 'No access_token found');
}
if (dpopEnabled && !dpopToken) {
throw new CLIError('CRITICAL', 'DPoP requested but no DPoP proof was sent');
}
} finally {
client.close();
}
Expand All @@ -624,7 +662,8 @@ export const handleArgs = (args: string[]) => {
},
async (argv) => {
log('DEBUG', 'Running encrypt command');
const authProvider = await processAuth(argv);
const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv);
const authProvider = await processAuth(argv, dpopKeyPair);
log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`);
const guessedPolicyEndpoint = guessPolicyUrl(argv);

Expand All @@ -633,7 +672,8 @@ export const handleArgs = (args: string[]) => {
defaultCreateOptions: {
defaultKASEndpoint: argv.kasEndpoint,
},
disableDPoP: !argv.dpop,
disableDPoP: !dpopEnabled,
dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined,
policyEndpoint: guessedPolicyEndpoint,
platformUrl: argv.platformUrl || guessedPolicyEndpoint,
});
Expand Down
Loading