From 26d491b3e121f65a53730089ef3384ef0c1674ae Mon Sep 17 00:00:00 2001 From: Baozier Date: Wed, 28 Aug 2024 21:28:00 -0400 Subject: [PATCH] Add more tests for token exchange by auth code and session (#132) --- server/src/routes/__tests__/identity.test.tsx | 44 +-- server/src/routes/__tests__/oauth.test.tsx | 275 ++++++++++++++++-- server/src/tests/util.ts | 8 + 3 files changed, 277 insertions(+), 50 deletions(-) diff --git a/server/src/routes/__tests__/identity.test.tsx b/server/src/routes/__tests__/identity.test.tsx index f98a9c50..0543ec98 100644 --- a/server/src/routes/__tests__/identity.test.tsx +++ b/server/src/routes/__tests__/identity.test.tsx @@ -17,6 +17,9 @@ import { appModel, userModel, } from 'models' import { oauthDto } from 'dtos' +import { + enrollEmailMfa, enrollOtpMfa, +} from 'tests/util' let db: Database @@ -53,19 +56,20 @@ export const getApp = (db: Database) => { return appRecord } -export const getAuthorizeParams = (appRecord: appModel.Record) => { +export const getAuthorizeParams = async (appRecord: appModel.Record) => { + const codeChallenge = await genCodeChallenge('abc') let params = '' params += `?client_id=${appRecord.clientId}&redirect_uri=http://localhost:3000/en/dashboard` params += '&response_type=code&state=123&locale=en' params += '&scope=openid%20profile%20offline_access' - params += '&code_challenge_method=S256&code_challenge=abc' + params += `&code_challenge_method=S256&code_challenge=${codeChallenge}` return params } export const getSignInRequest = async ( db: Database, url: string, appRecord: appModel.Record, ) => { - const params = getAuthorizeParams(appRecord) + const params = await getAuthorizeParams(appRecord) const res = await app.request( `${url}${params}`, @@ -120,7 +124,7 @@ const prepareFollowUpParams = async () => { return `?state=123&redirect_uri=http://localhost:3000/en/dashboard&locale=en&code=${json.code}` } -const prepareFollowUpBody = async () => { +export const prepareFollowUpBody = async (db: Database) => { const appRecord = getApp(db) const res = await postSignInRequest( db, @@ -397,7 +401,7 @@ describe( 'should show sign up page', async () => { const appRecord = getApp(db) - const params = getAuthorizeParams(appRecord) + const params = await getAuthorizeParams(appRecord) const res = await app.request( `${BaseRoute}/authorize-account${params}`, @@ -423,7 +427,7 @@ describe( global.process.env.ENABLE_NAMES = false as unknown as string const appRecord = getApp(db) - const params = getAuthorizeParams(appRecord) + const params = await getAuthorizeParams(appRecord) const res = await app.request( `${BaseRoute}/authorize-account${params}`, @@ -604,7 +608,7 @@ describe( 'should show reset page', async () => { const appRecord = getApp(db) - const params = getAuthorizeParams(appRecord) + const params = await getAuthorizeParams(appRecord) const res = await app.request( `${BaseRoute}/authorize-reset${params}`, @@ -822,7 +826,7 @@ describe( db, false, ) - const body = await prepareFollowUpBody() + const body = await prepareFollowUpBody(db) const res = await app.request( `${BaseRoute}/authorize-mfa-enroll`, @@ -860,7 +864,7 @@ describe( db, false, ) - const body = await prepareFollowUpBody() + const body = await prepareFollowUpBody(db) const res = await app.request( `${BaseRoute}/authorize-mfa-enroll`, @@ -935,7 +939,7 @@ describe( db, false, ) - db.prepare('update user set mfaTypes = ?').run('otp') + enrollOtpMfa(db) const res = await testGetOtpMfa('/authorize-otp-mfa') const html = await res.text() const dom = new JSDOM(html) @@ -957,7 +961,7 @@ describe( db, false, ) - db.prepare('update user set mfaTypes = ?').run('otp') + enrollOtpMfa(db) const res = await testGetOtpMfa('/authorize-otp-mfa') const html = await res.text() const dom = new JSDOM(html) @@ -981,8 +985,8 @@ describe( db, false, ) - db.prepare('update user set mfaTypes = ? where id = 1').run('otp') - const body = await prepareFollowUpBody() + enrollOtpMfa(db) + const body = await prepareFollowUpBody(db) const currentUser = db.prepare('select * from user where id = 1').get() as userModel.Raw const token = authenticator.generate(currentUser.otpSecret) @@ -1032,7 +1036,7 @@ describe( db, false, ) - db.prepare('update user set mfaTypes = ? where id = 1').run('email') + enrollEmailMfa(db) const params = await prepareFollowUpParams() const res = await app.request( @@ -1063,8 +1067,8 @@ describe( db, false, ) - db.prepare('update user set mfaTypes = ? where id = 1').run('email') - const body = await prepareFollowUpBody() + enrollEmailMfa(db) + const body = await prepareFollowUpBody(db) const res = await app.request( `${BaseRoute}/resend-email-mfa`, @@ -1096,7 +1100,7 @@ describe( db, false, ) - db.prepare('update user set mfaTypes = ? where id = 1').run('email') + enrollEmailMfa(db) const params = await prepareFollowUpParams() await app.request( @@ -1142,8 +1146,8 @@ describe( db, false, ) - db.prepare('update user set mfaTypes = ? where id = 1').run('email') - const body = await prepareFollowUpBody() + enrollEmailMfa(db) + const body = await prepareFollowUpBody(db) await app.request( `${BaseRoute}/resend-email-mfa`, @@ -1230,7 +1234,7 @@ describe( db, false, ) - const body = await prepareFollowUpBody() + const body = await prepareFollowUpBody(db) const res = await app.request( `${BaseRoute}/authorize-consent`, diff --git a/server/src/routes/__tests__/oauth.test.tsx b/server/src/routes/__tests__/oauth.test.tsx index a54406b4..e60e25f8 100644 --- a/server/src/routes/__tests__/oauth.test.tsx +++ b/server/src/routes/__tests__/oauth.test.tsx @@ -4,16 +4,22 @@ import { import { Database } from 'better-sqlite3' import app from 'index' import { + kv, migrate, mock, session, } from 'tests/mock' -import { routeConfig } from 'configs' +import { + adapterConfig, routeConfig, +} from 'configs' import { getApp, getAuthorizeParams, getSignInRequest, insertUsers, postSignInRequest, + prepareFollowUpBody, } from 'routes/__tests__/identity.test' import { oauthDto } from 'dtos' import { appModel } from 'models' -import { dbTime } from 'tests/util' +import { + dbTime, enrollEmailMfa, enrollOtpMfa, +} from 'tests/util' let db: Database @@ -40,48 +46,182 @@ describe( url, appRecord, ) - const params = getAuthorizeParams(appRecord) + const params = await getAuthorizeParams(appRecord) expect(res.status).toBe(302) expect(res.headers.get('Location')).toBe(`/identity/v1/authorize-password${params}`) }, ) + + test( + 'could login through session', + async () => { + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = false as unknown as string + const appRecord = getApp(db) + insertUsers(db) + await postSignInRequest( + db, + appRecord, + ) + + const url = `${BaseRoute}/authorize` + const res = await getSignInRequest( + db, + url, + appRecord, + ) + expect(res.status).toBe(302) + const path = res.headers.get('Location') + expect(path).toContain('http://localhost:3000/en/dashboard?code') + const code = path!.split('?')[1].split('&')[0].split('=')[1] + const tokenRes = await app.request( + `${BaseRoute}/token`, + { + method: 'POST', + body: new URLSearchParams({ + grant_type: oauthDto.TokenGrantType.AuthorizationCode, + code, + code_verifier: 'abc', + }).toString(), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + mock(db), + ) + expect(tokenRes.status).toBe(200) + + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = true as unknown as string + }, + ) + + test( + 'could login through session and bypass mfa', + async () => { + global.process.env.OTP_MFA_IS_REQUIRED = true as unknown as string + global.process.env.EMAIL_MFA_IS_REQUIRED = true as unknown as string + const appRecord = getApp(db) + insertUsers(db) + await postSignInRequest( + db, + appRecord, + ) + + const body = await prepareFollowUpBody(db) + kv[`${adapterConfig.BaseKVKey.OtpMfaCode}-${body.code}`] = 'aaaaaaaa' + await app.request( + `${routeConfig.InternalRoute.Identity}/authorize-otp-mfa`, + { + method: 'POST', + body: JSON.stringify({ + ...body, + mfaCode: 'aaaaaaaa', + }), + }, + mock(db), + ) + + kv[`${adapterConfig.BaseKVKey.EmailMfaCode}-${body.code}`] = 'bbbbbbbb' + await app.request( + `${routeConfig.InternalRoute.Identity}/authorize-email-mfa`, + { + method: 'POST', + body: JSON.stringify({ + ...body, + mfaCode: 'bbbbbbbb', + }), + }, + mock(db), + ) + + const url = `${BaseRoute}/authorize` + const res = await getSignInRequest( + db, + url, + appRecord, + ) + expect(res.status).toBe(302) + const path = res.headers.get('Location') + expect(path).toContain('http://localhost:3000/en/dashboard?code') + const code = path!.split('?')[1].split('&')[0].split('=')[1] + const tokenRes = await app.request( + `${BaseRoute}/token`, + { + method: 'POST', + body: new URLSearchParams({ + grant_type: oauthDto.TokenGrantType.AuthorizationCode, + code, + code_verifier: 'abc', + }).toString(), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + mock(db), + ) + expect(tokenRes.status).toBe(200) + global.process.env.OTP_MFA_IS_REQUIRED = false as unknown as string + global.process.env.EMAIL_MFA_IS_REQUIRED = false as unknown as string + }, + ) + + test( + 'could disable session', + async () => { + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = false as unknown as string + global.process.env.SERVER_SESSION_EXPIRES_IN = 0 as unknown as string + const appRecord = getApp(db) + insertUsers(db) + await postSignInRequest( + db, + appRecord, + ) + + const url = `${BaseRoute}/authorize` + const res = await getSignInRequest( + db, + url, + appRecord, + ) + const params = await getAuthorizeParams(appRecord) + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toBe(`/identity/v1/authorize-password${params}`) + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = true as unknown as string + global.process.env.SERVER_SESSION_EXPIRES_IN = 1800 as unknown as string + }, + ) }, ) +const exchangeWithAuthToken = async () => { + const appRecord = getApp(db) + + const res = await postSignInRequest( + db, + appRecord, + ) + const json = await res.json() as { code: string } + + const body = { + grant_type: oauthDto.TokenGrantType.AuthorizationCode, + code: json.code, + code_verifier: 'abc', + } + const tokenRes = await app.request( + `${BaseRoute}/token`, + { + method: 'POST', + body: new URLSearchParams(body).toString(), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + mock(db), + ) + return tokenRes +} + describe( '/token', () => { - const exchangeWithAuthToken = async () => { - const appRecord = getApp(db) - insertUsers(db) - - const res = await postSignInRequest( - db, - appRecord, - ) - const json = await res.json() as { code: string } - - const body = { - grant_type: oauthDto.TokenGrantType.AuthorizationCode, - code: json.code, - code_verifier: 'abc', - } - const tokenRes = await app.request( - `${BaseRoute}/token`, - { - method: 'POST', - body: new URLSearchParams(body).toString(), - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }, - mock(db), - ) - return tokenRes - } - test( 'could get token use auth code', async () => { global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = false as unknown as string + insertUsers(db) const tokenRes = await exchangeWithAuthToken() const tokenJson = await tokenRes.json() @@ -106,6 +246,7 @@ describe( 'could get token use refresh token', async () => { global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = false as unknown as string + insertUsers(db) const tokenRes = await exchangeWithAuthToken() const tokenJson = await tokenRes.json() as { refresh_token: string } @@ -169,6 +310,80 @@ describe( }, ) +describe( + 'auth-code token exchange', + () => { + test( + 'should fail if consent to app is required', + async () => { + insertUsers( + db, + false, + ) + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = false as unknown as string + const tokenRes = await exchangeWithAuthToken() + expect(tokenRes.status).toBe(401) + + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = true as unknown as string + }, + ) + + test( + 'should fail if mfa enroll is required', + async () => { + insertUsers(db) + const tokenRes = await exchangeWithAuthToken() + expect(tokenRes.status).toBe(401) + }, + ) + + test( + 'should fail if otp mfa is required', + async () => { + global.process.env.OTP_MFA_IS_REQUIRED = true as unknown as string + insertUsers(db) + const tokenRes = await exchangeWithAuthToken() + expect(tokenRes.status).toBe(401) + + global.process.env.OTP_MFA_IS_REQUIRED = false as unknown as string + }, + ) + + test( + 'should fail if enrolled with otp mfa', + async () => { + insertUsers(db) + enrollOtpMfa(db) + const tokenRes = await exchangeWithAuthToken() + expect(tokenRes.status).toBe(401) + global.process.env.OTP_MFA_IS_REQUIRED = false as unknown as string + }, + ) + + test( + 'should fail if email mfa is required', + async () => { + global.process.env.EMAIL_MFA_IS_REQUIRED = true as unknown as string + insertUsers(db) + const tokenRes = await exchangeWithAuthToken() + expect(tokenRes.status).toBe(401) + global.process.env.EMAIL_MFA_IS_REQUIRED = false as unknown as string + }, + ) + + test( + 'should fail if enrolled with email mfa', + async () => { + insertUsers(db) + enrollEmailMfa(db) + const tokenRes = await exchangeWithAuthToken() + expect(tokenRes.status).toBe(401) + global.process.env.OTP_MFA_IS_REQUIRED = false as unknown as string + }, + ) + }, +) + describe( '/logout', () => { diff --git a/server/src/tests/util.ts b/server/src/tests/util.ts index 83ef06c5..b03ae584 100644 --- a/server/src/tests/util.ts +++ b/server/src/tests/util.ts @@ -58,6 +58,14 @@ export const attachIndividualScopes = (db: Database) => { }) } +export const enrollOtpMfa = (db: Database) => { + db.prepare('update user set mfaTypes = ? where id = 1').run('otp') +} + +export const enrollEmailMfa = (db: Database) => { + db.prepare('update user set mfaTypes = ? where id = 1').run('email') +} + export const getS2sToken = async ( db: Database, scope: string = 'root', ) => {