diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index abf6acffa..0b70812a4 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -166,6 +166,19 @@ function getAPIImplementation() { }, }; }, + userInfoGET: async ({ accessTokenPayload, user, scopes, tenantId, options, userContext }) => { + const userInfo = await options.recipeImplementation.buildUserInfo({ + user, + accessTokenPayload, + scopes, + tenantId, + userContext, + }); + return { + status: "OK", + info: userInfo, + }; + }, }; } exports.default = getAPIImplementation; diff --git a/lib/build/recipe/oauth2/api/userInfo.d.ts b/lib/build/recipe/oauth2/api/userInfo.d.ts new file mode 100644 index 000000000..d0b8cdf4e --- /dev/null +++ b/lib/build/recipe/oauth2/api/userInfo.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function userInfoGET( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2/api/userInfo.js b/lib/build/recipe/oauth2/api/userInfo.js new file mode 100644 index 000000000..dadeb840c --- /dev/null +++ b/lib/build/recipe/oauth2/api/userInfo.js @@ -0,0 +1,82 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const __1 = require("../../.."); +// TODO: Replace stub implementation by the actual implementation +async function validateOAuth2AccessToken(accessToken) { + const resp = await fetch(`http://localhost:4445/admin/oauth2/introspect`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ token: accessToken }), + }); + return await resp.json(); +} +async function userInfoGET(apiImplementation, tenantId, options, userContext) { + if (apiImplementation.userInfoGET === undefined) { + return false; + } + const authHeader = options.req.getHeaderValue("authorization") || options.req.getHeaderValue("Authorization"); + if (authHeader === undefined || !authHeader.startsWith("Bearer ")) { + // TODO: Returning a 400 instead of a 401 to prevent a potential refresh loop in the client SDK. + // When addressing this TODO, review other response codes in this function as well. + utils_1.sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 400); + return true; + } + const accessToken = authHeader.replace(/^Bearer /, "").trim(); + let accessTokenPayload; + try { + accessTokenPayload = await validateOAuth2AccessToken(accessToken); + } catch (error) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + utils_1.sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token", 400); + return true; + } + if ( + accessTokenPayload === null || + typeof accessTokenPayload !== "object" || + typeof accessTokenPayload.sub !== "string" || + typeof accessTokenPayload.scope !== "string" + ) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + utils_1.sendNon200ResponseWithMessage(options.res, "Malformed access token payload", 400); + return true; + } + const userId = accessTokenPayload.sub; + const user = await __1.getUser(userId, userContext); + if (user === undefined) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + utils_1.sendNon200ResponseWithMessage( + options.res, + "Couldn't find any user associated with the access token", + 400 + ); + return true; + } + const response = await apiImplementation.userInfoGET({ + accessTokenPayload, + user, + tenantId, + scopes: accessTokenPayload.scope.split(" "), + options, + userContext, + }); + utils_1.send200Response(options.res, response); + return true; +} +exports.default = userInfoGET; diff --git a/lib/build/recipe/oauth2/constants.d.ts b/lib/build/recipe/oauth2/constants.d.ts index e5fea5b40..e5a5c4263 100644 --- a/lib/build/recipe/oauth2/constants.d.ts +++ b/lib/build/recipe/oauth2/constants.d.ts @@ -6,3 +6,4 @@ export declare const CONSENT_PATH = "/oauth2/consent"; export declare const AUTH_PATH = "/oauth2/auth"; export declare const TOKEN_PATH = "/oauth2/token"; export declare const LOGIN_INFO_PATH = "/oauth2/login/info"; +export declare const USER_INFO_PATH = "/oauth2/userinfo"; diff --git a/lib/build/recipe/oauth2/constants.js b/lib/build/recipe/oauth2/constants.js index f249f20d4..2ea314db4 100644 --- a/lib/build/recipe/oauth2/constants.js +++ b/lib/build/recipe/oauth2/constants.js @@ -14,7 +14,7 @@ * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.CONSENT_PATH = exports.LOGOUT_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; +exports.USER_INFO_PATH = exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.CONSENT_PATH = exports.LOGOUT_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; exports.OAUTH2_BASE_PATH = "/oauth2/"; exports.LOGIN_PATH = "/oauth2/login"; exports.LOGOUT_PATH = "/oauth2/logout"; @@ -22,3 +22,4 @@ exports.CONSENT_PATH = "/oauth2/consent"; exports.AUTH_PATH = "/oauth2/auth"; exports.TOKEN_PATH = "/oauth2/token"; exports.LOGIN_INFO_PATH = "/oauth2/login/info"; +exports.USER_INFO_PATH = "/oauth2/userinfo"; diff --git a/lib/build/recipe/oauth2/recipe.d.ts b/lib/build/recipe/oauth2/recipe.d.ts index 8efd16f20..19a890e21 100644 --- a/lib/build/recipe/oauth2/recipe.d.ts +++ b/lib/build/recipe/oauth2/recipe.d.ts @@ -4,12 +4,20 @@ import type { BaseRequest, BaseResponse } from "../../framework"; import NormalisedURLPath from "../../normalisedURLPath"; import RecipeModule from "../../recipeModule"; import { APIHandled, HTTPMethod, JSONObject, NormalisedAppinfo, RecipeListFunction, UserContext } from "../../types"; -import { APIInterface, RecipeInterface, TypeInput, TypeNormalisedInput } from "./types"; +import { + APIInterface, + RecipeInterface, + TypeInput, + TypeNormalisedInput, + UserInfo, + UserInfoBuilderFunction, +} from "./types"; import { User } from "../../user"; export default class Recipe extends RecipeModule { static RECIPE_ID: string; private static instance; private idTokenBuilders; + private userInfoBuilders; config: TypeNormalisedInput; recipeInterfaceImpl: RecipeInterface; apiImpl: APIInterface; @@ -19,10 +27,11 @@ export default class Recipe extends RecipeModule { static getInstanceOrThrowError(): Recipe; static init(config?: TypeInput): RecipeListFunction; static reset(): void; + addUserInfoBuilderFromOtherRecipe: (userInfoBuilderFn: UserInfoBuilderFunction) => void; getAPIsHandled(): APIHandled[]; handleAPIRequest: ( id: string, - _tenantId: string | undefined, + tenantId: string, req: BaseRequest, res: BaseResponse, _path: NormalisedURLPath, @@ -33,4 +42,11 @@ export default class Recipe extends RecipeModule { getAllCORSHeaders(): string[]; isErrorFromThisRecipe(err: any): err is error; getDefaultIdTokenPayload(user: User, scopes: string[], userContext: UserContext): Promise; + getDefaultUserInfoPayload( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + tenantId: string, + userContext: UserContext + ): Promise; } diff --git a/lib/build/recipe/oauth2/recipe.js b/lib/build/recipe/oauth2/recipe.js index 4e6d0d78c..048abe7dd 100644 --- a/lib/build/recipe/oauth2/recipe.js +++ b/lib/build/recipe/oauth2/recipe.js @@ -34,11 +34,16 @@ const constants_1 = require("./constants"); const recipeImplementation_1 = __importDefault(require("./recipeImplementation")); const utils_1 = require("./utils"); const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); +const userInfo_1 = __importDefault(require("./api/userInfo")); class Recipe extends recipeModule_1.default { constructor(recipeId, appInfo, isInServerlessEnv, config) { super(recipeId, appInfo); this.idTokenBuilders = []; - this.handleAPIRequest = async (id, _tenantId, req, res, _path, _method, userContext) => { + this.userInfoBuilders = []; + this.addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn) => { + this.userInfoBuilders.push(userInfoBuilderFn); + }; + this.handleAPIRequest = async (id, tenantId, req, res, _path, _method, userContext) => { let options = { config: this.config, recipeId: this.getRecipeId(), @@ -65,6 +70,9 @@ class Recipe extends recipeModule_1.default { if (id === constants_1.LOGIN_INFO_PATH) { return loginInfo_1.default(this.apiImpl, options, userContext); } + if (id === constants_1.USER_INFO_PATH) { + return userInfo_1.default(this.apiImpl, tenantId, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; this.config = utils_1.validateAndNormaliseUserInput(this, appInfo, config); @@ -75,7 +83,8 @@ class Recipe extends recipeModule_1.default { querier_1.Querier.getNewInstanceOrThrowError(recipeId), this.config, appInfo, - this.getDefaultIdTokenPayload.bind(this) + this.getDefaultIdTokenPayload.bind(this), + this.getDefaultUserInfoPayload.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); @@ -168,6 +177,12 @@ class Recipe extends recipeModule_1.default { id: constants_1.LOGIN_INFO_PATH, disabled: this.apiImpl.loginInfoGET === undefined, }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.USER_INFO_PATH), + id: constants_1.USER_INFO_PATH, + disabled: this.apiImpl.userInfoGET === undefined, + }, ]; } handleError(error, _, __, _userContext) { @@ -200,6 +215,32 @@ class Recipe extends recipeModule_1.default { } return payload; } + async getDefaultUserInfoPayload(user, accessTokenPayload, scopes, tenantId, userContext) { + let payload = { + sub: accessTokenPayload.sub, + }; + if (scopes.includes("email")) { + payload.email = user === null || user === void 0 ? void 0 : user.emails[0]; + payload.email_verified = user.loginMethods.some( + (lm) => lm.hasSameEmailAs(user === null || user === void 0 ? void 0 : user.emails[0]) && lm.verified + ); + } + if (scopes.includes("phoneNumber")) { + payload.phoneNumber = user === null || user === void 0 ? void 0 : user.phoneNumbers[0]; + payload.phoneNumber_verified = user.loginMethods.some( + (lm) => + lm.hasSamePhoneNumberAs(user === null || user === void 0 ? void 0 : user.phoneNumbers[0]) && + lm.verified + ); + } + for (const fn of this.userInfoBuilders) { + payload = Object.assign( + Object.assign({}, payload), + await fn(user, accessTokenPayload, scopes, tenantId, userContext) + ); + } + return payload; + } } exports.default = Recipe; Recipe.RECIPE_ID = "oauth2"; diff --git a/lib/build/recipe/oauth2/recipeImplementation.d.ts b/lib/build/recipe/oauth2/recipeImplementation.d.ts index 0029a9016..273bef941 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.d.ts +++ b/lib/build/recipe/oauth2/recipeImplementation.d.ts @@ -1,10 +1,11 @@ // @ts-nocheck import { Querier } from "../../querier"; import { NormalisedAppinfo } from "../../types"; -import { RecipeInterface, TypeNormalisedInput, PayloadBuilderFunction } from "./types"; +import { RecipeInterface, TypeNormalisedInput, PayloadBuilderFunction, UserInfoBuilderFunction } from "./types"; export default function getRecipeInterface( querier: Querier, _config: TypeNormalisedInput, _appInfo: NormalisedAppinfo, - getDefaultIdTokenPayload: PayloadBuilderFunction + getDefaultIdTokenPayload: PayloadBuilderFunction, + getDefaultUserInfoPayload: UserInfoBuilderFunction ): RecipeInterface; diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index ac5e17e13..a2158ccb9 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -24,7 +24,7 @@ const querier_1 = require("../../querier"); const utils_1 = require("../../utils"); const OAuth2Client_1 = require("./OAuth2Client"); const __1 = require("../.."); -function getRecipeInterface(querier, _config, _appInfo, getDefaultIdTokenPayload) { +function getRecipeInterface(querier, _config, _appInfo, getDefaultIdTokenPayload, getDefaultUserInfoPayload) { return { getLoginRequest: async function (input) { const resp = await querier.sendGetRequest( @@ -394,8 +394,8 @@ function getRecipeInterface(querier, _config, _appInfo, getDefaultIdTokenPayload buildIdTokenPayload: async function (input) { return input.defaultPayload; }, - buildUserInfo: async function (input) { - return input.user.toJson(); // Proper impl + buildUserInfo: async function ({ user, accessTokenPayload, scopes, tenantId, userContext }) { + return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, tenantId, userContext); }, }; } diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index b95d8f50e..d8db28595 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -1,7 +1,7 @@ // @ts-nocheck import type { BaseRequest, BaseResponse } from "../../framework"; import OverrideableBuilder from "supertokens-js-override"; -import { GeneralErrorResponse, JSONObject, NonNullableProperties, UserContext } from "../../types"; +import { GeneralErrorResponse, JSONObject, JSONValue, NonNullableProperties, UserContext } from "../../types"; import { SessionContainerInterface } from "../session/types"; import { OAuth2Client } from "./OAuth2Client"; import { User } from "../../user"; @@ -87,6 +87,14 @@ export declare type LoginInfo = { logoUri: string; metadata?: Record | null; }; +export declare type UserInfo = { + sub: string; + email?: string; + email_verified?: boolean; + phoneNumber?: string; + phoneNumber_verified?: boolean; + [key: string]: JSONValue; +}; export declare type RecipeInterface = { authorization(input: { params: any; @@ -224,7 +232,7 @@ export declare type RecipeInterface = { user: User; accessTokenPayload: JSONObject; scopes: string[]; - defaultInfo: JSONObject; + tenantId: string; userContext: UserContext; }): Promise; }; @@ -344,6 +352,22 @@ export declare type APIInterface = { } | GeneralErrorResponse >); + userInfoGET: + | undefined + | ((input: { + accessTokenPayload: JSONObject; + user: User; + scopes: string[]; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + info: JSONObject; + } + | GeneralErrorResponse + >); }; export declare type OAuth2ClientOptions = { clientId: string; @@ -445,3 +469,10 @@ export declare type PayloadBuilderFunction = ( scopes: string[], userContext: UserContext ) => Promise; +export declare type UserInfoBuilderFunction = ( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + tenantId: string, + userContext: UserContext +) => Promise; diff --git a/lib/build/recipe/oauth2client/index.js b/lib/build/recipe/oauth2client/index.js index e9481bbfa..cdf968aaf 100644 --- a/lib/build/recipe/oauth2client/index.js +++ b/lib/build/recipe/oauth2client/index.js @@ -25,35 +25,38 @@ const recipe_1 = __importDefault(require("./recipe")); class Wrapper { static async getAuthorisationRedirectURL(redirectURIOnProviderDashboard, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = utils_1.getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipeInterfaceImpl.getAuthorisationRedirectURL({ providerConfig, redirectURIOnProviderDashboard, - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); } static async exchangeAuthCodeForOAuthTokens(redirectURIInfo, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = utils_1.getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ providerConfig, redirectURIInfo, - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); } static async getUserInfo(oAuthTokens, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = utils_1.getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ providerConfig, oAuthTokens, - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); } } diff --git a/lib/build/recipe/oauth2client/recipeImplementation.js b/lib/build/recipe/oauth2client/recipeImplementation.js index 8343d5d61..012749192 100644 --- a/lib/build/recipe/oauth2client/recipeImplementation.js +++ b/lib/build/recipe/oauth2client/recipeImplementation.js @@ -63,10 +63,9 @@ function getRecipeImplementation(_querier, config) { if (oidcInfo.token_endpoint === undefined) { throw new Error("Failed to token_endpoint from the oidcDiscoveryEndpoint."); } - // TODO: We currently don't have this - // if (oidcInfo.userinfo_endpoint === undefined) { - // throw new Error("Failed to userinfo_endpoint from the oidcDiscoveryEndpoint."); - // } + if (oidcInfo.userinfo_endpoint === undefined) { + throw new Error("Failed to userinfo_endpoint from the oidcDiscoveryEndpoint."); + } if (oidcInfo.jwks_uri === undefined) { throw new Error("Failed to jwks_uri from the oidcDiscoveryEndpoint."); } diff --git a/lib/build/recipe/openid/recipeImplementation.js b/lib/build/recipe/openid/recipeImplementation.js index d3b582d2e..f01e8b272 100644 --- a/lib/build/recipe/openid/recipeImplementation.js +++ b/lib/build/recipe/openid/recipeImplementation.js @@ -24,6 +24,7 @@ function getRecipeInterface(config, jwtRecipeImplementation, appInfo) { jwks_uri, authorization_endpoint: apiBasePath + constants_2.AUTH_PATH, token_endpoint: apiBasePath + constants_2.TOKEN_PATH, + userinfo_endpoint: apiBasePath + constants_2.USER_INFO_PATH, subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], response_types_supported: ["code", "id_token", "id_token token"], diff --git a/lib/build/recipe/userroles/recipe.js b/lib/build/recipe/userroles/recipe.js index 53f5d56b6..0587a295f 100644 --- a/lib/build/recipe/userroles/recipe.js +++ b/lib/build/recipe/userroles/recipe.js @@ -27,6 +27,7 @@ const utils_1 = require("./utils"); const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); const postSuperTokensInitCallbacks_1 = require("../../postSuperTokensInitCallbacks"); const recipe_1 = __importDefault(require("../session/recipe")); +const recipe_2 = __importDefault(require("../oauth2/recipe")); const userRoleClaim_1 = require("./userRoleClaim"); const permissionClaim_1 = require("./permissionClaim"); class Recipe extends recipeModule_1.default { @@ -51,6 +52,43 @@ class Recipe extends recipeModule_1.default { if (!this.config.skipAddingPermissionsToAccessToken) { recipe_1.default.getInstanceOrThrowError().addClaimFromOtherRecipe(permissionClaim_1.PermissionClaim); } + recipe_2.default + .getInstanceOrThrowError() + .addUserInfoBuilderFromOtherRecipe(async (user, _accessTokenPayload, scopes, tenantId, userContext) => { + let userInfo = {}; + let userRoles = []; + if (scopes.includes("roles") || scopes.includes("permissions")) { + const res = await this.recipeInterfaceImpl.getRolesForUser({ + userId: user.id, + tenantId, + userContext, + }); + if (res.status !== "OK") { + throw new Error("Failed to fetch roles for the user"); + } + userRoles = res.roles; + } + if (scopes.includes("roles")) { + userInfo.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); + } + } + userInfo.permissons = Array.from(userPermissions); + } + return userInfo; + }); }); } /* Init functions */ diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index 577655a12..e301a1011 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -170,5 +170,19 @@ export default function getAPIImplementation(): APIInterface { }, }; }, + userInfoGET: async ({ accessTokenPayload, user, scopes, tenantId, options, userContext }) => { + const userInfo = await options.recipeImplementation.buildUserInfo({ + user, + accessTokenPayload, + scopes, + tenantId, + userContext, + }); + + return { + status: "OK", + info: userInfo, + }; + }, }; } diff --git a/lib/ts/recipe/oauth2/api/userInfo.ts b/lib/ts/recipe/oauth2/api/userInfo.ts new file mode 100644 index 000000000..b3d601697 --- /dev/null +++ b/lib/ts/recipe/oauth2/api/userInfo.ts @@ -0,0 +1,96 @@ +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import { send200Response, sendNon200ResponseWithMessage } from "../../../utils"; +import { APIInterface, APIOptions } from ".."; +import { JSONObject, UserContext } from "../../../types"; +import { getUser } from "../../.."; + +// TODO: Replace stub implementation by the actual implementation +async function validateOAuth2AccessToken(accessToken: string) { + const resp = await fetch(`http://localhost:4445/admin/oauth2/introspect`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ token: accessToken }), + }); + return await resp.json(); +} + +export default async function userInfoGET( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.userInfoGET === undefined) { + return false; + } + + const authHeader = options.req.getHeaderValue("authorization") || options.req.getHeaderValue("Authorization"); + + if (authHeader === undefined || !authHeader.startsWith("Bearer ")) { + // TODO: Returning a 400 instead of a 401 to prevent a potential refresh loop in the client SDK. + // When addressing this TODO, review other response codes in this function as well. + sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 400); + return true; + } + + const accessToken = authHeader.replace(/^Bearer /, "").trim(); + + let accessTokenPayload: JSONObject; + + try { + accessTokenPayload = await validateOAuth2AccessToken(accessToken); + } catch (error) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token", 400); + return true; + } + + if ( + accessTokenPayload === null || + typeof accessTokenPayload !== "object" || + typeof accessTokenPayload.sub !== "string" || + typeof accessTokenPayload.scope !== "string" + ) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + sendNon200ResponseWithMessage(options.res, "Malformed access token payload", 400); + return true; + } + + const userId = accessTokenPayload.sub; + + const user = await getUser(userId, userContext); + + if (user === undefined) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + sendNon200ResponseWithMessage(options.res, "Couldn't find any user associated with the access token", 400); + return true; + } + + const response = await apiImplementation.userInfoGET({ + accessTokenPayload, + user, + tenantId, + scopes: accessTokenPayload.scope.split(" "), + options, + userContext, + }); + + send200Response(options.res, response); + return true; +} diff --git a/lib/ts/recipe/oauth2/constants.ts b/lib/ts/recipe/oauth2/constants.ts index ddfdef4d6..1175abacf 100644 --- a/lib/ts/recipe/oauth2/constants.ts +++ b/lib/ts/recipe/oauth2/constants.ts @@ -21,3 +21,4 @@ export const CONSENT_PATH = "/oauth2/consent"; export const AUTH_PATH = "/oauth2/auth"; export const TOKEN_PATH = "/oauth2/token"; export const LOGIN_INFO_PATH = "/oauth2/login/info"; +export const USER_INFO_PATH = "/oauth2/userinfo"; diff --git a/lib/ts/recipe/oauth2/recipe.ts b/lib/ts/recipe/oauth2/recipe.ts index a209cff1c..bb0948421 100644 --- a/lib/ts/recipe/oauth2/recipe.ts +++ b/lib/ts/recipe/oauth2/recipe.ts @@ -27,17 +27,35 @@ import loginAPI from "./api/login"; import logoutAPI from "./api/logout"; import tokenPOST from "./api/token"; import loginInfoGET from "./api/loginInfo"; -import { AUTH_PATH, CONSENT_PATH, LOGIN_INFO_PATH, LOGIN_PATH, LOGOUT_PATH, TOKEN_PATH } from "./constants"; +import { + AUTH_PATH, + CONSENT_PATH, + LOGIN_INFO_PATH, + LOGIN_PATH, + LOGOUT_PATH, + TOKEN_PATH, + USER_INFO_PATH, +} from "./constants"; import RecipeImplementation from "./recipeImplementation"; -import { APIInterface, PayloadBuilderFunction, RecipeInterface, TypeInput, TypeNormalisedInput } from "./types"; +import { + APIInterface, + PayloadBuilderFunction, + RecipeInterface, + TypeInput, + TypeNormalisedInput, + UserInfo, + UserInfoBuilderFunction, +} from "./types"; import { validateAndNormaliseUserInput } from "./utils"; import OverrideableBuilder from "supertokens-js-override"; import { User } from "../../user"; +import userInfoGET from "./api/userInfo"; export default class Recipe extends RecipeModule { static RECIPE_ID = "oauth2"; private static instance: Recipe | undefined = undefined; private idTokenBuilders: PayloadBuilderFunction[] = []; + private userInfoBuilders: UserInfoBuilderFunction[] = []; config: TypeNormalisedInput; recipeInterfaceImpl: RecipeInterface; @@ -55,7 +73,8 @@ export default class Recipe extends RecipeModule { Querier.getNewInstanceOrThrowError(recipeId), this.config, appInfo, - this.getDefaultIdTokenPayload.bind(this) + this.getDefaultIdTokenPayload.bind(this), + this.getDefaultUserInfoPayload.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); @@ -96,6 +115,10 @@ export default class Recipe extends RecipeModule { Recipe.instance = undefined; } + addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn: UserInfoBuilderFunction) => { + this.userInfoBuilders.push(userInfoBuilderFn); + }; + /* RecipeModule functions */ getAPIsHandled(): APIHandled[] { @@ -154,12 +177,18 @@ export default class Recipe extends RecipeModule { id: LOGIN_INFO_PATH, disabled: this.apiImpl.loginInfoGET === undefined, }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(USER_INFO_PATH), + id: USER_INFO_PATH, + disabled: this.apiImpl.userInfoGET === undefined, + }, ]; } handleAPIRequest = async ( id: string, - _tenantId: string | undefined, + tenantId: string, req: BaseRequest, res: BaseResponse, _path: NormalisedURLPath, @@ -193,6 +222,9 @@ export default class Recipe extends RecipeModule { if (id === LOGIN_INFO_PATH) { return loginInfoGET(this.apiImpl, options, userContext); } + if (id === USER_INFO_PATH) { + return userInfoGET(this.apiImpl, tenantId, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; @@ -230,4 +262,35 @@ export default class Recipe extends RecipeModule { return payload; } + + async getDefaultUserInfoPayload( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + tenantId: string, + userContext: UserContext + ) { + let payload: JSONObject = { + sub: accessTokenPayload.sub, + }; + if (scopes.includes("email")) { + payload.email = user?.emails[0]; + payload.email_verified = user.loginMethods.some((lm) => lm.hasSameEmailAs(user?.emails[0]) && lm.verified); + } + if (scopes.includes("phoneNumber")) { + payload.phoneNumber = user?.phoneNumbers[0]; + payload.phoneNumber_verified = user.loginMethods.some( + (lm) => lm.hasSamePhoneNumberAs(user?.phoneNumbers[0]) && lm.verified + ); + } + + for (const fn of this.userInfoBuilders) { + payload = { + ...payload, + ...(await fn(user, accessTokenPayload, scopes, tenantId, userContext)), + }; + } + + return payload as UserInfo; + } } diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index 0f5e62274..db697e68a 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -23,6 +23,7 @@ import { LoginRequest, LogoutRequest, PayloadBuilderFunction, + UserInfoBuilderFunction, } from "./types"; import { toSnakeCase, transformObjectKeys } from "../../utils"; import { OAuth2Client } from "./OAuth2Client"; @@ -32,7 +33,8 @@ export default function getRecipeInterface( querier: Querier, _config: TypeNormalisedInput, _appInfo: NormalisedAppinfo, - getDefaultIdTokenPayload: PayloadBuilderFunction + getDefaultIdTokenPayload: PayloadBuilderFunction, + getDefaultUserInfoPayload: UserInfoBuilderFunction ): RecipeInterface { return { getLoginRequest: async function (this: RecipeInterface, input): Promise { @@ -423,8 +425,8 @@ export default function getRecipeInterface( buildIdTokenPayload: async function (input) { return input.defaultPayload; }, - buildUserInfo: async function (input) { - return input.user.toJson(); // Proper impl + buildUserInfo: async function ({ user, accessTokenPayload, scopes, tenantId, userContext }) { + return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, tenantId, userContext); }, }; } diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index f2a79b025..cf113b5d2 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -15,7 +15,7 @@ import type { BaseRequest, BaseResponse } from "../../framework"; import OverrideableBuilder from "supertokens-js-override"; -import { GeneralErrorResponse, JSONObject, NonNullableProperties, UserContext } from "../../types"; +import { GeneralErrorResponse, JSONObject, JSONValue, NonNullableProperties, UserContext } from "../../types"; import { SessionContainerInterface } from "../session/types"; import { OAuth2Client } from "./OAuth2Client"; import { User } from "../../user"; @@ -188,6 +188,15 @@ export type LoginInfo = { metadata?: Record | null; }; +export type UserInfo = { + sub: string; + email?: string; + email_verified?: boolean; + phoneNumber?: string; + phoneNumber_verified?: boolean; + [key: string]: JSONValue; +}; + export type RecipeInterface = { authorization(input: { params: any; @@ -353,7 +362,7 @@ export type RecipeInterface = { user: User; accessTokenPayload: JSONObject; scopes: string[]; - defaultInfo: JSONObject; + tenantId: string; userContext: UserContext; }): Promise; }; @@ -437,6 +446,16 @@ export type APIInterface = { options: APIOptions; userContext: UserContext; }) => Promise<{ status: "OK"; info: LoginInfo } | GeneralErrorResponse>); + userInfoGET: + | undefined + | ((input: { + accessTokenPayload: JSONObject; + user: User; + scopes: string[]; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ status: "OK"; info: JSONObject } | GeneralErrorResponse>); }; export type OAuth2ClientOptions = { @@ -547,3 +566,10 @@ export type DeleteOAuth2ClientInput = { }; export type PayloadBuilderFunction = (user: User, scopes: string[], userContext: UserContext) => Promise; +export type UserInfoBuilderFunction = ( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + tenantId: string, + userContext: UserContext +) => Promise; diff --git a/lib/ts/recipe/oauth2client/index.ts b/lib/ts/recipe/oauth2client/index.ts index 0ae790b4d..0ae16a19d 100644 --- a/lib/ts/recipe/oauth2client/index.ts +++ b/lib/ts/recipe/oauth2client/index.ts @@ -25,13 +25,14 @@ export default class Wrapper { userContext?: Record ) { const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipeInterfaceImpl.getAuthorisationRedirectURL({ providerConfig, redirectURIOnProviderDashboard, - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); } @@ -44,25 +45,27 @@ export default class Wrapper { userContext?: Record ) { const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ providerConfig, redirectURIInfo, - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); } static async getUserInfo(oAuthTokens: OAuthTokens, userContext?: Record) { const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ providerConfig, oAuthTokens, - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); } } diff --git a/lib/ts/recipe/oauth2client/recipeImplementation.ts b/lib/ts/recipe/oauth2client/recipeImplementation.ts index ef374b8f6..68748247b 100644 --- a/lib/ts/recipe/oauth2client/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2client/recipeImplementation.ts @@ -100,10 +100,9 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN if (oidcInfo.token_endpoint === undefined) { throw new Error("Failed to token_endpoint from the oidcDiscoveryEndpoint."); } - // TODO: We currently don't have this - // if (oidcInfo.userinfo_endpoint === undefined) { - // throw new Error("Failed to userinfo_endpoint from the oidcDiscoveryEndpoint."); - // } + if (oidcInfo.userinfo_endpoint === undefined) { + throw new Error("Failed to userinfo_endpoint from the oidcDiscoveryEndpoint."); + } if (oidcInfo.jwks_uri === undefined) { throw new Error("Failed to jwks_uri from the oidcDiscoveryEndpoint."); } diff --git a/lib/ts/recipe/openid/recipeImplementation.ts b/lib/ts/recipe/openid/recipeImplementation.ts index 2ed40f6f5..bbb633768 100644 --- a/lib/ts/recipe/openid/recipeImplementation.ts +++ b/lib/ts/recipe/openid/recipeImplementation.ts @@ -17,7 +17,7 @@ import { RecipeInterface as JWTRecipeInterface, JsonWebKey } from "../jwt/types" import NormalisedURLPath from "../../normalisedURLPath"; import { GET_JWKS_API } from "../jwt/constants"; import { NormalisedAppinfo, UserContext } from "../../types"; -import { AUTH_PATH, TOKEN_PATH } from "../oauth2/constants"; +import { AUTH_PATH, TOKEN_PATH, USER_INFO_PATH } from "../oauth2/constants"; export default function getRecipeInterface( config: TypeNormalisedInput, @@ -38,6 +38,7 @@ export default function getRecipeInterface( jwks_uri, authorization_endpoint: apiBasePath + AUTH_PATH, token_endpoint: apiBasePath + TOKEN_PATH, + userinfo_endpoint: apiBasePath + USER_INFO_PATH, subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], response_types_supported: ["code", "id_token", "id_token token"], diff --git a/lib/ts/recipe/userroles/recipe.ts b/lib/ts/recipe/userroles/recipe.ts index 0f0176c7b..9a2c4ecff 100644 --- a/lib/ts/recipe/userroles/recipe.ts +++ b/lib/ts/recipe/userroles/recipe.ts @@ -27,6 +27,7 @@ import { validateAndNormaliseUserInput } from "./utils"; import OverrideableBuilder from "supertokens-js-override"; import { PostSuperTokensInitCallbacks } from "../../postSuperTokensInitCallbacks"; import SessionRecipe from "../session/recipe"; +import OAuth2Recipe from "../oauth2/recipe"; import { UserRoleClaim } from "./userRoleClaim"; import { PermissionClaim } from "./permissionClaim"; @@ -55,6 +56,56 @@ export default class Recipe extends RecipeModule { if (!this.config.skipAddingPermissionsToAccessToken) { SessionRecipe.getInstanceOrThrowError().addClaimFromOtherRecipe(PermissionClaim); } + + OAuth2Recipe.getInstanceOrThrowError().addUserInfoBuilderFromOtherRecipe( + async (user, _accessTokenPayload, scopes, tenantId, userContext) => { + let userInfo: { + roles?: string[]; + permissons?: string[]; + } = {}; + + let userRoles: string[] = []; + + if (scopes.includes("roles") || scopes.includes("permissions")) { + const res = await this.recipeInterfaceImpl.getRolesForUser({ + userId: user.id, + tenantId, + userContext, + }); + + if (res.status !== "OK") { + throw new Error("Failed to fetch roles for the user"); + } + userRoles = res.roles; + } + + if (scopes.includes("roles")) { + userInfo.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); + } + } + + userInfo.permissons = Array.from(userPermissions); + } + + return userInfo; + } + ); }); }