From 9f7866c2b962a783220332316b824234fb4bb573 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Mon, 19 Aug 2024 00:02:30 +0200 Subject: [PATCH] feat: add shouldTryRefresh plus self-review and test related fixes --- lib/build/recipe/oauth2provider/api/auth.js | 20 ++-- .../oauth2provider/api/implementation.js | 8 +- lib/build/recipe/oauth2provider/api/login.js | 12 ++- .../recipe/oauth2provider/api/utils.d.ts | 4 + lib/build/recipe/oauth2provider/api/utils.js | 52 +++++++-- lib/build/recipe/oauth2provider/index.js | 25 +++-- lib/build/recipe/oauth2provider/recipe.d.ts | 18 +++- lib/build/recipe/oauth2provider/recipe.js | 61 ++++++++++- .../oauth2provider/recipeImplementation.d.ts | 6 +- .../oauth2provider/recipeImplementation.js | 102 +++++++++++++----- lib/build/recipe/oauth2provider/types.d.ts | 36 ++++--- lib/build/recipe/userroles/recipe.js | 41 ++++++- lib/ts/recipe/oauth2provider/api/auth.ts | 20 ++-- .../oauth2provider/api/implementation.ts | 8 +- lib/ts/recipe/oauth2provider/api/login.ts | 12 ++- lib/ts/recipe/oauth2provider/api/utils.ts | 39 +++++-- lib/ts/recipe/oauth2provider/index.ts | 42 ++++---- lib/ts/recipe/oauth2provider/recipe.ts | 68 +++++++++++- .../oauth2provider/recipeImplementation.ts | 99 +++++++++++------ lib/ts/recipe/oauth2provider/types.ts | 42 +++++--- lib/ts/recipe/userroles/recipe.ts | 66 +++++++++++- 21 files changed, 613 insertions(+), 168 deletions(-) diff --git a/lib/build/recipe/oauth2provider/api/auth.js b/lib/build/recipe/oauth2provider/api/auth.js index 9e8b5d7a0..a064f9bb4 100644 --- a/lib/build/recipe/oauth2provider/api/auth.js +++ b/lib/build/recipe/oauth2provider/api/auth.js @@ -22,6 +22,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const utils_1 = require("../../../utils"); 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) { if (apiImplementation.authGET === undefined) { return false; @@ -29,21 +30,26 @@ async function authGET(apiImplementation, options, userContext) { const origURL = options.req.getOriginalURL(); const splitURL = origURL.split("?"); const params = new URLSearchParams(splitURL[1]); - let session; + let session, shouldTryRefresh; try { session = await session_1.default.getSession(options.req, options.res, { sessionRequired: false }, userContext); - } catch (_a) { - // We ignore this here since the authGET is called from the auth endpoint which is used in full-page redirections - // Returning a 401 would break the sign-in flow. - // In theory we could serve some JS that handles refreshing and retrying, but this is not implemented. - // What we do is that the auth endpoint will redirect to the login page, and the login page handles refreshing and - // redirect to the auth endpoint again. This is not optimal, but it works for now. + shouldTryRefresh = false; + } catch (error) { + session = undefined; + if (error_1.default.isErrorFromSuperTokens(error) && error.type === error_1.default.TRY_REFRESH_TOKEN) { + shouldTryRefresh = true; + } else { + // This should generally not happen, but we can handle this as if the session is not present, + // because then we redirect to the frontend, which should handle the validation error + shouldTryRefresh = false; + } } let response = await apiImplementation.authGET({ options, params: Object.fromEntries(params.entries()), cookie: options.req.getHeaderValue("cookie"), session, + shouldTryRefresh, userContext, }); if ("redirectTo" in response) { diff --git a/lib/build/recipe/oauth2provider/api/implementation.js b/lib/build/recipe/oauth2provider/api/implementation.js index bfb5c3ee8..fbe42c1c4 100644 --- a/lib/build/recipe/oauth2provider/api/implementation.js +++ b/lib/build/recipe/oauth2provider/api/implementation.js @@ -17,11 +17,12 @@ Object.defineProperty(exports, "__esModule", { value: true }); const utils_1 = require("./utils"); function getAPIImplementation() { return { - loginGET: async ({ loginChallenge, options, session, userContext }) => { + loginGET: async ({ loginChallenge, options, session, shouldTryRefresh, userContext }) => { const response = await utils_1.loginGET({ recipeImplementation: options.recipeImplementation, loginChallenge, session, + shouldTryRefresh, isDirectCall: true, userContext, }); @@ -30,10 +31,11 @@ function getAPIImplementation() { cookie: options.req.getHeaderValue("cookie"), recipeImplementation: options.recipeImplementation, session, + shouldTryRefresh, userContext, }); }, - authGET: async ({ options, params, cookie, session, userContext }) => { + authGET: async ({ options, params, cookie, session, shouldTryRefresh, userContext }) => { const response = await options.recipeImplementation.authorization({ params, cookies: cookie, @@ -45,6 +47,7 @@ function getAPIImplementation() { recipeImplementation: options.recipeImplementation, cookie, session, + shouldTryRefresh, userContext, }); }, @@ -63,6 +66,7 @@ function getAPIImplementation() { return { status: "OK", info: { + clientId: client.clientId, clientName: client.clientName, tosUri: client.tosUri, policyUri: client.policyUri, diff --git a/lib/build/recipe/oauth2provider/api/login.js b/lib/build/recipe/oauth2provider/api/login.js index 57dbed8c4..6ca65a5e3 100644 --- a/lib/build/recipe/oauth2provider/api/login.js +++ b/lib/build/recipe/oauth2provider/api/login.js @@ -23,18 +23,25 @@ const set_cookie_parser_1 = __importDefault(require("set-cookie-parser")); const utils_1 = require("../../../utils"); 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; if (apiImplementation.loginGET === undefined) { return false; } - let session; + let session, shouldTryRefresh; try { session = await session_1.default.getSession(options.req, options.res, { sessionRequired: false }, userContext); - } catch (_b) { + shouldTryRefresh = false; + } catch (error) { // We can handle this as if the session is not present, because then we redirect to the frontend, // which should handle the validation error session = undefined; + if (error_1.default.isErrorFromSuperTokens(error) && error.type === error_2.default.TRY_REFRESH_TOKEN) { + shouldTryRefresh = true; + } else { + shouldTryRefresh = false; + } } const loginChallenge = (_a = options.req.getKeyValueFromQuery("login_challenge")) !== null && _a !== void 0 @@ -50,6 +57,7 @@ async function login(apiImplementation, options, userContext) { options, loginChallenge, session, + shouldTryRefresh, userContext, }); if ("status" in response) { diff --git a/lib/build/recipe/oauth2provider/api/utils.d.ts b/lib/build/recipe/oauth2provider/api/utils.d.ts index 2271ba148..cff72a21a 100644 --- a/lib/build/recipe/oauth2provider/api/utils.d.ts +++ b/lib/build/recipe/oauth2provider/api/utils.d.ts @@ -5,6 +5,7 @@ import { RecipeInterface } from "../types"; export declare function loginGET({ recipeImplementation, loginChallenge, + shouldTryRefresh, session, setCookie, isDirectCall, @@ -13,6 +14,7 @@ export declare function loginGET({ recipeImplementation: RecipeInterface; loginChallenge: string; session?: SessionContainerInterface; + shouldTryRefresh: boolean; setCookie?: string; userContext: UserContext; isDirectCall: boolean; @@ -24,6 +26,7 @@ export declare function handleInternalRedirects({ response, recipeImplementation, session, + shouldTryRefresh, cookie, userContext, }: { @@ -33,6 +36,7 @@ export declare function handleInternalRedirects({ }; recipeImplementation: RecipeInterface; session?: SessionContainerInterface; + shouldTryRefresh: boolean; cookie?: string; userContext: UserContext; }): Promise<{ diff --git a/lib/build/recipe/oauth2provider/api/utils.js b/lib/build/recipe/oauth2provider/api/utils.js index b7cb5cd80..b3d9a94d3 100644 --- a/lib/build/recipe/oauth2provider/api/utils.js +++ b/lib/build/recipe/oauth2provider/api/utils.js @@ -13,7 +13,15 @@ const constants_2 = require("../constants"); const set_cookie_parser_1 = __importDefault(require("set-cookie-parser")); // API implementation for the loginGET function. // Extracted for use in both apiImplementation and handleInternalRedirects. -async function loginGET({ recipeImplementation, loginChallenge, session, setCookie, isDirectCall, userContext }) { +async function loginGET({ + recipeImplementation, + loginChallenge, + shouldTryRefresh, + session, + setCookie, + isDirectCall, + userContext, +}) { var _a, _b; const loginRequest = await recipeImplementation.getLoginRequest({ challenge: loginChallenge, @@ -80,6 +88,30 @@ async function loginGET({ recipeImplementation, loginChallenge, session, setCook }); 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, + }); + return { + redirectTo: websiteDomain + websiteBasePath + `/try-refresh?${queryParamsForTryRefreshPage.toString()}`, + setCookie, + }; + } if (promptParam === "none") { const reject = await recipeImplementation.rejectLoginRequest({ challenge: loginChallenge, @@ -92,14 +124,6 @@ async function loginGET({ recipeImplementation, loginChallenge, session, setCook }); return { redirectTo: reject.redirectTo, setCookie }; } - const appInfo = supertokens_1.default.getInstanceOrThrowError().appInfo; - const websiteDomain = appInfo - .getOrigin({ - request: undefined, - userContext: userContext, - }) - .getAsStringDangerous(); - const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); const queryParamsForAuthPage = new URLSearchParams({ loginChallenge, }); @@ -160,7 +184,14 @@ function isInternalRedirect(redirectTo) { // In the OAuth2 flow, we do several internal redirects. These redirects don't require a frontend-to-api-server round trip. // If an internal redirect is identified, it's handled directly by this function. // Currently, we only need to handle redirects to /oauth/login and /oauth/auth endpoints. -async function handleInternalRedirects({ response, recipeImplementation, session, cookie = "", userContext }) { +async function handleInternalRedirects({ + response, + recipeImplementation, + session, + shouldTryRefresh, + cookie = "", + userContext, +}) { var _a; if (!isInternalRedirect(response.redirectTo)) { return response; @@ -183,6 +214,7 @@ async function handleInternalRedirects({ response, recipeImplementation, session recipeImplementation, loginChallenge, session, + shouldTryRefresh, setCookie: response.setCookie, isDirectCall: false, userContext, diff --git a/lib/build/recipe/oauth2provider/index.js b/lib/build/recipe/oauth2provider/index.js index ac13eead6..3a769700e 100644 --- a/lib/build/recipe/oauth2provider/index.js +++ b/lib/build/recipe/oauth2provider/index.js @@ -24,29 +24,38 @@ const utils_1 = require("../../utils"); const recipe_1 = __importDefault(require("./recipe")); class Wrapper { static async getOAuth2Client(clientId, userContext) { - return await recipe_1.default - .getInstanceOrThrowError() - .recipeInterfaceImpl.getOAuth2Client({ clientId }, utils_1.getUserContext(userContext)); + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getOAuth2Client({ + clientId, + userContext: utils_1.getUserContext(userContext), + }); } static async getOAuth2Clients(input, userContext) { return await recipe_1.default .getInstanceOrThrowError() - .recipeInterfaceImpl.getOAuth2Clients(input, utils_1.getUserContext(userContext)); + .recipeInterfaceImpl.getOAuth2Clients( + Object.assign(Object.assign({}, input), { userContext: utils_1.getUserContext(userContext) }) + ); } static async createOAuth2Client(input, userContext) { return await recipe_1.default .getInstanceOrThrowError() - .recipeInterfaceImpl.createOAuth2Client(input, utils_1.getUserContext(userContext)); + .recipeInterfaceImpl.createOAuth2Client( + Object.assign(Object.assign({}, input), { userContext: utils_1.getUserContext(userContext) }) + ); } static async updateOAuth2Client(input, userContext) { return await recipe_1.default .getInstanceOrThrowError() - .recipeInterfaceImpl.updateOAuth2Client(input, utils_1.getUserContext(userContext)); + .recipeInterfaceImpl.updateOAuth2Client( + Object.assign(Object.assign({}, input), { userContext: utils_1.getUserContext(userContext) }) + ); } static async deleteOAuth2Client(input, userContext) { return await recipe_1.default .getInstanceOrThrowError() - .recipeInterfaceImpl.deleteOAuth2Client(input, utils_1.getUserContext(userContext)); + .recipeInterfaceImpl.deleteOAuth2Client( + Object.assign(Object.assign({}, input), { userContext: utils_1.getUserContext(userContext) }) + ); } static validateOAuth2AccessToken(token, requirements, checkDatabase, userContext) { return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.validateOAuth2AccessToken({ @@ -72,7 +81,7 @@ class Wrapper { let authorizationHeader = undefined; const normalisedUserContext = utils_1.getUserContext(userContext); const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; - const res = await recipeInterfaceImpl.getOAuth2Client({ clientId }, normalisedUserContext); + const res = await recipeInterfaceImpl.getOAuth2Client({ clientId, userContext: normalisedUserContext }); if (res.status !== "OK") { throw new Error(`Failed to get OAuth2 client with id ${clientId}: ${res.error}`); } diff --git a/lib/build/recipe/oauth2provider/recipe.d.ts b/lib/build/recipe/oauth2provider/recipe.d.ts index 19a890e21..25c12cc57 100644 --- a/lib/build/recipe/oauth2provider/recipe.d.ts +++ b/lib/build/recipe/oauth2provider/recipe.d.ts @@ -6,6 +6,7 @@ import RecipeModule from "../../recipeModule"; import { APIHandled, HTTPMethod, JSONObject, NormalisedAppinfo, RecipeListFunction, UserContext } from "../../types"; import { APIInterface, + PayloadBuilderFunction, RecipeInterface, TypeInput, TypeNormalisedInput, @@ -16,6 +17,7 @@ import { User } from "../../user"; export default class Recipe extends RecipeModule { static RECIPE_ID: string; private static instance; + private accessTokenBuilders; private idTokenBuilders; private userInfoBuilders; config: TypeNormalisedInput; @@ -28,6 +30,9 @@ export default class Recipe extends RecipeModule { static init(config?: TypeInput): RecipeListFunction; static reset(): void; 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, @@ -41,7 +46,18 @@ export default class Recipe extends RecipeModule { handleError(error: error, _: BaseRequest, __: BaseResponse, _userContext: UserContext): Promise; getAllCORSHeaders(): string[]; isErrorFromThisRecipe(err: any): err is error; - getDefaultIdTokenPayload(user: User, scopes: string[], userContext: UserContext): Promise; + getDefaultAccessTokenPayload( + user: User, + scopes: string[], + sessionHandle: string, + userContext: UserContext + ): Promise; + getDefaultIdTokenPayload( + user: User, + scopes: string[], + sessionHandle: string, + userContext: UserContext + ): Promise; getDefaultUserInfoPayload( user: User, accessTokenPayload: JSONObject, diff --git a/lib/build/recipe/oauth2provider/recipe.js b/lib/build/recipe/oauth2provider/recipe.js index 7f0965f78..fbd77ed7c 100644 --- a/lib/build/recipe/oauth2provider/recipe.js +++ b/lib/build/recipe/oauth2provider/recipe.js @@ -36,14 +36,27 @@ const userInfo_1 = __importDefault(require("./api/userInfo")); const combinedRemoteJWKSet_1 = require("../../combinedRemoteJWKSet"); const revokeToken_1 = __importDefault(require("./api/revokeToken")); const introspectToken_1 = __importDefault(require("./api/introspectToken")); +const 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); + this.accessTokenBuilders = []; this.idTokenBuilders = []; this.userInfoBuilders = []; this.addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn) => { this.userInfoBuilders.push(userInfoBuilderFn); }; + this.addAccessTokenBuilderFromOtherRecipe = (accessTokenBuilders) => { + this.accessTokenBuilders.push(accessTokenBuilders); + }; + 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, @@ -74,6 +87,23 @@ class Recipe extends recipeModule_1.default { if (id === constants_1.INTROSPECT_TOKEN_PATH) { return introspectToken_1.default(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); @@ -84,8 +114,10 @@ class Recipe extends recipeModule_1.default { querier_1.Querier.getNewInstanceOrThrowError(recipeId), this.config, appInfo, + this.getDefaultAccessTokenPayload.bind(this), this.getDefaultIdTokenPayload.bind(this), - this.getDefaultUserInfoPayload.bind(this) + this.getDefaultUserInfoPayload.bind(this), + this.saveTokensForHook.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); @@ -167,6 +199,13 @@ 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, + }, ]; } handleError(error, _, __, _userContext) { @@ -178,7 +217,23 @@ class Recipe extends recipeModule_1.default { isErrorFromThisRecipe(err) { return error_1.default.isErrorFromSuperTokens(err) && err.fromRecipe === Recipe.RECIPE_ID; } - async getDefaultIdTokenPayload(user, scopes, userContext) { + async getDefaultAccessTokenPayload(user, scopes, sessionHandle, userContext) { + const sessionInfo = await session_1.getSessionInformation(sessionHandle); + 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, + }; + 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(), }; @@ -197,7 +252,7 @@ class Recipe extends recipeModule_1.default { ); } for (const fn of this.idTokenBuilders) { - payload = Object.assign(Object.assign({}, payload), await fn(user, scopes, userContext)); + payload = Object.assign(Object.assign({}, payload), await fn(user, scopes, sessionHandle, userContext)); } return payload; } diff --git a/lib/build/recipe/oauth2provider/recipeImplementation.d.ts b/lib/build/recipe/oauth2provider/recipeImplementation.d.ts index 3d7982106..3b6fee065 100644 --- a/lib/build/recipe/oauth2provider/recipeImplementation.d.ts +++ b/lib/build/recipe/oauth2provider/recipeImplementation.d.ts @@ -1,11 +1,13 @@ // @ts-nocheck import { Querier } from "../../querier"; -import { NormalisedAppinfo } from "../../types"; +import { JSONObject, NormalisedAppinfo } from "../../types"; import { RecipeInterface, TypeNormalisedInput, PayloadBuilderFunction, UserInfoBuilderFunction } from "./types"; export default function getRecipeInterface( querier: Querier, _config: TypeNormalisedInput, appInfo: NormalisedAppinfo, + getDefaultAccessTokenPayload: PayloadBuilderFunction, getDefaultIdTokenPayload: PayloadBuilderFunction, - getDefaultUserInfoPayload: UserInfoBuilderFunction + getDefaultUserInfoPayload: UserInfoBuilderFunction, + saveTokensForHook: (sessionHandle: string, idToken: JSONObject, accessToken: JSONObject) => void ): RecipeInterface; diff --git a/lib/build/recipe/oauth2provider/recipeImplementation.js b/lib/build/recipe/oauth2provider/recipeImplementation.js index bf043da11..c0371d998 100644 --- a/lib/build/recipe/oauth2provider/recipeImplementation.js +++ b/lib/build/recipe/oauth2provider/recipeImplementation.js @@ -72,7 +72,15 @@ function getUpdatedRedirectTo(appInfo, redirectTo) { ) .replace("oauth2/", "oauth/"); } -function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, getDefaultUserInfoPayload) { +function getRecipeInterface( + querier, + _config, + appInfo, + getDefaultAccessTokenPayload, + getDefaultIdTokenPayload, + getDefaultUserInfoPayload, + saveTokensForHook +) { return { getLoginRequest: async function (input) { const resp = await querier.sendGetRequest( @@ -227,17 +235,17 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, if (!user) { throw new Error("Should not happen"); } - const idToken = this.buildIdTokenPayload({ + const idToken = await this.buildIdTokenPayload({ user, client: consentRequest.client, - session: input.session, + sessionHandle: input.session.getHandle(), scopes: consentRequest.requestedScope || [], userContext: input.userContext, }); const accessTokenPayload = await this.buildAccessTokenPayload({ user, client: consentRequest.client, - session: input.session, + sessionHandle: input.session.getHandle(), scopes: consentRequest.requestedScope || [], userContext: input.userContext, }); @@ -269,10 +277,61 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, }; }, 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") { + const scopes = + (_b = (_a = input.body.scope) === null || _a === void 0 ? void 0 : _a.split(" ")) !== null && + _b !== void 0 + ? _b + : []; + const tokenInfo = await this.introspectToken({ + token: input.body.refresh_token, + scopes, + userContext: input.userContext, + }); + if (tokenInfo.active === true) { + const sessionHandle = tokenInfo.ext.sessionHandle; + const clientInfo = await this.getOAuth2Client({ + clientId: tokenInfo.client_id, + userContext: input.userContext, + }); + if (clientInfo.status === "ERROR") { + return { + statusCode: 400, + error: clientInfo.error, + errorDescription: clientInfo.errorHint, + }; + } + const client = clientInfo.client; + const user = await __1.getUser(tokenInfo.sub); + if (!user) { + throw new Error("User not found"); + } + const idToken = await this.buildIdTokenPayload({ + user, + client, + sessionHandle: sessionHandle, + scopes, + userContext: input.userContext, + }); + const accessTokenPayload = 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; } @@ -290,7 +349,7 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, } return res.data; }, - getOAuth2Clients: async function (input, userContext) { + getOAuth2Clients: async function (input) { var _a; let response = await querier.sendGetRequestWithResponseHeaders( new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients`), @@ -298,7 +357,7 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, page_token: input.paginationToken, }), {}, - userContext + input.userContext ); if (response.body.status === "OK") { // Pagination info is in the Link header, containing comma-separated links: @@ -326,12 +385,12 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, }; } }, - getOAuth2Client: async function (input, userContext) { + getOAuth2Client: async function (input) { let response = await querier.sendGetRequestWithResponseHeaders( new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients/${input.clientId}`), {}, {}, - userContext + input.userContext ); if (response.body.status === "OK") { return { @@ -346,7 +405,7 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, }; } }, - createOAuth2Client: async function (input, userContext) { + 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")), { @@ -355,7 +414,7 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, skip_consent: true, subject_type: "public", }), - userContext + input.userContext ); if (response.status === "OK") { return { @@ -370,7 +429,7 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, }; } }, - updateOAuth2Client: async function (input, userContext) { + 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({ @@ -384,7 +443,7 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, let response = await querier.sendPatchRequest( new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients/${input.clientId}`), requestBody, - userContext + input.userContext ); if (response.status === "OK") { return { @@ -399,12 +458,12 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, }; } }, - deleteOAuth2Client: async function (input, userContext) { + deleteOAuth2Client: async function (input) { let response = await querier.sendDeleteRequest( new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients/${input.clientId}`), undefined, undefined, - userContext + input.userContext ); if (response.status === "OK") { return { status: "OK" }; @@ -417,21 +476,10 @@ function getRecipeInterface(querier, _config, appInfo, getDefaultIdTokenPayload, } }, buildAccessTokenPayload: async function (input) { - const stAccessTokenPayload = input.session.getAccessTokenPayload(input.userContext); - const sessionInfo = await session_1.getSessionInformation(stAccessTokenPayload.sessionHandle); - if (sessionInfo === undefined) { - throw new Error("Session not found"); - } - return { - tId: stAccessTokenPayload.tId, - rsub: stAccessTokenPayload.rsub, - sessionHandle: stAccessTokenPayload.sessionHandle, - // auth_time: sessionInfo?.timeCreated, - iss: appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(), - }; + return getDefaultAccessTokenPayload(input.user, input.scopes, input.sessionHandle, input.userContext); }, buildIdTokenPayload: async function (input) { - return getDefaultIdTokenPayload(input.user, input.scopes, input.userContext); + 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); diff --git a/lib/build/recipe/oauth2provider/types.d.ts b/lib/build/recipe/oauth2provider/types.d.ts index da01b04b8..d8569f07d 100644 --- a/lib/build/recipe/oauth2provider/types.d.ts +++ b/lib/build/recipe/oauth2provider/types.d.ts @@ -70,6 +70,7 @@ export declare type TokenInfo = { token_type: string; }; export declare type LoginInfo = { + clientId: string; clientName: string; tosUri?: string; policyUri?: string; @@ -151,10 +152,10 @@ export declare type RecipeInterface = { }): Promise<{ redirectTo: string; }>; - getOAuth2Client( - input: Pick, - userContext: UserContext - ): Promise< + getOAuth2Client(input: { + clientId: string; + userContext: UserContext; + }): Promise< | { status: "OK"; client: OAuth2Client; @@ -166,8 +167,9 @@ export declare type RecipeInterface = { } >; getOAuth2Clients( - input: GetOAuth2ClientsInput, - userContext: UserContext + input: GetOAuth2ClientsInput & { + userContext: UserContext; + } ): Promise< | { status: "OK"; @@ -181,8 +183,9 @@ export declare type RecipeInterface = { } >; createOAuth2Client( - input: CreateOAuth2ClientInput, - userContext: UserContext + input: CreateOAuth2ClientInput & { + userContext: UserContext; + } ): Promise< | { status: "OK"; @@ -195,8 +198,9 @@ export declare type RecipeInterface = { } >; updateOAuth2Client( - input: UpdateOAuth2ClientInput, - userContext: UserContext + input: UpdateOAuth2ClientInput & { + userContext: UserContext; + } ): Promise< | { status: "OK"; @@ -209,8 +213,9 @@ export declare type RecipeInterface = { } >; deleteOAuth2Client( - input: DeleteOAuth2ClientInput, - userContext: UserContext + input: DeleteOAuth2ClientInput & { + userContext: UserContext; + } ): Promise< | { status: "OK"; @@ -237,14 +242,14 @@ export declare type RecipeInterface = { buildAccessTokenPayload(input: { user: User; client: OAuth2Client; - session: SessionContainerInterface; + sessionHandle: string; scopes: string[]; userContext: UserContext; }): Promise; buildIdTokenPayload(input: { user: User; client: OAuth2Client; - session: SessionContainerInterface; + sessionHandle: string; scopes: string[]; userContext: UserContext; }): Promise; @@ -287,6 +292,7 @@ export declare type APIInterface = { loginChallenge: string; options: APIOptions; session?: SessionContainerInterface; + shouldTryRefresh: boolean; userContext: UserContext; }) => Promise< | { @@ -301,6 +307,7 @@ export declare type APIInterface = { params: any; cookie: string | undefined; session: SessionContainerInterface | undefined; + shouldTryRefresh: boolean; options: APIOptions; userContext: UserContext; }) => Promise< @@ -437,6 +444,7 @@ export declare type DeleteOAuth2ClientInput = { export declare type PayloadBuilderFunction = ( user: User, scopes: string[], + sessionHandle: string, userContext: UserContext ) => Promise; export declare type UserInfoBuilderFunction = ( diff --git a/lib/build/recipe/userroles/recipe.js b/lib/build/recipe/userroles/recipe.js index f9829586d..c341f1c19 100644 --- a/lib/build/recipe/userroles/recipe.js +++ b/lib/build/recipe/userroles/recipe.js @@ -30,6 +30,7 @@ const recipe_1 = __importDefault(require("../session/recipe")); const recipe_2 = __importDefault(require("../oauth2provider/recipe")); const userRoleClaim_1 = require("./userRoleClaim"); const permissionClaim_1 = require("./permissionClaim"); +const session_1 = require("../session"); class Recipe extends recipeModule_1.default { constructor(recipeId, appInfo, isInServerlessEnv, config) { super(recipeId, appInfo); @@ -52,6 +53,44 @@ class Recipe extends recipeModule_1.default { if (!this.config.skipAddingPermissionsToAccessToken) { recipe_1.default.getInstanceOrThrowError().addClaimFromOtherRecipe(permissionClaim_1.PermissionClaim); } + const tokenPayloadBuilder = async (user, scopes, sessionHandle, userContext) => { + let payload = {}; + const sessionInfo = await session_1.getSessionInformation(sessionHandle, userContext); + let userRoles = []; + if (scopes.includes("roles") || scopes.includes("permissions")) { + const res = await this.recipeInterfaceImpl.getRolesForUser({ + userId: user.id, + tenantId: sessionInfo.tenantId, + userContext, + }); + if (res.status !== "OK") { + throw new Error("Failed to fetch roles for the user"); + } + userRoles = res.roles; + } + if (scopes.includes("roles")) { + payload.roles = userRoles; + } + if (scopes.includes("permissions")) { + const userPermissions = new Set(); + for (const role of userRoles) { + const rolePermissions = await this.recipeInterfaceImpl.getPermissionsForRole({ + role, + userContext, + }); + if (rolePermissions.status !== "OK") { + throw new Error("Failed to fetch permissions for the role"); + } + for (const perm of rolePermissions.permissions) { + userPermissions.add(perm); + } + } + payload.permissions = Array.from(userPermissions); + } + return payload; + }; + recipe_2.default.getInstanceOrThrowError().addAccessTokenBuilderFromOtherRecipe(tokenPayloadBuilder); + recipe_2.default.getInstanceOrThrowError().addIdTokenBuilderFromOtherRecipe(tokenPayloadBuilder); recipe_2.default .getInstanceOrThrowError() .addUserInfoBuilderFromOtherRecipe(async (user, _accessTokenPayload, scopes, tenantId, userContext) => { @@ -85,7 +124,7 @@ class Recipe extends recipeModule_1.default { userPermissions.add(perm); } } - userInfo.permissons = Array.from(userPermissions); + userInfo.permissions = Array.from(userPermissions); } return userInfo; }); diff --git a/lib/ts/recipe/oauth2provider/api/auth.ts b/lib/ts/recipe/oauth2provider/api/auth.ts index 3a75fd563..c6fc92e10 100644 --- a/lib/ts/recipe/oauth2provider/api/auth.ts +++ b/lib/ts/recipe/oauth2provider/api/auth.ts @@ -18,6 +18,7 @@ import { APIInterface, APIOptions } from ".."; import { UserContext } from "../../../types"; import setCookieParser from "set-cookie-parser"; import Session from "../../session"; +import SessionError from "../../../recipe/session/error"; export default async function authGET( apiImplementation: APIInterface, @@ -30,15 +31,19 @@ export default async function authGET( const origURL = options.req.getOriginalURL(); const splitURL = origURL.split("?"); const params = new URLSearchParams(splitURL[1]); - let session; + let session, shouldTryRefresh; try { session = await Session.getSession(options.req, options.res, { sessionRequired: false }, userContext); - } catch { - // We ignore this here since the authGET is called from the auth endpoint which is used in full-page redirections - // Returning a 401 would break the sign-in flow. - // In theory we could serve some JS that handles refreshing and retrying, but this is not implemented. - // What we do is that the auth endpoint will redirect to the login page, and the login page handles refreshing and - // redirect to the auth endpoint again. This is not optimal, but it works for now. + shouldTryRefresh = false; + } catch (error) { + session = undefined; + if (SessionError.isErrorFromSuperTokens(error) && error.type === SessionError.TRY_REFRESH_TOKEN) { + shouldTryRefresh = true; + } else { + // This should generally not happen, but we can handle this as if the session is not present, + // because then we redirect to the frontend, which should handle the validation error + shouldTryRefresh = false; + } } let response = await apiImplementation.authGET({ @@ -46,6 +51,7 @@ export default async function authGET( params: Object.fromEntries(params.entries()), cookie: options.req.getHeaderValue("cookie"), session, + shouldTryRefresh, userContext, }); if ("redirectTo" in response) { diff --git a/lib/ts/recipe/oauth2provider/api/implementation.ts b/lib/ts/recipe/oauth2provider/api/implementation.ts index 0e3bb71bc..6a09e7316 100644 --- a/lib/ts/recipe/oauth2provider/api/implementation.ts +++ b/lib/ts/recipe/oauth2provider/api/implementation.ts @@ -18,11 +18,12 @@ import { handleInternalRedirects, loginGET } from "./utils"; export default function getAPIImplementation(): APIInterface { return { - loginGET: async ({ loginChallenge, options, session, userContext }) => { + loginGET: async ({ loginChallenge, options, session, shouldTryRefresh, userContext }) => { const response = await loginGET({ recipeImplementation: options.recipeImplementation, loginChallenge, session, + shouldTryRefresh, isDirectCall: true, userContext, }); @@ -31,11 +32,12 @@ export default function getAPIImplementation(): APIInterface { cookie: options.req.getHeaderValue("cookie"), recipeImplementation: options.recipeImplementation, session, + shouldTryRefresh, userContext, }); }, - authGET: async ({ options, params, cookie, session, userContext }) => { + authGET: async ({ options, params, cookie, session, shouldTryRefresh, userContext }) => { const response = await options.recipeImplementation.authorization({ params, cookies: cookie, @@ -48,6 +50,7 @@ export default function getAPIImplementation(): APIInterface { recipeImplementation: options.recipeImplementation, cookie, session, + shouldTryRefresh, userContext, }); }, @@ -67,6 +70,7 @@ export default function getAPIImplementation(): APIInterface { return { status: "OK", info: { + clientId: client.clientId, clientName: client.clientName, tosUri: client.tosUri, policyUri: client.policyUri, diff --git a/lib/ts/recipe/oauth2provider/api/login.ts b/lib/ts/recipe/oauth2provider/api/login.ts index 78ef67780..9284c1e29 100644 --- a/lib/ts/recipe/oauth2provider/api/login.ts +++ b/lib/ts/recipe/oauth2provider/api/login.ts @@ -19,6 +19,7 @@ import { APIInterface, APIOptions } from ".."; import Session from "../../session"; import { UserContext } from "../../../types"; import SuperTokensError from "../../../error"; +import SessionError from "../../../recipe/session/error"; export default async function login( apiImplementation: APIInterface, @@ -29,13 +30,19 @@ export default async function login( return false; } - let session; + let session, shouldTryRefresh; try { session = await Session.getSession(options.req, options.res, { sessionRequired: false }, userContext); - } catch { + shouldTryRefresh = false; + } catch (error) { // We can handle this as if the session is not present, because then we redirect to the frontend, // which should handle the validation error session = undefined; + if (SuperTokensError.isErrorFromSuperTokens(error) && error.type === SessionError.TRY_REFRESH_TOKEN) { + shouldTryRefresh = true; + } else { + shouldTryRefresh = false; + } } const loginChallenge = @@ -50,6 +57,7 @@ export default async function login( options, loginChallenge, session, + shouldTryRefresh, userContext, }); if ("status" in response) { diff --git a/lib/ts/recipe/oauth2provider/api/utils.ts b/lib/ts/recipe/oauth2provider/api/utils.ts index 735979690..d0b2b088d 100644 --- a/lib/ts/recipe/oauth2provider/api/utils.ts +++ b/lib/ts/recipe/oauth2provider/api/utils.ts @@ -12,6 +12,7 @@ import setCookieParser from "set-cookie-parser"; export async function loginGET({ recipeImplementation, loginChallenge, + shouldTryRefresh, session, setCookie, isDirectCall, @@ -20,6 +21,7 @@ export async function loginGET({ recipeImplementation: RecipeInterface; loginChallenge: string; session?: SessionContainerInterface; + shouldTryRefresh: boolean; setCookie?: string; userContext: UserContext; isDirectCall: boolean; @@ -83,6 +85,32 @@ 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, + }); + + return { + redirectTo: websiteDomain + websiteBasePath + `/try-refresh?${queryParamsForTryRefreshPage.toString()}`, + setCookie, + }; + } if (promptParam === "none") { const reject = await recipeImplementation.rejectLoginRequest({ challenge: loginChallenge, @@ -95,14 +123,6 @@ export async function loginGET({ }); return { redirectTo: reject.redirectTo, setCookie }; } - const appInfo = SuperTokens.getInstanceOrThrowError().appInfo; - const websiteDomain = appInfo - .getOrigin({ - request: undefined, - userContext: userContext, - }) - .getAsStringDangerous(); - const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); const queryParamsForAuthPage = new URLSearchParams({ loginChallenge, @@ -179,12 +199,14 @@ export async function handleInternalRedirects({ response, recipeImplementation, session, + shouldTryRefresh, cookie = "", userContext, }: { response: { redirectTo: string; setCookie: string | undefined }; recipeImplementation: RecipeInterface; session?: SessionContainerInterface; + shouldTryRefresh: boolean; cookie?: string; userContext: UserContext; }): Promise<{ redirectTo: string; setCookie: string | undefined }> { @@ -213,6 +235,7 @@ export async function handleInternalRedirects({ recipeImplementation, loginChallenge, session, + shouldTryRefresh, setCookie: response.setCookie, isDirectCall: false, userContext, diff --git a/lib/ts/recipe/oauth2provider/index.ts b/lib/ts/recipe/oauth2provider/index.ts index 9fb20b0ba..01424b051 100644 --- a/lib/ts/recipe/oauth2provider/index.ts +++ b/lib/ts/recipe/oauth2provider/index.ts @@ -29,34 +29,34 @@ export default class Wrapper { static init = Recipe.init; static async getOAuth2Client(clientId: string, userContext?: Record) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getOAuth2Client( - { clientId }, - getUserContext(userContext) - ); + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getOAuth2Client({ + clientId, + userContext: getUserContext(userContext), + }); } static async getOAuth2Clients(input: GetOAuth2ClientsInput, userContext?: Record) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getOAuth2Clients( - input, - getUserContext(userContext) - ); + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getOAuth2Clients({ + ...input, + userContext: getUserContext(userContext), + }); } static async createOAuth2Client(input: CreateOAuth2ClientInput, userContext?: Record) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.createOAuth2Client( - input, - getUserContext(userContext) - ); + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.createOAuth2Client({ + ...input, + userContext: getUserContext(userContext), + }); } static async updateOAuth2Client(input: UpdateOAuth2ClientInput, userContext?: Record) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.updateOAuth2Client( - input, - getUserContext(userContext) - ); + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.updateOAuth2Client({ + ...input, + userContext: getUserContext(userContext), + }); } static async deleteOAuth2Client(input: DeleteOAuth2ClientInput, userContext?: Record) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.deleteOAuth2Client( - input, - getUserContext(userContext) - ); + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.deleteOAuth2Client({ + ...input, + userContext: getUserContext(userContext), + }); } static validateOAuth2AccessToken( @@ -107,7 +107,7 @@ export default class Wrapper { const normalisedUserContext = getUserContext(userContext); const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; - const res = await recipeInterfaceImpl.getOAuth2Client({ clientId }, normalisedUserContext); + const res = await recipeInterfaceImpl.getOAuth2Client({ clientId, userContext: normalisedUserContext }); if (res.status !== "OK") { throw new Error(`Failed to get OAuth2 client with id ${clientId}: ${res.error}`); diff --git a/lib/ts/recipe/oauth2provider/recipe.ts b/lib/ts/recipe/oauth2provider/recipe.ts index f671b7b9b..054c44281 100644 --- a/lib/ts/recipe/oauth2provider/recipe.ts +++ b/lib/ts/recipe/oauth2provider/recipe.ts @@ -51,10 +51,15 @@ import userInfoGET from "./api/userInfo"; import { resetCombinedJWKS } from "../../combinedRemoteJWKSet"; import revokeTokenPOST from "./api/revokeToken"; import introspectTokenPOST from "./api/introspectToken"; +import { getSessionInformation } from "../session"; +import { send200Response } from "../../utils"; + +const tokenHookMap = new Map(); export default class Recipe extends RecipeModule { static RECIPE_ID = "oauth2provider"; private static instance: Recipe | undefined = undefined; + private accessTokenBuilders: PayloadBuilderFunction[] = []; private idTokenBuilders: PayloadBuilderFunction[] = []; private userInfoBuilders: UserInfoBuilderFunction[] = []; @@ -74,8 +79,10 @@ export default class Recipe extends RecipeModule { Querier.getNewInstanceOrThrowError(recipeId), this.config, appInfo, + this.getDefaultAccessTokenPayload.bind(this), this.getDefaultIdTokenPayload.bind(this), - this.getDefaultUserInfoPayload.bind(this) + this.getDefaultUserInfoPayload.bind(this), + this.saveTokensForHook.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); @@ -120,6 +127,15 @@ export default class Recipe extends RecipeModule { addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn: UserInfoBuilderFunction) => { this.userInfoBuilders.push(userInfoBuilderFn); }; + addAccessTokenBuilderFromOtherRecipe = (accessTokenBuilders: PayloadBuilderFunction) => { + this.accessTokenBuilders.push(accessTokenBuilders); + }; + addIdTokenBuilderFromOtherRecipe = (idTokenBuilder: PayloadBuilderFunction) => { + this.idTokenBuilders.push(idTokenBuilder); + }; + saveTokensForHook = (sessionHandle: string, idToken: JSONObject, accessToken: JSONObject) => { + tokenHookMap.set(sessionHandle, { idToken, accessToken }); + }; /* RecipeModule functions */ @@ -167,6 +183,13 @@ 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, + }, ]; } @@ -209,6 +232,24 @@ export default class Recipe extends RecipeModule { if (id === INTROSPECT_TOKEN_PATH) { return introspectTokenPOST(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"); }; @@ -224,7 +265,28 @@ export default class Recipe extends RecipeModule { return SuperTokensError.isErrorFromSuperTokens(err) && err.fromRecipe === Recipe.RECIPE_ID; } - async getDefaultIdTokenPayload(user: User, scopes: string[], userContext: UserContext) { + async getDefaultAccessTokenPayload(user: User, scopes: string[], sessionHandle: string, userContext: UserContext) { + const sessionInfo = await getSessionInformation(sessionHandle); + 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, + }; + + for (const fn of this.accessTokenBuilders) { + payload = { + ...payload, + ...(await fn(user, scopes, sessionHandle, userContext)), + }; + } + + return payload; + } + async getDefaultIdTokenPayload(user: User, scopes: string[], sessionHandle: string, userContext: UserContext) { let payload: JSONObject = { iss: this.appInfo.apiDomain.getAsStringDangerous() + this.appInfo.apiBasePath.getAsStringDangerous(), }; @@ -242,7 +304,7 @@ export default class Recipe extends RecipeModule { for (const fn of this.idTokenBuilders) { payload = { ...payload, - ...(await fn(user, scopes, userContext)), + ...(await fn(user, scopes, sessionHandle, userContext)), }; } diff --git a/lib/ts/recipe/oauth2provider/recipeImplementation.ts b/lib/ts/recipe/oauth2provider/recipeImplementation.ts index f870812d6..77aa57abc 100644 --- a/lib/ts/recipe/oauth2provider/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2provider/recipeImplementation.ts @@ -16,7 +16,7 @@ import * as jose from "jose"; import NormalisedURLPath from "../../normalisedURLPath"; import { Querier, hydraPubDomain } from "../../querier"; -import { NormalisedAppinfo } from "../../types"; +import { JSONObject, NormalisedAppinfo } from "../../types"; import { RecipeInterface, TypeNormalisedInput, @@ -29,7 +29,6 @@ import { toSnakeCase, transformObjectKeys } from "../../utils"; import { OAuth2Client } from "./OAuth2Client"; import { getUser } from "../.."; import { getCombinedJWKS } from "../../combinedRemoteJWKSet"; -import { JSONObject } from "@loopback/core"; import { getSessionInformation } from "../session"; // TODO: Remove this core changes are done @@ -43,8 +42,10 @@ export default function getRecipeInterface( querier: Querier, _config: TypeNormalisedInput, appInfo: NormalisedAppinfo, + getDefaultAccessTokenPayload: PayloadBuilderFunction, getDefaultIdTokenPayload: PayloadBuilderFunction, - getDefaultUserInfoPayload: UserInfoBuilderFunction + getDefaultUserInfoPayload: UserInfoBuilderFunction, + saveTokensForHook: (sessionHandle: string, idToken: JSONObject, accessToken: JSONObject) => void ): RecipeInterface { return { getLoginRequest: async function (this: RecipeInterface, input): Promise { @@ -209,18 +210,17 @@ export default function getRecipeInterface( if (!user) { throw new Error("Should not happen"); } - const idToken = this.buildIdTokenPayload({ + const idToken = await this.buildIdTokenPayload({ user, client: consentRequest.client!, - session: input.session, + sessionHandle: input.session.getHandle(), scopes: consentRequest.requestedScope || [], userContext: input.userContext, }); - const accessTokenPayload = await this.buildAccessTokenPayload({ user, client: consentRequest.client!, - session: input.session, + sessionHandle: input.session.getHandle(), scopes: consentRequest.requestedScope || [], userContext: input.userContext, }); @@ -257,6 +257,56 @@ export default function getRecipeInterface( body[key] = input.body[key]; } + if (input.body.grant_type === "refresh_token") { + const scopes = input.body.scope?.split(" ") ?? []; + const tokenInfo = await this.introspectToken({ + token: input.body.refresh_token!, + scopes, + userContext: input.userContext, + }); + + if (tokenInfo.active === true) { + const sessionHandle = (tokenInfo.ext as any).sessionHandle as string; + + const clientInfo = await this.getOAuth2Client({ + clientId: tokenInfo.client_id as string, + userContext: input.userContext, + }); + if (clientInfo.status === "ERROR") { + return { + statusCode: 400, + error: clientInfo.error, + errorDescription: clientInfo.errorHint, + }; + } + const client = clientInfo.client; + const user = await getUser(tokenInfo.sub as string); + if (!user) { + throw new Error("User not found"); + } + const idToken = await this.buildIdTokenPayload({ + user, + client, + sessionHandle: sessionHandle, + scopes, + userContext: input.userContext, + }); + const accessTokenPayload = 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; } @@ -277,7 +327,7 @@ export default function getRecipeInterface( return res.data; }, - getOAuth2Clients: async function (input, userContext) { + getOAuth2Clients: async function (input) { let response = await querier.sendGetRequestWithResponseHeaders( new NormalisedURLPath(`/recipe/oauth2/admin/clients`), { @@ -285,7 +335,7 @@ export default function getRecipeInterface( page_token: input.paginationToken, }, {}, - userContext + input.userContext ); if (response.body.status === "OK") { @@ -317,12 +367,12 @@ export default function getRecipeInterface( }; } }, - getOAuth2Client: async function (input, userContext) { + getOAuth2Client: async function (input) { let response = await querier.sendGetRequestWithResponseHeaders( new NormalisedURLPath(`/recipe/oauth2/admin/clients/${input.clientId}`), {}, {}, - userContext + input.userContext ); if (response.body.status === "OK") { @@ -338,7 +388,7 @@ export default function getRecipeInterface( }; } }, - createOAuth2Client: async function (input, userContext) { + createOAuth2Client: async function (input) { let response = await querier.sendPostRequest( new NormalisedURLPath(`/recipe/oauth2/admin/clients`), { @@ -348,7 +398,7 @@ export default function getRecipeInterface( skip_consent: true, subject_type: "public", }, - userContext + input.userContext ); if (response.status === "OK") { @@ -364,7 +414,7 @@ export default function getRecipeInterface( }; } }, - updateOAuth2Client: async function (input, userContext) { + 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 }> @@ -381,7 +431,7 @@ export default function getRecipeInterface( let response = await querier.sendPatchRequest( new NormalisedURLPath(`/recipe/oauth2/admin/clients/${input.clientId}`), requestBody, - userContext + input.userContext ); if (response.status === "OK") { @@ -397,12 +447,12 @@ export default function getRecipeInterface( }; } }, - deleteOAuth2Client: async function (input, userContext) { + deleteOAuth2Client: async function (input) { let response = await querier.sendDeleteRequest( new NormalisedURLPath(`/recipe/oauth2/admin/clients/${input.clientId}`), undefined, undefined, - userContext + input.userContext ); if (response.status === "OK") { @@ -416,21 +466,10 @@ export default function getRecipeInterface( } }, buildAccessTokenPayload: async function (input) { - const stAccessTokenPayload = input.session.getAccessTokenPayload(input.userContext); - const sessionInfo = await getSessionInformation(stAccessTokenPayload.sessionHandle); - if (sessionInfo === undefined) { - throw new Error("Session not found"); - } - return { - tId: stAccessTokenPayload.tId, - rsub: stAccessTokenPayload.rsub, - sessionHandle: stAccessTokenPayload.sessionHandle, - // auth_time: sessionInfo?.timeCreated, - iss: appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(), - }; + return getDefaultAccessTokenPayload(input.user, input.scopes, input.sessionHandle, input.userContext); }, buildIdTokenPayload: async function (input) { - return getDefaultIdTokenPayload(input.user, input.scopes, input.userContext); + 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); diff --git a/lib/ts/recipe/oauth2provider/types.ts b/lib/ts/recipe/oauth2provider/types.ts index 255966931..2bd79ef3f 100644 --- a/lib/ts/recipe/oauth2provider/types.ts +++ b/lib/ts/recipe/oauth2provider/types.ts @@ -148,6 +148,7 @@ export type TokenInfo = { }; export type LoginInfo = { + clientId: string; // The name of the client. clientName: string; // The URI of the client's terms of service. @@ -259,10 +260,10 @@ export type RecipeInterface = { userContext: UserContext; }): Promise<{ redirectTo: string }>; - getOAuth2Client( - input: Pick, - userContext: UserContext - ): Promise< + getOAuth2Client(input: { + clientId: string; + userContext: UserContext; + }): Promise< | { status: "OK"; client: OAuth2Client; @@ -275,8 +276,9 @@ export type RecipeInterface = { } >; getOAuth2Clients( - input: GetOAuth2ClientsInput, - userContext: UserContext + input: GetOAuth2ClientsInput & { + userContext: UserContext; + } ): Promise< | { status: "OK"; @@ -291,8 +293,9 @@ export type RecipeInterface = { } >; createOAuth2Client( - input: CreateOAuth2ClientInput, - userContext: UserContext + input: CreateOAuth2ClientInput & { + userContext: UserContext; + } ): Promise< | { status: "OK"; @@ -306,8 +309,9 @@ export type RecipeInterface = { } >; updateOAuth2Client( - input: UpdateOAuth2ClientInput, - userContext: UserContext + input: UpdateOAuth2ClientInput & { + userContext: UserContext; + } ): Promise< | { status: "OK"; @@ -321,8 +325,9 @@ export type RecipeInterface = { } >; deleteOAuth2Client( - input: DeleteOAuth2ClientInput, - userContext: UserContext + input: DeleteOAuth2ClientInput & { + userContext: UserContext; + } ): Promise< | { status: "OK"; @@ -349,14 +354,14 @@ export type RecipeInterface = { buildAccessTokenPayload(input: { user: User; client: OAuth2Client; - session: SessionContainerInterface; + sessionHandle: string; scopes: string[]; userContext: UserContext; }): Promise; buildIdTokenPayload(input: { user: User; client: OAuth2Client; - session: SessionContainerInterface; + sessionHandle: string; scopes: string[]; userContext: UserContext; }): Promise; @@ -392,6 +397,7 @@ export type APIInterface = { loginChallenge: string; options: APIOptions; session?: SessionContainerInterface; + shouldTryRefresh: boolean; userContext: UserContext; }) => Promise<{ redirectTo: string; setCookie: string | undefined } | GeneralErrorResponse>); @@ -401,6 +407,7 @@ export type APIInterface = { params: any; cookie: string | undefined; session: SessionContainerInterface | undefined; + shouldTryRefresh: boolean; options: APIOptions; userContext: UserContext; }) => Promise<{ redirectTo: string; setCookie: string | undefined } | ErrorOAuth2 | GeneralErrorResponse>); @@ -523,7 +530,12 @@ export type DeleteOAuth2ClientInput = { clientId: string; }; -export type PayloadBuilderFunction = (user: User, scopes: string[], userContext: UserContext) => Promise; +export type PayloadBuilderFunction = ( + user: User, + scopes: string[], + sessionHandle: string, + userContext: UserContext +) => Promise; export type UserInfoBuilderFunction = ( user: User, accessTokenPayload: JSONObject, diff --git a/lib/ts/recipe/userroles/recipe.ts b/lib/ts/recipe/userroles/recipe.ts index 07583783f..36befb4f2 100644 --- a/lib/ts/recipe/userroles/recipe.ts +++ b/lib/ts/recipe/userroles/recipe.ts @@ -19,7 +19,7 @@ import type { BaseRequest, BaseResponse } from "../../framework"; import normalisedURLPath from "../../normalisedURLPath"; import { Querier } from "../../querier"; import RecipeModule from "../../recipeModule"; -import { APIHandled, HTTPMethod, NormalisedAppinfo, RecipeListFunction } from "../../types"; +import { APIHandled, HTTPMethod, NormalisedAppinfo, RecipeListFunction, UserContext } from "../../types"; import RecipeImplementation from "./recipeImplementation"; import { RecipeInterface, TypeInput, TypeNormalisedInput } from "./types"; @@ -30,6 +30,8 @@ import SessionRecipe from "../session/recipe"; import OAuth2Recipe from "../oauth2provider/recipe"; import { UserRoleClaim } from "./userRoleClaim"; import { PermissionClaim } from "./permissionClaim"; +import { User } from "../../user"; +import { getSessionInformation } from "../session"; export default class Recipe extends RecipeModule { static RECIPE_ID = "userroles"; @@ -57,11 +59,69 @@ export default class Recipe extends RecipeModule { SessionRecipe.getInstanceOrThrowError().addClaimFromOtherRecipe(PermissionClaim); } + const tokenPayloadBuilder = async ( + user: User, + scopes: string[], + sessionHandle: string, + userContext: UserContext + ) => { + let payload: { + roles?: string[]; + permissions?: string[]; + } = {}; + + const sessionInfo = await getSessionInformation(sessionHandle, userContext); + + let userRoles: string[] = []; + + if (scopes.includes("roles") || scopes.includes("permissions")) { + const res = await this.recipeInterfaceImpl.getRolesForUser({ + userId: user.id, + tenantId: sessionInfo!.tenantId, + userContext, + }); + + if (res.status !== "OK") { + throw new Error("Failed to fetch roles for the user"); + } + userRoles = res.roles; + } + + if (scopes.includes("roles")) { + payload.roles = userRoles; + } + + if (scopes.includes("permissions")) { + const userPermissions = new Set(); + for (const role of userRoles) { + const rolePermissions = await this.recipeInterfaceImpl.getPermissionsForRole({ + role, + userContext, + }); + + if (rolePermissions.status !== "OK") { + throw new Error("Failed to fetch permissions for the role"); + } + + for (const perm of rolePermissions.permissions) { + userPermissions.add(perm); + } + } + + payload.permissions = Array.from(userPermissions); + } + + return payload; + }; + + OAuth2Recipe.getInstanceOrThrowError().addAccessTokenBuilderFromOtherRecipe(tokenPayloadBuilder); + OAuth2Recipe.getInstanceOrThrowError().addIdTokenBuilderFromOtherRecipe(tokenPayloadBuilder); + OAuth2Recipe.getInstanceOrThrowError().addUserInfoBuilderFromOtherRecipe( async (user, _accessTokenPayload, scopes, tenantId, userContext) => { let userInfo: { roles?: string[]; - permissons?: string[]; + permissions?: string[]; } = {}; let userRoles: string[] = []; @@ -100,7 +160,7 @@ export default class Recipe extends RecipeModule { } } - userInfo.permissons = Array.from(userPermissions); + userInfo.permissions = Array.from(userPermissions); } return userInfo;