From 64bb9c7dd25d196d85a2935a73526c4152fa2c40 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Sun, 21 Jul 2024 17:46:15 +0530 Subject: [PATCH] feat: Add userInfoGET endpoint --- lib/build/recipe/oauth2/api/implementation.js | 12 +++ lib/build/recipe/oauth2/api/userInfo.d.ts | 8 ++ lib/build/recipe/oauth2/api/userInfo.js | 68 ++++++++++++++++ lib/build/recipe/oauth2/constants.d.ts | 1 + lib/build/recipe/oauth2/constants.js | 3 +- lib/build/recipe/oauth2/recipe.d.ts | 17 +++- lib/build/recipe/oauth2/recipe.js | 43 +++++++++- .../recipe/oauth2/recipeImplementation.d.ts | 5 +- .../recipe/oauth2/recipeImplementation.js | 6 +- lib/build/recipe/oauth2/types.d.ts | 30 ++++++- lib/build/recipe/oauth2client/index.js | 8 +- lib/ts/recipe/oauth2/api/implementation.ts | 13 +++ lib/ts/recipe/oauth2/api/userInfo.ts | 79 +++++++++++++++++++ lib/ts/recipe/oauth2/constants.ts | 1 + lib/ts/recipe/oauth2/recipe.ts | 68 +++++++++++++++- lib/ts/recipe/oauth2/recipeImplementation.ts | 8 +- lib/ts/recipe/oauth2/types.ts | 25 +++++- 17 files changed, 376 insertions(+), 19 deletions(-) create mode 100644 lib/build/recipe/oauth2/api/userInfo.d.ts create mode 100644 lib/build/recipe/oauth2/api/userInfo.js create mode 100644 lib/ts/recipe/oauth2/api/userInfo.ts diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index b4bdc6cad..7219aa72d 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -190,6 +190,18 @@ function getAPIImplementation() { }, }; }, + userInfoGET: async ({ accessTokenPayload, user, scopes, options, userContext }) => { + const userInfo = await options.recipeImplementation.buildUserInfo({ + user, + accessTokenPayload, + scopes, + 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..44085e2d4 --- /dev/null +++ b/lib/build/recipe/oauth2/api/userInfo.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function userInfoGET( + apiImplementation: APIInterface, + 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..545df953f --- /dev/null +++ b/lib/build/recipe/oauth2/api/userInfo.js @@ -0,0 +1,68 @@ +"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, options, userContext) { + var _a; + if (apiImplementation.userInfoGET === undefined) { + return false; + } + const authHeader = options.req.getHeaderValue("authorization") || options.req.getHeaderValue("Authorization"); + if (authHeader === undefined || !authHeader.startsWith("Bearer ")) { + utils_1.sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 401); + return true; + } + const accessToken = authHeader.replace(/^Bearer /, "").trim(); + let accessTokenPayload; + try { + accessTokenPayload = await validateOAuth2AccessToken(accessToken); + } catch (error) { + utils_1.sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token!", 401); + return true; + } + const userId = accessTokenPayload.sub; + const user = await __1.getUser(userId, userContext); + if (user === undefined) { + utils_1.sendNon200ResponseWithMessage( + options.res, + "Couldn't find any user associated with the access token", + 401 + ); + return true; + } + const response = await apiImplementation.userInfoGET({ + accessTokenPayload, + user, + scopes: ((_a = accessTokenPayload.scope) !== null && _a !== void 0 ? _a : "").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..d4429d27d 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,6 +27,7 @@ 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, @@ -33,4 +42,10 @@ 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[], + userContext: UserContext + ): Promise; } diff --git a/lib/build/recipe/oauth2/recipe.js b/lib/build/recipe/oauth2/recipe.js index 4e6d0d78c..a17cd76fe 100644 --- a/lib/build/recipe/oauth2/recipe.js +++ b/lib/build/recipe/oauth2/recipe.js @@ -34,10 +34,15 @@ 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.userInfoBuilders = []; + this.addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn) => { + this.userInfoBuilders.push(userInfoBuilderFn); + }; this.handleAPIRequest = async (id, _tenantId, req, res, _path, _method, userContext) => { let options = { config: this.config, @@ -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, 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, 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, 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..2c82dbb9a 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, userContext }) { + return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, userContext); }, }; } diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index b95d8f50e..63b690fe5 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -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]: any; +}; export declare type RecipeInterface = { authorization(input: { params: any; @@ -224,7 +232,6 @@ export declare type RecipeInterface = { user: User; accessTokenPayload: JSONObject; scopes: string[]; - defaultInfo: JSONObject; userContext: UserContext; }): Promise; }; @@ -344,6 +351,21 @@ export declare type APIInterface = { } | GeneralErrorResponse >); + userInfoGET: + | undefined + | ((input: { + accessTokenPayload: JSONObject; + user: User; + scopes: string[]; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + info: JSONObject; + } + | GeneralErrorResponse + >); }; export declare type OAuth2ClientOptions = { clientId: string; @@ -445,3 +467,9 @@ export declare type PayloadBuilderFunction = ( scopes: string[], userContext: UserContext ) => Promise; +export declare type UserInfoBuilderFunction = ( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + userContext: UserContext +) => Promise; diff --git a/lib/build/recipe/oauth2client/index.js b/lib/build/recipe/oauth2client/index.js index 285af7656..bc34955ad 100644 --- a/lib/build/recipe/oauth2client/index.js +++ b/lib/build/recipe/oauth2client/index.js @@ -43,9 +43,11 @@ class Wrapper { static async getUserInfo(oAuthTokens, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; const providerConfig = await recipeInterfaceImpl.getProviderConfig({ userContext }); - return await recipe_1.default - .getInstanceOrThrowError() - .recipeInterfaceImpl.getUserInfo({ providerConfig, oAuthTokens, userContext }); + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ + providerConfig, + oAuthTokens, + userContext, + }); } } exports.default = Wrapper; diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index 69c865874..ee6d9dcd3 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -188,5 +188,18 @@ export default function getAPIImplementation(): APIInterface { }, }; }, + userInfoGET: async ({ accessTokenPayload, user, scopes, options, userContext }) => { + const userInfo = await options.recipeImplementation.buildUserInfo({ + user, + accessTokenPayload, + scopes, + 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..82d65d128 --- /dev/null +++ b/lib/ts/recipe/oauth2/api/userInfo.ts @@ -0,0 +1,79 @@ +/* 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, + 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 ")) { + sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 401); + return true; + } + + const accessToken = authHeader.replace(/^Bearer /, "").trim(); + + let accessTokenPayload: JSONObject; + + try { + accessTokenPayload = await validateOAuth2AccessToken(accessToken); + } catch (error) { + sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token!", 401); + return true; + } + + const userId = accessTokenPayload.sub as string; + + const user = await getUser(userId, userContext); + + if (user === undefined) { + sendNon200ResponseWithMessage(options.res, "Couldn't find any user associated with the access token", 401); + return true; + } + + const response = await apiImplementation.userInfoGET({ + accessTokenPayload, + user, + scopes: ((accessTokenPayload.scope as string) ?? "").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..076006e57 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,6 +177,12 @@ 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, + }, ]; } @@ -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, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; @@ -230,4 +262,34 @@ export default class Recipe extends RecipeModule { return payload; } + + async getDefaultUserInfoPayload( + user: User, + accessTokenPayload: JSONObject, + scopes: 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, userContext)), + }; + } + + return payload as UserInfo; + } } diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index 0f5e62274..9a5a40251 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, userContext }) { + return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, userContext); }, }; } diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index f2a79b025..ae088e9a2 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -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]: any; +}; + export type RecipeInterface = { authorization(input: { params: any; @@ -353,7 +362,6 @@ export type RecipeInterface = { user: User; accessTokenPayload: JSONObject; scopes: string[]; - defaultInfo: JSONObject; userContext: UserContext; }): Promise; }; @@ -437,6 +445,15 @@ export type APIInterface = { options: APIOptions; userContext: UserContext; }) => Promise<{ status: "OK"; info: LoginInfo } | GeneralErrorResponse>); + userInfoGET: + | undefined + | ((input: { + accessTokenPayload: JSONObject; + user: User; + scopes: string[]; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ status: "OK"; info: JSONObject } | GeneralErrorResponse>); }; export type OAuth2ClientOptions = { @@ -547,3 +564,9 @@ export type DeleteOAuth2ClientInput = { }; export type PayloadBuilderFunction = (user: User, scopes: string[], userContext: UserContext) => Promise; +export type UserInfoBuilderFunction = ( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + userContext: UserContext +) => Promise;