Skip to content

Commit

Permalink
feat: Add userInfoGET endpoint (#890)
Browse files Browse the repository at this point in the history
* feat: add initial oauth2 client apis

* feat: Add an api to get login info

* fix: merge issues and FE path

* fix: WIP fix for CSRF and redirection issues

* fix: OAuth2 fixes and test-server updates (#871)

* feat: update oauth2 login info endpoint types to match our general patterns

* fix: make login flow work

* fix: circular dependency

* feat: Add OAuth2Client recipe

* fix: PR changes

* fix: PR changes

* fix: PR changes

* feat: Add userInfoGET endpoint

* fix: PR changes

* fix: PR changes

* fix: PR changes

---------

Co-authored-by: Mihaly Lengyel <mihaly@lengyel.tech>
  • Loading branch information
anku255 and porcellus authored Jul 26, 2024
1 parent 0b39ad9 commit 92121af
Show file tree
Hide file tree
Showing 24 changed files with 534 additions and 42 deletions.
13 changes: 13 additions & 0 deletions lib/build/recipe/oauth2/api/implementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
9 changes: 9 additions & 0 deletions lib/build/recipe/oauth2/api/userInfo.d.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
82 changes: 82 additions & 0 deletions lib/build/recipe/oauth2/api/userInfo.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions lib/build/recipe/oauth2/constants.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
3 changes: 2 additions & 1 deletion lib/build/recipe/oauth2/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
* 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";
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";
20 changes: 18 additions & 2 deletions lib/build/recipe/oauth2/recipe.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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<JSONObject>;
getDefaultUserInfoPayload(
user: User,
accessTokenPayload: JSONObject,
scopes: string[],
tenantId: string,
userContext: UserContext
): Promise<UserInfo>;
}
45 changes: 43 additions & 2 deletions lib/build/recipe/oauth2/recipe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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";
Expand Down
5 changes: 3 additions & 2 deletions lib/build/recipe/oauth2/recipeImplementation.d.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions lib/build/recipe/oauth2/recipeImplementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
},
};
}
Expand Down
35 changes: 33 additions & 2 deletions lib/build/recipe/oauth2/types.d.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -87,6 +87,14 @@ export declare type LoginInfo = {
logoUri: string;
metadata?: Record<string, any> | 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;
Expand Down Expand Up @@ -224,7 +232,7 @@ export declare type RecipeInterface = {
user: User;
accessTokenPayload: JSONObject;
scopes: string[];
defaultInfo: JSONObject;
tenantId: string;
userContext: UserContext;
}): Promise<JSONObject>;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -445,3 +469,10 @@ export declare type PayloadBuilderFunction = (
scopes: string[],
userContext: UserContext
) => Promise<JSONObject>;
export declare type UserInfoBuilderFunction = (
user: User,
accessTokenPayload: JSONObject,
scopes: string[],
tenantId: string,
userContext: UserContext
) => Promise<JSONObject>;
Loading

0 comments on commit 92121af

Please sign in to comment.