From 41007055f9053a999e1bcacab383a206f9baeb3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20Lengyel?= Date: Tue, 24 Sep 2024 12:28:49 +0200 Subject: [PATCH] feat: integrate with OAuth2 core impl (#926) * WIP * WIP * feat: clean up earlier debugging changes * feat: expose new revoke functions + update tests * feat: make the frontend redirection urls overrideable * feat: update how oauth token payloads work --- lib/build/combinedRemoteJWKSet.d.ts | 4 +- lib/build/combinedRemoteJWKSet.js | 18 +- lib/build/querier.d.ts | 1 - lib/build/querier.js | 67 +- lib/build/recipe/jwt/api/implementation.js | 9 - .../recipe/oauth2client/api/implementation.js | 16 +- lib/build/recipe/oauth2client/api/signin.js | 7 + lib/build/recipe/oauth2client/index.d.ts | 1 + lib/build/recipe/oauth2client/index.js | 19 +- .../oauth2client/recipeImplementation.js | 17 +- lib/build/recipe/oauth2client/types.d.ts | 7 +- lib/build/recipe/oauth2client/utils.js | 25 +- lib/build/recipe/oauth2provider/api/auth.js | 6 + .../recipe/oauth2provider/api/endSession.js | 19 +- .../oauth2provider/api/implementation.js | 16 +- lib/build/recipe/oauth2provider/api/login.js | 14 +- lib/build/recipe/oauth2provider/api/logout.js | 14 +- lib/build/recipe/oauth2provider/api/token.js | 5 +- .../recipe/oauth2provider/api/utils.d.ts | 22 +- lib/build/recipe/oauth2provider/api/utils.js | 57 +- lib/build/recipe/oauth2provider/index.d.ts | 14 + lib/build/recipe/oauth2provider/index.js | 16 +- lib/build/recipe/oauth2provider/recipe.d.ts | 1 - lib/build/recipe/oauth2provider/recipe.js | 43 +- .../oauth2provider/recipeImplementation.d.ts | 5 +- .../oauth2provider/recipeImplementation.js | 591 +++++++++-------- lib/build/recipe/oauth2provider/types.d.ts | 112 +++- lib/build/recipe/session/constants.d.ts | 1 + lib/build/recipe/session/constants.js | 3 +- lib/build/recipe/session/sessionFunctions.js | 2 +- lib/ts/combinedRemoteJWKSet.ts | 11 +- lib/ts/querier.ts | 72 +-- lib/ts/recipe/jwt/api/implementation.ts | 11 - .../recipe/oauth2client/api/implementation.ts | 17 +- lib/ts/recipe/oauth2client/api/signin.ts | 8 + lib/ts/recipe/oauth2client/index.ts | 20 +- .../oauth2client/recipeImplementation.ts | 20 +- lib/ts/recipe/oauth2client/types.ts | 7 +- lib/ts/recipe/oauth2client/utils.ts | 25 +- lib/ts/recipe/oauth2provider/api/auth.ts | 8 +- .../recipe/oauth2provider/api/endSession.ts | 19 +- .../oauth2provider/api/implementation.ts | 20 +- lib/ts/recipe/oauth2provider/api/login.ts | 15 +- lib/ts/recipe/oauth2provider/api/logout.ts | 14 +- lib/ts/recipe/oauth2provider/api/token.ts | 4 +- lib/ts/recipe/oauth2provider/api/utils.ts | 69 +- lib/ts/recipe/oauth2provider/index.ts | 16 + lib/ts/recipe/oauth2provider/recipe.ts | 46 +- .../oauth2provider/recipeImplementation.ts | 595 +++++++++++------- lib/ts/recipe/oauth2provider/types.ts | 69 +- lib/ts/recipe/session/constants.ts | 1 + lib/ts/recipe/session/sessionFunctions.ts | 2 +- test/oauth2/oauth2client.test.js | 2 +- test/session/jwksCache.test.js | 2 +- 54 files changed, 1217 insertions(+), 988 deletions(-) diff --git a/lib/build/combinedRemoteJWKSet.d.ts b/lib/build/combinedRemoteJWKSet.d.ts index 88153ee4e..d36ad16ae 100644 --- a/lib/build/combinedRemoteJWKSet.d.ts +++ b/lib/build/combinedRemoteJWKSet.d.ts @@ -13,7 +13,9 @@ export declare function resetCombinedJWKS(): void; Every core instance a backend is connected to is expected to connect to the same database and use the same key set for token verification. Otherwise, the result of session verification would depend on which core is currently available. */ -export declare function getCombinedJWKS(): ( +export declare function getCombinedJWKS(config: { + jwksRefreshIntervalSec: number; +}): ( protectedHeader?: import("jose").JWSHeaderParameters | undefined, token?: import("jose").FlattenedJWSInput | undefined ) => Promise; diff --git a/lib/build/combinedRemoteJWKSet.js b/lib/build/combinedRemoteJWKSet.js index 0adeefc15..9714ae483 100644 --- a/lib/build/combinedRemoteJWKSet.js +++ b/lib/build/combinedRemoteJWKSet.js @@ -15,10 +15,6 @@ function resetCombinedJWKS() { combinedJWKS = undefined; } exports.resetCombinedJWKS = resetCombinedJWKS; -// TODO: remove this after proper core support -const hydraJWKS = jose_1.createRemoteJWKSet(new URL("http://localhost:4444/.well-known/jwks.json"), { - cooldownDuration: constants_1.JWKCacheCooldownInMs, -}); /** The function returned by this getter fetches all JWKs from the first available core instance. This combines the other JWKS functions to become error resistant. @@ -26,28 +22,18 @@ const hydraJWKS = jose_1.createRemoteJWKSet(new URL("http://localhost:4444/.well Every core instance a backend is connected to is expected to connect to the same database and use the same key set for token verification. Otherwise, the result of session verification would depend on which core is currently available. */ -function getCombinedJWKS() { +function getCombinedJWKS(config) { if (combinedJWKS === undefined) { const JWKS = querier_1.Querier.getNewInstanceOrThrowError(undefined) .getAllCoreUrlsForPath("/.well-known/jwks.json") .map((url) => jose_1.createRemoteJWKSet(new URL(url), { + cacheMaxAge: config.jwksRefreshIntervalSec, cooldownDuration: constants_1.JWKCacheCooldownInMs, }) ); combinedJWKS = async (...args) => { - var _a, _b, _c, _d; let lastError = undefined; - if ( - !((_b = (_a = args[0]) === null || _a === void 0 ? void 0 : _a.kid) === null || _b === void 0 - ? void 0 - : _b.startsWith("s-")) && - !((_d = (_c = args[0]) === null || _c === void 0 ? void 0 : _c.kid) === null || _d === void 0 - ? void 0 - : _d.startsWith("d-")) - ) { - return hydraJWKS(...args); - } if (JWKS.length === 0) { throw Error( "No SuperTokens core available to query. Please pass supertokens > connectionURI to the init function, or override all the functions of the recipe you are using." diff --git a/lib/build/querier.d.ts b/lib/build/querier.d.ts index bc25a61fe..17d12763b 100644 --- a/lib/build/querier.d.ts +++ b/lib/build/querier.d.ts @@ -3,7 +3,6 @@ import NormalisedURLDomain from "./normalisedURLDomain"; import NormalisedURLPath from "./normalisedURLPath"; import { UserContext } from "./types"; import { NetworkInterceptor } from "./types"; -export declare const hydraPubDomain: string; export declare class Querier { private static initCalled; private static hosts; diff --git a/lib/build/querier.js b/lib/build/querier.js index ee366ca91..67345b3d9 100644 --- a/lib/build/querier.js +++ b/lib/build/querier.js @@ -4,9 +4,8 @@ var __importDefault = function (mod) { return mod && mod.__esModule ? mod : { default: mod }; }; -var _a, _b; Object.defineProperty(exports, "__esModule", { value: true }); -exports.Querier = exports.hydraPubDomain = void 0; +exports.Querier = void 0; /* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. * * This software is licensed under the Apache License, Version 2.0 (the @@ -28,10 +27,6 @@ const processState_1 = require("./processState"); const constants_1 = require("./constants"); const logger_1 = require("./logger"); const supertokens_1 = __importDefault(require("./supertokens")); -exports.hydraPubDomain = (_a = process.env.HYDRA_PUB) !== null && _a !== void 0 ? _a : "http://localhost:4444"; // This will be used as a domain for paths starting with hydraPubPathPrefix -const hydraAdmDomain = (_b = process.env.HYDRA_ADM) !== null && _b !== void 0 ? _b : "http://localhost:4445"; // This will be used as a domain for paths starting with hydraAdmPathPrefix -const hydraPubPathPrefix = "/recipe/oauth2/pub"; // Replaced with "/oauth2" when sending the request (/recipe/oauth2/pub/token -> /oauth2/token) -const hydraAdmPathPrefix = "/recipe/oauth2/admin"; // Replaced with "/admin" when sending the request (/recipe/oauth2/admin/clients -> /admin/clients) class Querier { // we have rIdToCore so that recipes can force change the rId sent to core. This is a hack until the core is able // to support multiple rIds per API @@ -106,11 +101,6 @@ class Querier { this.sendPostRequest = async (path, body, userContext) => { var _a; this.invalidateCoreCallCache(userContext); - // TODO: remove FormData - const isForm = body !== undefined && body["$isFormData"]; - if (isForm) { - delete body["$isFormData"]; - } const { body: respBody } = await this.sendRequestHelper( path, "POST", @@ -119,16 +109,7 @@ class Querier { let headers = { "cdi-version": apiVersion, }; - if (isForm) { - headers["content-type"] = "application/x-www-form-urlencoded"; - } else { - headers["content-type"] = "application/json; charset=utf-8"; - } - // TODO: Remove this after core changes are done - if (body !== undefined && body["authorizationHeader"]) { - headers["authorization"] = body["authorizationHeader"]; - delete body["authorizationHeader"]; - } + headers["content-type"] = "application/json; charset=utf-8"; if (Querier.apiKey !== undefined) { headers = Object.assign(Object.assign({}, headers), { "api-key": Querier.apiKey }); } @@ -153,11 +134,7 @@ class Querier { } return utils_1.doFetch(url, { method: "POST", - body: isForm - ? new URLSearchParams(Object.entries(body)).toString() - : body !== undefined - ? JSON.stringify(body) - : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, headers, }); }, @@ -471,17 +448,6 @@ class Querier { let currentDomain = this.__hosts[Querier.lastTriedIndex].domain.getAsStringDangerous(); let currentBasePath = this.__hosts[Querier.lastTriedIndex].basePath.getAsStringDangerous(); let strPath = path.getAsStringDangerous(); - const isHydraAPICall = strPath.startsWith(hydraAdmPathPrefix) || strPath.startsWith(hydraPubPathPrefix); - if (strPath.startsWith(hydraPubPathPrefix)) { - currentDomain = exports.hydraPubDomain; - currentBasePath = ""; - strPath = strPath.replace(hydraPubPathPrefix, "/oauth2"); - } - if (strPath.startsWith(hydraAdmPathPrefix)) { - currentDomain = hydraAdmDomain; - currentBasePath = ""; - strPath = strPath.replace(hydraAdmPathPrefix, "/admin"); - } const url = currentDomain + currentBasePath + strPath; const maxRetries = 5; if (retryInfoMap === undefined) { @@ -501,10 +467,6 @@ class Querier { if (process.env.TEST_MODE === "testing") { Querier.hostsAliveForTesting.add(currentDomain + currentBasePath); } - // TODO: Temporary solution for handling Hydra API calls. Remove when Hydra is no longer called directly. - if (isHydraAPICall) { - return handleHydraAPICall(response); - } if (response.status !== 200) { throw response; } @@ -602,26 +564,3 @@ Querier.hostsAliveForTesting = new Set(); Querier.networkInterceptor = undefined; Querier.globalCacheTag = Date.now(); Querier.disableCache = false; -async function handleHydraAPICall(response) { - const contentType = response.headers.get("Content-Type"); - if (contentType === null || contentType === void 0 ? void 0 : contentType.startsWith("application/json")) { - return { - body: { - status: response.ok ? "OK" : "ERROR", - statusCode: response.status, - data: await response.clone().json(), - }, - headers: response.headers, - }; - } else if (contentType === null || contentType === void 0 ? void 0 : contentType.startsWith("text/plain")) { - return { - body: { - status: response.ok ? "OK" : "ERROR", - statusCode: response.status, - data: await response.clone().text(), - }, - headers: response.headers, - }; - } - return { body: { status: response.ok ? "OK" : "ERROR", statusCode: response.status }, headers: response.headers }; -} diff --git a/lib/build/recipe/jwt/api/implementation.js b/lib/build/recipe/jwt/api/implementation.js index cbef7fd16..e52174f38 100644 --- a/lib/build/recipe/jwt/api/implementation.js +++ b/lib/build/recipe/jwt/api/implementation.js @@ -21,15 +21,6 @@ function getAPIImplementation() { if (resp.validityInSeconds !== undefined) { options.res.setHeader("Cache-Control", `max-age=${resp.validityInSeconds}, must-revalidate`, false); } - const oauth2Provider = require("../../oauth2provider/recipe").default.getInstance(); - // TODO: dirty hack until we get core support - if (oauth2Provider !== undefined) { - const oauth2JWKSRes = await fetch("http://localhost:4444/.well-known/jwks.json"); - if (oauth2JWKSRes.ok) { - const oauth2RespBody = await oauth2JWKSRes.json(); - resp.keys = resp.keys.concat(oauth2RespBody.keys); - } - } return { keys: resp.keys, }; diff --git a/lib/build/recipe/oauth2client/api/implementation.js b/lib/build/recipe/oauth2client/api/implementation.js index 159016c57..5a5e1d56f 100644 --- a/lib/build/recipe/oauth2client/api/implementation.js +++ b/lib/build/recipe/oauth2client/api/implementation.js @@ -9,8 +9,20 @@ const session_1 = __importDefault(require("../../session")); function getAPIInterface() { return { signInPOST: async function (input) { - const { options, tenantId, userContext } = input; - const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + const { options, tenantId, userContext, clientId } = input; + let normalisedClientId = clientId; + if (normalisedClientId === undefined) { + if (options.config.providerConfigs.length > 1) { + throw new Error( + "Should never come here: clientId is undefined and there are multiple providerConfigs" + ); + } + normalisedClientId = options.config.providerConfigs[0].clientId; + } + const providerConfig = await options.recipeImplementation.getProviderConfig({ + clientId: normalisedClientId, + userContext, + }); let oAuthTokensToUse = {}; if ("redirectURIInfo" in input && input.redirectURIInfo !== undefined) { oAuthTokensToUse = await options.recipeImplementation.exchangeAuthCodeForOAuthTokens({ diff --git a/lib/build/recipe/oauth2client/api/signin.js b/lib/build/recipe/oauth2client/api/signin.js index 52631ac60..fa3d2f69f 100644 --- a/lib/build/recipe/oauth2client/api/signin.js +++ b/lib/build/recipe/oauth2client/api/signin.js @@ -29,6 +29,12 @@ async function signInAPI(apiImplementation, tenantId, options, userContext) { const bodyParams = await options.req.getJSONBody(); let redirectURIInfo; let oAuthTokens; + if (bodyParams.clientId === undefined && options.config.providerConfigs.length > 1) { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide the clientId in request body", + }); + } if (bodyParams.redirectURIInfo !== undefined) { if (bodyParams.redirectURIInfo.redirectURI === undefined) { throw new error_1.default({ @@ -59,6 +65,7 @@ async function signInAPI(apiImplementation, tenantId, options, userContext) { } let result = await apiImplementation.signInPOST({ tenantId, + clientId: bodyParams.clientId, redirectURIInfo, oAuthTokens, options, diff --git a/lib/build/recipe/oauth2client/index.d.ts b/lib/build/recipe/oauth2client/index.d.ts index fd09feb41..ff01c3827 100644 --- a/lib/build/recipe/oauth2client/index.d.ts +++ b/lib/build/recipe/oauth2client/index.d.ts @@ -9,6 +9,7 @@ export default class Wrapper { redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; }, + clientId?: string, userContext?: Record ): Promise; static getUserInfo( diff --git a/lib/build/recipe/oauth2client/index.js b/lib/build/recipe/oauth2client/index.js index c70a69f41..e24e2d323 100644 --- a/lib/build/recipe/oauth2client/index.js +++ b/lib/build/recipe/oauth2client/index.js @@ -21,12 +21,22 @@ var __importDefault = Object.defineProperty(exports, "__esModule", { value: true }); exports.getUserInfo = exports.exchangeAuthCodeForOAuthTokens = exports.init = void 0; const utils_1 = require("../../utils"); +const jwt_1 = require("../session/jwt"); const recipe_1 = __importDefault(require("./recipe")); class Wrapper { - static async exchangeAuthCodeForOAuthTokens(redirectURIInfo, userContext) { - const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + static async exchangeAuthCodeForOAuthTokens(redirectURIInfo, clientId, userContext) { + let normalisedClientId = clientId; + const instance = recipe_1.default.getInstanceOrThrowError(); + const recipeInterfaceImpl = instance.recipeInterfaceImpl; const normalisedUserContext = utils_1.getUserContext(userContext); + if (normalisedClientId === undefined) { + if (instance.config.providerConfigs.length > 1) { + throw new Error("clientId is required if there are more than one provider configs defined"); + } + normalisedClientId = instance.config.providerConfigs[0].clientId; + } const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + clientId: normalisedClientId, userContext: normalisedUserContext, }); return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ @@ -38,7 +48,12 @@ class Wrapper { static async getUserInfo(oAuthTokens, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; const normalisedUserContext = utils_1.getUserContext(userContext); + if (oAuthTokens.access_token === undefined) { + throw new Error("access_token is required to get user info"); + } + const preparseJWTInfo = jwt_1.parseJWTWithoutSignatureVerification(oAuthTokens.access_token); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + clientId: preparseJWTInfo.payload.client_id, userContext: normalisedUserContext, }); return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ diff --git a/lib/build/recipe/oauth2client/recipeImplementation.js b/lib/build/recipe/oauth2client/recipeImplementation.js index e935c9e60..351fa3a45 100644 --- a/lib/build/recipe/oauth2client/recipeImplementation.js +++ b/lib/build/recipe/oauth2client/recipeImplementation.js @@ -11,7 +11,7 @@ const __1 = require("../.."); const logger_1 = require("../../logger"); const jose_1 = require("jose"); function getRecipeImplementation(_querier, config) { - let providerConfigWithOIDCInfo = null; + let providerConfigsWithOIDCInfo = {}; return { signIn: async function ({ userId, tenantId, userContext, oAuthTokens, rawUserInfo }) { const user = await __1.getUser(userId, userContext); @@ -26,11 +26,14 @@ function getRecipeImplementation(_querier, config) { rawUserInfo, }; }, - getProviderConfig: async function () { - if (providerConfigWithOIDCInfo !== null) { - return providerConfigWithOIDCInfo; + getProviderConfig: async function ({ clientId }) { + if (providerConfigsWithOIDCInfo[clientId] !== undefined) { + return providerConfigsWithOIDCInfo[clientId]; } - const oidcInfo = await thirdpartyUtils_1.getOIDCDiscoveryInfo(config.providerConfig.oidcDiscoveryEndpoint); + const providerConfig = config.providerConfigs.find( + (providerConfig) => providerConfig.clientId === clientId + ); + const oidcInfo = await thirdpartyUtils_1.getOIDCDiscoveryInfo(providerConfig.oidcDiscoveryEndpoint); if (oidcInfo.authorization_endpoint === undefined) { throw new Error("Failed to authorization_endpoint from the oidcDiscoveryEndpoint."); } @@ -43,13 +46,13 @@ function getRecipeImplementation(_querier, config) { if (oidcInfo.jwks_uri === undefined) { throw new Error("Failed to jwks_uri from the oidcDiscoveryEndpoint."); } - providerConfigWithOIDCInfo = Object.assign(Object.assign({}, config.providerConfig), { + providerConfigsWithOIDCInfo[clientId] = Object.assign(Object.assign({}, providerConfig), { authorizationEndpoint: oidcInfo.authorization_endpoint, tokenEndpoint: oidcInfo.token_endpoint, userInfoEndpoint: oidcInfo.userinfo_endpoint, jwksURI: oidcInfo.jwks_uri, }); - return providerConfigWithOIDCInfo; + return providerConfigsWithOIDCInfo[clientId]; }, exchangeAuthCodeForOAuthTokens: async function ({ providerConfig, redirectURIInfo }) { if (providerConfig.tokenEndpoint === undefined) { diff --git a/lib/build/recipe/oauth2client/types.d.ts b/lib/build/recipe/oauth2client/types.d.ts index 36b685837..84fab1847 100644 --- a/lib/build/recipe/oauth2client/types.d.ts +++ b/lib/build/recipe/oauth2client/types.d.ts @@ -40,7 +40,7 @@ export declare type OAuthTokenResponse = { token_type: string; }; export declare type TypeInput = { - providerConfig: ProviderConfigInput; + providerConfigs: ProviderConfigInput[]; override?: { functions?: ( originalImplementation: RecipeInterface, @@ -50,7 +50,7 @@ export declare type TypeInput = { }; }; export declare type TypeNormalisedInput = { - providerConfig: ProviderConfigInput; + providerConfigs: ProviderConfigInput[]; override: { functions: ( originalImplementation: RecipeInterface, @@ -60,7 +60,7 @@ export declare type TypeNormalisedInput = { }; }; export declare type RecipeInterface = { - getProviderConfig(input: { userContext: UserContext }): Promise; + getProviderConfig(input: { clientId: string; userContext: UserContext }): Promise; signIn(input: { userId: string; oAuthTokens: OAuthTokens; @@ -116,6 +116,7 @@ export declare type APIInterface = { signInPOST: ( input: { tenantId: string; + clientId?: string; options: APIOptions; userContext: UserContext; } & ( diff --git a/lib/build/recipe/oauth2client/utils.js b/lib/build/recipe/oauth2client/utils.js index 0c5e2f39b..25e759254 100644 --- a/lib/build/recipe/oauth2client/utils.js +++ b/lib/build/recipe/oauth2client/utils.js @@ -16,20 +16,19 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.validateAndNormaliseUserInput = void 0; function validateAndNormaliseUserInput(_appInfo, config) { - if (config === undefined || config.providerConfig === undefined) { - throw new Error("Please pass providerConfig argument in the OAuth2Client recipe."); + if (config === undefined || config.providerConfigs === undefined) { + throw new Error("Please pass providerConfigs argument in the OAuth2Client recipe."); } - if (config.providerConfig.clientId === undefined) { - throw new Error("Please pass clientId argument in the OAuth2Client providerConfig."); + if (config.providerConfigs.some((providerConfig) => providerConfig.clientId === undefined)) { + throw new Error("Please pass clientId for all providerConfigs."); } - // TODO: Decide on the prefix and also if we will allow users to customise clientIds - // if (!config.providerConfig.clientId.startsWith("supertokens_")) { - // throw new Error( - // `Only Supertokens OAuth ClientIds are supported in the OAuth2Client recipe. For any other OAuth Clients use the thirdparty recipe.` - // ); - // } - if (config.providerConfig.oidcDiscoveryEndpoint === undefined) { - throw new Error("Please pass oidcDiscoveryEndpoint argument in the OAuth2Client providerConfig."); + if (!config.providerConfigs.every((providerConfig) => providerConfig.clientId.startsWith("stcl_"))) { + throw new Error( + `Only Supertokens OAuth ClientIds are supported in the OAuth2Client recipe. For any other OAuth Clients use the ThirdParty recipe.` + ); + } + if (config.providerConfigs.some((providerConfig) => providerConfig.oidcDiscoveryEndpoint === undefined)) { + throw new Error("Please pass oidcDiscoveryEndpoint for all providerConfigs."); } let override = Object.assign( { @@ -39,7 +38,7 @@ function validateAndNormaliseUserInput(_appInfo, config) { config === null || config === void 0 ? void 0 : config.override ); return { - providerConfig: config.providerConfig, + providerConfigs: config.providerConfigs, override, }; } diff --git a/lib/build/recipe/oauth2provider/api/auth.js b/lib/build/recipe/oauth2provider/api/auth.js index a064f9bb4..17e61b66b 100644 --- a/lib/build/recipe/oauth2provider/api/auth.js +++ b/lib/build/recipe/oauth2provider/api/auth.js @@ -24,6 +24,7 @@ const set_cookie_parser_1 = __importDefault(require("set-cookie-parser")); const session_1 = __importDefault(require("../../session")); const error_1 = __importDefault(require("../../../recipe/session/error")); async function authGET(apiImplementation, options, userContext) { + var _a; if (apiImplementation.authGET === undefined) { return false; } @@ -70,6 +71,11 @@ async function authGET(apiImplementation, options, userContext) { } } options.res.original.redirect(response.redirectTo); + } else if ("statusCode" in response) { + utils_1.sendNon200Response(options.res, (_a = response.statusCode) !== null && _a !== void 0 ? _a : 400, { + error: response.error, + error_description: response.errorDescription, + }); } else { utils_1.send200Response(options.res, response); } diff --git a/lib/build/recipe/oauth2provider/api/endSession.js b/lib/build/recipe/oauth2provider/api/endSession.js index aba2e1f4c..f0d1da3b2 100644 --- a/lib/build/recipe/oauth2provider/api/endSession.js +++ b/lib/build/recipe/oauth2provider/api/endSession.js @@ -48,10 +48,10 @@ async function endSessionPOST(apiImplementation, options, userContext) { } exports.endSessionPOST = endSessionPOST; async function endSessionCommon(params, apiImplementation, options, userContext) { + var _a; if (apiImplementation === undefined) { return false; } - // TODO (core): If client_id is passed, validate if it the same one that was used to issue the id_token let session, shouldTryRefresh; try { session = await session_1.default.getSession(options.req, options.res, { sessionRequired: false }, userContext); @@ -74,17 +74,12 @@ async function endSessionCommon(params, apiImplementation, options, userContext) 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); - } + options.res.original.redirect(response.redirectTo); + } else if ("error" in response) { + utils_1.sendNon200Response(options.res, (_a = response.statusCode) !== null && _a !== void 0 ? _a : 400, { + error: response.error, + error_description: response.errorDescription, + }); } else { utils_1.send200Response(options.res, response); } diff --git a/lib/build/recipe/oauth2provider/api/implementation.js b/lib/build/recipe/oauth2provider/api/implementation.js index 34d39ace4..986c23590 100644 --- a/lib/build/recipe/oauth2provider/api/implementation.js +++ b/lib/build/recipe/oauth2provider/api/implementation.js @@ -42,6 +42,9 @@ function getAPIImplementation() { session, userContext, }); + if ("error" in response) { + return response; + } return utils_1.handleLoginInternalRedirects({ response, recipeImplementation: options.recipeImplementation, @@ -117,6 +120,9 @@ function getAPIImplementation() { shouldTryRefresh, userContext, }); + if ("error" in response) { + return response; + } return utils_1.handleLogoutInternalRedirects({ response, session, @@ -131,6 +137,9 @@ function getAPIImplementation() { shouldTryRefresh, userContext, }); + if ("error" in response) { + return response; + } return utils_1.handleLogoutInternalRedirects({ response, session, @@ -146,12 +155,15 @@ function getAPIImplementation() { challenge: logoutChallenge, userContext, }); - const { redirectTo } = await utils_1.handleLogoutInternalRedirects({ + const res = await utils_1.handleLogoutInternalRedirects({ response, recipeImplementation: options.recipeImplementation, userContext, }); - return { status: "OK", frontendRedirectTo: redirectTo }; + if ("error" in res) { + return res; + } + return { status: "OK", frontendRedirectTo: res.redirectTo }; }, }; } diff --git a/lib/build/recipe/oauth2provider/api/login.js b/lib/build/recipe/oauth2provider/api/login.js index 6ca65a5e3..af7a36837 100644 --- a/lib/build/recipe/oauth2provider/api/login.js +++ b/lib/build/recipe/oauth2provider/api/login.js @@ -25,7 +25,7 @@ const session_1 = __importDefault(require("../../session")); const error_1 = __importDefault(require("../../../error")); const error_2 = __importDefault(require("../../../recipe/session/error")); async function login(apiImplementation, options, userContext) { - var _a; + var _a, _b; if (apiImplementation.loginGET === undefined) { return false; } @@ -60,9 +60,7 @@ async function login(apiImplementation, options, userContext) { shouldTryRefresh, userContext, }); - if ("status" in response) { - utils_1.send200Response(options.res, response); - } else { + if ("redirectTo" in response) { if (response.setCookie) { const cookieStr = set_cookie_parser_1.default.splitCookiesString(response.setCookie); const cookies = set_cookie_parser_1.default.parse(cookieStr); @@ -80,6 +78,14 @@ async function login(apiImplementation, options, userContext) { } } options.res.original.redirect(response.redirectTo); + } else if ("statusCode" in response) { + utils_1.sendNon200ResponseWithMessage( + options.res, + response.error + ": " + response.errorDescription, + (_b = response.statusCode) !== null && _b !== void 0 ? _b : 400 + ); + } else { + utils_1.send200Response(options.res, response); } return true; } diff --git a/lib/build/recipe/oauth2provider/api/logout.js b/lib/build/recipe/oauth2provider/api/logout.js index 1e382996b..16a51b511 100644 --- a/lib/build/recipe/oauth2provider/api/logout.js +++ b/lib/build/recipe/oauth2provider/api/logout.js @@ -24,13 +24,14 @@ const utils_1 = require("../../../utils"); const session_1 = __importDefault(require("../../session")); const error_1 = __importDefault(require("../../../error")); async function logoutPOST(apiImplementation, options, userContext) { + var _a; if (apiImplementation.logoutPOST === undefined) { return false; } let session; try { session = await session_1.default.getSession(options.req, options.res, { sessionRequired: false }, userContext); - } catch (_a) { + } catch (_b) { session = undefined; } const body = await options.req.getBodyAsJSONOrFormData(); @@ -46,7 +47,16 @@ async function logoutPOST(apiImplementation, options, userContext) { session, userContext, }); - utils_1.send200Response(options.res, response); + if ("status" in response && response.status === "OK") { + utils_1.send200Response(options.res, response); + } else if ("statusCode" in response) { + utils_1.sendNon200Response(options.res, (_a = response.statusCode) !== null && _a !== void 0 ? _a : 400, { + error: response.error, + error_description: response.errorDescription, + }); + } else { + utils_1.send200Response(options.res, response); + } return true; } exports.logoutPOST = logoutPOST; diff --git a/lib/build/recipe/oauth2provider/api/token.js b/lib/build/recipe/oauth2provider/api/token.js index 3ff27158c..9aaa0bd38 100644 --- a/lib/build/recipe/oauth2provider/api/token.js +++ b/lib/build/recipe/oauth2provider/api/token.js @@ -16,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const utils_1 = require("../../../utils"); async function tokenPOST(apiImplementation, options, userContext) { + var _a; if (apiImplementation.tokenPOST === undefined) { return false; } @@ -26,8 +27,8 @@ async function tokenPOST(apiImplementation, options, userContext) { body: await options.req.getBodyAsJSONOrFormData(), userContext, }); - if ("statusCode" in response && response.statusCode !== 200) { - utils_1.sendNon200Response(options.res, response.statusCode, { + if ("error" in response) { + utils_1.sendNon200Response(options.res, (_a = response.statusCode) !== null && _a !== void 0 ? _a : 400, { error: response.error, error_description: response.errorDescription, }); diff --git a/lib/build/recipe/oauth2provider/api/utils.d.ts b/lib/build/recipe/oauth2provider/api/utils.d.ts index 527e87a35..63245a812 100644 --- a/lib/build/recipe/oauth2provider/api/utils.d.ts +++ b/lib/build/recipe/oauth2provider/api/utils.d.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { UserContext } from "../../../types"; import { SessionContainerInterface } from "../../session/types"; -import { RecipeInterface } from "../types"; +import { ErrorOAuth2, RecipeInterface } from "../types"; export declare function loginGET({ recipeImplementation, loginChallenge, @@ -39,10 +39,13 @@ export declare function handleLoginInternalRedirects({ shouldTryRefresh: boolean; cookie?: string; userContext: UserContext; -}): Promise<{ - redirectTo: string; - setCookie?: string; -}>; +}): Promise< + | { + redirectTo: string; + setCookie?: string; + } + | ErrorOAuth2 +>; export declare function handleLogoutInternalRedirects({ response, recipeImplementation, @@ -55,6 +58,9 @@ export declare function handleLogoutInternalRedirects({ recipeImplementation: RecipeInterface; session?: SessionContainerInterface; userContext: UserContext; -}): Promise<{ - redirectTo: string; -}>; +}): Promise< + | { + redirectTo: string; + } + | ErrorOAuth2 +>; diff --git a/lib/build/recipe/oauth2provider/api/utils.js b/lib/build/recipe/oauth2provider/api/utils.js index 531f2bf13..58390408c 100644 --- a/lib/build/recipe/oauth2provider/api/utils.js +++ b/lib/build/recipe/oauth2provider/api/utils.js @@ -88,27 +88,13 @@ async function loginGET({ }); return { redirectTo: accept.redirectTo, setCookie }; } - const appInfo = supertokens_1.default.getInstanceOrThrowError().appInfo; - const websiteDomain = appInfo - .getOrigin({ - request: undefined, - userContext: userContext, - }) - .getAsStringDangerous(); - const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); - if (shouldTryRefresh) { - const websiteDomain = appInfo - .getOrigin({ - request: undefined, - userContext: userContext, - }) - .getAsStringDangerous(); - const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); - const queryParamsForTryRefreshPage = new URLSearchParams({ - loginChallenge, - }); + if (shouldTryRefresh && promptParam !== "login") { return { - redirectTo: websiteDomain + websiteBasePath + `/try-refresh?${queryParamsForTryRefreshPage.toString()}`, + redirectTo: await recipeImplementation.getFrontendRedirectionURL({ + type: "try-refresh", + loginChallenge, + userContext, + }), setCookie, }; } @@ -124,20 +110,16 @@ async function loginGET({ }); return { redirectTo: reject.redirectTo, setCookie }; } - const queryParamsForAuthPage = new URLSearchParams({ - loginChallenge, - }); - if ((_b = loginRequest.oidcContext) === null || _b === void 0 ? void 0 : _b.login_hint) { - queryParamsForAuthPage.set("hint", loginRequest.oidcContext.login_hint); - } - if (session !== undefined || promptParam === "login") { - queryParamsForAuthPage.set("forceFreshAuth", "true"); - } - if (tenantIdParam !== null && tenantIdParam !== constants_1.DEFAULT_TENANT_ID) { - queryParamsForAuthPage.set("tenantId", tenantIdParam); - } return { - redirectTo: websiteDomain + websiteBasePath + `?${queryParamsForAuthPage.toString()}`, + redirectTo: await recipeImplementation.getFrontendRedirectionURL({ + type: "login", + loginChallenge, + forceFreshAuth: session !== undefined || promptParam === "login", + tenantId: + tenantIdParam !== null && tenantIdParam !== void 0 ? tenantIdParam : constants_1.DEFAULT_TENANT_ID, + hint: (_b = loginRequest.oidcContext) === null || _b === void 0 ? void 0 : _b.login_hint, + userContext, + }), setCookie, }; } @@ -237,6 +219,9 @@ async function handleLoginInternalRedirects({ session, userContext, }); + if ("error" in authRes) { + return authRes; + } response = { redirectTo: authRes.redirectTo, setCookie: mergeSetCookieHeaders(authRes.setCookie, response.setCookie), @@ -264,7 +249,7 @@ async function handleLogoutInternalRedirects({ response, recipeImplementation, s const queryString = response.redirectTo.split("?")[1]; const params = new URLSearchParams(queryString); if (response.redirectTo.includes(constants_2.END_SESSION_PATH)) { - response = await recipeImplementation.endSession({ + const endSessionRes = await recipeImplementation.endSession({ params: Object.fromEntries(params.entries()), session, // We internally redirect to the `end_session_endpoint` at the end of the logout flow. @@ -273,6 +258,10 @@ async function handleLogoutInternalRedirects({ response, recipeImplementation, s shouldTryRefresh: false, userContext, }); + if ("error" in endSessionRes) { + return endSessionRes; + } + response = endSessionRes; } else { throw new Error(`Unexpected internal redirect ${response.redirectTo}`); } diff --git a/lib/build/recipe/oauth2provider/index.d.ts b/lib/build/recipe/oauth2provider/index.d.ts index 7a6f3c8f3..7d94b9903 100644 --- a/lib/build/recipe/oauth2provider/index.d.ts +++ b/lib/build/recipe/oauth2provider/index.d.ts @@ -112,6 +112,18 @@ export default class Wrapper { status: "OK"; } >; + static revokeTokensByClientId( + clientId: string, + userContext?: Record + ): Promise<{ + status: "OK"; + }>; + static revokeTokensBySessionHandle( + sessionHandle: string, + userContext?: Record + ): Promise<{ + status: "OK"; + }>; static validateOAuth2RefreshToken( token: string, scopes?: string[], @@ -126,4 +138,6 @@ export declare let deleteOAuth2Client: typeof Wrapper.deleteOAuth2Client; export declare let validateOAuth2AccessToken: typeof Wrapper.validateOAuth2AccessToken; export declare let createTokenForClientCredentials: typeof Wrapper.createTokenForClientCredentials; export declare let revokeToken: typeof Wrapper.revokeToken; +export declare let revokeTokensByClientId: typeof Wrapper.revokeTokensByClientId; +export declare let revokeTokensBySessionHandle: typeof Wrapper.revokeTokensBySessionHandle; export type { APIInterface, APIOptions, RecipeInterface }; diff --git a/lib/build/recipe/oauth2provider/index.js b/lib/build/recipe/oauth2provider/index.js index 3a769700e..7bb585509 100644 --- a/lib/build/recipe/oauth2provider/index.js +++ b/lib/build/recipe/oauth2provider/index.js @@ -19,7 +19,7 @@ var __importDefault = return mod && mod.__esModule ? mod : { default: mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.revokeToken = exports.createTokenForClientCredentials = exports.validateOAuth2AccessToken = exports.deleteOAuth2Client = exports.updateOAuth2Client = exports.createOAuth2Client = exports.getOAuth2Clients = exports.init = void 0; +exports.revokeTokensBySessionHandle = exports.revokeTokensByClientId = exports.revokeToken = exports.createTokenForClientCredentials = exports.validateOAuth2AccessToken = exports.deleteOAuth2Client = exports.updateOAuth2Client = exports.createOAuth2Client = exports.getOAuth2Clients = exports.init = void 0; const utils_1 = require("../../utils"); const recipe_1 = __importDefault(require("./recipe")); class Wrapper { @@ -105,6 +105,18 @@ class Wrapper { userContext: normalisedUserContext, }); } + static async revokeTokensByClientId(clientId, userContext) { + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.revokeTokensByClientId({ + clientId, + userContext: utils_1.getUserContext(userContext), + }); + } + static async revokeTokensBySessionHandle(sessionHandle, userContext) { + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.revokeTokensBySessionHandle({ + sessionHandle, + userContext: utils_1.getUserContext(userContext), + }); + } static validateOAuth2RefreshToken(token, scopes, userContext) { return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.introspectToken({ token, @@ -123,3 +135,5 @@ exports.deleteOAuth2Client = Wrapper.deleteOAuth2Client; exports.validateOAuth2AccessToken = Wrapper.validateOAuth2AccessToken; exports.createTokenForClientCredentials = Wrapper.createTokenForClientCredentials; exports.revokeToken = Wrapper.revokeToken; +exports.revokeTokensByClientId = Wrapper.revokeTokensByClientId; +exports.revokeTokensBySessionHandle = Wrapper.revokeTokensBySessionHandle; diff --git a/lib/build/recipe/oauth2provider/recipe.d.ts b/lib/build/recipe/oauth2provider/recipe.d.ts index 32241c801..d2739dbf7 100644 --- a/lib/build/recipe/oauth2provider/recipe.d.ts +++ b/lib/build/recipe/oauth2provider/recipe.d.ts @@ -32,7 +32,6 @@ export default class Recipe extends RecipeModule { addUserInfoBuilderFromOtherRecipe: (userInfoBuilderFn: UserInfoBuilderFunction) => void; addAccessTokenBuilderFromOtherRecipe: (accessTokenBuilders: PayloadBuilderFunction) => void; addIdTokenBuilderFromOtherRecipe: (idTokenBuilder: PayloadBuilderFunction) => void; - saveTokensForHook: (sessionHandle: string, idToken: JSONObject, accessToken: JSONObject) => void; getAPIsHandled(): APIHandled[]; handleAPIRequest: ( id: string, diff --git a/lib/build/recipe/oauth2provider/recipe.js b/lib/build/recipe/oauth2provider/recipe.js index 171d58467..c0265a40f 100644 --- a/lib/build/recipe/oauth2provider/recipe.js +++ b/lib/build/recipe/oauth2provider/recipe.js @@ -39,8 +39,6 @@ const introspectToken_1 = __importDefault(require("./api/introspectToken")); const endSession_1 = require("./api/endSession"); const logout_1 = require("./api/logout"); const session_1 = require("../session"); -const utils_2 = require("../../utils"); -const tokenHookMap = new Map(); class Recipe extends recipeModule_1.default { constructor(recipeId, appInfo, isInServerlessEnv, config) { super(recipeId, appInfo); @@ -56,9 +54,6 @@ class Recipe extends recipeModule_1.default { this.addIdTokenBuilderFromOtherRecipe = (idTokenBuilder) => { this.idTokenBuilders.push(idTokenBuilder); }; - this.saveTokensForHook = (sessionHandle, idToken, accessToken) => { - tokenHookMap.set(sessionHandle, { idToken, accessToken }); - }; this.handleAPIRequest = async (id, tenantId, req, res, _path, method, userContext) => { let options = { config: this.config, @@ -98,23 +93,6 @@ class Recipe extends recipeModule_1.default { if (id === constants_1.LOGOUT_PATH && method === "post") { return logout_1.logoutPOST(this.apiImpl, options, userContext); } - if (id === "token-hook") { - const body = await options.req.getBodyAsJSONOrFormData(); - const sessionHandle = body.session.extra.sessionHandle; - const tokens = tokenHookMap.get(sessionHandle); - if (tokens !== undefined) { - const { idToken, accessToken } = tokens; - utils_2.send200Response(options.res, { - session: { - access_token: accessToken, - id_token: idToken, - }, - }); - } else { - utils_2.send200Response(options.res, {}); - } - return true; - } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; this.config = utils_1.validateAndNormaliseUserInput(this, appInfo, config); @@ -127,8 +105,7 @@ class Recipe extends recipeModule_1.default { appInfo, this.getDefaultAccessTokenPayload.bind(this), this.getDefaultIdTokenPayload.bind(this), - this.getDefaultUserInfoPayload.bind(this), - this.saveTokensForHook.bind(this) + this.getDefaultUserInfoPayload.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); @@ -210,13 +187,6 @@ class Recipe extends recipeModule_1.default { id: constants_1.INTROSPECT_TOKEN_PATH, disabled: this.apiImpl.introspectTokenPOST === undefined, }, - { - // TODO: remove this once we get core support - method: "post", - pathWithoutApiBasePath: new normalisedURLPath_1.default("/oauth/token-hook"), - id: "token-hook", - disabled: false, - }, { method: "get", pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.END_SESSION_PATH), @@ -251,21 +221,14 @@ class Recipe extends recipeModule_1.default { if (sessionInfo === undefined) { throw new Error("Session not found"); } - let payload = { - iss: this.appInfo.apiDomain.getAsStringDangerous() + this.appInfo.apiBasePath.getAsStringDangerous(), - tId: sessionInfo.tenantId, - rsub: sessionInfo.recipeUserId.getAsString(), - sessionHandle: sessionHandle, - }; + let payload = {}; for (const fn of this.accessTokenBuilders) { payload = Object.assign(Object.assign({}, payload), await fn(user, scopes, sessionHandle, userContext)); } return payload; } async getDefaultIdTokenPayload(user, scopes, sessionHandle, userContext) { - let payload = { - iss: this.appInfo.apiDomain.getAsStringDangerous() + this.appInfo.apiBasePath.getAsStringDangerous(), - }; + let payload = {}; if (scopes.includes("email")) { payload.email = user === null || user === void 0 ? void 0 : user.emails[0]; payload.email_verified = user.loginMethods.some( diff --git a/lib/build/recipe/oauth2provider/recipeImplementation.d.ts b/lib/build/recipe/oauth2provider/recipeImplementation.d.ts index 3b6fee065..4ecaeef69 100644 --- a/lib/build/recipe/oauth2provider/recipeImplementation.d.ts +++ b/lib/build/recipe/oauth2provider/recipeImplementation.d.ts @@ -1,6 +1,6 @@ // @ts-nocheck import { Querier } from "../../querier"; -import { JSONObject, NormalisedAppinfo } from "../../types"; +import { NormalisedAppinfo } from "../../types"; import { RecipeInterface, TypeNormalisedInput, PayloadBuilderFunction, UserInfoBuilderFunction } from "./types"; export default function getRecipeInterface( querier: Querier, @@ -8,6 +8,5 @@ export default function getRecipeInterface( appInfo: NormalisedAppinfo, getDefaultAccessTokenPayload: PayloadBuilderFunction, getDefaultIdTokenPayload: PayloadBuilderFunction, - getDefaultUserInfoPayload: UserInfoBuilderFunction, - saveTokensForHook: (sessionHandle: string, idToken: JSONObject, accessToken: JSONObject) => void + getDefaultUserInfoPayload: UserInfoBuilderFunction ): RecipeInterface; diff --git a/lib/build/recipe/oauth2provider/recipeImplementation.js b/lib/build/recipe/oauth2provider/recipeImplementation.js index b513d77e2..3031e5f43 100644 --- a/lib/build/recipe/oauth2provider/recipeImplementation.js +++ b/lib/build/recipe/oauth2provider/recipeImplementation.js @@ -57,20 +57,23 @@ var __importDefault = Object.defineProperty(exports, "__esModule", { value: true }); const jose = __importStar(require("jose")); const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); -const querier_1 = require("../../querier"); -const utils_1 = require("../../utils"); const OAuth2Client_1 = require("./OAuth2Client"); const __1 = require("../.."); const combinedRemoteJWKSet_1 = require("../../combinedRemoteJWKSet"); -const session_1 = require("../session"); -// TODO: Remove this core changes are done +const recipe_1 = __importDefault(require("../session/recipe")); +const constants_1 = require("../multitenancy/constants"); function getUpdatedRedirectTo(appInfo, redirectTo) { - return redirectTo - .replace( - querier_1.hydraPubDomain, - appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous() - ) - .replace("oauth2/", "oauth/"); + return redirectTo.replace( + "{apiDomain}", + appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous() + ); +} +function copyAndCleanRequestBodyInput(input) { + let result = Object.assign({}, input); + delete result.userContext; + delete result.tenantId; + delete result.session; + return result; } function getRecipeInterface( querier, @@ -78,59 +81,57 @@ function getRecipeInterface( appInfo, getDefaultAccessTokenPayload, getDefaultIdTokenPayload, - getDefaultUserInfoPayload, - saveTokensForHook + getDefaultUserInfoPayload ) { return { getLoginRequest: async function (input) { const resp = await querier.sendGetRequest( - new normalisedURLPath_1.default("/recipe/oauth2/admin/oauth2/auth/requests/login"), - { login_challenge: input.challenge }, + new normalisedURLPath_1.default("/recipe/oauth/auth/requests/login"), + { loginChallenge: input.challenge }, input.userContext ); return { - challenge: resp.data.challenge, - client: OAuth2Client_1.OAuth2Client.fromAPIResponse(resp.data.client), - oidcContext: resp.data.oidc_context, - requestUrl: resp.data.request_url, - requestedAccessTokenAudience: resp.data.requested_access_token_audience, - requestedScope: resp.data.requested_scope, - sessionId: resp.data.session_id, - skip: resp.data.skip, - subject: resp.data.subject, + challenge: resp.challenge, + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(resp.client), + oidcContext: resp.oidcContext, + requestUrl: resp.requestUrl, + requestedAccessTokenAudience: resp.requestedAccessTokenAudience, + requestedScope: resp.requestedScope, + sessionId: resp.sessionId, + skip: resp.skip, + subject: resp.subject, }; }, acceptLoginRequest: async function (input) { const resp = await querier.sendPutRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/login/accept`), + new normalisedURLPath_1.default(`/recipe/oauth/auth/requests/login/accept`), { acr: input.acr, amr: input.amr, context: input.context, - extend_session_lifespan: input.extendSessionLifespan, - force_subject_identifier: input.forceSubjectIdentifier, - identity_provider_session_id: input.identityProviderSessionId, + extendSessionLifespan: input.extendSessionLifespan, + forceSubjectIdentifier: input.forceSubjectIdentifier, + identityProviderSessionId: input.identityProviderSessionId, remember: input.remember, - remember_for: input.rememberFor, + rememberFor: input.rememberFor, subject: input.subject, }, { - login_challenge: input.challenge, + loginChallenge: input.challenge, }, input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to), + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo), }; }, rejectLoginRequest: async function (input) { const resp = await querier.sendPutRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/login/reject`), + new normalisedURLPath_1.default(`/recipe/oauth/auth/requests/login/reject`), { error: input.error.error, - error_description: input.error.errorDescription, - status_code: input.error.statusCode, + errorDescription: input.error.errorDescription, + statusCode: input.error.statusCode, }, { login_challenge: input.challenge, @@ -138,89 +139,155 @@ function getRecipeInterface( input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to), + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo), }; }, getConsentRequest: async function (input) { const resp = await querier.sendGetRequest( - new normalisedURLPath_1.default("/recipe/oauth2/admin/oauth2/auth/requests/consent"), - { consent_challenge: input.challenge }, + new normalisedURLPath_1.default("/recipe/oauth/auth/requests/consent"), + { consentChallenge: input.challenge }, input.userContext ); return { - acr: resp.data.acr, - amr: resp.data.amr, - challenge: resp.data.challenge, - client: OAuth2Client_1.OAuth2Client.fromAPIResponse(resp.data.client), - context: resp.data.context, - loginChallenge: resp.data.login_challenge, - loginSessionId: resp.data.login_session_id, - oidcContext: resp.data.oidc_context, - requestedAccessTokenAudience: resp.data.requested_access_token_audience, - requestedScope: resp.data.requested_scope, - skip: resp.data.skip, - subject: resp.data.subject, + acr: resp.acr, + amr: resp.amr, + challenge: resp.challenge, + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(resp.client), + context: resp.context, + loginChallenge: resp.loginChallenge, + loginSessionId: resp.loginSessionId, + oidcContext: resp.oidcContext, + requestedAccessTokenAudience: resp.requestedAccessTokenAudience, + requestedScope: resp.requestedScope, + skip: resp.skip, + subject: resp.subject, }; }, acceptConsentRequest: async function (input) { const resp = await querier.sendPutRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/consent/accept`), + new normalisedURLPath_1.default(`/recipe/oauth/auth/requests/consent/accept`), { context: input.context, - grant_access_token_audience: input.grantAccessTokenAudience, - grant_scope: input.grantScope, - handled_at: input.handledAt, + grantAccessTokenAudience: input.grantAccessTokenAudience, + grantScope: input.grantScope, + handledAt: input.handledAt, remember: input.remember, - remember_for: input.rememberFor, - session: input.session, + rememberFor: input.rememberFor, + iss: appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(), + tId: input.tenantId, + rsub: input.rsub, + sessionHandle: input.sessionHandle, + initialAccessTokenPayload: input.initialAccessTokenPayload, + initialIdTokenPayload: input.initialIdTokenPayload, }, { - consent_challenge: input.challenge, + consentChallenge: input.challenge, }, input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to), + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo), }; }, rejectConsentRequest: async function (input) { const resp = await querier.sendPutRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/consent/reject`), + new normalisedURLPath_1.default(`/recipe/oauth/auth/requests/consent/reject`), { error: input.error.error, - error_description: input.error.errorDescription, - status_code: input.error.statusCode, + errorDescription: input.error.errorDescription, + statusCode: input.error.statusCode, }, { - consent_challenge: input.challenge, + consentChallenge: input.challenge, }, input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to), + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo), }; }, authorization: async function (input) { - var _a, _b; + var _a, _b, _c, _d, _e; + // we handle this in the backend SDK level + if (input.params.prompt === "none") { + input.params["st_prompt"] = "none"; + delete input.params.prompt; + } + let payloads; + if (input.params.client_id === undefined || typeof input.params.client_id !== "string") { + return { + statusCode: 400, + error: "invalid_request", + errorDescription: "client_id is required and must be a string", + }; + } + const scopes = await this.getRequestedScopes({ + scopeParam: ((_a = input.params.scope) === null || _a === void 0 ? void 0 : _a.split(" ")) || [], + clientId: input.params.client_id, + recipeUserId: (_b = input.session) === null || _b === void 0 ? void 0 : _b.getRecipeUserId(), + sessionHandle: (_c = input.session) === null || _c === void 0 ? void 0 : _c.getHandle(), + userContext: input.userContext, + }); + const responseTypes = + (_e = (_d = input.params.response_type) === null || _d === void 0 ? void 0 : _d.split(" ")) !== null && + _e !== void 0 + ? _e + : []; if (input.session !== undefined) { - if (input.params.prompt === "none") { - input.params["st_prompt"] = "none"; - delete input.params.prompt; + const clientInfo = await this.getOAuth2Client({ + clientId: input.params.client_id, + userContext: input.userContext, + }); + if (clientInfo.status === "ERROR") { + throw new Error(clientInfo.error); } + const client = clientInfo.client; + const user = await __1.getUser(input.session.getUserId()); + if (!user) { + throw new Error("User not found"); + } + const idToken = + responseTypes.includes("id_token") || responseTypes.includes("code") + ? await this.buildIdTokenPayload({ + user, + client, + sessionHandle: input.session.getHandle(), + scopes, + userContext: input.userContext, + }) + : undefined; + const accessToken = + responseTypes.includes("token") || responseTypes.includes("code") + ? await this.buildAccessTokenPayload({ + user, + client, + sessionHandle: input.session.getHandle(), + scopes, + userContext: input.userContext, + }) + : undefined; + payloads = { + idToken, + accessToken, + }; } - const resp = await querier.sendGetRequestWithResponseHeaders( - new normalisedURLPath_1.default(`/recipe/oauth2/pub/auth`), - input.params, + const resp = await querier.sendPostRequest( + new normalisedURLPath_1.default(`/recipe/oauth/auth`), { - // TODO: if session is not set also clear the oauth2 cookie - Cookie: `${input.cookies}`, + params: Object.assign(Object.assign({}, input.params), { scope: scopes.join(" ") }), + cookies: input.cookies, + session: payloads, }, input.userContext ); - const redirectTo = getUpdatedRedirectTo(appInfo, resp.headers.get("Location")); + if (resp.status !== "OK") { + return { + statusCode: resp.statusCode, + error: resp.error, + errorDescription: resp.errorDescription, + }; + } + const redirectTo = getUpdatedRedirectTo(appInfo, resp.redirectTo); if (redirectTo === undefined) { throw new Error(resp.body); } @@ -231,70 +298,84 @@ function getRecipeInterface( challenge: consentChallenge, userContext: input.userContext, }); - const user = await __1.getUser(input.session.getUserId()); - if (!user) { - throw new Error("Should not happen"); - } - const idToken = await this.buildIdTokenPayload({ - user, - client: consentRequest.client, - sessionHandle: input.session.getHandle(), - scopes: consentRequest.requestedScope || [], + const consentRes = await this.acceptConsentRequest({ userContext: input.userContext, - }); - const accessTokenPayload = await this.buildAccessTokenPayload({ - user, - client: consentRequest.client, + challenge: consentRequest.challenge, + grantAccessTokenAudience: consentRequest.requestedAccessTokenAudience, + grantScope: consentRequest.requestedScope, + tenantId: input.session.getTenantId(), + rsub: input.session.getRecipeUserId().getAsString(), sessionHandle: input.session.getHandle(), - scopes: consentRequest.requestedScope || [], - userContext: input.userContext, + initialAccessTokenPayload: payloads === null || payloads === void 0 ? void 0 : payloads.accessToken, + initialIdTokenPayload: payloads === null || payloads === void 0 ? void 0 : payloads.idToken, }); - const sessionInfo = await session_1.getSessionInformation(input.session.getHandle()); - if (!sessionInfo) { - throw new Error("Session not found"); - } - const consentRes = await this.acceptConsentRequest( - Object.assign(Object.assign({}, input), { - challenge: consentRequest.challenge, - grantAccessTokenAudience: consentRequest.requestedAccessTokenAudience, - grantScope: consentRequest.requestedScope, - remember: true, - session: { - id_token: idToken, - access_token: accessTokenPayload, - }, - handledAt: new Date(sessionInfo.timeCreated).toISOString(), - }) - ); return { redirectTo: consentRes.redirectTo, - setCookie: (_a = resp.headers.get("set-cookie")) !== null && _a !== void 0 ? _a : undefined, + setCookie: resp.cookies, }; } - return { - redirectTo, - setCookie: (_b = resp.headers.get("set-cookie")) !== null && _b !== void 0 ? _b : undefined, - }; + return { redirectTo, setCookie: resp.cookies }; }, tokenExchange: async function (input) { - var _a, _b; - const body = { $isFormData: true }; // TODO: we ideally want to avoid using formdata, the core can do the translation - for (const key in input.body) { - body[key] = input.body[key]; - } - if (input.body.grant_type === "refresh_token") { + var _a, _b, _c, _d; + const body = { + inputBody: input.body, + authorizationHeader: input.authorizationHeader, + }; + body.iss = appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(); + if (input.body.grant_type === "client_credentials") { + if (input.body.client_id === undefined) { + return { + statusCode: 400, + error: "invalid_request", + errorDescription: "client_id is required", + }; + } const scopes = (_b = (_a = input.body.scope) === null || _a === void 0 ? void 0 : _a.split(" ")) !== null && _b !== void 0 ? _b : []; + const clientInfo = await this.getOAuth2Client({ + clientId: input.body.client_id, + userContext: input.userContext, + }); + if (clientInfo.status === "ERROR") { + return { + statusCode: 400, + error: clientInfo.error, + errorDescription: clientInfo.errorHint, + }; + } + const client = clientInfo.client; + body["id_token"] = await this.buildIdTokenPayload({ + user: undefined, + client, + sessionHandle: undefined, + scopes, + userContext: input.userContext, + }); + body["access_token"] = await this.buildAccessTokenPayload({ + user: undefined, + client, + sessionHandle: undefined, + scopes, + userContext: input.userContext, + }); + } + if (input.body.grant_type === "refresh_token") { + const scopes = + (_d = (_c = input.body.scope) === null || _c === void 0 ? void 0 : _c.split(" ")) !== null && + _d !== void 0 + ? _d + : []; const tokenInfo = await this.introspectToken({ token: input.body.refresh_token, scopes, userContext: input.userContext, }); if (tokenInfo.active === true) { - const sessionHandle = tokenInfo.ext.sessionHandle; + const sessionHandle = tokenInfo.sessionHandle; const clientInfo = await this.getOAuth2Client({ clientId: tokenInfo.client_id, userContext: input.userContext, @@ -311,158 +392,122 @@ function getRecipeInterface( if (!user) { throw new Error("User not found"); } - const idToken = await this.buildIdTokenPayload({ + body["id_token"] = await this.buildIdTokenPayload({ user, client, - sessionHandle: sessionHandle, + sessionHandle, scopes, userContext: input.userContext, }); - const accessTokenPayload = await this.buildAccessTokenPayload({ + body["access_token"] = await this.buildAccessTokenPayload({ user, client, sessionHandle: sessionHandle, scopes, userContext: input.userContext, }); - body["session"] = { - id_token: idToken, - access_token: accessTokenPayload, - }; - saveTokensForHook(sessionHandle, idToken, accessTokenPayload); } } if (input.authorizationHeader) { body["authorizationHeader"] = input.authorizationHeader; } const res = await querier.sendPostRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/pub/token`), + new normalisedURLPath_1.default(`/recipe/oauth/token`), body, input.userContext ); if (res.status !== "OK") { return { statusCode: res.statusCode, - error: res.data.error, - errorDescription: res.data.error_description, + error: res.error, + errorDescription: res.errorDescription, }; } - return res.data; + return res; }, getOAuth2Clients: async function (input) { - var _a; let response = await querier.sendGetRequestWithResponseHeaders( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients`), - Object.assign(Object.assign({}, utils_1.transformObjectKeys(input, "snake-case")), { - page_token: input.paginationToken, - }), + new normalisedURLPath_1.default(`/recipe/oauth/clients/list`), + { + pageSize: input.pageSize, + clientName: input.clientName, + owner: input.owner, + pageToken: input.paginationToken, + }, {}, input.userContext ); if (response.body.status === "OK") { - // Pagination info is in the Link header, containing comma-separated links: - // "first", "next" (if applicable). - // Example: Link: ; rel="first", ; rel="next" - // We parse the nextPaginationToken from the Link header using RegExp - let nextPaginationToken; - const linkHeader = (_a = response.headers.get("link")) !== null && _a !== void 0 ? _a : ""; - const nextLinkMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/); - if (nextLinkMatch) { - const url = nextLinkMatch[1]; - const urlParams = new URLSearchParams(url.split("?")[1]); - nextPaginationToken = urlParams.get("page_token"); - } return { status: "OK", - clients: response.body.data.map((client) => OAuth2Client_1.OAuth2Client.fromAPIResponse(client)), - nextPaginationToken, + clients: response.body.clients.map((client) => OAuth2Client_1.OAuth2Client.fromAPIResponse(client)), + nextPaginationToken: response.body.nextPaginationToken, }; } else { return { status: "ERROR", - error: response.body.data.error, - errorHint: response.body.data.errorHint, + error: response.body.error, + errorHint: response.body.errorHint, }; } }, getOAuth2Client: async function (input) { let response = await querier.sendGetRequestWithResponseHeaders( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients/${input.clientId}`), - {}, + new normalisedURLPath_1.default(`/recipe/oauth/clients`), + { clientId: input.clientId }, {}, input.userContext ); - if (response.body.status === "OK") { - return { - status: "OK", - client: OAuth2Client_1.OAuth2Client.fromAPIResponse(response.body.data), - }; - } else { - return { - status: "ERROR", - error: response.body.data.error, - errorHint: response.body.data.errorHint, - }; - } + return { + status: "OK", + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(response.body), + }; + // return { + // status: "ERROR", + // error: response.body.error, + // errorHint: response.body.errorHint, + // }; }, createOAuth2Client: async function (input) { let response = await querier.sendPostRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients`), - Object.assign(Object.assign({}, utils_1.transformObjectKeys(input, "snake-case")), { - // TODO: these defaults should be set/enforced on the core side - access_token_strategy: "jwt", - skip_consent: true, - subject_type: "public", - }), + new normalisedURLPath_1.default(`/recipe/oauth/clients`), + copyAndCleanRequestBodyInput(input), input.userContext ); - if (response.status === "OK") { - return { - status: "OK", - client: OAuth2Client_1.OAuth2Client.fromAPIResponse(response.data), - }; - } else { - return { - status: "ERROR", - error: response.data.error, - errorHint: response.data.errorHint, - }; - } + return { + status: "OK", + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(response), + }; + // return { + // status: "ERROR", + // error: response.error, + // errorHint: response.errorHint, + // }; }, updateOAuth2Client: async function (input) { - // We convert the input into an array of "replace" operations - const requestBody = Object.entries(input).reduce((result, [key, value]) => { - result.push({ - from: `/${utils_1.toSnakeCase(key)}`, - op: "replace", - path: `/${utils_1.toSnakeCase(key)}`, - value, - }); - return result; - }, []); - let response = await querier.sendPatchRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients/${input.clientId}`), - requestBody, + let response = await querier.sendPutRequest( + new normalisedURLPath_1.default(`/recipe/oauth/clients`), + copyAndCleanRequestBodyInput(input), + { clientId: input.clientId }, input.userContext ); if (response.status === "OK") { return { status: "OK", - client: OAuth2Client_1.OAuth2Client.fromAPIResponse(response.data), + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(response), }; } else { return { status: "ERROR", - error: response.data.error, - errorHint: response.data.errorHint, + error: response.error, + errorHint: response.errorHint, }; } }, deleteOAuth2Client: async function (input) { - let response = await querier.sendDeleteRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients/${input.clientId}`), - undefined, - undefined, + let response = await querier.sendPostRequest( + new normalisedURLPath_1.default(`/recipe/oauth/clients/remove`), + { clientId: input.clientId }, input.userContext ); if (response.status === "OK") { @@ -470,38 +515,75 @@ function getRecipeInterface( } else { return { status: "ERROR", - error: response.data.error, - errorHint: response.data.errorHint, + error: response.error, + errorHint: response.errorHint, }; } }, + getRequestedScopes: async function (input) { + return input.scopeParam; + }, buildAccessTokenPayload: async function (input) { + if (input.user === undefined || input.sessionHandle === undefined) { + return {}; + } return getDefaultAccessTokenPayload(input.user, input.scopes, input.sessionHandle, input.userContext); }, buildIdTokenPayload: async function (input) { + if (input.user === undefined || input.sessionHandle === undefined) { + return {}; + } return getDefaultIdTokenPayload(input.user, input.scopes, input.sessionHandle, input.userContext); }, buildUserInfo: async function ({ user, accessTokenPayload, scopes, tenantId, userContext }) { return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, tenantId, userContext); }, + getFrontendRedirectionURL: async function (input) { + const websiteDomain = appInfo + .getOrigin({ request: undefined, userContext: input.userContext }) + .getAsStringDangerous(); + const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); + if (input.type === "login") { + const queryParams = new URLSearchParams({ + loginChallenge: input.loginChallenge, + }); + if (input.tenantId !== undefined && input.tenantId !== constants_1.DEFAULT_TENANT_ID) { + queryParams.set("tenantId", input.tenantId); + } + if (input.hint !== undefined) { + queryParams.set("hint", input.hint); + } + if (input.forceFreshAuth) { + queryParams.set("forceFreshAuth", "true"); + } + return `${websiteDomain}${websiteBasePath}?${queryParams.toString()}`; + } else if (input.type === "try-refresh") { + return `${websiteDomain}${websiteBasePath}/try-refresh?loginChallenge=${input.loginChallenge}`; + } else if (input.type === "post-logout-fallback") { + return `${websiteDomain}${websiteBasePath}`; + } else if (input.type === "logout-confirmation") { + return `${websiteDomain}${websiteBasePath}/oauth/logout?logoutChallenge=${input.logoutChallenge}`; + } + throw new Error("This should never happen: invalid type passed to getFrontendRedirectionURL"); + }, validateOAuth2AccessToken: async function (input) { var _a, _b, _c, _d, _e; - const payload = (await jose.jwtVerify(input.token, combinedRemoteJWKSet_1.getCombinedJWKS())).payload; - // if (payload.stt !== 1) { - // throw new Error("Wrong token type"); - // } - // TODO: we should be able uncomment this after we get proper core support - // TODO: make this configurable? - // const expectedIssuer = - // appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(); - // if (payload.iss !== expectedIssuer) { - // throw new Error("Issuer mismatch: this token was likely issued by another application or spoofed"); - // } + const payload = ( + await jose.jwtVerify( + input.token, + combinedRemoteJWKSet_1.getCombinedJWKS(recipe_1.default.getInstanceOrThrowError().config) + ) + ).payload; + if (payload.stt !== 1) { + throw new Error("Wrong token type"); + } if ( ((_a = input.requirements) === null || _a === void 0 ? void 0 : _a.clientId) !== undefined && payload.client_id !== input.requirements.clientId ) { - throw new Error("The token doesn't belong to the specified client"); + throw new Error( + `The token doesn't belong to the specified client (${input.requirements.clientId} !== ${payload.client_id})` + ); } if ( ((_b = input.requirements) === null || _b === void 0 ? void 0 : _b.scopes) !== undefined && @@ -524,23 +606,20 @@ function getRecipeInterface( } if (input.checkDatabase) { let response = await querier.sendPostRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/introspect`), + new normalisedURLPath_1.default(`/recipe/oauth/introspect`), { - $isFormData: true, token: input.token, }, input.userContext ); - // TODO: fix after the core interface is there - if (response.status !== "OK" || response.data.active !== true) { - throw new Error(response.data.error); + if (response.active !== true) { + throw new Error("The token is expired, invalid or has been revoked"); } } return { status: "OK", payload: payload }; }, revokeToken: async function (input) { const requestBody = { - $isFormData: true, token: input.token, }; if ("authorizationHeader" in input && input.authorizationHeader !== undefined) { @@ -554,22 +633,38 @@ function getRecipeInterface( } } const res = await querier.sendPostRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/pub/revoke`), + new normalisedURLPath_1.default(`/recipe/oauth/token/revoke`), requestBody, input.userContext ); if (res.status !== "OK") { return { statusCode: res.statusCode, - error: res.data.error, - errorDescription: res.data.error_description, + error: res.error, + errorDescription: res.errorDescription, }; } return { status: "OK" }; }, + revokeTokensBySessionHandle: async function (input) { + await querier.sendPostRequest( + new normalisedURLPath_1.default(`/recipe/oauth/session/revoke`), + { sessionHandle: input.sessionHandle }, + input.userContext + ); + return { status: "OK" }; + }, + revokeTokensByClientId: async function (input) { + await querier.sendPostRequest( + new normalisedURLPath_1.default(`/recipe/oauth/tokens/revoke`), + { clientId: input.clientId }, + input.userContext + ); + return { status: "OK" }; + }, introspectToken: async function ({ token, scopes, userContext }) { - // Determine if the token is an access token by checking if it doesn't start with "ory_rt" - const isAccessToken = !token.startsWith("ory_rt"); + // Determine if the token is an access token by checking if it doesn't start with "st_rt" + const isAccessToken = !token.startsWith("st_rt"); // Attempt to validate the access token locally // If it fails, the token is not active, and we return early if (isAccessToken) { @@ -587,15 +682,14 @@ function getRecipeInterface( // For tokens that passed local validation or if it's a refresh token, // validate the token with the database by calling the core introspection endpoint const res = await querier.sendPostRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/introspect`), + new normalisedURLPath_1.default(`/recipe/oauth/introspect`), { - $isFormData: true, token, scope: scopes ? scopes.join(" ") : undefined, }, userContext ); - return res.data; + return res; }, endSession: async function (input) { /** @@ -611,7 +705,7 @@ function getRecipeInterface( * - Redirects to the `post_logout_redirect_uri` or the default logout fallback page. */ const resp = await querier.sendGetRequestWithResponseHeaders( - new normalisedURLPath_1.default(`/recipe/oauth2/pub/sessions/logout`), + new normalisedURLPath_1.default(`/recipe/oauth/sessions/logout`), input.params, {}, input.userContext @@ -620,10 +714,6 @@ function getRecipeInterface( if (redirectTo === undefined) { throw new Error(resp.body); } - const websiteDomain = appInfo - .getOrigin({ request: undefined, userContext: input.userContext }) - .getAsStringDangerous(); - const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); const redirectToURL = new URL(redirectTo); const logoutChallenge = redirectToURL.searchParams.get("logout_challenge"); // CASE 1 (See above notes) @@ -631,8 +721,11 @@ function getRecipeInterface( // Redirect to the frontend to ask for logout confirmation if there is a valid or expired supertokens session if (input.session !== undefined || input.shouldTryRefresh) { return { - redirectTo: - websiteDomain + websiteBasePath + "/oauth/logout" + `?logoutChallenge=${logoutChallenge}`, + redirectTo: await this.getFrontendRedirectionURL({ + type: "logout-confirmation", + logoutChallenge, + userContext: input.userContext, + }), }; } else { // Accept the logout challenge immediately as there is no supertokens session @@ -643,37 +736,41 @@ function getRecipeInterface( } } // CASE 2 or 3 (See above notes) - // TODO: + // TODO: add test for this // 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")) { - return { redirectTo: `${websiteDomain}${websiteBasePath}` }; + return { + redirectTo: await this.getFrontendRedirectionURL({ + type: "post-logout-fallback", + userContext: input.userContext, + }), + }; } return { redirectTo }; }, acceptLogoutRequest: async function (input) { const resp = await querier.sendPutRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/logout/accept`), + new normalisedURLPath_1.default(`/recipe/oauth/auth/requests/logout/accept`), {}, { logout_challenge: input.challenge }, input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to) + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo) // NOTE: This renaming only applies to this endpoint, hence not part of the generic "getUpdatedRedirectTo" function. .replace("/sessions/logout", "/end_session"), }; }, rejectLogoutRequest: async function (input) { const resp = await querier.sendPutRequest( - new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/logout/reject`), + new normalisedURLPath_1.default(`/recipe/oauth/auth/requests/logout/reject`), {}, { logout_challenge: input.challenge }, input.userContext ); if (resp.status != "OK") { - throw new Error(resp.data.error); + throw new Error(resp.error); } return { status: "OK" }; }, diff --git a/lib/build/recipe/oauth2provider/types.d.ts b/lib/build/recipe/oauth2provider/types.d.ts index 2165d097b..7f42a024d 100644 --- a/lib/build/recipe/oauth2provider/types.d.ts +++ b/lib/build/recipe/oauth2provider/types.d.ts @@ -5,6 +5,7 @@ import { GeneralErrorResponse, JSONObject, JSONValue, NonNullableProperties, Use import { SessionContainerInterface } from "../session/types"; import { OAuth2Client } from "./OAuth2Client"; import { User } from "../../user"; +import RecipeUserId from "../../recipeUserId"; export declare type TypeInput = { override?: { functions?: ( @@ -99,10 +100,13 @@ export declare type RecipeInterface = { cookies: string | undefined; session: SessionContainerInterface | undefined; userContext: UserContext; - }): Promise<{ - redirectTo: string; - setCookie: string | undefined; - }>; + }): Promise< + | { + redirectTo: string; + setCookie: string | undefined; + } + | ErrorOAuth2 + >; tokenExchange(input: { authorizationHeader?: string; body: Record; @@ -117,7 +121,11 @@ export declare type RecipeInterface = { handledAt?: string; remember?: boolean; rememberFor?: number; - session?: any; + tenantId: string; + rsub: string; + sessionHandle: string; + initialAccessTokenPayload: JSONObject | undefined; + initialIdTokenPayload: JSONObject | undefined; userContext: UserContext; }): Promise<{ redirectTo: string; @@ -239,17 +247,24 @@ export declare type RecipeInterface = { status: "OK"; payload: JSONObject; }>; + getRequestedScopes(input: { + recipeUserId: RecipeUserId | undefined; + sessionHandle: string | undefined; + scopeParam: string[]; + clientId: string; + userContext: UserContext; + }): Promise; buildAccessTokenPayload(input: { - user: User; + user: User | undefined; client: OAuth2Client; - sessionHandle: string; + sessionHandle: string | undefined; scopes: string[]; userContext: UserContext; }): Promise; buildIdTokenPayload(input: { - user: User; + user: User | undefined; client: OAuth2Client; - sessionHandle: string; + sessionHandle: string | undefined; scopes: string[]; userContext: UserContext; }): Promise; @@ -260,6 +275,31 @@ export declare type RecipeInterface = { tenantId: string; userContext: UserContext; }): Promise; + getFrontendRedirectionURL( + input: + | { + type: "login"; + loginChallenge: string; + tenantId: string; + forceFreshAuth: boolean; + hint: string | undefined; + userContext: UserContext; + } + | { + type: "try-refresh"; + loginChallenge: string; + userContext: UserContext; + } + | { + type: "logout-confirmation"; + logoutChallenge: string; + userContext: UserContext; + } + | { + type: "post-logout-fallback"; + userContext: UserContext; + } + ): Promise; revokeToken( input: { token: string; @@ -279,6 +319,18 @@ export declare type RecipeInterface = { } | ErrorOAuth2 >; + revokeTokensByClientId(input: { + clientId: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + }>; + revokeTokensBySessionHandle(input: { + sessionHandle: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + }>; introspectToken(input: { token: string; scopes?: string[]; @@ -289,9 +341,12 @@ export declare type RecipeInterface = { session?: SessionContainerInterface; shouldTryRefresh: boolean; userContext: UserContext; - }): Promise<{ - redirectTo: string; - }>; + }): Promise< + | { + redirectTo: string; + } + | ErrorOAuth2 + >; acceptLogoutRequest(input: { challenge: string; userContext: UserContext; @@ -319,6 +374,7 @@ export declare type APIInterface = { redirectTo: string; setCookie?: string; } + | ErrorOAuth2 | GeneralErrorResponse >); authGET: @@ -407,9 +463,13 @@ export declare type APIInterface = { shouldTryRefresh: boolean; options: APIOptions; userContext: UserContext; - }) => Promise<{ - redirectTo: string; - }>); + }) => Promise< + | { + redirectTo: string; + } + | ErrorOAuth2 + | GeneralErrorResponse + >); endSessionPOST: | undefined | ((input: { @@ -418,9 +478,13 @@ export declare type APIInterface = { shouldTryRefresh: boolean; options: APIOptions; userContext: UserContext; - }) => Promise<{ - redirectTo: string; - }>); + }) => Promise< + | { + redirectTo: string; + } + | ErrorOAuth2 + | GeneralErrorResponse + >); logoutPOST: | undefined | ((input: { @@ -428,10 +492,14 @@ export declare type APIInterface = { options: APIOptions; session?: SessionContainerInterface; userContext: UserContext; - }) => Promise<{ - status: "OK"; - frontendRedirectTo: string; - }>); + }) => Promise< + | { + status: "OK"; + frontendRedirectTo: string; + } + | ErrorOAuth2 + | GeneralErrorResponse + >); }; export declare type OAuth2ClientOptions = { clientId: string; diff --git a/lib/build/recipe/session/constants.d.ts b/lib/build/recipe/session/constants.d.ts index 78a3ec979..b932678c9 100644 --- a/lib/build/recipe/session/constants.d.ts +++ b/lib/build/recipe/session/constants.d.ts @@ -5,4 +5,5 @@ export declare const SIGNOUT_API_PATH = "/signout"; export declare const availableTokenTransferMethods: TokenTransferMethod[]; export declare const oneYearInMs = 31536000000; export declare const JWKCacheCooldownInMs = 500; +export declare const JWKCacheMaxAgeInMs = 60000; export declare const protectedProps: string[]; diff --git a/lib/build/recipe/session/constants.js b/lib/build/recipe/session/constants.js index 84d5b0225..7e74320c7 100644 --- a/lib/build/recipe/session/constants.js +++ b/lib/build/recipe/session/constants.js @@ -14,12 +14,13 @@ * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.protectedProps = exports.JWKCacheCooldownInMs = exports.oneYearInMs = exports.availableTokenTransferMethods = exports.SIGNOUT_API_PATH = exports.REFRESH_API_PATH = void 0; +exports.protectedProps = exports.JWKCacheMaxAgeInMs = exports.JWKCacheCooldownInMs = exports.oneYearInMs = exports.availableTokenTransferMethods = exports.SIGNOUT_API_PATH = exports.REFRESH_API_PATH = void 0; exports.REFRESH_API_PATH = "/session/refresh"; exports.SIGNOUT_API_PATH = "/signout"; exports.availableTokenTransferMethods = ["cookie", "header"]; exports.oneYearInMs = 31536000000; exports.JWKCacheCooldownInMs = 500; +exports.JWKCacheMaxAgeInMs = 60000; exports.protectedProps = [ "sub", "iat", diff --git a/lib/build/recipe/session/sessionFunctions.js b/lib/build/recipe/session/sessionFunctions.js index 750831f0a..f939f0367 100644 --- a/lib/build/recipe/session/sessionFunctions.js +++ b/lib/build/recipe/session/sessionFunctions.js @@ -99,7 +99,7 @@ async function getSession( */ accessTokenInfo = await accessToken_1.getInfoFromAccessToken( parsedAccessToken, - combinedRemoteJWKSet_1.getCombinedJWKS(), + combinedRemoteJWKSet_1.getCombinedJWKS(config), helpers.config.antiCsrfFunctionOrString === "VIA_TOKEN" && doAntiCsrfCheck ); } catch (err) { diff --git a/lib/ts/combinedRemoteJWKSet.ts b/lib/ts/combinedRemoteJWKSet.ts index 9da2b8b07..4027c3a8e 100644 --- a/lib/ts/combinedRemoteJWKSet.ts +++ b/lib/ts/combinedRemoteJWKSet.ts @@ -14,10 +14,6 @@ export function resetCombinedJWKS() { combinedJWKS = undefined; } -// TODO: remove this after proper core support -const hydraJWKS = createRemoteJWKSet(new URL("http://localhost:4444/.well-known/jwks.json"), { - cooldownDuration: JWKCacheCooldownInMs, -}); /** The function returned by this getter fetches all JWKs from the first available core instance. This combines the other JWKS functions to become error resistant. @@ -25,12 +21,13 @@ const hydraJWKS = createRemoteJWKSet(new URL("http://localhost:4444/.well-known/ Every core instance a backend is connected to is expected to connect to the same database and use the same key set for token verification. Otherwise, the result of session verification would depend on which core is currently available. */ -export function getCombinedJWKS() { +export function getCombinedJWKS(config: { jwksRefreshIntervalSec: number }) { if (combinedJWKS === undefined) { const JWKS: ReturnType[] = Querier.getNewInstanceOrThrowError(undefined) .getAllCoreUrlsForPath("/.well-known/jwks.json") .map((url) => createRemoteJWKSet(new URL(url), { + cacheMaxAge: config.jwksRefreshIntervalSec, cooldownDuration: JWKCacheCooldownInMs, }) ); @@ -38,10 +35,6 @@ export function getCombinedJWKS() { combinedJWKS = async (...args) => { let lastError = undefined; - if (!args[0]?.kid?.startsWith("s-") && !args[0]?.kid?.startsWith("d-")) { - return hydraJWKS(...args); - } - if (JWKS.length === 0) { throw Error( "No SuperTokens core available to query. Please pass supertokens > connectionURI to the init function, or override all the functions of the recipe you are using." diff --git a/lib/ts/querier.ts b/lib/ts/querier.ts index 895239ca9..075652d3f 100644 --- a/lib/ts/querier.ts +++ b/lib/ts/querier.ts @@ -23,11 +23,6 @@ import { UserContext } from "./types"; import { NetworkInterceptor } from "./types"; import SuperTokens from "./supertokens"; -export const hydraPubDomain = process.env.HYDRA_PUB ?? "http://localhost:4444"; // This will be used as a domain for paths starting with hydraPubPathPrefix -const hydraAdmDomain = process.env.HYDRA_ADM ?? "http://localhost:4445"; // This will be used as a domain for paths starting with hydraAdmPathPrefix -const hydraPubPathPrefix = "/recipe/oauth2/pub"; // Replaced with "/oauth2" when sending the request (/recipe/oauth2/pub/token -> /oauth2/token) -const hydraAdmPathPrefix = "/recipe/oauth2/admin"; // Replaced with "/admin" when sending the request (/recipe/oauth2/admin/clients -> /admin/clients) - export class Querier { private static initCalled = false; private static hosts: { domain: NormalisedURLDomain; basePath: NormalisedURLPath }[] | undefined = undefined; @@ -160,11 +155,6 @@ export class Querier { // path should start with "/" sendPostRequest = async (path: NormalisedURLPath, body: any, userContext: UserContext): Promise => { this.invalidateCoreCallCache(userContext); - // TODO: remove FormData - const isForm = body !== undefined && body["$isFormData"]; - if (isForm) { - delete body["$isFormData"]; - } const { body: respBody } = await this.sendRequestHelper( path, @@ -174,17 +164,7 @@ export class Querier { let headers: any = { "cdi-version": apiVersion, }; - if (isForm) { - headers["content-type"] = "application/x-www-form-urlencoded"; - } else { - headers["content-type"] = "application/json; charset=utf-8"; - } - - // TODO: Remove this after core changes are done - if (body !== undefined && body["authorizationHeader"]) { - headers["authorization"] = body["authorizationHeader"]; - delete body["authorizationHeader"]; - } + headers["content-type"] = "application/json; charset=utf-8"; if (Querier.apiKey !== undefined) { headers = { @@ -216,11 +196,7 @@ export class Querier { } return doFetch(url, { method: "POST", - body: isForm - ? new URLSearchParams(Object.entries(body)).toString() - : body !== undefined - ? JSON.stringify(body) - : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, headers, }); }, @@ -613,19 +589,6 @@ export class Querier { let currentBasePath: string = this.__hosts[Querier.lastTriedIndex].basePath.getAsStringDangerous(); let strPath = path.getAsStringDangerous(); - const isHydraAPICall = strPath.startsWith(hydraAdmPathPrefix) || strPath.startsWith(hydraPubPathPrefix); - - if (strPath.startsWith(hydraPubPathPrefix)) { - currentDomain = hydraPubDomain; - currentBasePath = ""; - strPath = strPath.replace(hydraPubPathPrefix, "/oauth2"); - } - - if (strPath.startsWith(hydraAdmPathPrefix)) { - currentDomain = hydraAdmDomain; - currentBasePath = ""; - strPath = strPath.replace(hydraAdmPathPrefix, "/admin"); - } const url = currentDomain + currentBasePath + strPath; const maxRetries = 5; @@ -648,11 +611,6 @@ export class Querier { Querier.hostsAliveForTesting.add(currentDomain + currentBasePath); } - // TODO: Temporary solution for handling Hydra API calls. Remove when Hydra is no longer called directly. - if (isHydraAPICall) { - return handleHydraAPICall(response); - } - if (response.status !== 200) { throw response; } @@ -703,29 +661,3 @@ export class Querier { } }; } - -async function handleHydraAPICall(response: Response) { - const contentType = response.headers.get("Content-Type"); - - if (contentType?.startsWith("application/json")) { - return { - body: { - status: response.ok ? "OK" : "ERROR", - statusCode: response.status, - data: await response.clone().json(), - }, - headers: response.headers, - }; - } else if (contentType?.startsWith("text/plain")) { - return { - body: { - status: response.ok ? "OK" : "ERROR", - statusCode: response.status, - data: await response.clone().text(), - }, - headers: response.headers, - }; - } - - return { body: { status: response.ok ? "OK" : "ERROR", statusCode: response.status }, headers: response.headers }; -} diff --git a/lib/ts/recipe/jwt/api/implementation.ts b/lib/ts/recipe/jwt/api/implementation.ts index 03bcab7ec..4308c7cf1 100644 --- a/lib/ts/recipe/jwt/api/implementation.ts +++ b/lib/ts/recipe/jwt/api/implementation.ts @@ -31,17 +31,6 @@ export default function getAPIImplementation(): APIInterface { options.res.setHeader("Cache-Control", `max-age=${resp.validityInSeconds}, must-revalidate`, false); } - const oauth2Provider = require("../../oauth2provider/recipe").default.getInstance(); - - // TODO: dirty hack until we get core support - if (oauth2Provider !== undefined) { - const oauth2JWKSRes = await fetch("http://localhost:4444/.well-known/jwks.json"); - if (oauth2JWKSRes.ok) { - const oauth2RespBody = await oauth2JWKSRes.json(); - resp.keys = resp.keys.concat(oauth2RespBody.keys); - } - } - return { keys: resp.keys, }; diff --git a/lib/ts/recipe/oauth2client/api/implementation.ts b/lib/ts/recipe/oauth2client/api/implementation.ts index 7e18629e0..613b81937 100644 --- a/lib/ts/recipe/oauth2client/api/implementation.ts +++ b/lib/ts/recipe/oauth2client/api/implementation.ts @@ -5,9 +5,22 @@ import { OAuthTokens } from "../types"; export default function getAPIInterface(): APIInterface { return { signInPOST: async function (input) { - const { options, tenantId, userContext } = input; + const { options, tenantId, userContext, clientId } = input; - const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + let normalisedClientId = clientId; + if (normalisedClientId === undefined) { + if (options.config.providerConfigs.length > 1) { + throw new Error( + "Should never come here: clientId is undefined and there are multiple providerConfigs" + ); + } + + normalisedClientId = options.config.providerConfigs[0].clientId!; + } + const providerConfig = await options.recipeImplementation.getProviderConfig({ + clientId: normalisedClientId, + userContext, + }); let oAuthTokensToUse: OAuthTokens = {}; diff --git a/lib/ts/recipe/oauth2client/api/signin.ts b/lib/ts/recipe/oauth2client/api/signin.ts index 6e89436f9..345a56770 100644 --- a/lib/ts/recipe/oauth2client/api/signin.ts +++ b/lib/ts/recipe/oauth2client/api/signin.ts @@ -40,6 +40,13 @@ export default async function signInAPI( }; let oAuthTokens: any; + if (bodyParams.clientId === undefined && options.config.providerConfigs.length > 1) { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "Please provide the clientId in request body", + }); + } + if (bodyParams.redirectURIInfo !== undefined) { if (bodyParams.redirectURIInfo.redirectURI === undefined) { throw new STError({ @@ -73,6 +80,7 @@ export default async function signInAPI( let result = await apiImplementation.signInPOST({ tenantId, + clientId: bodyParams.clientId, redirectURIInfo, oAuthTokens, options, diff --git a/lib/ts/recipe/oauth2client/index.ts b/lib/ts/recipe/oauth2client/index.ts index d2b2e2a02..9d175ef1c 100644 --- a/lib/ts/recipe/oauth2client/index.ts +++ b/lib/ts/recipe/oauth2client/index.ts @@ -14,6 +14,7 @@ */ import { getUserContext } from "../../utils"; +import { parseJWTWithoutSignatureVerification } from "../session/jwt"; import Recipe from "./recipe"; import { RecipeInterface, APIInterface, APIOptions, OAuthTokens } from "./types"; @@ -26,11 +27,23 @@ export default class Wrapper { redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; }, + clientId?: string, userContext?: Record ) { - const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + let normalisedClientId = clientId; + const instance = Recipe.getInstanceOrThrowError(); + const recipeInterfaceImpl = instance.recipeInterfaceImpl; const normalisedUserContext = getUserContext(userContext); + if (normalisedClientId === undefined) { + if (instance.config.providerConfigs.length > 1) { + throw new Error("clientId is required if there are more than one provider configs defined"); + } + + normalisedClientId = instance.config.providerConfigs[0].clientId!; + } + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + clientId: normalisedClientId, userContext: normalisedUserContext, }); return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ @@ -43,7 +56,12 @@ export default class Wrapper { static async getUserInfo(oAuthTokens: OAuthTokens, userContext?: Record) { const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; const normalisedUserContext = getUserContext(userContext); + if (oAuthTokens.access_token === undefined) { + throw new Error("access_token is required to get user info"); + } + const preparseJWTInfo = parseJWTWithoutSignatureVerification(oAuthTokens.access_token!); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + clientId: preparseJWTInfo.payload.client_id, userContext: normalisedUserContext, }); return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ diff --git a/lib/ts/recipe/oauth2client/recipeImplementation.ts b/lib/ts/recipe/oauth2client/recipeImplementation.ts index a5115c815..59e90967b 100644 --- a/lib/ts/recipe/oauth2client/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2client/recipeImplementation.ts @@ -20,7 +20,7 @@ import { logDebugMessage } from "../../logger"; import { JWTVerifyGetKey, createRemoteJWKSet } from "jose"; export default function getRecipeImplementation(_querier: Querier, config: TypeNormalisedInput): RecipeInterface { - let providerConfigWithOIDCInfo: ProviderConfigWithOIDCInfo | null = null; + let providerConfigsWithOIDCInfo: Record = {}; return { signIn: async function ({ @@ -53,11 +53,14 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN rawUserInfo, }; }, - getProviderConfig: async function () { - if (providerConfigWithOIDCInfo !== null) { - return providerConfigWithOIDCInfo; + getProviderConfig: async function ({ clientId }) { + if (providerConfigsWithOIDCInfo[clientId] !== undefined) { + return providerConfigsWithOIDCInfo[clientId]; } - const oidcInfo = await getOIDCDiscoveryInfo(config.providerConfig.oidcDiscoveryEndpoint); + const providerConfig = config.providerConfigs.find( + (providerConfig) => providerConfig.clientId === clientId + )!; + const oidcInfo = await getOIDCDiscoveryInfo(providerConfig.oidcDiscoveryEndpoint); if (oidcInfo.authorization_endpoint === undefined) { throw new Error("Failed to authorization_endpoint from the oidcDiscoveryEndpoint."); @@ -72,14 +75,15 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN throw new Error("Failed to jwks_uri from the oidcDiscoveryEndpoint."); } - providerConfigWithOIDCInfo = { - ...config.providerConfig, + providerConfigsWithOIDCInfo[clientId] = { + ...providerConfig, authorizationEndpoint: oidcInfo.authorization_endpoint, tokenEndpoint: oidcInfo.token_endpoint, userInfoEndpoint: oidcInfo.userinfo_endpoint, jwksURI: oidcInfo.jwks_uri, }; - return providerConfigWithOIDCInfo; + + return providerConfigsWithOIDCInfo[clientId]; }, exchangeAuthCodeForOAuthTokens: async function (this: RecipeInterface, { providerConfig, redirectURIInfo }) { if (providerConfig.tokenEndpoint === undefined) { diff --git a/lib/ts/recipe/oauth2client/types.ts b/lib/ts/recipe/oauth2client/types.ts index 740d00d16..795e39086 100644 --- a/lib/ts/recipe/oauth2client/types.ts +++ b/lib/ts/recipe/oauth2client/types.ts @@ -53,7 +53,7 @@ export type OAuthTokenResponse = { }; export type TypeInput = { - providerConfig: ProviderConfigInput; + providerConfigs: ProviderConfigInput[]; override?: { functions?: ( originalImplementation: RecipeInterface, @@ -64,7 +64,7 @@ export type TypeInput = { }; export type TypeNormalisedInput = { - providerConfig: ProviderConfigInput; + providerConfigs: ProviderConfigInput[]; override: { functions: ( originalImplementation: RecipeInterface, @@ -75,7 +75,7 @@ export type TypeNormalisedInput = { }; export type RecipeInterface = { - getProviderConfig(input: { userContext: UserContext }): Promise; + getProviderConfig(input: { clientId: string; userContext: UserContext }): Promise; signIn(input: { userId: string; @@ -126,6 +126,7 @@ export type APIInterface = { signInPOST: ( input: { tenantId: string; + clientId?: string; options: APIOptions; userContext: UserContext; } & ( diff --git a/lib/ts/recipe/oauth2client/utils.ts b/lib/ts/recipe/oauth2client/utils.ts index 5996c2490..54c990144 100644 --- a/lib/ts/recipe/oauth2client/utils.ts +++ b/lib/ts/recipe/oauth2client/utils.ts @@ -17,23 +17,22 @@ import { NormalisedAppinfo } from "../../types"; import { TypeInput, TypeNormalisedInput, RecipeInterface, APIInterface } from "./types"; export function validateAndNormaliseUserInput(_appInfo: NormalisedAppinfo, config: TypeInput): TypeNormalisedInput { - if (config === undefined || config.providerConfig === undefined) { - throw new Error("Please pass providerConfig argument in the OAuth2Client recipe."); + if (config === undefined || config.providerConfigs === undefined) { + throw new Error("Please pass providerConfigs argument in the OAuth2Client recipe."); } - if (config.providerConfig.clientId === undefined) { - throw new Error("Please pass clientId argument in the OAuth2Client providerConfig."); + if (config.providerConfigs.some((providerConfig) => providerConfig.clientId === undefined)) { + throw new Error("Please pass clientId for all providerConfigs."); } - // TODO: Decide on the prefix and also if we will allow users to customise clientIds - // if (!config.providerConfig.clientId.startsWith("supertokens_")) { - // throw new Error( - // `Only Supertokens OAuth ClientIds are supported in the OAuth2Client recipe. For any other OAuth Clients use the thirdparty recipe.` - // ); - // } + if (!config.providerConfigs.every((providerConfig) => providerConfig.clientId.startsWith("stcl_"))) { + throw new Error( + `Only Supertokens OAuth ClientIds are supported in the OAuth2Client recipe. For any other OAuth Clients use the ThirdParty recipe.` + ); + } - if (config.providerConfig.oidcDiscoveryEndpoint === undefined) { - throw new Error("Please pass oidcDiscoveryEndpoint argument in the OAuth2Client providerConfig."); + if (config.providerConfigs.some((providerConfig) => providerConfig.oidcDiscoveryEndpoint === undefined)) { + throw new Error("Please pass oidcDiscoveryEndpoint for all providerConfigs."); } let override = { @@ -43,7 +42,7 @@ export function validateAndNormaliseUserInput(_appInfo: NormalisedAppinfo, confi }; return { - providerConfig: config.providerConfig, + providerConfigs: config.providerConfigs, override, }; } diff --git a/lib/ts/recipe/oauth2provider/api/auth.ts b/lib/ts/recipe/oauth2provider/api/auth.ts index c6fc92e10..98d459aaf 100644 --- a/lib/ts/recipe/oauth2provider/api/auth.ts +++ b/lib/ts/recipe/oauth2provider/api/auth.ts @@ -13,7 +13,7 @@ * under the License. */ -import { send200Response } from "../../../utils"; +import { send200Response, sendNon200Response } from "../../../utils"; import { APIInterface, APIOptions } from ".."; import { UserContext } from "../../../types"; import setCookieParser from "set-cookie-parser"; @@ -54,6 +54,7 @@ export default async function authGET( shouldTryRefresh, userContext, }); + if ("redirectTo" in response) { if (response.setCookie) { const cookieStr = setCookieParser.splitCookiesString(response.setCookie); @@ -72,6 +73,11 @@ export default async function authGET( } } options.res.original.redirect(response.redirectTo); + } else if ("statusCode" in response) { + sendNon200Response(options.res, response.statusCode ?? 400, { + error: response.error, + error_description: response.errorDescription, + }); } else { send200Response(options.res, response); } diff --git a/lib/ts/recipe/oauth2provider/api/endSession.ts b/lib/ts/recipe/oauth2provider/api/endSession.ts index 998c0e687..e298b8271 100644 --- a/lib/ts/recipe/oauth2provider/api/endSession.ts +++ b/lib/ts/recipe/oauth2provider/api/endSession.ts @@ -63,8 +63,6 @@ async function endSessionCommon( return false; } - // TODO (core): If client_id is passed, validate if it the same one that was used to issue the id_token - let session, shouldTryRefresh; try { session = await Session.getSession(options.req, options.res, { sessionRequired: false }, userContext); @@ -89,17 +87,12 @@ async function endSessionCommon( }); 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); - } + options.res.original.redirect(response.redirectTo); + } else if ("error" in response) { + sendNon200Response(options.res, response.statusCode ?? 400, { + error: response.error, + error_description: response.errorDescription, + }); } else { send200Response(options.res, response); } diff --git a/lib/ts/recipe/oauth2provider/api/implementation.ts b/lib/ts/recipe/oauth2provider/api/implementation.ts index 546589231..abf4d2ce2 100644 --- a/lib/ts/recipe/oauth2provider/api/implementation.ts +++ b/lib/ts/recipe/oauth2provider/api/implementation.ts @@ -45,6 +45,10 @@ export default function getAPIImplementation(): APIInterface { userContext, }); + if ("error" in response) { + return response; + } + return handleLoginInternalRedirects({ response, recipeImplementation: options.recipeImplementation, @@ -122,6 +126,10 @@ export default function getAPIImplementation(): APIInterface { userContext, }); + if ("error" in response) { + return response; + } + return handleLogoutInternalRedirects({ response, session, @@ -137,6 +145,10 @@ export default function getAPIImplementation(): APIInterface { userContext, }); + if ("error" in response) { + return response; + } + return handleLogoutInternalRedirects({ response, session, @@ -154,13 +166,17 @@ export default function getAPIImplementation(): APIInterface { userContext, }); - const { redirectTo } = await handleLogoutInternalRedirects({ + const res = await handleLogoutInternalRedirects({ response, recipeImplementation: options.recipeImplementation, userContext, }); - return { status: "OK", frontendRedirectTo: redirectTo }; + if ("error" in res) { + return res; + } + + return { status: "OK", frontendRedirectTo: res.redirectTo }; }, }; } diff --git a/lib/ts/recipe/oauth2provider/api/login.ts b/lib/ts/recipe/oauth2provider/api/login.ts index 9284c1e29..5a8041a01 100644 --- a/lib/ts/recipe/oauth2provider/api/login.ts +++ b/lib/ts/recipe/oauth2provider/api/login.ts @@ -14,7 +14,7 @@ */ import setCookieParser from "set-cookie-parser"; -import { send200Response } from "../../../utils"; +import { send200Response, sendNon200ResponseWithMessage } from "../../../utils"; import { APIInterface, APIOptions } from ".."; import Session from "../../session"; import { UserContext } from "../../../types"; @@ -60,9 +60,8 @@ export default async function login( shouldTryRefresh, userContext, }); - if ("status" in response) { - send200Response(options.res, response); - } else { + + if ("redirectTo" in response) { if (response.setCookie) { const cookieStr = setCookieParser.splitCookiesString(response.setCookie); const cookies = setCookieParser.parse(cookieStr); @@ -80,6 +79,14 @@ export default async function login( } } options.res.original.redirect(response.redirectTo); + } else if ("statusCode" in response) { + sendNon200ResponseWithMessage( + options.res, + response.error + ": " + response.errorDescription, + response.statusCode ?? 400 + ); + } else { + send200Response(options.res, response); } return true; } diff --git a/lib/ts/recipe/oauth2provider/api/logout.ts b/lib/ts/recipe/oauth2provider/api/logout.ts index 8771fad05..55c1c83ce 100644 --- a/lib/ts/recipe/oauth2provider/api/logout.ts +++ b/lib/ts/recipe/oauth2provider/api/logout.ts @@ -13,7 +13,7 @@ * under the License. */ -import { send200Response } from "../../../utils"; +import { send200Response, sendNon200Response } from "../../../utils"; import { APIInterface, APIOptions } from ".."; import Session from "../../session"; import { UserContext } from "../../../types"; @@ -51,6 +51,16 @@ export async function logoutPOST( userContext, }); - send200Response(options.res, response); + if ("status" in response && response.status === "OK") { + send200Response(options.res, response); + } else if ("statusCode" in response) { + sendNon200Response(options.res, response.statusCode ?? 400, { + error: response.error, + error_description: response.errorDescription, + }); + } else { + send200Response(options.res, response); + } + return true; } diff --git a/lib/ts/recipe/oauth2provider/api/token.ts b/lib/ts/recipe/oauth2provider/api/token.ts index 0ed290282..cd546567c 100644 --- a/lib/ts/recipe/oauth2provider/api/token.ts +++ b/lib/ts/recipe/oauth2provider/api/token.ts @@ -35,8 +35,8 @@ export default async function tokenPOST( userContext, }); - if ("statusCode" in response && response.statusCode !== 200) { - sendNon200Response(options.res, response.statusCode!, { + if ("error" in response) { + sendNon200Response(options.res, response.statusCode ?? 400, { error: response.error, error_description: response.errorDescription, }); diff --git a/lib/ts/recipe/oauth2provider/api/utils.ts b/lib/ts/recipe/oauth2provider/api/utils.ts index b0d28001a..8d3481f24 100644 --- a/lib/ts/recipe/oauth2provider/api/utils.ts +++ b/lib/ts/recipe/oauth2provider/api/utils.ts @@ -4,7 +4,7 @@ import { DEFAULT_TENANT_ID } from "../../multitenancy/constants"; import { getSessionInformation } from "../../session"; import { SessionContainerInterface } from "../../session/types"; import { AUTH_PATH, LOGIN_PATH, END_SESSION_PATH } from "../constants"; -import { RecipeInterface } from "../types"; +import { ErrorOAuth2, RecipeInterface } from "../types"; import setCookieParser from "set-cookie-parser"; // API implementation for the loginGET function. @@ -39,6 +39,7 @@ export async function loginGET({ const incomingAuthUrlQueryParams = new URLSearchParams(loginRequest.requestUrl.split("?")[1]); const promptParam = incomingAuthUrlQueryParams.get("prompt") ?? incomingAuthUrlQueryParams.get("st_prompt"); const maxAgeParam = incomingAuthUrlQueryParams.get("max_age"); + if (maxAgeParam !== null) { try { const maxAgeParsed = Number.parseInt(maxAgeParam); @@ -85,29 +86,14 @@ export async function loginGET({ }); return { redirectTo: accept.redirectTo, setCookie }; } - const appInfo = SuperTokens.getInstanceOrThrowError().appInfo; - const websiteDomain = appInfo - .getOrigin({ - request: undefined, - userContext: userContext, - }) - .getAsStringDangerous(); - const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); - if (shouldTryRefresh) { - const websiteDomain = appInfo - .getOrigin({ - request: undefined, - userContext: userContext, - }) - .getAsStringDangerous(); - const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); - - const queryParamsForTryRefreshPage = new URLSearchParams({ - loginChallenge, - }); + if (shouldTryRefresh && promptParam !== "login") { return { - redirectTo: websiteDomain + websiteBasePath + `/try-refresh?${queryParamsForTryRefreshPage.toString()}`, + redirectTo: await recipeImplementation.getFrontendRedirectionURL({ + type: "try-refresh", + loginChallenge, + userContext, + }), setCookie, }; } @@ -124,24 +110,15 @@ export async function loginGET({ return { redirectTo: reject.redirectTo, setCookie }; } - const queryParamsForAuthPage = new URLSearchParams({ - loginChallenge, - }); - - if (loginRequest.oidcContext?.login_hint) { - queryParamsForAuthPage.set("hint", loginRequest.oidcContext.login_hint); - } - - if (session !== undefined || promptParam === "login") { - queryParamsForAuthPage.set("forceFreshAuth", "true"); - } - - if (tenantIdParam !== null && tenantIdParam !== DEFAULT_TENANT_ID) { - queryParamsForAuthPage.set("tenantId", tenantIdParam); - } - return { - redirectTo: websiteDomain + websiteBasePath + `?${queryParamsForAuthPage.toString()}`, + redirectTo: await recipeImplementation.getFrontendRedirectionURL({ + type: "login", + loginChallenge, + forceFreshAuth: session !== undefined || promptParam === "login", + tenantId: tenantIdParam ?? DEFAULT_TENANT_ID, + hint: loginRequest.oidcContext?.login_hint, + userContext, + }), setCookie, }; } @@ -217,7 +194,7 @@ export async function handleLoginInternalRedirects({ shouldTryRefresh: boolean; cookie?: string; userContext: UserContext; -}): Promise<{ redirectTo: string; setCookie?: string }> { +}): Promise<{ redirectTo: string; setCookie?: string } | ErrorOAuth2> { if (!isLoginInternalRedirect(response.redirectTo)) { return response; } @@ -261,6 +238,10 @@ export async function handleLoginInternalRedirects({ userContext, }); + if ("error" in authRes) { + return authRes; + } + response = { redirectTo: authRes.redirectTo, setCookie: mergeSetCookieHeaders(authRes.setCookie, response.setCookie), @@ -287,7 +268,7 @@ export async function handleLogoutInternalRedirects({ recipeImplementation: RecipeInterface; session?: SessionContainerInterface; userContext: UserContext; -}): Promise<{ redirectTo: string }> { +}): Promise<{ redirectTo: string } | ErrorOAuth2> { if (!isLogoutInternalRedirect(response.redirectTo)) { return response; } @@ -302,7 +283,7 @@ export async function handleLogoutInternalRedirects({ const params = new URLSearchParams(queryString); if (response.redirectTo.includes(END_SESSION_PATH)) { - response = await recipeImplementation.endSession({ + const endSessionRes = await recipeImplementation.endSession({ params: Object.fromEntries(params.entries()), session, // We internally redirect to the `end_session_endpoint` at the end of the logout flow. @@ -311,6 +292,10 @@ export async function handleLogoutInternalRedirects({ shouldTryRefresh: false, userContext, }); + if ("error" in endSessionRes) { + return endSessionRes; + } + response = endSessionRes; } else { throw new Error(`Unexpected internal redirect ${response.redirectTo}`); } diff --git a/lib/ts/recipe/oauth2provider/index.ts b/lib/ts/recipe/oauth2provider/index.ts index 01424b051..af141630c 100644 --- a/lib/ts/recipe/oauth2provider/index.ts +++ b/lib/ts/recipe/oauth2provider/index.ts @@ -137,6 +137,20 @@ export default class Wrapper { }); } + static async revokeTokensByClientId(clientId: string, userContext?: Record) { + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.revokeTokensByClientId({ + clientId, + userContext: getUserContext(userContext), + }); + } + + static async revokeTokensBySessionHandle(sessionHandle: string, userContext?: Record) { + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.revokeTokensBySessionHandle({ + sessionHandle, + userContext: getUserContext(userContext), + }); + } + static validateOAuth2RefreshToken(token: string, scopes?: string[], userContext?: Record) { return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.introspectToken({ token, @@ -161,5 +175,7 @@ export let validateOAuth2AccessToken = Wrapper.validateOAuth2AccessToken; export let createTokenForClientCredentials = Wrapper.createTokenForClientCredentials; export let revokeToken = Wrapper.revokeToken; +export let revokeTokensByClientId = Wrapper.revokeTokensByClientId; +export let revokeTokensBySessionHandle = Wrapper.revokeTokensBySessionHandle; export type { APIInterface, APIOptions, RecipeInterface }; diff --git a/lib/ts/recipe/oauth2provider/recipe.ts b/lib/ts/recipe/oauth2provider/recipe.ts index 42fcc997f..6f63b0b1c 100644 --- a/lib/ts/recipe/oauth2provider/recipe.ts +++ b/lib/ts/recipe/oauth2provider/recipe.ts @@ -56,9 +56,6 @@ import introspectTokenPOST from "./api/introspectToken"; import { endSessionGET, endSessionPOST } from "./api/endSession"; import { logoutPOST } from "./api/logout"; import { getSessionInformation } from "../session"; -import { send200Response } from "../../utils"; - -const tokenHookMap = new Map(); export default class Recipe extends RecipeModule { static RECIPE_ID = "oauth2provider"; @@ -85,8 +82,7 @@ export default class Recipe extends RecipeModule { appInfo, this.getDefaultAccessTokenPayload.bind(this), this.getDefaultIdTokenPayload.bind(this), - this.getDefaultUserInfoPayload.bind(this), - this.saveTokensForHook.bind(this) + this.getDefaultUserInfoPayload.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); @@ -137,9 +133,6 @@ export default class Recipe extends RecipeModule { addIdTokenBuilderFromOtherRecipe = (idTokenBuilder: PayloadBuilderFunction) => { this.idTokenBuilders.push(idTokenBuilder); }; - saveTokensForHook = (sessionHandle: string, idToken: JSONObject, accessToken: JSONObject) => { - tokenHookMap.set(sessionHandle, { idToken, accessToken }); - }; /* RecipeModule functions */ @@ -187,13 +180,7 @@ export default class Recipe extends RecipeModule { id: INTROSPECT_TOKEN_PATH, disabled: this.apiImpl.introspectTokenPOST === undefined, }, - { - // TODO: remove this once we get core support - method: "post", - pathWithoutApiBasePath: new NormalisedURLPath("/oauth/token-hook"), - id: "token-hook", - disabled: false, - }, + { method: "get", pathWithoutApiBasePath: new NormalisedURLPath(END_SESSION_PATH), @@ -263,24 +250,6 @@ export default class Recipe extends RecipeModule { if (id === LOGOUT_PATH && method === "post") { return logoutPOST(this.apiImpl, options, userContext); } - if (id === "token-hook") { - const body = await options.req.getBodyAsJSONOrFormData(); - const sessionHandle = body.session.extra.sessionHandle; - const tokens = tokenHookMap.get(sessionHandle); - - if (tokens !== undefined) { - const { idToken, accessToken } = tokens; - send200Response(options.res, { - session: { - access_token: accessToken, - id_token: idToken, - }, - }); - } else { - send200Response(options.res, {}); - } - return true; - } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; @@ -301,12 +270,7 @@ export default class Recipe extends RecipeModule { if (sessionInfo === undefined) { throw new Error("Session not found"); } - let payload: JSONObject = { - iss: this.appInfo.apiDomain.getAsStringDangerous() + this.appInfo.apiBasePath.getAsStringDangerous(), - tId: sessionInfo.tenantId, - rsub: sessionInfo.recipeUserId.getAsString(), - sessionHandle: sessionHandle, - }; + let payload: JSONObject = {}; for (const fn of this.accessTokenBuilders) { payload = { @@ -318,9 +282,7 @@ export default class Recipe extends RecipeModule { return payload; } async getDefaultIdTokenPayload(user: User, scopes: string[], sessionHandle: string, userContext: UserContext) { - let payload: JSONObject = { - iss: this.appInfo.apiDomain.getAsStringDangerous() + this.appInfo.apiBasePath.getAsStringDangerous(), - }; + let payload: JSONObject = {}; if (scopes.includes("email")) { payload.email = user?.emails[0]; payload.email_verified = user.loginMethods.some((lm) => lm.hasSameEmailAs(user?.emails[0]) && lm.verified); diff --git a/lib/ts/recipe/oauth2provider/recipeImplementation.ts b/lib/ts/recipe/oauth2provider/recipeImplementation.ts index 463fcebc1..bbf9abd92 100644 --- a/lib/ts/recipe/oauth2provider/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2provider/recipeImplementation.ts @@ -15,7 +15,7 @@ import * as jose from "jose"; import NormalisedURLPath from "../../normalisedURLPath"; -import { Querier, hydraPubDomain } from "../../querier"; +import { Querier } from "../../querier"; import { JSONObject, NormalisedAppinfo } from "../../types"; import { RecipeInterface, @@ -25,17 +25,28 @@ import { PayloadBuilderFunction, UserInfoBuilderFunction, } from "./types"; -import { toSnakeCase, transformObjectKeys } from "../../utils"; import { OAuth2Client } from "./OAuth2Client"; import { getUser } from "../.."; import { getCombinedJWKS } from "../../combinedRemoteJWKSet"; -import { getSessionInformation } from "../session"; +import SessionRecipe from "../session/recipe"; +import { DEFAULT_TENANT_ID } from "../multitenancy/constants"; -// TODO: Remove this core changes are done function getUpdatedRedirectTo(appInfo: NormalisedAppinfo, redirectTo: string) { - return redirectTo - .replace(hydraPubDomain, appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous()) - .replace("oauth2/", "oauth/"); + return redirectTo.replace( + "{apiDomain}", + appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous() + ); +} + +function copyAndCleanRequestBodyInput(input: any): any { + let result = { + ...input, + }; + delete result.userContext; + delete result.tenantId; + delete result.session; + + return result; } export default function getRecipeInterface( @@ -44,61 +55,59 @@ export default function getRecipeInterface( appInfo: NormalisedAppinfo, getDefaultAccessTokenPayload: PayloadBuilderFunction, getDefaultIdTokenPayload: PayloadBuilderFunction, - getDefaultUserInfoPayload: UserInfoBuilderFunction, - saveTokensForHook: (sessionHandle: string, idToken: JSONObject, accessToken: JSONObject) => void + getDefaultUserInfoPayload: UserInfoBuilderFunction ): RecipeInterface { return { getLoginRequest: async function (this: RecipeInterface, input): Promise { const resp = await querier.sendGetRequest( - new NormalisedURLPath("/recipe/oauth2/admin/oauth2/auth/requests/login"), - { login_challenge: input.challenge }, + new NormalisedURLPath("/recipe/oauth/auth/requests/login"), + { loginChallenge: input.challenge }, input.userContext ); return { - challenge: resp.data.challenge, - client: OAuth2Client.fromAPIResponse(resp.data.client), - oidcContext: resp.data.oidc_context, - requestUrl: resp.data.request_url, - requestedAccessTokenAudience: resp.data.requested_access_token_audience, - requestedScope: resp.data.requested_scope, - sessionId: resp.data.session_id, - skip: resp.data.skip, - subject: resp.data.subject, + challenge: resp.challenge, + client: OAuth2Client.fromAPIResponse(resp.client), + oidcContext: resp.oidcContext, + requestUrl: resp.requestUrl, + requestedAccessTokenAudience: resp.requestedAccessTokenAudience, + requestedScope: resp.requestedScope, + sessionId: resp.sessionId, + skip: resp.skip, + subject: resp.subject, }; }, acceptLoginRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { const resp = await querier.sendPutRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/login/accept`), + new NormalisedURLPath(`/recipe/oauth/auth/requests/login/accept`), { acr: input.acr, amr: input.amr, context: input.context, - extend_session_lifespan: input.extendSessionLifespan, - force_subject_identifier: input.forceSubjectIdentifier, - identity_provider_session_id: input.identityProviderSessionId, + extendSessionLifespan: input.extendSessionLifespan, + forceSubjectIdentifier: input.forceSubjectIdentifier, + identityProviderSessionId: input.identityProviderSessionId, remember: input.remember, - remember_for: input.rememberFor, + rememberFor: input.rememberFor, subject: input.subject, }, { - login_challenge: input.challenge, + loginChallenge: input.challenge, }, input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to), + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo), }; }, rejectLoginRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { const resp = await querier.sendPutRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/login/reject`), + new NormalisedURLPath(`/recipe/oauth/auth/requests/login/reject`), { error: input.error.error, - error_description: input.error.errorDescription, - status_code: input.error.statusCode, + errorDescription: input.error.errorDescription, + statusCode: input.error.statusCode, }, { login_challenge: input.challenge, @@ -107,94 +116,168 @@ export default function getRecipeInterface( ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to), + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo), }; }, getConsentRequest: async function (this: RecipeInterface, input): Promise { const resp = await querier.sendGetRequest( - new NormalisedURLPath("/recipe/oauth2/admin/oauth2/auth/requests/consent"), - { consent_challenge: input.challenge }, + new NormalisedURLPath("/recipe/oauth/auth/requests/consent"), + { consentChallenge: input.challenge }, input.userContext ); return { - acr: resp.data.acr, - amr: resp.data.amr, - challenge: resp.data.challenge, - client: OAuth2Client.fromAPIResponse(resp.data.client), - context: resp.data.context, - loginChallenge: resp.data.login_challenge, - loginSessionId: resp.data.login_session_id, - oidcContext: resp.data.oidc_context, - requestedAccessTokenAudience: resp.data.requested_access_token_audience, - requestedScope: resp.data.requested_scope, - skip: resp.data.skip, - subject: resp.data.subject, + acr: resp.acr, + amr: resp.amr, + challenge: resp.challenge, + client: OAuth2Client.fromAPIResponse(resp.client), + context: resp.context, + loginChallenge: resp.loginChallenge, + loginSessionId: resp.loginSessionId, + oidcContext: resp.oidcContext, + requestedAccessTokenAudience: resp.requestedAccessTokenAudience, + requestedScope: resp.requestedScope, + skip: resp.skip, + subject: resp.subject, }; }, acceptConsentRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { const resp = await querier.sendPutRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/consent/accept`), + new NormalisedURLPath(`/recipe/oauth/auth/requests/consent/accept`), { context: input.context, - grant_access_token_audience: input.grantAccessTokenAudience, - grant_scope: input.grantScope, - handled_at: input.handledAt, + grantAccessTokenAudience: input.grantAccessTokenAudience, + grantScope: input.grantScope, + handledAt: input.handledAt, remember: input.remember, - remember_for: input.rememberFor, - session: input.session, + rememberFor: input.rememberFor, + iss: appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(), + tId: input.tenantId, + rsub: input.rsub, + sessionHandle: input.sessionHandle, + initialAccessTokenPayload: input.initialAccessTokenPayload, + initialIdTokenPayload: input.initialIdTokenPayload, }, { - consent_challenge: input.challenge, + consentChallenge: input.challenge, }, input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to), + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo), }; }, rejectConsentRequest: async function (this: RecipeInterface, input) { const resp = await querier.sendPutRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/consent/reject`), + new NormalisedURLPath(`/recipe/oauth/auth/requests/consent/reject`), { error: input.error.error, - error_description: input.error.errorDescription, - status_code: input.error.statusCode, + errorDescription: input.error.errorDescription, + statusCode: input.error.statusCode, }, { - consent_challenge: input.challenge, + consentChallenge: input.challenge, }, input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to), + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo), }; }, authorization: async function (this: RecipeInterface, input) { + // we handle this in the backend SDK level + if (input.params.prompt === "none") { + input.params["st_prompt"] = "none"; + delete input.params.prompt; + } + + let payloads: { idToken: JSONObject | undefined; accessToken: JSONObject | undefined } | undefined; + + if (input.params.client_id === undefined || typeof input.params.client_id !== "string") { + return { + statusCode: 400, + error: "invalid_request", + errorDescription: "client_id is required and must be a string", + }; + } + + const scopes = await this.getRequestedScopes({ + scopeParam: input.params.scope?.split(" ") || [], + clientId: input.params.client_id as string, + recipeUserId: input.session?.getRecipeUserId(), + sessionHandle: input.session?.getHandle(), + userContext: input.userContext, + }); + + const responseTypes = input.params.response_type?.split(" ") ?? []; + if (input.session !== undefined) { - if (input.params.prompt === "none") { - input.params["st_prompt"] = "none"; - delete input.params.prompt; + const clientInfo = await this.getOAuth2Client({ + clientId: input.params.client_id as string, + userContext: input.userContext, + }); + + if (clientInfo.status === "ERROR") { + throw new Error(clientInfo.error); } + const client = clientInfo.client; + + const user = await getUser(input.session.getUserId()); + if (!user) { + throw new Error("User not found"); + } + + const idToken = + responseTypes.includes("id_token") || responseTypes.includes("code") + ? await this.buildIdTokenPayload({ + user, + client, + sessionHandle: input.session.getHandle(), + scopes, + userContext: input.userContext, + }) + : undefined; + const accessToken = + responseTypes.includes("token") || responseTypes.includes("code") + ? await this.buildAccessTokenPayload({ + user, + client, + sessionHandle: input.session.getHandle(), + scopes, + userContext: input.userContext, + }) + : undefined; + payloads = { + idToken, + accessToken, + }; } - const resp = await querier.sendGetRequestWithResponseHeaders( - new NormalisedURLPath(`/recipe/oauth2/pub/auth`), - input.params, + const resp = await querier.sendPostRequest( + new NormalisedURLPath(`/recipe/oauth/auth`), { - // TODO: if session is not set also clear the oauth2 cookie - Cookie: `${input.cookies}`, + params: { + ...input.params, + scope: scopes.join(" "), + }, + cookies: input.cookies, + session: payloads, }, input.userContext ); - const redirectTo = getUpdatedRedirectTo(appInfo, resp.headers.get("Location")!); + if (resp.status !== "OK") { + return { + statusCode: resp.statusCode, + error: resp.error, + errorDescription: resp.errorDescription, + }; + } + + const redirectTo = getUpdatedRedirectTo(appInfo, resp.redirectTo); if (redirectTo === undefined) { throw new Error(resp.body); } @@ -206,55 +289,71 @@ export default function getRecipeInterface( userContext: input.userContext, }); - const user = await getUser(input.session.getUserId()); - if (!user) { - throw new Error("Should not happen"); - } - const idToken = await this.buildIdTokenPayload({ - user, - client: consentRequest.client!, - sessionHandle: input.session.getHandle(), - scopes: consentRequest.requestedScope || [], - userContext: input.userContext, - }); - const accessTokenPayload = await this.buildAccessTokenPayload({ - user, - client: consentRequest.client!, - sessionHandle: input.session.getHandle(), - scopes: consentRequest.requestedScope || [], - userContext: input.userContext, - }); - - const sessionInfo = await getSessionInformation(input.session.getHandle()); - if (!sessionInfo) { - throw new Error("Session not found"); - } - const consentRes = await this.acceptConsentRequest({ - ...input, + userContext: input.userContext, challenge: consentRequest.challenge, grantAccessTokenAudience: consentRequest.requestedAccessTokenAudience, grantScope: consentRequest.requestedScope, - remember: true, // TODO: verify that we need this - session: { - id_token: idToken, - access_token: accessTokenPayload, - }, - handledAt: new Date(sessionInfo.timeCreated).toISOString(), + tenantId: input.session.getTenantId(), + rsub: input.session.getRecipeUserId().getAsString(), + sessionHandle: input.session.getHandle(), + initialAccessTokenPayload: payloads?.accessToken, + initialIdTokenPayload: payloads?.idToken, }); return { redirectTo: consentRes.redirectTo, - setCookie: resp.headers.get("set-cookie") ?? undefined, + setCookie: resp.cookies, }; } - return { redirectTo, setCookie: resp.headers.get("set-cookie") ?? undefined }; + return { redirectTo, setCookie: resp.cookies }; }, tokenExchange: async function (this: RecipeInterface, input) { - const body: any = { $isFormData: true }; // TODO: we ideally want to avoid using formdata, the core can do the translation - for (const key in input.body) { - body[key] = input.body[key]; + const body: any = { + inputBody: input.body, + authorizationHeader: input.authorizationHeader, + }; + + body.iss = appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(); + + if (input.body.grant_type === "client_credentials") { + if (input.body.client_id === undefined) { + return { + statusCode: 400, + error: "invalid_request", + errorDescription: "client_id is required", + }; + } + + const scopes = input.body.scope?.split(" ") ?? []; + const clientInfo = await this.getOAuth2Client({ + clientId: input.body.client_id as string, + userContext: input.userContext, + }); + + if (clientInfo.status === "ERROR") { + return { + statusCode: 400, + error: clientInfo.error, + errorDescription: clientInfo.errorHint, + }; + } + const client = clientInfo.client; + body["id_token"] = await this.buildIdTokenPayload({ + user: undefined, + client, + sessionHandle: undefined, + scopes, + userContext: input.userContext, + }); + body["access_token"] = await this.buildAccessTokenPayload({ + user: undefined, + client, + sessionHandle: undefined, + scopes, + userContext: input.userContext, + }); } if (input.body.grant_type === "refresh_token") { @@ -266,7 +365,7 @@ export default function getRecipeInterface( }); if (tokenInfo.active === true) { - const sessionHandle = (tokenInfo.ext as any).sessionHandle as string; + const sessionHandle = tokenInfo.sessionHandle as string; const clientInfo = await this.getOAuth2Client({ clientId: tokenInfo.client_id as string, @@ -284,26 +383,20 @@ export default function getRecipeInterface( if (!user) { throw new Error("User not found"); } - const idToken = await this.buildIdTokenPayload({ + body["id_token"] = await this.buildIdTokenPayload({ user, client, - sessionHandle: sessionHandle, + sessionHandle, scopes, userContext: input.userContext, }); - const accessTokenPayload = await this.buildAccessTokenPayload({ + body["access_token"] = await this.buildAccessTokenPayload({ user, client, sessionHandle: sessionHandle, scopes, userContext: input.userContext, }); - body["session"] = { - id_token: idToken, - access_token: accessTokenPayload, - }; - - saveTokensForHook(sessionHandle, idToken, accessTokenPayload); } } @@ -312,7 +405,7 @@ export default function getRecipeInterface( } const res = await querier.sendPostRequest( - new NormalisedURLPath(`/recipe/oauth2/pub/token`), + new NormalisedURLPath(`/recipe/oauth/token`), body, input.userContext ); @@ -320,138 +413,100 @@ export default function getRecipeInterface( if (res.status !== "OK") { return { statusCode: res.statusCode, - error: res.data.error, - errorDescription: res.data.error_description, + error: res.error, + errorDescription: res.errorDescription, }; } - return res.data; + return res; }, getOAuth2Clients: async function (input) { let response = await querier.sendGetRequestWithResponseHeaders( - new NormalisedURLPath(`/recipe/oauth2/admin/clients`), + new NormalisedURLPath(`/recipe/oauth/clients/list`), { - ...transformObjectKeys(input, "snake-case"), - page_token: input.paginationToken, + pageSize: input.pageSize, + clientName: input.clientName, + owner: input.owner, + pageToken: input.paginationToken, }, {}, input.userContext ); if (response.body.status === "OK") { - // Pagination info is in the Link header, containing comma-separated links: - // "first", "next" (if applicable). - // Example: Link: ; rel="first", ; rel="next" - - // We parse the nextPaginationToken from the Link header using RegExp - let nextPaginationToken: string | undefined; - const linkHeader = response.headers.get("link") ?? ""; - - const nextLinkMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/); - if (nextLinkMatch) { - const url = nextLinkMatch[1]; - const urlParams = new URLSearchParams(url.split("?")[1]); - nextPaginationToken = urlParams.get("page_token") as string; - } - return { status: "OK", - clients: response.body.data.map((client: any) => OAuth2Client.fromAPIResponse(client)), - nextPaginationToken, + clients: response.body.clients.map((client: any) => OAuth2Client.fromAPIResponse(client)), + nextPaginationToken: response.body.nextPaginationToken, }; } else { return { status: "ERROR", - error: response.body.data.error, - errorHint: response.body.data.errorHint, + error: response.body.error, + errorHint: response.body.errorHint, }; } }, getOAuth2Client: async function (input) { let response = await querier.sendGetRequestWithResponseHeaders( - new NormalisedURLPath(`/recipe/oauth2/admin/clients/${input.clientId}`), - {}, + new NormalisedURLPath(`/recipe/oauth/clients`), + { clientId: input.clientId }, {}, input.userContext ); - if (response.body.status === "OK") { - return { - status: "OK", - client: OAuth2Client.fromAPIResponse(response.body.data), - }; - } else { - return { - status: "ERROR", - error: response.body.data.error, - errorHint: response.body.data.errorHint, - }; - } + return { + status: "OK", + client: OAuth2Client.fromAPIResponse(response.body), + }; + // return { + // status: "ERROR", + // error: response.body.error, + // errorHint: response.body.errorHint, + // }; }, createOAuth2Client: async function (input) { let response = await querier.sendPostRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/clients`), - { - ...transformObjectKeys(input, "snake-case"), - // TODO: these defaults should be set/enforced on the core side - access_token_strategy: "jwt", - skip_consent: true, - subject_type: "public", - }, + new NormalisedURLPath(`/recipe/oauth/clients`), + copyAndCleanRequestBodyInput(input), input.userContext ); - if (response.status === "OK") { - return { - status: "OK", - client: OAuth2Client.fromAPIResponse(response.data), - }; - } else { - return { - status: "ERROR", - error: response.data.error, - errorHint: response.data.errorHint, - }; - } + return { + status: "OK", + client: OAuth2Client.fromAPIResponse(response), + }; + // return { + // status: "ERROR", + // error: response.error, + // errorHint: response.errorHint, + // }; }, updateOAuth2Client: async function (input) { - // We convert the input into an array of "replace" operations - const requestBody = Object.entries(input).reduce< - Array<{ from: string; op: "replace"; path: string; value: any }> - >((result, [key, value]) => { - result.push({ - from: `/${toSnakeCase(key)}`, - op: "replace", - path: `/${toSnakeCase(key)}`, - value, - }); - return result; - }, []); - - let response = await querier.sendPatchRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/clients/${input.clientId}`), - requestBody, + let response = await querier.sendPutRequest( + new NormalisedURLPath(`/recipe/oauth/clients`), + copyAndCleanRequestBodyInput(input), + { clientId: input.clientId }, input.userContext ); if (response.status === "OK") { return { status: "OK", - client: OAuth2Client.fromAPIResponse(response.data), + client: OAuth2Client.fromAPIResponse(response), }; } else { return { status: "ERROR", - error: response.data.error, - errorHint: response.data.errorHint, + error: response.error, + errorHint: response.errorHint, }; } }, deleteOAuth2Client: async function (input) { - let response = await querier.sendDeleteRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/clients/${input.clientId}`), - undefined, - undefined, + let response = await querier.sendPostRequest( + new NormalisedURLPath(`/recipe/oauth/clients/remove`), + { clientId: input.clientId }, input.userContext ); @@ -460,37 +515,73 @@ export default function getRecipeInterface( } else { return { status: "ERROR", - error: response.data.error, - errorHint: response.data.errorHint, + error: response.error, + errorHint: response.errorHint, }; } }, + getRequestedScopes: async function (this: RecipeInterface, input): Promise { + return input.scopeParam; + }, buildAccessTokenPayload: async function (input) { + if (input.user === undefined || input.sessionHandle === undefined) { + return {}; + } return getDefaultAccessTokenPayload(input.user, input.scopes, input.sessionHandle, input.userContext); }, buildIdTokenPayload: async function (input) { + if (input.user === undefined || input.sessionHandle === undefined) { + return {}; + } return getDefaultIdTokenPayload(input.user, input.scopes, input.sessionHandle, input.userContext); }, buildUserInfo: async function ({ user, accessTokenPayload, scopes, tenantId, userContext }) { return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, tenantId, userContext); }, - validateOAuth2AccessToken: async function (input) { - const payload = (await jose.jwtVerify(input.token, getCombinedJWKS())).payload; + getFrontendRedirectionURL: async function (input) { + const websiteDomain = appInfo + .getOrigin({ request: undefined, userContext: input.userContext }) + .getAsStringDangerous(); + const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); - // if (payload.stt !== 1) { - // throw new Error("Wrong token type"); - // } + if (input.type === "login") { + const queryParams = new URLSearchParams({ + loginChallenge: input.loginChallenge, + }); + if (input.tenantId !== undefined && input.tenantId !== DEFAULT_TENANT_ID) { + queryParams.set("tenantId", input.tenantId); + } + if (input.hint !== undefined) { + queryParams.set("hint", input.hint); + } + if (input.forceFreshAuth) { + queryParams.set("forceFreshAuth", "true"); + } - // TODO: we should be able uncomment this after we get proper core support - // TODO: make this configurable? - // const expectedIssuer = - // appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(); - // if (payload.iss !== expectedIssuer) { - // throw new Error("Issuer mismatch: this token was likely issued by another application or spoofed"); - // } + return `${websiteDomain}${websiteBasePath}?${queryParams.toString()}`; + } else if (input.type === "try-refresh") { + return `${websiteDomain}${websiteBasePath}/try-refresh?loginChallenge=${input.loginChallenge}`; + } else if (input.type === "post-logout-fallback") { + return `${websiteDomain}${websiteBasePath}`; + } else if (input.type === "logout-confirmation") { + return `${websiteDomain}${websiteBasePath}/oauth/logout?logoutChallenge=${input.logoutChallenge}`; + } + + throw new Error("This should never happen: invalid type passed to getFrontendRedirectionURL"); + }, + validateOAuth2AccessToken: async function (input) { + const payload = ( + await jose.jwtVerify(input.token, getCombinedJWKS(SessionRecipe.getInstanceOrThrowError().config)) + ).payload; + + if (payload.stt !== 1) { + throw new Error("Wrong token type"); + } if (input.requirements?.clientId !== undefined && payload.client_id !== input.requirements.clientId) { - throw new Error("The token doesn't belong to the specified client"); + throw new Error( + `The token doesn't belong to the specified client (${input.requirements.clientId} !== ${payload.client_id})` + ); } if ( @@ -507,24 +598,21 @@ export default function getRecipeInterface( if (input.checkDatabase) { let response = await querier.sendPostRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/introspect`), + new NormalisedURLPath(`/recipe/oauth/introspect`), { - $isFormData: true, token: input.token, }, input.userContext ); - // TODO: fix after the core interface is there - if (response.status !== "OK" || response.data.active !== true) { - throw new Error(response.data.error); + if (response.active !== true) { + throw new Error("The token is expired, invalid or has been revoked"); } } return { status: "OK", payload: payload as JSONObject }; }, revokeToken: async function (this: RecipeInterface, input) { const requestBody: Record = { - $isFormData: true, token: input.token, }; @@ -540,7 +628,7 @@ export default function getRecipeInterface( } const res = await querier.sendPostRequest( - new NormalisedURLPath(`/recipe/oauth2/pub/revoke`), + new NormalisedURLPath(`/recipe/oauth/token/revoke`), requestBody, input.userContext ); @@ -548,17 +636,35 @@ export default function getRecipeInterface( if (res.status !== "OK") { return { statusCode: res.statusCode, - error: res.data.error, - errorDescription: res.data.error_description, + error: res.error, + errorDescription: res.errorDescription, }; } return { status: "OK" }; }, + revokeTokensBySessionHandle: async function (this: RecipeInterface, input) { + await querier.sendPostRequest( + new NormalisedURLPath(`/recipe/oauth/session/revoke`), + { sessionHandle: input.sessionHandle }, + input.userContext + ); + + return { status: "OK" }; + }, + revokeTokensByClientId: async function (this: RecipeInterface, input) { + await querier.sendPostRequest( + new NormalisedURLPath(`/recipe/oauth/tokens/revoke`), + { clientId: input.clientId }, + input.userContext + ); + + return { status: "OK" }; + }, introspectToken: async function (this: RecipeInterface, { token, scopes, userContext }) { - // Determine if the token is an access token by checking if it doesn't start with "ory_rt" - const isAccessToken = !token.startsWith("ory_rt"); + // Determine if the token is an access token by checking if it doesn't start with "st_rt" + const isAccessToken = !token.startsWith("st_rt"); // Attempt to validate the access token locally // If it fails, the token is not active, and we return early @@ -578,16 +684,15 @@ export default function getRecipeInterface( // For tokens that passed local validation or if it's a refresh token, // validate the token with the database by calling the core introspection endpoint const res = await querier.sendPostRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/introspect`), + new NormalisedURLPath(`/recipe/oauth/introspect`), { - $isFormData: true, token, scope: scopes ? scopes.join(" ") : undefined, }, userContext ); - return res.data; + return res; }, endSession: async function (this: RecipeInterface, input) { @@ -605,7 +710,7 @@ export default function getRecipeInterface( */ const resp = await querier.sendGetRequestWithResponseHeaders( - new NormalisedURLPath(`/recipe/oauth2/pub/sessions/logout`), + new NormalisedURLPath(`/recipe/oauth/sessions/logout`), input.params, {}, input.userContext @@ -616,11 +721,6 @@ export default function getRecipeInterface( throw new Error(resp.body); } - const websiteDomain = appInfo - .getOrigin({ request: undefined, userContext: input.userContext }) - .getAsStringDangerous(); - const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); - const redirectToURL = new URL(redirectTo); const logoutChallenge = redirectToURL.searchParams.get("logout_challenge"); @@ -629,8 +729,11 @@ export default function getRecipeInterface( // Redirect to the frontend to ask for logout confirmation if there is a valid or expired supertokens session if (input.session !== undefined || input.shouldTryRefresh) { return { - redirectTo: - websiteDomain + websiteBasePath + "/oauth/logout" + `?logoutChallenge=${logoutChallenge}`, + redirectTo: await this.getFrontendRedirectionURL({ + type: "logout-confirmation", + logoutChallenge, + userContext: input.userContext, + }), }; } else { // Accept the logout challenge immediately as there is no supertokens session @@ -643,40 +746,44 @@ export default function getRecipeInterface( // CASE 2 or 3 (See above notes) - // TODO: + // TODO: add test for this // 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")) { - return { redirectTo: `${websiteDomain}${websiteBasePath}` }; + return { + redirectTo: await this.getFrontendRedirectionURL({ + type: "post-logout-fallback", + userContext: input.userContext, + }), + }; } return { redirectTo }; }, acceptLogoutRequest: async function (this: RecipeInterface, input) { const resp = await querier.sendPutRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/logout/accept`), + new NormalisedURLPath(`/recipe/oauth/auth/requests/logout/accept`), {}, { logout_challenge: input.challenge }, input.userContext ); return { - // TODO: FIXME!!! - redirectTo: getUpdatedRedirectTo(appInfo, resp.data.redirect_to) + redirectTo: getUpdatedRedirectTo(appInfo, resp.redirectTo) // NOTE: This renaming only applies to this endpoint, hence not part of the generic "getUpdatedRedirectTo" function. .replace("/sessions/logout", "/end_session"), }; }, rejectLogoutRequest: async function (this: RecipeInterface, input) { const resp = await querier.sendPutRequest( - new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/logout/reject`), + new NormalisedURLPath(`/recipe/oauth/auth/requests/logout/reject`), {}, { logout_challenge: input.challenge }, input.userContext ); if (resp.status != "OK") { - throw new Error(resp.data.error); + throw new Error(resp.error); } return { status: "OK" }; diff --git a/lib/ts/recipe/oauth2provider/types.ts b/lib/ts/recipe/oauth2provider/types.ts index ee81d7868..f7b4ce91c 100644 --- a/lib/ts/recipe/oauth2provider/types.ts +++ b/lib/ts/recipe/oauth2provider/types.ts @@ -19,9 +19,9 @@ import { GeneralErrorResponse, JSONObject, JSONValue, NonNullableProperties, Use import { SessionContainerInterface } from "../session/types"; import { OAuth2Client } from "./OAuth2Client"; import { User } from "../../user"; +import RecipeUserId from "../../recipeUserId"; export type TypeInput = { - // TODO: issuer? override?: { functions?: ( originalImplementation: RecipeInterface, @@ -180,7 +180,7 @@ export type RecipeInterface = { cookies: string | undefined; session: SessionContainerInterface | undefined; userContext: UserContext; - }): Promise<{ redirectTo: string; setCookie: string | undefined }>; + }): Promise<{ redirectTo: string; setCookie: string | undefined } | ErrorOAuth2>; tokenExchange(input: { authorizationHeader?: string; body: Record; @@ -204,8 +204,12 @@ export type RecipeInterface = { // RememberFor sets how long the consent authorization should be remembered for in seconds. If set to 0, the authorization will be remembered indefinitely. integer rememberFor?: number; - // object (Pass session data to a consent request.) - session?: any; + tenantId: string; + rsub: string; + sessionHandle: string; + initialAccessTokenPayload: JSONObject | undefined; + initialIdTokenPayload: JSONObject | undefined; + userContext: UserContext; }): Promise<{ redirectTo: string }>; @@ -268,7 +272,6 @@ export type RecipeInterface = { status: "OK"; client: OAuth2Client; } - // TODO: Define specific error types once requirements are clearer | { status: "ERROR"; error: string; @@ -285,7 +288,6 @@ export type RecipeInterface = { clients: Array; nextPaginationToken?: string; } - // TODO: Define specific error types once requirements are clearer | { status: "ERROR"; error: string; @@ -301,7 +303,6 @@ export type RecipeInterface = { status: "OK"; client: OAuth2Client; } - // TODO: Define specific error types once requirements are clearer | { status: "ERROR"; error: string; @@ -317,7 +318,6 @@ export type RecipeInterface = { status: "OK"; client: OAuth2Client; } - // TODO: Define specific error types once requirements are clearer | { status: "ERROR"; error: string; @@ -332,7 +332,6 @@ export type RecipeInterface = { | { status: "OK"; } - // TODO: Define specific error types once requirements are clearer | { status: "ERROR"; error: string; @@ -351,17 +350,24 @@ export type RecipeInterface = { userContext: UserContext; }): Promise<{ status: "OK"; payload: JSONObject }>; + getRequestedScopes(input: { + recipeUserId: RecipeUserId | undefined; + sessionHandle: string | undefined; + scopeParam: string[]; + clientId: string; + userContext: UserContext; + }): Promise; buildAccessTokenPayload(input: { - user: User; + user: User | undefined; client: OAuth2Client; - sessionHandle: string; + sessionHandle: string | undefined; scopes: string[]; userContext: UserContext; }): Promise; buildIdTokenPayload(input: { - user: User; + user: User | undefined; client: OAuth2Client; - sessionHandle: string; + sessionHandle: string | undefined; scopes: string[]; userContext: UserContext; }): Promise; @@ -372,6 +378,31 @@ export type RecipeInterface = { tenantId: string; userContext: UserContext; }): Promise; + getFrontendRedirectionURL( + input: + | { + type: "login"; + loginChallenge: string; + tenantId: string; + forceFreshAuth: boolean; + hint: string | undefined; + userContext: UserContext; + } + | { + type: "try-refresh"; + loginChallenge: string; + userContext: UserContext; + } + | { + type: "logout-confirmation"; + logoutChallenge: string; + userContext: UserContext; + } + | { + type: "post-logout-fallback"; + userContext: UserContext; + } + ): Promise; revokeToken( input: { token: string; @@ -383,6 +414,8 @@ export type RecipeInterface = { | { clientId: string; clientSecret?: string } ) ): Promise<{ status: "OK" } | ErrorOAuth2>; + revokeTokensByClientId(input: { clientId: string; userContext: UserContext }): Promise<{ status: "OK" }>; + revokeTokensBySessionHandle(input: { sessionHandle: string; userContext: UserContext }): Promise<{ status: "OK" }>; introspectToken(input: { token: string; scopes?: string[]; @@ -393,7 +426,7 @@ export type RecipeInterface = { session?: SessionContainerInterface; shouldTryRefresh: boolean; userContext: UserContext; - }): Promise<{ redirectTo: string }>; + }): Promise<{ redirectTo: string } | ErrorOAuth2>; acceptLogoutRequest(input: { challenge: string; userContext: UserContext }): Promise<{ redirectTo: string }>; rejectLogoutRequest(input: { challenge: string; userContext: UserContext }): Promise<{ status: "OK" }>; }; @@ -407,7 +440,7 @@ export type APIInterface = { session?: SessionContainerInterface; shouldTryRefresh: boolean; userContext: UserContext; - }) => Promise<{ redirectTo: string; setCookie?: string } | GeneralErrorResponse>); + }) => Promise<{ redirectTo: string; setCookie?: string } | ErrorOAuth2 | GeneralErrorResponse>); authGET: | undefined @@ -469,7 +502,7 @@ export type APIInterface = { shouldTryRefresh: boolean; options: APIOptions; userContext: UserContext; - }) => Promise<{ redirectTo: string }>); + }) => Promise<{ redirectTo: string } | ErrorOAuth2 | GeneralErrorResponse>); endSessionPOST: | undefined | ((input: { @@ -478,7 +511,7 @@ export type APIInterface = { shouldTryRefresh: boolean; options: APIOptions; userContext: UserContext; - }) => Promise<{ redirectTo: string }>); + }) => Promise<{ redirectTo: string } | ErrorOAuth2 | GeneralErrorResponse>); logoutPOST: | undefined | ((input: { @@ -486,7 +519,7 @@ export type APIInterface = { options: APIOptions; session?: SessionContainerInterface; userContext: UserContext; - }) => Promise<{ status: "OK"; frontendRedirectTo: string }>); + }) => Promise<{ status: "OK"; frontendRedirectTo: string } | ErrorOAuth2 | GeneralErrorResponse>); }; export type OAuth2ClientOptions = { diff --git a/lib/ts/recipe/session/constants.ts b/lib/ts/recipe/session/constants.ts index e7a5c296a..e3dd1ac2a 100644 --- a/lib/ts/recipe/session/constants.ts +++ b/lib/ts/recipe/session/constants.ts @@ -23,6 +23,7 @@ export const availableTokenTransferMethods: TokenTransferMethod[] = ["cookie", " export const oneYearInMs = 31536000000; export const JWKCacheCooldownInMs = 500; +export const JWKCacheMaxAgeInMs = 60000; export const protectedProps = [ "sub", diff --git a/lib/ts/recipe/session/sessionFunctions.ts b/lib/ts/recipe/session/sessionFunctions.ts index dc05e4469..9c0b1bc40 100644 --- a/lib/ts/recipe/session/sessionFunctions.ts +++ b/lib/ts/recipe/session/sessionFunctions.ts @@ -112,7 +112,7 @@ export async function getSession( */ accessTokenInfo = await getInfoFromAccessToken( parsedAccessToken, - getCombinedJWKS(), + getCombinedJWKS(config), helpers.config.antiCsrfFunctionOrString === "VIA_TOKEN" && doAntiCsrfCheck ); } catch (err) { diff --git a/test/oauth2/oauth2client.test.js b/test/oauth2/oauth2client.test.js index cc2b701f4..a95af4fe8 100644 --- a/test/oauth2/oauth2client.test.js +++ b/test/oauth2/oauth2client.test.js @@ -154,7 +154,7 @@ describe(`OAuth2ClientTests: ${printPath("[test/oauth2/oauth2client.test.js]")}` let clientIds = new Set(); // Create 10 clients for (let i = 0; i < 10; i++) { - const client = await OAuth2Recipe.createOAuth2Client({}); + const { client } = await OAuth2Recipe.createOAuth2Client({}); clientIds.add(client.clientId); } diff --git a/test/session/jwksCache.test.js b/test/session/jwksCache.test.js index 3047e819d..11fa43deb 100644 --- a/test/session/jwksCache.test.js +++ b/test/session/jwksCache.test.js @@ -219,7 +219,7 @@ describe(`JWKs caching: ${printPath("[test/session/jwksCache.test.js]")}`, funct // This should be done using the cache assert.ok(await Session.getSessionWithoutRequestResponse(tokens.accessToken, tokens.antiCsrfToken)); assert.strictEqual(requestMock.callCount, 1); - // we "wait" for 3 seconds to make the cache out-of-date + // we "wait" for 20 seconds to make the cache out-of-date clock.tick(20000); // This should re-fetch from the core assert.ok(await Session.getSessionWithoutRequestResponse(tokens.accessToken, tokens.antiCsrfToken));