From 9b5cb082d08c2dc9cddfd5d38cc6b3d8b08778c6 Mon Sep 17 00:00:00 2001 From: Mahesh Makani Date: Fri, 6 Sep 2024 10:22:58 +0100 Subject: [PATCH] feat(okta): Use `X-Forwarded-For` header to forward users ip address to Okta --- src/server/controllers/changePassword.ts | 21 +++-- src/server/controllers/checkPasswordToken.ts | 2 + .../controllers/sendChangePasswordEmail.ts | 28 ++++-- .../lib/__tests__/okta/api/users.test.ts | 38 ++++++-- .../lib/__tests__/okta/register.test.ts | 26 ++++-- src/server/lib/jobs.ts | 17 ++-- src/server/lib/middleware/login.ts | 1 + .../lib/middleware/redirectIfLoggedIn.ts | 1 + src/server/lib/okta/api/apps.ts | 2 +- src/server/lib/okta/api/authentication.ts | 21 +++-- src/server/lib/okta/api/headers.ts | 17 +++- src/server/lib/okta/api/sessions.ts | 10 +- src/server/lib/okta/api/users.ts | 92 +++++++++++++------ .../okta/dangerouslySetPlaceholderPassword.ts | 20 ++-- src/server/lib/okta/fixProfile.ts | 18 ++-- src/server/lib/okta/idx/challenge.ts | 17 +++- src/server/lib/okta/idx/credential.ts | 3 + src/server/lib/okta/idx/enroll.ts | 6 ++ src/server/lib/okta/idx/identify.ts | 3 + src/server/lib/okta/idx/interact.ts | 1 + src/server/lib/okta/idx/introspect.ts | 3 + src/server/lib/okta/idx/recover.ts | 3 + src/server/lib/okta/idx/shared/idxFetch.ts | 23 +++-- .../lib/okta/idx/shared/submitPasscode.ts | 5 + src/server/lib/okta/idx/startIdxFlow.ts | 1 + src/server/lib/okta/oauth.ts | 1 + src/server/lib/okta/openid-connect.ts | 63 ++++++++++--- src/server/lib/okta/register.ts | 64 +++++++++---- src/server/lib/okta/validateEmail.ts | 20 ++-- src/server/lib/registrationPlatform.ts | 30 ++++-- src/server/lib/unvalidatedEmail.ts | 4 +- src/server/lib/updateRegistrationLocation.ts | 14 ++- src/server/routes/agree.ts | 1 + src/server/routes/delete.ts | 18 ++-- src/server/routes/register.ts | 10 +- src/server/routes/signIn.ts | 22 +++-- src/server/routes/signOut.ts | 7 +- src/server/routes/welcome.ts | 12 ++- 38 files changed, 473 insertions(+), 172 deletions(-) diff --git a/src/server/controllers/changePassword.ts b/src/server/controllers/changePassword.ts index 99fa0322a..25b37e21d 100644 --- a/src/server/controllers/changePassword.ts +++ b/src/server/controllers/changePassword.ts @@ -85,6 +85,7 @@ const oktaIdxApiPasswordHandler = async ({ stateHandle: encryptedState.stateHandle, }, state.requestId, + req.ip, ); // validate the introspect response to make sure we're in the correct state @@ -112,7 +113,6 @@ const oktaIdxApiPasswordHandler = async ({ // the interaction code flow, eventually redirecting the user back to where they need to go. return await setPasswordAndRedirect({ stateHandle: encryptedState.stateHandle, - body: { passcode: password, }, @@ -120,6 +120,7 @@ const oktaIdxApiPasswordHandler = async ({ expressRes: res, path, request_id: state.requestId, + ip: req.ip, }); } } catch (error) { @@ -200,22 +201,26 @@ export const setPasswordController = ( const [recoveryToken, encryptedRegistrationConsents] = decryptedRecoveryToken; - // We exhange the Okta recovery token for a freshly minted short-lived state + // We exchange the Okta recovery token for a freshly minted short-lived state // token, to complete this change password operation. If the recovery token // is invalid, we will show the user the link expired page. const { stateToken } = await validateTokenInOkta({ recoveryToken, + ip: req.ip, }); if (stateToken) { - const { sessionToken, _embedded } = await resetPasswordInOkta({ - stateToken, - newPassword: password, - }); + const { sessionToken, _embedded } = await resetPasswordInOkta( + { + stateToken, + newPassword: password, + }, + req.ip, + ); const { id } = _embedded?.user ?? {}; if (id) { - await validateEmailAndPasswordSetSecurely(id); + await validateEmailAndPasswordSetSecurely(id, req.ip); } else { logger.error( 'Failed to set validation flags in Okta as there was no id', @@ -229,7 +234,7 @@ export const setPasswordController = ( // When a jobs user is registering, we add them to the GRS group and set their name if (clientId === 'jobs' && path === '/welcome') { if (id) { - await setupJobsUserInOkta(firstName, secondName, id); + await setupJobsUserInOkta(firstName, secondName, id, req.ip); trackMetric('JobsGRSGroupAgree::Success'); } else { logger.error( diff --git a/src/server/controllers/checkPasswordToken.ts b/src/server/controllers/checkPasswordToken.ts index 163abd72a..5e12923c0 100644 --- a/src/server/controllers/checkPasswordToken.ts +++ b/src/server/controllers/checkPasswordToken.ts @@ -135,6 +135,7 @@ const oktaIdxApiCheckHandler = async ({ stateHandle: encryptedState.stateHandle, }, state.requestId, + req.ip, ); if (path === '/welcome') { @@ -237,6 +238,7 @@ export const checkTokenInOkta = async ( // return an error and we will show the link expired page. const { _embedded } = await validateTokenInOkta({ recoveryToken, + ip: req.ip, }); const email = _embedded?.user.profile.login; diff --git a/src/server/controllers/sendChangePasswordEmail.ts b/src/server/controllers/sendChangePasswordEmail.ts index 4e0d1f699..45f791a62 100644 --- a/src/server/controllers/sendChangePasswordEmail.ts +++ b/src/server/controllers/sendChangePasswordEmail.ts @@ -83,7 +83,7 @@ export const sendEmailInOkta = async ( try { // get the user object to check user status - const user = await getUser(email); + const user = await getUser(email, req.ip); if (passcodesEnabled && usePasscodesResetPassword) { // TODO: implement passcode reset password flow @@ -97,7 +97,7 @@ export const sendEmailInOkta = async ( case Status.ACTIVE: // inner try-catch block to handle specific errors from sendForgotPasswordEmail try { - const token = await forgotPassword(user.id); + const token = await forgotPassword(user.id, req.ip); if (!token) { throw new OktaError({ message: `Okta user reset password failed: missing reset password token`, @@ -144,7 +144,7 @@ export const sendEmailInOkta = async ( // check for user does not have a password set // (to make sure we don't override any existing password) if (!user.credentials.password) { - await dangerouslySetPlaceholderPassword(user.id); + await dangerouslySetPlaceholderPassword(user.id, req.ip); // now that the placeholder password has been set, the user behaves like a // normal user (provider = OKTA) and we can send the email by calling this method again return sendEmailInOkta(req, res, true); @@ -163,7 +163,10 @@ export const sendEmailInOkta = async ( // this will put them into the PROVISIONED state // we will send them a create password email try { - const tokenResponse = await activateUser(user.id); + const tokenResponse = await activateUser({ + id: user.id, + ip: req.ip, + }); if (!tokenResponse?.token.length) { throw new OktaError({ message: `Okta user activation failed: missing activation token`, @@ -203,7 +206,10 @@ export const sendEmailInOkta = async ( // 1. deactivate the user try { - await deactivateUser(user.id); + await deactivateUser({ + id: user.id, + ip: req.ip, + }); trackMetric('OktaDeactivateUser::Success'); } catch (error) { trackMetric('OktaDeactivateUser::Failure'); @@ -246,7 +252,10 @@ export const sendEmailInOkta = async ( // this will keep them in the PROVISIONED state // we will send them a create password email try { - const tokenResponse = await reactivateUser(user.id); + const tokenResponse = await reactivateUser({ + id: user.id, + ip: req.ip, + }); if (!tokenResponse?.token.length) { throw new OktaError({ message: `Okta user reactivation failed: missing re-activation token`, @@ -286,7 +295,10 @@ export const sendEmailInOkta = async ( // 1. deactivate the user try { - await deactivateUser(user.id); + await deactivateUser({ + id: user.id, + ip: req.ip, + }); trackMetric('OktaDeactivateUser::Success'); } catch (error) { trackMetric('OktaDeactivateUser::Failure'); @@ -330,7 +342,7 @@ export const sendEmailInOkta = async ( // if the user is RECOVERY or PASSWORD_EXPIRED, we use the // dangerouslyResetPassword method to put them into the RECOVERY state // and send them a reset password email - const token = await dangerouslyResetPassword(user.id); + const token = await dangerouslyResetPassword(user.id, req.ip); if (!token) { throw new OktaError({ message: `Okta user reset password failed: missing reset password token`, diff --git a/src/server/lib/__tests__/okta/api/users.test.ts b/src/server/lib/__tests__/okta/api/users.test.ts index c09665251..8e38a84cc 100644 --- a/src/server/lib/__tests__/okta/api/users.test.ts +++ b/src/server/lib/__tests__/okta/api/users.test.ts @@ -242,7 +242,12 @@ describe('okta#activateUser', () => { Promise.resolve({ ok: true, json } as Response), ); - await expect(activateUser(userId, true)).resolves.toEqual(undefined); + await expect( + activateUser({ + id: userId, + sendEmail: true, + }), + ).resolves.toEqual(undefined); }); test('should throw an error when a user is already activated', async () => { @@ -259,7 +264,11 @@ describe('okta#activateUser', () => { Promise.resolve({ ok: false, status: 403, json } as Response), ); - await expect(activateUser(userId)).rejects.toThrowError( + await expect( + activateUser({ + id: userId, + }), + ).rejects.toThrowError( new OktaError({ message: 'Activation failed because the user is already active', }), @@ -277,7 +286,12 @@ describe('okta#reactivateUser', () => { Promise.resolve({ ok: true, json } as Response), ); - await expect(reactivateUser(userId, true)).resolves.toEqual(undefined); + await expect( + reactivateUser({ + id: userId, + sendEmail: true, + }), + ).resolves.toEqual(undefined); }); test('throw a an error when a user cannot be reactivated', async () => { @@ -295,7 +309,11 @@ describe('okta#reactivateUser', () => { Promise.resolve({ ok: false, status: 403, json } as Response), ); - await expect(reactivateUser(userId)).rejects.toThrow( + await expect( + reactivateUser({ + id: userId, + }), + ).rejects.toThrow( new OktaError({ message: "This operation is not allowed in the user's current status.", }), @@ -311,7 +329,11 @@ describe('okta#clearUserSessions', () => { test('should clear user sessions', async () => { mockedFetch.mockReturnValueOnce(Promise.resolve({ ok: true } as Response)); - await expect(clearUserSessions(userId)).resolves.toEqual(undefined); + await expect( + clearUserSessions({ + id: userId, + }), + ).resolves.toEqual(undefined); }); test('should throw an error when a user session cannot be cleared', async () => { @@ -328,7 +350,11 @@ describe('okta#clearUserSessions', () => { Promise.resolve({ ok: false, status: 404, json } as Response), ); - await expect(clearUserSessions(userId)).rejects.toThrow( + await expect( + clearUserSessions({ + id: userId, + }), + ).rejects.toThrow( new OktaError({ message: 'Not found: Resource not found: (User)', }), diff --git a/src/server/lib/__tests__/okta/register.test.ts b/src/server/lib/__tests__/okta/register.test.ts index 0e5a5ab5e..6907b7b5c 100644 --- a/src/server/lib/__tests__/okta/register.test.ts +++ b/src/server/lib/__tests__/okta/register.test.ts @@ -64,15 +64,27 @@ const mockedCreateOktaUser = const mockedFetchOktaUser = mocked<(id: string) => Promise>(getUser); const mockedActivateOktaUser = - mocked<(id: string, sendEmail: boolean) => Promise>( - activateUser, - ); + mocked< + ({ + id, + sendEmail, + }: { + id: string; + sendEmail: boolean; + }) => Promise + >(activateUser); const mockedReactivateOktaUser = - mocked<(id: string, sendEmail: boolean) => Promise>( - reactivateUser, - ); + mocked< + ({ + id, + sendEmail, + }: { + id: string; + sendEmail: boolean; + }) => Promise + >(reactivateUser); const mockedDangerouslyResetPassword = mocked< - (id: string, sendEmail: boolean) => Promise + (id: string) => Promise >(dangerouslyResetPassword); const mockedGetUserGroups = mocked<(id: string) => Promise>(getUserGroups); diff --git a/src/server/lib/jobs.ts b/src/server/lib/jobs.ts index 38f68bc96..c0b648a81 100644 --- a/src/server/lib/jobs.ts +++ b/src/server/lib/jobs.ts @@ -4,6 +4,7 @@ export const setupJobsUserInOkta = ( firstName: string, lastName: string, id: string, + ip?: string, ) => { if (firstName === '' || lastName === '') { throw new Error('Empty values not permitted for first or last name.'); @@ -17,11 +18,15 @@ export const setupJobsUserInOkta = ( // When `isJobsUser` is set to true, Madgex will see that the user belongs to the GRS group // because we have made the `isJobsUser` flag the source of truth for this group membership // when IDAPI returns the user's groups, overriding the value stored in Postgres. - return updateUser(id, { - profile: { - isJobsUser: true, - firstName, - lastName, + return updateUser( + id, + { + profile: { + isJobsUser: true, + firstName, + lastName, + }, }, - }); + ip, + ); }; diff --git a/src/server/lib/middleware/login.ts b/src/server/lib/middleware/login.ts index 897277cac..2225cd6ed 100644 --- a/src/server/lib/middleware/login.ts +++ b/src/server/lib/middleware/login.ts @@ -45,6 +45,7 @@ export const loginMiddlewareOAuth = async ( // if there is an okta session cookie, check if it is valid, if not `getSession` will throw an error await getCurrentSession({ idx: oktaIdentityEngineSessionCookieId, + ip: req.ip, }); } catch (error) { trackMetric('LoginMiddlewareOAuth::NoOktaSession'); diff --git a/src/server/lib/middleware/redirectIfLoggedIn.ts b/src/server/lib/middleware/redirectIfLoggedIn.ts index a6e23201c..0469b6bd9 100644 --- a/src/server/lib/middleware/redirectIfLoggedIn.ts +++ b/src/server/lib/middleware/redirectIfLoggedIn.ts @@ -46,6 +46,7 @@ export const redirectIfLoggedIn = async ( // (this throws if the session is invalid) const session = await getCurrentSession({ idx: oktaIdentityEngineSessionCookieId, + ip: req.ip, }); // pull the user email from the session, which we need to display diff --git a/src/server/lib/okta/api/apps.ts b/src/server/lib/okta/api/apps.ts index 32c075c81..498a692c9 100644 --- a/src/server/lib/okta/api/apps.ts +++ b/src/server/lib/okta/api/apps.ts @@ -32,7 +32,7 @@ export const getApp = async (id: string): Promise => { const path = buildUrl(`/api/v1/apps/:id`, { id }); const app = await fetch(joinUrl(okta.orgUrl, path), { - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(), ...authorizationHeader() }, }).then(handleAppResponse); AppCache.set(id, app); diff --git a/src/server/lib/okta/api/authentication.ts b/src/server/lib/okta/api/authentication.ts index cf2b62436..92c6c145d 100644 --- a/src/server/lib/okta/api/authentication.ts +++ b/src/server/lib/okta/api/authentication.ts @@ -40,16 +40,18 @@ const { okta } = getConfiguration(); * @param {string} body.token Token received as part of activation user request. This token is emailed to the user when they register, and can be (re)generated by calling the activate or reactivate endpoints in the Users API * see [src/server/lib/okta/api/users.ts](users.ts) * + * @param {string} ip - The IP address of the user * @returns Promise */ export const authenticate = async ( body: AuthenticationRequestParameters, + ip?: string, ): Promise => { const path = buildUrl('/api/v1/authn'); return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', body: JSON.stringify(body), - headers: defaultHeaders, + headers: defaultHeaders(ip), }).then(handleAuthenticationResponse); }; @@ -71,8 +73,10 @@ export const authenticate = async ( */ export const validateRecoveryToken = async ({ recoveryToken, + ip, }: { recoveryToken: string; + ip?: string; }): Promise => { const path = buildUrl('/api/v1/authn/recovery/token'); @@ -83,7 +87,7 @@ export const validateRecoveryToken = async ({ return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', body: JSON.stringify(body), - headers: defaultHeaders, + headers: defaultHeaders(ip), }).then(handleAuthenticationResponse); }; @@ -106,10 +110,13 @@ export const validateRecoveryToken = async ({ * * @returns Promise */ -export const resetPassword = async (body: { - stateToken: string; - newPassword: string; -}): Promise => { +export const resetPassword = async ( + body: { + stateToken: string; + newPassword: string; + }, + ip?: string, +): Promise => { const path = buildUrl('/api/v1/authn/credentials/reset_password'); if (await isBreachedPassword(body.newPassword)) { @@ -122,7 +129,7 @@ export const resetPassword = async (body: { return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', body: JSON.stringify(body), - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(handleAuthenticationResponse); }; diff --git a/src/server/lib/okta/api/headers.ts b/src/server/lib/okta/api/headers.ts index 2659551aa..fe304a5af 100644 --- a/src/server/lib/okta/api/headers.ts +++ b/src/server/lib/okta/api/headers.ts @@ -1,8 +1,19 @@ import { getConfiguration } from '@/server/lib/getConfiguration'; -export const defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', +export const defaultHeaders = (ip?: string) => { + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + + if (ip) { + return { + ...headers, + 'X-Forwarded-For': ip, + }; + } + + return headers; }; /** diff --git a/src/server/lib/okta/api/sessions.ts b/src/server/lib/okta/api/sessions.ts index 1c7734a5f..06b0ecc75 100644 --- a/src/server/lib/okta/api/sessions.ts +++ b/src/server/lib/okta/api/sessions.ts @@ -22,19 +22,22 @@ const { okta } = getConfiguration(); * or throw an error on a failed response. * * @param idx Okta Identity Engine session cookie + * @param ip The IP address of the user * @returns Promise */ export const getCurrentSession = async ({ idx, + ip, }: { idx?: string; + ip?: string; }): Promise => { const path = buildUrl('/api/v1/sessions/me'); const Cookie = `${idx ? `idx=${idx};` : ''}`; const response = await fetch(joinUrl(okta.orgUrl, path), { - headers: { ...defaultHeaders, Cookie }, + headers: { ...defaultHeaders(ip), Cookie }, credentials: 'include', }); @@ -62,12 +65,15 @@ export const getCurrentSession = async ({ * returns a 404 on invalid. * * @param idx Okta Identity Engine session cookie + * @param ip The IP address of the user * @returns Promise */ export const closeCurrentSession = async ({ idx, + ip, }: { idx?: string; + ip?: string; }): Promise => { const path = buildUrl('/api/v1/sessions/me'); @@ -75,7 +81,7 @@ export const closeCurrentSession = async ({ const response = await fetch(joinUrl(okta.orgUrl, path), { method: 'DELETE', - headers: { ...defaultHeaders, Cookie }, + headers: { ...defaultHeaders(ip), Cookie }, credentials: 'include', }); diff --git a/src/server/lib/okta/api/users.ts b/src/server/lib/okta/api/users.ts index 32c6b9a52..430c376f7 100644 --- a/src/server/lib/okta/api/users.ts +++ b/src/server/lib/okta/api/users.ts @@ -39,11 +39,13 @@ const { okta } = getConfiguration(); * https://developer.okta.com/docs/reference/api/users/#create-user * * @param {UserCreationRequest} body the request body to create a user in Okta + * @param {string} ip The IP Address of the user * * @returns Promise */ export const createUser = async ( body: UserCreationRequest, + ip?: string, ): Promise => { // If 'activate' is true, Okta will peform the activation lifecycle operation // on the user, which in the case of a user without a password will send them @@ -59,7 +61,7 @@ export const createUser = async ( return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', body: JSON.stringify(body), - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(handleUserResponse); }; @@ -72,18 +74,20 @@ export const createUser = async ( * @param id accepts the Okta user ID, email address (login) or login shortname (as long as it is unambiguous) * @param body the fields to update on the User object. This performs a partial update, so it is only necessary * to pass in the fields you wish to update. + * @param ip The IP address of the user * * @returns Promise */ export const updateUser = async ( id: string, body: UserUpdateRequest, + ip?: string, ): Promise => { const path = buildUrl('/api/v1/users/:id', { id }); return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', body: JSON.stringify(body), - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(handleUserResponse); }; @@ -94,14 +98,14 @@ export const updateUser = async ( * https://developer.okta.com/docs/reference/api/users/#get-user * * @param id accepts the Okta user ID, email address (login) or login shortname (as long as it is unambiguous) + * @param ip The IP address of the user * * @returns Promise */ - -export const getUser = (id: string): Promise => { +export const getUser = (id: string, ip?: string): Promise => { const path = buildUrl('/api/v1/users/:id', { id }); return fetch(joinUrl(okta.orgUrl, path), { - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(handleUserResponse); }; @@ -112,14 +116,14 @@ export const getUser = (id: string): Promise => { * https://developer.okta.com/docs/reference/api/users/#get-user-s-groups * * @param id accepts the Okta user ID, email address (login) or login shortname (as long as it is unambiguous) + * @param ip The IP address of the user * * @returns Promise */ - -export const getUserGroups = (id: string): Promise => { +export const getUserGroups = (id: string, ip?: string): Promise => { const path = buildUrl('/api/v1/users/:id/groups', { id }); return fetch(joinUrl(okta.orgUrl, path), { - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(handleGroupsResponse); }; @@ -144,13 +148,19 @@ export const getUserGroups = (id: string): Promise => { * * @param id accepts the Okta user ID, email address (login) or login shortname (as long as it is unambiguous) * @param sendEmail Sends an activation email to the user if true + * @param ip The IP address of the user * * @returns Promise */ -export const activateUser = async ( - id: string, +export const activateUser = async ({ + id, sendEmail = false, -): Promise => { + ip, +}: { + id: string; + sendEmail?: boolean; + ip?: string; +}): Promise => { const path = buildApiUrlWithQueryParams( '/api/v1/users/:id/lifecycle/activate', { id }, @@ -158,7 +168,7 @@ export const activateUser = async ( ); return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(async (response) => { return sendEmail ? await handleVoidResponse(response) @@ -190,13 +200,19 @@ export const activateUser = async ( * * @param id accepts the Okta user ID * @param sendEmail Sends an deactivation email to the user if true, default is false + * @param ip The IP address of the user * * @returns Promise */ -export const deactivateUser = async ( - id: string, +export const deactivateUser = async ({ + id, sendEmail = false, -): Promise => { + ip, +}: { + id: string; + sendEmail?: boolean; + ip?: string; +}): Promise => { const path = buildApiUrlWithQueryParams( '/api/v1/users/:id/lifecycle/deactivate', { id }, @@ -204,7 +220,7 @@ export const deactivateUser = async ( ); return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then((response) => { return handleVoidResponse(response); }); @@ -229,13 +245,19 @@ export const deactivateUser = async ( * * @param id accepts the Okta user ID, email address (login) or login shortname (as long as it is unambiguous) * @param sendEmail Sends an activation email to the user if true + * @param ip The IP address of the user * * @returns Promise */ -export const reactivateUser = async ( - id: string, +export const reactivateUser = async ({ + id, sendEmail = false, -): Promise => { + ip, +}: { + id: string; + sendEmail?: boolean; + ip?: string; +}): Promise => { const path = buildApiUrlWithQueryParams( '/api/v1/users/:id/lifecycle/reactivate', { id }, @@ -243,7 +265,7 @@ export const reactivateUser = async ( ); return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(async (response) => { return sendEmail ? await handleVoidResponse(response) @@ -276,9 +298,13 @@ export const reactivateUser = async ( * https://developer.okta.com/docs/reference/api/users/#reset-password * * @param id Okta user Id + * @param ip The IP Address of the user * @returns Promise */ -export const dangerouslyResetPassword = async (id: string): Promise => { +export const dangerouslyResetPassword = async ( + id: string, + ip?: string, +): Promise => { const path = buildApiUrlWithQueryParams( '/api/v1/users/:id/lifecycle/reset_password', { id }, @@ -286,7 +312,7 @@ export const dangerouslyResetPassword = async (id: string): Promise => { ); return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(handleResetPasswordUrlResponse); }; @@ -300,12 +326,18 @@ export const dangerouslyResetPassword = async (id: string): Promise => { * * @param id Okta user ID * @param oauthTokens (optional, default: `true`) Revoke issued OpenID Connect and OAuth refresh and access tokens + * @param ip The IP address of the user * @returns Promise */ -export const clearUserSessions = async ( - id: string, +export const clearUserSessions = async ({ + id, oauthTokens = true, -): Promise => { + ip, +}: { + id: string; + oauthTokens?: boolean; + ip?: string; +}): Promise => { const path = buildApiUrlWithQueryParams( '/api/v1/users/:id/sessions', { id }, @@ -315,7 +347,7 @@ export const clearUserSessions = async ( ); return await fetch(joinUrl(okta.orgUrl, path), { method: 'DELETE', - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(handleVoidResponse); }; @@ -329,9 +361,13 @@ export const clearUserSessions = async ( * https://developer.okta.com/docs/reference/api/users/#forgot-password * * @param id Okta user Id + * @param ip The IP address of the user * @returns Promise */ -export const forgotPassword = async (id: string): Promise => { +export const forgotPassword = async ( + id: string, + ip?: string, +): Promise => { const path = buildApiUrlWithQueryParams( '/api/v1/users/:id/credentials/forgot_password', { id }, @@ -339,7 +375,7 @@ export const forgotPassword = async (id: string): Promise => { ); return await fetch(joinUrl(okta.orgUrl, path), { method: 'POST', - headers: { ...defaultHeaders, ...authorizationHeader() }, + headers: { ...defaultHeaders(ip), ...authorizationHeader() }, }).then(handleResetPasswordUrlResponse); }; diff --git a/src/server/lib/okta/dangerouslySetPlaceholderPassword.ts b/src/server/lib/okta/dangerouslySetPlaceholderPassword.ts index 7905124fc..4bff9435f 100644 --- a/src/server/lib/okta/dangerouslySetPlaceholderPassword.ts +++ b/src/server/lib/okta/dangerouslySetPlaceholderPassword.ts @@ -13,14 +13,19 @@ import { dangerouslyResetPassword } from './api/users'; * 2. Uses the OTT to set a cryptographically secure placeholder password for the user. * After these operations, we can send the user a password reset email. * @param id The Okta user ID + * @param id The IP address of the user */ -const dangerouslySetPlaceholderPassword = async (id: string): Promise => { +const dangerouslySetPlaceholderPassword = async ( + id: string, + ip?: string, +): Promise => { try { // Generate an recoveryToken OTT and put user into RECOVERY state - const recoveryToken = await dangerouslyResetPassword(id); + const recoveryToken = await dangerouslyResetPassword(id, ip); // Validate the token const { stateToken } = await validateRecoveryToken({ recoveryToken, + ip, }); // Check if state token is defined if (!stateToken) { @@ -30,10 +35,13 @@ const dangerouslySetPlaceholderPassword = async (id: string): Promise => { }); } // Set the placeholder password as a cryptographically secure UUID - await resetPassword({ - stateToken, - newPassword: crypto.randomUUID(), - }); + await resetPassword( + { + stateToken, + newPassword: crypto.randomUUID(), + }, + ip, + ); } catch (error) { logger.error( `dangerouslySetPlaceholderPassword failed: Error setting placeholder password for user ${id}`, diff --git a/src/server/lib/okta/fixProfile.ts b/src/server/lib/okta/fixProfile.ts index dbbd4568a..624b81724 100644 --- a/src/server/lib/okta/fixProfile.ts +++ b/src/server/lib/okta/fixProfile.ts @@ -11,11 +11,11 @@ export const fixOktaProfile = async ({ }: { oktaId: string; email?: string; - ip: string | undefined; + ip?: string; request_id?: string; }): Promise => { try { - const oktaUser = await getUser(oktaId); + const oktaUser = await getUser(oktaId, ip); // Check the legacyIdentityId field. If it's set, we don't need to do anything. if (oktaUser.profile.legacyIdentityId) { return true; @@ -28,12 +28,16 @@ export const fixOktaProfile = async ({ if (!idapiUser.id) { throw new Error(`fixOktaProfile - IDAPI profile missing ID`); } - await updateUser(oktaId, { - profile: { - legacyIdentityId: idapiUser.id, - searchPartitionKey: sha256Hex(idapiUser.id), + await updateUser( + oktaId, + { + profile: { + legacyIdentityId: idapiUser.id, + searchPartitionKey: sha256Hex(idapiUser.id), + }, }, - }); + ip, + ); return true; } catch (error) { logger.warn('fixOktaProfile - Could not fix Okta profile', error); diff --git a/src/server/lib/okta/idx/challenge.ts b/src/server/lib/okta/idx/challenge.ts index 37c0fa216..e8bf3113a 100644 --- a/src/server/lib/okta/idx/challenge.ts +++ b/src/server/lib/okta/idx/challenge.ts @@ -63,12 +63,14 @@ type ChallengeResponse = z.infer; * @param stateHandle - The state handle from the `identify`/`introspect` step * @param body - The authenticator object, containing the authenticator id and method type * @param request_id - The request id + * @param ip - The ip address * @returns Promise - The given authenticator challenge response */ export const challenge = ( stateHandle: IdxBaseResponse['stateHandle'], body: AuthenticatorBody['authenticator'], request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'challenge', @@ -78,6 +80,7 @@ export const challenge = ( }, schema: challengeResponseSchema, request_id, + ip, }); }; @@ -151,12 +154,14 @@ type ChallengeAnswerPasswordBody = IdxStateHandleBody<{ * @param stateHandle - The state handle from the previous step * @param body - The passcode object, containing the passcode * @param request_id - The request id + * @param ip - The ip address * @returns Promise - The challenge answer response */ export const challengeAnswerPasscode = ( stateHandle: IdxBaseResponse['stateHandle'], body: ChallengeAnswerPasswordBody['credentials'], request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'challenge/answer', @@ -166,6 +171,7 @@ export const challengeAnswerPasscode = ( }, schema: challengeAnswerResponseSchema, request_id, + ip, }); }; @@ -175,11 +181,13 @@ export const challengeAnswerPasscode = ( * * @param stateHandle - The state handle from the previous step * @param request_id - The request id + * @param ip - The ip address * @returns Promise - The challenge answer response */ export const challengeResend = ( stateHandle: IdxBaseResponse['stateHandle'], request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'challenge/resend', @@ -188,6 +196,7 @@ export const challengeResend = ( }, schema: challengeAnswerResponseSchema, request_id, + ip, }); }; @@ -198,6 +207,7 @@ export const challengeResend = ( * @param body - The password object, containing the password * @param expressRes - The express response object * @param request_id - The request id + * @param ip - The ip address * @returns Promise - Performs a express redirect */ export const setPasswordAndRedirect = async ({ @@ -207,6 +217,7 @@ export const setPasswordAndRedirect = async ({ expressRes, path, request_id, + ip, }: { stateHandle: IdxBaseResponse['stateHandle']; body: ChallengeAnswerPasswordBody['credentials']; @@ -214,6 +225,7 @@ export const setPasswordAndRedirect = async ({ expressRes: ResponseWithRequestState; path?: string; request_id?: string; + ip?: string; }): Promise => { const [completionResponse, redirectUrl] = await idxFetchCompletion({ @@ -224,12 +236,13 @@ export const setPasswordAndRedirect = async ({ }, expressRes, request_id, + ip, }); // set the validation flags in Okta const { id } = completionResponse.user.value; if (id) { - await validateEmailAndPasswordSetSecurely(id); + await validateEmailAndPasswordSetSecurely(id, ip); } else { logger.error( 'Failed to set validation flags in Okta as there was no id', @@ -247,7 +260,7 @@ export const setPasswordAndRedirect = async ({ ) { if (id) { const { firstName, secondName } = expressReq.body; - await setupJobsUserInOkta(firstName, secondName, id); + await setupJobsUserInOkta(firstName, secondName, id, ip); trackMetric('JobsGRSGroupAgree::Success'); } else { logger.error( diff --git a/src/server/lib/okta/idx/credential.ts b/src/server/lib/okta/idx/credential.ts index c2fcdbdd4..dbdec7550 100644 --- a/src/server/lib/okta/idx/credential.ts +++ b/src/server/lib/okta/idx/credential.ts @@ -35,12 +35,14 @@ type CredentialEnrollResponse = z.infer; * @param stateHandle - The state handle from the `enroll` step * @param body - The authenticator object, containing the authenticator id and method type * @param request_id - The request id + * @param ip - The IP address of the user * @returns Promise - The credential enroll response */ export const credentialEnroll = ( stateHandle: IdxBaseResponse['stateHandle'], body: AuthenticatorBody['authenticator'], request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'credential/enroll', @@ -50,5 +52,6 @@ export const credentialEnroll = ( }, schema: credentialEnrollResponse, request_id, + ip, }); }; diff --git a/src/server/lib/okta/idx/enroll.ts b/src/server/lib/okta/idx/enroll.ts index 478be44c5..1d629756f 100644 --- a/src/server/lib/okta/idx/enroll.ts +++ b/src/server/lib/okta/idx/enroll.ts @@ -41,11 +41,13 @@ type EnrollResponse = z.infer; * * @param stateHandle - The state handle from the `introspect` step * @param request_id - The request id + * @param ip - The IP address of the user * @returns Promise - The enroll response */ export const enroll = ( stateHandle: IdxBaseResponse['stateHandle'], request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'enroll', @@ -54,6 +56,7 @@ export const enroll = ( }, schema: enrollResponseSchema, request_id, + ip, }); }; @@ -110,12 +113,14 @@ type EnrollNewResponse = z.infer; * @param stateHandle - The state handle from the `enroll`/`introspect` step * @param body - The user profile object, containing the email address * @param request_id - The request id + * @param ip - The IP address of the user * @returns Promise - The enroll new response */ export const enrollNewWithEmail = ( stateHandle: IdxBaseResponse['stateHandle'], body: EnrollNewWithEmailBody['userProfile'], request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'enroll/new', @@ -125,6 +130,7 @@ export const enrollNewWithEmail = ( }, schema: enrollNewResponseSchema, request_id, + ip, }); }; diff --git a/src/server/lib/okta/idx/identify.ts b/src/server/lib/okta/idx/identify.ts index cb1d27822..bc9eec7bd 100644 --- a/src/server/lib/okta/idx/identify.ts +++ b/src/server/lib/okta/idx/identify.ts @@ -51,12 +51,14 @@ type IdentifyBody = { * @param stateHandle - The state handle from the `introspect` step * @param email - The email address of the user * @param request_id - The request id + * @param ip - The IP address of the user * @returns Promise - The identify response */ export const identify = ( stateHandle: IdxBaseResponse['stateHandle'], email: string, request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'identify', @@ -67,6 +69,7 @@ export const identify = ( }, schema: identifyResponseSchema, request_id, + ip, }); }; diff --git a/src/server/lib/okta/idx/interact.ts b/src/server/lib/okta/idx/interact.ts index 3e775980f..afa35dad1 100644 --- a/src/server/lib/okta/idx/interact.ts +++ b/src/server/lib/okta/idx/interact.ts @@ -67,6 +67,7 @@ export const interact = async ( if (oktaIdentityEngineSessionCookieId) { await closeCurrentSession({ idx: oktaIdentityEngineSessionCookieId, + ip: req.ip, }); } } diff --git a/src/server/lib/okta/idx/introspect.ts b/src/server/lib/okta/idx/introspect.ts index 3fa9e9818..fd2b4eb7e 100644 --- a/src/server/lib/okta/idx/introspect.ts +++ b/src/server/lib/okta/idx/introspect.ts @@ -87,17 +87,20 @@ type IntrospectBody = * * @param interactionHandle - The interaction handle returned from the `interact` step * @param request_id - The request id + * @param ip - The IP address of the user * @returns Promise - The introspect response */ export const introspect = ( body: IntrospectBody, request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'introspect', body, schema: introspectResponseSchema, request_id, + ip, }); }; diff --git a/src/server/lib/okta/idx/recover.ts b/src/server/lib/okta/idx/recover.ts index 83e7d9ad8..81e9b2c12 100644 --- a/src/server/lib/okta/idx/recover.ts +++ b/src/server/lib/okta/idx/recover.ts @@ -57,11 +57,13 @@ type RecoverResponse = z.infer; * @description Okta IDX API/Interaction Code flow - Start password recovery process. * @param stateHandle - The state handle from the `identify`/`introspect` step * @param request_id - The request id + * @param ip - The IP address of the user * @returns Promise - The recover response */ export const recover = ( stateHandle: IdxBaseResponse['stateHandle'], request_id?: string, + ip?: string, ): Promise => { return idxFetch({ path: 'recover', @@ -70,5 +72,6 @@ export const recover = ( }, schema: recoverResponseSchema, request_id, + ip, }); }; diff --git a/src/server/lib/okta/idx/shared/idxFetch.ts b/src/server/lib/okta/idx/shared/idxFetch.ts index ccb0897d3..c114f1f9e 100644 --- a/src/server/lib/okta/idx/shared/idxFetch.ts +++ b/src/server/lib/okta/idx/shared/idxFetch.ts @@ -48,6 +48,7 @@ type IDXFetchParams = { schema: z.Schema; expressRes: ResponseWithRequestState; request_id?: string; + ip?: string; }; // type for the idx cookie which is string, but named for clarity type IdxCookie = string; @@ -58,20 +59,24 @@ type IdxCookie = string; * * @param path - The path to the IDX API endpoint * @param body - The body of the request + * @param ip - The IP address of the user * @returns Promise<[Response, IdxCookie | undefined]> - The response from the IDX API, and the IDX cookie if it exists */ const idxFetchBase = async ({ path, body, -}: Pick, 'path' | 'body'>): Promise< + ip, +}: Pick, 'path' | 'body' | 'ip'>): Promise< [Response, IdxCookie | undefined] > => { + const headers = { + Accept: 'application/ion+json; okta-version=1.0.0', + 'Content-Type': 'application/ion+json; okta-version=1.0.0', + }; + const response = await fetch(joinUrl(okta.orgUrl, `/idp/idx/${path}`), { method: 'POST', - headers: { - Accept: 'application/ion+json; okta-version=1.0.0', - 'Content-Type': 'application/ion+json; okta-version=1.0.0', - }, + headers: ip ? { ...headers, 'X-Forwarded-For': ip } : headers, body: JSON.stringify(body), }); @@ -90,6 +95,7 @@ const idxFetchBase = async ({ * @param body - The body of the request * @param schema - The zod schema to validate the response * @param request_id - The request id + * @param ip - The IP address of the user * @returns Promise - The response from the IDX API */ export const idxFetch = async ({ @@ -97,12 +103,13 @@ export const idxFetch = async ({ body, schema, request_id, + ip, }: Omit< IDXFetchParams, 'expressRes' >): Promise => { try { - const [response] = await idxFetchBase({ path, body }); + const [response] = await idxFetchBase({ path, body, ip }); if (!response.ok) { await handleError(response); @@ -130,6 +137,7 @@ export const idxFetch = async ({ * @param body - The body of the request * @param expressRes - The express response object * @param request_id - The request id + * @param ip - The IP address of the user * @returns Promise<[CompleteLoginResponse, Redirect String]> */ export const idxFetchCompletion = async ({ @@ -137,11 +145,12 @@ export const idxFetchCompletion = async ({ body, expressRes, request_id, + ip, }: Omit, 'schema'>): Promise< [CompleteLoginResponse, string] > => { try { - const [response, idxCookie] = await idxFetchBase({ path, body }); + const [response, idxCookie] = await idxFetchBase({ path, body, ip }); if (!response.ok) { await handleError(response); diff --git a/src/server/lib/okta/idx/shared/submitPasscode.ts b/src/server/lib/okta/idx/shared/submitPasscode.ts index 030df41ea..c00010448 100644 --- a/src/server/lib/okta/idx/shared/submitPasscode.ts +++ b/src/server/lib/okta/idx/shared/submitPasscode.ts @@ -20,6 +20,7 @@ import { * @param introspectRemediation The remediation object name to validate the introspect response against * @param challengeAnswerRemediation The remediation object name to validate the challenge answer response against * @param requestId The request id + * @param ip The IP address of the user * @returns Promise - The challenge answer response */ export const submitPasscode = async ({ @@ -28,12 +29,14 @@ export const submitPasscode = async ({ introspectRemediation, challengeAnswerRemediation, requestId, + ip, }: { passcode: string; stateHandle: string; introspectRemediation: IntrospectRemediationNames; challengeAnswerRemediation: ChallengeAnswerRemediationNames; requestId?: string; + ip?: string; }): Promise => { // validate the code contains only numbers and is 6 characters long // The okta api will validate the input fully, but validating here will prevent unnecessary requests @@ -53,6 +56,7 @@ export const submitPasscode = async ({ stateHandle, }, requestId, + ip, ); // check if the remediation array contains the correct remediation object supplied @@ -65,6 +69,7 @@ export const submitPasscode = async ({ stateHandle, { passcode }, requestId, + ip, ); // check if the remediation array contains the correct remediation object supplied diff --git a/src/server/lib/okta/idx/startIdxFlow.ts b/src/server/lib/okta/idx/startIdxFlow.ts index 41fcb141b..cbae2dd4c 100644 --- a/src/server/lib/okta/idx/startIdxFlow.ts +++ b/src/server/lib/okta/idx/startIdxFlow.ts @@ -67,6 +67,7 @@ export const startIdxFlow = async ({ interactionHandle: interaction_handle, }, request_id, + req.ip, ); // Encrypt any consents we need to preserve, if consents exist, i.e through the create account flow diff --git a/src/server/lib/okta/oauth.ts b/src/server/lib/okta/oauth.ts index 1a454ea61..8f01d1985 100644 --- a/src/server/lib/okta/oauth.ts +++ b/src/server/lib/okta/oauth.ts @@ -127,6 +127,7 @@ export const performAuthorizationCodeFlow = async ( if (oktaIdentityEngineSessionCookieId) { await closeCurrentSession({ idx: oktaIdentityEngineSessionCookieId, + ip: req.ip, }); } } diff --git a/src/server/lib/okta/openid-connect.ts b/src/server/lib/okta/openid-connect.ts index 8a0f72d64..a1e052339 100644 --- a/src/server/lib/okta/openid-connect.ts +++ b/src/server/lib/okta/openid-connect.ts @@ -1,4 +1,4 @@ -import { Issuer, IssuerMetadata, Client } from 'openid-client'; +import { Issuer, IssuerMetadata, Client, custom } from 'openid-client'; import { randomBytes } from 'crypto'; import { Request, CookieOptions } from 'express'; import { joinUrl } from '@guardian/libs'; @@ -131,11 +131,32 @@ export const ProfileOpenIdClientRedirectUris: OpenIdClientRedirectUris = { * @property `callbackParams` - Get OpenID Connect query parameters returned to the callback (redirect_uri) * @property `oauthCallback` - Method used in the callback (redirect_uri) endpoint to get OAuth tokens */ -const ProfileOpenIdClient = new OIDCIssuer.Client({ - client_id: okta.clientId, - client_secret: okta.clientSecret, - redirect_uris: Object.values(ProfileOpenIdClientRedirectUris), -}) as OpenIdClient; +const ProfileOpenIdClient = (ip?: string) => { + const client = new OIDCIssuer.Client({ + client_id: okta.clientId, + client_secret: okta.clientSecret, + redirect_uris: Object.values(ProfileOpenIdClientRedirectUris), + }); + + // Make sure we forward the IP address to Okta by adding it to the headers in the library calls + // https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing + // eslint-disable-next-line functional/immutable-data + client[custom.http_options] = (_, options) => { + // Add the IP address to the headers + const headers = options.headers || {}; + if (ip) { + // eslint-disable-next-line functional/immutable-data + headers['X-Forwarded-For'] = ip; + } + + return { + ...options, + headers, + }; + }; + + return client as OpenIdClient; +}; /** * @class DevProfileIdClient @@ -149,17 +170,36 @@ const ProfileOpenIdClient = new OIDCIssuer.Client({ * with the okta domain * @param devIssuer - The okta domain issuer url to use for development */ -const DevProfileIdClient = (devIssuer: string) => { +const DevProfileIdClient = (devIssuer: string, ip?: string) => { const devOidcIssuer = new Issuer({ ...OIDC_METADATA, issuer: issuer.replace(okta.orgUrl.replace('https://', ''), devIssuer), }); - return new devOidcIssuer.Client({ + const devClient = new devOidcIssuer.Client({ client_id: okta.clientId, client_secret: okta.clientSecret, redirect_uris: Object.values(ProfileOpenIdClientRedirectUris), - }) as OpenIdClient; + }); + + // Make sure we forward the IP address to Okta by adding it to the headers in the library calls + // https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing + // eslint-disable-next-line functional/immutable-data + devClient[custom.http_options] = (_, options) => { + // Add the IP address to the headers + const headers = options.headers || {}; + if (ip) { + // eslint-disable-next-line functional/immutable-data + headers['X-Forwarded-For'] = ip; + } + + return { + ...options, + headers, + }; + }; + + return devClient as OpenIdClient; }; /** @@ -175,9 +215,10 @@ const DevProfileIdClient = (devIssuer: string) => { */ export const getOpenIdClient = (req: Request): OpenIdClient => { if (stage === 'DEV' && req.get('X-GU-Okta-Env')) { - return DevProfileIdClient(req.get('X-GU-Okta-Env') as string); + return DevProfileIdClient(req.get('X-GU-Okta-Env') as string, req.ip); } - return ProfileOpenIdClient; + + return ProfileOpenIdClient(req.ip); }; /** diff --git a/src/server/lib/okta/register.ts b/src/server/lib/okta/register.ts index 33b37a564..c84337cda 100644 --- a/src/server/lib/okta/register.ts +++ b/src/server/lib/okta/register.ts @@ -54,13 +54,15 @@ const sendRegistrationEmailByUserState = async ({ ref, refViewId, loopDetectionFlag = false, + ip, }: { email: string; appClientId?: string; request_id?: string; loopDetectionFlag?: boolean; + ip?: string; } & TrackingQueryParams): Promise => { - const user = await getUser(email); + const user = await getUser(email, ip); const { id, status } = user; switch (status) { @@ -74,7 +76,10 @@ const sendRegistrationEmailByUserState = async ({ * token through Gateway * And my status should become PROVISIONED */ - const tokenResponse = await activateUser(user.id); + const tokenResponse = await activateUser({ + id: user.id, + ip, + }); if (!tokenResponse?.token.length) { throw new OktaError({ message: `Okta user activation failed: missing activation token`, @@ -116,7 +121,10 @@ const sendRegistrationEmailByUserState = async ({ // 1. deactivate the user try { - await deactivateUser(user.id); + await deactivateUser({ + id: user.id, + ip, + }); trackMetric('OktaDeactivateUser::Success'); } catch (error) { trackMetric('OktaDeactivateUser::Failure'); @@ -142,6 +150,7 @@ const sendRegistrationEmailByUserState = async ({ ref, refViewId, loopDetectionFlag: true, + ip, }); } @@ -171,7 +180,10 @@ const sendRegistrationEmailByUserState = async ({ * token through Gateway * And my status should remain PROVISIONED */ - const tokenResponse = await reactivateUser(user.id); + const tokenResponse = await reactivateUser({ + id: user.id, + ip, + }); if (!tokenResponse?.token.length) { throw new OktaError({ message: `Okta user reactivation failed: missing re-activation token`, @@ -213,7 +225,10 @@ const sendRegistrationEmailByUserState = async ({ // 1. deactivate the user try { - await deactivateUser(user.id); + await deactivateUser({ + id: user.id, + ip, + }); trackMetric('OktaDeactivateUser::Success'); } catch (error) { trackMetric('OktaDeactivateUser::Failure'); @@ -239,6 +254,7 @@ const sendRegistrationEmailByUserState = async ({ ref, refViewId, loopDetectionFlag: true, + ip, }); } @@ -279,7 +295,7 @@ const sendRegistrationEmailByUserState = async ({ const doesNotHavePassword = !user.credentials.password; - const groups = await getUserGroups(id); + const groups = await getUserGroups(id, ip); // check if the user has their email validated based on group membership const emailValidated = groups.some( (group) => group.profile.name === 'GuardianUser-EmailValidated', @@ -288,7 +304,7 @@ const sendRegistrationEmailByUserState = async ({ if (doesNotHavePassword) { // The user does not have a password set, so we set a placeholder // password first, then proceed with the rest of the operation. - await dangerouslySetPlaceholderPassword(user.id); + await dangerouslySetPlaceholderPassword(user.id, ip); } // Now the user has a password set, so we can get a reset password token // and send them an email which contains it, allowing them to immediately @@ -304,6 +320,7 @@ const sendRegistrationEmailByUserState = async ({ request_id, ref, refViewId, + ip, }); } else { // The user has a validated email and a password set, so we can send @@ -312,7 +329,7 @@ const sendRegistrationEmailByUserState = async ({ // their password, they will still be able to log in and can disregard // the token in the email). try { - const activationToken = await forgotPassword(id); + const activationToken = await forgotPassword(id, ip); await sendAccountExistsEmail({ to: user.profile.email, activationToken: await encryptOktaRecoveryToken({ @@ -354,7 +371,7 @@ const sendRegistrationEmailByUserState = async ({ * token through Gateway * And my status should become RECOVERY */ - const token = await dangerouslyResetPassword(user.id); + const token = await dangerouslyResetPassword(user.id, ip); if (!token) { throw new OktaError({ message: `Okta user reset password failed: missing reset password token`, @@ -406,26 +423,31 @@ export const register = async ({ consents, ref, refViewId, + ip, }: { email: string; registrationLocation?: RegistrationLocation; appClientId?: string; request_id?: string; consents?: RegistrationConsents; + ip?: string; } & TrackingQueryParams): Promise => { try { // Create the user in Okta, but do not send the activation email // because we send the email ourselves through Gateway. - const userResponse = await createUser({ - profile: { - email, - login: email, - isGuardianUser: true, - registrationPlatform: await getRegistrationPlatform(appClientId), - registrationLocation: registrationLocation, + const userResponse = await createUser( + { + profile: { + email, + login: email, + isGuardianUser: true, + registrationPlatform: await getRegistrationPlatform(appClientId), + registrationLocation: registrationLocation, + }, + groupIds: [okta.groupIds.GuardianUserAll], }, - groupIds: [okta.groupIds.GuardianUserAll], - }); + ip, + ); if (!userResponse) { throw new OktaError({ message: `Okta user creation failed: missing user response`, @@ -440,7 +462,10 @@ export const register = async ({ const encryptedConsents = consents && encryptRegistrationConsents(consents); // Generate an activation token for the new user... - const tokenResponse = await activateUser(id); + const tokenResponse = await activateUser({ + id, + ip, + }); if (!tokenResponse?.token.length) { throw new OktaError({ message: `Okta user creation failed: missing activation token`, @@ -476,6 +501,7 @@ export const register = async ({ email, appClientId, request_id, + ip, }); } else { throw error; diff --git a/src/server/lib/okta/validateEmail.ts b/src/server/lib/okta/validateEmail.ts index 929cc947c..34e31bc45 100644 --- a/src/server/lib/okta/validateEmail.ts +++ b/src/server/lib/okta/validateEmail.ts @@ -9,22 +9,28 @@ import { trackMetric } from '@/server/lib/trackMetric'; * Used to update the user has verified/validated their email and set a password securely. * * @param {string} id accepts the Okta user ID, email address (login) or login shortname (as long as it is unambiguous) + * @param {string} ip The IP address of the user * @returns {Promise} Promise that resolves to the user object */ export const validateEmailAndPasswordSetSecurely = async ( id: string, + ip?: string, ): Promise => { try { const timestamp = new Date().toISOString(); - const user = await updateUser(id, { - profile: { - emailValidated: true, - lastEmailValidatedTimestamp: timestamp, - passwordSetSecurely: true, - lastPasswordSetSecurelyTimestamp: timestamp, + const user = await updateUser( + id, + { + profile: { + emailValidated: true, + lastEmailValidatedTimestamp: timestamp, + passwordSetSecurely: true, + lastPasswordSetSecurelyTimestamp: timestamp, + }, }, - }); + ip, + ); trackMetric('OktaAccountVerification::Success'); trackMetric('OktaUpdatePassword::Success'); diff --git a/src/server/lib/registrationPlatform.ts b/src/server/lib/registrationPlatform.ts index b047db1aa..bdd19b9ea 100644 --- a/src/server/lib/registrationPlatform.ts +++ b/src/server/lib/registrationPlatform.ts @@ -34,11 +34,17 @@ export const getRegistrationPlatform = async ( } }; -export const updateRegistrationPlatform = async ( - accessToken: Jwt, - appClientId?: string, - request_id?: string, -): Promise => { +export const updateRegistrationPlatform = async ({ + accessToken, + appClientId, + request_id, + ip, +}: { + accessToken: Jwt; + appClientId?: string; + request_id?: string; + ip?: string; +}): Promise => { if (!accessToken) { throw new Error('No access token provided'); } @@ -46,18 +52,22 @@ export const updateRegistrationPlatform = async ( try { const registrationPlatform = await getRegistrationPlatform(appClientId); - const user = await getUser(accessToken.claims.sub); + const user = await getUser(accessToken.claims.sub, ip); // don't update users who already have a platform set if (user.profile.registrationPlatform) { return; } - await updateUser(accessToken.claims.sub, { - profile: { - registrationPlatform, + await updateUser( + accessToken.claims.sub, + { + profile: { + registrationPlatform, + }, }, - }); + ip, + ); } catch (error) { logger.error(`Error updating registrationLocation via Okta`, error, { request_id, diff --git a/src/server/lib/unvalidatedEmail.ts b/src/server/lib/unvalidatedEmail.ts index 09550482b..34a08ae2c 100644 --- a/src/server/lib/unvalidatedEmail.ts +++ b/src/server/lib/unvalidatedEmail.ts @@ -11,6 +11,7 @@ type Props = { email: string; appClientId?: string; request_id?: string; + ip?: string; } & TrackingQueryParams; /** @@ -29,8 +30,9 @@ export const sendEmailToUnvalidatedUser = async ({ request_id, ref, refViewId, + ip, }: Props): Promise => { - const token = await forgotPassword(id); + const token = await forgotPassword(id, ip); if (!token) { throw new OktaError({ message: `Unvalidated email sign-in failed: missing reset password token`, diff --git a/src/server/lib/updateRegistrationLocation.ts b/src/server/lib/updateRegistrationLocation.ts index 6333d9eb3..d62e606ac 100644 --- a/src/server/lib/updateRegistrationLocation.ts +++ b/src/server/lib/updateRegistrationLocation.ts @@ -21,18 +21,22 @@ export const updateRegistrationLocationViaOkta = async ( } try { - const user = await getUser(accessToken.claims.sub); + const user = await getUser(accessToken.claims.sub, req.ip); // don't update users who already have a location set if (user.profile.registrationLocation) { return; } - await updateUser(accessToken.claims.sub, { - profile: { - registrationLocation, + await updateUser( + accessToken.claims.sub, + { + profile: { + registrationLocation, + }, }, - }); + req.ip, + ); } catch (error) { logger.error( `${req.method} ${req.originalUrl} Error updating registrationLocation via Okta`, diff --git a/src/server/routes/agree.ts b/src/server/routes/agree.ts index f3413a470..398cae2c5 100644 --- a/src/server/routes/agree.ts +++ b/src/server/routes/agree.ts @@ -109,6 +109,7 @@ router.post( firstName, secondName, state.oauthState.idToken.claims.sub, + req.ip, ); // delete the ID token cookie, as we've updated the user model, diff --git a/src/server/routes/delete.ts b/src/server/routes/delete.ts index aa18c95bc..c378afee9 100644 --- a/src/server/routes/delete.ts +++ b/src/server/routes/delete.ts @@ -54,7 +54,7 @@ router.get( */ // get the current user profile - const user = await getUser(state.oauthState.idToken.claims.sub); + const user = await getUser(state.oauthState.idToken.claims.sub, req.ip); // check if the user has validated their email address if ( @@ -243,10 +243,13 @@ router.post( // attempt to authenticate with okta // if authentication fails, it will fall through to the catch - const response = await authenticate({ - username: email as string, - password, - }); + const response = await authenticate( + { + username: email as string, + password, + }, + req.ip, + ); // we only support the SUCCESS status for Okta authentication in gateway // Other statuses could be supported in the future https://developer.okta.com/docs/reference/api/authn/#transaction-state @@ -325,11 +328,11 @@ router.post( } // get the user's profile from okta - const user = await getUser(sub); + const user = await getUser(sub, req.ip); // if the user doesn't have a password set, set a placeholder password if (!user.credentials.password) { - await dangerouslySetPlaceholderPassword(user.id); + await dangerouslySetPlaceholderPassword(user.id, req.ip); } // attempt to send the email @@ -337,6 +340,7 @@ router.post( id: sub, email: user.profile.email, request_id: state.requestId, + ip: req.ip, }); // redirect to the email sent page diff --git a/src/server/routes/register.ts b/src/server/routes/register.ts index 4133e1a02..2209b42c2 100644 --- a/src/server/routes/register.ts +++ b/src/server/routes/register.ts @@ -153,6 +153,7 @@ router.post( introspectRemediation: 'enroll-authenticator', challengeAnswerRemediation: 'select-authenticator-enroll', requestId, + ip: req.ip, }); // if passcode challenge is successful, we can proceed to the next step @@ -179,6 +180,7 @@ router.post( stateHandle, { id: passwordAuthenticatorId, methodType: 'password' }, requestId, + req.ip, ); updateEncryptedStateCookie(req, res, { @@ -241,6 +243,7 @@ router.post( stateHandle, }, requestId, + req.ip, ); // check if the remediation array contains a "enroll-authenticator" object @@ -251,7 +254,7 @@ router.post( ); // attempt to resend the email - await challengeResend(stateHandle, requestId); + await challengeResend(stateHandle, requestId, req.ip); // redirect to the email sent page return res.redirect( @@ -354,6 +357,7 @@ export const OktaRegistration = async ( const enrollResponse = await enroll( introspectResponse.stateHandle, request_id, + req.ip, ); // if we don't have the `enroll-profile` remediation property @@ -382,6 +386,7 @@ export const OktaRegistration = async ( registrationPlatform: await getRegistrationPlatform(appClientId), }, request_id, + req.ip, ); // we need to check if the email has been sent to the user, or if @@ -417,6 +422,7 @@ export const OktaRegistration = async ( enrollNewWithEmailResponse.stateHandle, { id: emailAuthenticatorId, methodType: 'email' }, request_id, + req.ip, ); } @@ -472,6 +478,7 @@ export const OktaRegistration = async ( consents, ref, refViewId, + ip: req.ip, }); // fire ophan component event if applicable @@ -550,6 +557,7 @@ const OktaResendEmail = async (req: Request, res: ResponseWithRequestState) => { appClientId: queryParams?.appClientId, ref: queryParams?.ref, refViewId: queryParams?.refViewId, + ip: req.ip, }); trackMetric('OktaRegistrationResendEmail::Success'); setEncryptedStateCookieForOktaRegistration(res, user); diff --git a/src/server/routes/signIn.ts b/src/server/routes/signIn.ts index 314993416..e86c20484 100644 --- a/src/server/routes/signIn.ts +++ b/src/server/routes/signIn.ts @@ -145,9 +145,9 @@ router.post( }); } - const user = await getUser(email); + const user = await getUser(email, req.ip); const { id } = user; - const groups = await getUserGroups(id); + const groups = await getUserGroups(id, req.ip); // check if the user has their email validated based on group membership const emailValidated = groups.some( (group) => group.profile.name === 'GuardianUser-EmailValidated', @@ -164,6 +164,7 @@ router.post( email: user.profile.email, appClientId, request_id, + ip: req.ip, }); setEncryptedStateCookie(res, { email: user.profile.email, @@ -304,6 +305,7 @@ const oktaSignInController = async ({ // if a session already exists then we redirect back to the returnUrl for apps, and the dotcom homepage for web await getCurrentSession({ idx: oktaIdentityEngineSessionCookieId, + ip: req.ip, }); return res.redirect(accountManagementUrl); } @@ -322,10 +324,13 @@ const oktaSignInController = async ({ // if authentication fails, it will fall through to the catch // the response contains a one time use sessionToken that we can exchange // for a session cookie - const response = await authenticateWithOkta({ - username: email, - password, - }); + const response = await authenticateWithOkta( + { + username: email, + password, + }, + req.ip, + ); // we only support the SUCCESS status for Okta authentication in gateway // Other statuses could be supported in the future https://developer.okta.com/docs/reference/api/authn/#transaction-state @@ -353,7 +358,7 @@ const oktaSignInController = async ({ if (response._embedded?.user.id) { // retrieve the user groups - const groups = await getUserGroups(response._embedded.user.id); + const groups = await getUserGroups(response._embedded.user.id, req.ip); // check if the user has their email validated based on group membership const emailValidated = groups.some( @@ -384,6 +389,7 @@ const oktaSignInController = async ({ email: response._embedded.user.profile.login, appClientId, request_id, + ip: req.ip, }); setEncryptedStateCookie(res, { email: response._embedded.user.profile.login, @@ -454,6 +460,7 @@ router.get( // the idx cookie value to Okta. await getCurrentSession({ idx: oktaIdentityEngineSessionCookieId, + ip: req.ip, }); return performAuthorizationCodeFlow(req, res, { doNotSetLastAccessCookie: true, @@ -511,6 +518,7 @@ router.get( interactionHandle: interaction_handle, }, res.locals.requestId, + req.ip, ); const updatedAuthState = updateAuthorizationStateData(authState, { diff --git a/src/server/routes/signOut.ts b/src/server/routes/signOut.ts index 22166dec4..2d5ef417b 100644 --- a/src/server/routes/signOut.ts +++ b/src/server/routes/signOut.ts @@ -124,6 +124,7 @@ const signOutFromOktaLocal = async ( if (oktaIdentityEngineSessionCookieId) { await closeCurrentSession({ idx: oktaIdentityEngineSessionCookieId, + ip: req.ip, }); trackMetric('OktaSignOut::Success'); } @@ -189,8 +190,12 @@ const signOutFromOktaGlobal = async ( if (oktaIdentityEngineSessionCookieId) { const { userId } = await getCurrentSession({ idx: oktaIdentityEngineSessionCookieId, + ip: req.ip, + }); + await clearUserSessions({ + id: userId, + ip: req.ip, }); - await clearUserSessions(userId); trackMetric('OktaSignOutGlobal::Success'); } } catch (error) { diff --git a/src/server/routes/welcome.ts b/src/server/routes/welcome.ts index 96fce9766..5042ae6c7 100644 --- a/src/server/routes/welcome.ts +++ b/src/server/routes/welcome.ts @@ -127,11 +127,12 @@ router.post( // update the registration platform for social users, as we're not able to do this // at the time of registration, as that happens in Okta - await updateRegistrationPlatform( - state.oauthState.accessToken, - state.queryParams.appClientId, - state.requestId, - ); + await updateRegistrationPlatform({ + accessToken: state.oauthState.accessToken, + appClientId: state.queryParams.appClientId, + request_id: state.requestId, + ip: req.ip, + }); const runningInCypress = process.env.RUNNING_IN_CYPRESS === 'true'; @@ -500,6 +501,7 @@ const OktaResendEmail = async (req: Request, res: ResponseWithRequestState) => { email, appClientId: state.queryParams.appClientId, request_id: state.requestId, + ip: req.ip, }); trackMetric('OktaWelcomeResendEmail::Success');