Skip to content

Commit 7217918

Browse files
authored
Merge pull request #19500 from mozilla/FXA-12429-12436
task(auth): Add additional authentication strategy
2 parents d1ed41d + 69ab6a5 commit 7217918

12 files changed

Lines changed: 452 additions & 23 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@ const convictConf = convict({
3131
default: 1,
3232
env: 'AUTH_API_VERSION',
3333
},
34+
authStrategies: {
35+
verifiedSessionToken: {
36+
skipEmailVerifiedCheckForRoutes: {
37+
default: '',
38+
doc: 'A regex that defines the routes that should skip the email verification check.',
39+
env: 'AUTH_STRATEGIES__VERIFIED_SESSION_TOKEN__SKIP_EMAIL_CHECK_FOR_ROUTES',
40+
format: String,
41+
},
42+
skipTokenVerifiedCheckForRoutes: {
43+
default: '',
44+
doc: 'A regex that defines the routes that should skip the token verified check.',
45+
env: 'AUTH_STRATEGIES__VERIFIED_SESSION_TOKEN__SKIP_TOKEN_VERIFIED_CHECK_FOR_ROUTES',
46+
format: String,
47+
},
48+
skipAalCheckForRoutes: {
49+
default: '',
50+
doc: 'A regex that defines the routes that should skip the account assurance level check.',
51+
env: 'AUTH_STRATEGIES__VERIFIED_SESSION_TOKEN__SKIP_AAL_CHECK_FOR_ROUTES',
52+
format: String,
53+
},
54+
},
55+
},
3456
// TODO: Remove this after we have synchronized login records to Firestore
3557
firestore: {
3658
credentials: {

packages/fxa-auth-server/lib/routes/auth-schemes/hawk-fxa-token.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,5 @@ function strategy(
160160

161161
module.exports = {
162162
strategy,
163+
parseAuthorizationHeader,
163164
};
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
'use strict';
6+
7+
const AppError = require('../../error');
8+
const authMethods = require('../../authMethods');
9+
const { parseAuthorizationHeader } = require('./hawk-fxa-token');
10+
11+
/**
12+
* Authentication strategy that validates a Hawk session token and ensures:
13+
* 1) account email is verified
14+
* 2) session token is verified (no tokenVerificationId)
15+
* 3) account AAL and session AAL match
16+
*
17+
* @param {Function} getCredentialsFunc - function to fetch a session token by id
18+
* @param {Object} db - database interface to fetch account and factors
19+
* @returns {Function}
20+
*/
21+
function strategy(getCredentialsFunc, db, config, statsd) {
22+
const tokenNotFoundError = () => {
23+
const error = AppError.unauthorized('Token not found');
24+
error.isMissing = true;
25+
return error;
26+
};
27+
28+
// Extract regular expressions to allow for optional skipping of certain routes for certain checks.
29+
const verifiedSessionTokenConfig =
30+
config?.authStrategies?.verifiedSessionToken;
31+
32+
const skipEmailVerifiedCheckForRoutes =
33+
verifiedSessionTokenConfig?.skipEmailVerifiedCheckForRoutes
34+
? new RegExp(verifiedSessionTokenConfig.skipEmailVerifiedCheckForRoutes)
35+
: null;
36+
37+
const skipTokenVerifiedCheckForRoutes =
38+
verifiedSessionTokenConfig?.skipTokenVerifiedCheckForRoutes
39+
? new RegExp(verifiedSessionTokenConfig.skipTokenVerifiedCheckForRoutes)
40+
: null;
41+
42+
const skipAalCheckForRoutes =
43+
verifiedSessionTokenConfig?.skipAalCheckForRoutes
44+
? new RegExp(verifiedSessionTokenConfig.skipAalCheckForRoutes)
45+
: null;
46+
47+
return function (server, options) {
48+
return {
49+
authenticate: async function (req, h) {
50+
const auth = req.headers.authorization;
51+
52+
if (!auth) {
53+
// if this strategy is selected, auth *cannot* be optional
54+
// "optional" mode is not supported
55+
throw tokenNotFoundError();
56+
}
57+
58+
const parsedHeader = parseAuthorizationHeader(auth);
59+
let token;
60+
try {
61+
token = await getCredentialsFunc(parsedHeader.id);
62+
} catch (_) {}
63+
64+
if (!token) {
65+
throw tokenNotFoundError();
66+
}
67+
68+
// Fetch the account for further checks
69+
const account = await db.account(token.uid);
70+
71+
// 1) account email is verified
72+
if (!account?.primaryEmail?.isVerified) {
73+
if (skipEmailVerifiedCheckForRoutes?.test(req.route.path)) {
74+
// Important! Using req.route.path which has much lower cardinality than req.path
75+
statsd?.increment(
76+
'verified_session_token.primary_email_not_verified.skipped',
77+
[`path:${req.route.path}`]
78+
);
79+
} else {
80+
statsd?.increment(
81+
'verified_session_token.primary_email_not_verified.error',
82+
[`path:${req.route.path}`]
83+
);
84+
throw AppError.unverifiedAccount();
85+
}
86+
}
87+
88+
// 2) session token is verified
89+
if (token.tokenVerificationId || token.tokenVerified === false) {
90+
if (skipTokenVerifiedCheckForRoutes?.test(req.route.path)) {
91+
statsd?.increment('verified_session_token.token_verified.skipped', [
92+
`path:${req.route.path}`,
93+
]);
94+
} else {
95+
statsd?.increment('verified_session_token.token_verified.error', [
96+
`path:${req.route.path}`,
97+
]);
98+
throw AppError.unverifiedSession();
99+
}
100+
}
101+
102+
// 3) account AAL and session AAL match
103+
const accountAmr = await authMethods.availableAuthenticationMethods(
104+
db,
105+
account
106+
);
107+
const accountAal = authMethods.maximumAssuranceLevel(accountAmr);
108+
const sessionAal = token.authenticatorAssuranceLevel;
109+
110+
if (accountAal !== sessionAal) {
111+
if (skipAalCheckForRoutes?.test(req.route.path)) {
112+
statsd?.increment('verified_session_token.aal.skipped', [
113+
`path:${req.route.path}`,
114+
]);
115+
} else {
116+
statsd?.increment('verified_session_token.aal.error', [
117+
`path:${req.route.path}`,
118+
]);
119+
throw AppError.unauthorized('AAL mismatch');
120+
}
121+
}
122+
123+
return h.authenticated({
124+
credentials: token,
125+
});
126+
},
127+
};
128+
};
129+
}
130+
131+
module.exports = {
132+
strategy,
133+
};

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,8 @@ export const mfaRoutes = (
253253
options: {
254254
pre: [{ method: featureEnabledCheck }],
255255
auth: {
256-
strategy: 'sessionToken',
256+
strategy: 'verifiedSessionToken',
257+
payload: false,
257258
},
258259
validate: {
259260
payload: isA.object({
@@ -274,7 +275,8 @@ export const mfaRoutes = (
274275
path: '/mfa/otp/verify',
275276
options: {
276277
auth: {
277-
strategy: 'sessionToken',
278+
strategy: 'verifiedSessionToken',
279+
payload: false,
278280
},
279281
validate: {
280282
payload: isA.object({

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ module.exports = function (
8282
options: {
8383
...PASSWORD_DOCS.PASSWORD_CHANGE_START_POST,
8484
auth: {
85-
strategy: 'sessionToken',
86-
payload: 'required',
85+
strategy: 'verifiedSessionToken',
86+
payload: false,
8787
},
8888
validate: {
8989
payload: isA.object({

packages/fxa-auth-server/lib/routes/recovery-codes.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => {
3434
options: {
3535
...RECOVERY_CODES_DOCS.RECOVERYCODES_GET,
3636
auth: {
37-
strategy: 'sessionToken',
37+
strategy: 'verifiedSessionToken',
38+
payload: false,
3839
},
3940
response: {
4041
schema: recoveryCodesSchema,
@@ -88,7 +89,8 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => {
8889
options: {
8990
...RECOVERY_CODES_DOCS.RECOVERY_CODES_POST,
9091
auth: {
91-
strategy: 'sessionToken',
92+
strategy: 'verifiedSessionToken',
93+
payload: false,
9294
},
9395
validate: {
9496
payload: recoveryCodesSchema,
@@ -108,13 +110,11 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => {
108110
// no previous backup codes should be in the database
109111
// the session should not yet have a higher assurance level
110112
const account = await db.account(uid);
111-
const { hasBackupCodes } =
112-
await backupCodeManager.getCountForUserId(uid);
113113
const hasTotpToken = await otpUtils.hasTotpToken({ uid });
114114
// for initial setup, only fail if totp is already enabled
115115
// if totp is not enabled/verified, it is safe to replace the recovery codes
116-
if (hasBackupCodes && hasTotpToken) {
117-
throw errors.recoveryCodesAlreadyExist();
116+
if (hasTotpToken) {
117+
throw errors.totpTokenAlreadyExists();
118118
}
119119

120120
const { recoveryCodes } = request.payload;
@@ -143,7 +143,8 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => {
143143
options: {
144144
...RECOVERY_CODES_DOCS.RECOVERY_CODES_PUT,
145145
auth: {
146-
strategy: 'sessionToken',
146+
strategy: 'verifiedSessionToken',
147+
payload: false,
147148
},
148149
validate: {
149150
payload: recoveryCodesSchema,

packages/fxa-auth-server/lib/routes/recovery-key.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ module.exports = (
3232
options: {
3333
...RECOVERY_KEY_DOCS.RECOVERYKEY_POST,
3434
auth: {
35-
strategy: 'sessionToken',
36-
payload: 'required',
35+
strategy: 'verifiedSessionToken',
36+
payload: false,
3737
},
3838
validate: {
3939
payload: isA.object({
@@ -395,7 +395,8 @@ module.exports = (
395395
options: {
396396
...RECOVERY_KEY_DOCS.RECOVERYKEY_DELETE,
397397
auth: {
398-
strategy: 'sessionToken',
398+
strategy: 'verifiedSessionToken',
399+
payload: false,
399400
},
400401
},
401402
async handler(request) {

packages/fxa-auth-server/lib/routes/recovery-phone.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,7 +1024,8 @@ export const recoveryPhoneRoutes = (
10241024
options: {
10251025
pre: [{ method: featureEnabledCheck }],
10261026
auth: {
1027-
strategy: 'sessionToken',
1027+
strategy: 'verifiedSessionToken',
1028+
payload: false,
10281029
},
10291030
validate: {
10301031
payload: isA.object({
@@ -1082,7 +1083,8 @@ export const recoveryPhoneRoutes = (
10821083
options: {
10831084
pre: [{ method: featureEnabledCheck }],
10841085
auth: {
1085-
strategy: 'sessionToken',
1086+
strategy: 'verifiedSessionToken',
1087+
payload: false,
10861088
},
10871089
validate: {
10881090
payload: isA.object({
@@ -1102,7 +1104,8 @@ export const recoveryPhoneRoutes = (
11021104
options: {
11031105
pre: [{ method: featureEnabledCheck }],
11041106
auth: {
1105-
strategy: 'sessionToken',
1107+
strategy: 'verifiedSessionToken',
1108+
payload: false,
11061109
},
11071110
validate: {
11081111
payload: isA.object({
@@ -1181,7 +1184,8 @@ export const recoveryPhoneRoutes = (
11811184
path: '/recovery_phone',
11821185
options: {
11831186
auth: {
1184-
strategy: 'sessionToken',
1187+
strategy: 'verifiedSessionToken',
1188+
payload: false,
11851189
},
11861190
},
11871191
handler: function (request: AuthRequest) {

packages/fxa-auth-server/lib/routes/totp.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,8 @@ module.exports = (
269269
options: {
270270
...TOTP_DOCS.TOTP_CREATE_POST,
271271
auth: {
272-
strategy: 'sessionToken',
273-
payload: 'required',
272+
strategy: 'verifiedSessionToken',
273+
payload: false,
274274
},
275275
validate: {
276276
payload: isA.object({
@@ -615,7 +615,8 @@ module.exports = (
615615
options: {
616616
...TOTP_DOCS.TOTP_DESTROY_POST,
617617
auth: {
618-
strategy: 'sessionToken',
618+
strategy: 'verifiedSessionToken',
619+
payload: false,
619620
},
620621
response: {},
621622
},

packages/fxa-auth-server/lib/server.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const {
2525
} = require('fxa-shared/sentry/report-validation-error');
2626
const { logErrorWithGlean } = require('./metrics/glean');
2727
const mfa = require('./routes/auth-schemes/mfa');
28+
const verifiedSessionToken = require('./routes/auth-schemes/verified-session-token');
2829

2930
function trimLocale(header) {
3031
if (!header) {
@@ -497,6 +498,17 @@ async function create(log, error, config, routes, db, statsd, glean, customs) {
497498
);
498499
server.auth.strategy('mfa', 'mfa');
499500

501+
server.auth.scheme(
502+
'verified-session-token',
503+
verifiedSessionToken.strategy(
504+
makeCredentialFn(db.sessionToken.bind(db)),
505+
db,
506+
config,
507+
statsd
508+
)
509+
);
510+
server.auth.strategy('verifiedSessionToken', 'verified-session-token');
511+
500512
// register all plugins and Swagger configuration
501513
await server.register([
502514
{

0 commit comments

Comments
 (0)