Skip to content

Commit

Permalink
feat: add debug flag for dumping OIDC tokens to output MONGOSH-1845 (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
addaleax authored Aug 1, 2024
1 parent 975dd26 commit 781d1c9
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 10 deletions.
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/arg-parser/src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@ export interface CliOptions {
oidcRedirectUri?: string;
oidcTrustedEndpoint?: boolean;
oidcIdTokenAsAccessToken?: boolean;
oidcDumpTokens?: boolean | 'redacted' | 'include-secrets';
browser?: string | false;
}
12 changes: 12 additions & 0 deletions packages/cli-repl/src/arg-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const OPTIONS = {
'build-info': 'buildInfo',
json: 'json', // List explicitly here since it can be a boolean or a string
browser: 'browser', // ditto
oidcDumpTokens: 'oidcDumpTokens', // ditto
oidcRedirectUrl: 'oidcRedirectUri', // I'd get this wrong about 50% of the time
oidcIDTokenAsAccessToken: 'oidcIdTokenAsAccessToken', // ditto
},
Expand Down Expand Up @@ -215,6 +216,17 @@ export function verifyCliArguments(args: any /* CliOptions */): string[] {
);
}

if (
![undefined, true, false, 'redacted', 'include-secrets'].includes(
args.oidcDumpTokens
)
) {
throw new MongoshUnimplementedError(
'--oidcDumpTokens can only have the values redacted or include-secrets',
CommonErrors.InvalidArgument
);
}

