Skip to content

Commit b94584b

Browse files
authored
Merge pull request #20106 from mozilla/FXA-13176
feat(auth): Disallow oauth exchange in non-Sync non-2FA unverified session state via config
2 parents 2878860 + f821f5a commit b94584b

9 files changed

Lines changed: 231 additions & 20 deletions

File tree

packages/fxa-auth-server/config/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,7 +1682,7 @@ const convictConf = convict({
16821682
},
16831683
signinConfirmation: {
16841684
forcedEmailAddresses: {
1685-
doc: 'Force sign-in confirmation for email addresses matching this regex for those that do not request scoped keys. Sets "mustVerify: 0" on created session tokens but creates an entry in unverifiedTokens, simulating an unverified session state',
1685+
doc: 'Force sign-in confirmation for email addresses matching this regex for those that do not request scoped keys. Sets "mustVerify: 0" on created session tokens but creates an entry in unverifiedTokens, simulating a non-Sync non-2FA unverified session state',
16861686
format: RegExp,
16871687
default: /.+@mozilla\.com$/,
16881688
env: 'SIGNIN_CONFIRMATION_FORCE_EMAIL_REGEX',
@@ -1695,7 +1695,7 @@ const convictConf = convict({
16951695
},
16961696
skipForNewAccounts: {
16971697
enabled: {
1698-
doc: 'Skip sign-in confirmation for newly-created accounts. Use this in tandem with forcedEmailAddresses',
1698+
doc: 'Skip all sign-in email confirmations for newly-created accounts',
16991699
default: true,
17001700
env: 'SIGNIN_CONFIRMATION_SKIP_FOR_NEW_ACCOUNTS',
17011701
},
@@ -1859,8 +1859,8 @@ const convictConf = convict({
18591859
env: 'SIGNIN_CODE_SIZE',
18601860
},
18611861
servicesWithEmailVerification: {
1862-
doc: 'Services that will alwasy send a session verification email on sign in',
1863-
default: ['e6eb0d1e856335fc'],
1862+
doc: 'For users in a non-2FA non-Sync unverified session state going through an RP redirect flow, we skip 1) redirecting the user to enter emailed code verification page, and we skip 2) sending that email verification. Services in this list will NOT skip the redirect or email, and are blocked in the BE from completing the oauth flow by hitting our API directly in this state. Content-server has this config as well.',
1863+
default: ['32aaeb6f1c21316a'],
18641864
format: Array,
18651865
env: 'SERVICES_WITH_EMAIL_VERIFICATION',
18661866
},

packages/fxa-auth-server/lib/routes/account.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,15 +1299,12 @@ export class AccountHandler {
12991299
// If the request wants keys , user *must* confirm their login session before they can actually
13001300
// use it. Otherwise, they don't *have* to verify their session. All sessions are created
13011301
// unverified because it prevents them from being used for sync.
1302-
// Also require verification if the service is in the servicesWithEmailVerification list.
13031302
let mustVerifySession =
13041303
needsVerificationId &&
13051304
(verificationForced === 'suspect' ||
13061305
verificationForced === 'global' ||
13071306
verificationForced === 'email' ||
1308-
requestHelper.wantsKeys(request) ||
1309-
(service &&
1310-
this.config.servicesWithEmailVerification.includes(service)));
1307+
requestHelper.wantsKeys(request));
13111308

13121309
// For accounts with TOTP, we always force verifying a session.
13131310
if (verificationMethod === 'totp-2fa') {
@@ -2382,16 +2379,20 @@ export class AccountHandler {
23822379
this.db.getRecoveryKeyRecordWithHint(uid),
23832380
Container.get(RecoveryPhoneService).hasConfirmed(uid),
23842381
request.app.geo?.location?.countryCode
2385-
? Container.get(RecoveryPhoneService).available(uid, request.app.geo.location.countryCode)
2382+
? Container.get(RecoveryPhoneService).available(
2383+
uid,
2384+
request.app.geo.location.countryCode
2385+
)
23862386
: Promise.resolve(false),
23872387
this.db.securityEventsByUid({ uid }),
23882388
this.db.devices(uid),
23892389
listAuthorizedClients(uid),
23902390
]);
23912391

2392-
const recoveryPhoneAvailable = recoveryPhoneAvailableResult.status === 'fulfilled'
2393-
? recoveryPhoneAvailableResult.value
2394-
: false;
2392+
const recoveryPhoneAvailable =
2393+
recoveryPhoneAvailableResult.status === 'fulfilled'
2394+
? recoveryPhoneAvailableResult.value
2395+
: false;
23952396

23962397
// Account record is required
23972398
if (accountRecord.status === 'rejected') {

packages/fxa-auth-server/lib/routes/oauth/authorization.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,20 @@ module.exports = ({ log, oauthDB, config }) => {
402402
handler: async function (req) {
403403
checkDisabledClientId(req.payload);
404404
const sessionToken = req.auth.credentials;
405+
406+
// For services that require email verification for non-2FA non-Sync
407+
// flows, reject sessions in this state. This is accounted for in the
408+
// front-end as well, but this guards in our BE. This purposefully
409+
// does NOT check session.mustVerify because users in this kind of
410+
// unverified session don't have that flag set.
411+
const clientId = req.payload.client_id;
412+
if (
413+
config.servicesWithEmailVerification.includes(clientId) &&
414+
!sessionToken.tokenVerified
415+
) {
416+
throw AuthError.unverifiedSession();
417+
}
418+
405419
req.payload.assertion = await makeAssertionJWT(config, sessionToken);
406420
const result = await authorizationHandler(req);
407421

@@ -415,8 +429,8 @@ module.exports = ({ log, oauthDB, config }) => {
415429
countryCode,
416430
deviceCount: devices.length,
417431
email,
418-
service: req.payload.client_id,
419-
clientId: req.payload.client_id,
432+
service: clientId,
433+
clientId,
420434
uid,
421435
userAgent: req.headers['user-agent'],
422436
});

packages/fxa-auth-server/test/local/routes/utils/signin.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ describe('sendSigninNotifications', () => {
809809
};
810810
config = {
811811
otp: otpOptions,
812-
servicesWithEmailVerification: ['e6eb0d1e856335fc'],
812+
servicesWithEmailVerification: ['32aaeb6f1c21316a'],
813813
};
814814

815815
sendSigninNotifications = makeSigninUtils({
@@ -1346,8 +1346,8 @@ describe('sendSigninNotifications', () => {
13461346
});
13471347
});
13481348

1349-
it('sends verification email when service is VPN', () => {
1350-
request.payload.service = 'e6eb0d1e856335fc';
1349+
it('sends verification email when service is in servicesWithEmailVerification', () => {
1350+
request.payload.service = '32aaeb6f1c21316a';
13511351
return sendSigninNotifications(
13521352
request,
13531353
accountRecord,

packages/fxa-auth-server/test/oauth/routes/authorization.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ const PKCE_CODE_CHALLENGE_METHOD = 'S256';
1717
const DISABLED_CLIENT_ID = 'd15ab1edd15ab1ed';
1818

1919
const _ = () => {};
20+
21+
const SERVICES_WITH_EMAIL_VERIFICATION_CLIENT = '32aaeb6f1c21316a';
22+
2023
const route = require('../../../lib/routes/oauth/authorization')({
2124
log: {
2225
info: _,
@@ -26,6 +29,28 @@ const route = require('../../../lib/routes/oauth/authorization')({
2629
oauthDB: {},
2730
})[1];
2831

32+
const sessionTokenRoute = require('../../../lib/routes/oauth/authorization')({
33+
log: {
34+
info: _,
35+
debug: _,
36+
warn: _,
37+
notifyAttachedServices: _,
38+
},
39+
oauthDB: {},
40+
config: {
41+
oauthServer: {
42+
expiration: { accessToken: 3600000 },
43+
disabledClients: [DISABLED_CLIENT_ID],
44+
allowHttpRedirects: false,
45+
contentUrl: 'http://localhost',
46+
},
47+
oauth: {
48+
disableNewConnectionsForClients: [],
49+
},
50+
servicesWithEmailVerification: [SERVICES_WITH_EMAIL_VERIFICATION_CLIENT],
51+
},
52+
})[2];
53+
2954
describe('/authorization POST', function () {
3055
describe('input validation', () => {
3156
const validation = route.config.validate.payload;
@@ -220,3 +245,104 @@ describe('/authorization POST', function () {
220245
});
221246
});
222247
});
248+
249+
describe('/oauth/authorization POST', function () {
250+
describe('servicesWithEmailVerification enforcement', () => {
251+
it('rejects unverified sessions for clients in servicesWithEmailVerification', async () => {
252+
const request = {
253+
headers: {},
254+
auth: {
255+
credentials: {
256+
tokenVerified: false,
257+
uid: 'abc123',
258+
259+
},
260+
},
261+
payload: {
262+
client_id: SERVICES_WITH_EMAIL_VERIFICATION_CLIENT,
263+
state: 'foo',
264+
scope: 'profile',
265+
},
266+
};
267+
268+
try {
269+
await sessionTokenRoute.handler(request);
270+
assert.fail('should have errored');
271+
} catch (err) {
272+
assert.equal(err.errno, 138); // Unverified session
273+
}
274+
});
275+
276+
it('allows verified sessions for clients in servicesWithEmailVerification', async () => {
277+
const request = {
278+
headers: {},
279+
auth: {
280+
credentials: {
281+
tokenVerified: true,
282+
uid: 'abc123',
283+
284+
emailVerified: true,
285+
verifierSetAt: Date.now(),
286+
lastAuthAt: () => Date.now(),
287+
authenticationMethods: new Set(['pwd']),
288+
authenticatorAssuranceLevel: 1,
289+
profileChangedAt: Date.now(),
290+
keysChangedAt: Date.now(),
291+
id: 'sessionTokenId',
292+
},
293+
},
294+
payload: {
295+
client_id: SERVICES_WITH_EMAIL_VERIFICATION_CLIENT,
296+
state: 'foo',
297+
scope: 'profile',
298+
},
299+
};
300+
301+
try {
302+
await sessionTokenRoute.handler(request);
303+
assert.fail('should have errored');
304+
} catch (err) {
305+
// Should pass the servicesWithEmailVerification check and fail
306+
// further downstream (e.g., at assertion verification or grant
307+
// validation), not at unverified session check.
308+
assert.notEqual(err.errno, 138);
309+
}
310+
});
311+
312+
it('allows unverified sessions for clients NOT in servicesWithEmailVerification', async () => {
313+
const request = {
314+
headers: {},
315+
auth: {
316+
credentials: {
317+
tokenVerified: false,
318+
uid: 'abc123',
319+
320+
emailVerified: true,
321+
verifierSetAt: Date.now(),
322+
lastAuthAt: () => Date.now(),
323+
authenticationMethods: new Set(['pwd']),
324+
authenticatorAssuranceLevel: 1,
325+
profileChangedAt: Date.now(),
326+
keysChangedAt: Date.now(),
327+
id: 'sessionTokenId',
328+
mustVerify: false,
329+
},
330+
},
331+
payload: {
332+
client_id: CLIENT_ID, // Not in servicesWithEmailVerification
333+
state: 'foo',
334+
scope: 'profile',
335+
},
336+
};
337+
338+
try {
339+
await sessionTokenRoute.handler(request);
340+
assert.fail('should have errored');
341+
} catch (err) {
342+
// Should NOT fail with unverified session error, but may fail
343+
// further downstream for other reasons.
344+
assert.notEqual(err.errno, 138);
345+
}
346+
});
347+
});
348+
});

packages/fxa-content-server/server/lib/configuration.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -908,8 +908,8 @@ const conf = (module.exports = convict({
908908
},
909909
},
910910
servicesWithEmailVerification: {
911-
doc: 'Services that will alwasy send a session verification email on sign in',
912-
default: ['e6eb0d1e856335fc'],
911+
doc: 'For users in a non-2FA non-Sync unverified session state going through an RP redirect flow, we skip 1) redirecting the user to enter emailed code verification page, and we skip 2) sending that email verification. Services in this list will NOT skip the redirect or email, and are blocked in the BE from completing the oauth flow by hitting our API directly in this state. Auth-server has this config as well.',
912+
default: ['32aaeb6f1c21316a'], // SubPlat dev
913913
format: Array,
914914
env: 'SERVICES_WITH_EMAIL_VERIFICATION',
915915
},

packages/fxa-settings/src/lib/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export function getDefault() {
206206
enabled: true,
207207
preview: true,
208208
},
209-
servicesWithEmailVerification: ['e6eb0d1e856335fc'],
209+
servicesWithEmailVerification: ['32aaeb6f1c21316a'],
210210
} as Config;
211211
}
212212

packages/fxa-settings/src/pages/Authorization/container.test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React from 'react';
66
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
77
import { screen, waitFor } from '@testing-library/react';
88
import AuthorizationContainer from './container';
9+
import { Config } from '../../lib/config';
910
import { OAUTH_ERRORS } from '../../lib/oauth/oauth-errors';
1011
import { Integration } from '../../models';
1112
import AuthClient from 'fxa-auth-client/browser';
@@ -52,6 +53,7 @@ describe('AuthorizationContainer', () => {
5253
const mockUseLocation = jest.spyOn(ReachRouterModule, 'useLocation');
5354
const mockUseSession = jest.spyOn(ModelsHooksModule, 'useSession');
5455
const mockUseAuthClient = jest.spyOn(ModelsHooksModule, 'useAuthClient');
56+
const mockUseConfig = jest.spyOn(ModelsHooksModule, 'useConfig');
5557
const mockIsOauthWebIntegration = jest.spyOn(
5658
OAuthWebIntegrationModule,
5759
'isOAuthWebIntegration'
@@ -78,6 +80,9 @@ describe('AuthorizationContainer', () => {
7880
mockLocation.search = '';
7981
mockUseLocation.mockReturnValue(mockLocation);
8082
mockUseAuthClient.mockReturnValue(mockAuthClient);
83+
mockUseConfig.mockReturnValue({
84+
servicesWithEmailVerification: ['test-service-id'],
85+
} as Config);
8186
mockUseSession.mockReturnValue(mockSession);
8287
mockIsOauthWebIntegration.mockReturnValue(true);
8388
mockCurrentAccount.mockReturnValue(mockAccount);
@@ -127,6 +132,7 @@ describe('AuthorizationContainer', () => {
127132
},
128133
wantsPromptNone: jest.fn().mockReturnValue(true),
129134
returnOnError: jest.fn().mockReturnValue(false),
135+
getClientId: jest.fn().mockReturnValue('some-other-client'),
130136
validatePromptNoneRequest: jest.fn().mockResolvedValue(undefined),
131137
};
132138

@@ -230,6 +236,56 @@ describe('AuthorizationContainer', () => {
230236
});
231237
});
232238

239+
it('rejects prompt=none and shows error when session is unverified and returnOnError=false for servicesWithEmailVerification client', async () => {
240+
mockCachedSignIn.mockResolvedValue({
241+
data: {
242+
verificationMethod: VerificationMethods.EMAIL_OTP,
243+
verificationReason: VerificationReasons.SIGN_IN,
244+
uid: mockAccount.uid,
245+
sessionVerified: false,
246+
emailVerified: true,
247+
totpIsActive: false,
248+
},
249+
error: undefined,
250+
});
251+
252+
const mockIntegration = {
253+
data: {
254+
redirectTo: '/settings',
255+
},
256+
wantsPromptNone: jest.fn().mockReturnValue(true),
257+
returnOnError: jest.fn().mockReturnValue(false),
258+
getClientId: jest.fn().mockReturnValue('test-service-id'),
259+
validatePromptNoneRequest: jest.fn().mockResolvedValue(undefined),
260+
};
261+
262+
render(mockIntegration);
263+
264+
await waitFor(() => {
265+
expect(screen.getByText('Bad Request')).toBeInTheDocument();
266+
});
267+
expect(screen.getByText('Unverified user or session')).toBeInTheDocument();
268+
expect(SigninUtilsModule.handleNavigation).not.toHaveBeenCalled();
269+
});
270+
271+
it('allows prompt=none with verified session for servicesWithEmailVerification client', async () => {
272+
const mockIntegration = {
273+
data: {
274+
redirectTo: '/settings',
275+
},
276+
wantsPromptNone: jest.fn().mockReturnValue(true),
277+
returnOnError: jest.fn().mockReturnValue(false),
278+
getClientId: jest.fn().mockReturnValue('test-service-id'),
279+
validatePromptNoneRequest: jest.fn().mockResolvedValue(undefined),
280+
};
281+
render(mockIntegration);
282+
283+
await waitFor(() => {
284+
expect(SigninUtilsModule.handleNavigation).toHaveBeenCalledTimes(1);
285+
});
286+
expect(screen.queryByText('Bad Request')).not.toBeInTheDocument();
287+
});
288+
233289
it("navigates to '/oauth' when no action is provided and not prompt=none", async () => {
234290
const mockOAuthWebIntegration = {
235291
data: {

0 commit comments

Comments
 (0)