Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: allow device auth flow without id_token MONGOSH-1669 #187

Merged
merged 14 commits into from
Feb 7, 2024
14 changes: 7 additions & 7 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"@mongodb-js/eslint-config-devtools": "^0.9.9",
"@mongodb-js/mocha-config-devtools": "^1.0.0",
"@mongodb-js/monorepo-tools": "^1.1.4",
"@mongodb-js/oidc-mock-provider": "^0.6.2",
"@mongodb-js/oidc-mock-provider": "^0.7.1",
"@mongodb-js/prettier-config-devtools": "^1.0.1",
"@mongodb-js/tsconfig-devtools": "^1.0.0",
"@types/chai": "^4.2.21",
Expand Down
67 changes: 57 additions & 10 deletions src/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import {
OIDCTestProvider,
functioningAuthCodeBrowserFlow,
} from '../test/oidc-test-provider';
import type {
OIDCMockProviderConfig,
TokenMetadata,
} from '@mongodb-js/oidc-mock-provider';
import { MongoClient } from 'mongodb';
import type { OpenBrowserOptions } from './';
import { createMongoDBOIDCPlugin } from './';
import { OIDCMockProvider } from '@mongodb-js/oidc-mock-provider';
import { MongoCluster } from 'mongodb-runner';
import path from 'path';
import sinon from 'sinon';

