Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GET /signout/all endpoint #2472

Merged
merged 1 commit into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/okta/signout.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,21 @@ sequenceDiagram
note over Gateway: Set `GU_SO` cookie
Gateway->>Browser: Redirect request `returnUrl`<br/>which tells the browser to clear any cookies
```

# Global sign out

User help sometimes sends users to a global sign out endpoint which will sign them out of all devices and browsers which is helpful for specific user scenarios. This was previously done by sending users to the `/signout` endpoint which would sign them out of all devices and browsers in Okta and Identity.

This new endpoint is `GET /signout/all` which does this behaviour.

Okta provides an administrative API endpoint within the Users API to clear all user sessions, and we can use this to invalidate all sessions for a user, as well as revoke all access and refresh tokens that are currently valid.

https://developer.okta.com/docs/reference/api/users/#clear-user-sessions

This endpoint requires the okta `userId`, which we get from the okta sessions api using the current okta session id cookie `idx`

We then use this user id to clear all Okta sessions for that user.

We also need to clear the Identity session too, we do this by calling the `/unauth` endpoint on the Identity API, which will clear the IDAPI session using the`SC_GU_U` cookie.

We then set the `GU_SO` which identifies that the user has signed out recently.
34 changes: 34 additions & 0 deletions src/server/lib/__tests__/okta/api/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
reactivateUser,
dangerouslyResetPassword,
getUserGroups,
clearUserSessions,
} from '@/server/lib/okta/api/users';
import { OktaError } from '@/server/models/okta/Error';
import { UserCreationRequest, UserResponse } from '@/server/models/okta/User';
Expand Down Expand Up @@ -302,6 +303,39 @@ describe('okta#reactivateUser', () => {
});
});

describe('okta#clearUserSessions', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('should clear user sessions', async () => {
mockedFetch.mockReturnValueOnce(Promise.resolve({ ok: true } as Response));

await expect(clearUserSessions(userId)).resolves.toEqual(undefined);
});

test('should throw an error when a user session cannot be cleared', async () => {
const errorResponse = {
errorCode: 'E0000007',
errorSummary: 'Not found: Resource not found: <userId> (User)',
errorLink: 'E0000007',
errorId: 'oaeZm9ypzgqQOq0n4PYgiFlZQ',
errorCauses: [],
};

json.mockResolvedValueOnce(errorResponse);
mockedFetch.mockReturnValueOnce(
Promise.resolve({ ok: false, status: 404, json } as Response),
);

await expect(clearUserSessions(userId)).rejects.toThrow(
new OktaError({
message: 'Not found: Resource not found: <userId> (User)',
}),
);
});
});

describe('okta#dangerouslyResetPassword', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
39 changes: 39 additions & 0 deletions src/server/lib/idapi/unauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
APIAddClientAccessToken,
APIForwardSessionIdentifier,
APIPostOptions,
idapiFetch,
IDAPIError,
} from '@/server/lib/IDAPIFetch';
import { logger } from '@/server/lib/serverSideLogger';
import { IdapiError } from '@/server/models/Error';
import { GenericErrors } from '@/shared/model/Errors';
import { IdapiCookies } from '@/server/lib/idapi/IDAPICookies';

const handleError = ({ status = 500 }: IDAPIError) => {
throw new IdapiError({ message: GenericErrors.DEFAULT, status });
};

export const logoutFromIDAPI = async (
sc_gu_u: string,
ip: string,
request_id?: string,
): Promise<IdapiCookies | undefined> => {
const options = APIAddClientAccessToken(
APIForwardSessionIdentifier(APIPostOptions(), sc_gu_u),
ip,
);
try {
const response = await idapiFetch({
path: '/unauth',
options,
});

return response.cookies;
} catch (error) {
logger.error(`IDAPI Error auth logout '/unauth'`, error, {
request_id,
});
return handleError(error as IDAPIError);
}
};
29 changes: 29 additions & 0 deletions src/server/lib/okta/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,35 @@ export const dangerouslyResetPassword = async (id: string): Promise<string> => {
}).then(handleResetPasswordUrlResponse);
};

/**
* Clear User sessions
*
* Removes all active identity provider sessions. This forces the user to authenticate on the next operation.
* Optionally revokes OpenID Connect and OAuth refresh and access tokens issued to the user.
*
* https://developer.okta.com/docs/reference/api/users/#clear-user-sessions
*
* @param id Okta user ID
* @param oauthTokens (optional, default: `true`) Revoke issued OpenID Connect and OAuth refresh and access tokens
* @returns Promise<void>
*/
export const clearUserSessions = async (
id: string,
oauthTokens = true,
): Promise<void> => {
const path = buildApiUrlWithQueryParams(
'/api/v1/users/:id/sessions',
{ id },
{
oauthTokens,
},
);
return await fetch(joinUrl(okta.orgUrl, path), {
method: 'DELETE',
headers: { ...defaultHeaders, ...authorizationHeader() },
}).then(handleVoidResponse);
};

/**
* Credential operations - Forgot Password
*
Expand Down
187 changes: 157 additions & 30 deletions src/server/routes/signOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import {
import { deleteAuthorizationStateCookie } from '@/server/lib/okta/openid-connect';
import { clearEncryptedStateCookie } from '@/server/lib/encryptedStateCookie';
import { trackMetric } from '@/server/lib/trackMetric';
import { closeCurrentSession } from '@/server/lib/okta/api/sessions';
import {
closeCurrentSession,
getCurrentSession,
} from '@/server/lib/okta/api/sessions';
import { checkAndDeleteOAuthTokenCookies } from '@/server/lib/okta/tokens';
import { clearUserSessions } from '@/server/lib/okta/api/users';
import { logoutFromIDAPI } from '@/server/lib/idapi/unauth';

const { defaultReturnUri, baseUri } = getConfiguration();

Expand All @@ -32,6 +37,10 @@ const DotComCookies = [
const OKTA_IDENTITY_CLASSIC_SESSION_COOKIE_NAME = 'sid';
const OKTA_IDENTITY_ENGINE_SESSION_COOKIE_NAME = 'idx';

/**
* @name clearDotComCookies
* @description Clear specific product related cookies valid for all guardian domains
*/
const clearDotComCookies = (res: ResponseWithRequestState) => {
// the baseUri is profile.theguardian.com so we strip the 'profile' as the cookie domain should be .theguardian.com
// we also remove the port after the ':' to make it work in localhost for development and testing
Expand All @@ -46,6 +55,10 @@ const clearDotComCookies = (res: ResponseWithRequestState) => {
});
};

/**
* @name clearOktaCookies
* @description Mark the `sid` and `idx` cookies as expired in order to clear them
*/
export const clearOktaCookies = (res: ResponseWithRequestState) => {
// We do not set a domain attribute as doing this makes the hostOnly=false
// and when the cookie is set by Okta, they do not specify a domain in the set-cookie header,
Expand All @@ -59,38 +72,34 @@ export const clearOktaCookies = (res: ResponseWithRequestState) => {
});
};

router.get(
'/signout',
handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => {
const { returnUrl } = res.locals.queryParams;

// We try the logout sequentially, as we need to log users out from Okta first,
await signOutFromOkta(req, res);

// if the user has no Okta sid cookie, we will then try and log them out from IDAPI
// the user will be in this state if they previously had their Okta cookie removed and got
// redirected back to the /signout endpoint
await signOutFromIDAPI(req, res);

// clear dotcom cookies
clearDotComCookies(res);

// clear gateway specific cookies
deleteAuthorizationStateCookie(res);
clearEncryptedStateCookie(res);
/**
* @name sharedSignOutHandler
* @description Clear/Set other session related things that are not specific to Okta or IDAPI
*/
export const sharedSignOutHandler = (
req: Request,
res: ResponseWithRequestState,
): void => {
// clear dotcom cookies
clearDotComCookies(res);

// clear oauth application cookies
checkAndDeleteOAuthTokenCookies(req, res);
// clear gateway specific cookies
deleteAuthorizationStateCookie(res);
clearEncryptedStateCookie(res);

// set the GU_SO (sign out) cookie
setSignOutCookie(res);
// clear oauth application cookies
checkAndDeleteOAuthTokenCookies(req, res);

return res.redirect(303, returnUrl || defaultReturnUri);
}),
);
// set the GU_SO (sign out) cookie
setSignOutCookie(res);
};

const signOutFromIDAPI = async (
req: Request,
/**
* @name signOutFromIDAPILocal
* @description Clear identity session and cookies from the current device/browser the user used to call this endpoint
*/
const signOutFromIDAPILocal = async (
_: Request,
res: ResponseWithRequestState,
): Promise<void> => {
// sign out from idapi will invalidate ALL IDAPI sessions for the user no matter the device/browser
Expand All @@ -101,7 +110,11 @@ const signOutFromIDAPI = async (
trackMetric('SignOut::Success');
};

const signOutFromOkta = async (
/**
* @name signOutFromOktaLocal
* @description Clear Okta session and cookies from the current device/browser the user used to call this endpoint
*/
const signOutFromOktaLocal = async (
req: Request,
res: ResponseWithRequestState,
): Promise<void> => {
Expand All @@ -128,4 +141,118 @@ const signOutFromOkta = async (
}
};

/**
* @name signOutFromIDAPIGlobal
* @description Clear all identity sessions from ALL devices/browser the user is logged in to
*/
const signOutFromIDAPIGlobal = async (
req: Request,
res: ResponseWithRequestState,
): Promise<void> => {
try {
// get the SC_GU_U cookie here
const sc_gu_u: string | undefined = req.cookies.SC_GU_U;

// attempt log out from Identity if we have a SC_GU_U cookie
if (sc_gu_u) {
// perform the logout from IDAPI
await logoutFromIDAPI(sc_gu_u, req.ip, res.locals.requestId);
}

trackMetric('SignOut::Success');
} catch (error) {
logger.error(`${req.method} ${req.originalUrl} Error`, error, {
request_id: res.locals.requestId,
});
trackMetric('SignOut::Failure');
} finally {
// we want to clear the IDAPI cookies anyway even if there was an
// idapi error so that we don't prevent users from logging out on their
// browser at least

// clear the IDAPI cookies
clearIDAPICookies(res);
}
};

/**
* @name signOutFromOktaLocal
* @description Clear all Okta sessions and tokens from ALL devices/browser the user is logged in to
*/
const signOutFromOktaGlobal = async (
req: Request,
res: ResponseWithRequestState,
): Promise<void> => {
try {
// attempt to log out from Okta if we have Okta session cookie
// Okta Identity Engine session cookie is called `idx`
const oktaIdentityEngineSessionCookieId: string | undefined =
req.cookies.idx;

if (oktaIdentityEngineSessionCookieId) {
const { userId } = await getCurrentSession({
idx: oktaIdentityEngineSessionCookieId,
});
await clearUserSessions(userId);
trackMetric('OktaSignOut::Success');
}
} catch (error) {
logger.error(`${req.method} ${req.originalUrl} Error`, error, {
request_id: res.locals.requestId,
});
trackMetric('OktaSignOut::Failure');
} finally {
//clear okta cookie
clearOktaCookies(res);
}
};

/**
* @name /signout
* @description Clear session and cookies from the current device/browser the user used to call this endpoint, and redirect to returnUrl
*/
router.get(
'/signout',
handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => {
const { returnUrl } = res.locals.queryParams;

// We try the logout sequentially, as we need to log users out from Okta first,
await signOutFromOktaLocal(req, res);

// if the user has no Okta sid cookie, we will then try and log them out from IDAPI
// the user will be in this state if they previously had their Okta cookie removed and got
// redirected back to the /signout endpoint
await signOutFromIDAPILocal(req, res);

// clear other cookies
sharedSignOutHandler(req, res);

return res.redirect(303, returnUrl || defaultReturnUri);
}),
);

/**
* @name /signout/all
* @description Clear all sessions and cookies from ALL devices/browser the user is logged in to, and redirect to returnUrl
*/
router.get(
'/signout/all',
handleAsyncErrors(async (req: Request, res: ResponseWithRequestState) => {
const { returnUrl } = res.locals.queryParams;

// We try the logout sequentially, as we need to log users out from Okta first,
await signOutFromOktaGlobal(req, res);

// if the user has no Okta sid cookie, we will then try and log them out from IDAPI
// the user will be in this state if they previously had their Okta cookie removed and got
// redirected back to the /signout endpoint
await signOutFromIDAPIGlobal(req, res);

// clear other cookies
sharedSignOutHandler(req, res);

return res.redirect(303, returnUrl || defaultReturnUri);
}),
);

export default router.router;
3 changes: 2 additions & 1 deletion src/shared/model/Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const ValidRoutePathsArray = [
'/oauth/authorization-code/application-callback',
'/oauth/authorization-code/callback',
'/oauth/authorization-code/delete-callback',
'/reauthenticate',
'/register',
'/register/email-sent',
'/register/email-sent/resend',
Expand All @@ -57,8 +58,8 @@ export const ValidRoutePathsArray = [
'/signin/:social',
'/signin/email-sent',
'/signin/email-sent/resend',
'/reauthenticate',
'/signout',
'/signout/all',
'/unsubscribe/:emailType/:data/:token',
'/unsubscribe/success',
'/unsubscribe/error',
Expand Down
Loading