diff --git a/src/infra/auth/jwt.ts b/src/infra/auth/jwt.ts index 953975673..a8318edc5 100644 --- a/src/infra/auth/jwt.ts +++ b/src/infra/auth/jwt.ts @@ -47,6 +47,14 @@ export interface TokenUserPayload authTime?: number; } +const jwtValidFields = [ + ...tokenFields, + 'authTime', + 'twoFactorRequired', + 'iat', + 'exp', +] satisfies Array; + export interface ExtraParams { existingToken?: Partial; jwtOptions?: SignOptions; @@ -82,7 +90,10 @@ let $getUserTokenDataCallback: GetUserTokenDataFn = async ( const newTokenData = _.pick(userData, tokenFields); const tokenData: TokenUserPayload = { - ...existingToken, + // The existingToken that we pass in is the augmented object that + // the jwt-passport returns, so we need to make sure we are not + // destructuring extra properties. + ..._.pick(existingToken, jwtValidFields), ...newTokenData, }; diff --git a/test/04_session.ts b/test/04_session.ts index 60bdb011d..1b4385c23 100644 --- a/test/04_session.ts +++ b/test/04_session.ts @@ -1,5 +1,9 @@ import { expect } from 'chai'; -import { SUPERUSER_EMAIL, SUPERUSER_PASSWORD } from '../src/lib/config.js'; +import { + JSON_WEB_TOKEN_LIMIT_EXPIRY_REFRESH, + SUPERUSER_EMAIL, + SUPERUSER_PASSWORD, +} from '../src/lib/config.js'; import { createScopedAccessToken } from '../src/infra/auth/jwt.js'; import * as fixtures from './test-lib/fixtures.js'; @@ -14,12 +18,8 @@ import { } from '../src/infra/auth/permissions.js'; import { permissions as pinePermissions, sbvrUtils } from '@balena/pinejs'; const { api } = sbvrUtils; -import type { JwtPayload } from 'jsonwebtoken'; -import { decode } from 'jsonwebtoken'; import { setTimeout } from 'timers/promises'; - -const atob = (x: string) => Buffer.from(x, 'base64').toString('binary'); -const parseJwt = (t: string) => JSON.parse(atob(t.split('.')[1])); +import { expectJwt } from './test-lib/api-helpers.js'; export default () => { versions.test((version) => { @@ -60,6 +60,29 @@ export default () => { await fixtures.clean(this.loadedFixtures); }); + it('/login_ returns 401 when the password is wrong', async function () { + await supertest() + .post('/login_') + .send({ + username: SUPERUSER_EMAIL, + password: `${SUPERUSER_PASSWORD}_wrong`, + }) + .expect(401); + }); + + it('/login_ returns a token with only the allowed properties', async function () { + const token = ( + await supertest() + .post('/login_') + .send({ + username: SUPERUSER_EMAIL, + password: SUPERUSER_PASSWORD, + }) + .expect(200) + ).text; + expectJwt(token); + }); + it('/user/v1/whoami returns a user', async function () { const user = (await supertest(admin).get('/user/v1/whoami').expect(200)) .body; @@ -126,7 +149,7 @@ export default () => { token = admin.token!; }); - it('should be refreshable with /user/v1/refresh-token', async function () { + it('should be refreshable with /user/v1/refresh-token and not include extra properties', async function () { // wait 2 seconds to make sure the token is already starting to expire await setTimeout(2000); @@ -134,43 +157,20 @@ export default () => { .get('/user/v1/refresh-token') .expect(200); - const oldDecodedToken = decode(token) as JwtPayload; - const newDecodedToken = decode(res.text) as JwtPayload; + const oldDecodedToken = expectJwt(token); + const newDecodedToken = expectJwt(res.text); token = res.text; - expect(oldDecodedToken) - .to.be.an('object') - .to.have.property('exp') - .that.is.a('number'); - expect(newDecodedToken) - .to.be.an('object') - .to.have.property('exp') - .that.is.a('number'); - expect(oldDecodedToken) - .to.be.an('object') - .to.have.property('iat') - .that.is.a('number'); - expect(newDecodedToken) - .to.be.an('object') - .to.have.property('iat') - .that.is.a('number'); - - const oldExp = oldDecodedToken.exp as number; - const newExp = newDecodedToken.exp as number; - expect(newExp).to.be.eq(oldExp); - - const oldIat = oldDecodedToken.iat as number; - const newIat = newDecodedToken.iat as number; - expect(newIat).to.be.gt(oldIat); - expect(newIat - oldIat).to.be.gt(2); + if (JSON_WEB_TOKEN_LIMIT_EXPIRY_REFRESH) { + expect(oldDecodedToken.exp, 'exp should not change').to.be.eq( + newDecodedToken.exp, + ); - const tokenParts = token.split('.'); - expect(tokenParts).to.be.an('array'); - expect(tokenParts).to.have.property('length', 3); - const payload = parseJwt(token); - expect(payload).to.have.property('id'); - expect(payload).to.not.have.property('username'); - expect(payload).to.not.have.property('email'); + expect(newDecodedToken.iat, 'should get a newer iat').to.be.gt( + oldDecodedToken.iat, + ); + expect(newDecodedToken.iat - oldDecodedToken.iat).to.be.gt(2); + } }); it('should refresh & update the authTime with a POST to /user/v1/refresh-token using a correct password', async function () { @@ -182,11 +182,7 @@ export default () => { const tokenParts = token.split('.'); expect(tokenParts).to.be.an('array'); expect(tokenParts).to.have.property('length', 3); - const payload = parseJwt(token); - expect(payload).to.have.property('id'); - expect(payload).to.not.have.property('username'); - expect(payload).to.not.have.property('email'); - expect(payload).to.have.property('authTime'); + const payload = expectJwt(token); const initialAuthTime: number = payload.authTime; const res1 = await supertest(token) @@ -198,10 +194,7 @@ export default () => { const tokenParts1 = token.split('.'); expect(tokenParts1).to.be.an('array'); expect(tokenParts1).to.have.property('length', 3); - const payload1 = parseJwt(token); - expect(payload1).to.have.property('id'); - expect(payload1).to.not.have.property('username'); - expect(payload1).to.not.have.property('email'); + const payload1 = expectJwt(token); expect(payload1) .to.have.property('authTime') .to.be.above(initialAuthTime); @@ -215,11 +208,7 @@ export default () => { const tokenParts = token.split('.'); expect(tokenParts).to.be.an('array'); expect(tokenParts).to.have.property('length', 3); - const payload = parseJwt(token); - expect(payload).to.have.property('id'); - expect(payload).to.not.have.property('username'); - expect(payload).to.not.have.property('email'); - expect(payload).to.have.property('authTime'); + const payload = expectJwt(token); const initialAuthTime: number = payload.authTime; const res1 = await supertest(token) @@ -229,10 +218,7 @@ export default () => { const tokenParts1 = token.split('.'); expect(tokenParts1).to.be.an('array'); expect(tokenParts1).to.have.property('length', 3); - const payload1 = parseJwt(token); - expect(payload1).to.have.property('id'); - expect(payload1).to.not.have.property('username'); - expect(payload1).to.not.have.property('email'); + const payload1 = expectJwt(token); expect(payload1) .to.have.property('authTime') .that.equals(initialAuthTime); diff --git a/test/test-lib/api-helpers.ts b/test/test-lib/api-helpers.ts index b59738e8e..4cf4e7a58 100644 --- a/test/test-lib/api-helpers.ts +++ b/test/test-lib/api-helpers.ts @@ -5,6 +5,7 @@ import { expect } from 'chai'; import type { UserObjectParam } from '../test-lib/supertest.js'; import { supertest } from '../test-lib/supertest.js'; import type { TokenUserPayload } from '../../src/index.js'; +import type { RequiredField } from '@balena/pinejs/out/sbvr-api/common-types.js'; const version = 'resin'; @@ -140,7 +141,10 @@ export function expectJwt(tokenOrJwt: string | AnyObject) { typeof tokenOrJwt === 'string' ? jsonwebtoken.decode(tokenOrJwt) : tokenOrJwt - ) as TokenUserPayload & { iat: number; exp: number }; + ) as RequiredField & { + iat: number; + exp: number; + }; expect(decoded).to.have.property('id').that.is.a('number'); expect(decoded).to.have.property('jwt_secret').that.is.a.string; expect(decoded.jwt_secret).to.be.a('string').that.has.length(32);