// node-fetch@3 is ESM-only...
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
Expand Down Expand Up @@ -120,6 +125,16 @@ describe('integration test with mongod', function () {
context('can authenticate with a mock IdP', function () {
let provider: OIDCMockProvider;
let connectionString: string;
let getTokenPayload: OIDCMockProviderConfig['getTokenPayload'];
const tokenPayload = {
expires_in: 3600,
payload: {
// Define the user information stored inside the access tokens
groups: ['testgroup'],
sub: 'testuser',
aud: 'resource-server-audience-value',
},
};

before(async function () {
if (+process.version.slice(1).split('.')[0] < 16) {
Expand All @@ -128,16 +143,8 @@ describe('integration test with mongod', function () {
return this.skip();
}
provider = await OIDCMockProvider.create({
getTokenPayload() {
return {
expires_in: 3600,
payload: {
// Define the user information stored inside the access tokens
groups: ['testgroup'],
sub: 'testuser',
aud: 'resource-server-audience-value',
},
};
getTokenPayload(metadata: TokenMetadata) {
return getTokenPayload(metadata);
},
});

Expand All @@ -160,6 +167,10 @@ describe('integration test with mongod', function () {
await Promise.all([cluster?.close?.(), provider?.close?.()]);
});

beforeEach(function () {
getTokenPayload = () => tokenPayload;
});

it('can successfully authenticate with a fake Auth Code Flow', async function () {
const plugin = createMongoDBOIDCPlugin({
openBrowserTimeout: 60_000,
Expand Down Expand Up @@ -234,5 +245,41 @@ describe('integration test with mongod', function () {
await client.close();
}
});

it('can successfully authenticate with a fake Device Auth Flow without an id_token - with a warning', async function () {
getTokenPayload = () => ({
...tokenPayload,
// id_token will not be included
skipIdToken: true,
});

const { mongoClientOptions, logger } = createMongoDBOIDCPlugin({
notifyDeviceFlow: () => {},
allowedFlows: ['device-auth'],
});
const logEmitSpy = sinon.spy(logger, 'emit');
const client = await MongoClient.connect(connectionString, {
...mongoClientOptions,
});
// without the id token, a warning will be logged
sinon.assert.calledWith(
logEmitSpy,
'mongodb-oidc-plugin:missing-id-token'
);
try {
const status = await client
.db('admin')
.command({ connectionStatus: 1 });
expect(status).to.deep.equal({
ok: 1,
authInfo: {
authenticatedUsers: [{ user: 'dev/testuser', db: '$external' }],
authenticatedUserRoles: [{ role: 'dev/testgroup', db: 'admin' }],
},
});
} finally {
await client.close();
}
});
});
});
9 changes: 9 additions & 0 deletions src/log-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,13 @@ export function hookLoggerToMongoLogWriter(
'Destroyed OIDC plugin instance'
);
});

emitter.on('mongodb-oidc-plugin:missing-id-token', () => {
log.warn(
'OIDC-PLUGIN',
mongoLogId(1_002_000_022),
`${contextPrefix}-oidc`,
'Missing ID token in IdP response'
);
});
}
75 changes: 53 additions & 22 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ import { kDefaultOpenBrowserTimeout } from './api';
import { spawn } from 'child_process';

/** @internal */

// The `sub` and `aud` claims in the ID token of the last-received
// TokenSet, if any.
// 'no-id-token' means that the previous token set contained no ID token
type LastIdTokenClaims =
| (Pick<IdTokenClaims, 'aud' | 'sub'> & { noIdToken?: never })
| { noIdToken: true };

interface UserOIDCAuthState {
// The information that the driver forwarded to us from the server
// about the OIDC Identity Provider config.
Expand All @@ -52,9 +60,7 @@ interface UserOIDCAuthState {
// A timer attached to this state that automatically calls
// currentTokenSet.tryRefresh() before the token expires.
timer?: ReturnType<typeof setTimeout>;
// The `sub` and `aud` claims in the ID token of the last-received
// TokenSet, if any.
lastIdTokenClaims?: Pick<IdTokenClaims, 'aud' | 'sub'>;
lastIdTokenClaims?: LastIdTokenClaims;
// A cached Client instance that uses the issuer metadata as discovered
// through serverOIDCMetadata.
client?: Client;
Expand Down Expand Up @@ -201,7 +207,7 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin {
}

for (const [key, serializedState] of original.state) {
const state = {
const state: UserOIDCAuthState = {
serverOIDCMetadata: { ...serializedState.serverOIDCMetadata },
currentAuthAttempt: null,
currentTokenSet: null,
Expand Down Expand Up @@ -433,28 +439,53 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin {
// for client A, the token expires before it is requested again by client A,
// then the plugin is passed to client B which requests a token, and we
// receive mismatching tokens for different users or different audiences.
const idTokenClaims = tokenSet.claims();
if (state.lastIdTokenClaims) {
for (const claim of ['aud', 'sub'] as const) {
const normalize = (value: string | string[]): string => {
return JSON.stringify(
Array.isArray(value) ? [...value].sort() : value
);
};
const knownClaim = normalize(state.lastIdTokenClaims[claim]);
const newClaim = normalize(idTokenClaims[claim]);
if (
!tokenSet.id_token &&
state.lastIdTokenClaims &&
!state.lastIdTokenClaims.noIdToken
) {
throw new MongoDBOIDCError(
`ID token expected, but not found. Expected claims: ${JSON.stringify(
state.lastIdTokenClaims
)}`
);
}

if (knownClaim !== newClaim) {
throw new MongoDBOIDCError(
`Unexpected '${claim}' field in id token: Expected ${knownClaim}, saw ${newClaim}`
);
if (
tokenSet.id_token &&
state.lastIdTokenClaims &&
state.lastIdTokenClaims.noIdToken
) {
throw new MongoDBOIDCError(`Unexpected ID token received.`);
}

if (tokenSet.id_token) {
const idTokenClaims = tokenSet.claims();
if (state.lastIdTokenClaims && !state.lastIdTokenClaims.noIdToken) {
for (const claim of ['aud', 'sub'] as const) {
const normalize = (value: string | string[]): string => {
return JSON.stringify(
Array.isArray(value) ? [...value].sort() : value
);
};
const knownClaim = normalize(state.lastIdTokenClaims[claim]);
const newClaim = normalize(idTokenClaims[claim]);

if (knownClaim !== newClaim) {
throw new MongoDBOIDCError(
`Unexpected '${claim}' field in id token: Expected ${knownClaim}, saw ${newClaim}`
);
}
}
}
state.lastIdTokenClaims = {
aud: idTokenClaims.aud,
sub: idTokenClaims.sub,
};
} else {
state.lastIdTokenClaims = { noIdToken: true };
this.logger.emit('mongodb-oidc-plugin:missing-id-token');
}
state.lastIdTokenClaims = {
aud: idTokenClaims.aud,
sub: idTokenClaims.sub,
};

const timerDuration = automaticRefreshTimeoutMS(tokenSet);
// Use `.call()` because in browsers, `setTimeout()` requires that it is called
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface MongoDBOIDCLogEventsMap {
expiresAt: string | null;
}) => void;
'mongodb-oidc-plugin:destroyed': () => void;
'mongodb-oidc-plugin:missing-id-token': () => void;
}

/** @public */
Expand Down
Loading