Skip to content

Commit 6bef820

Browse files
fix: allow device auth flow without id_token MONGOSH-1669 (#187)
* fix: allow device auth flow without id_token MONGOSH-1669 * test: add integration test * Update src/log-hook.ts Co-authored-by: Anna Henningsen <[email protected]> * test: reuse mock provider * extend error message * bump oidc-mock-provider * cleanup * types * fix: catch both inconsistencies in id_token presence * test: update test * update comment * refactor: use single type for LastIdTokenClaims * fix: check * test --------- Co-authored-by: Anna Henningsen <[email protected]>
1 parent 14de2c8 commit 6bef820

File tree

6 files changed

+128
-40
lines changed

6 files changed

+128
-40
lines changed

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"@mongodb-js/eslint-config-devtools": "^0.9.9",
5858
"@mongodb-js/mocha-config-devtools": "^1.0.0",
5959
"@mongodb-js/monorepo-tools": "^1.1.4",
60-
"@mongodb-js/oidc-mock-provider": "^0.6.2",
60+
"@mongodb-js/oidc-mock-provider": "^0.7.1",
6161
"@mongodb-js/prettier-config-devtools": "^1.0.1",
6262
"@mongodb-js/tsconfig-devtools": "^1.0.0",
6363
"@types/chai": "^4.2.21",

src/integration.spec.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ import {
55
OIDCTestProvider,
66
functioningAuthCodeBrowserFlow,
77
} from '../test/oidc-test-provider';
8+
import type {
9+
OIDCMockProviderConfig,
10+
TokenMetadata,
11+
} from '@mongodb-js/oidc-mock-provider';
812
import { MongoClient } from 'mongodb';
913
import type { OpenBrowserOptions } from './';
1014
import { createMongoDBOIDCPlugin } from './';
1115
import { OIDCMockProvider } from '@mongodb-js/oidc-mock-provider';
1216
import { MongoCluster } from 'mongodb-runner';
1317
import path from 'path';
18+
import sinon from 'sinon';
1419

1520
// node-fetch@3 is ESM-only...
1621
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -120,6 +125,16 @@ describe('integration test with mongod', function () {
120125
context('can authenticate with a mock IdP', function () {
121126
let provider: OIDCMockProvider;
122127
let connectionString: string;
128+
let getTokenPayload: OIDCMockProviderConfig['getTokenPayload'];
129+
const tokenPayload = {
130+
expires_in: 3600,
131+
payload: {
132+
// Define the user information stored inside the access tokens
133+
groups: ['testgroup'],
134+
sub: 'testuser',
135+
aud: 'resource-server-audience-value',
136+
},
137+
};
123138

124139
before(async function () {
125140
if (+process.version.slice(1).split('.')[0] < 16) {
@@ -128,16 +143,8 @@ describe('integration test with mongod', function () {
128143
return this.skip();
129144
}
130145
provider = await OIDCMockProvider.create({
131-
getTokenPayload() {
132-
return {
133-
expires_in: 3600,
134-
payload: {
135-
// Define the user information stored inside the access tokens
136-
groups: ['testgroup'],
137-
sub: 'testuser',
138-
aud: 'resource-server-audience-value',
139-
},
140-
};
146+
getTokenPayload(metadata: TokenMetadata) {
147+
return getTokenPayload(metadata);
141148
},
142149
});
143150

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

170+
beforeEach(function () {
171+
getTokenPayload = () => tokenPayload;
172+
});
173+
163174
it('can successfully authenticate with a fake Auth Code Flow', async function () {
164175
const plugin = createMongoDBOIDCPlugin({
165176
openBrowserTimeout: 60_000,
@@ -234,5 +245,41 @@ describe('integration test with mongod', function () {
234245
await client.close();
235246
}
236247
});
248+
249+
it('can successfully authenticate with a fake Device Auth Flow without an id_token - with a warning', async function () {
250+
getTokenPayload = () => ({
251+
...tokenPayload,
252+
// id_token will not be included
253+
skipIdToken: true,
254+
});
255+
256+
const { mongoClientOptions, logger } = createMongoDBOIDCPlugin({
257+
notifyDeviceFlow: () => {},
258+
allowedFlows: ['device-auth'],
259+
});
260+
const logEmitSpy = sinon.spy(logger, 'emit');
261+
const client = await MongoClient.connect(connectionString, {
262+
...mongoClientOptions,
263+
});
264+
// without the id token, a warning will be logged
265+
sinon.assert.calledWith(
266+
logEmitSpy,
267+
'mongodb-oidc-plugin:missing-id-token'
268+
);
269+
try {
270+
const status = await client
271+
.db('admin')
272+
.command({ connectionStatus: 1 });
273+
expect(status).to.deep.equal({
274+
ok: 1,
275+
authInfo: {
276+
authenticatedUsers: [{ user: 'dev/testuser', db: '$external' }],
277+
authenticatedUserRoles: [{ role: 'dev/testgroup', db: 'admin' }],
278+
},
279+
});
280+
} finally {
281+
await client.close();
282+
}
283+
});
237284
});
238285
});

src/log-hook.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,4 +260,13 @@ export function hookLoggerToMongoLogWriter(
260260
'Destroyed OIDC plugin instance'
261261
);
262262
});
263+
264+
emitter.on('mongodb-oidc-plugin:missing-id-token', () => {
265+
log.warn(
266+
'OIDC-PLUGIN',
267+
mongoLogId(1_002_000_022),
268+
`${contextPrefix}-oidc`,
269+
'Missing ID token in IdP response'
270+
);
271+
});
263272
}

src/plugin.ts

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ import { kDefaultOpenBrowserTimeout } from './api';
3636
import { spawn } from 'child_process';
3737

3838
/** @internal */
39+
40+
// The `sub` and `aud` claims in the ID token of the last-received
41+
// TokenSet, if any.
42+
// 'no-id-token' means that the previous token set contained no ID token
43+
type LastIdTokenClaims =
44+
| (Pick<IdTokenClaims, 'aud' | 'sub'> & { noIdToken?: never })
45+
| { noIdToken: true };
46+
3947
interface UserOIDCAuthState {
4048
// The information that the driver forwarded to us from the server
4149
// about the OIDC Identity Provider config.
@@ -52,9 +60,7 @@ interface UserOIDCAuthState {
5260
// A timer attached to this state that automatically calls
5361
// currentTokenSet.tryRefresh() before the token expires.
5462
timer?: ReturnType<typeof setTimeout>;
55-
// The `sub` and `aud` claims in the ID token of the last-received
56-
// TokenSet, if any.
57-
lastIdTokenClaims?: Pick<IdTokenClaims, 'aud' | 'sub'>;
63+
lastIdTokenClaims?: LastIdTokenClaims;
5864
// A cached Client instance that uses the issuer metadata as discovered
5965
// through serverOIDCMetadata.
6066
client?: Client;
@@ -201,7 +207,7 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin {
201207
}
202208

203209
for (const [key, serializedState] of original.state) {
204-
const state = {
210+
const state: UserOIDCAuthState = {
205211
serverOIDCMetadata: { ...serializedState.serverOIDCMetadata },
206212
currentAuthAttempt: null,
207213
currentTokenSet: null,
@@ -433,28 +439,53 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin {
433439
// for client A, the token expires before it is requested again by client A,
434440
// then the plugin is passed to client B which requests a token, and we
435441
// receive mismatching tokens for different users or different audiences.
436-
const idTokenClaims = tokenSet.claims();
437-
if (state.lastIdTokenClaims) {
438-
for (const claim of ['aud', 'sub'] as const) {
439-
const normalize = (value: string | string[]): string => {
440-
return JSON.stringify(
441-
Array.isArray(value) ? [...value].sort() : value
442-
);
443-
};
444-
const knownClaim = normalize(state.lastIdTokenClaims[claim]);
445-
const newClaim = normalize(idTokenClaims[claim]);
442+
if (
443+
!tokenSet.id_token &&
444+
state.lastIdTokenClaims &&
445+
!state.lastIdTokenClaims.noIdToken
446+
) {
447+
throw new MongoDBOIDCError(
448+
`ID token expected, but not found. Expected claims: ${JSON.stringify(
449+
state.lastIdTokenClaims
450+
)}`
451+
);
452+
}
446453

447-
if (knownClaim !== newClaim) {
448-
throw new MongoDBOIDCError(
449-
`Unexpected '${claim}' field in id token: Expected ${knownClaim}, saw ${newClaim}`
450-
);
454+
if (
455+
tokenSet.id_token &&
456+
state.lastIdTokenClaims &&
457+
state.lastIdTokenClaims.noIdToken
458+
) {
459+
throw new MongoDBOIDCError(`Unexpected ID token received.`);
460+
}
461+
462+
if (tokenSet.id_token) {
463+
const idTokenClaims = tokenSet.claims();
464+
if (state.lastIdTokenClaims && !state.lastIdTokenClaims.noIdToken) {
465+
for (const claim of ['aud', 'sub'] as const) {
466+
const normalize = (value: string | string[]): string => {
467+
return JSON.stringify(
468+
Array.isArray(value) ? [...value].sort() : value
469+
);
470+
};
471+
const knownClaim = normalize(state.lastIdTokenClaims[claim]);
472+
const newClaim = normalize(idTokenClaims[claim]);
473+
474+
if (knownClaim !== newClaim) {
475+
throw new MongoDBOIDCError(
476+
`Unexpected '${claim}' field in id token: Expected ${knownClaim}, saw ${newClaim}`
477+
);
478+
}
451479
}
452480
}
481+
state.lastIdTokenClaims = {
482+
aud: idTokenClaims.aud,
483+
sub: idTokenClaims.sub,
484+
};
485+
} else {
486+
state.lastIdTokenClaims = { noIdToken: true };
487+
this.logger.emit('mongodb-oidc-plugin:missing-id-token');
453488
}
454-
state.lastIdTokenClaims = {
455-
aud: idTokenClaims.aud,
456-
sub: idTokenClaims.sub,
457-
};
458489

459490
const timerDuration = automaticRefreshTimeoutMS(tokenSet);
460491
// Use `.call()` because in browsers, `setTimeout()` requires that it is called

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export interface MongoDBOIDCLogEventsMap {
4646
expiresAt: string | null;
4747
}) => void;
4848
'mongodb-oidc-plugin:destroyed': () => void;
49+
'mongodb-oidc-plugin:missing-id-token': () => void;
4950
}
5051

5152
/** @public */

0 commit comments

Comments
 (0)