Skip to content

Commit 31f4377

Browse files
committed
bug(auth): Fix mfa strategy return type
Because: - The return type must be of type session token, or down stream code can fail This commit: - Avoids spreading the sessionToken object - Ensures the original sessionToken object is returned - Uses errors consistent with sessionToken auth strategy, ie AppError.unauthorized This was detected, because the authenticatorAssuranceLevel was undefined on the credentials object returned by the mfa auth strategy.
1 parent cabad89 commit 31f4377

4 files changed

Lines changed: 160 additions & 13 deletions

File tree

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type Credentials = {
4646
verifiedAt?: string | null;
4747
metricsOptOutAt?: string | null;
4848
providerId?: string | null;
49+
scope?: string[];
4950
};
5051

5152
export const strategy = (
@@ -58,7 +59,7 @@ export const strategy = (
5859

5960
// Make sure auth header is at least semi valid.
6061
if (!auth || auth.indexOf('Bearer') !== 0) {
61-
throw AppError.unauthorized('Bearer token not provided');
62+
throw AppError.unauthorized('Token not found');
6263
}
6364

6465
// Extract jwt value
@@ -83,7 +84,7 @@ export const strategy = (
8384
scope?: string[];
8485
};
8586
} catch (err) {
86-
throw AppError.invalidToken(err.message);
87+
throw AppError.unauthorized('Token invalid');
8788
}
8889

8990
// Ensure required state
@@ -97,17 +98,20 @@ export const strategy = (
9798

9899
const sessionToken = await getCredentialsFunc(decoded.stid);
99100
if (!sessionToken) {
100-
throw AppError.invalidToken('Parent session token not found!');
101+
throw AppError.unauthorized('Token not found');
101102
}
102103

103-
// Check the underlying session
104+
if (sessionToken.uid !== decoded.sub) {
105+
throw AppError.unauthorized('Token invalid');
106+
}
107+
108+
// Decorate session token with scope
109+
sessionToken.scope = decoded.scope;
110+
104111
// Finalize auth
105112
return h.authenticated({
106-
credentials: {
107-
...sessionToken,
108-
uid: decoded.sub,
109-
scope: decoded.scope,
110-
},
113+
// Return actual session token instance!
114+
credentials: sessionToken,
111115
});
112116
},
113117
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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+
const { assert } = require('chai');
6+
const sinon = require('sinon');
7+
const AppError = require('../../../../lib/error');
8+
const { strategy } = require('../../../../lib/routes/auth-schemes/mfa');
9+
const jwt = require('jsonwebtoken');
10+
const uuid = require('uuid');
11+
12+
function makeJwt(account, sessionToken, config) {
13+
const now = Math.floor(Date.now() / 1000);
14+
const claims = {
15+
sub: account.uid,
16+
scope: [`mfa:test`],
17+
iat: now,
18+
jti: uuid.v4(),
19+
stid: sessionToken.id,
20+
};
21+
const opts = {
22+
algorithm: 'HS256',
23+
expiresIn: config.mfa.jwt.expiresInSec,
24+
audience: config.mfa.jwt.audience,
25+
issuer: config.mfa.jwt.issuer,
26+
};
27+
const key = config.mfa.jwt.secretKey;
28+
return jwt.sign(claims, key, opts);
29+
}
30+
31+
describe('lib/routes/auth-schemes/mfa', () => {
32+
const mockSessionToken = {
33+
uid: 'account-123',
34+
id: 'session-123',
35+
get foo() {
36+
return 'bar';
37+
},
38+
};
39+
const mockAccount = { uid: 'account-123' };
40+
const mockConfig = {
41+
mfa: {
42+
jwt: {
43+
expiresInSec: 1,
44+
audience: 'fxa',
45+
issuer: 'accounts.firefox.com',
46+
secretKey: 'foxes'.repeat(13),
47+
},
48+
},
49+
};
50+
51+
it('should authenticate with valid jwt token', async () => {
52+
const jwt = makeJwt(mockAccount, mockSessionToken, mockConfig);
53+
const request = {
54+
headers: { authorization: `Bearer ${jwt}` },
55+
auth: { mode: 'required' },
56+
};
57+
const h = { authenticated: sinon.fake() };
58+
const getCredentialsFunc = sinon.fake.resolves(mockSessionToken);
59+
const authStrategy = strategy(mockConfig, getCredentialsFunc)();
60+
61+
await authStrategy.authenticate(request, h);
62+
63+
// Important! Session token should be returned as credentials,
64+
// AND object reference should not change!
65+
assert.isTrue(
66+
h.authenticated.calledOnceWithExactly({
67+
credentials: sinon.match.same(mockSessionToken),
68+
})
69+
);
70+
71+
// Session token should be decorated with a scope.
72+
assert.equal(mockSessionToken.scope[0], 'mfa:test');
73+
});
74+
75+
it('should throw an error if no authorization header is provided', async () => {
76+
const getCredentialsFunc = sinon.fake.resolves(null);
77+
const authStrategy = strategy(mockConfig, getCredentialsFunc)();
78+
79+
const request = { headers: {}, auth: { mode: 'required' } };
80+
const h = { continue: Symbol('continue') };
81+
82+
try {
83+
await authStrategy.authenticate(request, h);
84+
assert.fail('Should have thrown an error');
85+
} catch (err) {
86+
assert.instanceOf(err, AppError);
87+
const errorResponse = err.output.payload;
88+
assert.equal(errorResponse.code, 401);
89+
assert.equal(errorResponse.errno, 110);
90+
assert.equal(errorResponse.message, 'Unauthorized for route');
91+
assert.equal(errorResponse.detail, 'Token not found');
92+
}
93+
});
94+
95+
it('should not authenticate if the parent session cannot be found', async () => {
96+
const getCredentialsFunc = sinon.fake.resolves(null);
97+
const authStrategy = strategy(mockConfig, getCredentialsFunc)();
98+
const jwt = makeJwt(mockAccount, mockSessionToken, mockConfig);
99+
100+
const request = {
101+
headers: { authorization: `Bearer ${jwt}` },
102+
auth: { mode: 'required' },
103+
};
104+
const h = { continue: Symbol('continue') };
105+
106+
try {
107+
await authStrategy.authenticate(request, h);
108+
assert.fail('Should have thrown an error');
109+
} catch (err) {
110+
assert.instanceOf(err, AppError);
111+
const errorResponse = err.output.payload;
112+
assert.equal(errorResponse.code, 401);
113+
assert.equal(errorResponse.errno, 110);
114+
assert.equal(errorResponse.message, 'Unauthorized for route');
115+
assert.equal(errorResponse.detail, 'Token not found');
116+
}
117+
});
118+
119+
it('should not authenticate with invalid jwt token due to sub mismatch', async () => {
120+
const getCredentialsFunc = sinon.fake.resolves({ sub: 'account-234' });
121+
const authStrategy = strategy(mockConfig, getCredentialsFunc)();
122+
const jwt = makeJwt(mockAccount, mockSessionToken, mockConfig);
123+
124+
const request = {
125+
headers: { authorization: `Bearer ${jwt}` },
126+
auth: { mode: 'required' },
127+
};
128+
const h = { continue: Symbol('continue') };
129+
130+
try {
131+
await authStrategy.authenticate(request, h);
132+
assert.fail('Should have thrown an error');
133+
} catch (err) {
134+
assert.instanceOf(err, AppError);
135+
const errorResponse = err.output.payload;
136+
assert.equal(errorResponse.code, 401);
137+
assert.equal(errorResponse.errno, 110);
138+
assert.equal(errorResponse.message, 'Unauthorized for route');
139+
assert.equal(errorResponse.detail, 'Token invalid');
140+
}
141+
});
142+
});

packages/fxa-auth-server/test/local/routes/mfa.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ describe('mfa', () => {
9999
// There's typically much more data returned by this callback, but
100100
// for testing purposes this is sufficient.
101101
id: SESSION_TOKEN_ID,
102+
uid: UID,
102103
uaBrowser: UA_BROWSER,
103104
});
104105

@@ -118,7 +119,7 @@ describe('mfa', () => {
118119
});
119120

120121
it('sends otp, verifies otp, and gets a valid jwt in return', async () => {
121-
const requestResult = await await runTest(
122+
const requestResult = await runTest(
122123
'/mfa/otp/request',
123124
{
124125
credentials: {
@@ -179,7 +180,7 @@ describe('mfa', () => {
179180
}
180181
assert.isDefined(error);
181182
assert.equal(error.errno, 110);
182-
assert.equal(error.message, 'jwt malformed');
183+
assert.equal(error.message, 'Unauthorized for route');
183184
});
184185

185186
it('will not allow an expired token', async () => {
@@ -194,6 +195,6 @@ describe('mfa', () => {
194195
}
195196
assert.isDefined(error);
196197
assert.equal(error.errno, 110);
197-
assert.equal(error.message, 'jwt expired');
198+
assert.equal(error.message, 'Unauthorized for route');
198199
});
199200
});

packages/fxa-settings/src/components/Settings/MfaGuard/error-boundary.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe('MfaErrorBoundary', () => {
5353
</MfaErrorBoundary>
5454
);
5555

56-
const authError: any = new Error('invalid jwt');
56+
const authError: any = new Error('Unauthorized for route');
5757
authError.code = 401;
5858
authError.errno = 110;
5959

0 commit comments

Comments
 (0)