From a4f6de7d9c6c9eafcff7792eeaa5c3586469aa92 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Thu, 15 Aug 2024 19:22:21 +0530 Subject: [PATCH] feat: Add APIs for rp-initiated logout --- .../recipe/oauth2provider/OAuth2Client.d.ts | 6 ++ .../recipe/oauth2provider/OAuth2Client.js | 2 + .../recipe/oauth2provider/api/endSession.d.ts | 13 +++ .../recipe/oauth2provider/api/endSession.js | 81 ++++++++++++++++ .../oauth2provider/api/implementation.js | 50 ++++++++++ .../recipe/oauth2provider/api/logout.d.ts | 13 +++ lib/build/recipe/oauth2provider/api/logout.js | 87 +++++++++++++++++ .../recipe/oauth2provider/api/utils.d.ts | 30 +++++- lib/build/recipe/oauth2provider/api/utils.js | 61 +++++++++++- .../recipe/oauth2provider/constants.d.ts | 2 + lib/build/recipe/oauth2provider/constants.js | 4 +- lib/build/recipe/oauth2provider/recipe.d.ts | 2 +- lib/build/recipe/oauth2provider/recipe.js | 40 +++++++- .../oauth2provider/recipeImplementation.js | 37 +++++++ lib/build/recipe/oauth2provider/types.d.ts | 57 ++++++++++- .../api/getOpenIdDiscoveryConfiguration.js | 1 + lib/build/recipe/openid/index.d.ts | 1 + .../recipe/openid/recipeImplementation.js | 1 + lib/build/recipe/openid/types.d.ts | 2 + lib/build/recipe/session/index.d.ts | 1 + lib/ts/recipe/oauth2provider/OAuth2Client.ts | 8 ++ .../recipe/oauth2provider/api/endSession.ts | 96 ++++++++++++++++++ .../oauth2provider/api/implementation.ts | 56 ++++++++++- lib/ts/recipe/oauth2provider/api/logout.ts | 97 +++++++++++++++++++ lib/ts/recipe/oauth2provider/api/utils.ts | 87 ++++++++++++++++- lib/ts/recipe/oauth2provider/constants.ts | 2 + lib/ts/recipe/oauth2provider/recipe.ts | 42 +++++++- .../oauth2provider/recipeImplementation.ts | 42 ++++++++ lib/ts/recipe/oauth2provider/types.ts | 39 +++++++- .../api/getOpenIdDiscoveryConfiguration.ts | 1 + lib/ts/recipe/openid/recipeImplementation.ts | 2 + lib/ts/recipe/openid/types.ts | 2 + test/with-typescript/index.ts | 1 + 33 files changed, 950 insertions(+), 16 deletions(-) create mode 100644 lib/build/recipe/oauth2provider/api/endSession.d.ts create mode 100644 lib/build/recipe/oauth2provider/api/endSession.js create mode 100644 lib/build/recipe/oauth2provider/api/logout.d.ts create mode 100644 lib/build/recipe/oauth2provider/api/logout.js create mode 100644 lib/ts/recipe/oauth2provider/api/endSession.ts create mode 100644 lib/ts/recipe/oauth2provider/api/logout.ts diff --git a/lib/build/recipe/oauth2provider/OAuth2Client.d.ts b/lib/build/recipe/oauth2provider/OAuth2Client.d.ts index f61f7c4f8..c647192be 100644 --- a/lib/build/recipe/oauth2provider/OAuth2Client.d.ts +++ b/lib/build/recipe/oauth2provider/OAuth2Client.d.ts @@ -28,6 +28,11 @@ export declare class OAuth2Client { * StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage. */ redirectUris: string[] | null; + /** + * Array of post logout redirect URIs + * StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage. + */ + postLogoutRedirectUris: string[] | null; /** * Authorization Code Grant Access Token Lifespan * NullDuration - ^[0-9]+(ns|us|ms|s|m|h)$ @@ -144,6 +149,7 @@ export declare class OAuth2Client { clientName, scope, redirectUris, + postLogoutRedirectUris, authorizationCodeGrantAccessTokenLifespan, authorizationCodeGrantIdTokenLifespan, authorizationCodeGrantRefreshTokenLifespan, diff --git a/lib/build/recipe/oauth2provider/OAuth2Client.js b/lib/build/recipe/oauth2provider/OAuth2Client.js index 4e9c91e41..943d31b84 100644 --- a/lib/build/recipe/oauth2provider/OAuth2Client.js +++ b/lib/build/recipe/oauth2provider/OAuth2Client.js @@ -23,6 +23,7 @@ class OAuth2Client { clientName, scope, redirectUris = null, + postLogoutRedirectUris = null, authorizationCodeGrantAccessTokenLifespan = null, authorizationCodeGrantIdTokenLifespan = null, authorizationCodeGrantRefreshTokenLifespan = null, @@ -55,6 +56,7 @@ class OAuth2Client { this.clientName = clientName; this.scope = scope; this.redirectUris = redirectUris; + this.postLogoutRedirectUris = postLogoutRedirectUris; this.authorizationCodeGrantAccessTokenLifespan = authorizationCodeGrantAccessTokenLifespan; this.authorizationCodeGrantIdTokenLifespan = authorizationCodeGrantIdTokenLifespan; this.authorizationCodeGrantRefreshTokenLifespan = authorizationCodeGrantRefreshTokenLifespan; diff --git a/lib/build/recipe/oauth2provider/api/endSession.d.ts b/lib/build/recipe/oauth2provider/api/endSession.d.ts new file mode 100644 index 000000000..1f454cbd0 --- /dev/null +++ b/lib/build/recipe/oauth2provider/api/endSession.d.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export declare function endSessionGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; +export declare function endSessionPOST( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2provider/api/endSession.js b/lib/build/recipe/oauth2provider/api/endSession.js new file mode 100644 index 000000000..8fc57213d --- /dev/null +++ b/lib/build/recipe/oauth2provider/api/endSession.js @@ -0,0 +1,81 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.endSessionPOST = exports.endSessionGET = void 0; +const utils_1 = require("../../../utils"); +const session_1 = __importDefault(require("../../session")); +async function endSessionGET(apiImplementation, options, userContext) { + if (apiImplementation.endSessionGET === undefined) { + return false; + } + const origURL = options.req.getOriginalURL(); + const splitURL = origURL.split("?"); + const params = new URLSearchParams(splitURL[1]); + return endSessionCommon( + Object.fromEntries(params.entries()), + apiImplementation.endSessionGET, + options, + userContext + ); +} +exports.endSessionGET = endSessionGET; +async function endSessionPOST(apiImplementation, options, userContext) { + if (apiImplementation.endSessionPOST === undefined) { + return false; + } + const params = await options.req.getBodyAsJSONOrFormData(); + return endSessionCommon(params, apiImplementation.endSessionPOST, options, userContext); +} +exports.endSessionPOST = endSessionPOST; +async function endSessionCommon(params, apiImplementation, options, userContext) { + if (apiImplementation === undefined) { + return false; + } + // TODO: Validate client_id if passed + let session; + try { + session = await session_1.default.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch (_a) { + session = undefined; + } + let response = await apiImplementation({ + options, + params, + session, + userContext, + }); + if ("redirectTo" in response) { + // TODO: Fix + if (response.redirectTo.includes("/oauth/fallbacks/error")) { + const redirectToUrlObj = new URL(response.redirectTo); + const res = { + error: redirectToUrlObj.searchParams.get("error"), + errorDescription: redirectToUrlObj.searchParams.get("error_description"), + }; + utils_1.sendNon200Response(options.res, 400, res); + } else { + options.res.original.redirect(response.redirectTo); + } + } else { + utils_1.send200Response(options.res, response); + } + return true; +} diff --git a/lib/build/recipe/oauth2provider/api/implementation.js b/lib/build/recipe/oauth2provider/api/implementation.js index bfb5c3ee8..69e1a16fd 100644 --- a/lib/build/recipe/oauth2provider/api/implementation.js +++ b/lib/build/recipe/oauth2provider/api/implementation.js @@ -106,6 +106,56 @@ function getAPIImplementation() { userContext: input.userContext, }); }, + endSessionGET: async ({ options, params, session, userContext }) => { + const response = await options.recipeImplementation.endSession({ + params, + userContext, + }); + return utils_1.handleInternalRedirects({ + response, + session, + recipeImplementation: options.recipeImplementation, + userContext, + }); + }, + endSessionPOST: async ({ options, params, session, userContext }) => { + const response = await options.recipeImplementation.endSession({ + params, + userContext, + }); + return utils_1.handleInternalRedirects({ + response, + session, + recipeImplementation: options.recipeImplementation, + userContext, + }); + }, + logoutGET: async ({ logoutChallenge, options, session, userContext }) => { + const response = await utils_1.logoutGET({ + logoutChallenge, + recipeImplementation: options.recipeImplementation, + session, + userContext, + }); + return utils_1.handleInternalRedirects({ + response, + recipeImplementation: options.recipeImplementation, + userContext, + }); + }, + logoutPOST: async ({ logoutChallenge, options, session, userContext }) => { + const response = await utils_1.logoutPOST({ + logoutChallenge, + recipeImplementation: options.recipeImplementation, + session, + userContext, + }); + return utils_1.handleInternalRedirects({ + response, + recipeImplementation: options.recipeImplementation, + userContext, + }); + }, }; } exports.default = getAPIImplementation; diff --git a/lib/build/recipe/oauth2provider/api/logout.d.ts b/lib/build/recipe/oauth2provider/api/logout.d.ts new file mode 100644 index 000000000..4a61e28ef --- /dev/null +++ b/lib/build/recipe/oauth2provider/api/logout.d.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export declare function logoutGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; +export declare function logoutPOST( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2provider/api/logout.js b/lib/build/recipe/oauth2provider/api/logout.js new file mode 100644 index 000000000..45f025315 --- /dev/null +++ b/lib/build/recipe/oauth2provider/api/logout.js @@ -0,0 +1,87 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.logoutPOST = exports.logoutGET = void 0; +const utils_1 = require("../../../utils"); +const session_1 = __importDefault(require("../../session")); +const error_1 = __importDefault(require("../../../error")); +async function logoutGET(apiImplementation, options, userContext) { + var _a; + if (apiImplementation.logoutGET === undefined) { + return false; + } + let session; + try { + session = await session_1.default.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch (_b) { + session = undefined; + } + const logoutChallenge = + (_a = options.req.getKeyValueFromQuery("logout_challenge")) !== null && _a !== void 0 + ? _a + : options.req.getKeyValueFromQuery("logoutChallenge"); + if (logoutChallenge === undefined) { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Missing input param: logoutChallenge", + }); + } + let response = await apiImplementation.logoutGET({ + options, + logoutChallenge, + session, + userContext, + }); + if ("redirectTo" in response) { + options.res.original.redirect(response.redirectTo); + } else { + utils_1.send200Response(options.res, response); + } + return true; +} +exports.logoutGET = logoutGET; +async function logoutPOST(apiImplementation, options, userContext) { + if (apiImplementation.logoutPOST === undefined) { + return false; + } + let session; + try { + session = await session_1.default.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch (_a) { + session = undefined; + } + const body = await options.req.getBodyAsJSONOrFormData(); + if (body.logoutChallenge === undefined) { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Missing body param: logoutChallenge", + }); + } + let response = await apiImplementation.logoutPOST({ + options, + logoutChallenge: body.logoutChallenge, + session, + userContext, + }); + utils_1.send200Response(options.res, response); + return true; +} +exports.logoutPOST = logoutPOST; diff --git a/lib/build/recipe/oauth2provider/api/utils.d.ts b/lib/build/recipe/oauth2provider/api/utils.d.ts index 2271ba148..84bf8db90 100644 --- a/lib/build/recipe/oauth2provider/api/utils.d.ts +++ b/lib/build/recipe/oauth2provider/api/utils.d.ts @@ -20,6 +20,32 @@ export declare function loginGET({ redirectTo: string; setCookie: string | undefined; }>; +export declare function logoutGET({ + logoutChallenge, + recipeImplementation, + session, + userContext, +}: { + logoutChallenge: string; + recipeImplementation: RecipeInterface; + session?: SessionContainerInterface; + userContext: UserContext; +}): Promise<{ + redirectTo: string; +}>; +export declare function logoutPOST({ + logoutChallenge, + session, + recipeImplementation, + userContext, +}: { + logoutChallenge: string; + recipeImplementation: RecipeInterface; + session?: SessionContainerInterface; + userContext: UserContext; +}): Promise<{ + redirectTo: string; +}>; export declare function handleInternalRedirects({ response, recipeImplementation, @@ -29,7 +55,7 @@ export declare function handleInternalRedirects({ }: { response: { redirectTo: string; - setCookie: string | undefined; + setCookie?: string; }; recipeImplementation: RecipeInterface; session?: SessionContainerInterface; @@ -37,5 +63,5 @@ export declare function handleInternalRedirects({ userContext: UserContext; }): Promise<{ redirectTo: string; - setCookie: string | undefined; + setCookie?: string; }>; diff --git a/lib/build/recipe/oauth2provider/api/utils.js b/lib/build/recipe/oauth2provider/api/utils.js index b7cb5cd80..9441c1163 100644 --- a/lib/build/recipe/oauth2provider/api/utils.js +++ b/lib/build/recipe/oauth2provider/api/utils.js @@ -5,7 +5,7 @@ var __importDefault = return mod && mod.__esModule ? mod : { default: mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.handleInternalRedirects = exports.loginGET = void 0; +exports.handleInternalRedirects = exports.logoutPOST = exports.logoutGET = exports.loginGET = void 0; const supertokens_1 = __importDefault(require("../../../supertokens")); const constants_1 = require("../../multitenancy/constants"); const session_1 = require("../../session"); @@ -118,6 +118,42 @@ async function loginGET({ recipeImplementation, loginChallenge, session, setCook }; } exports.loginGET = loginGET; +async function logoutGET({ logoutChallenge, recipeImplementation, session, userContext }) { + if (session !== undefined) { + const appInfo = supertokens_1.default.getInstanceOrThrowError().appInfo; + const websiteDomain = appInfo + .getOrigin({ + request: undefined, + userContext: userContext, + }) + .getAsStringDangerous(); + const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); + const queryParamsForLogoutPage = new URLSearchParams({ + logoutChallenge, + }); + return { + redirectTo: websiteDomain + websiteBasePath + "/oauth/logout" + `?${queryParamsForLogoutPage.toString()}`, + }; + } + // Accept the logout request immediately as there is no supertokens session + const accept = await recipeImplementation.acceptLogoutRequest({ + challenge: logoutChallenge, + userContext, + }); + return { redirectTo: accept.redirectTo }; +} +exports.logoutGET = logoutGET; +async function logoutPOST({ logoutChallenge, session, recipeImplementation, userContext }) { + if (session != undefined) { + await session.revokeSession(userContext); + } + const accept = await recipeImplementation.acceptLogoutRequest({ + challenge: logoutChallenge, + userContext, + }); + return { redirectTo: accept.redirectTo }; +} +exports.logoutPOST = logoutPOST; function getMergedCookies({ cookie = "", setCookie }) { if (!setCookie) { return cookie; @@ -153,15 +189,19 @@ function isInternalRedirect(redirectTo) { return [ constants_2.LOGIN_PATH, constants_2.AUTH_PATH, + constants_2.END_SESSION_PATH, + constants_2.LOGOUT_PATH, constants_2.LOGIN_PATH.replace("oauth", "oauth2"), constants_2.AUTH_PATH.replace("oauth", "oauth2"), + constants_2.END_SESSION_PATH.replace("oauth", "oauth2"), + constants_2.LOGOUT_PATH.replace("oauth", "oauth2"), ].some((path) => redirectTo.startsWith(`${basePath}${path}`)); } // In the OAuth2 flow, we do several internal redirects. These redirects don't require a frontend-to-api-server round trip. // If an internal redirect is identified, it's handled directly by this function. // Currently, we only need to handle redirects to /oauth/login and /oauth/auth endpoints. async function handleInternalRedirects({ response, recipeImplementation, session, cookie = "", userContext }) { - var _a; + var _a, _b; if (!isInternalRedirect(response.redirectTo)) { return response; } @@ -202,6 +242,23 @@ async function handleInternalRedirects({ response, recipeImplementation, session redirectTo: authRes.redirectTo, setCookie: mergeSetCookieHeaders(authRes.setCookie, response.setCookie), }; + } else if (response.redirectTo.includes(constants_2.END_SESSION_PATH)) { + response = await recipeImplementation.endSession({ + params: Object.fromEntries(params.entries()), + userContext, + }); + } else if (response.redirectTo.includes(constants_2.LOGOUT_PATH)) { + const logoutChallenge = + (_b = params.get("logout_challenge")) !== null && _b !== void 0 ? _b : params.get("logoutChallenge"); + if (!logoutChallenge) { + throw new Error(`Expected logoutChallenge in ${response.redirectTo}`); + } + response = await logoutGET({ + logoutChallenge, + recipeImplementation, + session, + userContext, + }); } else { throw new Error(`Unexpected internal redirect ${response.redirectTo}`); } diff --git a/lib/build/recipe/oauth2provider/constants.d.ts b/lib/build/recipe/oauth2provider/constants.d.ts index 10dd83851..56069cece 100644 --- a/lib/build/recipe/oauth2provider/constants.d.ts +++ b/lib/build/recipe/oauth2provider/constants.d.ts @@ -7,3 +7,5 @@ export declare const LOGIN_INFO_PATH = "/oauth/login/info"; export declare const USER_INFO_PATH = "/oauth/userinfo"; export declare const REVOKE_TOKEN_PATH = "/oauth/revoke"; export declare const INTROSPECT_TOKEN_PATH = "/oauth/introspect"; +export declare const END_SESSION_PATH = "/oauth/end_session"; +export declare const LOGOUT_PATH = "/oauth/logout"; diff --git a/lib/build/recipe/oauth2provider/constants.js b/lib/build/recipe/oauth2provider/constants.js index df8d4441c..398b11666 100644 --- a/lib/build/recipe/oauth2provider/constants.js +++ b/lib/build/recipe/oauth2provider/constants.js @@ -14,7 +14,7 @@ * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.INTROSPECT_TOKEN_PATH = exports.REVOKE_TOKEN_PATH = exports.USER_INFO_PATH = exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; +exports.LOGOUT_PATH = exports.END_SESSION_PATH = exports.INTROSPECT_TOKEN_PATH = exports.REVOKE_TOKEN_PATH = exports.USER_INFO_PATH = exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; exports.OAUTH2_BASE_PATH = "/oauth/"; exports.LOGIN_PATH = "/oauth/login"; exports.AUTH_PATH = "/oauth/auth"; @@ -23,3 +23,5 @@ exports.LOGIN_INFO_PATH = "/oauth/login/info"; exports.USER_INFO_PATH = "/oauth/userinfo"; exports.REVOKE_TOKEN_PATH = "/oauth/revoke"; exports.INTROSPECT_TOKEN_PATH = "/oauth/introspect"; +exports.END_SESSION_PATH = "/oauth/end_session"; +exports.LOGOUT_PATH = "/oauth/logout"; diff --git a/lib/build/recipe/oauth2provider/recipe.d.ts b/lib/build/recipe/oauth2provider/recipe.d.ts index 19a890e21..e7c5ebd3f 100644 --- a/lib/build/recipe/oauth2provider/recipe.d.ts +++ b/lib/build/recipe/oauth2provider/recipe.d.ts @@ -35,7 +35,7 @@ export default class Recipe extends RecipeModule { req: BaseRequest, res: BaseResponse, _path: NormalisedURLPath, - _method: HTTPMethod, + method: HTTPMethod, userContext: UserContext ) => Promise; handleError(error: error, _: BaseRequest, __: BaseResponse, _userContext: UserContext): Promise; diff --git a/lib/build/recipe/oauth2provider/recipe.js b/lib/build/recipe/oauth2provider/recipe.js index 7f0965f78..55cf33d88 100644 --- a/lib/build/recipe/oauth2provider/recipe.js +++ b/lib/build/recipe/oauth2provider/recipe.js @@ -36,6 +36,8 @@ const userInfo_1 = __importDefault(require("./api/userInfo")); const combinedRemoteJWKSet_1 = require("../../combinedRemoteJWKSet"); const revokeToken_1 = __importDefault(require("./api/revokeToken")); const introspectToken_1 = __importDefault(require("./api/introspectToken")); +const endSession_1 = require("./api/endSession"); +const logout_1 = require("./api/logout"); class Recipe extends recipeModule_1.default { constructor(recipeId, appInfo, isInServerlessEnv, config) { super(recipeId, appInfo); @@ -44,7 +46,7 @@ class Recipe extends recipeModule_1.default { this.addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn) => { this.userInfoBuilders.push(userInfoBuilderFn); }; - this.handleAPIRequest = async (id, tenantId, req, res, _path, _method, userContext) => { + this.handleAPIRequest = async (id, tenantId, req, res, _path, method, userContext) => { let options = { config: this.config, recipeId: this.getRecipeId(), @@ -74,6 +76,18 @@ class Recipe extends recipeModule_1.default { if (id === constants_1.INTROSPECT_TOKEN_PATH) { return introspectToken_1.default(this.apiImpl, options, userContext); } + if (id === constants_1.END_SESSION_PATH && method === "get") { + return endSession_1.endSessionGET(this.apiImpl, options, userContext); + } + if (id === constants_1.END_SESSION_PATH && method === "post") { + return endSession_1.endSessionPOST(this.apiImpl, options, userContext); + } + if (id === constants_1.LOGOUT_PATH && method === "get") { + return logout_1.logoutGET(this.apiImpl, options, userContext); + } + if (id === constants_1.LOGOUT_PATH && method === "post") { + return logout_1.logoutPOST(this.apiImpl, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; this.config = utils_1.validateAndNormaliseUserInput(this, appInfo, config); @@ -167,6 +181,30 @@ class Recipe extends recipeModule_1.default { id: constants_1.INTROSPECT_TOKEN_PATH, disabled: this.apiImpl.introspectTokenPOST === undefined, }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.END_SESSION_PATH), + id: constants_1.END_SESSION_PATH, + disabled: this.apiImpl.endSessionGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.END_SESSION_PATH), + id: constants_1.END_SESSION_PATH, + disabled: this.apiImpl.endSessionPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.LOGOUT_PATH), + id: constants_1.LOGOUT_PATH, + disabled: this.apiImpl.logoutGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.LOGOUT_PATH), + id: constants_1.LOGOUT_PATH, + disabled: this.apiImpl.logoutPOST === undefined, + }, ]; } handleError(error, _, __, _userContext) { diff --git a/lib/build/recipe/oauth2provider/recipeImplementation.js b/lib/build/recipe/oauth2provider/recipeImplementation.js index bf043da11..e659c4bfe 100644 --- a/lib/build/recipe/oauth2provider/recipeImplementation.js +++ b/lib/build/recipe/oauth2provider/recipeImplementation.js @@ -549,6 +549,43 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, ); return res.data; }, + endSession: async function (input) { + const resp = await querier.sendGetRequestWithResponseHeaders( + new normalisedURLPath_1.default(`/recipe/oauth2/pub/sessions/logout`), + input.params, + {}, + input.userContext + ); + const redirectTo = getUpdatedRedirectTo(appInfo, resp.headers.get("Location")); + if (redirectTo === undefined) { + throw new Error(resp.body); + } + // TODO: + // NOTE: If no post_logout_redirect_uri is provided, Hydra redirects to a fallback page. + // In this case, we redirect the user to the /auth page. + if (redirectTo.endsWith("/oauth/fallbacks/logout/callback")) { + const websiteDomain = appInfo + .getOrigin({ request: undefined, userContext: input.userContext }) + .getAsStringDangerous(); + const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); + return { redirectTo: `${websiteDomain}${websiteBasePath}` }; + } + return { redirectTo }; + }, + acceptLogoutRequest: async function (input) { + const resp = await querier.sendPutRequest( + new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/logout/accept`), + {}, + { logout_challenge: input.challenge }, + input.userContext + ); + return { + // TODO: FIXME!!! + redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to) + // NOTE: This renaming only applies to this endpoint, hence not part of the generic "getUpdatedRedirectTo" function. + .replace("/sessions/logout", "/end_session"), + }; + }, }; } exports.default = getRecipeInterface; diff --git a/lib/build/recipe/oauth2provider/types.d.ts b/lib/build/recipe/oauth2provider/types.d.ts index da01b04b8..21925d4a2 100644 --- a/lib/build/recipe/oauth2provider/types.d.ts +++ b/lib/build/recipe/oauth2provider/types.d.ts @@ -279,6 +279,18 @@ export declare type RecipeInterface = { scopes?: string[]; userContext: UserContext; }): Promise; + endSession(input: { + params: Record; + userContext: UserContext; + }): Promise<{ + redirectTo: string; + }>; + acceptLogoutRequest(input: { + challenge: string; + userContext: UserContext; + }): Promise<{ + redirectTo: string; + }>; }; export declare type APIInterface = { loginGET: @@ -291,7 +303,7 @@ export declare type APIInterface = { }) => Promise< | { redirectTo: string; - setCookie: string | undefined; + setCookie?: string; } | GeneralErrorResponse >); @@ -306,7 +318,7 @@ export declare type APIInterface = { }) => Promise< | { redirectTo: string; - setCookie: string | undefined; + setCookie?: string; } | ErrorOAuth2 | GeneralErrorResponse @@ -372,6 +384,46 @@ export declare type APIInterface = { options: APIOptions; userContext: UserContext; }) => Promise); + endSessionGET: + | undefined + | ((input: { + params: Record; + session?: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ + redirectTo: string; + }>); + endSessionPOST: + | undefined + | ((input: { + params: Record; + session?: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ + redirectTo: string; + }>); + logoutGET: + | undefined + | ((input: { + logoutChallenge: string; + options: APIOptions; + session?: SessionContainerInterface; + userContext: UserContext; + }) => Promise<{ + redirectTo: string; + }>); + logoutPOST: + | undefined + | ((input: { + logoutChallenge: string; + options: APIOptions; + session?: SessionContainerInterface; + userContext: UserContext; + }) => Promise<{ + redirectTo: string; + }>); }; export declare type OAuth2ClientOptions = { clientId: string; @@ -381,6 +433,7 @@ export declare type OAuth2ClientOptions = { clientName: string; scope: string; redirectUris?: string[] | null; + postLogoutRedirectUris?: string[] | null; allowedCorsOrigins?: string[]; authorizationCodeGrantAccessTokenLifespan?: string | null; authorizationCodeGrantIdTokenLifespan?: string | null; diff --git a/lib/build/recipe/openid/api/getOpenIdDiscoveryConfiguration.js b/lib/build/recipe/openid/api/getOpenIdDiscoveryConfiguration.js index 025542687..05b9f8403 100644 --- a/lib/build/recipe/openid/api/getOpenIdDiscoveryConfiguration.js +++ b/lib/build/recipe/openid/api/getOpenIdDiscoveryConfiguration.js @@ -19,6 +19,7 @@ async function getOpenIdDiscoveryConfiguration(apiImplementation, options, userC userinfo_endpoint: result.userinfo_endpoint, revocation_endpoint: result.revocation_endpoint, token_introspection_endpoint: result.token_introspection_endpoint, + end_session_endpoint: result.end_session_endpoint, subject_types_supported: result.subject_types_supported, id_token_signing_alg_values_supported: result.id_token_signing_alg_values_supported, response_types_supported: result.response_types_supported, diff --git a/lib/build/recipe/openid/index.d.ts b/lib/build/recipe/openid/index.d.ts index e32644715..e67e3d8a4 100644 --- a/lib/build/recipe/openid/index.d.ts +++ b/lib/build/recipe/openid/index.d.ts @@ -13,6 +13,7 @@ export default class OpenIdRecipeWrapper { userinfo_endpoint: string; revocation_endpoint: string; token_introspection_endpoint: string; + end_session_endpoint: string; subject_types_supported: string[]; id_token_signing_alg_values_supported: string[]; response_types_supported: string[]; diff --git a/lib/build/recipe/openid/recipeImplementation.js b/lib/build/recipe/openid/recipeImplementation.js index 58c278f4f..cd7e37bb7 100644 --- a/lib/build/recipe/openid/recipeImplementation.js +++ b/lib/build/recipe/openid/recipeImplementation.js @@ -27,6 +27,7 @@ function getRecipeInterface(config, jwtRecipeImplementation, appInfo) { userinfo_endpoint: apiBasePath + constants_2.USER_INFO_PATH, revocation_endpoint: apiBasePath + constants_2.REVOKE_TOKEN_PATH, token_introspection_endpoint: apiBasePath + constants_2.INTROSPECT_TOKEN_PATH, + end_session_endpoint: apiBasePath + constants_2.END_SESSION_PATH, subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], response_types_supported: ["code", "id_token", "id_token token"], diff --git a/lib/build/recipe/openid/types.d.ts b/lib/build/recipe/openid/types.d.ts index 0908e5b2c..6af18db9d 100644 --- a/lib/build/recipe/openid/types.d.ts +++ b/lib/build/recipe/openid/types.d.ts @@ -71,6 +71,7 @@ export declare type APIInterface = { userinfo_endpoint: string; revocation_endpoint: string; token_introspection_endpoint: string; + end_session_endpoint: string; subject_types_supported: string[]; id_token_signing_alg_values_supported: string[]; response_types_supported: string[]; @@ -90,6 +91,7 @@ export declare type RecipeInterface = { userinfo_endpoint: string; revocation_endpoint: string; token_introspection_endpoint: string; + end_session_endpoint: string; subject_types_supported: string[]; id_token_signing_alg_values_supported: string[]; response_types_supported: string[]; diff --git a/lib/build/recipe/session/index.d.ts b/lib/build/recipe/session/index.d.ts index fb87a07d1..d4cb6414b 100644 --- a/lib/build/recipe/session/index.d.ts +++ b/lib/build/recipe/session/index.d.ts @@ -182,6 +182,7 @@ export default class SessionWrapper { userinfo_endpoint: string; revocation_endpoint: string; token_introspection_endpoint: string; + end_session_endpoint: string; subject_types_supported: string[]; id_token_signing_alg_values_supported: string[]; response_types_supported: string[]; diff --git a/lib/ts/recipe/oauth2provider/OAuth2Client.ts b/lib/ts/recipe/oauth2provider/OAuth2Client.ts index 7105a9262..b59b415f4 100644 --- a/lib/ts/recipe/oauth2provider/OAuth2Client.ts +++ b/lib/ts/recipe/oauth2provider/OAuth2Client.ts @@ -49,6 +49,12 @@ export class OAuth2Client { */ redirectUris: string[] | null; + /** + * Array of post logout redirect URIs + * StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage. + */ + postLogoutRedirectUris: string[] | null; + /** * Authorization Code Grant Access Token Lifespan * NullDuration - ^[0-9]+(ns|us|ms|s|m|h)$ @@ -186,6 +192,7 @@ export class OAuth2Client { clientName, scope, redirectUris = null, + postLogoutRedirectUris = null, authorizationCodeGrantAccessTokenLifespan = null, authorizationCodeGrantIdTokenLifespan = null, authorizationCodeGrantRefreshTokenLifespan = null, @@ -213,6 +220,7 @@ export class OAuth2Client { this.clientName = clientName; this.scope = scope; this.redirectUris = redirectUris; + this.postLogoutRedirectUris = postLogoutRedirectUris; this.authorizationCodeGrantAccessTokenLifespan = authorizationCodeGrantAccessTokenLifespan; this.authorizationCodeGrantIdTokenLifespan = authorizationCodeGrantIdTokenLifespan; this.authorizationCodeGrantRefreshTokenLifespan = authorizationCodeGrantRefreshTokenLifespan; diff --git a/lib/ts/recipe/oauth2provider/api/endSession.ts b/lib/ts/recipe/oauth2provider/api/endSession.ts new file mode 100644 index 000000000..c5c45b106 --- /dev/null +++ b/lib/ts/recipe/oauth2provider/api/endSession.ts @@ -0,0 +1,96 @@ +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import { send200Response, sendNon200Response } from "../../../utils"; +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +import Session from "../../session"; + +export async function endSessionGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.endSessionGET === undefined) { + return false; + } + const origURL = options.req.getOriginalURL(); + const splitURL = origURL.split("?"); + const params = new URLSearchParams(splitURL[1]); + + return endSessionCommon( + Object.fromEntries(params.entries()), + apiImplementation.endSessionGET, + options, + userContext + ); +} + +export async function endSessionPOST( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.endSessionPOST === undefined) { + return false; + } + const params = await options.req.getBodyAsJSONOrFormData(); + + return endSessionCommon(params, apiImplementation.endSessionPOST, options, userContext); +} + +async function endSessionCommon( + params: Record, + apiImplementation: APIInterface["endSessionGET"] | APIInterface["endSessionPOST"], + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation === undefined) { + return false; + } + + // TODO: Validate client_id if passed + + let session; + try { + session = await Session.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch { + session = undefined; + } + + let response = await apiImplementation({ + options, + params, + session, + userContext, + }); + + if ("redirectTo" in response) { + // TODO: Fix + if (response.redirectTo.includes("/oauth/fallbacks/error")) { + const redirectToUrlObj = new URL(response.redirectTo); + const res = { + error: redirectToUrlObj.searchParams.get("error"), + errorDescription: redirectToUrlObj.searchParams.get("error_description"), + }; + sendNon200Response(options.res, 400, res); + } else { + options.res.original.redirect(response.redirectTo); + } + } else { + send200Response(options.res, response); + } + return true; +} diff --git a/lib/ts/recipe/oauth2provider/api/implementation.ts b/lib/ts/recipe/oauth2provider/api/implementation.ts index 0e3bb71bc..ce5d6a09b 100644 --- a/lib/ts/recipe/oauth2provider/api/implementation.ts +++ b/lib/ts/recipe/oauth2provider/api/implementation.ts @@ -14,7 +14,7 @@ */ import { APIInterface } from "../types"; -import { handleInternalRedirects, loginGET } from "./utils"; +import { handleInternalRedirects, loginGET, logoutGET, logoutPOST } from "./utils"; export default function getAPIImplementation(): APIInterface { return { @@ -110,5 +110,59 @@ export default function getAPIImplementation(): APIInterface { userContext: input.userContext, }); }, + endSessionGET: async ({ options, params, session, userContext }) => { + const response = await options.recipeImplementation.endSession({ + params, + userContext, + }); + + return handleInternalRedirects({ + response, + session, + recipeImplementation: options.recipeImplementation, + userContext, + }); + }, + endSessionPOST: async ({ options, params, session, userContext }) => { + const response = await options.recipeImplementation.endSession({ + params, + userContext, + }); + + return handleInternalRedirects({ + response, + session, + recipeImplementation: options.recipeImplementation, + userContext, + }); + }, + logoutGET: async ({ logoutChallenge, options, session, userContext }) => { + const response = await logoutGET({ + logoutChallenge, + recipeImplementation: options.recipeImplementation, + session, + userContext, + }); + + return handleInternalRedirects({ + response, + recipeImplementation: options.recipeImplementation, + userContext, + }); + }, + logoutPOST: async ({ logoutChallenge, options, session, userContext }) => { + const response = await logoutPOST({ + logoutChallenge, + recipeImplementation: options.recipeImplementation, + session, + userContext, + }); + + return handleInternalRedirects({ + response, + recipeImplementation: options.recipeImplementation, + userContext, + }); + }, }; } diff --git a/lib/ts/recipe/oauth2provider/api/logout.ts b/lib/ts/recipe/oauth2provider/api/logout.ts new file mode 100644 index 000000000..efc98a101 --- /dev/null +++ b/lib/ts/recipe/oauth2provider/api/logout.ts @@ -0,0 +1,97 @@ +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import { send200Response } from "../../../utils"; +import { APIInterface, APIOptions } from ".."; +import Session from "../../session"; +import { UserContext } from "../../../types"; +import SuperTokensError from "../../../error"; + +export async function logoutGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.logoutGET === undefined) { + return false; + } + + let session; + try { + session = await Session.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch { + session = undefined; + } + + const logoutChallenge = + options.req.getKeyValueFromQuery("logout_challenge") ?? options.req.getKeyValueFromQuery("logoutChallenge"); + + if (logoutChallenge === undefined) { + throw new SuperTokensError({ + type: SuperTokensError.BAD_INPUT_ERROR, + message: "Missing input param: logoutChallenge", + }); + } + + let response = await apiImplementation.logoutGET({ + options, + logoutChallenge, + session, + userContext, + }); + + if ("redirectTo" in response) { + options.res.original.redirect(response.redirectTo); + } else { + send200Response(options.res, response); + } + return true; +} + +export async function logoutPOST( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.logoutPOST === undefined) { + return false; + } + + let session; + try { + session = await Session.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch { + session = undefined; + } + + const body = await options.req.getBodyAsJSONOrFormData(); + + if (body.logoutChallenge === undefined) { + throw new SuperTokensError({ + type: SuperTokensError.BAD_INPUT_ERROR, + message: "Missing body param: logoutChallenge", + }); + } + + let response = await apiImplementation.logoutPOST({ + options, + logoutChallenge: body.logoutChallenge, + session, + userContext, + }); + + send200Response(options.res, response); + return true; +} diff --git a/lib/ts/recipe/oauth2provider/api/utils.ts b/lib/ts/recipe/oauth2provider/api/utils.ts index 735979690..a188b3394 100644 --- a/lib/ts/recipe/oauth2provider/api/utils.ts +++ b/lib/ts/recipe/oauth2provider/api/utils.ts @@ -3,7 +3,7 @@ import { UserContext } from "../../../types"; import { DEFAULT_TENANT_ID } from "../../multitenancy/constants"; import { getSessionInformation } from "../../session"; import { SessionContainerInterface } from "../../session/types"; -import { AUTH_PATH, LOGIN_PATH } from "../constants"; +import { AUTH_PATH, LOGIN_PATH, LOGOUT_PATH, END_SESSION_PATH } from "../constants"; import { RecipeInterface } from "../types"; import setCookieParser from "set-cookie-parser"; @@ -126,6 +126,66 @@ export async function loginGET({ }; } +export async function logoutGET({ + logoutChallenge, + recipeImplementation, + session, + userContext, +}: { + logoutChallenge: string; + recipeImplementation: RecipeInterface; + session?: SessionContainerInterface; + userContext: UserContext; +}) { + if (session !== undefined) { + const appInfo = SuperTokens.getInstanceOrThrowError().appInfo; + const websiteDomain = appInfo + .getOrigin({ + request: undefined, + userContext: userContext, + }) + .getAsStringDangerous(); + const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); + + const queryParamsForLogoutPage = new URLSearchParams({ + logoutChallenge, + }); + + return { + redirectTo: websiteDomain + websiteBasePath + "/oauth/logout" + `?${queryParamsForLogoutPage.toString()}`, + }; + } + + // Accept the logout request immediately as there is no supertokens session + const accept = await recipeImplementation.acceptLogoutRequest({ + challenge: logoutChallenge, + userContext, + }); + return { redirectTo: accept.redirectTo }; +} + +export async function logoutPOST({ + logoutChallenge, + session, + recipeImplementation, + userContext, +}: { + logoutChallenge: string; + recipeImplementation: RecipeInterface; + session?: SessionContainerInterface; + userContext: UserContext; +}) { + if (session != undefined) { + await session.revokeSession(userContext); + } + + const accept = await recipeImplementation.acceptLogoutRequest({ + challenge: logoutChallenge, + userContext, + }); + return { redirectTo: accept.redirectTo }; +} + function getMergedCookies({ cookie = "", setCookie }: { cookie?: string; setCookie?: string }): string { if (!setCookie) { return cookie; @@ -167,8 +227,12 @@ function isInternalRedirect(redirectTo: string): boolean { return [ LOGIN_PATH, AUTH_PATH, + END_SESSION_PATH, + LOGOUT_PATH, LOGIN_PATH.replace("oauth", "oauth2"), AUTH_PATH.replace("oauth", "oauth2"), + END_SESSION_PATH.replace("oauth", "oauth2"), + LOGOUT_PATH.replace("oauth", "oauth2"), ].some((path) => redirectTo.startsWith(`${basePath}${path}`)); } @@ -182,12 +246,12 @@ export async function handleInternalRedirects({ cookie = "", userContext, }: { - response: { redirectTo: string; setCookie: string | undefined }; + response: { redirectTo: string; setCookie?: string }; recipeImplementation: RecipeInterface; session?: SessionContainerInterface; cookie?: string; userContext: UserContext; -}): Promise<{ redirectTo: string; setCookie: string | undefined }> { +}): Promise<{ redirectTo: string; setCookie?: string }> { if (!isInternalRedirect(response.redirectTo)) { return response; } @@ -234,6 +298,23 @@ export async function handleInternalRedirects({ redirectTo: authRes.redirectTo, setCookie: mergeSetCookieHeaders(authRes.setCookie, response.setCookie), }; + } else if (response.redirectTo.includes(END_SESSION_PATH)) { + response = await recipeImplementation.endSession({ + params: Object.fromEntries(params.entries()), + userContext, + }); + } else if (response.redirectTo.includes(LOGOUT_PATH)) { + const logoutChallenge = params.get("logout_challenge") ?? params.get("logoutChallenge"); + if (!logoutChallenge) { + throw new Error(`Expected logoutChallenge in ${response.redirectTo}`); + } + + response = await logoutGET({ + logoutChallenge, + recipeImplementation, + session, + userContext, + }); } else { throw new Error(`Unexpected internal redirect ${response.redirectTo}`); } diff --git a/lib/ts/recipe/oauth2provider/constants.ts b/lib/ts/recipe/oauth2provider/constants.ts index 19c7173e3..2e4f1a097 100644 --- a/lib/ts/recipe/oauth2provider/constants.ts +++ b/lib/ts/recipe/oauth2provider/constants.ts @@ -22,3 +22,5 @@ export const LOGIN_INFO_PATH = "/oauth/login/info"; export const USER_INFO_PATH = "/oauth/userinfo"; export const REVOKE_TOKEN_PATH = "/oauth/revoke"; export const INTROSPECT_TOKEN_PATH = "/oauth/introspect"; +export const END_SESSION_PATH = "/oauth/end_session"; +export const LOGOUT_PATH = "/oauth/logout"; diff --git a/lib/ts/recipe/oauth2provider/recipe.ts b/lib/ts/recipe/oauth2provider/recipe.ts index f671b7b9b..e7c17bc6c 100644 --- a/lib/ts/recipe/oauth2provider/recipe.ts +++ b/lib/ts/recipe/oauth2provider/recipe.ts @@ -30,6 +30,8 @@ import { INTROSPECT_TOKEN_PATH, LOGIN_INFO_PATH, LOGIN_PATH, + LOGOUT_PATH, + END_SESSION_PATH, REVOKE_TOKEN_PATH, TOKEN_PATH, USER_INFO_PATH, @@ -51,6 +53,8 @@ import userInfoGET from "./api/userInfo"; import { resetCombinedJWKS } from "../../combinedRemoteJWKSet"; import revokeTokenPOST from "./api/revokeToken"; import introspectTokenPOST from "./api/introspectToken"; +import { endSessionGET, endSessionPOST } from "./api/endSession"; +import { logoutGET, logoutPOST } from "./api/logout"; export default class Recipe extends RecipeModule { static RECIPE_ID = "oauth2provider"; @@ -167,6 +171,30 @@ export default class Recipe extends RecipeModule { id: INTROSPECT_TOKEN_PATH, disabled: this.apiImpl.introspectTokenPOST === undefined, }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(END_SESSION_PATH), + id: END_SESSION_PATH, + disabled: this.apiImpl.endSessionGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(END_SESSION_PATH), + id: END_SESSION_PATH, + disabled: this.apiImpl.endSessionPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(LOGOUT_PATH), + id: LOGOUT_PATH, + disabled: this.apiImpl.logoutGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(LOGOUT_PATH), + id: LOGOUT_PATH, + disabled: this.apiImpl.logoutPOST === undefined, + }, ]; } @@ -176,7 +204,7 @@ export default class Recipe extends RecipeModule { req: BaseRequest, res: BaseResponse, _path: NormalisedURLPath, - _method: HTTPMethod, + method: HTTPMethod, userContext: UserContext ): Promise => { let options = { @@ -209,6 +237,18 @@ export default class Recipe extends RecipeModule { if (id === INTROSPECT_TOKEN_PATH) { return introspectTokenPOST(this.apiImpl, options, userContext); } + if (id === END_SESSION_PATH && method === "get") { + return endSessionGET(this.apiImpl, options, userContext); + } + if (id === END_SESSION_PATH && method === "post") { + return endSessionPOST(this.apiImpl, options, userContext); + } + if (id === LOGOUT_PATH && method === "get") { + return logoutGET(this.apiImpl, options, userContext); + } + if (id === LOGOUT_PATH && method === "post") { + return logoutPOST(this.apiImpl, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; diff --git a/lib/ts/recipe/oauth2provider/recipeImplementation.ts b/lib/ts/recipe/oauth2provider/recipeImplementation.ts index f870812d6..9f4460448 100644 --- a/lib/ts/recipe/oauth2provider/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2provider/recipeImplementation.ts @@ -550,5 +550,47 @@ export default function getRecipeInterface( return res.data; }, + + endSession: async function (this: RecipeInterface, input) { + const resp = await querier.sendGetRequestWithResponseHeaders( + new NormalisedURLPath(`/recipe/oauth2/pub/sessions/logout`), + input.params, + {}, + input.userContext + ); + + const redirectTo = getUpdatedRedirectTo(appInfo, resp.headers.get("Location")!); + if (redirectTo === undefined) { + throw new Error(resp.body); + } + + // TODO: + // NOTE: If no post_logout_redirect_uri is provided, Hydra redirects to a fallback page. + // In this case, we redirect the user to the /auth page. + if (redirectTo.endsWith("/oauth/fallbacks/logout/callback")) { + const websiteDomain = appInfo + .getOrigin({ request: undefined, userContext: input.userContext }) + .getAsStringDangerous(); + const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); + return { redirectTo: `${websiteDomain}${websiteBasePath}` }; + } + + return { redirectTo }; + }, + acceptLogoutRequest: async function (this: RecipeInterface, input) { + const resp = await querier.sendPutRequest( + new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/logout/accept`), + {}, + { logout_challenge: input.challenge }, + input.userContext + ); + + return { + // TODO: FIXME!!! + redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to) + // NOTE: This renaming only applies to this endpoint, hence not part of the generic "getUpdatedRedirectTo" function. + .replace("/sessions/logout", "/end_session"), + }; + }, }; } diff --git a/lib/ts/recipe/oauth2provider/types.ts b/lib/ts/recipe/oauth2provider/types.ts index 255966931..6f70a1cd0 100644 --- a/lib/ts/recipe/oauth2provider/types.ts +++ b/lib/ts/recipe/oauth2provider/types.ts @@ -383,6 +383,8 @@ export type RecipeInterface = { scopes?: string[]; userContext: UserContext; }): Promise; + endSession(input: { params: Record; userContext: UserContext }): Promise<{ redirectTo: string }>; + acceptLogoutRequest(input: { challenge: string; userContext: UserContext }): Promise<{ redirectTo: string }>; }; export type APIInterface = { @@ -393,7 +395,7 @@ export type APIInterface = { options: APIOptions; session?: SessionContainerInterface; userContext: UserContext; - }) => Promise<{ redirectTo: string; setCookie: string | undefined } | GeneralErrorResponse>); + }) => Promise<{ redirectTo: string; setCookie?: string } | GeneralErrorResponse>); authGET: | undefined @@ -403,7 +405,7 @@ export type APIInterface = { session: SessionContainerInterface | undefined; options: APIOptions; userContext: UserContext; - }) => Promise<{ redirectTo: string; setCookie: string | undefined } | ErrorOAuth2 | GeneralErrorResponse>); + }) => Promise<{ redirectTo: string; setCookie?: string } | ErrorOAuth2 | GeneralErrorResponse>); tokenPOST: | undefined | ((input: { @@ -446,6 +448,38 @@ export type APIInterface = { options: APIOptions; userContext: UserContext; }) => Promise); + endSessionGET: + | undefined + | ((input: { + params: Record; + session?: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ redirectTo: string }>); + endSessionPOST: + | undefined + | ((input: { + params: Record; + session?: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ redirectTo: string }>); + logoutGET: + | undefined + | ((input: { + logoutChallenge: string; + options: APIOptions; + session?: SessionContainerInterface; + userContext: UserContext; + }) => Promise<{ redirectTo: string }>); + logoutPOST: + | undefined + | ((input: { + logoutChallenge: string; + options: APIOptions; + session?: SessionContainerInterface; + userContext: UserContext; + }) => Promise<{ redirectTo: string }>); }; export type OAuth2ClientOptions = { @@ -458,6 +492,7 @@ export type OAuth2ClientOptions = { scope: string; redirectUris?: string[] | null; + postLogoutRedirectUris?: string[] | null; allowedCorsOrigins?: string[]; authorizationCodeGrantAccessTokenLifespan?: string | null; diff --git a/lib/ts/recipe/openid/api/getOpenIdDiscoveryConfiguration.ts b/lib/ts/recipe/openid/api/getOpenIdDiscoveryConfiguration.ts index 169589677..466a433f9 100644 --- a/lib/ts/recipe/openid/api/getOpenIdDiscoveryConfiguration.ts +++ b/lib/ts/recipe/openid/api/getOpenIdDiscoveryConfiguration.ts @@ -39,6 +39,7 @@ export default async function getOpenIdDiscoveryConfiguration( userinfo_endpoint: result.userinfo_endpoint, revocation_endpoint: result.revocation_endpoint, token_introspection_endpoint: result.token_introspection_endpoint, + end_session_endpoint: result.end_session_endpoint, subject_types_supported: result.subject_types_supported, id_token_signing_alg_values_supported: result.id_token_signing_alg_values_supported, response_types_supported: result.response_types_supported, diff --git a/lib/ts/recipe/openid/recipeImplementation.ts b/lib/ts/recipe/openid/recipeImplementation.ts index ce91ede5e..04e992059 100644 --- a/lib/ts/recipe/openid/recipeImplementation.ts +++ b/lib/ts/recipe/openid/recipeImplementation.ts @@ -19,6 +19,7 @@ import { GET_JWKS_API } from "../jwt/constants"; import { NormalisedAppinfo, UserContext } from "../../types"; import { AUTH_PATH, + END_SESSION_PATH, INTROSPECT_TOKEN_PATH, REVOKE_TOKEN_PATH, TOKEN_PATH, @@ -47,6 +48,7 @@ export default function getRecipeInterface( userinfo_endpoint: apiBasePath + USER_INFO_PATH, revocation_endpoint: apiBasePath + REVOKE_TOKEN_PATH, token_introspection_endpoint: apiBasePath + INTROSPECT_TOKEN_PATH, + end_session_endpoint: apiBasePath + END_SESSION_PATH, subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], response_types_supported: ["code", "id_token", "id_token token"], diff --git a/lib/ts/recipe/openid/types.ts b/lib/ts/recipe/openid/types.ts index 51702f454..ccd8435ab 100644 --- a/lib/ts/recipe/openid/types.ts +++ b/lib/ts/recipe/openid/types.ts @@ -88,6 +88,7 @@ export type APIInterface = { userinfo_endpoint: string; revocation_endpoint: string; token_introspection_endpoint: string; + end_session_endpoint: string; subject_types_supported: string[]; id_token_signing_alg_values_supported: string[]; response_types_supported: string[]; @@ -108,6 +109,7 @@ export type RecipeInterface = { userinfo_endpoint: string; revocation_endpoint: string; token_introspection_endpoint: string; + end_session_endpoint: string; subject_types_supported: string[]; id_token_signing_alg_values_supported: string[]; response_types_supported: string[]; diff --git a/test/with-typescript/index.ts b/test/with-typescript/index.ts index ae0b9fcd8..c60e07968 100644 --- a/test/with-typescript/index.ts +++ b/test/with-typescript/index.ts @@ -1599,6 +1599,7 @@ Session.init({ userinfo_endpoint: "http://localhost:3000/auth/oauth2/userinfo", revocation_endpoint: "http://localhost:3000/auth/oauth2/revoke", token_introspection_endpoint: "http://localhost:3000/auth/oauth2/introspect", + end_session_endpoint: "http://localhost:3000/auth/oauth2/introspect", id_token_signing_alg_values_supported: [], response_types_supported: [], subject_types_supported: [],