From 890fa53019c337e5f74aa9fdb233ce123d311121 Mon Sep 17 00:00:00 2001 From: dschom Date: Thu, 2 Oct 2025 15:23:45 -0700 Subject: [PATCH] task(auth): Expose more detailed session status info Because: - Just checking the state returned by `/session/status` often isn't enough - We need more details about the session status This Commit: - Updates /session/status adn that exposes details about the session status state. --- .../fxa-auth-server/lib/routes/session.js | 42 +++ .../auth-schemes/verified-session-token.js | 29 +- .../test/local/routes/session.js | 290 +++++++++++++++++- .../test/remote/session_tests.js | 6 + 4 files changed, 336 insertions(+), 31 deletions(-) diff --git a/packages/fxa-auth-server/lib/routes/session.js b/packages/fxa-auth-server/lib/routes/session.js index fef728aa8c5..274acd1a8b3 100644 --- a/packages/fxa-auth-server/lib/routes/session.js +++ b/packages/fxa-auth-server/lib/routes/session.js @@ -19,6 +19,7 @@ const { recordSecurityEvent } = require('./utils/security-event'); const { getOptionalCmsEmailConfig } = require('./utils/account'); const { Container } = require('typedi'); const { RelyingPartyConfigurationManager } = require('@fxa/shared/cms'); +const authMethods = require('../authMethods'); module.exports = function ( log, @@ -275,15 +276,56 @@ module.exports = function ( schema: isA.object({ state: isA.string().required(), uid: isA.string().regex(HEX_STRING).required(), + details: isA.object({ + accountEmailVerified: isA.boolean(), + sessionVerificationMethod: isA.string().allow(null), + sessionVerificationSuccessful: isA.boolean(), + sessionVerificationMeetsMinimumAAL: isA.boolean(), + }), }), }, }, handler: async function (request) { log.begin('Session.status', request); const sessionToken = request.auth.credentials; + const account = await db.account(sessionToken.uid); + + // Make sure the account still exists + if (!account) { + throw error.unknownAccount(); + } + + // Check account assurance level + const accountAmr = await authMethods.availableAuthenticationMethods( + db, + account + ); + const accountAal = authMethods.maximumAssuranceLevel(accountAmr); + const sessionAal = sessionToken.authenticatorAssuranceLevel; + + // Build response + const accountEmailVerified = + account.emails?.primaryEmail?.isVerified || false; + + const sessionVerificationMethod = sessionToken.verificationMethod; + + // See verified-session-token auth strategy + const sessionVerificationSuccessful = + sessionToken.tokenVerificationId == null && + sessionToken.tokenVerified !== false; + + // Account Assurance Level + const sessionVerificationMeetsMinimumAAL = sessionAal >= accountAal; + return { state: sessionToken.state, uid: sessionToken.uid, + details: { + accountEmailVerified, + sessionVerificationMethod, + sessionVerificationSuccessful, + sessionVerificationMeetsMinimumAAL, + }, }; }, }, diff --git a/packages/fxa-auth-server/test/local/routes/auth-schemes/verified-session-token.js b/packages/fxa-auth-server/test/local/routes/auth-schemes/verified-session-token.js index a3097034572..721883aaddd 100644 --- a/packages/fxa-auth-server/test/local/routes/auth-schemes/verified-session-token.js +++ b/packages/fxa-auth-server/test/local/routes/auth-schemes/verified-session-token.js @@ -8,9 +8,6 @@ const AppError = require('../../../../lib/error'); const { strategy, } = require('../../../../lib/routes/auth-schemes/verified-session-token'); -const authMethods = require('../../../../lib/authMethods'); - -const HAWK_HEADER = 'Hawk id="123", ts="123", nonce="123", mac="123"'; describe('lib/routes/auth-schemes/verified-session-token', () => { let config; @@ -21,10 +18,6 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { let request; let getCredentialsFunc; - before(() => { - sinon.stub(authMethods, 'availableAuthenticationMethods'); - }); - beforeEach(() => { // Default valid state. This state should pass email verified check, session token verified check, // and account assurance level check. @@ -53,12 +46,10 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { authenticatorAssuranceLevel: 1, }; - authMethods.availableAuthenticationMethods = sinon.fake.resolves( - new Set(['pwd', 'email']) - ); - request = { - headers: { authorization: HAWK_HEADER }, + headers: { + authorization: 'Hawk id="123", ts="123", nonce="123", mac="123"', + }, auth: { mode: 'required' }, route: { path: '/foo/{id}' }, }; @@ -209,9 +200,10 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { it('fails when AAL mismatch', async () => { // Force account AAL=2 by returning otp along with pwd/email - authMethods.availableAuthenticationMethods = sinon.fake.resolves( - new Set(['pwd', 'email', 'otp']) - ); + db.totpToken = sinon.fake.resolves({ + verified: true, + enabled: true, + }); const authStrategy = strategy(getCredentialsFunc, db, config, statsd)(); try { @@ -231,9 +223,10 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { it('skips AAL check when configured', async () => { // Force account AAL=2 by returning otp along with pwd/email - authMethods.availableAuthenticationMethods = sinon.fake.resolves( - new Set(['pwd', 'email', 'otp']) - ); + db.totpToken = sinon.fake.resolves({ + enabled: true, + verified: true, + }); // Skip AAL check for path config.authStrategies.verifiedSessionToken.skipAalCheckForRoutes = '/foo.*'; diff --git a/packages/fxa-auth-server/test/local/routes/session.js b/packages/fxa-auth-server/test/local/routes/session.js index df96c979897..90cc7711796 100644 --- a/packages/fxa-auth-server/test/local/routes/session.js +++ b/packages/fxa-auth-server/test/local/routes/session.js @@ -145,23 +145,287 @@ function getExpectedOtpCode(options = {}, secret = 'abcdef') { } describe('/session/status', () => { - const log = mocks.mockLog(); - const config = {}; - const routes = makeRoutes({ log, config }); - const route = getRoute(routes, '/session/status'); - const request = mocks.mockRequest({ - credentials: { - email: 'foo@example.org', - state: 'unverified', - uid: 'foo', - }, + let log, db, config, routes, route; + + beforeEach(() => { + sinon.reset(); + log = mocks.mockLog(); + db = { + account: () => {}, + totpToken: () => {}, + }; + config = {}; + routes = makeRoutes({ log, db, config }); + route = getRoute(routes, '/session/status'); + }); + + after(() => { + sinon.reset(); + }); + + it('returns unknown account error', async () => { + db.account = sinon.fake.returns(null); + let error; + try { + const request = mocks.mockRequest({ + credentials: { + uid: 'foo', + }, + }); + await runTest(route, request); + } catch (err) { + error = err; + } + assert.equal(error.message, 'Unknown account'); }); it('returns status correctly', () => { + db.account = sinon.fake.resolves({ + uid: 'account-123', + emails: {}, + }); + db.totpToken = sinon.fake.resolves({ + verified: false, + enabled: false, + }); + const request = mocks.mockRequest({ + credentials: { + email: 'foo@example.org', + state: 'unverified', + verificationMethod: 'totp-2fa', + verified: false, + tokenVerified: false, + tokenVerificationId: 'token-123', + uid: 'foo', + }, + }); return runTest(route, request).then((res) => { - assert.equal(Object.keys(res).length, 2); - assert.equal(res.uid, 'foo'); - assert.equal(res.state, 'unverified'); + assert.deepEqual(res, { + uid: 'foo', + state: 'unverified', + details: { + accountEmailVerified: false, + sessionVerificationMeetsMinimumAAL: false, + sessionVerificationMethod: 'totp-2fa', + sessionVerificationSuccessful: false, + }, + }); + }); + }); + + it('has unverified primary email', async () => { + db.account = sinon.fake.resolves({ + uid: 'account-123', + emails: {}, + }); + db.totpToken = sinon.fake.resolves({ + verified: false, + enabled: false, + }); + + const request = mocks.mockRequest({ + credentials: { + uid: 'account-123', + state: 'unverified', + verified: false, + tokenVerified: false, + verificationMethod: 'email', + authenticatorAssuranceLevel: 1, + }, + }); + const resp = await runTest(route, request); + + assert.deepEqual(resp, { + uid: 'account-123', + state: 'unverified', + details: { + accountEmailVerified: false, + sessionVerificationMethod: 'email', + sessionVerificationSuccessful: false, + sessionVerificationMeetsMinimumAAL: true, + }, + }); + }); + + it('has unverified session because of defined tokenVerificationId', async () => { + db.account = sinon.fake.resolves({ + uid: 'account-123', + emails: {}, + }); + db.totpToken = sinon.fake.resolves({ + verified: false, + enabled: false, + }); + + const request = mocks.mockRequest({ + credentials: { + uid: 'account-123', + state: 'unverified', + verified: false, + tokenVerificationId: 'token-123', + verificationMethod: 'email', + authenticatorAssuranceLevel: 1, + }, + }); + const resp = await runTest(route, request); + + assert.deepEqual(resp, { + uid: 'account-123', + state: 'unverified', + details: { + accountEmailVerified: false, + sessionVerificationMethod: 'email', + sessionVerificationSuccessful: false, + sessionVerificationMeetsMinimumAAL: true, + }, + }); + }); + + it('has unverified AAL 1', async () => { + db.account = sinon.fake.resolves({ + uid: 'account-123', + emails: { + primaryEmail: { + isVerified: true, + }, + }, + }); + db.totpToken = sinon.fake.resolves({ + verified: true, + enabled: true, + }); + + const request = mocks.mockRequest({ + credentials: { + uid: 'account-123', + state: 'unverified', + verified: false, + tokenVerified: false, + verificationMethod: 'email', + authenticatorAssuranceLevel: 1, + }, + }); + const resp = await runTest(route, request); + + assert.deepEqual(resp, { + uid: 'account-123', + state: 'unverified', + details: { + accountEmailVerified: true, + sessionVerificationMethod: 'email', + sessionVerificationSuccessful: false, + sessionVerificationMeetsMinimumAAL: false, + }, + }); + }); + + it('has unverified AAL 2', async () => { + db.account = sinon.fake.resolves({ + uid: 'account-123', + emails: { + primaryEmail: { + isVerified: true, + }, + }, + }); + db.totpToken = sinon.fake.resolves({ + verified: true, + enabled: true, + }); + + const request = mocks.mockRequest({ + credentials: { + uid: 'account-123', + state: 'verified', + verified: true, + verificationMethod: 'totp-2fa', + authenticatorAssuranceLevel: 1, + }, + }); + const resp = await runTest(route, request); + + assert.deepEqual(resp, { + uid: 'account-123', + state: 'verified', + details: { + accountEmailVerified: true, + sessionVerificationMethod: 'totp-2fa', + sessionVerificationSuccessful: true, + sessionVerificationMeetsMinimumAAL: false, + }, + }); + }); + + it('has verified AAL 1 state', async () => { + db.account = sinon.fake.resolves({ + uid: 'account-123', + emails: { + primaryEmail: { + isVerified: true, + }, + }, + }); + db.totpToken = sinon.fake.resolves({ + enabled: false, + }); + + const request = mocks.mockRequest({ + credentials: { + uid: 'account-123', + state: 'verified', + verified: true, + verificationMethod: 'email', + authenticatorAssuranceLevel: 1, + }, + }); + const resp = await runTest(route, request); + + assert.deepEqual(resp, { + uid: 'account-123', + state: 'verified', + details: { + accountEmailVerified: true, + sessionVerificationMethod: 'email', + sessionVerificationSuccessful: true, + sessionVerificationMeetsMinimumAAL: true, + }, + }); + }); + + it('has verified AAL 2', async () => { + db.account = sinon.fake.resolves({ + uid: 'account-123', + emails: { + primaryEmail: { + isVerified: true, + }, + }, + }); + db.totpToken = sinon.fake.resolves({ + verified: true, + enabled: true, + }); + + const request = mocks.mockRequest({ + credentials: { + uid: 'account-123', + state: 'verified', + verified: true, + verificationMethod: 'totp-2fa', + authenticatorAssuranceLevel: 2, + }, + }); + const resp = await runTest(route, request); + + assert.deepEqual(resp, { + uid: 'account-123', + state: 'verified', + details: { + accountEmailVerified: true, + sessionVerificationMethod: 'totp-2fa', + sessionVerificationSuccessful: true, + sessionVerificationMeetsMinimumAAL: true, + }, }); }); }); diff --git a/packages/fxa-auth-server/test/remote/session_tests.js b/packages/fxa-auth-server/test/remote/session_tests.js index a5a02cea51a..42cfa172ab1 100644 --- a/packages/fxa-auth-server/test/remote/session_tests.js +++ b/packages/fxa-auth-server/test/remote/session_tests.js @@ -538,6 +538,12 @@ const config = require('../../config').default.getProperties(); assert.deepEqual(x, { state: 'unverified', uid: uid, + details: { + accountEmailVerified: false, + sessionVerificationMeetsMinimumAAL: true, + sessionVerificationMethod: null, + sessionVerificationSuccessful: false, + }, }); }); });