const messages = [];
for (const deprecated in DEPRECATED_ARGS_WITH_REPLACEMENT) {
if (deprecated in args) {
Expand Down
99 changes: 99 additions & 0 deletions packages/cli-repl/src/cli-repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import path from 'path';
import { getOsInfo } from './get-os-info';
import { UpdateNotificationManager } from './update-notification-manager';
import { getTimingData, markTime, summariseTimingData } from './startup-timing';
import type { IdPInfo } from 'mongodb';

/**
* Connecting text key.
Expand Down Expand Up @@ -204,6 +205,8 @@ export class CliRepl implements MongoshIOProvider {
bus: this.bus,
ioProvider: this,
});

this.setupOIDCTokenDumpListener();
}

async getIsContainerizedEnvironment() {
Expand Down Expand Up @@ -1254,4 +1257,100 @@ export class CliRepl implements MongoshIOProvider {
version
);
}

private setupOIDCTokenDumpListener() {
function tryParseJWT(
token: string | null | undefined,
redact: 'redact' | 'include-secrets'
): unknown {
if (!token) return token;
const jwtParts = token.split('.');
if (
// If this is a three-part token consisting of valid base64url-encoded
// parts (without trailing `=`), assume that it is a JWT access/id token.
jwtParts.length === 3 &&
jwtParts.every(
(part) =>
Buffer.from(part, 'base64url')
.toString('base64url')
.replace(/=+$/, '') === part.replace(/=+$/, '')
)
) {
const [header, payload] = jwtParts.map((part) => {
try {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
} catch {
// Not a valid JWT in this case.
}
});
if (redact === 'include-secrets') {
return { header, payload, signature: jwtParts[2] };
}
if (header && payload) {
return { header, payload };
}
}
return redact === 'include-secrets' ? token : '<non-JWT token>';
}

let lastServerIdPInfo: IdPInfo | undefined;
const { oidcDumpTokens } = this.cliOptions;
if (oidcDumpTokens) {
this.bus.on(
'mongodb-oidc-plugin:received-server-params',
({ params: { idpInfo } }) => {
lastServerIdPInfo = idpInfo;
}
);
this.bus.on(
'mongodb-oidc-plugin:auth-succeeded',
({
tokenType,
refreshToken, // only an identifier, not the actual token
expiresAt,
passIdTokenAsAccessToken,
tokens: { accessToken: at, refreshToken: rt, idToken: idt },
}) => {
const printable = {
lastServerIdPInfo: lastServerIdPInfo && {
issuer: lastServerIdPInfo?.issuer,
clientId: lastServerIdPInfo?.clientId,
requestScopes: lastServerIdPInfo?.requestScopes,
},
tokenType,
refreshToken,
expiresAt,
passIdTokenAsAccessToken,
tokens:
oidcDumpTokens === 'include-secrets'
? {
accessToken: tryParseJWT(at, 'include-secrets'),
refreshToken: tryParseJWT(rt, 'include-secrets'),
idToken: tryParseJWT(idt, 'include-secrets'),
}
: {
accessToken: tryParseJWT(at, 'redact'),
idToken: tryParseJWT(idt, 'redact'),
},
};

this.output.write(
'\n' +
this.clr(
'----- BEGIN OIDC TOKEN DUMP -----',
'mongosh:section-header'
) +
'\n' +
JSON.stringify(printable, null, 2) +
'\n' +
this.clr(
'----- END OIDC TOKEN DUMP -----',
'mongosh:section-header'
) +
'\n'
);
}
);
}
}
}
24 changes: 24 additions & 0 deletions packages/cli-repl/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,30 @@ export const USAGE = `
'cli-repl.args.kmsURL'
)}
oidcFlows?: string;
oidcRedirectUri?: string;
oidcTrustedEndpoint?: boolean;
oidcIdTokenAsAccessToken?: boolean;
oidcDumpTokens?: boolean | 'redacted' | 'include-secrets';
${clr(i18n.__('cli-repl.args.oidcOptions'), 'mongosh:section-header')}
--oidcFlows[=auth-code,device-auth] ${i18n.__(
'cli-repl.args.oidcFlows'
)}
--oidcRedirectUri[=url] ${i18n.__(
'cli-repl.args.oidcRedirectUri'
)}
--oidcTrustedEndpoint ${i18n.__(
'cli-repl.args.oidcTrustedEndpoint'
)}
--oidcIdTokenAsAccessToken ${i18n.__(
'cli-repl.args.oidcIdTokenAsAccessToken'
)}
--oidcDumpTokens[=mode] ${i18n.__(
'cli-repl.args.oidcDumpTokens'
)}
${clr(i18n.__('cli-repl.args.dbAddressOptions'), 'mongosh:section-header')}
foo ${i18n.__(
Expand Down
42 changes: 42 additions & 0 deletions packages/e2e-tests/test/e2e-oidc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,4 +478,46 @@ describe('OIDC auth e2e', function () {
await verifyUser(shell, 'testuser-id', 'testuser-id-group');
shell.assertNoErrors();
});

it('can print tokens as debug information if requested', async function () {
shell = TestShell.start({
args: [
await testServer.connectionString(),
'--authenticationMechanism=MONGODB-OIDC',
'--oidcRedirectUri=http://localhost:0/',
'--oidcDumpTokens',
`--browser=${fetchBrowserFixture}`,
'--eval=42',
],
});
await shell.waitForExit();

shell.assertContainsOutput('BEGIN OIDC TOKEN DUMP');
shell.assertContainsOutput('"tokenType": "Bearer"');
shell.assertContainsOutput('"alg": "RS256"');
shell.assertContainsOutput('"sub": "testuser"');
shell.assertNotContainsOutput('"signature":');
shell.assertContainsOutput('"lastServerIdPInfo":');
shell.assertNotContainsOutput(/"refreshToken": "(?!debugid:)/);

shell = TestShell.start({
args: [
await testServer.connectionString(),
'--authenticationMechanism=MONGODB-OIDC',
'--oidcRedirectUri=http://localhost:0/',
'--oidcDumpTokens=include-secrets',
`--browser=${fetchBrowserFixture}`,
'--eval=42',
],
});
await shell.waitForExit();

shell.assertContainsOutput('BEGIN OIDC TOKEN DUMP');
shell.assertContainsOutput('"tokenType": "Bearer"');
shell.assertContainsOutput('"alg": "RS256"');
shell.assertContainsOutput('"sub": "testuser"');
shell.assertContainsOutput('"signature":');
shell.assertContainsOutput('"lastServerIdPInfo":');
shell.assertContainsOutput(/"refreshToken": "(?!debugid:)/);
});
});
11 changes: 11 additions & 0 deletions packages/i18n/src/locales/en_US.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable filename-rules/match */
import type Catalog from '../catalog';

/**
Expand Down Expand Up @@ -82,6 +83,16 @@ const translations: Catalog = {
connectionExampleWithDatabase:
"Start mongosh using 'ships' database on specified connection string:",
moreInformation: 'For more information on usage:',
oidcOptions: 'OIDC auth options:',
oidcFlows: 'Supported OIDC auth flows',
oidcRedirectUri:
'Local auth code flow redirect URL [http://localhost:27097/redirect]',
oidcTrustedEndpoint:
'Treat the cluster/database mongosh as a trusted endpoint',
oidcIdTokenAsAccessToken:
'Use ID tokens in place of access tokens for auth',
oidcDumpTokens:
"Debug OIDC by printing tokens to mongosh's output [full|include-secrets]",
},
'arg-parser': {
'unknown-option': 'Error parsing command line: unrecognized option:',
Expand Down
2 changes: 1 addition & 1 deletion packages/service-provider-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
},
"dependencies": {
"@mongodb-js/devtools-connect": "^3.0.5",
"@mongodb-js/oidc-plugin": "^1.1.0",
"@mongodb-js/oidc-plugin": "^1.1.1",
"@mongosh/errors": "0.0.0-dev.0",
"@mongosh/service-provider-core": "0.0.0-dev.0",
"@mongosh/types": "0.0.0-dev.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ const DEFAULT_BASE_OPTIONS: OperationOptions = Object.freeze({

/**
* Pick properties of `uri` and `opts` that as a tuple that can be matched
* against the correspondiung tuple for another `uri` and `opts` configuration,
* against the corresponding tuple for another `uri` and `opts` configuration,
* and when they do, it is meaningful to share connection state between them.
*
* Currently, this is only used for OIDC. We don't need to make sure that the
Expand Down

0 comments on commit 781d1c9

Please sign in to comment.