diff --git a/lib/build/recipe/jwt/api/implementation.js b/lib/build/recipe/jwt/api/implementation.js index 4e8bc342f1..ab43be07c5 100644 --- a/lib/build/recipe/jwt/api/implementation.js +++ b/lib/build/recipe/jwt/api/implementation.js @@ -21,7 +21,7 @@ function getAPIImplementation() { if (resp.validityInSeconds !== undefined) { options.res.setHeader("Cache-Control", `max-age=${resp.validityInSeconds}, must-revalidate`, false); } - const oauth2 = require("../../oauth2").getInstance(); + const oauth2 = require("../../oauth2/recipe").default.getInstance(); // TODO: dirty hack until we get core support if (oauth2 !== undefined) { const oauth2JWKSRes = await fetch("http://localhost:4444/.well-known/jwks.json"); diff --git a/lib/build/recipe/oauth2/recipe.js b/lib/build/recipe/oauth2/recipe.js index 9a7a01277e..4e6d0d78ce 100644 --- a/lib/build/recipe/oauth2/recipe.js +++ b/lib/build/recipe/oauth2/recipe.js @@ -75,7 +75,7 @@ class Recipe extends recipeModule_1.default { querier_1.Querier.getNewInstanceOrThrowError(recipeId), this.config, appInfo, - this.getDefaultIdTokenPayload + this.getDefaultIdTokenPayload.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); diff --git a/lib/build/recipe/oauth2client/api/authorisationUrl.d.ts b/lib/build/recipe/oauth2client/api/authorisationUrl.d.ts new file mode 100644 index 0000000000..61b231e1ad --- /dev/null +++ b/lib/build/recipe/oauth2client/api/authorisationUrl.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from "../"; +import { UserContext } from "../../../types"; +export default function authorisationUrlAPI( + apiImplementation: APIInterface, + _tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2client/api/authorisationUrl.js b/lib/build/recipe/oauth2client/api/authorisationUrl.js new file mode 100644 index 0000000000..e4544b31c6 --- /dev/null +++ b/lib/build/recipe/oauth2client/api/authorisationUrl.js @@ -0,0 +1,44 @@ +"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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const error_1 = __importDefault(require("../../../error")); +async function authorisationUrlAPI(apiImplementation, _tenantId, options, userContext) { + if (apiImplementation.authorisationUrlGET === undefined) { + return false; + } + // TODO: Check if we can rename `redirectURIOnProviderDashboard` to a more suitable name + const redirectURIOnProviderDashboard = options.req.getKeyValueFromQuery("redirectURIOnProviderDashboard"); + if (redirectURIOnProviderDashboard === undefined || typeof redirectURIOnProviderDashboard !== "string") { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide the redirectURIOnProviderDashboard as a GET param", + }); + } + let result = await apiImplementation.authorisationUrlGET({ + redirectURIOnProviderDashboard, + options, + userContext, + }); + utils_1.send200Response(options.res, result); + return true; +} +exports.default = authorisationUrlAPI; diff --git a/lib/build/recipe/oauth2client/api/implementation.d.ts b/lib/build/recipe/oauth2client/api/implementation.d.ts new file mode 100644 index 0000000000..dd40e7025c --- /dev/null +++ b/lib/build/recipe/oauth2client/api/implementation.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +import { APIInterface } from "../"; +export default function getAPIInterface(): APIInterface; diff --git a/lib/build/recipe/oauth2client/api/implementation.js b/lib/build/recipe/oauth2client/api/implementation.js new file mode 100644 index 0000000000..5b642629a2 --- /dev/null +++ b/lib/build/recipe/oauth2client/api/implementation.js @@ -0,0 +1,66 @@ +"use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const session_1 = __importDefault(require("../../session")); +function getAPIInterface() { + return { + authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard, userContext }) { + const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL({ + providerConfig, + redirectURIOnProviderDashboard, + userContext, + }); + return Object.assign({ status: "OK" }, authUrl); + }, + signInPOST: async function (input) { + const { options, tenantId, userContext } = input; + const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + let oAuthTokensToUse = {}; + if ("redirectURIInfo" in input && input.redirectURIInfo !== undefined) { + oAuthTokensToUse = await options.recipeImplementation.exchangeAuthCodeForOAuthTokens({ + providerConfig, + redirectURIInfo: input.redirectURIInfo, + userContext, + }); + } else if ("oAuthTokens" in input && input.oAuthTokens !== undefined) { + oAuthTokensToUse = input.oAuthTokens; + } else { + throw Error("should never come here"); + } + const { userId, rawUserInfoFromProvider } = await options.recipeImplementation.getUserInfo({ + providerConfig, + oAuthTokens: oAuthTokensToUse, + userContext, + }); + const { user, recipeUserId } = await options.recipeImplementation.signIn({ + userId, + tenantId, + rawUserInfoFromProvider, + oAuthTokens: oAuthTokensToUse, + userContext, + }); + const session = await session_1.default.createNewSession( + options.req, + options.res, + tenantId, + recipeUserId, + undefined, + undefined, + userContext + ); + return { + status: "OK", + user, + session, + oAuthTokens: oAuthTokensToUse, + rawUserInfoFromProvider, + }; + }, + }; +} +exports.default = getAPIInterface; diff --git a/lib/build/recipe/oauth2client/api/signin.d.ts b/lib/build/recipe/oauth2client/api/signin.d.ts new file mode 100644 index 0000000000..72cd6e46bd --- /dev/null +++ b/lib/build/recipe/oauth2client/api/signin.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function signInAPI( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2client/api/signin.js b/lib/build/recipe/oauth2client/api/signin.js new file mode 100644 index 0000000000..0a0fadc453 --- /dev/null +++ b/lib/build/recipe/oauth2client/api/signin.js @@ -0,0 +1,81 @@ +"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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const error_1 = __importDefault(require("../../../error")); +const utils_1 = require("../../../utils"); +const session_1 = __importDefault(require("../../session")); +async function signInAPI(apiImplementation, tenantId, options, userContext) { + if (apiImplementation.signInPOST === undefined) { + return false; + } + const bodyParams = await options.req.getJSONBody(); + let redirectURIInfo; + let oAuthTokens; + if (bodyParams.redirectURIInfo !== undefined) { + if (bodyParams.redirectURIInfo.redirectURIOnProviderDashboard === undefined) { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide the redirectURIOnProviderDashboard in request body", + }); + } + redirectURIInfo = bodyParams.redirectURIInfo; + } else if (bodyParams.oAuthTokens !== undefined) { + oAuthTokens = bodyParams.oAuthTokens; + } else { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide one of redirectURIInfo or oAuthTokens in the request body", + }); + } + let session = await session_1.default.getSession( + options.req, + options.res, + { + sessionRequired: false, + overrideGlobalClaimValidators: () => [], + }, + userContext + ); + if (session !== undefined) { + tenantId = session.getTenantId(); + } + let result = await apiImplementation.signInPOST({ + tenantId, + redirectURIInfo, + oAuthTokens, + session, + options, + userContext, + }); + if (result.status === "OK") { + utils_1.send200Response( + options.res, + Object.assign( + { status: result.status }, + utils_1.getBackwardsCompatibleUserInfo(options.req, result, userContext) + ) + ); + } else { + utils_1.send200Response(options.res, result); + } + return true; +} +exports.default = signInAPI; diff --git a/lib/build/recipe/oauth2client/constants.d.ts b/lib/build/recipe/oauth2client/constants.d.ts new file mode 100644 index 0000000000..fd2bef07da --- /dev/null +++ b/lib/build/recipe/oauth2client/constants.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +export declare const AUTHORISATION_API = "/oauth2client/authorisationurl"; +export declare const SIGN_IN_API = "/oauth2client/signin"; diff --git a/lib/build/recipe/oauth2client/constants.js b/lib/build/recipe/oauth2client/constants.js new file mode 100644 index 0000000000..e8156ec893 --- /dev/null +++ b/lib/build/recipe/oauth2client/constants.js @@ -0,0 +1,19 @@ +"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 }); +exports.SIGN_IN_API = exports.AUTHORISATION_API = void 0; +exports.AUTHORISATION_API = "/oauth2client/authorisationurl"; +exports.SIGN_IN_API = "/oauth2client/signin"; diff --git a/lib/build/recipe/oauth2client/index.d.ts b/lib/build/recipe/oauth2client/index.d.ts new file mode 100644 index 0000000000..a16f0bb05a --- /dev/null +++ b/lib/build/recipe/oauth2client/index.d.ts @@ -0,0 +1,30 @@ +// @ts-nocheck +import Recipe from "./recipe"; +import { RecipeInterface, APIInterface, APIOptions, OAuthTokens } from "./types"; +export default class Wrapper { + static init: typeof Recipe.init; + static getAuthorisationRedirectURL( + redirectURIOnProviderDashboard: string, + userContext?: Record + ): Promise<{ + urlWithQueryParams: string; + pkceCodeVerifier?: string | undefined; + }>; + static exchangeAuthCodeForOAuthTokens( + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }, + userContext?: Record + ): Promise; + static getUserInfo( + oAuthTokens: OAuthTokens, + userContext?: Record + ): Promise; +} +export declare let init: typeof Recipe.init; +export declare let getAuthorisationRedirectURL: typeof Wrapper.getAuthorisationRedirectURL; +export declare let exchangeAuthCodeForOAuthTokens: typeof Wrapper.exchangeAuthCodeForOAuthTokens; +export declare let getUserInfo: typeof Wrapper.getUserInfo; +export type { RecipeInterface, APIInterface, APIOptions }; diff --git a/lib/build/recipe/oauth2client/index.js b/lib/build/recipe/oauth2client/index.js new file mode 100644 index 0000000000..e9481bbfae --- /dev/null +++ b/lib/build/recipe/oauth2client/index.js @@ -0,0 +1,65 @@ +"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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getUserInfo = exports.exchangeAuthCodeForOAuthTokens = exports.getAuthorisationRedirectURL = exports.init = void 0; +const utils_1 = require("../../utils"); +const recipe_1 = __importDefault(require("./recipe")); +class Wrapper { + static async getAuthorisationRedirectURL(redirectURIOnProviderDashboard, userContext) { + const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + userContext: utils_1.getUserContext(userContext), + }); + return await recipeInterfaceImpl.getAuthorisationRedirectURL({ + providerConfig, + redirectURIOnProviderDashboard, + userContext: utils_1.getUserContext(userContext), + }); + } + static async exchangeAuthCodeForOAuthTokens(redirectURIInfo, userContext) { + const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + userContext: utils_1.getUserContext(userContext), + }); + return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ + providerConfig, + redirectURIInfo, + userContext: utils_1.getUserContext(userContext), + }); + } + static async getUserInfo(oAuthTokens, userContext) { + const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + userContext: utils_1.getUserContext(userContext), + }); + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ + providerConfig, + oAuthTokens, + userContext: utils_1.getUserContext(userContext), + }); + } +} +exports.default = Wrapper; +Wrapper.init = recipe_1.default.init; +exports.init = Wrapper.init; +exports.getAuthorisationRedirectURL = Wrapper.getAuthorisationRedirectURL; +exports.exchangeAuthCodeForOAuthTokens = Wrapper.exchangeAuthCodeForOAuthTokens; +exports.getUserInfo = Wrapper.getUserInfo; diff --git a/lib/build/recipe/oauth2client/recipe.d.ts b/lib/build/recipe/oauth2client/recipe.d.ts new file mode 100644 index 0000000000..1802271698 --- /dev/null +++ b/lib/build/recipe/oauth2client/recipe.d.ts @@ -0,0 +1,38 @@ +// @ts-nocheck +import RecipeModule from "../../recipeModule"; +import { NormalisedAppinfo, APIHandled, RecipeListFunction, HTTPMethod, UserContext } from "../../types"; +import { TypeInput, TypeNormalisedInput, RecipeInterface, APIInterface } from "./types"; +import STError from "../../error"; +import NormalisedURLPath from "../../normalisedURLPath"; +import type { BaseRequest, BaseResponse } from "../../framework"; +export default class Recipe extends RecipeModule { + private static instance; + static RECIPE_ID: string; + config: TypeNormalisedInput; + recipeInterfaceImpl: RecipeInterface; + apiImpl: APIInterface; + isInServerlessEnv: boolean; + constructor( + recipeId: string, + appInfo: NormalisedAppinfo, + isInServerlessEnv: boolean, + config: TypeInput, + _recipes: {} + ); + static init(config: TypeInput): RecipeListFunction; + static getInstanceOrThrowError(): Recipe; + static reset(): void; + getAPIsHandled: () => APIHandled[]; + handleAPIRequest: ( + id: string, + tenantId: string, + req: BaseRequest, + res: BaseResponse, + _path: NormalisedURLPath, + _method: HTTPMethod, + userContext: UserContext + ) => Promise; + handleError: (err: STError, _request: BaseRequest, _response: BaseResponse) => Promise; + getAllCORSHeaders: () => string[]; + isErrorFromThisRecipe: (err: any) => err is STError; +} diff --git a/lib/build/recipe/oauth2client/recipe.js b/lib/build/recipe/oauth2client/recipe.js new file mode 100644 index 0000000000..d3ad7c29ab --- /dev/null +++ b/lib/build/recipe/oauth2client/recipe.js @@ -0,0 +1,116 @@ +"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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const recipeModule_1 = __importDefault(require("../../recipeModule")); +const utils_1 = require("./utils"); +const error_1 = __importDefault(require("../../error")); +const constants_1 = require("./constants"); +const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); +const signin_1 = __importDefault(require("./api/signin")); +const authorisationUrl_1 = __importDefault(require("./api/authorisationUrl")); +const recipeImplementation_1 = __importDefault(require("./recipeImplementation")); +const implementation_1 = __importDefault(require("./api/implementation")); +const querier_1 = require("../../querier"); +const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); +class Recipe extends recipeModule_1.default { + constructor(recipeId, appInfo, isInServerlessEnv, config, _recipes) { + super(recipeId, appInfo); + this.getAPIsHandled = () => { + return [ + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.SIGN_IN_API), + id: constants_1.SIGN_IN_API, + disabled: this.apiImpl.signInPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.AUTHORISATION_API), + id: constants_1.AUTHORISATION_API, + disabled: this.apiImpl.authorisationUrlGET === undefined, + }, + ]; + }; + this.handleAPIRequest = async (id, tenantId, req, res, _path, _method, userContext) => { + let options = { + config: this.config, + recipeId: this.getRecipeId(), + isInServerlessEnv: this.isInServerlessEnv, + recipeImplementation: this.recipeInterfaceImpl, + req, + res, + appInfo: this.getAppInfo(), + }; + if (id === constants_1.SIGN_IN_API) { + return await signin_1.default(this.apiImpl, tenantId, options, userContext); + } else if (id === constants_1.AUTHORISATION_API) { + return await authorisationUrl_1.default(this.apiImpl, tenantId, options, userContext); + } + return false; + }; + this.handleError = async (err, _request, _response) => { + throw err; + }; + this.getAllCORSHeaders = () => { + return []; + }; + this.isErrorFromThisRecipe = (err) => { + return error_1.default.isErrorFromSuperTokens(err) && err.fromRecipe === Recipe.RECIPE_ID; + }; + this.config = utils_1.validateAndNormaliseUserInput(appInfo, config); + this.isInServerlessEnv = isInServerlessEnv; + { + let builder = new supertokens_js_override_1.default( + recipeImplementation_1.default(querier_1.Querier.getNewInstanceOrThrowError(recipeId), this.config) + ); + this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); + } + { + let builder = new supertokens_js_override_1.default(implementation_1.default()); + this.apiImpl = builder.override(this.config.override.apis).build(); + } + } + static init(config) { + return (appInfo, isInServerlessEnv) => { + if (Recipe.instance === undefined) { + Recipe.instance = new Recipe(Recipe.RECIPE_ID, appInfo, isInServerlessEnv, config, {}); + return Recipe.instance; + } else { + throw new Error("OAuth2Client recipe has already been initialised. Please check your code for bugs."); + } + }; + } + static getInstanceOrThrowError() { + if (Recipe.instance !== undefined) { + return Recipe.instance; + } + throw new Error("Initialisation not done. Did you forget to call the OAuth2Client.init function?"); + } + static reset() { + if (process.env.TEST_MODE !== "testing") { + throw new Error("calling testing function in non testing env"); + } + Recipe.instance = undefined; + } +} +exports.default = Recipe; +Recipe.instance = undefined; +Recipe.RECIPE_ID = "oauth2client"; diff --git a/lib/build/recipe/oauth2client/recipeImplementation.d.ts b/lib/build/recipe/oauth2client/recipeImplementation.d.ts new file mode 100644 index 0000000000..24599a2c7b --- /dev/null +++ b/lib/build/recipe/oauth2client/recipeImplementation.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +import { RecipeInterface, TypeNormalisedInput } from "./types"; +import { Querier } from "../../querier"; +export default function getRecipeImplementation(_querier: Querier, config: TypeNormalisedInput): RecipeInterface; diff --git a/lib/build/recipe/oauth2client/recipeImplementation.js b/lib/build/recipe/oauth2client/recipeImplementation.js new file mode 100644 index 0000000000..8343d5d617 --- /dev/null +++ b/lib/build/recipe/oauth2client/recipeImplementation.js @@ -0,0 +1,165 @@ +"use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const recipeUserId_1 = __importDefault(require("../../recipeUserId")); +const thirdpartyUtils_1 = require("../../thirdpartyUtils"); +const pkce_challenge_1 = __importDefault(require("pkce-challenge")); +const __1 = require("../.."); +const logger_1 = require("../../logger"); +const jose_1 = require("jose"); +function getRecipeImplementation(_querier, config) { + let providerConfigWithOIDCInfo = null; + return { + getAuthorisationRedirectURL: async function ({ providerConfig, redirectURIOnProviderDashboard }) { + const queryParams = { + client_id: providerConfig.clientId, + redirect_uri: redirectURIOnProviderDashboard, + response_type: "code", + }; + if (providerConfig.scope !== undefined) { + queryParams.scope = providerConfig.scope.join(" "); + } + let pkceCodeVerifier = undefined; + if (providerConfig.clientSecret === undefined || providerConfig.forcePKCE) { + const { code_challenge, code_verifier } = pkce_challenge_1.default(64); // According to https://www.rfc-editor.org/rfc/rfc7636, length must be between 43 and 128 + queryParams["code_challenge"] = code_challenge; + queryParams["code_challenge_method"] = "S256"; + pkceCodeVerifier = code_verifier; + } + const urlObj = new URL(providerConfig.authorizationEndpoint); + for (const [key, value] of Object.entries(queryParams)) { + urlObj.searchParams.set(key, value); + } + return { + urlWithQueryParams: urlObj.toString(), + pkceCodeVerifier: pkceCodeVerifier, + }; + }, + signIn: async function ({ userId, tenantId, userContext, oAuthTokens, rawUserInfoFromProvider }) { + const user = await __1.getUser(userId, userContext); + if (user === undefined) { + throw new Error(`Failed to getUser from the userId ${userId} in the ${tenantId} tenant`); + } + return { + status: "OK", + user, + recipeUserId: new recipeUserId_1.default(userId), + oAuthTokens, + rawUserInfoFromProvider, + }; + }, + getProviderConfig: async function () { + if (providerConfigWithOIDCInfo !== null) { + return providerConfigWithOIDCInfo; + } + const oidcInfo = await thirdpartyUtils_1.getOIDCDiscoveryInfo(config.providerConfig.oidcDiscoveryEndpoint); + if (oidcInfo.authorization_endpoint === undefined) { + throw new Error("Failed to authorization_endpoint from the oidcDiscoveryEndpoint."); + } + 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.jwks_uri === undefined) { + throw new Error("Failed to jwks_uri from the oidcDiscoveryEndpoint."); + } + providerConfigWithOIDCInfo = Object.assign(Object.assign({}, config.providerConfig), { + authorizationEndpoint: oidcInfo.authorization_endpoint, + tokenEndpoint: oidcInfo.token_endpoint, + userInfoEndpoint: oidcInfo.userinfo_endpoint, + jwksURI: oidcInfo.jwks_uri, + }); + return providerConfigWithOIDCInfo; + }, + exchangeAuthCodeForOAuthTokens: async function ({ providerConfig, redirectURIInfo }) { + if (providerConfig.tokenEndpoint === undefined) { + throw new Error("OAuth2Client provider's tokenEndpoint is not configured."); + } + const tokenAPIURL = providerConfig.tokenEndpoint; + const accessTokenAPIParams = { + client_id: providerConfig.clientId, + redirect_uri: redirectURIInfo.redirectURIOnProviderDashboard, + code: redirectURIInfo.redirectURIQueryParams["code"], + grant_type: "authorization_code", + }; + if (providerConfig.clientSecret !== undefined) { + accessTokenAPIParams["client_secret"] = providerConfig.clientSecret; + } + if (redirectURIInfo.pkceCodeVerifier !== undefined) { + accessTokenAPIParams["code_verifier"] = redirectURIInfo.pkceCodeVerifier; + } + const tokenResponse = await thirdpartyUtils_1.doPostRequest(tokenAPIURL, accessTokenAPIParams); + if (tokenResponse.status >= 400) { + logger_1.logDebugMessage( + `Received response with status ${tokenResponse.status} and body ${tokenResponse.stringResponse}` + ); + throw new Error( + `Received response with status ${tokenResponse.status} and body ${tokenResponse.stringResponse}` + ); + } + return tokenResponse.jsonResponse; + }, + getUserInfo: async function ({ providerConfig, oAuthTokens }) { + let jwks; + const accessToken = oAuthTokens["access_token"]; + const idToken = oAuthTokens["id_token"]; + let rawUserInfoFromProvider = { + fromUserInfoAPI: {}, + fromIdTokenPayload: {}, + }; + if (idToken && providerConfig.jwksURI !== undefined) { + if (jwks === undefined) { + jwks = jose_1.createRemoteJWKSet(new URL(providerConfig.jwksURI)); + } + rawUserInfoFromProvider.fromIdTokenPayload = await thirdpartyUtils_1.verifyIdTokenFromJWKSEndpointAndGetPayload( + idToken, + jwks, + { + audience: providerConfig.clientId, + } + ); + } + if (accessToken && providerConfig.userInfoEndpoint !== undefined) { + const headers = { + Authorization: "Bearer " + accessToken, + }; + const queryParams = {}; + const userInfoFromAccessToken = await thirdpartyUtils_1.doGetRequest( + providerConfig.userInfoEndpoint, + queryParams, + headers + ); + if (userInfoFromAccessToken.status >= 400) { + logger_1.logDebugMessage( + `Received response with status ${userInfoFromAccessToken.status} and body ${userInfoFromAccessToken.stringResponse}` + ); + throw new Error( + `Received response with status ${userInfoFromAccessToken.status} and body ${userInfoFromAccessToken.stringResponse}` + ); + } + rawUserInfoFromProvider.fromUserInfoAPI = userInfoFromAccessToken.jsonResponse; + } + let userId = undefined; + if (rawUserInfoFromProvider.fromIdTokenPayload !== undefined) { + userId = rawUserInfoFromProvider.fromIdTokenPayload["sub"]; + } else if (rawUserInfoFromProvider.fromUserInfoAPI !== undefined) { + userId = rawUserInfoFromProvider.fromUserInfoAPI["sub"]; + } + if (userId === undefined) { + throw new Error(`Failed to get userId from both the idToken and userInfo endpoint.`); + } + return { + userId, + rawUserInfoFromProvider, + }; + }, + }; +} +exports.default = getRecipeImplementation; diff --git a/lib/build/recipe/oauth2client/types.d.ts b/lib/build/recipe/oauth2client/types.d.ts new file mode 100644 index 0000000000..d00a4a069d --- /dev/null +++ b/lib/build/recipe/oauth2client/types.d.ts @@ -0,0 +1,182 @@ +// @ts-nocheck +import type { BaseRequest, BaseResponse } from "../../framework"; +import { NormalisedAppinfo, UserContext } from "../../types"; +import OverrideableBuilder from "supertokens-js-override"; +import { SessionContainerInterface } from "../session/types"; +import { GeneralErrorResponse, User } from "../../types"; +import RecipeUserId from "../../recipeUserId"; +export declare type UserInfo = { + userId: string; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { + [key: string]: any; + }; + fromUserInfoAPI?: { + [key: string]: any; + }; + }; +}; +export declare type ProviderConfigInput = { + clientId: string; + clientSecret: string; + authorizationEndpointQueryParams?: { + [key: string]: string | null; + }; + oidcDiscoveryEndpoint: string; + scope?: string[]; + forcePKCE?: boolean; +}; +export declare type ProviderConfigWithOIDCInfo = ProviderConfigInput & { + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + jwksURI: string; +}; +export declare type OAuthTokens = { + access_token?: string; + id_token?: string; +}; +export declare type OAuthTokenResponse = { + access_token: string; + id_token?: string; + refresh_token?: string; + expires_in: number; + scope?: string; + token_type: string; +}; +export declare type TypeInput = { + providerConfig: ProviderConfigInput; + override?: { + functions?: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + apis?: (originalImplementation: APIInterface, builder?: OverrideableBuilder) => APIInterface; + }; +}; +export declare type TypeNormalisedInput = { + providerConfig: ProviderConfigInput; + override: { + functions: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + apis: (originalImplementation: APIInterface, builder?: OverrideableBuilder) => APIInterface; + }; +}; +export declare type RecipeInterface = { + getAuthorisationRedirectURL(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIOnProviderDashboard: string; + userContext: UserContext; + }): Promise<{ + urlWithQueryParams: string; + pkceCodeVerifier?: string; + }>; + getProviderConfig(input: { userContext: UserContext }): Promise; + signIn(input: { + userId: string; + oAuthTokens: OAuthTokens; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { + [key: string]: any; + }; + fromUserInfoAPI?: { + [key: string]: any; + }; + }; + tenantId: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + recipeUserId: RecipeUserId; + user: User; + oAuthTokens: OAuthTokens; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { + [key: string]: any; + }; + fromUserInfoAPI?: { + [key: string]: any; + }; + }; + }>; + exchangeAuthCodeForOAuthTokens(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }; + userContext: UserContext; + }): Promise; + getUserInfo(input: { + providerConfig: ProviderConfigWithOIDCInfo; + oAuthTokens: OAuthTokens; + userContext: UserContext; + }): Promise; +}; +export declare type APIOptions = { + recipeImplementation: RecipeInterface; + config: TypeNormalisedInput; + recipeId: string; + isInServerlessEnv: boolean; + req: BaseRequest; + res: BaseResponse; + appInfo: NormalisedAppinfo; +}; +export declare type APIInterface = { + authorisationUrlGET: + | undefined + | ((input: { + redirectURIOnProviderDashboard: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + urlWithQueryParams: string; + pkceCodeVerifier?: string; + } + | GeneralErrorResponse + >); + signInPOST: ( + input: { + tenantId: string; + session: SessionContainerInterface | undefined; + options: APIOptions; + userContext: UserContext; + } & ( + | { + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string; + }; + } + | { + oAuthTokens: { + [key: string]: any; + }; + } + ) + ) => Promise< + | { + status: "OK"; + user: User; + session: SessionContainerInterface; + oAuthTokens: { + [key: string]: any; + }; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { + [key: string]: any; + }; + fromUserInfoAPI?: { + [key: string]: any; + }; + }; + } + | GeneralErrorResponse + >; +}; diff --git a/lib/build/recipe/oauth2client/types.js b/lib/build/recipe/oauth2client/types.js new file mode 100644 index 0000000000..9f12373197 --- /dev/null +++ b/lib/build/recipe/oauth2client/types.js @@ -0,0 +1,16 @@ +"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 }); diff --git a/lib/build/recipe/oauth2client/utils.d.ts b/lib/build/recipe/oauth2client/utils.d.ts new file mode 100644 index 0000000000..6a930e6416 --- /dev/null +++ b/lib/build/recipe/oauth2client/utils.d.ts @@ -0,0 +1,7 @@ +// @ts-nocheck +import { NormalisedAppinfo } from "../../types"; +import { TypeInput, TypeNormalisedInput } from "./types"; +export declare function validateAndNormaliseUserInput( + _appInfo: NormalisedAppinfo, + config: TypeInput +): TypeNormalisedInput; diff --git a/lib/build/recipe/oauth2client/utils.js b/lib/build/recipe/oauth2client/utils.js new file mode 100644 index 0000000000..cc35b347f1 --- /dev/null +++ b/lib/build/recipe/oauth2client/utils.js @@ -0,0 +1,49 @@ +"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 }); +exports.validateAndNormaliseUserInput = void 0; +function validateAndNormaliseUserInput(_appInfo, config) { + if (config === undefined || config.providerConfig === undefined) { + throw new Error("Please pass providerConfig argument in the OAuth2Client recipe."); + } + if (config.providerConfig.clientId === undefined) { + throw new Error("Please pass clientId argument in the OAuth2Client providerConfig."); + } + // TODO: Decide on the prefix and also if we will allow users to customise clientIds + // if (!config.providerConfig.clientId.startsWith("supertokens_")) { + // throw new Error( + // `Only Supertokens OAuth ClientIds are supported in the OAuth2Client recipe. For any other OAuth Clients use the thirdparty recipe.` + // ); + // } + if (config.providerConfig.clientSecret === undefined) { + throw new Error("Please pass clientSecret argument in the OAuth2Client providerConfig."); + } + if (config.providerConfig.oidcDiscoveryEndpoint === undefined) { + throw new Error("Please pass oidcDiscoveryEndpoint argument in the OAuth2Client providerConfig."); + } + let override = Object.assign( + { + functions: (originalImplementation) => originalImplementation, + apis: (originalImplementation) => originalImplementation, + }, + config === null || config === void 0 ? void 0 : config.override + ); + return { + providerConfig: config.providerConfig, + override, + }; +} +exports.validateAndNormaliseUserInput = validateAndNormaliseUserInput; diff --git a/lib/build/recipe/thirdparty/providers/bitbucket.js b/lib/build/recipe/thirdparty/providers/bitbucket.js index 27588fc51b..6c5a60d23c 100644 --- a/lib/build/recipe/thirdparty/providers/bitbucket.js +++ b/lib/build/recipe/thirdparty/providers/bitbucket.js @@ -19,7 +19,7 @@ var __importDefault = return mod && mod.__esModule ? mod : { default: mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -const utils_1 = require("./utils"); +const thirdpartyUtils_1 = require("../../../thirdpartyUtils"); const custom_1 = __importDefault(require("./custom")); const logger_1 = require("../../../logger"); function Bitbucket(input) { @@ -59,7 +59,7 @@ function Bitbucket(input) { fromUserInfoAPI: {}, fromIdTokenPayload: {}, }; - const userInfoFromAccessToken = await utils_1.doGetRequest( + const userInfoFromAccessToken = await thirdpartyUtils_1.doGetRequest( "https://api.bitbucket.org/2.0/user", undefined, headers @@ -73,7 +73,7 @@ function Bitbucket(input) { ); } rawUserInfoFromProvider.fromUserInfoAPI = userInfoFromAccessToken.jsonResponse; - const userInfoFromEmail = await utils_1.doGetRequest( + const userInfoFromEmail = await thirdpartyUtils_1.doGetRequest( "https://api.bitbucket.org/2.0/user/emails", undefined, headers diff --git a/lib/build/recipe/thirdparty/providers/custom.js b/lib/build/recipe/thirdparty/providers/custom.js index 3aead6ffc3..ddd2b7a000 100644 --- a/lib/build/recipe/thirdparty/providers/custom.js +++ b/lib/build/recipe/thirdparty/providers/custom.js @@ -6,7 +6,7 @@ var __importDefault = }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getActualClientIdFromDevelopmentClientId = exports.isUsingDevelopmentClientId = exports.DEV_OAUTH_REDIRECT_URL = void 0; -const utils_1 = require("./utils"); +const thirdpartyUtils_1 = require("../../../thirdpartyUtils"); const pkce_challenge_1 = __importDefault(require("pkce-challenge")); const configUtils_1 = require("./configUtils"); const jose_1 = require("jose"); @@ -251,7 +251,7 @@ function NewProvider(input) { accessTokenAPIParams["redirect_uri"] = exports.DEV_OAUTH_REDIRECT_URL; } /* Transformation needed for dev keys END */ - const tokenResponse = await utils_1.doPostRequest(tokenAPIURL, accessTokenAPIParams); + const tokenResponse = await thirdpartyUtils_1.doPostRequest(tokenAPIURL, accessTokenAPIParams); if (tokenResponse.status >= 400) { logger_1.logDebugMessage( `Received response with status ${tokenResponse.status} and body ${tokenResponse.stringResponse}` @@ -273,7 +273,7 @@ function NewProvider(input) { if (jwks === undefined) { jwks = jose_1.createRemoteJWKSet(new URL(impl.config.jwksURI)); } - rawUserInfoFromProvider.fromIdTokenPayload = await utils_1.verifyIdTokenFromJWKSEndpointAndGetPayload( + rawUserInfoFromProvider.fromIdTokenPayload = await thirdpartyUtils_1.verifyIdTokenFromJWKSEndpointAndGetPayload( idToken, jwks, { @@ -318,7 +318,7 @@ function NewProvider(input) { } } } - const userInfoFromAccessToken = await utils_1.doGetRequest( + const userInfoFromAccessToken = await thirdpartyUtils_1.doGetRequest( impl.config.userInfoEndpoint, queryParams, headers diff --git a/lib/build/recipe/thirdparty/providers/github.js b/lib/build/recipe/thirdparty/providers/github.js index a656dd8950..1f3c86029d 100644 --- a/lib/build/recipe/thirdparty/providers/github.js +++ b/lib/build/recipe/thirdparty/providers/github.js @@ -6,7 +6,7 @@ var __importDefault = }; Object.defineProperty(exports, "__esModule", { value: true }); const custom_1 = __importDefault(require("./custom")); -const utils_1 = require("./utils"); +const thirdpartyUtils_1 = require("../../../thirdpartyUtils"); function getSupertokensUserInfoFromRawUserInfoResponseForGithub(rawUserInfoResponse) { if (rawUserInfoResponse.fromUserInfoAPI === undefined) { throw new Error("rawUserInfoResponse.fromUserInfoAPI is not available"); @@ -44,7 +44,7 @@ function Github(input) { const basicAuthToken = Buffer.from( `${clientConfig.clientId}:${clientConfig.clientSecret === undefined ? "" : clientConfig.clientSecret}` ).toString("base64"); - const applicationResponse = await utils_1.doPostRequest( + const applicationResponse = await thirdpartyUtils_1.doPostRequest( `https://api.github.com/applications/${clientConfig.clientId}/token`, { access_token: accessToken, @@ -81,14 +81,22 @@ function Github(input) { Accept: "application/vnd.github.v3+json", }; const rawResponse = {}; - const emailInfoResp = await utils_1.doGetRequest("https://api.github.com/user/emails", undefined, headers); + const emailInfoResp = await thirdpartyUtils_1.doGetRequest( + "https://api.github.com/user/emails", + undefined, + headers + ); if (emailInfoResp.status >= 400) { throw new Error( `Getting userInfo failed with ${emailInfoResp.status}: ${emailInfoResp.stringResponse}` ); } rawResponse.emails = emailInfoResp.jsonResponse; - const userInfoResp = await utils_1.doGetRequest("https://api.github.com/user", undefined, headers); + const userInfoResp = await thirdpartyUtils_1.doGetRequest( + "https://api.github.com/user", + undefined, + headers + ); if (userInfoResp.status >= 400) { throw new Error(`Getting userInfo failed with ${userInfoResp.status}: ${userInfoResp.stringResponse}`); } diff --git a/lib/build/recipe/thirdparty/providers/linkedin.js b/lib/build/recipe/thirdparty/providers/linkedin.js index bce0eeaf4f..defa0739cc 100644 --- a/lib/build/recipe/thirdparty/providers/linkedin.js +++ b/lib/build/recipe/thirdparty/providers/linkedin.js @@ -21,7 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); */ const logger_1 = require("../../../logger"); const custom_1 = __importDefault(require("./custom")); -const utils_1 = require("./utils"); +const thirdpartyUtils_1 = require("../../../thirdpartyUtils"); function Linkedin(input) { if (input.config.name === undefined) { input.config.name = "LinkedIn"; @@ -56,7 +56,7 @@ function Linkedin(input) { fromIdTokenPayload: {}, }; // https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2?context=linkedin%2Fconsumer%2Fcontext#sample-api-response - const userInfoFromAccessToken = await utils_1.doGetRequest( + const userInfoFromAccessToken = await thirdpartyUtils_1.doGetRequest( "https://api.linkedin.com/v2/userinfo", undefined, headers diff --git a/lib/build/recipe/thirdparty/providers/twitter.js b/lib/build/recipe/thirdparty/providers/twitter.js index 3e54592c7e..7a7078b512 100644 --- a/lib/build/recipe/thirdparty/providers/twitter.js +++ b/lib/build/recipe/thirdparty/providers/twitter.js @@ -52,7 +52,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); */ const logger_1 = require("../../../logger"); const custom_1 = __importStar(require("./custom")); -const utils_1 = require("./utils"); +const thirdpartyUtils_1 = require("../../../thirdpartyUtils"); function Twitter(input) { var _a; if (input.config.name === undefined) { @@ -116,7 +116,7 @@ function Twitter(input) { }, originalImplementation.config.tokenEndpointBodyParams ); - const tokenResponse = await utils_1.doPostRequest( + const tokenResponse = await thirdpartyUtils_1.doPostRequest( originalImplementation.config.tokenEndpoint, twitterOauthTokenParams, { diff --git a/lib/build/recipe/thirdparty/providers/utils.d.ts b/lib/build/recipe/thirdparty/providers/utils.d.ts index c43eb396dd..26592d1258 100644 --- a/lib/build/recipe/thirdparty/providers/utils.d.ts +++ b/lib/build/recipe/thirdparty/providers/utils.d.ts @@ -1,35 +1,3 @@ // @ts-nocheck -import * as jose from "jose"; import { ProviderConfigForClientType } from "../types"; -export declare function doGetRequest( - url: string, - queryParams?: { - [key: string]: string; - }, - headers?: { - [key: string]: string; - } -): Promise<{ - jsonResponse: Record | undefined; - status: number; - stringResponse: string; -}>; -export declare function doPostRequest( - url: string, - params: { - [key: string]: any; - }, - headers?: { - [key: string]: string; - } -): Promise<{ - jsonResponse: Record | undefined; - status: number; - stringResponse: string; -}>; -export declare function verifyIdTokenFromJWKSEndpointAndGetPayload( - idToken: string, - jwks: jose.JWTVerifyGetKey, - otherOptions: jose.JWTVerifyOptions -): Promise; export declare function discoverOIDCEndpoints(config: ProviderConfigForClientType): Promise; diff --git a/lib/build/recipe/thirdparty/providers/utils.js b/lib/build/recipe/thirdparty/providers/utils.js index 0fc79f65a9..90640abcea 100644 --- a/lib/build/recipe/thirdparty/providers/utils.js +++ b/lib/build/recipe/thirdparty/providers/utils.js @@ -1,135 +1,10 @@ "use strict"; -var __createBinding = - (this && this.__createBinding) || - (Object.create - ? function (o, m, k, k2) { - if (k2 === undefined) k2 = k; - Object.defineProperty(o, k2, { - enumerable: true, - get: function () { - return m[k]; - }, - }); - } - : function (o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; - }); -var __setModuleDefault = - (this && this.__setModuleDefault) || - (Object.create - ? function (o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } - : function (o, v) { - o["default"] = v; - }); -var __importStar = - (this && this.__importStar) || - function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; - }; -var __importDefault = - (this && this.__importDefault) || - function (mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.discoverOIDCEndpoints = exports.verifyIdTokenFromJWKSEndpointAndGetPayload = exports.doPostRequest = exports.doGetRequest = void 0; -const jose = __importStar(require("jose")); -const normalisedURLDomain_1 = __importDefault(require("../../../normalisedURLDomain")); -const normalisedURLPath_1 = __importDefault(require("../../../normalisedURLPath")); -const logger_1 = require("../../../logger"); -const utils_1 = require("../../../utils"); -async function doGetRequest(url, queryParams, headers) { - logger_1.logDebugMessage( - `GET request to ${url}, with query params ${JSON.stringify(queryParams)} and headers ${JSON.stringify(headers)}` - ); - if ((headers === null || headers === void 0 ? void 0 : headers["Accept"]) === undefined) { - headers = Object.assign(Object.assign({}, headers), { Accept: "application/json" }); - } - const finalURL = new URL(url); - finalURL.search = new URLSearchParams(queryParams).toString(); - let response = await utils_1.doFetch(finalURL.toString(), { - headers: headers, - }); - const stringResponse = await response.text(); - let jsonResponse = undefined; - if (response.status < 400) { - jsonResponse = JSON.parse(stringResponse); - } - logger_1.logDebugMessage(`Received response with status ${response.status} and body ${stringResponse}`); - return { - stringResponse, - status: response.status, - jsonResponse, - }; -} -exports.doGetRequest = doGetRequest; -async function doPostRequest(url, params, headers) { - if (headers === undefined) { - headers = {}; - } - headers["Content-Type"] = "application/x-www-form-urlencoded"; - headers["Accept"] = "application/json"; // few providers like github don't send back json response by default - logger_1.logDebugMessage( - `POST request to ${url}, with params ${JSON.stringify(params)} and headers ${JSON.stringify(headers)}` - ); - const body = new URLSearchParams(params).toString(); - let response = await utils_1.doFetch(url, { - method: "POST", - body, - headers, - }); - const stringResponse = await response.text(); - let jsonResponse = undefined; - if (response.status < 400) { - jsonResponse = JSON.parse(stringResponse); - } - logger_1.logDebugMessage(`Received response with status ${response.status} and body ${stringResponse}`); - return { - stringResponse, - status: response.status, - jsonResponse, - }; -} -exports.doPostRequest = doPostRequest; -async function verifyIdTokenFromJWKSEndpointAndGetPayload(idToken, jwks, otherOptions) { - const { payload } = await jose.jwtVerify(idToken, jwks, otherOptions); - return payload; -} -exports.verifyIdTokenFromJWKSEndpointAndGetPayload = verifyIdTokenFromJWKSEndpointAndGetPayload; -// OIDC utils -var oidcInfoMap = {}; -async function getOIDCDiscoveryInfo(issuer) { - const normalizedDomain = new normalisedURLDomain_1.default(issuer); - let normalizedPath = new normalisedURLPath_1.default(issuer); - const openIdConfigPath = new normalisedURLPath_1.default("/.well-known/openid-configuration"); - normalizedPath = normalizedPath.appendPath(openIdConfigPath); - if (oidcInfoMap[issuer] !== undefined) { - return oidcInfoMap[issuer]; - } - const oidcInfo = await doGetRequest( - normalizedDomain.getAsStringDangerous() + normalizedPath.getAsStringDangerous() - ); - if (oidcInfo.status >= 400) { - logger_1.logDebugMessage( - `Received response with status ${oidcInfo.status} and body ${oidcInfo.stringResponse}` - ); - throw new Error(`Received response with status ${oidcInfo.status} and body ${oidcInfo.stringResponse}`); - } - oidcInfoMap[issuer] = oidcInfo.jsonResponse; - return oidcInfo.jsonResponse; -} +exports.discoverOIDCEndpoints = void 0; +const thirdpartyUtils_1 = require("../../../thirdpartyUtils"); async function discoverOIDCEndpoints(config) { if (config.oidcDiscoveryEndpoint !== undefined) { - const oidcInfo = await getOIDCDiscoveryInfo(config.oidcDiscoveryEndpoint); + const oidcInfo = await thirdpartyUtils_1.getOIDCDiscoveryInfo(config.oidcDiscoveryEndpoint); if (oidcInfo.authorization_endpoint !== undefined && config.authorizationEndpoint === undefined) { config.authorizationEndpoint = oidcInfo.authorization_endpoint; } diff --git a/lib/build/thirdpartyUtils.d.ts b/lib/build/thirdpartyUtils.d.ts new file mode 100644 index 0000000000..84517d5e28 --- /dev/null +++ b/lib/build/thirdpartyUtils.d.ts @@ -0,0 +1,34 @@ +// @ts-nocheck +import * as jose from "jose"; +export declare function doGetRequest( + url: string, + queryParams?: { + [key: string]: string; + }, + headers?: { + [key: string]: string; + } +): Promise<{ + jsonResponse: Record | undefined; + status: number; + stringResponse: string; +}>; +export declare function doPostRequest( + url: string, + params: { + [key: string]: any; + }, + headers?: { + [key: string]: string; + } +): Promise<{ + jsonResponse: Record | undefined; + status: number; + stringResponse: string; +}>; +export declare function verifyIdTokenFromJWKSEndpointAndGetPayload( + idToken: string, + jwks: jose.JWTVerifyGetKey, + otherOptions: jose.JWTVerifyOptions +): Promise; +export declare function getOIDCDiscoveryInfo(issuer: string): Promise; diff --git a/lib/build/thirdpartyUtils.js b/lib/build/thirdpartyUtils.js new file mode 100644 index 0000000000..ee7a2fcb19 --- /dev/null +++ b/lib/build/thirdpartyUtils.js @@ -0,0 +1,130 @@ +"use strict"; +var __createBinding = + (this && this.__createBinding) || + (Object.create + ? function (o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { + enumerable: true, + get: function () { + return m[k]; + }, + }); + } + : function (o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; + }); +var __setModuleDefault = + (this && this.__setModuleDefault) || + (Object.create + ? function (o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } + : function (o, v) { + o["default"] = v; + }); +var __importStar = + (this && this.__importStar) || + function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; + }; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getOIDCDiscoveryInfo = exports.verifyIdTokenFromJWKSEndpointAndGetPayload = exports.doPostRequest = exports.doGetRequest = void 0; +const jose = __importStar(require("jose")); +const logger_1 = require("./logger"); +const utils_1 = require("./utils"); +const normalisedURLDomain_1 = __importDefault(require("./normalisedURLDomain")); +const normalisedURLPath_1 = __importDefault(require("./normalisedURLPath")); +async function doGetRequest(url, queryParams, headers) { + logger_1.logDebugMessage( + `GET request to ${url}, with query params ${JSON.stringify(queryParams)} and headers ${JSON.stringify(headers)}` + ); + if ((headers === null || headers === void 0 ? void 0 : headers["Accept"]) === undefined) { + headers = Object.assign(Object.assign({}, headers), { Accept: "application/json" }); + } + const finalURL = new URL(url); + finalURL.search = new URLSearchParams(queryParams).toString(); + let response = await utils_1.doFetch(finalURL.toString(), { + headers: headers, + }); + const stringResponse = await response.text(); + let jsonResponse = undefined; + if (response.status < 400) { + jsonResponse = JSON.parse(stringResponse); + } + logger_1.logDebugMessage(`Received response with status ${response.status} and body ${stringResponse}`); + return { + stringResponse, + status: response.status, + jsonResponse, + }; +} +exports.doGetRequest = doGetRequest; +async function doPostRequest(url, params, headers) { + if (headers === undefined) { + headers = {}; + } + headers["Content-Type"] = "application/x-www-form-urlencoded"; + headers["Accept"] = "application/json"; + logger_1.logDebugMessage( + `POST request to ${url}, with params ${JSON.stringify(params)} and headers ${JSON.stringify(headers)}` + ); + const body = new URLSearchParams(params).toString(); + let response = await utils_1.doFetch(url, { + method: "POST", + body, + headers, + }); + const stringResponse = await response.text(); + let jsonResponse = undefined; + if (response.status < 400) { + jsonResponse = JSON.parse(stringResponse); + } + logger_1.logDebugMessage(`Received response with status ${response.status} and body ${stringResponse}`); + return { + stringResponse, + status: response.status, + jsonResponse, + }; +} +exports.doPostRequest = doPostRequest; +async function verifyIdTokenFromJWKSEndpointAndGetPayload(idToken, jwks, otherOptions) { + const { payload } = await jose.jwtVerify(idToken, jwks, otherOptions); + return payload; +} +exports.verifyIdTokenFromJWKSEndpointAndGetPayload = verifyIdTokenFromJWKSEndpointAndGetPayload; +// OIDC utils +var oidcInfoMap = {}; +async function getOIDCDiscoveryInfo(issuer) { + const normalizedDomain = new normalisedURLDomain_1.default(issuer); + let normalizedPath = new normalisedURLPath_1.default(issuer); + const openIdConfigPath = new normalisedURLPath_1.default("/.well-known/openid-configuration"); + normalizedPath = normalizedPath.appendPath(openIdConfigPath); + if (oidcInfoMap[issuer] !== undefined) { + return oidcInfoMap[issuer]; + } + const oidcInfo = await doGetRequest( + normalizedDomain.getAsStringDangerous() + normalizedPath.getAsStringDangerous() + ); + if (oidcInfo.status >= 400) { + logger_1.logDebugMessage( + `Received response with status ${oidcInfo.status} and body ${oidcInfo.stringResponse}` + ); + throw new Error(`Received response with status ${oidcInfo.status} and body ${oidcInfo.stringResponse}`); + } + oidcInfoMap[issuer] = oidcInfo.jsonResponse; + return oidcInfo.jsonResponse; +} +exports.getOIDCDiscoveryInfo = getOIDCDiscoveryInfo; diff --git a/lib/ts/recipe/jwt/api/implementation.ts b/lib/ts/recipe/jwt/api/implementation.ts index 5a029d4a1a..17871e963c 100644 --- a/lib/ts/recipe/jwt/api/implementation.ts +++ b/lib/ts/recipe/jwt/api/implementation.ts @@ -31,7 +31,8 @@ export default function getAPIImplementation(): APIInterface { options.res.setHeader("Cache-Control", `max-age=${resp.validityInSeconds}, must-revalidate`, false); } - const oauth2 = require("../../oauth2").getInstance(); + const oauth2 = require("../../oauth2/recipe").default.getInstance(); + // TODO: dirty hack until we get core support if (oauth2 !== undefined) { const oauth2JWKSRes = await fetch("http://localhost:4444/.well-known/jwks.json"); diff --git a/lib/ts/recipe/oauth2/recipe.ts b/lib/ts/recipe/oauth2/recipe.ts index 5aa8a38883..a209cff1c2 100644 --- a/lib/ts/recipe/oauth2/recipe.ts +++ b/lib/ts/recipe/oauth2/recipe.ts @@ -55,7 +55,7 @@ export default class Recipe extends RecipeModule { Querier.getNewInstanceOrThrowError(recipeId), this.config, appInfo, - this.getDefaultIdTokenPayload + this.getDefaultIdTokenPayload.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index f88435f43b..f2a79b0251 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -437,16 +437,6 @@ export type APIInterface = { options: APIOptions; userContext: UserContext; }) => Promise<{ status: "OK"; info: LoginInfo } | GeneralErrorResponse>); - // userInfoGET: - // | undefined - // | ((input: { - // accessTokenPayload: JSONObject; // after validating the access token passed to the endpoint - // user: User; // ge - // scopes: string[]; - // defaultInfo: JSONObject; - // options: APIOptions; - // userContext: UserContext; - // }) => Promise<{ status: "OK"; info: LoginInfo } | GeneralErrorResponse>); }; export type OAuth2ClientOptions = { diff --git a/lib/ts/recipe/oauth2client/api/authorisationUrl.ts b/lib/ts/recipe/oauth2client/api/authorisationUrl.ts new file mode 100644 index 0000000000..80d43138c4 --- /dev/null +++ b/lib/ts/recipe/oauth2client/api/authorisationUrl.ts @@ -0,0 +1,49 @@ +/* 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 } from "../../../utils"; +import STError from "../../../error"; +import { APIInterface, APIOptions } from "../"; +import { UserContext } from "../../../types"; + +export default async function authorisationUrlAPI( + apiImplementation: APIInterface, + _tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.authorisationUrlGET === undefined) { + return false; + } + + // TODO: Check if we can rename `redirectURIOnProviderDashboard` to a more suitable name + const redirectURIOnProviderDashboard = options.req.getKeyValueFromQuery("redirectURIOnProviderDashboard"); + + if (redirectURIOnProviderDashboard === undefined || typeof redirectURIOnProviderDashboard !== "string") { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "Please provide the redirectURIOnProviderDashboard as a GET param", + }); + } + + let result = await apiImplementation.authorisationUrlGET({ + redirectURIOnProviderDashboard, + options, + userContext, + }); + + send200Response(options.res, result); + return true; +} diff --git a/lib/ts/recipe/oauth2client/api/implementation.ts b/lib/ts/recipe/oauth2client/api/implementation.ts new file mode 100644 index 0000000000..a6f14e0cbd --- /dev/null +++ b/lib/ts/recipe/oauth2client/api/implementation.ts @@ -0,0 +1,72 @@ +import { APIInterface } from "../"; +import Session from "../../session"; +import { OAuthTokens } from "../types"; + +export default function getAPIInterface(): APIInterface { + return { + authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard, userContext }) { + const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + + const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL({ + providerConfig, + redirectURIOnProviderDashboard, + userContext, + }); + return { + status: "OK", + ...authUrl, + }; + }, + signInPOST: async function (input) { + const { options, tenantId, userContext } = input; + + const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + + let oAuthTokensToUse: OAuthTokens = {}; + + if ("redirectURIInfo" in input && input.redirectURIInfo !== undefined) { + oAuthTokensToUse = await options.recipeImplementation.exchangeAuthCodeForOAuthTokens({ + providerConfig, + redirectURIInfo: input.redirectURIInfo, + userContext, + }); + } else if ("oAuthTokens" in input && input.oAuthTokens !== undefined) { + oAuthTokensToUse = input.oAuthTokens; + } else { + throw Error("should never come here"); + } + + const { userId, rawUserInfoFromProvider } = await options.recipeImplementation.getUserInfo({ + providerConfig, + oAuthTokens: oAuthTokensToUse, + userContext, + }); + + const { user, recipeUserId } = await options.recipeImplementation.signIn({ + userId, + tenantId, + rawUserInfoFromProvider, + oAuthTokens: oAuthTokensToUse, + userContext, + }); + + const session = await Session.createNewSession( + options.req, + options.res, + tenantId, + recipeUserId, + undefined, + undefined, + userContext + ); + + return { + status: "OK", + user, + session, + oAuthTokens: oAuthTokensToUse, + rawUserInfoFromProvider, + }; + }, + }; +} diff --git a/lib/ts/recipe/oauth2client/api/signin.ts b/lib/ts/recipe/oauth2client/api/signin.ts new file mode 100644 index 0000000000..663af60e48 --- /dev/null +++ b/lib/ts/recipe/oauth2client/api/signin.ts @@ -0,0 +1,92 @@ +/* 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 STError from "../../../error"; +import { getBackwardsCompatibleUserInfo, send200Response } from "../../../utils"; +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +import Session from "../../session"; + +export default async function signInAPI( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.signInPOST === undefined) { + return false; + } + + const bodyParams = await options.req.getJSONBody(); + + let redirectURIInfo: + | undefined + | { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string; + }; + let oAuthTokens: any; + + if (bodyParams.redirectURIInfo !== undefined) { + if (bodyParams.redirectURIInfo.redirectURIOnProviderDashboard === undefined) { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "Please provide the redirectURIOnProviderDashboard in request body", + }); + } + redirectURIInfo = bodyParams.redirectURIInfo; + } else if (bodyParams.oAuthTokens !== undefined) { + oAuthTokens = bodyParams.oAuthTokens; + } else { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "Please provide one of redirectURIInfo or oAuthTokens in the request body", + }); + } + + let session = await Session.getSession( + options.req, + options.res, + { + sessionRequired: false, + overrideGlobalClaimValidators: () => [], + }, + userContext + ); + + if (session !== undefined) { + tenantId = session.getTenantId(); + } + + let result = await apiImplementation.signInPOST({ + tenantId, + redirectURIInfo, + oAuthTokens, + session, + options, + userContext, + }); + + if (result.status === "OK") { + send200Response(options.res, { + status: result.status, + ...getBackwardsCompatibleUserInfo(options.req, result, userContext), + }); + } else { + send200Response(options.res, result); + } + return true; +} diff --git a/lib/ts/recipe/oauth2client/constants.ts b/lib/ts/recipe/oauth2client/constants.ts new file mode 100644 index 0000000000..545ef08f1a --- /dev/null +++ b/lib/ts/recipe/oauth2client/constants.ts @@ -0,0 +1,18 @@ +/* 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. + */ + +export const AUTHORISATION_API = "/oauth2client/authorisationurl"; + +export const SIGN_IN_API = "/oauth2client/signin"; diff --git a/lib/ts/recipe/oauth2client/index.ts b/lib/ts/recipe/oauth2client/index.ts new file mode 100644 index 0000000000..0ae790b4df --- /dev/null +++ b/lib/ts/recipe/oauth2client/index.ts @@ -0,0 +1,78 @@ +/* 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 { getUserContext } from "../../utils"; +import Recipe from "./recipe"; +import { RecipeInterface, APIInterface, APIOptions, OAuthTokens } from "./types"; + +export default class Wrapper { + static init = Recipe.init; + + static async getAuthorisationRedirectURL( + redirectURIOnProviderDashboard: string, + userContext?: Record + ) { + const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + userContext: getUserContext(userContext), + }); + return await recipeInterfaceImpl.getAuthorisationRedirectURL({ + providerConfig, + redirectURIOnProviderDashboard, + userContext: getUserContext(userContext), + }); + } + + static async exchangeAuthCodeForOAuthTokens( + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }, + userContext?: Record + ) { + const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + userContext: getUserContext(userContext), + }); + return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ + providerConfig, + redirectURIInfo, + userContext: getUserContext(userContext), + }); + } + + static async getUserInfo(oAuthTokens: OAuthTokens, userContext?: Record) { + const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ + userContext: getUserContext(userContext), + }); + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ + providerConfig, + oAuthTokens, + userContext: getUserContext(userContext), + }); + } +} + +export let init = Wrapper.init; + +export let getAuthorisationRedirectURL = Wrapper.getAuthorisationRedirectURL; + +export let exchangeAuthCodeForOAuthTokens = Wrapper.exchangeAuthCodeForOAuthTokens; + +export let getUserInfo = Wrapper.getUserInfo; + +export type { RecipeInterface, APIInterface, APIOptions }; diff --git a/lib/ts/recipe/oauth2client/recipe.ts b/lib/ts/recipe/oauth2client/recipe.ts new file mode 100644 index 0000000000..9dfc98a900 --- /dev/null +++ b/lib/ts/recipe/oauth2client/recipe.ts @@ -0,0 +1,146 @@ +/* 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 RecipeModule from "../../recipeModule"; +import { NormalisedAppinfo, APIHandled, RecipeListFunction, HTTPMethod, UserContext } from "../../types"; +import { TypeInput, TypeNormalisedInput, RecipeInterface, APIInterface } from "./types"; +import { validateAndNormaliseUserInput } from "./utils"; +import STError from "../../error"; +import { SIGN_IN_API, AUTHORISATION_API } from "./constants"; +import NormalisedURLPath from "../../normalisedURLPath"; +import signInAPI from "./api/signin"; +import authorisationUrlAPI from "./api/authorisationUrl"; +import RecipeImplementation from "./recipeImplementation"; +import APIImplementation from "./api/implementation"; +import { Querier } from "../../querier"; +import type { BaseRequest, BaseResponse } from "../../framework"; +import OverrideableBuilder from "supertokens-js-override"; + +export default class Recipe extends RecipeModule { + private static instance: Recipe | undefined = undefined; + static RECIPE_ID = "oauth2client"; + + config: TypeNormalisedInput; + + recipeInterfaceImpl: RecipeInterface; + + apiImpl: APIInterface; + + isInServerlessEnv: boolean; + + constructor( + recipeId: string, + appInfo: NormalisedAppinfo, + isInServerlessEnv: boolean, + config: TypeInput, + _recipes: {} + ) { + super(recipeId, appInfo); + this.config = validateAndNormaliseUserInput(appInfo, config); + this.isInServerlessEnv = isInServerlessEnv; + + { + let builder = new OverrideableBuilder( + RecipeImplementation(Querier.getNewInstanceOrThrowError(recipeId), this.config) + ); + this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); + } + { + let builder = new OverrideableBuilder(APIImplementation()); + this.apiImpl = builder.override(this.config.override.apis).build(); + } + } + + static init(config: TypeInput): RecipeListFunction { + return (appInfo, isInServerlessEnv) => { + if (Recipe.instance === undefined) { + Recipe.instance = new Recipe(Recipe.RECIPE_ID, appInfo, isInServerlessEnv, config, {}); + + return Recipe.instance; + } else { + throw new Error("OAuth2Client recipe has already been initialised. Please check your code for bugs."); + } + }; + } + + static getInstanceOrThrowError(): Recipe { + if (Recipe.instance !== undefined) { + return Recipe.instance; + } + throw new Error("Initialisation not done. Did you forget to call the OAuth2Client.init function?"); + } + + static reset() { + if (process.env.TEST_MODE !== "testing") { + throw new Error("calling testing function in non testing env"); + } + Recipe.instance = undefined; + } + + getAPIsHandled = (): APIHandled[] => { + return [ + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(SIGN_IN_API), + id: SIGN_IN_API, + disabled: this.apiImpl.signInPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(AUTHORISATION_API), + id: AUTHORISATION_API, + disabled: this.apiImpl.authorisationUrlGET === undefined, + }, + ]; + }; + + handleAPIRequest = async ( + id: string, + tenantId: string, + req: BaseRequest, + res: BaseResponse, + _path: NormalisedURLPath, + _method: HTTPMethod, + userContext: UserContext + ): Promise => { + let options = { + config: this.config, + recipeId: this.getRecipeId(), + isInServerlessEnv: this.isInServerlessEnv, + recipeImplementation: this.recipeInterfaceImpl, + req, + res, + appInfo: this.getAppInfo(), + }; + if (id === SIGN_IN_API) { + return await signInAPI(this.apiImpl, tenantId, options, userContext); + } else if (id === AUTHORISATION_API) { + return await authorisationUrlAPI(this.apiImpl, tenantId, options, userContext); + } + return false; + }; + + handleError = async (err: STError, _request: BaseRequest, _response: BaseResponse): Promise => { + throw err; + }; + + getAllCORSHeaders = (): string[] => { + return []; + }; + + isErrorFromThisRecipe = (err: any): err is STError => { + return STError.isErrorFromSuperTokens(err) && err.fromRecipe === Recipe.RECIPE_ID; + }; +} diff --git a/lib/ts/recipe/oauth2client/recipeImplementation.ts b/lib/ts/recipe/oauth2client/recipeImplementation.ts new file mode 100644 index 0000000000..ef374b8f6e --- /dev/null +++ b/lib/ts/recipe/oauth2client/recipeImplementation.ts @@ -0,0 +1,221 @@ +import { + OAuthTokenResponse, + OAuthTokens, + ProviderConfigWithOIDCInfo, + RecipeInterface, + TypeNormalisedInput, + UserInfo, +} from "./types"; +import { Querier } from "../../querier"; +import RecipeUserId from "../../recipeUserId"; +import { User as UserType } from "../../types"; +import { + doGetRequest, + doPostRequest, + getOIDCDiscoveryInfo, + verifyIdTokenFromJWKSEndpointAndGetPayload, +} from "../../thirdpartyUtils"; +import pkceChallenge from "pkce-challenge"; +import { getUser } from "../.."; +import { logDebugMessage } from "../../logger"; +import { JWTVerifyGetKey, createRemoteJWKSet } from "jose"; + +export default function getRecipeImplementation(_querier: Querier, config: TypeNormalisedInput): RecipeInterface { + let providerConfigWithOIDCInfo: ProviderConfigWithOIDCInfo | null = null; + + return { + getAuthorisationRedirectURL: async function ( + this: RecipeInterface, + { providerConfig, redirectURIOnProviderDashboard } + ) { + const queryParams: { [key: string]: string } = { + client_id: providerConfig.clientId, + redirect_uri: redirectURIOnProviderDashboard, + response_type: "code", + }; + + if (providerConfig.scope !== undefined) { + queryParams.scope = providerConfig.scope.join(" "); + } + + let pkceCodeVerifier: string | undefined = undefined; + + if (providerConfig.clientSecret === undefined || providerConfig.forcePKCE) { + const { code_challenge, code_verifier } = pkceChallenge(64); // According to https://www.rfc-editor.org/rfc/rfc7636, length must be between 43 and 128 + queryParams["code_challenge"] = code_challenge; + queryParams["code_challenge_method"] = "S256"; + pkceCodeVerifier = code_verifier; + } + + const urlObj = new URL(providerConfig.authorizationEndpoint); + + for (const [key, value] of Object.entries(queryParams)) { + urlObj.searchParams.set(key, value); + } + + return { + urlWithQueryParams: urlObj.toString(), + pkceCodeVerifier: pkceCodeVerifier, + }; + }, + signIn: async function ({ + userId, + tenantId, + userContext, + oAuthTokens, + rawUserInfoFromProvider, + }): Promise<{ + status: "OK"; + user: UserType; + recipeUserId: RecipeUserId; + oAuthTokens: OAuthTokens; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { [key: string]: any }; + fromUserInfoAPI?: { [key: string]: any }; + }; + }> { + const user = await getUser(userId, userContext); + + if (user === undefined) { + throw new Error(`Failed to getUser from the userId ${userId} in the ${tenantId} tenant`); + } + + return { + status: "OK", + user, + recipeUserId: new RecipeUserId(userId), + oAuthTokens, + rawUserInfoFromProvider, + }; + }, + getProviderConfig: async function () { + if (providerConfigWithOIDCInfo !== null) { + return providerConfigWithOIDCInfo; + } + const oidcInfo = await getOIDCDiscoveryInfo(config.providerConfig.oidcDiscoveryEndpoint); + + if (oidcInfo.authorization_endpoint === undefined) { + throw new Error("Failed to authorization_endpoint from the oidcDiscoveryEndpoint."); + } + 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.jwks_uri === undefined) { + throw new Error("Failed to jwks_uri from the oidcDiscoveryEndpoint."); + } + + providerConfigWithOIDCInfo = { + ...config.providerConfig, + authorizationEndpoint: oidcInfo.authorization_endpoint, + tokenEndpoint: oidcInfo.token_endpoint, + userInfoEndpoint: oidcInfo.userinfo_endpoint, + jwksURI: oidcInfo.jwks_uri, + }; + return providerConfigWithOIDCInfo; + }, + exchangeAuthCodeForOAuthTokens: async function (this: RecipeInterface, { providerConfig, redirectURIInfo }) { + if (providerConfig.tokenEndpoint === undefined) { + throw new Error("OAuth2Client provider's tokenEndpoint is not configured."); + } + const tokenAPIURL = providerConfig.tokenEndpoint; + const accessTokenAPIParams: { [key: string]: string } = { + client_id: providerConfig.clientId, + redirect_uri: redirectURIInfo.redirectURIOnProviderDashboard, + code: redirectURIInfo.redirectURIQueryParams["code"], + grant_type: "authorization_code", + }; + if (providerConfig.clientSecret !== undefined) { + accessTokenAPIParams["client_secret"] = providerConfig.clientSecret; + } + if (redirectURIInfo.pkceCodeVerifier !== undefined) { + accessTokenAPIParams["code_verifier"] = redirectURIInfo.pkceCodeVerifier; + } + + const tokenResponse = await doPostRequest(tokenAPIURL, accessTokenAPIParams); + + if (tokenResponse.status >= 400) { + logDebugMessage( + `Received response with status ${tokenResponse.status} and body ${tokenResponse.stringResponse}` + ); + throw new Error( + `Received response with status ${tokenResponse.status} and body ${tokenResponse.stringResponse}` + ); + } + + return tokenResponse.jsonResponse as OAuthTokenResponse; + }, + getUserInfo: async function ({ providerConfig, oAuthTokens }): Promise { + let jwks: JWTVerifyGetKey | undefined; + + const accessToken = oAuthTokens["access_token"]; + const idToken = oAuthTokens["id_token"]; + + let rawUserInfoFromProvider: { + fromUserInfoAPI: any; + fromIdTokenPayload: any; + } = { + fromUserInfoAPI: {}, + fromIdTokenPayload: {}, + }; + + if (idToken && providerConfig.jwksURI !== undefined) { + if (jwks === undefined) { + jwks = createRemoteJWKSet(new URL(providerConfig.jwksURI)); + } + + rawUserInfoFromProvider.fromIdTokenPayload = await verifyIdTokenFromJWKSEndpointAndGetPayload( + idToken, + jwks, + { + audience: providerConfig.clientId, + } + ); + } + + if (accessToken && providerConfig.userInfoEndpoint !== undefined) { + const headers: { [key: string]: string } = { + Authorization: "Bearer " + accessToken, + }; + const queryParams: { [key: string]: string } = {}; + + const userInfoFromAccessToken = await doGetRequest( + providerConfig.userInfoEndpoint, + queryParams, + headers + ); + + if (userInfoFromAccessToken.status >= 400) { + logDebugMessage( + `Received response with status ${userInfoFromAccessToken.status} and body ${userInfoFromAccessToken.stringResponse}` + ); + throw new Error( + `Received response with status ${userInfoFromAccessToken.status} and body ${userInfoFromAccessToken.stringResponse}` + ); + } + + rawUserInfoFromProvider.fromUserInfoAPI = userInfoFromAccessToken.jsonResponse; + } + + let userId: string | undefined = undefined; + + if (rawUserInfoFromProvider.fromIdTokenPayload !== undefined) { + userId = rawUserInfoFromProvider.fromIdTokenPayload["sub"]; + } else if (rawUserInfoFromProvider.fromUserInfoAPI !== undefined) { + userId = rawUserInfoFromProvider.fromUserInfoAPI["sub"]; + } + + if (userId === undefined) { + throw new Error(`Failed to get userId from both the idToken and userInfo endpoint.`); + } + + return { + userId, + rawUserInfoFromProvider, + }; + }, + }; +} diff --git a/lib/ts/recipe/oauth2client/types.ts b/lib/ts/recipe/oauth2client/types.ts new file mode 100644 index 0000000000..75d3a55c2c --- /dev/null +++ b/lib/ts/recipe/oauth2client/types.ts @@ -0,0 +1,183 @@ +/* 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 type { BaseRequest, BaseResponse } from "../../framework"; +import { NormalisedAppinfo, UserContext } from "../../types"; +import OverrideableBuilder from "supertokens-js-override"; +import { SessionContainerInterface } from "../session/types"; +import { GeneralErrorResponse, User } from "../../types"; +import RecipeUserId from "../../recipeUserId"; + +export type UserInfo = { + userId: string; + rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any }; fromUserInfoAPI?: { [key: string]: any } }; +}; + +export type ProviderConfigInput = { + clientId: string; + clientSecret: string; + authorizationEndpointQueryParams?: { [key: string]: string | null }; + oidcDiscoveryEndpoint: string; + scope?: string[]; + forcePKCE?: boolean; +}; + +export type ProviderConfigWithOIDCInfo = ProviderConfigInput & { + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + jwksURI: string; +}; + +export type OAuthTokens = { + access_token?: string; + id_token?: string; +}; + +export type OAuthTokenResponse = { + access_token: string; + id_token?: string; + refresh_token?: string; + expires_in: number; + scope?: string; + token_type: string; +}; + +export type TypeInput = { + providerConfig: ProviderConfigInput; + override?: { + functions?: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + apis?: (originalImplementation: APIInterface, builder?: OverrideableBuilder) => APIInterface; + }; +}; + +export type TypeNormalisedInput = { + providerConfig: ProviderConfigInput; + override: { + functions: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + apis: (originalImplementation: APIInterface, builder?: OverrideableBuilder) => APIInterface; + }; +}; + +export type RecipeInterface = { + getAuthorisationRedirectURL(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIOnProviderDashboard: string; + userContext: UserContext; + }): Promise<{ + urlWithQueryParams: string; + pkceCodeVerifier?: string; + }>; + getProviderConfig(input: { userContext: UserContext }): Promise; + + signIn(input: { + userId: string; + oAuthTokens: OAuthTokens; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { [key: string]: any }; + fromUserInfoAPI?: { [key: string]: any }; + }; + tenantId: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + recipeUserId: RecipeUserId; + user: User; + oAuthTokens: OAuthTokens; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { [key: string]: any }; + fromUserInfoAPI?: { [key: string]: any }; + }; + }>; + exchangeAuthCodeForOAuthTokens(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }; + userContext: UserContext; + }): Promise; + getUserInfo(input: { + providerConfig: ProviderConfigWithOIDCInfo; + oAuthTokens: OAuthTokens; + userContext: UserContext; + }): Promise; +}; + +export type APIOptions = { + recipeImplementation: RecipeInterface; + config: TypeNormalisedInput; + recipeId: string; + isInServerlessEnv: boolean; + req: BaseRequest; + res: BaseResponse; + appInfo: NormalisedAppinfo; +}; + +export type APIInterface = { + authorisationUrlGET: + | undefined + | ((input: { + redirectURIOnProviderDashboard: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + urlWithQueryParams: string; + pkceCodeVerifier?: string; + } + | GeneralErrorResponse + >); + + signInPOST: ( + input: { + tenantId: string; + session: SessionContainerInterface | undefined; + options: APIOptions; + userContext: UserContext; + } & ( + | { + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string; + }; + } + | { + oAuthTokens: { [key: string]: any }; + } + ) + ) => Promise< + | { + status: "OK"; + user: User; + session: SessionContainerInterface; + oAuthTokens: { [key: string]: any }; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { [key: string]: any }; + fromUserInfoAPI?: { [key: string]: any }; + }; + } + | GeneralErrorResponse + >; +}; diff --git a/lib/ts/recipe/oauth2client/utils.ts b/lib/ts/recipe/oauth2client/utils.ts new file mode 100644 index 0000000000..925d67d662 --- /dev/null +++ b/lib/ts/recipe/oauth2client/utils.ts @@ -0,0 +1,53 @@ +/* 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 { NormalisedAppinfo } from "../../types"; +import { TypeInput, TypeNormalisedInput, RecipeInterface, APIInterface } from "./types"; + +export function validateAndNormaliseUserInput(_appInfo: NormalisedAppinfo, config: TypeInput): TypeNormalisedInput { + if (config === undefined || config.providerConfig === undefined) { + throw new Error("Please pass providerConfig argument in the OAuth2Client recipe."); + } + + if (config.providerConfig.clientId === undefined) { + throw new Error("Please pass clientId argument in the OAuth2Client providerConfig."); + } + + // TODO: Decide on the prefix and also if we will allow users to customise clientIds + // if (!config.providerConfig.clientId.startsWith("supertokens_")) { + // throw new Error( + // `Only Supertokens OAuth ClientIds are supported in the OAuth2Client recipe. For any other OAuth Clients use the thirdparty recipe.` + // ); + // } + + if (config.providerConfig.clientSecret === undefined) { + throw new Error("Please pass clientSecret argument in the OAuth2Client providerConfig."); + } + + if (config.providerConfig.oidcDiscoveryEndpoint === undefined) { + throw new Error("Please pass oidcDiscoveryEndpoint argument in the OAuth2Client providerConfig."); + } + + let override = { + functions: (originalImplementation: RecipeInterface) => originalImplementation, + apis: (originalImplementation: APIInterface) => originalImplementation, + ...config?.override, + }; + + return { + providerConfig: config.providerConfig, + override, + }; +} diff --git a/lib/ts/recipe/thirdparty/providers/bitbucket.ts b/lib/ts/recipe/thirdparty/providers/bitbucket.ts index 56cc8073f9..6d5ace5e59 100644 --- a/lib/ts/recipe/thirdparty/providers/bitbucket.ts +++ b/lib/ts/recipe/thirdparty/providers/bitbucket.ts @@ -14,7 +14,7 @@ */ import { ProviderInput, TypeProvider } from "../types"; -import { doGetRequest } from "./utils"; +import { doGetRequest } from "../../../thirdpartyUtils"; import NewProvider from "./custom"; import { logDebugMessage } from "../../../logger"; diff --git a/lib/ts/recipe/thirdparty/providers/custom.ts b/lib/ts/recipe/thirdparty/providers/custom.ts index 6e24fc0b69..ba7226836e 100644 --- a/lib/ts/recipe/thirdparty/providers/custom.ts +++ b/lib/ts/recipe/thirdparty/providers/custom.ts @@ -1,5 +1,5 @@ import { TypeProvider, ProviderInput, UserInfo, ProviderConfigForClientType } from "../types"; -import { doGetRequest, doPostRequest, verifyIdTokenFromJWKSEndpointAndGetPayload } from "./utils"; +import { doGetRequest, doPostRequest, verifyIdTokenFromJWKSEndpointAndGetPayload } from "../../../thirdpartyUtils"; import pkceChallenge from "pkce-challenge"; import { getProviderConfigForClient } from "./configUtils"; import { JWTVerifyGetKey, createRemoteJWKSet } from "jose"; diff --git a/lib/ts/recipe/thirdparty/providers/github.ts b/lib/ts/recipe/thirdparty/providers/github.ts index f556eba71c..e1f218143d 100644 --- a/lib/ts/recipe/thirdparty/providers/github.ts +++ b/lib/ts/recipe/thirdparty/providers/github.ts @@ -14,7 +14,7 @@ */ import { ProviderInput, TypeProvider, UserInfo } from "../types"; import NewProvider from "./custom"; -import { doGetRequest, doPostRequest } from "./utils"; +import { doGetRequest, doPostRequest } from "../../../thirdpartyUtils"; function getSupertokensUserInfoFromRawUserInfoResponseForGithub(rawUserInfoResponse: { fromIdTokenPayload?: any; diff --git a/lib/ts/recipe/thirdparty/providers/linkedin.ts b/lib/ts/recipe/thirdparty/providers/linkedin.ts index 5aa79976fb..c179b0269c 100644 --- a/lib/ts/recipe/thirdparty/providers/linkedin.ts +++ b/lib/ts/recipe/thirdparty/providers/linkedin.ts @@ -15,7 +15,7 @@ import { logDebugMessage } from "../../../logger"; import { ProviderInput, TypeProvider } from "../types"; import NewProvider from "./custom"; -import { doGetRequest } from "./utils"; +import { doGetRequest } from "../../../thirdpartyUtils"; export default function Linkedin(input: ProviderInput): TypeProvider { if (input.config.name === undefined) { diff --git a/lib/ts/recipe/thirdparty/providers/twitter.ts b/lib/ts/recipe/thirdparty/providers/twitter.ts index cb60db8d3a..083cf821f6 100644 --- a/lib/ts/recipe/thirdparty/providers/twitter.ts +++ b/lib/ts/recipe/thirdparty/providers/twitter.ts @@ -19,7 +19,7 @@ import NewProvider, { getActualClientIdFromDevelopmentClientId, isUsingDevelopmentClientId, } from "./custom"; -import { doPostRequest } from "./utils"; +import { doPostRequest } from "../../../thirdpartyUtils"; export default function Twitter(input: ProviderInput): TypeProvider { if (input.config.name === undefined) { diff --git a/lib/ts/recipe/thirdparty/providers/utils.ts b/lib/ts/recipe/thirdparty/providers/utils.ts index a4e4a7d7b3..347c049423 100644 --- a/lib/ts/recipe/thirdparty/providers/utils.ts +++ b/lib/ts/recipe/thirdparty/providers/utils.ts @@ -1,127 +1,5 @@ -import * as jose from "jose"; - import { ProviderConfigForClientType } from "../types"; -import NormalisedURLDomain from "../../../normalisedURLDomain"; -import NormalisedURLPath from "../../../normalisedURLPath"; -import { logDebugMessage } from "../../../logger"; -import { doFetch } from "../../../utils"; - -export async function doGetRequest( - url: string, - queryParams?: { [key: string]: string }, - headers?: { [key: string]: string } -): Promise<{ - jsonResponse: Record | undefined; - status: number; - stringResponse: string; -}> { - logDebugMessage( - `GET request to ${url}, with query params ${JSON.stringify(queryParams)} and headers ${JSON.stringify(headers)}` - ); - if (headers?.["Accept"] === undefined) { - headers = { - ...headers, - Accept: "application/json", // few providers like github don't send back json response by default - }; - } - const finalURL = new URL(url); - finalURL.search = new URLSearchParams(queryParams).toString(); - let response = await doFetch(finalURL.toString(), { - headers: headers, - }); - - const stringResponse = await response.text(); - let jsonResponse: Record | undefined = undefined; - - if (response.status < 400) { - jsonResponse = JSON.parse(stringResponse); - } - - logDebugMessage(`Received response with status ${response.status} and body ${stringResponse}`); - return { - stringResponse, - status: response.status, - jsonResponse, - }; -} - -export async function doPostRequest( - url: string, - params: { [key: string]: any }, - headers?: { [key: string]: string } -): Promise<{ - jsonResponse: Record | undefined; - status: number; - stringResponse: string; -}> { - if (headers === undefined) { - headers = {}; - } - - headers["Content-Type"] = "application/x-www-form-urlencoded"; - headers["Accept"] = "application/json"; // few providers like github don't send back json response by default - - logDebugMessage( - `POST request to ${url}, with params ${JSON.stringify(params)} and headers ${JSON.stringify(headers)}` - ); - - const body = new URLSearchParams(params).toString(); - let response = await doFetch(url, { - method: "POST", - body, - headers, - }); - - const stringResponse = await response.text(); - let jsonResponse: Record | undefined = undefined; - - if (response.status < 400) { - jsonResponse = JSON.parse(stringResponse); - } - - logDebugMessage(`Received response with status ${response.status} and body ${stringResponse}`); - return { - stringResponse, - status: response.status, - jsonResponse, - }; -} - -export async function verifyIdTokenFromJWKSEndpointAndGetPayload( - idToken: string, - jwks: jose.JWTVerifyGetKey, - otherOptions: jose.JWTVerifyOptions -): Promise { - const { payload } = await jose.jwtVerify(idToken, jwks, otherOptions); - - return payload; -} - -// OIDC utils -var oidcInfoMap: { [key: string]: any } = {}; - -async function getOIDCDiscoveryInfo(issuer: string): Promise { - const normalizedDomain = new NormalisedURLDomain(issuer); - let normalizedPath = new NormalisedURLPath(issuer); - const openIdConfigPath = new NormalisedURLPath("/.well-known/openid-configuration"); - - normalizedPath = normalizedPath.appendPath(openIdConfigPath); - - if (oidcInfoMap[issuer] !== undefined) { - return oidcInfoMap[issuer]; - } - const oidcInfo = await doGetRequest( - normalizedDomain.getAsStringDangerous() + normalizedPath.getAsStringDangerous() - ); - - if (oidcInfo.status >= 400) { - logDebugMessage(`Received response with status ${oidcInfo.status} and body ${oidcInfo.stringResponse}`); - throw new Error(`Received response with status ${oidcInfo.status} and body ${oidcInfo.stringResponse}`); - } - - oidcInfoMap[issuer] = oidcInfo.jsonResponse!; - return oidcInfo.jsonResponse!; -} +import { getOIDCDiscoveryInfo } from "../../../thirdpartyUtils"; export async function discoverOIDCEndpoints(config: ProviderConfigForClientType): Promise { if (config.oidcDiscoveryEndpoint !== undefined) { diff --git a/lib/ts/thirdpartyUtils.ts b/lib/ts/thirdpartyUtils.ts new file mode 100644 index 0000000000..349d29ad0c --- /dev/null +++ b/lib/ts/thirdpartyUtils.ts @@ -0,0 +1,122 @@ +import * as jose from "jose"; +import { logDebugMessage } from "./logger"; +import { doFetch } from "./utils"; +import NormalisedURLDomain from "./normalisedURLDomain"; +import NormalisedURLPath from "./normalisedURLPath"; + +export async function doGetRequest( + url: string, + queryParams?: { [key: string]: string }, + headers?: { [key: string]: string } +): Promise<{ + jsonResponse: Record | undefined; + status: number; + stringResponse: string; +}> { + logDebugMessage( + `GET request to ${url}, with query params ${JSON.stringify(queryParams)} and headers ${JSON.stringify(headers)}` + ); + if (headers?.["Accept"] === undefined) { + headers = { + ...headers, + Accept: "application/json", + }; + } + const finalURL = new URL(url); + finalURL.search = new URLSearchParams(queryParams).toString(); + let response = await doFetch(finalURL.toString(), { + headers: headers, + }); + + const stringResponse = await response.text(); + let jsonResponse: Record | undefined = undefined; + + if (response.status < 400) { + jsonResponse = JSON.parse(stringResponse); + } + + logDebugMessage(`Received response with status ${response.status} and body ${stringResponse}`); + return { + stringResponse, + status: response.status, + jsonResponse, + }; +} + +export async function doPostRequest( + url: string, + params: { [key: string]: any }, + headers?: { [key: string]: string } +): Promise<{ + jsonResponse: Record | undefined; + status: number; + stringResponse: string; +}> { + if (headers === undefined) { + headers = {}; + } + + headers["Content-Type"] = "application/x-www-form-urlencoded"; + headers["Accept"] = "application/json"; + + logDebugMessage( + `POST request to ${url}, with params ${JSON.stringify(params)} and headers ${JSON.stringify(headers)}` + ); + + const body = new URLSearchParams(params).toString(); + let response = await doFetch(url, { + method: "POST", + body, + headers, + }); + + const stringResponse = await response.text(); + let jsonResponse: Record | undefined = undefined; + + if (response.status < 400) { + jsonResponse = JSON.parse(stringResponse); + } + + logDebugMessage(`Received response with status ${response.status} and body ${stringResponse}`); + return { + stringResponse, + status: response.status, + jsonResponse, + }; +} + +export async function verifyIdTokenFromJWKSEndpointAndGetPayload( + idToken: string, + jwks: jose.JWTVerifyGetKey, + otherOptions: jose.JWTVerifyOptions +): Promise { + const { payload } = await jose.jwtVerify(idToken, jwks, otherOptions); + + return payload; +} + +// OIDC utils +var oidcInfoMap: { [key: string]: any } = {}; + +export async function getOIDCDiscoveryInfo(issuer: string): Promise { + const normalizedDomain = new NormalisedURLDomain(issuer); + let normalizedPath = new NormalisedURLPath(issuer); + const openIdConfigPath = new NormalisedURLPath("/.well-known/openid-configuration"); + + normalizedPath = normalizedPath.appendPath(openIdConfigPath); + + if (oidcInfoMap[issuer] !== undefined) { + return oidcInfoMap[issuer]; + } + const oidcInfo = await doGetRequest( + normalizedDomain.getAsStringDangerous() + normalizedPath.getAsStringDangerous() + ); + + if (oidcInfo.status >= 400) { + logDebugMessage(`Received response with status ${oidcInfo.status} and body ${oidcInfo.stringResponse}`); + throw new Error(`Received response with status ${oidcInfo.status} and body ${oidcInfo.stringResponse}`); + } + + oidcInfoMap[issuer] = oidcInfo.jsonResponse!; + return oidcInfo.jsonResponse!; +} diff --git a/recipe/oauth2client/index.d.ts b/recipe/oauth2client/index.d.ts new file mode 100644 index 0000000000..89f4241f83 --- /dev/null +++ b/recipe/oauth2client/index.d.ts @@ -0,0 +1,10 @@ +export * from "../../lib/build/recipe/oauth2client"; +/** + * 'export *' does not re-export a default. + * import NextJS from "supertokens-node/nextjs"; + * the above import statement won't be possible unless either + * - user add "esModuleInterop": true in their tsconfig.json file + * - we do the following change: + */ +import * as _default from "../../lib/build/recipe/oauth2client"; +export default _default; diff --git a/recipe/oauth2client/index.js b/recipe/oauth2client/index.js new file mode 100644 index 0000000000..f1b31d6dbd --- /dev/null +++ b/recipe/oauth2client/index.js @@ -0,0 +1,6 @@ +"use strict"; +function __export(m) { + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +exports.__esModule = true; +__export(require("../../lib/build/recipe/oauth2client")); diff --git a/recipe/oauth2client/types/index.d.ts b/recipe/oauth2client/types/index.d.ts new file mode 100644 index 0000000000..e475d45760 --- /dev/null +++ b/recipe/oauth2client/types/index.d.ts @@ -0,0 +1,10 @@ +export * from "../../../lib/build/recipe/oauth2client/types"; +/** + * 'export *' does not re-export a default. + * import NextJS from "supertokens-node/nextjs"; + * the above import statement won't be possible unless either + * - user add "esModuleInterop": true in their tsconfig.json file + * - we do the following change: + */ +import * as _default from "../../../lib/build/recipe/oauth2client/types"; +export default _default; diff --git a/recipe/oauth2client/types/index.js b/recipe/oauth2client/types/index.js new file mode 100644 index 0000000000..01b5c40c6e --- /dev/null +++ b/recipe/oauth2client/types/index.js @@ -0,0 +1,6 @@ +"use strict"; +function __export(m) { + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +exports.__esModule = true; +__export(require("../../../lib/build/recipe/oauth2client/types")); diff --git a/test/test-server/src/index.ts b/test/test-server/src/index.ts index 6402e5ab78..7ae66ec900 100644 --- a/test/test-server/src/index.ts +++ b/test/test-server/src/index.ts @@ -21,6 +21,9 @@ import { TypeInput as MFATypeInput } from "../../../lib/build/recipe/multifactor import TOTPRecipe from "../../../lib/build/recipe/totp/recipe"; import OAuth2Recipe from "../../../lib/build/recipe/oauth2/recipe"; import { TypeInput as OAuth2TypeInput } from "../../../lib/build/recipe/oauth2/types"; +import OAuth2ClientRecipe from "../../../lib/build/recipe/oauth2client/recipe"; +import { TypeInput as OAuth2ClientTypeInput } from "../../../lib/build/recipe/oauth2client/types"; +import { TypeInput as OpenIdRecipeTypeInput } from "../../../lib/build/recipe/openid/types"; import UserMetadataRecipe from "../../../lib/build/recipe/usermetadata/recipe"; import SuperTokensRecipe from "../../../lib/build/supertokens"; import { RecipeListFunction } from "../../../lib/build/types"; @@ -35,6 +38,7 @@ import { verifySession } from "../../../recipe/session/framework/express"; import ThirdParty from "../../../recipe/thirdparty"; import TOTP from "../../../recipe/totp"; import OAuth2 from "../../../recipe/oauth2"; +import OAuth2Client from "../../../recipe/oauth2client"; import accountlinkingRoutes from "./accountlinking"; import emailpasswordRoutes from "./emailpassword"; import emailverificationRoutes from "./emailverification"; @@ -89,6 +93,7 @@ function STReset() { MultiFactorAuthRecipe.reset(); TOTPRecipe.reset(); OAuth2Recipe.reset(); + OAuth2ClientRecipe.reset(); SuperTokensRecipe.reset(); } @@ -311,6 +316,24 @@ function initST(config: any) { } recipeList.push(OAuth2.init(initConfig)); } + if (recipe.recipeId === "oauth2client") { + let initConfig: OAuth2ClientTypeInput = { + ...config, + }; + if (initConfig.override?.functions) { + initConfig.override = { + ...initConfig.override, + functions: getFunc(`${initConfig.override.functions}`), + }; + } + if (initConfig.override?.apis) { + initConfig.override = { + ...initConfig.override, + apis: getFunc(`${initConfig.override.apis}`), + }; + } + recipeList.push(OAuth2Client.init(initConfig)); + } }); init.recipeList = recipeList;