From d292b0e9e05c800d8b074e2408aac51341a65062 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Thu, 13 Jun 2024 22:01:27 +0200 Subject: [PATCH 01/16] feat: add initial oauth2 client apis --- .gitignore | 3 +- lib/build/querier.d.ts | 7 +- lib/build/querier.js | 13 +- .../emailpassword/recipeImplementation.js | 1 + .../multitenancy/recipeImplementation.js | 2 + lib/build/recipe/oauth2/api/auth.d.ts | 8 + lib/build/recipe/oauth2/api/auth.js | 37 +++ lib/build/recipe/oauth2/api/consent.d.ts | 8 + lib/build/recipe/oauth2/api/consent.js | 63 ++++ lib/build/recipe/oauth2/api/implementation.js | 160 +++++++++- lib/build/recipe/oauth2/api/login.d.ts | 8 + lib/build/recipe/oauth2/api/login.js | 87 ++++++ lib/build/recipe/oauth2/api/logout.d.ts | 8 + lib/build/recipe/oauth2/api/logout.js | 57 ++++ lib/build/recipe/oauth2/api/token.d.ts | 8 + lib/build/recipe/oauth2/api/token.js | 30 ++ lib/build/recipe/oauth2/constants.d.ts | 5 + lib/build/recipe/oauth2/constants.js | 7 +- lib/build/recipe/oauth2/recipe.d.ts | 9 +- lib/build/recipe/oauth2/recipe.js | 96 +++++- .../recipe/oauth2/recipeImplementation.d.ts | 2 +- .../recipe/oauth2/recipeImplementation.js | 181 ++++++++++- lib/build/recipe/oauth2/types.d.ts | 216 ++++++++++++- .../api/getOpenIdDiscoveryConfiguration.js | 5 + lib/build/recipe/openid/index.d.ts | 5 + lib/build/recipe/openid/recipe.js | 2 +- .../recipe/openid/recipeImplementation.d.ts | 4 +- .../recipe/openid/recipeImplementation.js | 9 +- lib/build/recipe/openid/types.d.ts | 10 + .../passwordless/recipeImplementation.js | 1 + lib/build/recipe/session/index.d.ts | 5 + lib/build/recipe/session/sessionFunctions.js | 2 + lib/build/recipe/totp/recipeImplementation.js | 1 + .../usermetadata/recipeImplementation.js | 1 + .../recipe/userroles/recipeImplementation.js | 2 + lib/build/supertokens.js | 1 + lib/build/utils.js | 2 + lib/ts/querier.ts | 21 +- .../emailpassword/recipeImplementation.ts | 1 + .../multitenancy/recipeImplementation.ts | 2 + lib/ts/recipe/oauth2/api/auth.ts | 43 +++ lib/ts/recipe/oauth2/api/consent.ts | 66 ++++ lib/ts/recipe/oauth2/api/implementation.ts | 156 +++++++++- lib/ts/recipe/oauth2/api/login.ts | 76 +++++ lib/ts/recipe/oauth2/api/logout.ts | 63 ++++ lib/ts/recipe/oauth2/api/token.ts | 37 +++ lib/ts/recipe/oauth2/constants.ts | 6 + lib/ts/recipe/oauth2/recipe.ts | 103 ++++++- lib/ts/recipe/oauth2/recipeImplementation.ts | 190 +++++++++++- lib/ts/recipe/oauth2/types.ts | 284 +++++++++++++++++- .../api/getOpenIdDiscoveryConfiguration.ts | 5 + lib/ts/recipe/openid/api/implementation.ts | 11 +- lib/ts/recipe/openid/recipe.ts | 4 +- lib/ts/recipe/openid/recipeImplementation.ts | 19 +- lib/ts/recipe/openid/types.ts | 10 + .../passwordless/recipeImplementation.ts | 1 + lib/ts/recipe/session/sessionFunctions.ts | 2 + lib/ts/recipe/totp/recipeImplementation.ts | 1 + .../usermetadata/recipeImplementation.ts | 1 + .../recipe/userroles/recipeImplementation.ts | 8 +- lib/ts/supertokens.ts | 1 + lib/ts/utils.ts | 2 + test/querier.test.js | 2 +- test/with-typescript/index.ts | 5 + 64 files changed, 2114 insertions(+), 72 deletions(-) create mode 100644 lib/build/recipe/oauth2/api/auth.d.ts create mode 100644 lib/build/recipe/oauth2/api/auth.js create mode 100644 lib/build/recipe/oauth2/api/consent.d.ts create mode 100644 lib/build/recipe/oauth2/api/consent.js create mode 100644 lib/build/recipe/oauth2/api/login.d.ts create mode 100644 lib/build/recipe/oauth2/api/login.js create mode 100644 lib/build/recipe/oauth2/api/logout.d.ts create mode 100644 lib/build/recipe/oauth2/api/logout.js create mode 100644 lib/build/recipe/oauth2/api/token.d.ts create mode 100644 lib/build/recipe/oauth2/api/token.js create mode 100644 lib/ts/recipe/oauth2/api/auth.ts create mode 100644 lib/ts/recipe/oauth2/api/consent.ts create mode 100644 lib/ts/recipe/oauth2/api/login.ts create mode 100644 lib/ts/recipe/oauth2/api/logout.ts create mode 100644 lib/ts/recipe/oauth2/api/token.ts diff --git a/.gitignore b/.gitignore index d13b6071e..fdb5e981f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /node_modules +test/test-server/node_modules /examples/**/node_modules .DS_Store /.history @@ -10,4 +11,4 @@ releasePassword .tmp .idea /test_report -/.nyc_output \ No newline at end of file +/.nyc_output diff --git a/lib/build/querier.d.ts b/lib/build/querier.d.ts index 0e0ad52bd..7365599c2 100644 --- a/lib/build/querier.d.ts +++ b/lib/build/querier.d.ts @@ -49,7 +49,12 @@ export declare class Querier { body: any; headers: Headers; }>; - sendPutRequest: (path: NormalisedURLPath, body: any, userContext: UserContext) => Promise; + sendPutRequest: ( + path: NormalisedURLPath, + body: any, + params: Record, + userContext: UserContext + ) => Promise; invalidateCoreCallCache: (userContext: UserContext, updGlobalCacheTagIfNecessary?: boolean) => void; getAllCoreUrlsForPath(path: string): string[]; private sendRequestHelper; diff --git a/lib/build/querier.js b/lib/build/querier.js index 121687dcb..c6dd0c1d4 100644 --- a/lib/build/querier.js +++ b/lib/build/querier.js @@ -250,6 +250,9 @@ class Querier { method: "GET", headers, }); + if (response.status === 302) { + return response; + } if (response.status === 200 && !Querier.disableCache) { // If the request was successful, we save the result into the cache // plus we update the cache tag @@ -314,7 +317,7 @@ class Querier { ); }; // path should start with "/" - this.sendPutRequest = async (path, body, userContext) => { + this.sendPutRequest = async (path, body, params, userContext) => { var _a; this.invalidateCoreCallCache(userContext); const { body: respBody } = await this.sendRequestHelper( @@ -336,6 +339,7 @@ class Querier { method: "put", headers: headers, body: body, + params: params, }, userContext ); @@ -345,7 +349,12 @@ class Querier { body = request.body; } } - return utils_1.doFetch(url, { + const finalURL = new URL(url); + const searchParams = new URLSearchParams( + Object.entries(params).filter(([_, value]) => value !== undefined) + ); + finalURL.search = searchParams.toString(); + return utils_1.doFetch(finalURL.toString(), { method: "PUT", body: body !== undefined ? JSON.stringify(body) : undefined, headers, diff --git a/lib/build/recipe/emailpassword/recipeImplementation.js b/lib/build/recipe/emailpassword/recipeImplementation.js index 937dca20e..f3ccf2847 100644 --- a/lib/build/recipe/emailpassword/recipeImplementation.js +++ b/lib/build/recipe/emailpassword/recipeImplementation.js @@ -190,6 +190,7 @@ function getRecipeInterface(querier, getEmailPasswordConfig) { email: input.email, password: input.password, }, + {}, input.userContext ); if (response.status === "OK") { diff --git a/lib/build/recipe/multitenancy/recipeImplementation.js b/lib/build/recipe/multitenancy/recipeImplementation.js index cee1af3c8..31d8c09a4 100644 --- a/lib/build/recipe/multitenancy/recipeImplementation.js +++ b/lib/build/recipe/multitenancy/recipeImplementation.js @@ -16,6 +16,7 @@ function getRecipeInterface(querier) { let response = await querier.sendPutRequest( new normalisedURLPath_1.default(`/recipe/multitenancy/tenant`), Object.assign({ tenantId }, config), + {}, userContext ); return response; @@ -62,6 +63,7 @@ function getRecipeInterface(querier) { config, skipValidation, }, + {}, userContext ); return response; diff --git a/lib/build/recipe/oauth2/api/auth.d.ts b/lib/build/recipe/oauth2/api/auth.d.ts new file mode 100644 index 000000000..059876918 --- /dev/null +++ b/lib/build/recipe/oauth2/api/auth.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function authGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2/api/auth.js b/lib/build/recipe/oauth2/api/auth.js new file mode 100644 index 000000000..a67d42c32 --- /dev/null +++ b/lib/build/recipe/oauth2/api/auth.js @@ -0,0 +1,37 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +async function authGET(apiImplementation, options, userContext) { + if (apiImplementation.authGET === undefined) { + return false; + } + const origURL = options.req.getOriginalURL(); + const splitURL = origURL.split("?"); + const params = new URLSearchParams(splitURL[1]); + let response = await apiImplementation.authGET({ + options, + params: Object.fromEntries(params.entries()), + userContext, + }); + if ("redirectTo" in response) { + options.res.original.redirect(response.redirectTo); + } else { + utils_1.send200Response(options.res, response); + } + return true; +} +exports.default = authGET; diff --git a/lib/build/recipe/oauth2/api/consent.d.ts b/lib/build/recipe/oauth2/api/consent.d.ts new file mode 100644 index 000000000..b38df7de8 --- /dev/null +++ b/lib/build/recipe/oauth2/api/consent.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function consent( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2/api/consent.js b/lib/build/recipe/oauth2/api/consent.js new file mode 100644 index 000000000..b95113d71 --- /dev/null +++ b/lib/build/recipe/oauth2/api/consent.js @@ -0,0 +1,63 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +// TODO: separate post and get? +async function consent(apiImplementation, options, userContext) { + var _a; + if (utils_1.normaliseHttpMethod(options.req.getMethod()) === "post") { + if (apiImplementation.consentPOST === undefined) { + return false; + } + const reqBody = await options.req.getJSONBody(); + let response = await apiImplementation.consentPOST({ + options, + accept: reqBody.accept, + consentChallenge: reqBody.consentChallenge, + grantScope: reqBody.grantScope, + remember: reqBody.remember, + userContext, + }); + if ("status" in response) { + utils_1.send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } else { + if (apiImplementation.consentGET === undefined) { + return false; + } + const consentChallenge = + (_a = options.req.getKeyValueFromQuery("consentChallenge")) !== null && _a !== void 0 + ? _a + : options.req.getKeyValueFromQuery("consent_challenge"); + if (consentChallenge === undefined) { + throw new Error("TODO"); + } + let response = await apiImplementation.consentGET({ + options, + consentChallenge, + userContext, + }); + if ("status" in response) { + utils_1.send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } + return true; +} +exports.default = consent; diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index 4a0dd5c82..0a728d0e2 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -13,8 +13,166 @@ * 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 supertokens_1 = __importDefault(require("../../../supertokens")); function getAPIImplementation() { - return {}; + return { + loginGET: async ({ loginChallenge, options, session, userContext }) => { + var _a, _b; + const request = await options.recipeImplementation.getLoginRequest({ + challenge: loginChallenge, + userContext, + }); + if (request.skip) { + const accept = await options.recipeImplementation.acceptLoginRequest({ + challenge: loginChallenge, + subject: request.subject, + userContext, + }); + return { redirectTo: accept.redirectTo }; + } else if (session) { + if (session.getUserId() !== request.subject) { + // TODO? + } + const accept = await options.recipeImplementation.acceptLoginRequest({ + challenge: loginChallenge, + subject: session.getUserId(), + userContext, + }); + return { redirectTo: accept.redirectTo }; + } + const appInfo = supertokens_1.default.getInstanceOrThrowError().appInfo; + const websiteDomain = appInfo + .getOrigin({ + request: options.req, + userContext: userContext, + }) + .getAsStringDangerous(); + const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); + // TODO: + return { + redirectTo: + websiteDomain + + websiteBasePath + + `?hint=${ + (_b = (_a = request.oidcContext) === null || _a === void 0 ? void 0 : _a.login_hint) !== null && + _b !== void 0 + ? _b + : "" + }&redirectToPath=${encodeURIComponent( + "/continue-/auth/oauth2/login?login_challenge=" + loginChallenge + )}`, + }; + }, + loginPOST: async ({ loginChallenge, accept, options, session, userContext }) => { + const res = accept + ? await options.recipeImplementation.acceptLoginRequest({ + challenge: loginChallenge, + subject: session.getUserId(), + userContext, + }) + : await options.recipeImplementation.rejectLoginRequest({ + challenge: loginChallenge, + error: { error: "access_denied", errorDescription: "The resource owner denied the request" }, + userContext, + }); + return { redirectTo: res.redirectTo }; + }, + logoutGET: async ({ logoutChallenge, options, userContext }) => { + const request = await options.recipeImplementation.getLogoutRequest({ + challenge: logoutChallenge, + userContext, + }); + const appInfo = supertokens_1.default.getInstanceOrThrowError().appInfo; + return { + redirectTo: + appInfo + .getOrigin({ + request: options.req, + userContext: userContext, + }) + .getAsStringDangerous() + + appInfo.websiteBasePath.getAsStringDangerous() + + `/logout?challenge=${request.challenge}`, + }; + }, + logoutPOST: async ({ logoutChallenge, accept, options, userContext }) => { + if (accept) { + const res = await options.recipeImplementation.acceptLogoutRequest({ + challenge: logoutChallenge, + userContext, + }); + return { redirectTo: res.redirectTo }; + } + await options.recipeImplementation.rejectLogoutRequest({ + challenge: logoutChallenge, + // error: { error: "access_denied", errorDescription: "The resource owner denied the request" }, + userContext, + }); + const appInfo = supertokens_1.default.getInstanceOrThrowError().appInfo; + return { + redirectTo: + appInfo + .getOrigin({ + request: options.req, + userContext: userContext, + }) + .getAsStringDangerous() + appInfo.websiteBasePath.getAsStringDangerous(), + }; + }, + consentGET: async ({ consentChallenge, options, userContext }) => { + const request = await options.recipeImplementation.getConsentRequest({ + challenge: consentChallenge, + userContext, + }); + const appInfo = supertokens_1.default.getInstanceOrThrowError().appInfo; + return { + redirectTo: + appInfo + .getOrigin({ + request: options.req, + userContext: userContext, + }) + .getAsStringDangerous() + + appInfo.websiteBasePath.getAsStringDangerous() + + `/consent?challenge=${request.challenge}&scopes=${request.requestedScope}&client=${request.client}&`, + }; + }, + consentPOST: async ({ consentChallenge, accept, remember, grantScope, options, userContext }) => { + const request = await options.recipeImplementation.getConsentRequest({ + challenge: consentChallenge, + userContext, + }); + const res = accept + ? await options.recipeImplementation.acceptConsentRequest({ + challenge: consentChallenge, + grantAccessTokenAudience: request.requestedAccessTokenAudience, + remember, + grantScope, + userContext, + }) + : await options.recipeImplementation.rejectConsentRequest({ + challenge: consentChallenge, + error: { error: "access_denied", errorDescription: "The resource owner denied the request" }, + userContext, + }); + return { redirectTo: res.redirectTo }; + }, + authGET: async (input) => { + const res = await input.options.recipeImplementation.authorization({ + params: input.params, + userContext: input.userContext, + }); + return res; + }, + tokenPOST: async (input) => { + return input.options.recipeImplementation.token({ body: input.body, userContext: input.userContext }); + }, + }; } exports.default = getAPIImplementation; diff --git a/lib/build/recipe/oauth2/api/login.d.ts b/lib/build/recipe/oauth2/api/login.d.ts new file mode 100644 index 000000000..6f4253cef --- /dev/null +++ b/lib/build/recipe/oauth2/api/login.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function login( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2/api/login.js b/lib/build/recipe/oauth2/api/login.js new file mode 100644 index 000000000..878d1a6a8 --- /dev/null +++ b/lib/build/recipe/oauth2/api/login.js @@ -0,0 +1,87 @@ +"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 session_1 = __importDefault(require("../../session")); +// TODO: separate post and get? +async function login(apiImplementation, options, userContext) { + var _a; + if (utils_1.normaliseHttpMethod(options.req.getMethod()) === "post") { + if (apiImplementation.loginPOST === undefined) { + return false; + } + const session = await session_1.default.getSession( + options.req, + options.res, + { sessionRequired: true }, + userContext + ); + const reqBody = await options.req.getJSONBody(); + let response = await apiImplementation.loginPOST({ + options, + accept: reqBody.accept, + loginChallenge: reqBody.loginChallenge, + session, + userContext, + }); + if ("status" in response) { + utils_1.send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } else { + if (apiImplementation.loginGET === undefined) { + return false; + } + let session; + try { + session = await session_1.default.getSession( + options.req, + options.res, + { sessionRequired: false }, + userContext + ); + } catch (_b) { + // TODO: Claim validation failure + } + // TODO: take only one + const loginChallenge = + (_a = options.req.getKeyValueFromQuery("login_challenge")) !== null && _a !== void 0 + ? _a + : options.req.getKeyValueFromQuery("loginChallenge"); + if (loginChallenge === undefined) { + throw new Error("TODO"); + } + let response = await apiImplementation.loginGET({ + options, + loginChallenge, + session, + userContext, + }); + if ("status" in response) { + utils_1.send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } + return true; +} +exports.default = login; diff --git a/lib/build/recipe/oauth2/api/logout.d.ts b/lib/build/recipe/oauth2/api/logout.d.ts new file mode 100644 index 000000000..d9242afff --- /dev/null +++ b/lib/build/recipe/oauth2/api/logout.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function logout( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2/api/logout.js b/lib/build/recipe/oauth2/api/logout.js new file mode 100644 index 000000000..ed9d07e8d --- /dev/null +++ b/lib/build/recipe/oauth2/api/logout.js @@ -0,0 +1,57 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +// TODO: separate post and get? +async function logout(apiImplementation, options, userContext) { + if (utils_1.normaliseHttpMethod(options.req.getMethod()) === "post") { + if (apiImplementation.logoutPOST === undefined) { + return false; + } + const reqBody = await options.req.getJSONBody(); + let response = await apiImplementation.logoutPOST({ + options, + accept: reqBody.accept, + logoutChallenge: reqBody.logoutChallenge, + userContext, + }); + if ("status" in response) { + utils_1.send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } else { + if (apiImplementation.logoutGET === undefined) { + return false; + } + const logoutChallenge = options.req.getKeyValueFromQuery("logoutChallenge"); + if (logoutChallenge === undefined) { + throw new Error("TODO"); + } + let response = await apiImplementation.logoutGET({ + options, + logoutChallenge, + userContext, + }); + if ("status" in response) { + utils_1.send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } + return true; +} +exports.default = logout; diff --git a/lib/build/recipe/oauth2/api/token.d.ts b/lib/build/recipe/oauth2/api/token.d.ts new file mode 100644 index 000000000..c697b7744 --- /dev/null +++ b/lib/build/recipe/oauth2/api/token.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function tokenPOST( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2/api/token.js b/lib/build/recipe/oauth2/api/token.js new file mode 100644 index 000000000..2a7c8d03d --- /dev/null +++ b/lib/build/recipe/oauth2/api/token.js @@ -0,0 +1,30 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +async function tokenPOST(apiImplementation, options, userContext) { + if (apiImplementation.tokenPOST === undefined) { + return false; + } + let response = await apiImplementation.tokenPOST({ + options, + body: options.req.getFormData(), + userContext, + }); + utils_1.send200Response(options.res, response); + return true; +} +exports.default = tokenPOST; diff --git a/lib/build/recipe/oauth2/constants.d.ts b/lib/build/recipe/oauth2/constants.d.ts index 0d55262a7..cff481248 100644 --- a/lib/build/recipe/oauth2/constants.d.ts +++ b/lib/build/recipe/oauth2/constants.d.ts @@ -1,2 +1,7 @@ // @ts-nocheck export declare const OAUTH2_BASE_PATH = "/oauth2/"; +export declare const LOGIN_PATH = "/oauth2/login"; +export declare const LOGOUT_PATH = "/oauth2/logout"; +export declare const CONSENT_PATH = "/oauth2/consent"; +export declare const AUTH_PATH = "/oauth2/auth"; +export declare const TOKEN_PATH = "/oauth2/token"; diff --git a/lib/build/recipe/oauth2/constants.js b/lib/build/recipe/oauth2/constants.js index e8dc43a0d..6eb5434b8 100644 --- a/lib/build/recipe/oauth2/constants.js +++ b/lib/build/recipe/oauth2/constants.js @@ -14,5 +14,10 @@ * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.OAUTH2_BASE_PATH = void 0; +exports.TOKEN_PATH = exports.AUTH_PATH = exports.CONSENT_PATH = exports.LOGOUT_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; exports.OAUTH2_BASE_PATH = "/oauth2/"; +exports.LOGIN_PATH = "/oauth2/login"; +exports.LOGOUT_PATH = "/oauth2/logout"; +exports.CONSENT_PATH = "/oauth2/consent"; +exports.AUTH_PATH = "/oauth2/auth"; +exports.TOKEN_PATH = "/oauth2/token"; diff --git a/lib/build/recipe/oauth2/recipe.d.ts b/lib/build/recipe/oauth2/recipe.d.ts index c979f3d0c..2bab64401 100644 --- a/lib/build/recipe/oauth2/recipe.d.ts +++ b/lib/build/recipe/oauth2/recipe.d.ts @@ -13,18 +13,19 @@ export default class Recipe extends RecipeModule { apiImpl: APIInterface; isInServerlessEnv: boolean; constructor(recipeId: string, appInfo: NormalisedAppinfo, isInServerlessEnv: boolean, config?: TypeInput); + static getInstance(): Recipe | undefined; static getInstanceOrThrowError(): Recipe; static init(config?: TypeInput): RecipeListFunction; static reset(): void; getAPIsHandled(): APIHandled[]; handleAPIRequest: ( - _id: string, + id: string, _tenantId: string | undefined, - _req: BaseRequest, - _res: BaseResponse, + req: BaseRequest, + res: BaseResponse, _path: NormalisedURLPath, _method: HTTPMethod, - _userContext: UserContext + userContext: UserContext ) => Promise; handleError(error: error, _: BaseRequest, __: BaseResponse, _userContext: UserContext): Promise; getAllCORSHeaders(): string[]; diff --git a/lib/build/recipe/oauth2/recipe.js b/lib/build/recipe/oauth2/recipe.js index 44be8d270..cc397ccd8 100644 --- a/lib/build/recipe/oauth2/recipe.js +++ b/lib/build/recipe/oauth2/recipe.js @@ -20,25 +20,47 @@ var __importDefault = }; Object.defineProperty(exports, "__esModule", { value: true }); const error_1 = __importDefault(require("../../error")); +const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); const querier_1 = require("../../querier"); const recipeModule_1 = __importDefault(require("../../recipeModule")); +const auth_1 = __importDefault(require("./api/auth")); +const consent_1 = __importDefault(require("./api/consent")); const implementation_1 = __importDefault(require("./api/implementation")); +const login_1 = __importDefault(require("./api/login")); +const logout_1 = __importDefault(require("./api/logout")); +const token_1 = __importDefault(require("./api/token")); +const constants_1 = require("./constants"); const recipeImplementation_1 = __importDefault(require("./recipeImplementation")); const utils_1 = require("./utils"); const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); class Recipe extends recipeModule_1.default { constructor(recipeId, appInfo, isInServerlessEnv, config) { super(recipeId, appInfo); - 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, - // }; - throw new Error("Not implemented"); + 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, + }; + if (id === constants_1.LOGIN_PATH) { + return login_1.default(this.apiImpl, options, userContext); + } + if (id === constants_1.LOGOUT_PATH) { + return logout_1.default(this.apiImpl, options, userContext); + } + if (id === constants_1.CONSENT_PATH) { + return consent_1.default(this.apiImpl, options, userContext); + } + if (id === constants_1.TOKEN_PATH) { + return token_1.default(this.apiImpl, options, userContext); + } + if (id === constants_1.AUTH_PATH) { + return auth_1.default(this.apiImpl, options, userContext); + } + throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; this.config = utils_1.validateAndNormaliseUserInput(this, appInfo, config); this.isInServerlessEnv = isInServerlessEnv; @@ -58,6 +80,9 @@ class Recipe extends recipeModule_1.default { } } /* Init functions */ + static getInstance() { + return Recipe.instance; + } static getInstanceOrThrowError() { if (Recipe.instance !== undefined) { return Recipe.instance; @@ -82,7 +107,56 @@ class Recipe extends recipeModule_1.default { } /* RecipeModule functions */ getAPIsHandled() { - return []; + return [ + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.LOGIN_PATH), + id: constants_1.LOGIN_PATH, + disabled: this.apiImpl.loginPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.LOGIN_PATH), + id: constants_1.LOGIN_PATH, + disabled: this.apiImpl.loginGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.LOGOUT_PATH), + id: constants_1.LOGOUT_PATH, + disabled: this.apiImpl.logoutPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.LOGOUT_PATH), + id: constants_1.LOGOUT_PATH, + disabled: this.apiImpl.logoutGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.CONSENT_PATH), + id: constants_1.CONSENT_PATH, + disabled: this.apiImpl.consentPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.CONSENT_PATH), + id: constants_1.CONSENT_PATH, + disabled: this.apiImpl.consentGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.TOKEN_PATH), + id: constants_1.TOKEN_PATH, + disabled: this.apiImpl.tokenPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.AUTH_PATH), + id: constants_1.AUTH_PATH, + disabled: this.apiImpl.authGET === undefined, + }, + ]; } handleError(error, _, __, _userContext) { throw error; diff --git a/lib/build/recipe/oauth2/recipeImplementation.d.ts b/lib/build/recipe/oauth2/recipeImplementation.d.ts index 513f5df92..d2a1542a2 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.d.ts +++ b/lib/build/recipe/oauth2/recipeImplementation.d.ts @@ -3,7 +3,7 @@ import { Querier } from "../../querier"; import { NormalisedAppinfo } from "../../types"; import { RecipeInterface, TypeNormalisedInput } from "./types"; export default function getRecipeInterface( - _querier: Querier, + querier: Querier, _config: TypeNormalisedInput, _appInfo: NormalisedAppinfo ): RecipeInterface; diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index 67c4636f5..9c2ec89f2 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -13,8 +13,185 @@ * 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 }); -function getRecipeInterface(_querier, _config, _appInfo) { - return {}; +const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); +function getRecipeInterface(querier, _config, _appInfo) { + return { + getLoginRequest: async function (input) { + const resp = await querier.sendGetRequest( + new normalisedURLPath_1.default("/recipe/oauth2/admin/oauth2/auth/requests/login"), + { login_challenge: input.challenge }, + input.userContext + ); + return { + challenge: resp.challenge, + client: resp.client, + oidcContext: resp.oidc_context, + requestUrl: resp.request_url, + requestedAccessTokenAudience: resp.requested_access_token_audience, + requestedScope: resp.requested_scope, + sessionId: resp.session_id, + skip: resp.skip, + subject: resp.subject, + }; + }, + acceptLoginRequest: async function (input) { + const resp = await querier.sendPutRequest( + new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/login/accept`), + { + acr: input.acr, + amr: input.amr, + context: input.context, + extend_session_lifespan: input.extendSessionLifespan, + force_subject_identifier: input.forceSubjectIdentifier, + identity_provider_session_id: input.identityProviderSessionId, + remember: input.remember, + remember_for: input.rememberFor, + subject: input.subject, + }, + { + login_challenge: input.challenge, + }, + input.userContext + ); + return { redirectTo: resp.redirect_to }; + }, + rejectLoginRequest: async function (input) { + const resp = await querier.sendPutRequest( + new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/login/reject`), + { + error: input.error.error, + error_debug: input.error.errorDebug, + error_description: input.error.errorDescription, + error_hint: input.error.errorHint, + status_code: input.error.statusCode, + }, + { + login_challenge: input.challenge, + }, + input.userContext + ); + return { redirectTo: resp.redirect_to }; + }, + getConsentRequest: async function (input) { + const resp = await querier.sendGetRequest( + new normalisedURLPath_1.default("/recipe/oauth2/admin/oauth2/auth/requests/consent"), + { consent_challenge: input.challenge }, + input.userContext + ); + return { + acr: resp.acr, + amr: resp.amr, + challenge: resp.challenge, + client: resp.client, + context: resp.context, + loginChallenge: resp.login_challenge, + loginSessionId: resp.login_session_id, + oidcContext: resp.oidc_context, + requestUrl: resp.request_url, + requestedAccessTokenAudience: resp.requested_access_token_audience, + requestedScope: resp.requested_scope, + skip: resp.skip, + subject: resp.subject, + }; + }, + acceptConsentRequest: async function (input) { + const resp = await querier.sendPutRequest( + new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/consent/accept`), + { + context: input.context, + grant_access_token_audience: input.grantAccessTokenAudience, + grant_scope: input.grantScope, + handled_at: input.handledAt, + remember: input.remember, + remember_for: input.rememberFor, + session: input.session, + }, + { + consent_challenge: input.challenge, + }, + input.userContext + ); + return { redirectTo: resp.redirect_to }; + }, + rejectConsentRequest: async function (input) { + const resp = await querier.sendPutRequest( + new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/consent/reject`), + { + error: input.error.error, + error_debug: input.error.errorDebug, + error_description: input.error.errorDescription, + error_hint: input.error.errorHint, + status_code: input.error.statusCode, + }, + { + consent_challenge: input.challenge, + }, + input.userContext + ); + return { redirectTo: resp.redirect_to }; + }, + getLogoutRequest: async function (input) { + const resp = await querier.sendGetRequest( + new normalisedURLPath_1.default("/recipe/oauth2/admin/oauth2/auth/requests/logout"), + { logout_challenge: input.challenge }, + input.userContext + ); + return { + challenge: resp.challenge, + client: resp.client, + requestUrl: resp.request_url, + rpInitiated: resp.rp_initiated, + sid: resp.sid, + subject: resp.subject, + }; + }, + acceptLogoutRequest: async function (input) { + const resp = await querier.sendPutRequest( + new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/consent/logout/accept`), + {}, + { + logout_challenge: input.challenge, + }, + input.userContext + ); + return { redirectTo: resp.redirect_to }; + }, + rejectLogoutRequest: async function (input) { + await querier.sendPutRequest( + new normalisedURLPath_1.default(`/recipe/oauth2/admin/oauth2/auth/requests/consent/logout/reject`), + {}, + { + logout_challenge: input.challenge, + }, + input.userContext + ); + }, + authorization: async function (input) { + const resp = await querier.sendGetRequestWithResponseHeaders( + new normalisedURLPath_1.default(`/recipe/oauth2/pub/auth`), + input.params, + input.userContext + ); + const redirectTo = resp.headers.get("Location"); + if (redirectTo === undefined) { + throw new Error(resp.body); + } + return { redirectTo }; + }, + token: async function (input) { + // TODO: Untested and suspicios + return querier.sendGetRequest( + new normalisedURLPath_1.default(`/recipe/oauth2/pub/token`), + input.body, + input.userContext + ); + }, + }; } exports.default = getRecipeInterface; diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index f2e26d16d..2aea9a4a3 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -1,6 +1,8 @@ // @ts-nocheck import type { BaseRequest, BaseResponse } from "../../framework"; import OverrideableBuilder from "supertokens-js-override"; +import { GeneralErrorResponse, JSONObject, UserContext } from "../../types"; +import { SessionContainerInterface } from "../session/types"; export declare type TypeInput = { override?: { functions?: ( @@ -27,5 +29,215 @@ export declare type APIOptions = { req: BaseRequest; res: BaseResponse; }; -export declare type RecipeInterface = {}; -export declare type APIInterface = {}; +export declare type OAuth2Client = {}; +export declare type ErrorOAuth2 = { + error: string; + errorDescription: string; + errorDebug?: string; + errorHint?: string; + statusCode?: number; +}; +export declare type ConsentRequest = { + acr?: string; + amr?: string[]; + challenge: string; + client?: OAuth2Client; + context?: JSONObject; + loginChallenge?: string; + loginSessionId?: string; + oidcContext?: any; + requestUrl?: string; + requestedAccessTokenAudience?: string[]; + requestedScope?: string[]; + skip?: boolean; + subject?: string; +}; +export declare type LoginRequest = { + challenge: string; + client: OAuth2Client; + oidcContext?: any; + requestUrl: string; + requestedAccessTokenAudience?: string[]; + requestedScope?: string[]; + sessionId?: string; + skip: boolean; + subject: string; +}; +export declare type LogoutRequest = { + challenge: string; + client: OAuth2Client; + requestUrl: string; + rpInitiated: boolean; + sid: string; + subject: string; +}; +export declare type TokenInfo = { + accessToken: string; + expiresIn: number; + idToken: string; + refreshToken: string; + scope: string; + tokenType: string; +}; +export declare type RecipeInterface = { + authorization(input: { + params: any; + userContext: UserContext; + }): Promise<{ + redirectTo: string; + }>; + token(input: { body: any; userContext: UserContext }): Promise; + getConsentRequest(input: { challenge: string; userContext: UserContext }): Promise; + acceptConsentRequest(input: { + challenge: string; + context?: any; + grantAccessTokenAudience?: string[]; + grantScope?: string[]; + handledAt?: string[]; + remember?: boolean; + rememberFor?: number; + session?: any; + userContext: UserContext; + }): Promise<{ + redirectTo: string; + }>; + rejectConsentRequest(input: { + challenge: string; + error: ErrorOAuth2; + userContext: UserContext; + }): Promise<{ + redirectTo: string; + }>; + getLoginRequest(input: { challenge: string; userContext: UserContext }): Promise; + acceptLoginRequest(input: { + challenge: string; + acr?: string; + amr?: string[]; + context?: any; + extendSessionLifespan?: boolean; + forceSubjectIdentifier?: string; + identityProviderSessionId?: string; + remember?: boolean; + rememberFor?: number; + subject: string; + userContext: UserContext; + }): Promise<{ + redirectTo: string; + }>; + rejectLoginRequest(input: { + challenge: string; + error: ErrorOAuth2; + userContext: UserContext; + }): Promise<{ + redirectTo: string; + }>; + getLogoutRequest(input: { challenge: string; userContext: UserContext }): Promise; + acceptLogoutRequest(input: { + challenge: string; + userContext: UserContext; + }): Promise<{ + redirectTo: string; + }>; + rejectLogoutRequest(input: { challenge: string; userContext: UserContext }): Promise; +}; +export declare type APIInterface = { + loginGET: + | undefined + | ((input: { + loginChallenge: string; + options: APIOptions; + session?: SessionContainerInterface; + userContext: UserContext; + }) => Promise< + | { + redirectTo: string; + } + | GeneralErrorResponse + >); + loginPOST: + | undefined + | ((input: { + loginChallenge: string; + accept: boolean; + session: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + redirectTo: string; + } + | GeneralErrorResponse + >); + logoutGET: + | undefined + | ((input: { + logoutChallenge: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + redirectTo: string; + } + | GeneralErrorResponse + >); + logoutPOST: + | undefined + | ((input: { + logoutChallenge: string; + accept: boolean; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + redirectTo: string; + } + | GeneralErrorResponse + >); + consentGET: + | undefined + | ((input: { + consentChallenge: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + redirectTo: string; + } + | GeneralErrorResponse + >); + consentPOST: + | undefined + | ((input: { + consentChallenge: string; + accept: boolean; + grantScope: string[]; + remember: boolean; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + redirectTo: string; + } + | GeneralErrorResponse + >); + authGET: + | undefined + | ((input: { + params: any; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + redirectTo: string; + } + | ErrorOAuth2 + | GeneralErrorResponse + >); + tokenPOST: + | undefined + | ((input: { + body: any; + options: APIOptions; + userContext: UserContext; + }) => Promise); +}; diff --git a/lib/build/recipe/openid/api/getOpenIdDiscoveryConfiguration.js b/lib/build/recipe/openid/api/getOpenIdDiscoveryConfiguration.js index b308bfffb..7e2fdb593 100644 --- a/lib/build/recipe/openid/api/getOpenIdDiscoveryConfiguration.js +++ b/lib/build/recipe/openid/api/getOpenIdDiscoveryConfiguration.js @@ -14,6 +14,11 @@ async function getOpenIdDiscoveryConfiguration(apiImplementation, options, userC utils_1.send200Response(options.res, { issuer: result.issuer, jwks_uri: result.jwks_uri, + authorization_endpoint: result.authorization_endpoint, + token_endpoint: result.token_endpoint, + subject_types_supported: result.subject_types_supported, + id_token_signing_alg_values_supported: result.id_token_signing_alg_values_supported, + response_types_supported: result.response_types_supported, }); } else { utils_1.send200Response(options.res, result); diff --git a/lib/build/recipe/openid/index.d.ts b/lib/build/recipe/openid/index.d.ts index e94fd0092..a7f961b19 100644 --- a/lib/build/recipe/openid/index.d.ts +++ b/lib/build/recipe/openid/index.d.ts @@ -8,6 +8,11 @@ export default class OpenIdRecipeWrapper { status: "OK"; issuer: string; jwks_uri: string; + authorization_endpoint: string; + token_endpoint: string; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + response_types_supported: string[]; }>; static createJWT( payload?: any, diff --git a/lib/build/recipe/openid/recipe.js b/lib/build/recipe/openid/recipe.js index fd7a67398..4e0eb29e1 100644 --- a/lib/build/recipe/openid/recipe.js +++ b/lib/build/recipe/openid/recipe.js @@ -79,7 +79,7 @@ class OpenIdRecipe extends recipeModule_1.default { override: this.config.override.jwtFeature, }); let builder = new supertokens_js_override_1.default( - recipeImplementation_1.default(this.config, this.jwtRecipe.recipeInterfaceImpl) + recipeImplementation_1.default(this.config, this.jwtRecipe.recipeInterfaceImpl, appInfo) ); this.recipeImplementation = builder.override(this.config.override.functions).build(); let apiBuilder = new supertokens_js_override_1.default(implementation_1.default()); diff --git a/lib/build/recipe/openid/recipeImplementation.d.ts b/lib/build/recipe/openid/recipeImplementation.d.ts index d4698099c..be9ecbb29 100644 --- a/lib/build/recipe/openid/recipeImplementation.d.ts +++ b/lib/build/recipe/openid/recipeImplementation.d.ts @@ -1,7 +1,9 @@ // @ts-nocheck import { RecipeInterface, TypeNormalisedInput } from "./types"; import { RecipeInterface as JWTRecipeInterface } from "../jwt/types"; +import { NormalisedAppinfo } from "../../types"; export default function getRecipeInterface( config: TypeNormalisedInput, - jwtRecipeImplementation: JWTRecipeInterface + jwtRecipeImplementation: JWTRecipeInterface, + appInfo: NormalisedAppinfo ): RecipeInterface; diff --git a/lib/build/recipe/openid/recipeImplementation.js b/lib/build/recipe/openid/recipeImplementation.js index 0edb22162..d3b582d2e 100644 --- a/lib/build/recipe/openid/recipeImplementation.js +++ b/lib/build/recipe/openid/recipeImplementation.js @@ -7,7 +7,8 @@ var __importDefault = Object.defineProperty(exports, "__esModule", { value: true }); const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); const constants_1 = require("../jwt/constants"); -function getRecipeInterface(config, jwtRecipeImplementation) { +const constants_2 = require("../oauth2/constants"); +function getRecipeInterface(config, jwtRecipeImplementation, appInfo) { return { getOpenIdDiscoveryConfiguration: async function () { let issuer = config.issuerDomain.getAsStringDangerous() + config.issuerPath.getAsStringDangerous(); @@ -16,10 +17,16 @@ function getRecipeInterface(config, jwtRecipeImplementation) { config.issuerPath .appendPath(new normalisedURLPath_1.default(constants_1.GET_JWKS_API)) .getAsStringDangerous(); + const apiBasePath = appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(); return { status: "OK", issuer, jwks_uri, + authorization_endpoint: apiBasePath + constants_2.AUTH_PATH, + token_endpoint: apiBasePath + constants_2.TOKEN_PATH, + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + response_types_supported: ["code", "id_token", "id_token token"], }; }, createJWT: async function ({ payload, validitySeconds, useStaticSigningKey, userContext }) { diff --git a/lib/build/recipe/openid/types.d.ts b/lib/build/recipe/openid/types.d.ts index 5b907a8d5..d99d7d529 100644 --- a/lib/build/recipe/openid/types.d.ts +++ b/lib/build/recipe/openid/types.d.ts @@ -66,6 +66,11 @@ export declare type APIInterface = { status: "OK"; issuer: string; jwks_uri: string; + authorization_endpoint: string; + token_endpoint: string; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + response_types_supported: string[]; } | GeneralErrorResponse >); @@ -77,6 +82,11 @@ export declare type RecipeInterface = { status: "OK"; issuer: string; jwks_uri: string; + authorization_endpoint: string; + token_endpoint: string; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + response_types_supported: string[]; }>; createJWT(input: { payload?: any; diff --git a/lib/build/recipe/passwordless/recipeImplementation.js b/lib/build/recipe/passwordless/recipeImplementation.js index 8a3782d64..2cf3a2aee 100644 --- a/lib/build/recipe/passwordless/recipeImplementation.js +++ b/lib/build/recipe/passwordless/recipeImplementation.js @@ -150,6 +150,7 @@ function getRecipeInterface(querier) { let response = await querier.sendPutRequest( new normalisedURLPath_1.default(`/recipe/user`), copyAndRemoveUserContextAndTenantId(input), + {}, input.userContext ); if (response.status !== "OK") { diff --git a/lib/build/recipe/session/index.d.ts b/lib/build/recipe/session/index.d.ts index 55fbe988b..b96971a45 100644 --- a/lib/build/recipe/session/index.d.ts +++ b/lib/build/recipe/session/index.d.ts @@ -177,6 +177,11 @@ export default class SessionWrapper { status: "OK"; issuer: string; jwks_uri: string; + authorization_endpoint: string; + token_endpoint: string; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + response_types_supported: string[]; }>; static fetchAndSetClaim( sessionHandle: string, diff --git a/lib/build/recipe/session/sessionFunctions.js b/lib/build/recipe/session/sessionFunctions.js index 1f3d91722..90c828ce8 100644 --- a/lib/build/recipe/session/sessionFunctions.js +++ b/lib/build/recipe/session/sessionFunctions.js @@ -453,6 +453,7 @@ async function updateSessionDataInDatabase(helpers, sessionHandle, newSessionDat sessionHandle, userDataInDatabase: newSessionData, }, + {}, userContext ); if (response.status === "UNAUTHORISED") { @@ -470,6 +471,7 @@ async function updateAccessTokenPayload(helpers, sessionHandle, newAccessTokenPa sessionHandle, userDataInJWT: newAccessTokenPayload, }, + {}, userContext ); if (response.status === "UNAUTHORISED") { diff --git a/lib/build/recipe/totp/recipeImplementation.js b/lib/build/recipe/totp/recipeImplementation.js index 5465f884c..5ad44874b 100644 --- a/lib/build/recipe/totp/recipeImplementation.js +++ b/lib/build/recipe/totp/recipeImplementation.js @@ -100,6 +100,7 @@ function getRecipeInterface(querier, config) { existingDeviceName: input.existingDeviceName, newDeviceName: input.newDeviceName, }, + {}, input.userContext ); }, diff --git a/lib/build/recipe/usermetadata/recipeImplementation.js b/lib/build/recipe/usermetadata/recipeImplementation.js index e3aa25a5f..2fd78898b 100644 --- a/lib/build/recipe/usermetadata/recipeImplementation.js +++ b/lib/build/recipe/usermetadata/recipeImplementation.js @@ -36,6 +36,7 @@ function getRecipeInterface(querier) { userId, metadataUpdate, }, + {}, userContext ); }, diff --git a/lib/build/recipe/userroles/recipeImplementation.js b/lib/build/recipe/userroles/recipeImplementation.js index ca12893f9..47dbdc1ba 100644 --- a/lib/build/recipe/userroles/recipeImplementation.js +++ b/lib/build/recipe/userroles/recipeImplementation.js @@ -29,6 +29,7 @@ function getRecipeInterface(querier) { `/${tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId}/recipe/user/role` ), { userId, role }, + {}, userContext ); }, @@ -63,6 +64,7 @@ function getRecipeInterface(querier) { return querier.sendPutRequest( new normalisedURLPath_1.default("/recipe/role"), { role, permissions }, + {}, userContext ); }, diff --git a/lib/build/supertokens.js b/lib/build/supertokens.js index 30486abfa..07b52a330 100644 --- a/lib/build/supertokens.js +++ b/lib/build/supertokens.js @@ -135,6 +135,7 @@ class SuperTokens { userIdType: input.userIdType, externalUserIdInfo: input.externalUserIdInfo, }, + {}, input.userContext ); } else { diff --git a/lib/build/utils.js b/lib/build/utils.js index 73a678826..db5118f3f 100644 --- a/lib/build/utils.js +++ b/lib/build/utils.js @@ -58,6 +58,7 @@ const doFetch = (input, init) => { ); init = { cache: "no-cache", + redirect: "manual", }; } else { if (init.cache === undefined) { @@ -65,6 +66,7 @@ const doFetch = (input, init) => { processState_1.PROCESS_STATE.ADDING_NO_CACHE_HEADER_IN_FETCH ); init.cache = "no-cache"; + init.redirect = "manual"; } } const fetchFunction = typeof fetch !== "undefined" ? fetch : cross_fetch_1.default; diff --git a/lib/ts/querier.ts b/lib/ts/querier.ts index 26daf1951..a60bf32d8 100644 --- a/lib/ts/querier.ts +++ b/lib/ts/querier.ts @@ -319,12 +319,15 @@ export class Querier { finalURL.search = searchParams.toString(); // Update cache and return - let response = await doFetch(finalURL.toString(), { method: "GET", headers, }); + if (response.status === 302) { + return response; + } + if (response.status === 200 && !Querier.disableCache) { // If the request was successful, we save the result into the cache // plus we update the cache tag @@ -400,7 +403,12 @@ export class Querier { }; // path should start with "/" - sendPutRequest = async (path: NormalisedURLPath, body: any, userContext: UserContext): Promise => { + sendPutRequest = async ( + path: NormalisedURLPath, + body: any, + params: Record, + userContext: UserContext + ): Promise => { this.invalidateCoreCallCache(userContext); const { body: respBody } = await this.sendRequestHelper( @@ -428,6 +436,7 @@ export class Querier { method: "put", headers: headers, body: body, + params: params, }, userContext ); @@ -438,7 +447,13 @@ export class Querier { } } - return doFetch(url, { + const finalURL = new URL(url); + const searchParams = new URLSearchParams( + Object.entries(params).filter(([_, value]) => value !== undefined) as string[][] + ); + finalURL.search = searchParams.toString(); + + return doFetch(finalURL.toString(), { method: "PUT", body: body !== undefined ? JSON.stringify(body) : undefined, headers, diff --git a/lib/ts/recipe/emailpassword/recipeImplementation.ts b/lib/ts/recipe/emailpassword/recipeImplementation.ts index e1f44b073..58e2e4f89 100644 --- a/lib/ts/recipe/emailpassword/recipeImplementation.ts +++ b/lib/ts/recipe/emailpassword/recipeImplementation.ts @@ -284,6 +284,7 @@ export default function getRecipeInterface( email: input.email, password: input.password, }, + {}, input.userContext ); diff --git a/lib/ts/recipe/multitenancy/recipeImplementation.ts b/lib/ts/recipe/multitenancy/recipeImplementation.ts index c6a655b84..c311b41c3 100644 --- a/lib/ts/recipe/multitenancy/recipeImplementation.ts +++ b/lib/ts/recipe/multitenancy/recipeImplementation.ts @@ -16,6 +16,7 @@ export default function getRecipeInterface(querier: Querier): RecipeInterface { tenantId, ...config, }, + {}, userContext ); @@ -68,6 +69,7 @@ export default function getRecipeInterface(querier: Querier): RecipeInterface { config, skipValidation, }, + {}, userContext ); return response; diff --git a/lib/ts/recipe/oauth2/api/auth.ts b/lib/ts/recipe/oauth2/api/auth.ts new file mode 100644 index 000000000..cdcb6fe37 --- /dev/null +++ b/lib/ts/recipe/oauth2/api/auth.ts @@ -0,0 +1,43 @@ +/* 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 { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; + +export default async function authGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.authGET === undefined) { + return false; + } + const origURL = options.req.getOriginalURL(); + const splitURL = origURL.split("?"); + const params = new URLSearchParams(splitURL[1]); + + let response = await apiImplementation.authGET({ + options, + params: Object.fromEntries(params.entries()), + userContext, + }); + if ("redirectTo" in response) { + options.res.original.redirect(response.redirectTo); + } else { + send200Response(options.res, response); + } + return true; +} diff --git a/lib/ts/recipe/oauth2/api/consent.ts b/lib/ts/recipe/oauth2/api/consent.ts new file mode 100644 index 000000000..efc254432 --- /dev/null +++ b/lib/ts/recipe/oauth2/api/consent.ts @@ -0,0 +1,66 @@ +/* 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 { normaliseHttpMethod, send200Response } from "../../../utils"; +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; + +// TODO: separate post and get? +export default async function consent( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (normaliseHttpMethod(options.req.getMethod()) === "post") { + if (apiImplementation.consentPOST === undefined) { + return false; + } + const reqBody = await options.req.getJSONBody(); + let response = await apiImplementation.consentPOST({ + options, + accept: reqBody.accept, + consentChallenge: reqBody.consentChallenge, + grantScope: reqBody.grantScope, + remember: reqBody.remember, + userContext, + }); + if ("status" in response) { + send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } else { + if (apiImplementation.consentGET === undefined) { + return false; + } + const consentChallenge = + options.req.getKeyValueFromQuery("consentChallenge") ?? + options.req.getKeyValueFromQuery("consent_challenge"); + if (consentChallenge === undefined) { + throw new Error("TODO"); + } + let response = await apiImplementation.consentGET({ + options, + consentChallenge, + userContext, + }); + if ("status" in response) { + send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } + return true; +} diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index 3cba9819d..d27fc68bc 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -13,8 +13,162 @@ * under the License. */ +import SuperTokens from "../../../supertokens"; import { APIInterface } from "../types"; export default function getAPIImplementation(): APIInterface { - return {}; + return { + loginGET: async ({ loginChallenge, options, session, userContext }) => { + const request = await options.recipeImplementation.getLoginRequest({ + challenge: loginChallenge, + userContext, + }); + + if (request.skip) { + const accept = await options.recipeImplementation.acceptLoginRequest({ + challenge: loginChallenge, + subject: request.subject, + userContext, + }); + return { redirectTo: accept.redirectTo }; + } else if (session) { + if (session.getUserId() !== request.subject) { + // TODO? + } + const accept = await options.recipeImplementation.acceptLoginRequest({ + challenge: loginChallenge, + subject: session.getUserId(), + userContext, + }); + return { redirectTo: accept.redirectTo }; + } + const appInfo = SuperTokens.getInstanceOrThrowError().appInfo; + const websiteDomain = appInfo + .getOrigin({ + request: options.req, + userContext: userContext, + }) + .getAsStringDangerous(); + const websiteBasePath = appInfo.websiteBasePath.getAsStringDangerous(); + // TODO: + return { + redirectTo: + websiteDomain + + websiteBasePath + + `?hint=${request.oidcContext?.login_hint ?? ""}&redirectToPath=${encodeURIComponent( + "/continue-/auth/oauth2/login?login_challenge=" + loginChallenge + )}`, + }; + }, + loginPOST: async ({ loginChallenge, accept, options, session, userContext }) => { + const res = accept + ? await options.recipeImplementation.acceptLoginRequest({ + challenge: loginChallenge, + subject: session.getUserId(), + userContext, + }) + : await options.recipeImplementation.rejectLoginRequest({ + challenge: loginChallenge, + error: { error: "access_denied", errorDescription: "The resource owner denied the request" }, + userContext, + }); + return { redirectTo: res.redirectTo }; + }, + + logoutGET: async ({ logoutChallenge, options, userContext }) => { + const request = await options.recipeImplementation.getLogoutRequest({ + challenge: logoutChallenge, + userContext, + }); + + const appInfo = SuperTokens.getInstanceOrThrowError().appInfo; + return { + redirectTo: + appInfo + .getOrigin({ + request: options.req, + userContext: userContext, + }) + .getAsStringDangerous() + + appInfo.websiteBasePath.getAsStringDangerous() + + `/logout?challenge=${request.challenge}`, + }; + }, + + logoutPOST: async ({ logoutChallenge, accept, options, userContext }) => { + if (accept) { + const res = await options.recipeImplementation.acceptLogoutRequest({ + challenge: logoutChallenge, + userContext, + }); + return { redirectTo: res.redirectTo }; + } + await options.recipeImplementation.rejectLogoutRequest({ + challenge: logoutChallenge, + // error: { error: "access_denied", errorDescription: "The resource owner denied the request" }, + userContext, + }); + const appInfo = SuperTokens.getInstanceOrThrowError().appInfo; + return { + redirectTo: + appInfo + .getOrigin({ + request: options.req, + userContext: userContext, + }) + .getAsStringDangerous() + appInfo.websiteBasePath.getAsStringDangerous(), + }; + }, + + consentGET: async ({ consentChallenge, options, userContext }) => { + const request = await options.recipeImplementation.getConsentRequest({ + challenge: consentChallenge, + userContext, + }); + + const appInfo = SuperTokens.getInstanceOrThrowError().appInfo; + return { + redirectTo: + appInfo + .getOrigin({ + request: options.req, + userContext: userContext, + }) + .getAsStringDangerous() + + appInfo.websiteBasePath.getAsStringDangerous() + + `/consent?challenge=${request.challenge}&scopes=${request.requestedScope}&client=${request.client}&`, + }; + }, + + consentPOST: async ({ consentChallenge, accept, remember, grantScope, options, userContext }) => { + const request = await options.recipeImplementation.getConsentRequest({ + challenge: consentChallenge, + userContext, + }); + const res = accept + ? await options.recipeImplementation.acceptConsentRequest({ + challenge: consentChallenge, + grantAccessTokenAudience: request.requestedAccessTokenAudience, + remember, + grantScope, + userContext, + }) + : await options.recipeImplementation.rejectConsentRequest({ + challenge: consentChallenge, + error: { error: "access_denied", errorDescription: "The resource owner denied the request" }, + userContext, + }); + return { redirectTo: res.redirectTo }; + }, + authGET: async (input) => { + const res = await input.options.recipeImplementation.authorization({ + params: input.params, + userContext: input.userContext, + }); + return res; + }, + tokenPOST: async (input) => { + return input.options.recipeImplementation.token({ body: input.body, userContext: input.userContext }); + }, + }; } diff --git a/lib/ts/recipe/oauth2/api/login.ts b/lib/ts/recipe/oauth2/api/login.ts new file mode 100644 index 000000000..f1912059a --- /dev/null +++ b/lib/ts/recipe/oauth2/api/login.ts @@ -0,0 +1,76 @@ +/* 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 { normaliseHttpMethod, send200Response } from "../../../utils"; +import { APIInterface, APIOptions } from ".."; +import Session from "../../session"; +import { UserContext } from "../../../types"; + +// TODO: separate post and get? +export default async function login( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (normaliseHttpMethod(options.req.getMethod()) === "post") { + if (apiImplementation.loginPOST === undefined) { + return false; + } + const session = await Session.getSession(options.req, options.res, { sessionRequired: true }, userContext); + const reqBody = await options.req.getJSONBody(); + let response = await apiImplementation.loginPOST({ + options, + accept: reqBody.accept, + loginChallenge: reqBody.loginChallenge, + session, + userContext, + }); + if ("status" in response) { + send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } else { + if (apiImplementation.loginGET === undefined) { + return false; + } + + let session; + try { + session = await Session.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch { + // TODO: Claim validation failure + } + + // TODO: take only one + const loginChallenge = + options.req.getKeyValueFromQuery("login_challenge") ?? options.req.getKeyValueFromQuery("loginChallenge"); + if (loginChallenge === undefined) { + throw new Error("TODO"); + } + let response = await apiImplementation.loginGET({ + options, + loginChallenge, + session, + userContext, + }); + if ("status" in response) { + send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } + return true; +} diff --git a/lib/ts/recipe/oauth2/api/logout.ts b/lib/ts/recipe/oauth2/api/logout.ts new file mode 100644 index 000000000..c827a42e4 --- /dev/null +++ b/lib/ts/recipe/oauth2/api/logout.ts @@ -0,0 +1,63 @@ +/* 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 { normaliseHttpMethod, send200Response } from "../../../utils"; +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; + +// TODO: separate post and get? +export default async function logout( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (normaliseHttpMethod(options.req.getMethod()) === "post") { + if (apiImplementation.logoutPOST === undefined) { + return false; + } + const reqBody = await options.req.getJSONBody(); + let response = await apiImplementation.logoutPOST({ + options, + accept: reqBody.accept, + logoutChallenge: reqBody.logoutChallenge, + userContext, + }); + if ("status" in response) { + send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } else { + if (apiImplementation.logoutGET === undefined) { + return false; + } + + const logoutChallenge = options.req.getKeyValueFromQuery("logoutChallenge"); + if (logoutChallenge === undefined) { + throw new Error("TODO"); + } + let response = await apiImplementation.logoutGET({ + options, + logoutChallenge, + userContext, + }); + if ("status" in response) { + send200Response(options.res, response); + } else { + options.res.original.redirect(response.redirectTo); + } + } + return true; +} diff --git a/lib/ts/recipe/oauth2/api/token.ts b/lib/ts/recipe/oauth2/api/token.ts new file mode 100644 index 000000000..1f4b85b60 --- /dev/null +++ b/lib/ts/recipe/oauth2/api/token.ts @@ -0,0 +1,37 @@ +/* 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 { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; + +export default async function tokenPOST( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.tokenPOST === undefined) { + return false; + } + + let response = await apiImplementation.tokenPOST({ + options, + body: options.req.getFormData(), + userContext, + }); + + send200Response(options.res, response); + return true; +} diff --git a/lib/ts/recipe/oauth2/constants.ts b/lib/ts/recipe/oauth2/constants.ts index 172ca6987..51daa6b6c 100644 --- a/lib/ts/recipe/oauth2/constants.ts +++ b/lib/ts/recipe/oauth2/constants.ts @@ -14,3 +14,9 @@ */ export const OAUTH2_BASE_PATH = "/oauth2/"; + +export const LOGIN_PATH = "/oauth2/login"; +export const LOGOUT_PATH = "/oauth2/logout"; +export const CONSENT_PATH = "/oauth2/consent"; +export const AUTH_PATH = "/oauth2/auth"; +export const TOKEN_PATH = "/oauth2/token"; diff --git a/lib/ts/recipe/oauth2/recipe.ts b/lib/ts/recipe/oauth2/recipe.ts index 72aa025ae..1de4a59fc 100644 --- a/lib/ts/recipe/oauth2/recipe.ts +++ b/lib/ts/recipe/oauth2/recipe.ts @@ -20,7 +20,13 @@ import NormalisedURLPath from "../../normalisedURLPath"; import { Querier } from "../../querier"; import RecipeModule from "../../recipeModule"; import { APIHandled, HTTPMethod, NormalisedAppinfo, RecipeListFunction, UserContext } from "../../types"; +import authGET from "./api/auth"; +import consentAPI from "./api/consent"; import APIImplementation from "./api/implementation"; +import loginAPI from "./api/login"; +import logoutAPI from "./api/logout"; +import tokenPOST from "./api/token"; +import { AUTH_PATH, CONSENT_PATH, LOGIN_PATH, LOGOUT_PATH, TOKEN_PATH } from "./constants"; import RecipeImplementation from "./recipeImplementation"; import { APIInterface, RecipeInterface, TypeInput, TypeNormalisedInput } from "./types"; import { validateAndNormaliseUserInput } from "./utils"; @@ -54,6 +60,9 @@ export default class Recipe extends RecipeModule { /* Init functions */ + static getInstance(): Recipe | undefined { + return Recipe.instance; + } static getInstanceOrThrowError(): Recipe { if (Recipe.instance !== undefined) { return Recipe.instance; @@ -82,28 +91,92 @@ export default class Recipe extends RecipeModule { /* RecipeModule functions */ getAPIsHandled(): APIHandled[] { - return []; + return [ + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(LOGIN_PATH), + id: LOGIN_PATH, + disabled: this.apiImpl.loginPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(LOGIN_PATH), + id: LOGIN_PATH, + disabled: this.apiImpl.loginGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(LOGOUT_PATH), + id: LOGOUT_PATH, + disabled: this.apiImpl.logoutPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(LOGOUT_PATH), + id: LOGOUT_PATH, + disabled: this.apiImpl.logoutGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(CONSENT_PATH), + id: CONSENT_PATH, + disabled: this.apiImpl.consentPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(CONSENT_PATH), + id: CONSENT_PATH, + disabled: this.apiImpl.consentGET === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(TOKEN_PATH), + id: TOKEN_PATH, + disabled: this.apiImpl.tokenPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(AUTH_PATH), + id: AUTH_PATH, + disabled: this.apiImpl.authGET === undefined, + }, + ]; } handleAPIRequest = async ( - _id: string, + id: string, _tenantId: string | undefined, - _req: BaseRequest, - _res: BaseResponse, + req: BaseRequest, + res: BaseResponse, _path: NormalisedURLPath, _method: HTTPMethod, - _userContext: UserContext + userContext: UserContext ): Promise => { - // let options = { - // config: this.config, - // recipeId: this.getRecipeId(), - // isInServerlessEnv: this.isInServerlessEnv, - // recipeImplementation: this.recipeInterfaceImpl, - // req, - // res, - // }; - - throw new Error("Not implemented"); + let options = { + config: this.config, + recipeId: this.getRecipeId(), + isInServerlessEnv: this.isInServerlessEnv, + recipeImplementation: this.recipeInterfaceImpl, + req, + res, + }; + + if (id === LOGIN_PATH) { + return loginAPI(this.apiImpl, options, userContext); + } + if (id === LOGOUT_PATH) { + return logoutAPI(this.apiImpl, options, userContext); + } + if (id === CONSENT_PATH) { + return consentAPI(this.apiImpl, options, userContext); + } + if (id === TOKEN_PATH) { + return tokenPOST(this.apiImpl, options, userContext); + } + if (id === AUTH_PATH) { + return authGET(this.apiImpl, options, userContext); + } + throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; handleError(error: error, _: BaseRequest, __: BaseResponse, _userContext: UserContext): Promise { diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index 1bd119bb2..266308164 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -13,14 +13,198 @@ * under the License. */ +import NormalisedURLPath from "../../normalisedURLPath"; import { Querier } from "../../querier"; import { NormalisedAppinfo } from "../../types"; -import { RecipeInterface, TypeNormalisedInput } from "./types"; +import { ConsentRequest, LoginRequest, LogoutRequest, RecipeInterface, TypeNormalisedInput } from "./types"; export default function getRecipeInterface( - _querier: Querier, + querier: Querier, _config: TypeNormalisedInput, _appInfo: NormalisedAppinfo ): RecipeInterface { - return {}; + return { + getLoginRequest: async function (this: RecipeInterface, input): Promise { + const resp = await querier.sendGetRequest( + new NormalisedURLPath("/recipe/oauth2/admin/oauth2/auth/requests/login"), + { login_challenge: input.challenge }, + input.userContext + ); + + return { + challenge: resp.challenge, + client: resp.client, + oidcContext: resp.oidc_context, + requestUrl: resp.request_url, + requestedAccessTokenAudience: resp.requested_access_token_audience, + requestedScope: resp.requested_scope, + sessionId: resp.session_id, + skip: resp.skip, + subject: resp.subject, + }; + }, + acceptLoginRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { + const resp = await querier.sendPutRequest( + new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/login/accept`), + { + acr: input.acr, + amr: input.amr, + context: input.context, + extend_session_lifespan: input.extendSessionLifespan, + force_subject_identifier: input.forceSubjectIdentifier, + identity_provider_session_id: input.identityProviderSessionId, + remember: input.remember, + remember_for: input.rememberFor, + subject: input.subject, + }, + { + login_challenge: input.challenge, + }, + input.userContext + ); + + return { redirectTo: resp.redirect_to }; + }, + rejectLoginRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { + const resp = await querier.sendPutRequest( + new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/login/reject`), + { + error: input.error.error, + error_debug: input.error.errorDebug, + error_description: input.error.errorDescription, + error_hint: input.error.errorHint, + status_code: input.error.statusCode, + }, + { + login_challenge: input.challenge, + }, + input.userContext + ); + + return { redirectTo: resp.redirect_to }; + }, + getConsentRequest: async function (this: RecipeInterface, input): Promise { + const resp = await querier.sendGetRequest( + new NormalisedURLPath("/recipe/oauth2/admin/oauth2/auth/requests/consent"), + { consent_challenge: input.challenge }, + input.userContext + ); + + return { + acr: resp.acr, + amr: resp.amr, + challenge: resp.challenge, + client: resp.client, + context: resp.context, + loginChallenge: resp.login_challenge, + loginSessionId: resp.login_session_id, + oidcContext: resp.oidc_context, + requestUrl: resp.request_url, + requestedAccessTokenAudience: resp.requested_access_token_audience, + requestedScope: resp.requested_scope, + skip: resp.skip, + subject: resp.subject, + }; + }, + acceptConsentRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { + const resp = await querier.sendPutRequest( + new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/consent/accept`), + { + context: input.context, + grant_access_token_audience: input.grantAccessTokenAudience, + grant_scope: input.grantScope, + handled_at: input.handledAt, + remember: input.remember, + remember_for: input.rememberFor, + session: input.session, + }, + { + consent_challenge: input.challenge, + }, + input.userContext + ); + + return { redirectTo: resp.redirect_to }; + }, + + rejectConsentRequest: async function (this: RecipeInterface, input) { + const resp = await querier.sendPutRequest( + new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/consent/reject`), + { + error: input.error.error, + error_debug: input.error.errorDebug, + error_description: input.error.errorDescription, + error_hint: input.error.errorHint, + status_code: input.error.statusCode, + }, + { + consent_challenge: input.challenge, + }, + input.userContext + ); + + return { redirectTo: resp.redirect_to }; + }, + + getLogoutRequest: async function (this: RecipeInterface, input): Promise { + const resp = await querier.sendGetRequest( + new NormalisedURLPath("/recipe/oauth2/admin/oauth2/auth/requests/logout"), + { logout_challenge: input.challenge }, + input.userContext + ); + + return { + challenge: resp.challenge, + client: resp.client, + requestUrl: resp.request_url, + rpInitiated: resp.rp_initiated, + sid: resp.sid, + subject: resp.subject, + }; + }, + acceptLogoutRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { + const resp = await querier.sendPutRequest( + new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/consent/logout/accept`), + {}, + { + logout_challenge: input.challenge, + }, + input.userContext + ); + + return { redirectTo: resp.redirect_to }; + }, + rejectLogoutRequest: async function (this: RecipeInterface, input): Promise { + await querier.sendPutRequest( + new NormalisedURLPath(`/recipe/oauth2/admin/oauth2/auth/requests/consent/logout/reject`), + {}, + { + logout_challenge: input.challenge, + }, + input.userContext + ); + }, + authorization: async function (this: RecipeInterface, input) { + const resp = await querier.sendGetRequestWithResponseHeaders( + new NormalisedURLPath(`/recipe/oauth2/pub/auth`), + input.params, + input.userContext + ); + + const redirectTo = resp.headers.get("Location")!; + if (redirectTo === undefined) { + throw new Error(resp.body); + } + return { redirectTo }; + }, + + token: async function (this: RecipeInterface, input) { + // TODO: Untested and suspicios + return querier.sendGetRequest( + new NormalisedURLPath(`/recipe/oauth2/pub/token`), + input.body, + input.userContext + ); + }, + }; } diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index 31bfdbf94..0441a26bc 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -15,6 +15,8 @@ import type { BaseRequest, BaseResponse } from "../../framework"; import OverrideableBuilder from "supertokens-js-override"; +import { GeneralErrorResponse, JSONObject, UserContext } from "../../types"; +import { SessionContainerInterface } from "../session/types"; export type TypeInput = { override?: { @@ -45,6 +47,284 @@ export type APIOptions = { res: BaseResponse; }; -export type RecipeInterface = {}; +export type OAuth2Client = {}; -export type APIInterface = {}; +export type ErrorOAuth2 = { + // The error should follow the OAuth2 error format (e.g. invalid_request, login_required). + // Defaults to request_denied. + error: string; + + // Description of the error in a human readable format. + errorDescription: string; + + // Debug contains information to help resolve the problem as a developer. Usually not exposed to the public but only in the server logs. + errorDebug?: string; + + // Hint to help resolve the error. + errorHint?: string; + + // Represents the HTTP status code of the error (e.g. 401 or 403) + // Defaults to 400 + statusCode?: number; +}; + +export type ConsentRequest = { + // ACR represents the Authentication AuthorizationContext Class Reference value for this authentication session. You can use it to express that, for example, a user authenticated using two factor authentication. + acr?: string; + + // Array of strings (StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.) + amr?: string[]; + + // ID is the identifier ("authorization challenge") of the consent authorization request. It is used to identify the session. + challenge: string; + + // OAuth 2.0 Clients are used to perform OAuth 2.0 and OpenID Connect flows. Usually, OAuth 2.0 clients are generated for applications which want to consume your OAuth 2.0 or OpenID Connect capabilities. + client?: OAuth2Client; + + // any (JSONRawMessage represents a json.RawMessage that works well with JSON, SQL, and Swagger.) + context?: JSONObject; + + // LoginChallenge is the login challenge this consent challenge belongs to. It can be used to associate a login and consent request in the login & consent app. + loginChallenge?: string; + + // LoginSessionID is the login session ID. If the user-agent reuses a login session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the "sid" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive login requests by a certain user. + loginSessionId?: string; + + // object (Contains optional information about the OpenID Connect request.) + oidcContext?: any; + + // RequestURL is the original OAuth 2.0 Authorization URL requested by the OAuth 2.0 client. It is the URL which initiates the OAuth 2.0 Authorization Code or OAuth 2.0 Implicit flow. This URL is typically not needed, but might come in handy if you want to deal with additional request parameters. + requestUrl?: string; + + // Array of strings (StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.) + requestedAccessTokenAudience?: string[]; + + // Array of strings (StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.) + requestedScope?: string[]; + + // Skip, if true, implies that the client has requested the same scopes from the same user previously. If true, you must not ask the user to grant the requested scopes. You must however either allow or deny the consent request using the usual API call. + skip?: boolean; + + // Subject is the user ID of the end-user that authenticated. Now, that end user needs to grant or deny the scope requested by the OAuth 2.0 client. + subject?: string; +}; + +export type LoginRequest = { + // ID is the identifier ("login challenge") of the login request. It is used to identify the session. + challenge: string; + + // OAuth 2.0 Clients are used to perform OAuth 2.0 and OpenID Connect flows. Usually, OAuth 2.0 clients are generated for applications which want to consume your OAuth 2.0 or OpenID Connect capabilities. + client: OAuth2Client; + + // object (Contains optional information about the OpenID Connect request.) + oidcContext?: any; + + // RequestURL is the original OAuth 2.0 Authorization URL requested by the OAuth 2.0 client. It is the URL which initiates the OAuth 2.0 Authorization Code or OAuth 2.0 Implicit flow. This URL is typically not needed, but might come in handy if you want to deal with additional request parameters. + requestUrl: string; + + // Array of strings (StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.) + requestedAccessTokenAudience?: string[]; + + // Array of strings (StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.) + requestedScope?: string[]; + + // SessionID is the login session ID. If the user-agent reuses a login session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the "sid" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive login requests by a certain user. + sessionId?: string; + + // Skip, if true, implies that the client has requested the same scopes from the same user previously. If true, you can skip asking the user to grant the requested scopes, and simply forward the user to the redirect URL. + // This feature allows you to update / set session information. + skip: boolean; + + // Subject is the user ID of the end-user that authenticated. Now, that end user needs to grant or deny the scope requested by the OAuth 2.0 client. If this value is set and skip is true, you MUST include this subject type when accepting the login request, or the request will fail. + subject: string; +}; + +export type LogoutRequest = { + // Challenge is the identifier ("logout challenge") of the logout authentication request. It is used to identify the session. + challenge: string; + + // OAuth 2.0 Clients are used to perform OAuth 2.0 and OpenID Connect flows. Usually, OAuth 2.0 clients are generated for applications which want to consume your OAuth 2.0 or OpenID Connect capabilities. + client: OAuth2Client; + + // RequestURL is the original Logout URL requested. + requestUrl: string; + + // RPInitiated is set to true if the request was initiated by a Relying Party (RP), also known as an OAuth 2.0 Client. + rpInitiated: boolean; + + // SessionID is the login session ID that was requested to log out. + sid: string; + + // Subject is the user for whom the logout was request. + subject: string; +}; + +export type TokenInfo = { + // The access token issued by the authorization server. + accessToken: string; + // The lifetime in seconds of the access token. For example, the value "3600" denotes that the access token will expire in one hour from the time the response was generated. + // integer + expiresIn: number; + // To retrieve a refresh token request the id_token scope. + idToken: string; + // The refresh token, which can be used to obtain new access tokens. To retrieve it add the scope "offline" to your access token request. + refreshToken: string; + // The scope of the access token + scope: string; + // The type of the token issued + tokenType: string; +}; + +export type RecipeInterface = { + authorization(input: { params: any; userContext: UserContext }): Promise<{ redirectTo: string }>; + token(input: { body: any; userContext: UserContext }): Promise; + getConsentRequest(input: { challenge: string; userContext: UserContext }): Promise; + acceptConsentRequest(input: { + challenge: string; + + // any (JSONRawMessage represents a json.RawMessage that works well with JSON, SQL, and Swagger.) + context?: any; + // Array of strings (StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.) + grantAccessTokenAudience?: string[]; + // Array of strings (StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.) + grantScope?: string[]; + // string (NullTime implements sql.NullTime functionality.) + handledAt?: string[]; + // Remember, if set to true, tells ORY Hydra to remember this consent authorization and reuse it if the same client asks the same user for the same, or a subset of, scope. + remember?: boolean; + + // RememberFor sets how long the consent authorization should be remembered for in seconds. If set to 0, the authorization will be remembered indefinitely. integer + rememberFor?: number; + + // object (Pass session data to a consent request.) + session?: any; + userContext: UserContext; + }): Promise<{ redirectTo: string }>; + + rejectConsentRequest(input: { + challenge: string; + error: ErrorOAuth2; + userContext: UserContext; + }): Promise<{ redirectTo: string }>; + + getLoginRequest(input: { challenge: string; userContext: UserContext }): Promise; + acceptLoginRequest(input: { + challenge: string; + + // ACR sets the Authentication AuthorizationContext Class Reference value for this authentication session. You can use it to express that, for example, a user authenticated using two factor authentication. + acr?: string; + + // Array of strings (StringSliceJSONFormat represents []string{} which is encoded to/from JSON for SQL storage.) + amr?: string[]; + + // any (JSONRawMessage represents a json.RawMessage that works well with JSON, SQL, and Swagger.) + context?: any; + + // Extend OAuth2 authentication session lifespan + // If set to true, the OAuth2 authentication cookie lifespan is extended. This is for example useful if you want the user to be able to use prompt=none continuously. + // This value can only be set to true if the user has an authentication, which is the case if the skip value is true. + extendSessionLifespan?: boolean; + + // ForceSubjectIdentifier forces the "pairwise" user ID of the end-user that authenticated. The "pairwise" user ID refers to the (Pairwise Identifier Algorithm)[http://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg] of the OpenID Connect specification. It allows you to set an obfuscated subject ("user") identifier that is unique to the client. + // Please note that this changes the user ID on endpoint /userinfo and sub claim of the ID Token. It does not change the sub claim in the OAuth 2.0 Introspection. + forceSubjectIdentifier?: string; + + // Per default, ORY Hydra handles this value with its own algorithm. In case you want to set this yourself you can use this field. Please note that setting this field has no effect if pairwise is not configured in ORY Hydra or the OAuth 2.0 Client does not expect a pairwise identifier (set via subject_type key in the client's configuration). + // Please also be aware that ORY Hydra is unable to properly compute this value during authentication. This implies that you have to compute this value on every authentication process (probably depending on the client ID or some other unique value). + // If you fail to compute the proper value, then authentication processes which have id_token_hint set might fail. + + // IdentityProviderSessionID is the session ID of the end-user that authenticated. If specified, we will use this value to propagate the logout. + identityProviderSessionId?: string; + + // Remember, if set to true, tells ORY Hydra to remember this user by telling the user agent (browser) to store a cookie with authentication data. If the same user performs another OAuth 2.0 Authorization Request, he/she will not be asked to log in again. + remember?: boolean; + + // RememberFor sets how long the authentication should be remembered for in seconds. If set to 0, the authorization will be remembered for the duration of the browser session (using a session cookie). integer + rememberFor?: number; + + // Subject is the user ID of the end-user that authenticated. + subject: string; + userContext: UserContext; + }): Promise<{ redirectTo: string }>; + rejectLoginRequest(input: { + challenge: string; + error: ErrorOAuth2; + userContext: UserContext; + }): Promise<{ redirectTo: string }>; + + getLogoutRequest(input: { challenge: string; userContext: UserContext }): Promise; + acceptLogoutRequest(input: { challenge: string; userContext: UserContext }): Promise<{ redirectTo: string }>; + rejectLogoutRequest(input: { challenge: string; userContext: UserContext }): Promise; +}; + +export type APIInterface = { + // TODO: add json versions? + loginGET: + | undefined + | ((input: { + loginChallenge: string; + options: APIOptions; + session?: SessionContainerInterface; + userContext: UserContext; + }) => Promise<{ redirectTo: string } | GeneralErrorResponse>); + + loginPOST: + | undefined + | ((input: { + loginChallenge: string; + accept: boolean; + session: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ redirectTo: string } | GeneralErrorResponse>); + + logoutGET: + | undefined + | ((input: { + logoutChallenge: string; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ redirectTo: string } | GeneralErrorResponse>); + + logoutPOST: + | undefined + | ((input: { + logoutChallenge: string; + accept: boolean; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ redirectTo: string } | GeneralErrorResponse>); + + consentGET: + | undefined + | ((input: { + consentChallenge: string; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ redirectTo: string } | GeneralErrorResponse>); + + consentPOST: + | undefined + | ((input: { + consentChallenge: string; + accept: boolean; + grantScope: string[]; + remember: boolean; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ redirectTo: string } | GeneralErrorResponse>); + authGET: + | undefined + | ((input: { + params: any; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ redirectTo: string } | ErrorOAuth2 | GeneralErrorResponse>); + tokenPOST: + | undefined + | ((input: { + body: any; + options: APIOptions; + userContext: UserContext; + }) => Promise); +}; diff --git a/lib/ts/recipe/openid/api/getOpenIdDiscoveryConfiguration.ts b/lib/ts/recipe/openid/api/getOpenIdDiscoveryConfiguration.ts index 90c291574..7a331169b 100644 --- a/lib/ts/recipe/openid/api/getOpenIdDiscoveryConfiguration.ts +++ b/lib/ts/recipe/openid/api/getOpenIdDiscoveryConfiguration.ts @@ -34,6 +34,11 @@ export default async function getOpenIdDiscoveryConfiguration( send200Response(options.res, { issuer: result.issuer, jwks_uri: result.jwks_uri, + authorization_endpoint: result.authorization_endpoint, + token_endpoint: result.token_endpoint, + subject_types_supported: result.subject_types_supported, + id_token_signing_alg_values_supported: result.id_token_signing_alg_values_supported, + response_types_supported: result.response_types_supported, }); } else { send200Response(options.res, result); diff --git a/lib/ts/recipe/openid/api/implementation.ts b/lib/ts/recipe/openid/api/implementation.ts index ee1f83e21..72921dffd 100644 --- a/lib/ts/recipe/openid/api/implementation.ts +++ b/lib/ts/recipe/openid/api/implementation.ts @@ -12,18 +12,11 @@ * License for the specific language governing permissions and limitations * under the License. */ -import { APIInterface, APIOptions } from "../types"; -import { GeneralErrorResponse, UserContext } from "../../../types"; +import { APIInterface } from "../types"; export default function getAPIImplementation(): APIInterface { return { - getOpenIdDiscoveryConfigurationGET: async function ({ - options, - userContext, - }: { - options: APIOptions; - userContext: UserContext; - }): Promise<{ status: "OK"; issuer: string; jwks_uri: string } | GeneralErrorResponse> { + getOpenIdDiscoveryConfigurationGET: async function ({ options, userContext }) { return await options.recipeImplementation.getOpenIdDiscoveryConfiguration({ userContext }); }, }; diff --git a/lib/ts/recipe/openid/recipe.ts b/lib/ts/recipe/openid/recipe.ts index 1dc802de5..1726f9c43 100644 --- a/lib/ts/recipe/openid/recipe.ts +++ b/lib/ts/recipe/openid/recipe.ts @@ -44,7 +44,9 @@ export default class OpenIdRecipe extends RecipeModule { override: this.config.override.jwtFeature, }); - let builder = new OverrideableBuilder(RecipeImplementation(this.config, this.jwtRecipe.recipeInterfaceImpl)); + let builder = new OverrideableBuilder( + RecipeImplementation(this.config, this.jwtRecipe.recipeInterfaceImpl, appInfo) + ); this.recipeImplementation = builder.override(this.config.override.functions).build(); diff --git a/lib/ts/recipe/openid/recipeImplementation.ts b/lib/ts/recipe/openid/recipeImplementation.ts index f161e8d23..2ed40f6f5 100644 --- a/lib/ts/recipe/openid/recipeImplementation.ts +++ b/lib/ts/recipe/openid/recipeImplementation.ts @@ -16,26 +16,31 @@ import { RecipeInterface, TypeNormalisedInput } from "./types"; import { RecipeInterface as JWTRecipeInterface, JsonWebKey } from "../jwt/types"; import NormalisedURLPath from "../../normalisedURLPath"; import { GET_JWKS_API } from "../jwt/constants"; -import { UserContext } from "../../types"; +import { NormalisedAppinfo, UserContext } from "../../types"; +import { AUTH_PATH, TOKEN_PATH } from "../oauth2/constants"; export default function getRecipeInterface( config: TypeNormalisedInput, - jwtRecipeImplementation: JWTRecipeInterface + jwtRecipeImplementation: JWTRecipeInterface, + appInfo: NormalisedAppinfo ): RecipeInterface { return { - getOpenIdDiscoveryConfiguration: async function (): Promise<{ - status: "OK"; - issuer: string; - jwks_uri: string; - }> { + getOpenIdDiscoveryConfiguration: async function () { let issuer = config.issuerDomain.getAsStringDangerous() + config.issuerPath.getAsStringDangerous(); let jwks_uri = config.issuerDomain.getAsStringDangerous() + config.issuerPath.appendPath(new NormalisedURLPath(GET_JWKS_API)).getAsStringDangerous(); + + const apiBasePath = appInfo.apiDomain.getAsStringDangerous() + appInfo.apiBasePath.getAsStringDangerous(); return { status: "OK", issuer, jwks_uri, + authorization_endpoint: apiBasePath + AUTH_PATH, + token_endpoint: apiBasePath + TOKEN_PATH, + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + response_types_supported: ["code", "id_token", "id_token token"], }; }, createJWT: async function ({ diff --git a/lib/ts/recipe/openid/types.ts b/lib/ts/recipe/openid/types.ts index c303cb0ca..22aa651a5 100644 --- a/lib/ts/recipe/openid/types.ts +++ b/lib/ts/recipe/openid/types.ts @@ -83,6 +83,11 @@ export type APIInterface = { status: "OK"; issuer: string; jwks_uri: string; + authorization_endpoint: string; + token_endpoint: string; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + response_types_supported: string[]; } | GeneralErrorResponse >); @@ -95,6 +100,11 @@ export type RecipeInterface = { status: "OK"; issuer: string; jwks_uri: string; + authorization_endpoint: string; + token_endpoint: string; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + response_types_supported: string[]; }>; createJWT(input: { payload?: any; diff --git a/lib/ts/recipe/passwordless/recipeImplementation.ts b/lib/ts/recipe/passwordless/recipeImplementation.ts index 71ab078b0..920a3bc02 100644 --- a/lib/ts/recipe/passwordless/recipeImplementation.ts +++ b/lib/ts/recipe/passwordless/recipeImplementation.ts @@ -164,6 +164,7 @@ export default function getRecipeInterface(querier: Querier): RecipeInterface { let response = await querier.sendPutRequest( new NormalisedURLPath(`/recipe/user`), copyAndRemoveUserContextAndTenantId(input), + {}, input.userContext ); if (response.status !== "OK") { diff --git a/lib/ts/recipe/session/sessionFunctions.ts b/lib/ts/recipe/session/sessionFunctions.ts index 8dae9d1b1..30d1726e9 100644 --- a/lib/ts/recipe/session/sessionFunctions.ts +++ b/lib/ts/recipe/session/sessionFunctions.ts @@ -500,6 +500,7 @@ export async function updateSessionDataInDatabase( sessionHandle, userDataInDatabase: newSessionData, }, + {}, userContext ); if (response.status === "UNAUTHORISED") { @@ -522,6 +523,7 @@ export async function updateAccessTokenPayload( sessionHandle, userDataInJWT: newAccessTokenPayload, }, + {}, userContext ); if (response.status === "UNAUTHORISED") { diff --git a/lib/ts/recipe/totp/recipeImplementation.ts b/lib/ts/recipe/totp/recipeImplementation.ts index 745f23cf2..a87a35c39 100644 --- a/lib/ts/recipe/totp/recipeImplementation.ts +++ b/lib/ts/recipe/totp/recipeImplementation.ts @@ -123,6 +123,7 @@ export default function getRecipeInterface(querier: Querier, config: TypeNormali existingDeviceName: input.existingDeviceName, newDeviceName: input.newDeviceName, }, + {}, input.userContext ); }, diff --git a/lib/ts/recipe/usermetadata/recipeImplementation.ts b/lib/ts/recipe/usermetadata/recipeImplementation.ts index 51ab77ea5..2ab9d24e2 100644 --- a/lib/ts/recipe/usermetadata/recipeImplementation.ts +++ b/lib/ts/recipe/usermetadata/recipeImplementation.ts @@ -30,6 +30,7 @@ export default function getRecipeInterface(querier: Querier): RecipeInterface { userId, metadataUpdate, }, + {}, userContext ); }, diff --git a/lib/ts/recipe/userroles/recipeImplementation.ts b/lib/ts/recipe/userroles/recipeImplementation.ts index eaee083f7..7141a8bfe 100644 --- a/lib/ts/recipe/userroles/recipeImplementation.ts +++ b/lib/ts/recipe/userroles/recipeImplementation.ts @@ -24,6 +24,7 @@ export default function getRecipeInterface(querier: Querier): RecipeInterface { return querier.sendPutRequest( new NormalisedURLPath(`/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/user/role`), { userId, role }, + {}, userContext ); }, @@ -55,7 +56,12 @@ export default function getRecipeInterface(querier: Querier): RecipeInterface { }, createNewRoleOrAddPermissions: function ({ role, permissions, userContext }) { - return querier.sendPutRequest(new NormalisedURLPath("/recipe/role"), { role, permissions }, userContext); + return querier.sendPutRequest( + new NormalisedURLPath("/recipe/role"), + { role, permissions }, + {}, + userContext + ); }, getPermissionsForRole: function ({ role, userContext }) { diff --git a/lib/ts/supertokens.ts b/lib/ts/supertokens.ts index fe95fc396..104542b6e 100644 --- a/lib/ts/supertokens.ts +++ b/lib/ts/supertokens.ts @@ -333,6 +333,7 @@ export default class SuperTokens { userIdType: input.userIdType, externalUserIdInfo: input.externalUserIdInfo, }, + {}, input.userContext ); } else { diff --git a/lib/ts/utils.ts b/lib/ts/utils.ts index 82aabe181..968e96e1b 100644 --- a/lib/ts/utils.ts +++ b/lib/ts/utils.ts @@ -18,11 +18,13 @@ export const doFetch: typeof fetch = (input: RequestInfo | URL, init?: RequestIn ProcessState.getInstance().addState(PROCESS_STATE.ADDING_NO_CACHE_HEADER_IN_FETCH); init = { cache: "no-cache", + redirect: "manual", }; } else { if (init.cache === undefined) { ProcessState.getInstance().addState(PROCESS_STATE.ADDING_NO_CACHE_HEADER_IN_FETCH); init.cache = "no-cache"; + init.redirect = "manual"; } } const fetchFunction = typeof fetch !== "undefined" ? fetch : crossFetch; diff --git a/test/querier.test.js b/test/querier.test.js index 22c9646da..64ff73480 100644 --- a/test/querier.test.js +++ b/test/querier.test.js @@ -193,7 +193,7 @@ describe(`Querier: ${printPath("[test/querier.test.js]")}`, function () { assert.equal(await q.sendPostRequest(new NormalisedURLPath("/hello"), {}, {}), "Hello\n"); let hostsAlive = q.getHostsAliveForTesting(); assert.equal(hostsAlive.size, 2); - assert.equal(await q.sendPutRequest(new NormalisedURLPath("/hello"), {}, {}), "Hello\n"); // this will be the 4th API call + assert.equal(await q.sendPutRequest(new NormalisedURLPath("/hello"), {}, {}, {}), "Hello\n"); // this will be the 4th API call hostsAlive = q.getHostsAliveForTesting(); assert.equal(hostsAlive.size, 2); assert.equal(hostsAlive.has(connectionURI), true); diff --git a/test/with-typescript/index.ts b/test/with-typescript/index.ts index f16582249..4a2a3d500 100644 --- a/test/with-typescript/index.ts +++ b/test/with-typescript/index.ts @@ -1593,6 +1593,11 @@ Session.init({ getOpenIdDiscoveryConfiguration: async (input) => ({ issuer: "your issuer", jwks_uri: "https://your.api.domain/auth/jwt/jwks.json", + token_endpoint: "http://localhost:3000/auth/oauth2/token", + authorization_endpoint: "http://localhost:3000/auth/oauth2/auth", + id_token_signing_alg_values_supported: [], + response_types_supported: [], + subject_types_supported: [], status: "OK", }), }), From 508bfad6b129d47d52363a33a8131404294ac7bf Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Fri, 21 Jun 2024 22:24:59 +0530 Subject: [PATCH 02/16] feat: Add an api to get login info --- lib/build/recipe/oauth2/api/implementation.js | 13 ++++++ lib/build/recipe/oauth2/api/loginInfo.d.ts | 8 ++++ lib/build/recipe/oauth2/api/loginInfo.js | 38 ++++++++++++++++ lib/build/recipe/oauth2/constants.d.ts | 1 + lib/build/recipe/oauth2/constants.js | 3 +- lib/build/recipe/oauth2/recipe.js | 10 +++++ .../recipe/oauth2/recipeImplementation.js | 2 +- lib/build/recipe/oauth2/types.d.ts | 14 ++++++ lib/ts/recipe/oauth2/api/implementation.ts | 14 ++++++ lib/ts/recipe/oauth2/api/loginInfo.ts | 44 +++++++++++++++++++ lib/ts/recipe/oauth2/constants.ts | 1 + lib/ts/recipe/oauth2/recipe.ts | 12 ++++- lib/ts/recipe/oauth2/recipeImplementation.ts | 2 +- lib/ts/recipe/oauth2/types.ts | 20 +++++++++ 14 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 lib/build/recipe/oauth2/api/loginInfo.d.ts create mode 100644 lib/build/recipe/oauth2/api/loginInfo.js create mode 100644 lib/ts/recipe/oauth2/api/loginInfo.ts diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index 0a728d0e2..1363c212c 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -173,6 +173,19 @@ function getAPIImplementation() { tokenPOST: async (input) => { return input.options.recipeImplementation.token({ body: input.body, userContext: input.userContext }); }, + loginInfoGET: async ({ loginChallenge, options, userContext }) => { + const { client } = await options.recipeImplementation.getLoginRequest({ + challenge: loginChallenge, + userContext, + }); + return { + clientName: client.clientName, + tosUri: client.tosUri, + policyUri: client.policyUri, + logoUri: client.logoUri, + metadata: client.metadata, + }; + }, }; } exports.default = getAPIImplementation; diff --git a/lib/build/recipe/oauth2/api/loginInfo.d.ts b/lib/build/recipe/oauth2/api/loginInfo.d.ts new file mode 100644 index 000000000..536858263 --- /dev/null +++ b/lib/build/recipe/oauth2/api/loginInfo.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function loginInfoGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2/api/loginInfo.js b/lib/build/recipe/oauth2/api/loginInfo.js new file mode 100644 index 000000000..b1d06cd64 --- /dev/null +++ b/lib/build/recipe/oauth2/api/loginInfo.js @@ -0,0 +1,38 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +async function loginInfoGET(apiImplementation, options, userContext) { + var _a; + if (apiImplementation.loginInfoGET === undefined) { + return false; + } + const loginChallenge = + (_a = options.req.getKeyValueFromQuery("login_challenge")) !== null && _a !== void 0 + ? _a + : options.req.getKeyValueFromQuery("loginChallenge"); + if (loginChallenge === undefined) { + throw new Error("TODO"); + } + let response = await apiImplementation.loginInfoGET({ + options, + loginChallenge, + userContext, + }); + utils_1.send200Response(options.res, response); + return true; +} +exports.default = loginInfoGET; diff --git a/lib/build/recipe/oauth2/constants.d.ts b/lib/build/recipe/oauth2/constants.d.ts index cff481248..e5fea5b40 100644 --- a/lib/build/recipe/oauth2/constants.d.ts +++ b/lib/build/recipe/oauth2/constants.d.ts @@ -5,3 +5,4 @@ export declare const LOGOUT_PATH = "/oauth2/logout"; export declare const CONSENT_PATH = "/oauth2/consent"; export declare const AUTH_PATH = "/oauth2/auth"; export declare const TOKEN_PATH = "/oauth2/token"; +export declare const LOGIN_INFO_PATH = "/oauth2/login/info"; diff --git a/lib/build/recipe/oauth2/constants.js b/lib/build/recipe/oauth2/constants.js index 6eb5434b8..f249f20d4 100644 --- a/lib/build/recipe/oauth2/constants.js +++ b/lib/build/recipe/oauth2/constants.js @@ -14,10 +14,11 @@ * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.TOKEN_PATH = exports.AUTH_PATH = exports.CONSENT_PATH = exports.LOGOUT_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; +exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.CONSENT_PATH = exports.LOGOUT_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; exports.OAUTH2_BASE_PATH = "/oauth2/"; exports.LOGIN_PATH = "/oauth2/login"; exports.LOGOUT_PATH = "/oauth2/logout"; exports.CONSENT_PATH = "/oauth2/consent"; exports.AUTH_PATH = "/oauth2/auth"; exports.TOKEN_PATH = "/oauth2/token"; +exports.LOGIN_INFO_PATH = "/oauth2/login/info"; diff --git a/lib/build/recipe/oauth2/recipe.js b/lib/build/recipe/oauth2/recipe.js index ef7673b5e..8b031b748 100644 --- a/lib/build/recipe/oauth2/recipe.js +++ b/lib/build/recipe/oauth2/recipe.js @@ -29,6 +29,7 @@ const implementation_1 = __importDefault(require("./api/implementation")); const login_1 = __importDefault(require("./api/login")); const logout_1 = __importDefault(require("./api/logout")); const token_1 = __importDefault(require("./api/token")); +const loginInfo_1 = __importDefault(require("./api/loginInfo")); const constants_1 = require("./constants"); const recipeImplementation_1 = __importDefault(require("./recipeImplementation")); const utils_1 = require("./utils"); @@ -60,6 +61,9 @@ class Recipe extends recipeModule_1.default { if (id === constants_1.AUTH_PATH) { return auth_1.default(this.apiImpl, options, userContext); } + if (id === constants_1.LOGIN_INFO_PATH) { + return loginInfo_1.default(this.apiImpl, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; this.config = utils_1.validateAndNormaliseUserInput(this, appInfo, config); @@ -156,6 +160,12 @@ class Recipe extends recipeModule_1.default { id: constants_1.AUTH_PATH, disabled: this.apiImpl.authGET === undefined, }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.LOGIN_INFO_PATH), + id: constants_1.LOGIN_INFO_PATH, + disabled: this.apiImpl.loginInfoGET === undefined, + }, ]; } handleError(error, _, __, _userContext) { diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index 907ab666c..96c1a3ad7 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -32,7 +32,7 @@ function getRecipeInterface(querier, _config, _appInfo) { ); return { challenge: resp.challenge, - client: resp.client, + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(resp.client), oidcContext: resp.oidc_context, requestUrl: resp.request_url, requestedAccessTokenAudience: resp.requested_access_token_audience, diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index 848ba8875..c87fe0315 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -79,6 +79,13 @@ export declare type TokenInfo = { scope: string; tokenType: string; }; +export declare type LoginInfo = { + clientName: string; + tosUri: string; + policyUri: string; + logoUri: string; + metadata?: Record | null; +}; export declare type RecipeInterface = { authorization(input: { params: any; @@ -296,6 +303,13 @@ export declare type APIInterface = { options: APIOptions; userContext: UserContext; }) => Promise); + loginInfoGET: + | undefined + | ((input: { + loginChallenge: string; + options: APIOptions; + userContext: UserContext; + }) => Promise); }; export declare type OAuth2ClientOptions = { clientId: string; diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index d27fc68bc..42b230fd3 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -170,5 +170,19 @@ export default function getAPIImplementation(): APIInterface { tokenPOST: async (input) => { return input.options.recipeImplementation.token({ body: input.body, userContext: input.userContext }); }, + loginInfoGET: async ({ loginChallenge, options, userContext }) => { + const { client } = await options.recipeImplementation.getLoginRequest({ + challenge: loginChallenge, + userContext, + }); + + return { + clientName: client.clientName, + tosUri: client.tosUri, + policyUri: client.policyUri, + logoUri: client.logoUri, + metadata: client.metadata, + }; + }, }; } diff --git a/lib/ts/recipe/oauth2/api/loginInfo.ts b/lib/ts/recipe/oauth2/api/loginInfo.ts new file mode 100644 index 000000000..b0d6241be --- /dev/null +++ b/lib/ts/recipe/oauth2/api/loginInfo.ts @@ -0,0 +1,44 @@ +/* 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 { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; + +export default async function loginInfoGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.loginInfoGET === undefined) { + return false; + } + + const loginChallenge = + options.req.getKeyValueFromQuery("login_challenge") ?? options.req.getKeyValueFromQuery("loginChallenge"); + + if (loginChallenge === undefined) { + throw new Error("TODO"); + } + + let response = await apiImplementation.loginInfoGET({ + options, + loginChallenge, + userContext, + }); + + send200Response(options.res, response); + return true; +} diff --git a/lib/ts/recipe/oauth2/constants.ts b/lib/ts/recipe/oauth2/constants.ts index 51daa6b6c..ddfdef4d6 100644 --- a/lib/ts/recipe/oauth2/constants.ts +++ b/lib/ts/recipe/oauth2/constants.ts @@ -20,3 +20,4 @@ export const LOGOUT_PATH = "/oauth2/logout"; export const CONSENT_PATH = "/oauth2/consent"; export const AUTH_PATH = "/oauth2/auth"; export const TOKEN_PATH = "/oauth2/token"; +export const LOGIN_INFO_PATH = "/oauth2/login/info"; diff --git a/lib/ts/recipe/oauth2/recipe.ts b/lib/ts/recipe/oauth2/recipe.ts index 06e7b14eb..0f6b66d26 100644 --- a/lib/ts/recipe/oauth2/recipe.ts +++ b/lib/ts/recipe/oauth2/recipe.ts @@ -26,7 +26,8 @@ import APIImplementation from "./api/implementation"; import loginAPI from "./api/login"; import logoutAPI from "./api/logout"; import tokenPOST from "./api/token"; -import { AUTH_PATH, CONSENT_PATH, LOGIN_PATH, LOGOUT_PATH, TOKEN_PATH } from "./constants"; +import loginInfoGET from "./api/loginInfo"; +import { AUTH_PATH, CONSENT_PATH, LOGIN_INFO_PATH, LOGIN_PATH, LOGOUT_PATH, TOKEN_PATH } from "./constants"; import RecipeImplementation from "./recipeImplementation"; import { APIInterface, RecipeInterface, TypeInput, TypeNormalisedInput } from "./types"; import { validateAndNormaliseUserInput } from "./utils"; @@ -140,6 +141,12 @@ export default class Recipe extends RecipeModule { id: AUTH_PATH, disabled: this.apiImpl.authGET === undefined, }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(LOGIN_INFO_PATH), + id: LOGIN_INFO_PATH, + disabled: this.apiImpl.loginInfoGET === undefined, + }, ]; } @@ -176,6 +183,9 @@ export default class Recipe extends RecipeModule { if (id === AUTH_PATH) { return authGET(this.apiImpl, options, userContext); } + if (id === LOGIN_INFO_PATH) { + return loginInfoGET(this.apiImpl, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index b5eacf935..87723d268 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -35,7 +35,7 @@ export default function getRecipeInterface( return { challenge: resp.challenge, - client: resp.client, + client: OAuth2Client.fromAPIResponse(resp.client), oidcContext: resp.oidc_context, requestUrl: resp.request_url, requestedAccessTokenAudience: resp.requested_access_token_audience, diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index 30533239c..3755b485c 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -174,6 +174,19 @@ export type TokenInfo = { tokenType: string; }; +export type LoginInfo = { + // The name of the client. + clientName: string; + // The URI of the client's terms of service. + tosUri: string; + // The URI of the client's privacy policy. + policyUri: string; + // The URI of the client's logo. + logoUri: string; + // The metadata associated with the client. + metadata?: Record | null; +}; + export type RecipeInterface = { authorization(input: { params: any; userContext: UserContext }): Promise<{ redirectTo: string }>; token(input: { body: any; userContext: UserContext }): Promise; @@ -387,6 +400,13 @@ export type APIInterface = { options: APIOptions; userContext: UserContext; }) => Promise); + loginInfoGET: + | undefined + | ((input: { + loginChallenge: string; + options: APIOptions; + userContext: UserContext; + }) => Promise); }; export type OAuth2ClientOptions = { From 849c6547a5039008c16897552e4c33dcc85b6b32 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Thu, 27 Jun 2024 01:36:50 +0200 Subject: [PATCH 03/16] fix: merge issues and FE path --- lib/build/querier.js | 2 +- lib/build/recipe/oauth2/api/implementation.js | 4 +- .../recipe/oauth2/recipeImplementation.js | 66 +++++++++---------- lib/ts/querier.ts | 2 +- lib/ts/recipe/oauth2/api/implementation.ts | 4 +- lib/ts/recipe/oauth2/recipeImplementation.ts | 66 +++++++++---------- 6 files changed, 70 insertions(+), 74 deletions(-) diff --git a/lib/build/querier.js b/lib/build/querier.js index b9679cb8a..9b75a073e 100644 --- a/lib/build/querier.js +++ b/lib/build/querier.js @@ -574,5 +574,5 @@ async function handleHydraAPICall(response) { headers: response.headers, }; } - return { body: { status: response.ok ? "OK" : "ERROR", headers: response.headers } }; + return { body: { status: response.ok ? "OK" : "ERROR" }, headers: response.headers }; } diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index 0a728d0e2..c8e7045a3 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -64,9 +64,7 @@ function getAPIImplementation() { _b !== void 0 ? _b : "" - }&redirectToPath=${encodeURIComponent( - "/continue-/auth/oauth2/login?login_challenge=" + loginChallenge - )}`, + }&loginChallenge=${loginChallenge}`, }; }, loginPOST: async ({ loginChallenge, accept, options, session, userContext }) => { diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index 907ab666c..8f49fe9c9 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -31,15 +31,15 @@ function getRecipeInterface(querier, _config, _appInfo) { input.userContext ); return { - challenge: resp.challenge, - client: resp.client, - oidcContext: resp.oidc_context, - requestUrl: resp.request_url, - requestedAccessTokenAudience: resp.requested_access_token_audience, - requestedScope: resp.requested_scope, - sessionId: resp.session_id, - skip: resp.skip, - subject: resp.subject, + challenge: resp.data.challenge, + client: resp.data.client, + oidcContext: resp.data.oidc_context, + requestUrl: resp.data.request_url, + requestedAccessTokenAudience: resp.data.requested_access_token_audience, + requestedScope: resp.data.requested_scope, + sessionId: resp.data.session_id, + skip: resp.data.skip, + subject: resp.data.subject, }; }, acceptLoginRequest: async function (input) { @@ -61,7 +61,7 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, rejectLoginRequest: async function (input) { const resp = await querier.sendPutRequest( @@ -78,7 +78,7 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, getConsentRequest: async function (input) { const resp = await querier.sendGetRequest( @@ -87,19 +87,19 @@ function getRecipeInterface(querier, _config, _appInfo) { input.userContext ); return { - acr: resp.acr, - amr: resp.amr, - challenge: resp.challenge, - client: resp.client, - context: resp.context, - loginChallenge: resp.login_challenge, - loginSessionId: resp.login_session_id, - oidcContext: resp.oidc_context, - requestUrl: resp.request_url, - requestedAccessTokenAudience: resp.requested_access_token_audience, - requestedScope: resp.requested_scope, - skip: resp.skip, - subject: resp.subject, + acr: resp.data.acr, + amr: resp.data.amr, + challenge: resp.data.challenge, + client: resp.data.client, + context: resp.data.context, + loginChallenge: resp.data.login_challenge, + loginSessionId: resp.data.login_session_id, + oidcContext: resp.data.oidc_context, + requestUrl: resp.data.request_url, + requestedAccessTokenAudience: resp.data.requested_access_token_audience, + requestedScope: resp.data.requested_scope, + skip: resp.data.skip, + subject: resp.data.subject, }; }, acceptConsentRequest: async function (input) { @@ -119,7 +119,7 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, rejectConsentRequest: async function (input) { const resp = await querier.sendPutRequest( @@ -136,7 +136,7 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, getLogoutRequest: async function (input) { const resp = await querier.sendGetRequest( @@ -145,12 +145,12 @@ function getRecipeInterface(querier, _config, _appInfo) { input.userContext ); return { - challenge: resp.challenge, - client: resp.client, - requestUrl: resp.request_url, - rpInitiated: resp.rp_initiated, - sid: resp.sid, - subject: resp.subject, + challenge: resp.data.challenge, + client: resp.data.client, + requestUrl: resp.data.request_url, + rpInitiated: resp.data.rp_initiated, + sid: resp.data.sid, + subject: resp.data.subject, }; }, acceptLogoutRequest: async function (input) { @@ -162,7 +162,7 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, rejectLogoutRequest: async function (input) { await querier.sendPutRequest( diff --git a/lib/ts/querier.ts b/lib/ts/querier.ts index 244e95e31..ae12c5362 100644 --- a/lib/ts/querier.ts +++ b/lib/ts/querier.ts @@ -667,5 +667,5 @@ async function handleHydraAPICall(response: Response) { }; } - return { body: { status: response.ok ? "OK" : "ERROR", headers: response.headers } }; + return { body: { status: response.ok ? "OK" : "ERROR" }, headers: response.headers }; } diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index d27fc68bc..e1cb66252 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -55,9 +55,7 @@ export default function getAPIImplementation(): APIInterface { redirectTo: websiteDomain + websiteBasePath + - `?hint=${request.oidcContext?.login_hint ?? ""}&redirectToPath=${encodeURIComponent( - "/continue-/auth/oauth2/login?login_challenge=" + loginChallenge - )}`, + `?hint=${request.oidcContext?.login_hint ?? ""}&loginChallenge=${loginChallenge}`, }; }, loginPOST: async ({ loginChallenge, accept, options, session, userContext }) => { diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index b5eacf935..3a84a1128 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -34,15 +34,15 @@ export default function getRecipeInterface( ); return { - challenge: resp.challenge, - client: resp.client, - oidcContext: resp.oidc_context, - requestUrl: resp.request_url, - requestedAccessTokenAudience: resp.requested_access_token_audience, - requestedScope: resp.requested_scope, - sessionId: resp.session_id, - skip: resp.skip, - subject: resp.subject, + challenge: resp.data.challenge, + client: resp.data.client, + oidcContext: resp.data.oidc_context, + requestUrl: resp.data.request_url, + requestedAccessTokenAudience: resp.data.requested_access_token_audience, + requestedScope: resp.data.requested_scope, + sessionId: resp.data.session_id, + skip: resp.data.skip, + subject: resp.data.subject, }; }, acceptLoginRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { @@ -65,7 +65,7 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, rejectLoginRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { const resp = await querier.sendPutRequest( @@ -83,7 +83,7 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, getConsentRequest: async function (this: RecipeInterface, input): Promise { const resp = await querier.sendGetRequest( @@ -93,19 +93,19 @@ export default function getRecipeInterface( ); return { - acr: resp.acr, - amr: resp.amr, - challenge: resp.challenge, - client: resp.client, - context: resp.context, - loginChallenge: resp.login_challenge, - loginSessionId: resp.login_session_id, - oidcContext: resp.oidc_context, - requestUrl: resp.request_url, - requestedAccessTokenAudience: resp.requested_access_token_audience, - requestedScope: resp.requested_scope, - skip: resp.skip, - subject: resp.subject, + acr: resp.data.acr, + amr: resp.data.amr, + challenge: resp.data.challenge, + client: resp.data.client, + context: resp.data.context, + loginChallenge: resp.data.login_challenge, + loginSessionId: resp.data.login_session_id, + oidcContext: resp.data.oidc_context, + requestUrl: resp.data.request_url, + requestedAccessTokenAudience: resp.data.requested_access_token_audience, + requestedScope: resp.data.requested_scope, + skip: resp.data.skip, + subject: resp.data.subject, }; }, acceptConsentRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { @@ -126,7 +126,7 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, rejectConsentRequest: async function (this: RecipeInterface, input) { @@ -145,7 +145,7 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, getLogoutRequest: async function (this: RecipeInterface, input): Promise { @@ -156,12 +156,12 @@ export default function getRecipeInterface( ); return { - challenge: resp.challenge, - client: resp.client, - requestUrl: resp.request_url, - rpInitiated: resp.rp_initiated, - sid: resp.sid, - subject: resp.subject, + challenge: resp.data.challenge, + client: resp.data.client, + requestUrl: resp.data.request_url, + rpInitiated: resp.data.rp_initiated, + sid: resp.data.sid, + subject: resp.data.subject, }; }, acceptLogoutRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { @@ -174,7 +174,7 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.redirect_to }; + return { redirectTo: resp.data.redirect_to }; }, rejectLogoutRequest: async function (this: RecipeInterface, input): Promise { await querier.sendPutRequest( From 5bcedd6ae51329fed4d83a9ab48e9ce5226689cc Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Thu, 27 Jun 2024 04:01:15 +0200 Subject: [PATCH 04/16] fix: WIP fix for CSRF and redirection issues --- lib/build/querier.d.ts | 3 + lib/build/querier.js | 21 ++--- lib/build/recipe/jwt/recipeImplementation.js | 1 + lib/build/recipe/oauth2/api/auth.js | 7 ++ lib/build/recipe/oauth2/api/implementation.js | 1 + .../recipe/oauth2/recipeImplementation.js | 80 ++++++++++++++++--- lib/build/recipe/oauth2/types.d.ts | 4 + lib/ts/querier.ts | 10 ++- lib/ts/recipe/jwt/recipeImplementation.ts | 1 + lib/ts/recipe/oauth2/api/auth.ts | 7 ++ lib/ts/recipe/oauth2/api/implementation.ts | 1 + lib/ts/recipe/oauth2/recipeImplementation.ts | 75 ++++++++++++++--- lib/ts/recipe/oauth2/types.ts | 9 ++- 13 files changed, 187 insertions(+), 33 deletions(-) diff --git a/lib/build/querier.d.ts b/lib/build/querier.d.ts index dceac2ec0..6d7607b4b 100644 --- a/lib/build/querier.d.ts +++ b/lib/build/querier.d.ts @@ -3,6 +3,8 @@ import NormalisedURLDomain from "./normalisedURLDomain"; import NormalisedURLPath from "./normalisedURLPath"; import { UserContext } from "./types"; import { NetworkInterceptor } from "./types"; +export declare const hydraPubDomain: string; +export declare const hydraPubPathPrefix = "/recipe/oauth2/pub"; export declare class Querier { private static initCalled; private static hosts; @@ -44,6 +46,7 @@ export declare class Querier { sendGetRequestWithResponseHeaders: ( path: NormalisedURLPath, params: Record, + inpHeaders: Record | undefined, userContext: UserContext ) => Promise<{ body: any; diff --git a/lib/build/querier.js b/lib/build/querier.js index 9b75a073e..8f1a41977 100644 --- a/lib/build/querier.js +++ b/lib/build/querier.js @@ -6,7 +6,7 @@ var __importDefault = }; var _a, _b; Object.defineProperty(exports, "__esModule", { value: true }); -exports.Querier = void 0; +exports.Querier = exports.hydraPubPathPrefix = exports.hydraPubDomain = void 0; /* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. * * This software is licensed under the Apache License, Version 2.0 (the @@ -27,9 +27,9 @@ const normalisedURLPath_1 = __importDefault(require("./normalisedURLPath")); const processState_1 = require("./processState"); const constants_1 = require("./constants"); const logger_1 = require("./logger"); -const hydraPubDomain = (_a = process.env.HYDRA_PUB) !== null && _a !== void 0 ? _a : "http://localhost:4444"; // This will be used as a domain for paths starting with hydraPubPathPrefix +exports.hydraPubDomain = (_a = process.env.HYDRA_PUB) !== null && _a !== void 0 ? _a : "http://localhost:4444"; // This will be used as a domain for paths starting with hydraPubPathPrefix const hydraAdmDomain = (_b = process.env.HYDRA_ADM) !== null && _b !== void 0 ? _b : "http://localhost:4445"; // This will be used as a domain for paths starting with hydraAdmPathPrefix -const hydraPubPathPrefix = "/recipe/oauth2/pub"; // Replaced with "/oauth2" when sending the request (/recipe/oauth2/pub/token -> /oauth2/token) +exports.hydraPubPathPrefix = "/recipe/oauth2/pub"; // Replaced with "/oauth2" when sending the request (/recipe/oauth2/pub/token -> /oauth2/token) const hydraAdmPathPrefix = "/recipe/oauth2/admin"; // Replaced with "/admin" when sending the request (/recipe/oauth2/admin/clients -> /admin/clients) class Querier { // we have rIdToCore so that recipes can force change the rId sent to core. This is a hack until the core is able @@ -273,14 +273,15 @@ class Querier { ); return respBody; }; - this.sendGetRequestWithResponseHeaders = async (path, params, userContext) => { + this.sendGetRequestWithResponseHeaders = async (path, params, inpHeaders, userContext) => { var _a; return await this.sendRequestHelper( path, "GET", async (url) => { let apiVersion = await this.getAPIVersion(); - let headers = { "cdi-version": apiVersion }; + let headers = inpHeaders !== null && inpHeaders !== void 0 ? inpHeaders : {}; + headers["cdi-version"] = apiVersion; if (Querier.apiKey !== undefined) { headers = Object.assign(Object.assign({}, headers), { "api-key": Querier.apiKey }); } @@ -430,10 +431,11 @@ class Querier { let currentDomain = this.__hosts[Querier.lastTriedIndex].domain.getAsStringDangerous(); let currentBasePath = this.__hosts[Querier.lastTriedIndex].basePath.getAsStringDangerous(); let strPath = path.getAsStringDangerous(); - const isHydraAPICall = strPath.startsWith(hydraAdmPathPrefix) || strPath.startsWith(hydraPubPathPrefix); - if (strPath.startsWith(hydraPubPathPrefix)) { - currentDomain = hydraPubDomain; - strPath = strPath.replace(hydraPubPathPrefix, "/oauth2"); + const isHydraAPICall = + strPath.startsWith(hydraAdmPathPrefix) || strPath.startsWith(exports.hydraPubPathPrefix); + if (strPath.startsWith(exports.hydraPubPathPrefix)) { + currentDomain = exports.hydraPubDomain; + strPath = strPath.replace(exports.hydraPubPathPrefix, "/oauth2"); } if (strPath.startsWith(hydraAdmPathPrefix)) { currentDomain = hydraAdmDomain; @@ -559,6 +561,7 @@ Querier.networkInterceptor = undefined; Querier.globalCacheTag = Date.now(); Querier.disableCache = false; async function handleHydraAPICall(response) { + console.log({ hydraResponse: response, text: await response.clone().text() }); const contentType = response.headers.get("Content-Type"); if (contentType === null || contentType === void 0 ? void 0 : contentType.startsWith("application/json")) { return { diff --git a/lib/build/recipe/jwt/recipeImplementation.js b/lib/build/recipe/jwt/recipeImplementation.js index 073f14b88..0ac8ddf6f 100644 --- a/lib/build/recipe/jwt/recipeImplementation.js +++ b/lib/build/recipe/jwt/recipeImplementation.js @@ -54,6 +54,7 @@ function getRecipeInterface(querier, config, appInfo) { const { body, headers } = await querier.sendGetRequestWithResponseHeaders( new normalisedURLPath_1.default("/.well-known/jwks.json"), {}, + undefined, userContext ); let validityInSeconds = defaultJWKSMaxAge; diff --git a/lib/build/recipe/oauth2/api/auth.js b/lib/build/recipe/oauth2/api/auth.js index a67d42c32..90103ef66 100644 --- a/lib/build/recipe/oauth2/api/auth.js +++ b/lib/build/recipe/oauth2/api/auth.js @@ -25,9 +25,16 @@ async function authGET(apiImplementation, options, userContext) { let response = await apiImplementation.authGET({ options, params: Object.fromEntries(params.entries()), + cookie: options.req.getHeaderValue("cookie"), userContext, }); if ("redirectTo" in response) { + // TODO: + if (response.setCookie) { + for (const c of response.setCookie.replace(/, (\w+=)/, "\n$1").split("\n")) { + options.res.setHeader("set-cookie", c, true); + } + } options.res.original.redirect(response.redirectTo); } else { utils_1.send200Response(options.res, response); diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index c8e7045a3..2167eaa84 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -164,6 +164,7 @@ function getAPIImplementation() { authGET: async (input) => { const res = await input.options.recipeImplementation.authorization({ params: input.params, + cookies: input.cookie, userContext: input.userContext, }); return res; diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index 8f49fe9c9..f4559d5de 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -20,6 +20,7 @@ var __importDefault = }; Object.defineProperty(exports, "__esModule", { value: true }); const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); +const querier_1 = require("../../querier"); const utils_1 = require("../../utils"); const OAuth2Client_1 = require("./OAuth2Client"); function getRecipeInterface(querier, _config, _appInfo) { @@ -32,7 +33,7 @@ function getRecipeInterface(querier, _config, _appInfo) { ); return { challenge: resp.data.challenge, - client: resp.data.client, + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(resp.data.client), oidcContext: resp.data.oidc_context, requestUrl: resp.data.request_url, requestedAccessTokenAudience: resp.data.requested_access_token_audience, @@ -61,7 +62,13 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + querier_1.hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, rejectLoginRequest: async function (input) { const resp = await querier.sendPutRequest( @@ -78,7 +85,13 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + querier_1.hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, getConsentRequest: async function (input) { const resp = await querier.sendGetRequest( @@ -90,7 +103,7 @@ function getRecipeInterface(querier, _config, _appInfo) { acr: resp.data.acr, amr: resp.data.amr, challenge: resp.data.challenge, - client: resp.data.client, + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(resp.data.client), context: resp.data.context, loginChallenge: resp.data.login_challenge, loginSessionId: resp.data.login_session_id, @@ -119,7 +132,13 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + querier_1.hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, rejectConsentRequest: async function (input) { const resp = await querier.sendPutRequest( @@ -136,7 +155,13 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + querier_1.hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, getLogoutRequest: async function (input) { const resp = await querier.sendGetRequest( @@ -146,7 +171,7 @@ function getRecipeInterface(querier, _config, _appInfo) { ); return { challenge: resp.data.challenge, - client: resp.data.client, + client: OAuth2Client_1.OAuth2Client.fromAPIResponse(resp.data.client), requestUrl: resp.data.request_url, rpInitiated: resp.data.rp_initiated, sid: resp.data.sid, @@ -162,7 +187,13 @@ function getRecipeInterface(querier, _config, _appInfo) { }, input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + querier_1.hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, rejectLogoutRequest: async function (input) { await querier.sendPutRequest( @@ -175,16 +206,47 @@ function getRecipeInterface(querier, _config, _appInfo) { ); }, authorization: async function (input) { + var _a, _b, _c; const resp = await querier.sendGetRequestWithResponseHeaders( new normalisedURLPath_1.default(`/recipe/oauth2/pub/auth`), input.params, + { + Cookie: `${input.cookies}`, + }, input.userContext ); const redirectTo = resp.headers.get("Location"); if (redirectTo === undefined) { throw new Error(resp.body); } - return { redirectTo }; + const redirectToURL = new URL(redirectTo); + const consentChallenge = redirectToURL.searchParams.get("consent_challenge"); + if (consentChallenge !== null) { + const consentRequest = await this.getConsentRequest({ + challenge: consentChallenge, + userContext: input.userContext, + }); + if ( + consentRequest.skip || + ((_a = consentRequest.client) === null || _a === void 0 ? void 0 : _a.skipConsent) + ) { + const consentRes = await this.acceptConsentRequest( + Object.assign(Object.assign({}, input), { + challenge: consentRequest.challenge, + grantAccessTokenAudience: consentRequest.requestedAccessTokenAudience, + grantScope: consentRequest.requestedScope, + }) + ); + return { + redirectTo: consentRes.redirectTo, + setCookie: (_b = resp.headers.get("set-cookie")) !== null && _b !== void 0 ? _b : undefined, + }; + } + } + return { + redirectTo, + setCookie: (_c = resp.headers.get("set-cookie")) !== null && _c !== void 0 ? _c : undefined, + }; }, token: async function (input) { // TODO: Untested and suspicios diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index 848ba8875..c6173b500 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -82,9 +82,11 @@ export declare type TokenInfo = { export declare type RecipeInterface = { authorization(input: { params: any; + cookies: string | undefined; userContext: UserContext; }): Promise<{ redirectTo: string; + setCookie: string | undefined; }>; token(input: { body: any; userContext: UserContext }): Promise; getConsentRequest(input: { challenge: string; userContext: UserContext }): Promise; @@ -280,11 +282,13 @@ export declare type APIInterface = { | undefined | ((input: { params: any; + cookie: string | undefined; options: APIOptions; userContext: UserContext; }) => Promise< | { redirectTo: string; + setCookie: string | undefined; } | ErrorOAuth2 | GeneralErrorResponse diff --git a/lib/ts/querier.ts b/lib/ts/querier.ts index ae12c5362..da0d059e0 100644 --- a/lib/ts/querier.ts +++ b/lib/ts/querier.ts @@ -22,9 +22,9 @@ import { logDebugMessage } from "./logger"; import { UserContext } from "./types"; import { NetworkInterceptor } from "./types"; -const hydraPubDomain = process.env.HYDRA_PUB ?? "http://localhost:4444"; // This will be used as a domain for paths starting with hydraPubPathPrefix +export const hydraPubDomain = process.env.HYDRA_PUB ?? "http://localhost:4444"; // This will be used as a domain for paths starting with hydraPubPathPrefix const hydraAdmDomain = process.env.HYDRA_ADM ?? "http://localhost:4445"; // This will be used as a domain for paths starting with hydraAdmPathPrefix -const hydraPubPathPrefix = "/recipe/oauth2/pub"; // Replaced with "/oauth2" when sending the request (/recipe/oauth2/pub/token -> /oauth2/token) +export const hydraPubPathPrefix = "/recipe/oauth2/pub"; // Replaced with "/oauth2" when sending the request (/recipe/oauth2/pub/token -> /oauth2/token) const hydraAdmPathPrefix = "/recipe/oauth2/admin"; // Replaced with "/admin" when sending the request (/recipe/oauth2/admin/clients -> /admin/clients) export class Querier { @@ -352,6 +352,7 @@ export class Querier { sendGetRequestWithResponseHeaders = async ( path: NormalisedURLPath, params: Record, + inpHeaders: Record | undefined, userContext: UserContext ): Promise<{ body: any; headers: Headers }> => { return await this.sendRequestHelper( @@ -359,7 +360,9 @@ export class Querier { "GET", async (url: string) => { let apiVersion = await this.getAPIVersion(); - let headers: any = { "cdi-version": apiVersion }; + let headers: any = inpHeaders ?? {}; + headers["cdi-version"] = apiVersion; + if (Querier.apiKey !== undefined) { headers = { ...headers, @@ -650,6 +653,7 @@ export class Querier { } async function handleHydraAPICall(response: Response) { + console.log({ hydraResponse: response, text: await response.clone().text() }); const contentType = response.headers.get("Content-Type"); if (contentType?.startsWith("application/json")) { diff --git a/lib/ts/recipe/jwt/recipeImplementation.ts b/lib/ts/recipe/jwt/recipeImplementation.ts index dc8656124..fa937c881 100644 --- a/lib/ts/recipe/jwt/recipeImplementation.ts +++ b/lib/ts/recipe/jwt/recipeImplementation.ts @@ -78,6 +78,7 @@ export default function getRecipeInterface( const { body, headers } = await querier.sendGetRequestWithResponseHeaders( new NormalisedURLPath("/.well-known/jwks.json"), {}, + undefined, userContext ); let validityInSeconds = defaultJWKSMaxAge; diff --git a/lib/ts/recipe/oauth2/api/auth.ts b/lib/ts/recipe/oauth2/api/auth.ts index cdcb6fe37..802d408ef 100644 --- a/lib/ts/recipe/oauth2/api/auth.ts +++ b/lib/ts/recipe/oauth2/api/auth.ts @@ -32,9 +32,16 @@ export default async function authGET( let response = await apiImplementation.authGET({ options, params: Object.fromEntries(params.entries()), + cookie: options.req.getHeaderValue("cookie"), userContext, }); if ("redirectTo" in response) { + // TODO: + if (response.setCookie) { + for (const c of response.setCookie.replace(/, (\w+=)/, "\n$1").split("\n")) { + options.res.setHeader("set-cookie", c, true); + } + } options.res.original.redirect(response.redirectTo); } else { send200Response(options.res, response); diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index e1cb66252..4b3f28d8c 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -161,6 +161,7 @@ export default function getAPIImplementation(): APIInterface { authGET: async (input) => { const res = await input.options.recipeImplementation.authorization({ params: input.params, + cookies: input.cookie, userContext: input.userContext, }); return res; diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index 3a84a1128..207ab1a02 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -14,7 +14,7 @@ */ import NormalisedURLPath from "../../normalisedURLPath"; -import { Querier } from "../../querier"; +import { Querier, hydraPubDomain } from "../../querier"; import { NormalisedAppinfo } from "../../types"; import { RecipeInterface, TypeNormalisedInput, ConsentRequest, LoginRequest, LogoutRequest } from "./types"; import { toSnakeCase, transformObjectKeys } from "../../utils"; @@ -35,7 +35,7 @@ export default function getRecipeInterface( return { challenge: resp.data.challenge, - client: resp.data.client, + client: OAuth2Client.fromAPIResponse(resp.data.client), oidcContext: resp.data.oidc_context, requestUrl: resp.data.request_url, requestedAccessTokenAudience: resp.data.requested_access_token_audience, @@ -65,7 +65,13 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, rejectLoginRequest: async function (this: RecipeInterface, input): Promise<{ redirectTo: string }> { const resp = await querier.sendPutRequest( @@ -83,7 +89,13 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, getConsentRequest: async function (this: RecipeInterface, input): Promise { const resp = await querier.sendGetRequest( @@ -96,7 +108,7 @@ export default function getRecipeInterface( acr: resp.data.acr, amr: resp.data.amr, challenge: resp.data.challenge, - client: resp.data.client, + client: OAuth2Client.fromAPIResponse(resp.data.client), context: resp.data.context, loginChallenge: resp.data.login_challenge, loginSessionId: resp.data.login_session_id, @@ -126,7 +138,13 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, rejectConsentRequest: async function (this: RecipeInterface, input) { @@ -145,7 +163,13 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, getLogoutRequest: async function (this: RecipeInterface, input): Promise { @@ -157,7 +181,7 @@ export default function getRecipeInterface( return { challenge: resp.data.challenge, - client: resp.data.client, + client: OAuth2Client.fromAPIResponse(resp.data.client), requestUrl: resp.data.request_url, rpInitiated: resp.data.rp_initiated, sid: resp.data.sid, @@ -174,7 +198,13 @@ export default function getRecipeInterface( input.userContext ); - return { redirectTo: resp.data.redirect_to }; + return { + // TODO: FIXME!!! + redirectTo: resp.data.redirect_to.replace( + hydraPubDomain, + _appInfo.apiDomain.getAsStringDangerous() + _appInfo.apiBasePath.getAsStringDangerous() + ), + }; }, rejectLogoutRequest: async function (this: RecipeInterface, input): Promise { await querier.sendPutRequest( @@ -190,6 +220,9 @@ export default function getRecipeInterface( const resp = await querier.sendGetRequestWithResponseHeaders( new NormalisedURLPath(`/recipe/oauth2/pub/auth`), input.params, + { + Cookie: `${input.cookies}`, + }, input.userContext ); @@ -197,7 +230,29 @@ export default function getRecipeInterface( if (redirectTo === undefined) { throw new Error(resp.body); } - return { redirectTo }; + const redirectToURL = new URL(redirectTo); + const consentChallenge = redirectToURL.searchParams.get("consent_challenge"); + if (consentChallenge !== null) { + const consentRequest = await this.getConsentRequest({ + challenge: consentChallenge, + userContext: input.userContext, + }); + + if (consentRequest.skip || consentRequest.client?.skipConsent) { + const consentRes = await this.acceptConsentRequest({ + ...input, + challenge: consentRequest.challenge, + grantAccessTokenAudience: consentRequest.requestedAccessTokenAudience, + grantScope: consentRequest.requestedScope, + }); + + return { + redirectTo: consentRes.redirectTo, + setCookie: resp.headers.get("set-cookie") ?? undefined, + }; + } + } + return { redirectTo, setCookie: resp.headers.get("set-cookie") ?? undefined }; }, token: async function (this: RecipeInterface, input) { diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index 30533239c..f825b2081 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -175,7 +175,11 @@ export type TokenInfo = { }; export type RecipeInterface = { - authorization(input: { params: any; userContext: UserContext }): Promise<{ redirectTo: string }>; + authorization(input: { + params: any; + cookies: string | undefined; + userContext: UserContext; + }): Promise<{ redirectTo: string; setCookie: string | undefined }>; token(input: { body: any; userContext: UserContext }): Promise; getConsentRequest(input: { challenge: string; userContext: UserContext }): Promise; acceptConsentRequest(input: { @@ -377,9 +381,10 @@ export type APIInterface = { | undefined | ((input: { params: any; + cookie: string | undefined; options: APIOptions; userContext: UserContext; - }) => Promise<{ redirectTo: string } | ErrorOAuth2 | GeneralErrorResponse>); + }) => Promise<{ redirectTo: string; setCookie: string | undefined } | ErrorOAuth2 | GeneralErrorResponse>); tokenPOST: | undefined | ((input: { From deb0392c167e64d4d6366e60de32196bb0dee845 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Thu, 27 Jun 2024 21:39:11 +0000 Subject: [PATCH 05/16] fix: OAuth2 fixes and test-server updates (#871) --- lib/build/querier.js | 3 +- lib/build/recipe/oauth2/api/auth.js | 21 ++++++++- .../recipe/oauth2/recipeImplementation.js | 14 +++--- lib/ts/querier.ts | 3 +- lib/ts/recipe/oauth2/api/auth.ts | 16 ++++++- lib/ts/recipe/oauth2/recipeImplementation.ts | 13 +++--- package-lock.json | 26 +++++++++-- package.json | 2 + test/test-server/src/index.ts | 24 ++++++++++ test/test-server/src/oauth2.ts | 46 +++++++++++++++++++ 10 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 test/test-server/src/oauth2.ts diff --git a/lib/build/querier.js b/lib/build/querier.js index 8f1a41977..038181ada 100644 --- a/lib/build/querier.js +++ b/lib/build/querier.js @@ -435,10 +435,12 @@ class Querier { strPath.startsWith(hydraAdmPathPrefix) || strPath.startsWith(exports.hydraPubPathPrefix); if (strPath.startsWith(exports.hydraPubPathPrefix)) { currentDomain = exports.hydraPubDomain; + currentBasePath = ""; strPath = strPath.replace(exports.hydraPubPathPrefix, "/oauth2"); } if (strPath.startsWith(hydraAdmPathPrefix)) { currentDomain = hydraAdmDomain; + currentBasePath = ""; strPath = strPath.replace(hydraAdmPathPrefix, "/admin"); } const url = currentDomain + currentBasePath + strPath; @@ -561,7 +563,6 @@ Querier.networkInterceptor = undefined; Querier.globalCacheTag = Date.now(); Querier.disableCache = false; async function handleHydraAPICall(response) { - console.log({ hydraResponse: response, text: await response.clone().text() }); const contentType = response.headers.get("Content-Type"); if (contentType === null || contentType === void 0 ? void 0 : contentType.startsWith("application/json")) { return { diff --git a/lib/build/recipe/oauth2/api/auth.js b/lib/build/recipe/oauth2/api/auth.js index 90103ef66..e9ffab92a 100644 --- a/lib/build/recipe/oauth2/api/auth.js +++ b/lib/build/recipe/oauth2/api/auth.js @@ -13,8 +13,14 @@ * 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 set_cookie_parser_1 = __importDefault(require("set-cookie-parser")); async function authGET(apiImplementation, options, userContext) { if (apiImplementation.authGET === undefined) { return false; @@ -31,8 +37,19 @@ async function authGET(apiImplementation, options, userContext) { if ("redirectTo" in response) { // TODO: if (response.setCookie) { - for (const c of response.setCookie.replace(/, (\w+=)/, "\n$1").split("\n")) { - options.res.setHeader("set-cookie", c, true); + const cookieStr = set_cookie_parser_1.default.splitCookiesString(response.setCookie); + const cookies = set_cookie_parser_1.default.parse(cookieStr); + for (const cookie of cookies) { + options.res.setCookie( + cookie.name, + cookie.value, + cookie.domain, + !!cookie.secure, + !!cookie.httpOnly, + new Date(cookie.expires).getTime(), + cookie.path || "/", + cookie.sameSite + ); } } options.res.original.redirect(response.redirectTo); diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index f4559d5de..d1f29aa71 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -257,20 +257,22 @@ function getRecipeInterface(querier, _config, _appInfo) { ); }, getOAuth2Clients: async function (input, userContext) { - let response = await querier.sendGetRequest( + var _a; + let response = await querier.sendGetRequestWithResponseHeaders( new normalisedURLPath_1.default(`/recipe/oauth2/admin/clients`), Object.assign(Object.assign({}, utils_1.transformObjectKeys(input, "snake-case")), { page_token: input.paginationToken, }), + {}, userContext ); - if (response.status === "OK") { + if (response.body.status === "OK") { // Pagination info is in the Link header, containing comma-separated links: // "first", "next" (if applicable). // Example: Link: ; rel="first", ; rel="next" // We parse the nextPaginationToken from the Link header using RegExp let nextPaginationToken; - const linkHeader = response.headers.get("link"); + const linkHeader = (_a = response.headers.get("link")) !== null && _a !== void 0 ? _a : ""; const nextLinkMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/); if (nextLinkMatch) { const url = nextLinkMatch[1]; @@ -279,14 +281,14 @@ function getRecipeInterface(querier, _config, _appInfo) { } return { status: "OK", - clients: response.data.map((client) => OAuth2Client_1.OAuth2Client.fromAPIResponse(client)), + clients: response.body.data.map((client) => OAuth2Client_1.OAuth2Client.fromAPIResponse(client)), nextPaginationToken, }; } else { return { status: "ERROR", - error: response.data.error, - errorHint: response.data.errorHint, + error: response.body.data.error, + errorHint: response.body.data.errorHint, }; } }, diff --git a/lib/ts/querier.ts b/lib/ts/querier.ts index da0d059e0..380eb3d40 100644 --- a/lib/ts/querier.ts +++ b/lib/ts/querier.ts @@ -567,11 +567,13 @@ export class Querier { if (strPath.startsWith(hydraPubPathPrefix)) { currentDomain = hydraPubDomain; + currentBasePath = ""; strPath = strPath.replace(hydraPubPathPrefix, "/oauth2"); } if (strPath.startsWith(hydraAdmPathPrefix)) { currentDomain = hydraAdmDomain; + currentBasePath = ""; strPath = strPath.replace(hydraAdmPathPrefix, "/admin"); } @@ -653,7 +655,6 @@ export class Querier { } async function handleHydraAPICall(response: Response) { - console.log({ hydraResponse: response, text: await response.clone().text() }); const contentType = response.headers.get("Content-Type"); if (contentType?.startsWith("application/json")) { diff --git a/lib/ts/recipe/oauth2/api/auth.ts b/lib/ts/recipe/oauth2/api/auth.ts index 802d408ef..b1d133b12 100644 --- a/lib/ts/recipe/oauth2/api/auth.ts +++ b/lib/ts/recipe/oauth2/api/auth.ts @@ -16,6 +16,7 @@ import { send200Response } from "../../../utils"; import { APIInterface, APIOptions } from ".."; import { UserContext } from "../../../types"; +import setCookieParser from "set-cookie-parser"; export default async function authGET( apiImplementation: APIInterface, @@ -38,8 +39,19 @@ export default async function authGET( if ("redirectTo" in response) { // TODO: if (response.setCookie) { - for (const c of response.setCookie.replace(/, (\w+=)/, "\n$1").split("\n")) { - options.res.setHeader("set-cookie", c, true); + const cookieStr = setCookieParser.splitCookiesString(response.setCookie); + const cookies = setCookieParser.parse(cookieStr); + for (const cookie of cookies) { + options.res.setCookie( + cookie.name, + cookie.value, + cookie.domain, + !!cookie.secure, + !!cookie.httpOnly, + new Date(cookie.expires!).getTime(), + cookie.path || "/", + cookie.sameSite as any + ); } } options.res.original.redirect(response.redirectTo); diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index 207ab1a02..4317e442d 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -265,23 +265,24 @@ export default function getRecipeInterface( }, getOAuth2Clients: async function (input, userContext) { - let response = await querier.sendGetRequest( + let response = await querier.sendGetRequestWithResponseHeaders( new NormalisedURLPath(`/recipe/oauth2/admin/clients`), { ...transformObjectKeys(input, "snake-case"), page_token: input.paginationToken, }, + {}, userContext ); - if (response.status === "OK") { + if (response.body.status === "OK") { // Pagination info is in the Link header, containing comma-separated links: // "first", "next" (if applicable). // Example: Link: ; rel="first", ; rel="next" // We parse the nextPaginationToken from the Link header using RegExp let nextPaginationToken: string | undefined; - const linkHeader = response.headers.get("link"); + const linkHeader = response.headers.get("link") ?? ""; const nextLinkMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/); if (nextLinkMatch) { @@ -292,14 +293,14 @@ export default function getRecipeInterface( return { status: "OK", - clients: response.data.map((client: any) => OAuth2Client.fromAPIResponse(client)), + clients: response.body.data.map((client: any) => OAuth2Client.fromAPIResponse(client)), nextPaginationToken, }; } else { return { status: "ERROR", - error: response.data.error, - errorHint: response.data.errorHint, + error: response.body.data.error, + errorHint: response.body.data.errorHint, }; } }, diff --git a/package-lock.json b/package-lock.json index d4f1b4611..d439ee1f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "nodemailer": "^6.7.2", "pkce-challenge": "^3.0.0", "psl": "1.8.0", + "set-cookie-parser": "^2.6.0", "supertokens-js-override": "^0.0.4", "twilio": "^4.19.3" }, @@ -39,6 +40,7 @@ "@types/koa-bodyparser": "^4.3.3", "@types/nodemailer": "^6.4.4", "@types/psl": "1.1.0", + "@types/set-cookie-parser": "^2.4.9", "@types/validator": "10.11.0", "aws-sdk-mock": "^5.4.0", "body-parser": "1.20.1", @@ -1713,6 +1715,15 @@ "@types/node": "*" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.9.tgz", + "integrity": "sha512-bCorlULvl0xTdjj4BPUHX4cqs9I+go2TfW/7Do1nnFYWS0CPP429Qr1AY42kiFhCwLpvAkWFr1XIBHd8j6/MCQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/type-is": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/@types/type-is/-/type-is-1.6.6.tgz", @@ -7010,8 +7021,7 @@ "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", - "dev": true + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -9568,6 +9578,15 @@ "@types/node": "*" } }, + "@types/set-cookie-parser": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.9.tgz", + "integrity": "sha512-bCorlULvl0xTdjj4BPUHX4cqs9I+go2TfW/7Do1nnFYWS0CPP429Qr1AY42kiFhCwLpvAkWFr1XIBHd8j6/MCQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/type-is": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/@types/type-is/-/type-is-1.6.6.tgz", @@ -13642,8 +13661,7 @@ "set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", - "dev": true + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" }, "set-function-length": { "version": "1.2.2", diff --git a/package.json b/package.json index 8f5377af5..2022a0fc6 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "nodemailer": "^6.7.2", "pkce-challenge": "^3.0.0", "psl": "1.8.0", + "set-cookie-parser": "^2.6.0", "supertokens-js-override": "^0.0.4", "twilio": "^4.19.3" }, @@ -143,6 +144,7 @@ "@types/koa-bodyparser": "^4.3.3", "@types/nodemailer": "^6.4.4", "@types/psl": "1.1.0", + "@types/set-cookie-parser": "^2.4.9", "@types/validator": "10.11.0", "aws-sdk-mock": "^5.4.0", "body-parser": "1.20.1", diff --git a/test/test-server/src/index.ts b/test/test-server/src/index.ts index 2856ffa26..0fdb993cb 100644 --- a/test/test-server/src/index.ts +++ b/test/test-server/src/index.ts @@ -19,6 +19,8 @@ import ThirdPartyRecipe from "../../../lib/build/recipe/thirdparty/recipe"; import { TypeInput as ThirdPartyTypeInput } from "../../../lib/build/recipe/thirdparty/types"; import { TypeInput as MFATypeInput } from "../../../lib/build/recipe/multifactorauth/types"; 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 UserMetadataRecipe from "../../../lib/build/recipe/usermetadata/recipe"; import SuperTokensRecipe from "../../../lib/build/supertokens"; import { RecipeListFunction } from "../../../lib/build/types"; @@ -32,6 +34,7 @@ import Session from "../../../recipe/session"; import { verifySession } from "../../../recipe/session/framework/express"; import ThirdParty from "../../../recipe/thirdparty"; import TOTP from "../../../recipe/totp"; +import OAuth2 from "../../../recipe/oauth2"; import accountlinkingRoutes from "./accountlinking"; import emailpasswordRoutes from "./emailpassword"; import emailverificationRoutes from "./emailverification"; @@ -39,6 +42,7 @@ import { logger } from "./logger"; import multiFactorAuthRoutes from "./multifactorauth"; import multitenancyRoutes from "./multitenancy"; import passwordlessRoutes from "./passwordless"; +import oAuth2Routes from "./oauth2"; import sessionRoutes from "./session"; import supertokensRoutes from "./supertokens"; import thirdPartyRoutes from "./thirdparty"; @@ -81,6 +85,7 @@ function STReset() { ProcessState.getInstance().reset(); MultiFactorAuthRecipe.reset(); TOTPRecipe.reset(); + OAuth2Recipe.reset(); SuperTokensRecipe.reset(); } @@ -237,6 +242,24 @@ function initST(config: any) { if (recipe.recipeId === "totp") { recipeList.push(TOTP.init(config)); } + if (recipe.recipeId === "oauth2") { + let initConfig: OAuth2TypeInput = { + ...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(OAuth2.init(initConfig)); + } }); settings.recipeList = recipeList; @@ -318,6 +341,7 @@ app.use("/test/multifactorauth", multiFactorAuthRoutes); app.use("/test/thirdparty", thirdPartyRoutes); app.use("/test/totp", TOTPRoutes); app.use("/test/usermetadata", userMetadataRoutes); +app.use("/test/oauth2", oAuth2Routes); // *** Custom routes to help with session tests *** app.post("/create", async (req, res, next) => { diff --git a/test/test-server/src/oauth2.ts b/test/test-server/src/oauth2.ts new file mode 100644 index 000000000..d54dff6fe --- /dev/null +++ b/test/test-server/src/oauth2.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; +import OAuth2 from "../../../recipe/oauth2"; +import { logger } from "./logger"; + +const namespace = "com.supertokens:node-test-server:oauth2"; +const { logDebugMessage } = logger(namespace); + +const router = Router() + .post("/getoauth2clients", async (req, res, next) => { + try { + logDebugMessage("OAuth2:getOAuth2Clients %j", req.body); + const response = await OAuth2.getOAuth2Clients(req.body.input, req.body.userContext); + res.json(response); + } catch (e) { + next(e); + } + }) + .post("/createoauth2client", async (req, res, next) => { + try { + logDebugMessage("OAuth2:createOAuth2Client %j", req.body); + const response = await OAuth2.createOAuth2Client(req.body.input, req.body.userContext); + res.json(response); + } catch (e) { + next(e); + } + }) + .post("/updateoauth2client", async (req, res, next) => { + try { + logDebugMessage("OAuth2:updateOAuth2Client %j", req.body); + const response = await OAuth2.updateOAuth2Client(req.body.input, req.body.userContext); + res.json(response); + } catch (e) { + next(e); + } + }) + .post("/deleteoauth2client", async (req, res, next) => { + try { + logDebugMessage("OAuth2:deleteOAuth2Client %j", req.body); + const response = await OAuth2.deleteOAuth2Client(req.body.input, req.body.userContext); + res.json(response); + } catch (e) { + next(e); + } + }); + +export default router; From 3af555ea14d82b3c33133339c9ecb6ec42274272 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Thu, 27 Jun 2024 23:54:04 +0200 Subject: [PATCH 06/16] feat: update oauth2 login info endpoint types to match our general patterns --- lib/build/recipe/oauth2/api/implementation.js | 13 ++++++++----- lib/build/recipe/oauth2/types.d.ts | 8 +++++++- lib/ts/recipe/oauth2/api/implementation.ts | 13 ++++++++----- lib/ts/recipe/oauth2/types.ts | 2 +- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index a5b5ecf92..015c28f19 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -178,11 +178,14 @@ function getAPIImplementation() { userContext, }); return { - clientName: client.clientName, - tosUri: client.tosUri, - policyUri: client.policyUri, - logoUri: client.logoUri, - metadata: client.metadata, + status: "OK", + info: { + clientName: client.clientName, + tosUri: client.tosUri, + policyUri: client.policyUri, + logoUri: client.logoUri, + metadata: client.metadata, + }, }; }, }; diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index 1fcdecd4b..68455859e 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -313,7 +313,13 @@ export declare type APIInterface = { loginChallenge: string; options: APIOptions; userContext: UserContext; - }) => Promise); + }) => Promise< + | { + status: "OK"; + info: LoginInfo; + } + | GeneralErrorResponse + >); }; export declare type OAuth2ClientOptions = { clientId: string; diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index dc56f711f..c8cd1d94a 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -176,11 +176,14 @@ export default function getAPIImplementation(): APIInterface { }); return { - clientName: client.clientName, - tosUri: client.tosUri, - policyUri: client.policyUri, - logoUri: client.logoUri, - metadata: client.metadata, + status: "OK", + info: { + clientName: client.clientName, + tosUri: client.tosUri, + policyUri: client.policyUri, + logoUri: client.logoUri, + metadata: client.metadata, + }, }; }, }; diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index 4a02ee63b..059d37b56 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -411,7 +411,7 @@ export type APIInterface = { loginChallenge: string; options: APIOptions; userContext: UserContext; - }) => Promise); + }) => Promise<{ status: "OK"; info: LoginInfo } | GeneralErrorResponse>); }; export type OAuth2ClientOptions = { From ff2135b542a37fec07af44e9d72467e1304cc147 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Fri, 28 Jun 2024 01:30:59 +0200 Subject: [PATCH 07/16] fix: make login flow work --- lib/build/querier.js | 8 +++- lib/build/recipe/jwt/api/implementation.js | 15 +++++++ lib/build/recipe/oauth2/api/auth.js | 9 ++++ lib/build/recipe/oauth2/api/implementation.js | 2 + lib/build/recipe/oauth2/api/token.js | 2 +- .../recipe/oauth2/recipeImplementation.js | 45 ++++++++++++++++--- lib/build/recipe/oauth2/types.d.ts | 12 ++--- lib/ts/querier.ts | 8 +++- lib/ts/recipe/jwt/api/implementation.ts | 11 +++++ lib/ts/recipe/oauth2/api/auth.ts | 9 ++++ lib/ts/recipe/oauth2/api/implementation.ts | 2 + lib/ts/recipe/oauth2/api/token.ts | 2 +- lib/ts/recipe/oauth2/recipeImplementation.ts | 34 ++++++++++++-- lib/ts/recipe/oauth2/types.ts | 12 ++--- 14 files changed, 144 insertions(+), 27 deletions(-) diff --git a/lib/build/querier.js b/lib/build/querier.js index 038181ada..dc289267f 100644 --- a/lib/build/querier.js +++ b/lib/build/querier.js @@ -84,6 +84,8 @@ class Querier { this.sendPostRequest = async (path, body, userContext) => { var _a; this.invalidateCoreCallCache(userContext); + // TODO: remove FormData + const isForm = body !== undefined && body instanceof FormData; const { body: respBody } = await this.sendRequestHelper( path, "POST", @@ -91,8 +93,10 @@ class Querier { let apiVersion = await this.getAPIVersion(); let headers = { "cdi-version": apiVersion, - "content-type": "application/json; charset=utf-8", }; + if (!isForm) { + headers["content-type"] = "application/json; charset=utf-8"; + } if (Querier.apiKey !== undefined) { headers = Object.assign(Object.assign({}, headers), { "api-key": Querier.apiKey }); } @@ -117,7 +121,7 @@ class Querier { } return utils_1.doFetch(url, { method: "POST", - body: body !== undefined ? JSON.stringify(body) : undefined, + body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined, headers, }); }, diff --git a/lib/build/recipe/jwt/api/implementation.js b/lib/build/recipe/jwt/api/implementation.js index e52174f38..d81fc72b0 100644 --- a/lib/build/recipe/jwt/api/implementation.js +++ b/lib/build/recipe/jwt/api/implementation.js @@ -13,7 +13,13 @@ * 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 recipe_1 = __importDefault(require("../../oauth2/recipe")); function getAPIImplementation() { return { getJWKSGET: async function ({ options, userContext }) { @@ -21,6 +27,15 @@ function getAPIImplementation() { if (resp.validityInSeconds !== undefined) { options.res.setHeader("Cache-Control", `max-age=${resp.validityInSeconds}, must-revalidate`, false); } + const oauth2 = recipe_1.default.getInstance(); + // TODO: dirty hack until we get core support + if (oauth2 !== undefined) { + const oauth2JWKSRes = await fetch("http://localhost:4444/.well-known/jwks.json"); + if (oauth2JWKSRes.ok) { + const oauth2RespBody = await oauth2JWKSRes.json(); + resp.keys = resp.keys.concat(oauth2RespBody.keys); + } + } return { keys: resp.keys, }; diff --git a/lib/build/recipe/oauth2/api/auth.js b/lib/build/recipe/oauth2/api/auth.js index e9ffab92a..30135c712 100644 --- a/lib/build/recipe/oauth2/api/auth.js +++ b/lib/build/recipe/oauth2/api/auth.js @@ -21,6 +21,7 @@ var __importDefault = Object.defineProperty(exports, "__esModule", { value: true }); const utils_1 = require("../../../utils"); const set_cookie_parser_1 = __importDefault(require("set-cookie-parser")); +const session_1 = __importDefault(require("../../session")); async function authGET(apiImplementation, options, userContext) { if (apiImplementation.authGET === undefined) { return false; @@ -28,10 +29,18 @@ async function authGET(apiImplementation, options, userContext) { const origURL = options.req.getOriginalURL(); const splitURL = origURL.split("?"); const params = new URLSearchParams(splitURL[1]); + let session; + try { + session = await session_1.default.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch (_a) { + // TODO: explain + // ignore + } let response = await apiImplementation.authGET({ options, params: Object.fromEntries(params.entries()), cookie: options.req.getHeaderValue("cookie"), + session, userContext, }); if ("redirectTo" in response) { diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index 015c28f19..b4bdc6cad 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -42,6 +42,7 @@ function getAPIImplementation() { const accept = await options.recipeImplementation.acceptLoginRequest({ challenge: loginChallenge, subject: session.getUserId(), + identityProviderSessionId: session.getHandle(), userContext, }); return { redirectTo: accept.redirectTo }; @@ -165,6 +166,7 @@ function getAPIImplementation() { const res = await input.options.recipeImplementation.authorization({ params: input.params, cookies: input.cookie, + session: input.session, userContext: input.userContext, }); return res; diff --git a/lib/build/recipe/oauth2/api/token.js b/lib/build/recipe/oauth2/api/token.js index 2a7c8d03d..f8d076426 100644 --- a/lib/build/recipe/oauth2/api/token.js +++ b/lib/build/recipe/oauth2/api/token.js @@ -21,7 +21,7 @@ async function tokenPOST(apiImplementation, options, userContext) { } let response = await apiImplementation.tokenPOST({ options, - body: options.req.getFormData(), + body: await options.req.getFormData(), userContext, }); utils_1.send200Response(options.res, response); diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index d1f29aa71..28c889ec9 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -23,6 +23,7 @@ const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); const querier_1 = require("../../querier"); const utils_1 = require("../../utils"); const OAuth2Client_1 = require("./OAuth2Client"); +const __1 = require("../.."); function getRecipeInterface(querier, _config, _appInfo) { return { getLoginRequest: async function (input) { @@ -206,7 +207,7 @@ function getRecipeInterface(querier, _config, _appInfo) { ); }, authorization: async function (input) { - var _a, _b, _c; + var _a, _b, _c, _d, _e; const resp = await querier.sendGetRequestWithResponseHeaders( new normalisedURLPath_1.default(`/recipe/oauth2/pub/auth`), input.params, @@ -221,40 +222,70 @@ function getRecipeInterface(querier, _config, _appInfo) { } const redirectToURL = new URL(redirectTo); const consentChallenge = redirectToURL.searchParams.get("consent_challenge"); - if (consentChallenge !== null) { + if (consentChallenge !== null && input.session !== undefined) { const consentRequest = await this.getConsentRequest({ challenge: consentChallenge, userContext: input.userContext, }); + const user = await __1.getUser(input.session.getUserId()); + if (!user) { + throw new Error("Should not happen"); + } if ( consentRequest.skip || ((_a = consentRequest.client) === null || _a === void 0 ? void 0 : _a.skipConsent) ) { + const idToken = {}; + if ( + (_b = consentRequest.requestedScope) === null || _b === void 0 ? void 0 : _b.includes("email") + ) { + idToken.email = user === null || user === void 0 ? void 0 : user.emails[0]; + idToken.email_verified = user.loginMethods.some( + (lm) => lm.hasSameEmailAs(idToken.email) && lm.verified + ); + } + if ( + (_c = consentRequest.requestedScope) === null || _c === void 0 + ? void 0 + : _c.includes("phoneNumber") + ) { + idToken.phoneNumber = user === null || user === void 0 ? void 0 : user.phoneNumbers[0]; + idToken.phoneNumber_verified = user.loginMethods.some( + (lm) => lm.hasSamePhoneNumberAs(idToken.phoneNumber) && lm.verified + ); + } const consentRes = await this.acceptConsentRequest( Object.assign(Object.assign({}, input), { challenge: consentRequest.challenge, grantAccessTokenAudience: consentRequest.requestedAccessTokenAudience, grantScope: consentRequest.requestedScope, + session: { + id_token: idToken, + }, }) ); return { redirectTo: consentRes.redirectTo, - setCookie: (_b = resp.headers.get("set-cookie")) !== null && _b !== void 0 ? _b : undefined, + setCookie: (_d = resp.headers.get("set-cookie")) !== null && _d !== void 0 ? _d : undefined, }; } } return { redirectTo, - setCookie: (_c = resp.headers.get("set-cookie")) !== null && _c !== void 0 ? _c : undefined, + setCookie: (_e = resp.headers.get("set-cookie")) !== null && _e !== void 0 ? _e : undefined, }; }, token: async function (input) { - // TODO: Untested and suspicios - return querier.sendGetRequest( + const body = new FormData(); // TODO: we ideally want to avoid using formdata, the core can do the translation + for (const key in input.body) { + body.append(key, input.body[key]); + } + const res = await querier.sendPostRequest( new normalisedURLPath_1.default(`/recipe/oauth2/pub/token`), - input.body, + body, input.userContext ); + return res.data; }, getOAuth2Clients: async function (input, userContext) { var _a; diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index 68455859e..6430b4d35 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -72,12 +72,12 @@ export declare type LogoutRequest = { subject: string; }; export declare type TokenInfo = { - accessToken: string; - expiresIn: number; - idToken: string; - refreshToken: string; + access_token: string; + expires_in: number; + id_token: string; + refresh_token: string; scope: string; - tokenType: string; + token_type: string; }; export declare type LoginInfo = { clientName: string; @@ -90,6 +90,7 @@ export declare type RecipeInterface = { authorization(input: { params: any; cookies: string | undefined; + session: SessionContainerInterface | undefined; userContext: UserContext; }): Promise<{ redirectTo: string; @@ -290,6 +291,7 @@ export declare type APIInterface = { | ((input: { params: any; cookie: string | undefined; + session: SessionContainerInterface | undefined; options: APIOptions; userContext: UserContext; }) => Promise< diff --git a/lib/ts/querier.ts b/lib/ts/querier.ts index 380eb3d40..828a4cd6b 100644 --- a/lib/ts/querier.ts +++ b/lib/ts/querier.ts @@ -130,6 +130,8 @@ export class Querier { // path should start with "/" sendPostRequest = async (path: NormalisedURLPath, body: any, userContext: UserContext): Promise => { this.invalidateCoreCallCache(userContext); + // TODO: remove FormData + const isForm = body !== undefined && body instanceof FormData; const { body: respBody } = await this.sendRequestHelper( path, @@ -138,8 +140,10 @@ export class Querier { let apiVersion = await this.getAPIVersion(); let headers: any = { "cdi-version": apiVersion, - "content-type": "application/json; charset=utf-8", }; + if (!isForm) { + headers["content-type"] = "application/json; charset=utf-8"; + } if (Querier.apiKey !== undefined) { headers = { ...headers, @@ -170,7 +174,7 @@ export class Querier { } return doFetch(url, { method: "POST", - body: body !== undefined ? JSON.stringify(body) : undefined, + body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined, headers, }); }, diff --git a/lib/ts/recipe/jwt/api/implementation.ts b/lib/ts/recipe/jwt/api/implementation.ts index 4308c7cf1..e3418a969 100644 --- a/lib/ts/recipe/jwt/api/implementation.ts +++ b/lib/ts/recipe/jwt/api/implementation.ts @@ -13,6 +13,7 @@ * under the License. */ +import OAuth2 from "../../oauth2/recipe"; import { APIInterface, APIOptions, JsonWebKey } from "../types"; import { GeneralErrorResponse, UserContext } from "../../../types"; @@ -31,6 +32,16 @@ export default function getAPIImplementation(): APIInterface { options.res.setHeader("Cache-Control", `max-age=${resp.validityInSeconds}, must-revalidate`, false); } + const oauth2 = OAuth2.getInstance(); + // TODO: dirty hack until we get core support + if (oauth2 !== undefined) { + const oauth2JWKSRes = await fetch("http://localhost:4444/.well-known/jwks.json"); + if (oauth2JWKSRes.ok) { + const oauth2RespBody = await oauth2JWKSRes.json(); + resp.keys = resp.keys.concat(oauth2RespBody.keys); + } + } + return { keys: resp.keys, }; diff --git a/lib/ts/recipe/oauth2/api/auth.ts b/lib/ts/recipe/oauth2/api/auth.ts index b1d133b12..50eba1b00 100644 --- a/lib/ts/recipe/oauth2/api/auth.ts +++ b/lib/ts/recipe/oauth2/api/auth.ts @@ -17,6 +17,7 @@ import { send200Response } from "../../../utils"; import { APIInterface, APIOptions } from ".."; import { UserContext } from "../../../types"; import setCookieParser from "set-cookie-parser"; +import Session from "../../session"; export default async function authGET( apiImplementation: APIInterface, @@ -29,11 +30,19 @@ export default async function authGET( const origURL = options.req.getOriginalURL(); const splitURL = origURL.split("?"); const params = new URLSearchParams(splitURL[1]); + let session; + try { + session = await Session.getSession(options.req, options.res, { sessionRequired: false }, userContext); + } catch { + // TODO: explain + // ignore + } let response = await apiImplementation.authGET({ options, params: Object.fromEntries(params.entries()), cookie: options.req.getHeaderValue("cookie"), + session, userContext, }); if ("redirectTo" in response) { diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index c8cd1d94a..69c865874 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -38,6 +38,7 @@ export default function getAPIImplementation(): APIInterface { const accept = await options.recipeImplementation.acceptLoginRequest({ challenge: loginChallenge, subject: session.getUserId(), + identityProviderSessionId: session.getHandle(), userContext, }); return { redirectTo: accept.redirectTo }; @@ -162,6 +163,7 @@ export default function getAPIImplementation(): APIInterface { const res = await input.options.recipeImplementation.authorization({ params: input.params, cookies: input.cookie, + session: input.session, userContext: input.userContext, }); return res; diff --git a/lib/ts/recipe/oauth2/api/token.ts b/lib/ts/recipe/oauth2/api/token.ts index 1f4b85b60..91f2d3cc6 100644 --- a/lib/ts/recipe/oauth2/api/token.ts +++ b/lib/ts/recipe/oauth2/api/token.ts @@ -28,7 +28,7 @@ export default async function tokenPOST( let response = await apiImplementation.tokenPOST({ options, - body: options.req.getFormData(), + body: await options.req.getFormData(), userContext, }); diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index 4317e442d..19371d370 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -19,6 +19,7 @@ import { NormalisedAppinfo } from "../../types"; import { RecipeInterface, TypeNormalisedInput, ConsentRequest, LoginRequest, LogoutRequest } from "./types"; import { toSnakeCase, transformObjectKeys } from "../../utils"; import { OAuth2Client } from "./OAuth2Client"; +import { getUser } from "../.."; export default function getRecipeInterface( querier: Querier, @@ -232,18 +233,38 @@ export default function getRecipeInterface( } const redirectToURL = new URL(redirectTo); const consentChallenge = redirectToURL.searchParams.get("consent_challenge"); - if (consentChallenge !== null) { + if (consentChallenge !== null && input.session !== undefined) { const consentRequest = await this.getConsentRequest({ challenge: consentChallenge, userContext: input.userContext, }); + const user = await getUser(input.session.getUserId()); + if (!user) { + throw new Error("Should not happen"); + } if (consentRequest.skip || consentRequest.client?.skipConsent) { + const idToken: any = {}; + if (consentRequest.requestedScope?.includes("email")) { + idToken.email = user?.emails[0]; + idToken.email_verified = user.loginMethods.some( + (lm) => lm.hasSameEmailAs(idToken.email) && lm.verified + ); + } + if (consentRequest.requestedScope?.includes("phoneNumber")) { + idToken.phoneNumber = user?.phoneNumbers[0]; + idToken.phoneNumber_verified = user.loginMethods.some( + (lm) => lm.hasSamePhoneNumberAs(idToken.phoneNumber) && lm.verified + ); + } const consentRes = await this.acceptConsentRequest({ ...input, challenge: consentRequest.challenge, grantAccessTokenAudience: consentRequest.requestedAccessTokenAudience, grantScope: consentRequest.requestedScope, + session: { + id_token: idToken, + }, }); return { @@ -256,12 +277,17 @@ export default function getRecipeInterface( }, token: async function (this: RecipeInterface, input) { - // TODO: Untested and suspicios - return querier.sendGetRequest( + const body = new FormData(); // TODO: we ideally want to avoid using formdata, the core can do the translation + for (const key in input.body) { + body.append(key, input.body[key]); + } + const res = await querier.sendPostRequest( new NormalisedURLPath(`/recipe/oauth2/pub/token`), - input.body, + body, input.userContext ); + + return res.data; }, getOAuth2Clients: async function (input, userContext) { diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index 059d37b56..9e77e8679 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -160,18 +160,18 @@ export type LogoutRequest = { export type TokenInfo = { // The access token issued by the authorization server. - accessToken: string; + access_token: string; // The lifetime in seconds of the access token. For example, the value "3600" denotes that the access token will expire in one hour from the time the response was generated. // integer - expiresIn: number; + expires_in: number; // To retrieve a refresh token request the id_token scope. - idToken: string; + id_token: string; // The refresh token, which can be used to obtain new access tokens. To retrieve it add the scope "offline" to your access token request. - refreshToken: string; + refresh_token: string; // The scope of the access token scope: string; // The type of the token issued - tokenType: string; + token_type: string; }; export type LoginInfo = { @@ -191,6 +191,7 @@ export type RecipeInterface = { authorization(input: { params: any; cookies: string | undefined; + session: SessionContainerInterface | undefined; userContext: UserContext; }): Promise<{ redirectTo: string; setCookie: string | undefined }>; token(input: { body: any; userContext: UserContext }): Promise; @@ -395,6 +396,7 @@ export type APIInterface = { | ((input: { params: any; cookie: string | undefined; + session: SessionContainerInterface | undefined; options: APIOptions; userContext: UserContext; }) => Promise<{ redirectTo: string; setCookie: string | undefined } | ErrorOAuth2 | GeneralErrorResponse>); From 8c977f47ac7711e438bf4905b0b8c5dc78dab7a2 Mon Sep 17 00:00:00 2001 From: Mihaly Lengyel Date: Thu, 4 Jul 2024 11:59:19 +0200 Subject: [PATCH 08/16] fix: circular dependency --- lib/build/recipe/jwt/api/implementation.js | 8 +------- lib/ts/recipe/jwt/api/implementation.ts | 3 +-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/lib/build/recipe/jwt/api/implementation.js b/lib/build/recipe/jwt/api/implementation.js index d81fc72b0..4e8bc342f 100644 --- a/lib/build/recipe/jwt/api/implementation.js +++ b/lib/build/recipe/jwt/api/implementation.js @@ -13,13 +13,7 @@ * 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 recipe_1 = __importDefault(require("../../oauth2/recipe")); function getAPIImplementation() { return { getJWKSGET: async function ({ options, userContext }) { @@ -27,7 +21,7 @@ function getAPIImplementation() { if (resp.validityInSeconds !== undefined) { options.res.setHeader("Cache-Control", `max-age=${resp.validityInSeconds}, must-revalidate`, false); } - const oauth2 = recipe_1.default.getInstance(); + const oauth2 = require("../../oauth2").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/jwt/api/implementation.ts b/lib/ts/recipe/jwt/api/implementation.ts index e3418a969..5a029d4a1 100644 --- a/lib/ts/recipe/jwt/api/implementation.ts +++ b/lib/ts/recipe/jwt/api/implementation.ts @@ -13,7 +13,6 @@ * under the License. */ -import OAuth2 from "../../oauth2/recipe"; import { APIInterface, APIOptions, JsonWebKey } from "../types"; import { GeneralErrorResponse, UserContext } from "../../../types"; @@ -32,7 +31,7 @@ export default function getAPIImplementation(): APIInterface { options.res.setHeader("Cache-Control", `max-age=${resp.validityInSeconds}, must-revalidate`, false); } - const oauth2 = OAuth2.getInstance(); + const oauth2 = require("../../oauth2").getInstance(); // TODO: dirty hack until we get core support if (oauth2 !== undefined) { const oauth2JWKSRes = await fetch("http://localhost:4444/.well-known/jwks.json"); From 769a04963e7d23469d71d51de5b9c381b3e410c9 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Mon, 8 Jul 2024 13:33:21 +0530 Subject: [PATCH 09/16] feat: Add OAuth2Client recipe --- lib/build/recipe/jwt/api/implementation.js | 2 +- .../oauth2client/api/authorisationUrl.d.ts | 9 + .../oauth2client/api/authorisationUrl.js | 43 ++++ .../oauth2client/api/implementation.d.ts | 3 + .../recipe/oauth2client/api/implementation.js | 61 +++++ lib/build/recipe/oauth2client/api/signin.d.ts | 9 + lib/build/recipe/oauth2client/api/signin.js | 81 +++++++ lib/build/recipe/oauth2client/constants.d.ts | 3 + lib/build/recipe/oauth2client/constants.js | 19 ++ lib/build/recipe/oauth2client/index.d.ts | 8 + lib/build/recipe/oauth2client/index.js | 27 +++ lib/build/recipe/oauth2client/recipe.d.ts | 38 +++ lib/build/recipe/oauth2client/recipe.js | 116 +++++++++ .../oauth2client/recipeImplementation.d.ts | 4 + .../oauth2client/recipeImplementation.js | 166 +++++++++++++ lib/build/recipe/oauth2client/types.d.ts | 167 +++++++++++++ lib/build/recipe/oauth2client/types.js | 16 ++ lib/build/recipe/oauth2client/utils.d.ts | 40 ++++ lib/build/recipe/oauth2client/utils.js | 176 ++++++++++++++ lib/ts/recipe/jwt/api/implementation.ts | 2 +- .../oauth2client/api/authorisationUrl.ts | 48 ++++ .../recipe/oauth2client/api/implementation.ts | 65 ++++++ lib/ts/recipe/oauth2client/api/signin.ts | 92 ++++++++ lib/ts/recipe/oauth2client/constants.ts | 18 ++ lib/ts/recipe/oauth2client/index.ts | 25 ++ lib/ts/recipe/oauth2client/recipe.ts | 146 ++++++++++++ .../oauth2client/recipeImplementation.ts | 221 ++++++++++++++++++ lib/ts/recipe/oauth2client/types.ts | 162 +++++++++++++ lib/ts/recipe/oauth2client/utils.ts | 175 ++++++++++++++ recipe/oauth2client/index.d.ts | 10 + recipe/oauth2client/index.js | 6 + recipe/oauth2client/types/index.d.ts | 10 + recipe/oauth2client/types/index.js | 6 + recipe/openid/index.d.ts | 10 + recipe/openid/index.js | 6 + recipe/openid/types/index.d.ts | 10 + recipe/openid/types/index.js | 6 + test/test-server/src/index.ts | 44 ++++ 38 files changed, 2048 insertions(+), 2 deletions(-) create mode 100644 lib/build/recipe/oauth2client/api/authorisationUrl.d.ts create mode 100644 lib/build/recipe/oauth2client/api/authorisationUrl.js create mode 100644 lib/build/recipe/oauth2client/api/implementation.d.ts create mode 100644 lib/build/recipe/oauth2client/api/implementation.js create mode 100644 lib/build/recipe/oauth2client/api/signin.d.ts create mode 100644 lib/build/recipe/oauth2client/api/signin.js create mode 100644 lib/build/recipe/oauth2client/constants.d.ts create mode 100644 lib/build/recipe/oauth2client/constants.js create mode 100644 lib/build/recipe/oauth2client/index.d.ts create mode 100644 lib/build/recipe/oauth2client/index.js create mode 100644 lib/build/recipe/oauth2client/recipe.d.ts create mode 100644 lib/build/recipe/oauth2client/recipe.js create mode 100644 lib/build/recipe/oauth2client/recipeImplementation.d.ts create mode 100644 lib/build/recipe/oauth2client/recipeImplementation.js create mode 100644 lib/build/recipe/oauth2client/types.d.ts create mode 100644 lib/build/recipe/oauth2client/types.js create mode 100644 lib/build/recipe/oauth2client/utils.d.ts create mode 100644 lib/build/recipe/oauth2client/utils.js create mode 100644 lib/ts/recipe/oauth2client/api/authorisationUrl.ts create mode 100644 lib/ts/recipe/oauth2client/api/implementation.ts create mode 100644 lib/ts/recipe/oauth2client/api/signin.ts create mode 100644 lib/ts/recipe/oauth2client/constants.ts create mode 100644 lib/ts/recipe/oauth2client/index.ts create mode 100644 lib/ts/recipe/oauth2client/recipe.ts create mode 100644 lib/ts/recipe/oauth2client/recipeImplementation.ts create mode 100644 lib/ts/recipe/oauth2client/types.ts create mode 100644 lib/ts/recipe/oauth2client/utils.ts create mode 100644 recipe/oauth2client/index.d.ts create mode 100644 recipe/oauth2client/index.js create mode 100644 recipe/oauth2client/types/index.d.ts create mode 100644 recipe/oauth2client/types/index.js create mode 100644 recipe/openid/index.d.ts create mode 100644 recipe/openid/index.js create mode 100644 recipe/openid/types/index.d.ts create mode 100644 recipe/openid/types/index.js diff --git a/lib/build/recipe/jwt/api/implementation.js b/lib/build/recipe/jwt/api/implementation.js index 4e8bc342f..96188f65d 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"); // 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/oauth2client/api/authorisationUrl.d.ts b/lib/build/recipe/oauth2client/api/authorisationUrl.d.ts new file mode 100644 index 000000000..61b231e1a --- /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 000000000..82cb75fda --- /dev/null +++ b/lib/build/recipe/oauth2client/api/authorisationUrl.js @@ -0,0 +1,43 @@ +"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; + } + 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 000000000..dd40e7025 --- /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 000000000..8ca4717d2 --- /dev/null +++ b/lib/build/recipe/oauth2client/api/implementation.js @@ -0,0 +1,61 @@ +"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 }) { + const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL({ + redirectURIOnProviderDashboard, + }); + 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, + }); + } 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, + }); + 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 000000000..72cd6e46b --- /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 000000000..0a0fadc45 --- /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 000000000..fd2bef07d --- /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 000000000..e8156ec89 --- /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 000000000..327cc7b3e --- /dev/null +++ b/lib/build/recipe/oauth2client/index.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import Recipe from "./recipe"; +import { RecipeInterface, APIInterface, APIOptions } from "./types"; +export default class Wrapper { + static init: typeof Recipe.init; +} +export declare let init: typeof Recipe.init; +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 000000000..252d1397c --- /dev/null +++ b/lib/build/recipe/oauth2client/index.js @@ -0,0 +1,27 @@ +"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.init = void 0; +const recipe_1 = __importDefault(require("./recipe")); +class Wrapper {} +exports.default = Wrapper; +Wrapper.init = recipe_1.default.init; +exports.init = Wrapper.init; diff --git a/lib/build/recipe/oauth2client/recipe.d.ts b/lib/build/recipe/oauth2client/recipe.d.ts new file mode 100644 index 000000000..180227169 --- /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 000000000..d3ad7c29a --- /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 000000000..24599a2c7 --- /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 000000000..688d9b713 --- /dev/null +++ b/lib/build/recipe/oauth2client/recipeImplementation.js @@ -0,0 +1,166 @@ +"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 utils_1 = require("./utils"); +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 ({ redirectURIOnProviderDashboard }) { + const providerConfig = await this.getProviderConfig(); + 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 utils_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 utils_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 utils_1.verifyIdTokenFromJWKSEndpointAndGetPayload( + idToken, + jwks, + { + audience: providerConfig.clientId, + } + ); + } + if (accessToken && providerConfig.userInfoEndpoint !== undefined) { + const headers = { + Authorization: "Bearer " + accessToken, + }; + const queryParams = {}; + const userInfoFromAccessToken = await utils_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 000000000..ebddf645e --- /dev/null +++ b/lib/build/recipe/oauth2client/types.d.ts @@ -0,0 +1,167 @@ +// @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 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: { + redirectURIOnProviderDashboard: string; + }): Promise<{ + urlWithQueryParams: string; + pkceCodeVerifier?: string; + }>; + getProviderConfig(input: { userContext: UserContext }): Promise; + signIn(input: { + userId: string; + oAuthTokens: { + [key: string]: any; + }; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { + [key: string]: any; + }; + fromUserInfoAPI?: { + [key: string]: any; + }; + }; + tenantId: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + recipeUserId: RecipeUserId; + user: User; + oAuthTokens: { + [key: string]: any; + }; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { + [key: string]: any; + }; + fromUserInfoAPI?: { + [key: string]: any; + }; + }; + }>; + exchangeAuthCodeForOAuthTokens(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }; + }): Promise | undefined>; + getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: any }): 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 000000000..9f1237319 --- /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 000000000..1347d081e --- /dev/null +++ b/lib/build/recipe/oauth2client/utils.d.ts @@ -0,0 +1,40 @@ +// @ts-nocheck +import { NormalisedAppinfo } from "../../types"; +import { TypeInput, TypeNormalisedInput } from "./types"; +import * as jose from "jose"; +export declare function validateAndNormaliseUserInput( + _appInfo: NormalisedAppinfo, + config: TypeInput +): TypeNormalisedInput; +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/recipe/oauth2client/utils.js b/lib/build/recipe/oauth2client/utils.js new file mode 100644 index 000000000..b4b182ccf --- /dev/null +++ b/lib/build/recipe/oauth2client/utils.js @@ -0,0 +1,176 @@ +"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 __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 = exports.validateAndNormaliseUserInput = void 0; +const jose = __importStar(require("jose")); +const normalisedURLDomain_1 = __importDefault(require("../../normalisedURLDomain")); +const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); +const utils_1 = require("../../utils"); +const logger_1 = require("../../logger"); +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; +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 5a029d4a1..2ce7ec108 100644 --- a/lib/ts/recipe/jwt/api/implementation.ts +++ b/lib/ts/recipe/jwt/api/implementation.ts @@ -31,7 +31,7 @@ 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"); // 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/oauth2client/api/authorisationUrl.ts b/lib/ts/recipe/oauth2client/api/authorisationUrl.ts new file mode 100644 index 000000000..6171f6375 --- /dev/null +++ b/lib/ts/recipe/oauth2client/api/authorisationUrl.ts @@ -0,0 +1,48 @@ +/* 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; + } + + 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 000000000..069c1f152 --- /dev/null +++ b/lib/ts/recipe/oauth2client/api/implementation.ts @@ -0,0 +1,65 @@ +import { APIInterface } from "../"; +import Session from "../../session"; + +export default function getAPIInterface(): APIInterface { + return { + authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard }) { + const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL({ + redirectURIOnProviderDashboard, + }); + return { + status: "OK", + ...authUrl, + }; + }, + signInPOST: async function (input) { + const { options, tenantId, userContext } = input; + + const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + + let oAuthTokensToUse: any = {}; + + if ("redirectURIInfo" in input && input.redirectURIInfo !== undefined) { + oAuthTokensToUse = await options.recipeImplementation.exchangeAuthCodeForOAuthTokens({ + providerConfig, + redirectURIInfo: input.redirectURIInfo, + }); + } 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, + }); + + 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 000000000..663af60e4 --- /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 000000000..545ef08f1 --- /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 000000000..adc2799cb --- /dev/null +++ b/lib/ts/recipe/oauth2client/index.ts @@ -0,0 +1,25 @@ +/* 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 Recipe from "./recipe"; +import { RecipeInterface, APIInterface, APIOptions } from "./types"; + +export default class Wrapper { + static init = Recipe.init; +} + +export let init = Wrapper.init; + +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 000000000..9dfc98a90 --- /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 000000000..6f26284cf --- /dev/null +++ b/lib/ts/recipe/oauth2client/recipeImplementation.ts @@ -0,0 +1,221 @@ +import { 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 "./utils"; +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 ({ redirectURIOnProviderDashboard }) { + const providerConfig = await this.getProviderConfig(); + + 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 ( + this: RecipeInterface, + { userId, tenantId, userContext, oAuthTokens, rawUserInfoFromProvider } + ): Promise<{ + status: "OK"; + user: UserType; + recipeUserId: RecipeUserId; + oAuthTokens: { [key: string]: any }; + 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 ({ + providerConfig, + redirectURIInfo, + }: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }; + }) { + 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; + }, + getUserInfo: async function ({ + providerConfig, + oAuthTokens, + }: { + providerConfig: ProviderConfigWithOIDCInfo; + oAuthTokens: any; + }): 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 000000000..32f809ee5 --- /dev/null +++ b/lib/ts/recipe/oauth2client/types.ts @@ -0,0 +1,162 @@ +/* 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 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: { + redirectURIOnProviderDashboard: string; + }): Promise<{ + urlWithQueryParams: string; + pkceCodeVerifier?: string; + }>; + getProviderConfig(input: { userContext: UserContext }): Promise; + + signIn(input: { + userId: string; + oAuthTokens: { [key: string]: any }; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { [key: string]: any }; + fromUserInfoAPI?: { [key: string]: any }; + }; + tenantId: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + recipeUserId: RecipeUserId; + user: User; + oAuthTokens: { [key: string]: any }; + rawUserInfoFromProvider: { + fromIdTokenPayload?: { [key: string]: any }; + fromUserInfoAPI?: { [key: string]: any }; + }; + }>; + exchangeAuthCodeForOAuthTokens(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }; + }): Promise | undefined>; + getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: any }): 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 000000000..a12b6706a --- /dev/null +++ b/lib/ts/recipe/oauth2client/utils.ts @@ -0,0 +1,175 @@ +/* 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"; +import * as jose from "jose"; +import NormalisedURLDomain from "../../normalisedURLDomain"; +import NormalisedURLPath from "../../normalisedURLPath"; +import { doFetch } from "../../utils"; +import { logDebugMessage } from "../../logger"; + +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, + }; +} + +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 000000000..89f4241f8 --- /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 000000000..f1b31d6db --- /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 000000000..e475d4576 --- /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 000000000..01b5c40c6 --- /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/recipe/openid/index.d.ts b/recipe/openid/index.d.ts new file mode 100644 index 000000000..64f95c7b5 --- /dev/null +++ b/recipe/openid/index.d.ts @@ -0,0 +1,10 @@ +export * from "../../lib/build/recipe/openid"; +/** + * '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/openid"; +export default _default; diff --git a/recipe/openid/index.js b/recipe/openid/index.js new file mode 100644 index 000000000..276ef8f9d --- /dev/null +++ b/recipe/openid/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/openid")); diff --git a/recipe/openid/types/index.d.ts b/recipe/openid/types/index.d.ts new file mode 100644 index 000000000..2c15f75f4 --- /dev/null +++ b/recipe/openid/types/index.d.ts @@ -0,0 +1,10 @@ +export * from "../../../lib/build/recipe/openid/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/openid/types"; +export default _default; diff --git a/recipe/openid/types/index.js b/recipe/openid/types/index.js new file mode 100644 index 000000000..26a5b76d2 --- /dev/null +++ b/recipe/openid/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/openid/types")); diff --git a/test/test-server/src/index.ts b/test/test-server/src/index.ts index 0fdb993cb..cf86168a1 100644 --- a/test/test-server/src/index.ts +++ b/test/test-server/src/index.ts @@ -21,6 +21,10 @@ 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 OpenIdRecipe from "../../../lib/build/recipe/openid/recipe"; +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 +39,8 @@ 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 OpenId from "../../../recipe/openid"; import accountlinkingRoutes from "./accountlinking"; import emailpasswordRoutes from "./emailpassword"; import emailverificationRoutes from "./emailverification"; @@ -86,6 +92,8 @@ function STReset() { MultiFactorAuthRecipe.reset(); TOTPRecipe.reset(); OAuth2Recipe.reset(); + OAuth2ClientRecipe.reset(); + OpenIdRecipe.reset(); SuperTokensRecipe.reset(); } @@ -260,6 +268,42 @@ 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)); + } + if (recipe.recipeId === "openid") { + let initConfig: OpenIdRecipeTypeInput = { + ...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(OpenId.init(initConfig)); + } }); settings.recipeList = recipeList; From 7cd08a1f859b0082fe93b673c734da967d7974e3 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Wed, 10 Jul 2024 23:14:46 +0530 Subject: [PATCH 10/16] fix: PR changes --- .../oauth2client/api/authorisationUrl.js | 1 + lib/build/recipe/oauth2client/index.d.ts | 23 ++- lib/build/recipe/oauth2client/index.js | 19 ++- .../oauth2client/recipeImplementation.js | 2 +- lib/build/recipe/oauth2client/types.d.ts | 24 ++-- lib/build/recipe/oauth2client/utils.d.ts | 33 ----- lib/build/recipe/oauth2client/utils.js | 129 +---------------- .../recipe/thirdparty/providers/bitbucket.js | 2 +- .../recipe/thirdparty/providers/custom.js | 2 +- .../recipe/thirdparty/providers/github.js | 2 +- .../recipe/thirdparty/providers/linkedin.js | 2 +- .../recipe/thirdparty/providers/twitter.js | 2 +- .../recipe/thirdparty/providers/utils.d.ts | 32 ----- .../recipe/thirdparty/providers/utils.js | 131 +----------------- lib/build/recipe/utils.d.ts | 34 +++++ lib/build/recipe/utils.js | 130 +++++++++++++++++ .../oauth2client/api/authorisationUrl.ts | 1 + .../recipe/oauth2client/api/implementation.ts | 3 +- lib/ts/recipe/oauth2client/index.ts | 27 +++- .../oauth2client/recipeImplementation.ts | 22 ++- lib/ts/recipe/oauth2client/types.ts | 22 ++- lib/ts/recipe/oauth2client/utils.ts | 122 ---------------- .../recipe/thirdparty/providers/bitbucket.ts | 2 +- lib/ts/recipe/thirdparty/providers/custom.ts | 2 +- lib/ts/recipe/thirdparty/providers/github.ts | 2 +- .../recipe/thirdparty/providers/linkedin.ts | 2 +- lib/ts/recipe/thirdparty/providers/twitter.ts | 2 +- lib/ts/recipe/thirdparty/providers/utils.ts | 124 +---------------- lib/ts/recipe/utils.ts | 122 ++++++++++++++++ recipe/openid/index.d.ts | 10 -- recipe/openid/index.js | 6 - recipe/openid/types/index.d.ts | 10 -- recipe/openid/types/index.js | 6 - test/test-server/src/index.ts | 20 --- 34 files changed, 422 insertions(+), 651 deletions(-) create mode 100644 lib/build/recipe/utils.d.ts create mode 100644 lib/build/recipe/utils.js create mode 100644 lib/ts/recipe/utils.ts delete mode 100644 recipe/openid/index.d.ts delete mode 100644 recipe/openid/index.js delete mode 100644 recipe/openid/types/index.d.ts delete mode 100644 recipe/openid/types/index.js diff --git a/lib/build/recipe/oauth2client/api/authorisationUrl.js b/lib/build/recipe/oauth2client/api/authorisationUrl.js index 82cb75fda..e4544b31c 100644 --- a/lib/build/recipe/oauth2client/api/authorisationUrl.js +++ b/lib/build/recipe/oauth2client/api/authorisationUrl.js @@ -25,6 +25,7 @@ async function authorisationUrlAPI(apiImplementation, _tenantId, options, userCo 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({ diff --git a/lib/build/recipe/oauth2client/index.d.ts b/lib/build/recipe/oauth2client/index.d.ts index 327cc7b3e..f3c8d2594 100644 --- a/lib/build/recipe/oauth2client/index.d.ts +++ b/lib/build/recipe/oauth2client/index.d.ts @@ -1,8 +1,29 @@ // @ts-nocheck import Recipe from "./recipe"; -import { RecipeInterface, APIInterface, APIOptions } from "./types"; +import { RecipeInterface, APIInterface, APIOptions, ProviderConfigWithOIDCInfo, OAuthTokens } from "./types"; export default class Wrapper { static init: typeof Recipe.init; + static getAuthorisationRedirectURL(input: { + redirectURIOnProviderDashboard: string; + }): Promise<{ + urlWithQueryParams: string; + pkceCodeVerifier?: string | undefined; + }>; + static exchangeAuthCodeForOAuthTokens(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }; + }): Promise; + static getUserInfo(input: { + providerConfig: ProviderConfigWithOIDCInfo; + oAuthTokens: OAuthTokens; + }): 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 index 252d1397c..a10ce70e0 100644 --- a/lib/build/recipe/oauth2client/index.js +++ b/lib/build/recipe/oauth2client/index.js @@ -19,9 +19,24 @@ var __importDefault = return mod && mod.__esModule ? mod : { default: mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.init = void 0; +exports.getUserInfo = exports.exchangeAuthCodeForOAuthTokens = exports.getAuthorisationRedirectURL = exports.init = void 0; const recipe_1 = __importDefault(require("./recipe")); -class Wrapper {} +class Wrapper { + static async getAuthorisationRedirectURL(input) { + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getAuthorisationRedirectURL(input); + } + static async exchangeAuthCodeForOAuthTokens(input) { + return await recipe_1.default + .getInstanceOrThrowError() + .recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens(input); + } + static async getUserInfo(input) { + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo(input); + } +} 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/recipeImplementation.js b/lib/build/recipe/oauth2client/recipeImplementation.js index 688d9b713..45cde1a57 100644 --- a/lib/build/recipe/oauth2client/recipeImplementation.js +++ b/lib/build/recipe/oauth2client/recipeImplementation.js @@ -6,7 +6,7 @@ var __importDefault = }; Object.defineProperty(exports, "__esModule", { value: true }); const recipeUserId_1 = __importDefault(require("../../recipeUserId")); -const utils_1 = require("./utils"); +const utils_1 = require("../utils"); const pkce_challenge_1 = __importDefault(require("pkce-challenge")); const __1 = require("../.."); const logger_1 = require("../../logger"); diff --git a/lib/build/recipe/oauth2client/types.d.ts b/lib/build/recipe/oauth2client/types.d.ts index ebddf645e..a322c3ef1 100644 --- a/lib/build/recipe/oauth2client/types.d.ts +++ b/lib/build/recipe/oauth2client/types.d.ts @@ -32,6 +32,18 @@ export declare type ProviderConfigWithOIDCInfo = ProviderConfigInput & { 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?: { @@ -62,9 +74,7 @@ export declare type RecipeInterface = { getProviderConfig(input: { userContext: UserContext }): Promise; signIn(input: { userId: string; - oAuthTokens: { - [key: string]: any; - }; + oAuthTokens: OAuthTokens; rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any; @@ -79,9 +89,7 @@ export declare type RecipeInterface = { status: "OK"; recipeUserId: RecipeUserId; user: User; - oAuthTokens: { - [key: string]: any; - }; + oAuthTokens: OAuthTokens; rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any; @@ -98,8 +106,8 @@ export declare type RecipeInterface = { redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; }; - }): Promise | undefined>; - getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: any }): Promise; + }): Promise; + getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: OAuthTokens }): Promise; }; export declare type APIOptions = { recipeImplementation: RecipeInterface; diff --git a/lib/build/recipe/oauth2client/utils.d.ts b/lib/build/recipe/oauth2client/utils.d.ts index 1347d081e..6a930e641 100644 --- a/lib/build/recipe/oauth2client/utils.d.ts +++ b/lib/build/recipe/oauth2client/utils.d.ts @@ -1,40 +1,7 @@ // @ts-nocheck import { NormalisedAppinfo } from "../../types"; import { TypeInput, TypeNormalisedInput } from "./types"; -import * as jose from "jose"; export declare function validateAndNormaliseUserInput( _appInfo: NormalisedAppinfo, config: TypeInput ): TypeNormalisedInput; -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/recipe/oauth2client/utils.js b/lib/build/recipe/oauth2client/utils.js index b4b182ccf..cc35b347f 100644 --- a/lib/build/recipe/oauth2client/utils.js +++ b/lib/build/recipe/oauth2client/utils.js @@ -13,54 +13,8 @@ * License for the specific language governing permissions and limitations * under the License. */ -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 = exports.validateAndNormaliseUserInput = void 0; -const jose = __importStar(require("jose")); -const normalisedURLDomain_1 = __importDefault(require("../../normalisedURLDomain")); -const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); -const utils_1 = require("../../utils"); -const logger_1 = require("../../logger"); +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."); @@ -93,84 +47,3 @@ function validateAndNormaliseUserInput(_appInfo, config) { }; } exports.validateAndNormaliseUserInput = validateAndNormaliseUserInput; -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/build/recipe/thirdparty/providers/bitbucket.js b/lib/build/recipe/thirdparty/providers/bitbucket.js index 27588fc51..eae1d6a0f 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 utils_1 = require("../../utils"); const custom_1 = __importDefault(require("./custom")); const logger_1 = require("../../../logger"); function Bitbucket(input) { diff --git a/lib/build/recipe/thirdparty/providers/custom.js b/lib/build/recipe/thirdparty/providers/custom.js index 3aead6ffc..bb0f7e531 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 utils_1 = require("../../utils"); const pkce_challenge_1 = __importDefault(require("pkce-challenge")); const configUtils_1 = require("./configUtils"); const jose_1 = require("jose"); diff --git a/lib/build/recipe/thirdparty/providers/github.js b/lib/build/recipe/thirdparty/providers/github.js index a656dd895..dc451c880 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 utils_1 = require("../../utils"); function getSupertokensUserInfoFromRawUserInfoResponseForGithub(rawUserInfoResponse) { if (rawUserInfoResponse.fromUserInfoAPI === undefined) { throw new Error("rawUserInfoResponse.fromUserInfoAPI is not available"); diff --git a/lib/build/recipe/thirdparty/providers/linkedin.js b/lib/build/recipe/thirdparty/providers/linkedin.js index bce0eeaf4..03730e039 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 utils_1 = require("../../utils"); function Linkedin(input) { if (input.config.name === undefined) { input.config.name = "LinkedIn"; diff --git a/lib/build/recipe/thirdparty/providers/twitter.js b/lib/build/recipe/thirdparty/providers/twitter.js index 3e54592c7..3c8170fee 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 utils_1 = require("../../utils"); function Twitter(input) { var _a; if (input.config.name === undefined) { diff --git a/lib/build/recipe/thirdparty/providers/utils.d.ts b/lib/build/recipe/thirdparty/providers/utils.d.ts index c43eb396d..26592d125 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 0fc79f65a..bb38d84af 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 utils_1 = require("../../utils"); async function discoverOIDCEndpoints(config) { if (config.oidcDiscoveryEndpoint !== undefined) { - const oidcInfo = await getOIDCDiscoveryInfo(config.oidcDiscoveryEndpoint); + const oidcInfo = await utils_1.getOIDCDiscoveryInfo(config.oidcDiscoveryEndpoint); if (oidcInfo.authorization_endpoint !== undefined && config.authorizationEndpoint === undefined) { config.authorizationEndpoint = oidcInfo.authorization_endpoint; } diff --git a/lib/build/recipe/utils.d.ts b/lib/build/recipe/utils.d.ts new file mode 100644 index 000000000..84517d5e2 --- /dev/null +++ b/lib/build/recipe/utils.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/recipe/utils.js b/lib/build/recipe/utils.js new file mode 100644 index 000000000..4d064c39d --- /dev/null +++ b/lib/build/recipe/utils.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/oauth2client/api/authorisationUrl.ts b/lib/ts/recipe/oauth2client/api/authorisationUrl.ts index 6171f6375..80d43138c 100644 --- a/lib/ts/recipe/oauth2client/api/authorisationUrl.ts +++ b/lib/ts/recipe/oauth2client/api/authorisationUrl.ts @@ -28,6 +28,7 @@ export default async function authorisationUrlAPI( 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") { diff --git a/lib/ts/recipe/oauth2client/api/implementation.ts b/lib/ts/recipe/oauth2client/api/implementation.ts index 069c1f152..56f82b447 100644 --- a/lib/ts/recipe/oauth2client/api/implementation.ts +++ b/lib/ts/recipe/oauth2client/api/implementation.ts @@ -1,5 +1,6 @@ import { APIInterface } from "../"; import Session from "../../session"; +import { OAuthTokens } from "../types"; export default function getAPIInterface(): APIInterface { return { @@ -17,7 +18,7 @@ export default function getAPIInterface(): APIInterface { const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); - let oAuthTokensToUse: any = {}; + let oAuthTokensToUse: OAuthTokens = {}; if ("redirectURIInfo" in input && input.redirectURIInfo !== undefined) { oAuthTokensToUse = await options.recipeImplementation.exchangeAuthCodeForOAuthTokens({ diff --git a/lib/ts/recipe/oauth2client/index.ts b/lib/ts/recipe/oauth2client/index.ts index adc2799cb..45baf876a 100644 --- a/lib/ts/recipe/oauth2client/index.ts +++ b/lib/ts/recipe/oauth2client/index.ts @@ -14,12 +14,37 @@ */ import Recipe from "./recipe"; -import { RecipeInterface, APIInterface, APIOptions } from "./types"; +import { RecipeInterface, APIInterface, APIOptions, ProviderConfigWithOIDCInfo, OAuthTokens } from "./types"; export default class Wrapper { static init = Recipe.init; + + static async getAuthorisationRedirectURL(input: { redirectURIOnProviderDashboard: string }) { + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getAuthorisationRedirectURL(input); + } + + static async exchangeAuthCodeForOAuthTokens(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIInfo: { + redirectURIOnProviderDashboard: string; + redirectURIQueryParams: any; + pkceCodeVerifier?: string | undefined; + }; + }) { + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens(input); + } + + static async getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: OAuthTokens }) { + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo(input); + } } 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/recipeImplementation.ts b/lib/ts/recipe/oauth2client/recipeImplementation.ts index 6f26284cf..ac6996e9e 100644 --- a/lib/ts/recipe/oauth2client/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2client/recipeImplementation.ts @@ -1,8 +1,20 @@ -import { ProviderConfigWithOIDCInfo, RecipeInterface, TypeNormalisedInput, UserInfo } from "./types"; +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 "./utils"; +import { + doGetRequest, + doPostRequest, + getOIDCDiscoveryInfo, + verifyIdTokenFromJWKSEndpointAndGetPayload, +} from "../utils"; import pkceChallenge from "pkce-challenge"; import { getUser } from "../.."; import { logDebugMessage } from "../../logger"; @@ -52,7 +64,7 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN status: "OK"; user: UserType; recipeUserId: RecipeUserId; - oAuthTokens: { [key: string]: any }; + oAuthTokens: OAuthTokens; rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any }; fromUserInfoAPI?: { [key: string]: any }; @@ -140,14 +152,14 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN ); } - return tokenResponse.jsonResponse; + return tokenResponse.jsonResponse as OAuthTokenResponse; }, getUserInfo: async function ({ providerConfig, oAuthTokens, }: { providerConfig: ProviderConfigWithOIDCInfo; - oAuthTokens: any; + oAuthTokens: OAuthTokens; }): Promise { let jwks: JWTVerifyGetKey | undefined; diff --git a/lib/ts/recipe/oauth2client/types.ts b/lib/ts/recipe/oauth2client/types.ts index 32f809ee5..8199c2838 100644 --- a/lib/ts/recipe/oauth2client/types.ts +++ b/lib/ts/recipe/oauth2client/types.ts @@ -41,6 +41,20 @@ export type ProviderConfigWithOIDCInfo = ProviderConfigInput & { 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?: { @@ -74,7 +88,7 @@ export type RecipeInterface = { signIn(input: { userId: string; - oAuthTokens: { [key: string]: any }; + oAuthTokens: OAuthTokens; rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any }; fromUserInfoAPI?: { [key: string]: any }; @@ -85,7 +99,7 @@ export type RecipeInterface = { status: "OK"; recipeUserId: RecipeUserId; user: User; - oAuthTokens: { [key: string]: any }; + oAuthTokens: OAuthTokens; rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any }; fromUserInfoAPI?: { [key: string]: any }; @@ -98,8 +112,8 @@ export type RecipeInterface = { redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; }; - }): Promise | undefined>; - getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: any }): Promise; + }): Promise; + getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: OAuthTokens }): Promise; }; export type APIOptions = { diff --git a/lib/ts/recipe/oauth2client/utils.ts b/lib/ts/recipe/oauth2client/utils.ts index a12b6706a..925d67d66 100644 --- a/lib/ts/recipe/oauth2client/utils.ts +++ b/lib/ts/recipe/oauth2client/utils.ts @@ -15,11 +15,6 @@ import { NormalisedAppinfo } from "../../types"; import { TypeInput, TypeNormalisedInput, RecipeInterface, APIInterface } from "./types"; -import * as jose from "jose"; -import NormalisedURLDomain from "../../normalisedURLDomain"; -import NormalisedURLPath from "../../normalisedURLPath"; -import { doFetch } from "../../utils"; -import { logDebugMessage } from "../../logger"; export function validateAndNormaliseUserInput(_appInfo: NormalisedAppinfo, config: TypeInput): TypeNormalisedInput { if (config === undefined || config.providerConfig === undefined) { @@ -56,120 +51,3 @@ export function validateAndNormaliseUserInput(_appInfo: NormalisedAppinfo, confi override, }; } - -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/lib/ts/recipe/thirdparty/providers/bitbucket.ts b/lib/ts/recipe/thirdparty/providers/bitbucket.ts index 56cc8073f..ce8cabd8f 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 "../../utils"; 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 6e24fc0b6..2c65eeb61 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 "../../utils"; 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 f556eba71..27d9c5204 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 "../../utils"; function getSupertokensUserInfoFromRawUserInfoResponseForGithub(rawUserInfoResponse: { fromIdTokenPayload?: any; diff --git a/lib/ts/recipe/thirdparty/providers/linkedin.ts b/lib/ts/recipe/thirdparty/providers/linkedin.ts index 5aa79976f..2a3efb7bd 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 "../../utils"; 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 cb60db8d3..12ca06460 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 "../../utils"; 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 a4e4a7d7b..c3c46a0eb 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 "../../utils"; export async function discoverOIDCEndpoints(config: ProviderConfigForClientType): Promise { if (config.oidcDiscoveryEndpoint !== undefined) { diff --git a/lib/ts/recipe/utils.ts b/lib/ts/recipe/utils.ts new file mode 100644 index 000000000..89a17c7d9 --- /dev/null +++ b/lib/ts/recipe/utils.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/openid/index.d.ts b/recipe/openid/index.d.ts deleted file mode 100644 index 64f95c7b5..000000000 --- a/recipe/openid/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from "../../lib/build/recipe/openid"; -/** - * '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/openid"; -export default _default; diff --git a/recipe/openid/index.js b/recipe/openid/index.js deleted file mode 100644 index 276ef8f9d..000000000 --- a/recipe/openid/index.js +++ /dev/null @@ -1,6 +0,0 @@ -"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/openid")); diff --git a/recipe/openid/types/index.d.ts b/recipe/openid/types/index.d.ts deleted file mode 100644 index 2c15f75f4..000000000 --- a/recipe/openid/types/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from "../../../lib/build/recipe/openid/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/openid/types"; -export default _default; diff --git a/recipe/openid/types/index.js b/recipe/openid/types/index.js deleted file mode 100644 index 26a5b76d2..000000000 --- a/recipe/openid/types/index.js +++ /dev/null @@ -1,6 +0,0 @@ -"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/openid/types")); diff --git a/test/test-server/src/index.ts b/test/test-server/src/index.ts index cf86168a1..5693f9fe4 100644 --- a/test/test-server/src/index.ts +++ b/test/test-server/src/index.ts @@ -40,7 +40,6 @@ import ThirdParty from "../../../recipe/thirdparty"; import TOTP from "../../../recipe/totp"; import OAuth2 from "../../../recipe/oauth2"; import OAuth2Client from "../../../recipe/oauth2client"; -import OpenId from "../../../recipe/openid"; import accountlinkingRoutes from "./accountlinking"; import emailpasswordRoutes from "./emailpassword"; import emailverificationRoutes from "./emailverification"; @@ -93,7 +92,6 @@ function STReset() { TOTPRecipe.reset(); OAuth2Recipe.reset(); OAuth2ClientRecipe.reset(); - OpenIdRecipe.reset(); SuperTokensRecipe.reset(); } @@ -286,24 +284,6 @@ function initST(config: any) { } recipeList.push(OAuth2Client.init(initConfig)); } - if (recipe.recipeId === "openid") { - let initConfig: OpenIdRecipeTypeInput = { - ...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(OpenId.init(initConfig)); - } }); settings.recipeList = recipeList; From efa789ba0a2bf27ff1b70b974ad7142ea541e1ad Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Fri, 12 Jul 2024 17:40:49 +0530 Subject: [PATCH 11/16] fix: PR changes --- .../recipe/oauth2client/api/implementation.js | 34 +++++++------- lib/build/recipe/oauth2client/index.d.ts | 23 +++++----- lib/build/recipe/oauth2client/index.js | 16 ++++--- .../oauth2client/recipeImplementation.js | 22 +++++---- lib/build/recipe/oauth2client/types.d.ts | 33 ++++++------- .../recipe/thirdparty/providers/bitbucket.js | 6 +-- .../recipe/thirdparty/providers/custom.js | 8 ++-- .../recipe/thirdparty/providers/github.js | 16 +++++-- .../recipe/thirdparty/providers/linkedin.js | 4 +- .../recipe/thirdparty/providers/twitter.js | 4 +- .../recipe/thirdparty/providers/utils.js | 4 +- .../utils.d.ts => thirdpartyUtils.d.ts} | 0 .../{recipe/utils.js => thirdpartyUtils.js} | 8 ++-- .../recipe/oauth2client/api/implementation.ts | 35 +++++++------- lib/ts/recipe/oauth2client/index.ts | 27 +++++++---- .../oauth2client/recipeImplementation.ts | 46 +++++++++++-------- lib/ts/recipe/oauth2client/types.ts | 33 ++++++------- .../recipe/thirdparty/providers/bitbucket.ts | 2 +- lib/ts/recipe/thirdparty/providers/custom.ts | 2 +- lib/ts/recipe/thirdparty/providers/github.ts | 2 +- .../recipe/thirdparty/providers/linkedin.ts | 2 +- lib/ts/recipe/thirdparty/providers/twitter.ts | 2 +- lib/ts/recipe/thirdparty/providers/utils.ts | 2 +- .../{recipe/utils.ts => thirdpartyUtils.ts} | 8 ++-- 24 files changed, 183 insertions(+), 156 deletions(-) rename lib/build/{recipe/utils.d.ts => thirdpartyUtils.d.ts} (100%) rename lib/build/{recipe/utils.js => thirdpartyUtils.js} (95%) rename lib/ts/{recipe/utils.ts => thirdpartyUtils.ts} (94%) diff --git a/lib/build/recipe/oauth2client/api/implementation.js b/lib/build/recipe/oauth2client/api/implementation.js index 8ca4717d2..7ac101e0c 100644 --- a/lib/build/recipe/oauth2client/api/implementation.js +++ b/lib/build/recipe/oauth2client/api/implementation.js @@ -8,37 +8,37 @@ Object.defineProperty(exports, "__esModule", { value: true }); const session_1 = __importDefault(require("../../session")); function getAPIInterface() { return { - authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard }) { - const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL({ + authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard, userContext }) { + const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL( 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, - }); + oAuthTokensToUse = await options.recipeImplementation.exchangeAuthCodeForOAuthTokens( + 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, - }); - const { user, recipeUserId } = await options.recipeImplementation.signIn({ + const { userId, rawUserInfoFromProvider } = await options.recipeImplementation.getUserInfo( + oAuthTokensToUse, + userContext + ); + const { user, recipeUserId } = await options.recipeImplementation.signIn( userId, - tenantId, + oAuthTokensToUse, rawUserInfoFromProvider, - oAuthTokens: oAuthTokensToUse, - userContext, - }); + tenantId, + userContext + ); const session = await session_1.default.createNewSession( options.req, options.res, diff --git a/lib/build/recipe/oauth2client/index.d.ts b/lib/build/recipe/oauth2client/index.d.ts index f3c8d2594..b42ed62c5 100644 --- a/lib/build/recipe/oauth2client/index.d.ts +++ b/lib/build/recipe/oauth2client/index.d.ts @@ -1,26 +1,25 @@ // @ts-nocheck +import { UserContext } from "../../types"; import Recipe from "./recipe"; -import { RecipeInterface, APIInterface, APIOptions, ProviderConfigWithOIDCInfo, OAuthTokens } from "./types"; +import { RecipeInterface, APIInterface, APIOptions, OAuthTokens } from "./types"; export default class Wrapper { static init: typeof Recipe.init; - static getAuthorisationRedirectURL(input: { - redirectURIOnProviderDashboard: string; - }): Promise<{ + static getAuthorisationRedirectURL( + redirectURIOnProviderDashboard: string, + userContext: UserContext + ): Promise<{ urlWithQueryParams: string; pkceCodeVerifier?: string | undefined; }>; - static exchangeAuthCodeForOAuthTokens(input: { - providerConfig: ProviderConfigWithOIDCInfo; + static exchangeAuthCodeForOAuthTokens( redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; - }; - }): Promise; - static getUserInfo(input: { - providerConfig: ProviderConfigWithOIDCInfo; - oAuthTokens: OAuthTokens; - }): Promise; + }, + userContext: UserContext + ): Promise; + static getUserInfo(oAuthTokens: OAuthTokens, userContext: UserContext): Promise; } export declare let init: typeof Recipe.init; export declare let getAuthorisationRedirectURL: typeof Wrapper.getAuthorisationRedirectURL; diff --git a/lib/build/recipe/oauth2client/index.js b/lib/build/recipe/oauth2client/index.js index a10ce70e0..45d0ec291 100644 --- a/lib/build/recipe/oauth2client/index.js +++ b/lib/build/recipe/oauth2client/index.js @@ -22,16 +22,20 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.getUserInfo = exports.exchangeAuthCodeForOAuthTokens = exports.getAuthorisationRedirectURL = exports.init = void 0; const recipe_1 = __importDefault(require("./recipe")); class Wrapper { - static async getAuthorisationRedirectURL(input) { - return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getAuthorisationRedirectURL(input); + static async getAuthorisationRedirectURL(redirectURIOnProviderDashboard, userContext) { + return await recipe_1.default + .getInstanceOrThrowError() + .recipeInterfaceImpl.getAuthorisationRedirectURL(redirectURIOnProviderDashboard, userContext); } - static async exchangeAuthCodeForOAuthTokens(input) { + static async exchangeAuthCodeForOAuthTokens(redirectURIInfo, userContext) { return await recipe_1.default .getInstanceOrThrowError() - .recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens(input); + .recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens(redirectURIInfo, userContext); } - static async getUserInfo(input) { - return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo(input); + static async getUserInfo(oAuthTokens, userContext) { + return await recipe_1.default + .getInstanceOrThrowError() + .recipeInterfaceImpl.getUserInfo(oAuthTokens, userContext); } } exports.default = Wrapper; diff --git a/lib/build/recipe/oauth2client/recipeImplementation.js b/lib/build/recipe/oauth2client/recipeImplementation.js index 45cde1a57..e1d7f6205 100644 --- a/lib/build/recipe/oauth2client/recipeImplementation.js +++ b/lib/build/recipe/oauth2client/recipeImplementation.js @@ -6,7 +6,7 @@ var __importDefault = }; Object.defineProperty(exports, "__esModule", { value: true }); const recipeUserId_1 = __importDefault(require("../../recipeUserId")); -const utils_1 = require("../utils"); +const thirdpartyUtils_1 = require("../../thirdpartyUtils"); const pkce_challenge_1 = __importDefault(require("pkce-challenge")); const __1 = require("../.."); const logger_1 = require("../../logger"); @@ -14,8 +14,8 @@ const jose_1 = require("jose"); function getRecipeImplementation(_querier, config) { let providerConfigWithOIDCInfo = null; return { - getAuthorisationRedirectURL: async function ({ redirectURIOnProviderDashboard }) { - const providerConfig = await this.getProviderConfig(); + getAuthorisationRedirectURL: async function (redirectURIOnProviderDashboard, userContext) { + const providerConfig = await this.getProviderConfig(userContext); const queryParams = { client_id: providerConfig.clientId, redirect_uri: redirectURIOnProviderDashboard, @@ -40,7 +40,7 @@ function getRecipeImplementation(_querier, config) { pkceCodeVerifier: pkceCodeVerifier, }; }, - signIn: async function ({ userId, tenantId, userContext, oAuthTokens, rawUserInfoFromProvider }) { + signIn: async function (userId, oAuthTokens, rawUserInfoFromProvider, tenantId, userContext) { const user = await __1.getUser(userId, userContext); if (user === undefined) { throw new Error(`Failed to getUser from the userId ${userId} in the ${tenantId} tenant`); @@ -57,7 +57,7 @@ function getRecipeImplementation(_querier, config) { if (providerConfigWithOIDCInfo !== null) { return providerConfigWithOIDCInfo; } - const oidcInfo = await utils_1.getOIDCDiscoveryInfo(config.providerConfig.oidcDiscoveryEndpoint); + const oidcInfo = await thirdpartyUtils_1.getOIDCDiscoveryInfo(config.providerConfig.oidcDiscoveryEndpoint); if (oidcInfo.authorization_endpoint === undefined) { throw new Error("Failed to authorization_endpoint from the oidcDiscoveryEndpoint."); } @@ -79,7 +79,8 @@ function getRecipeImplementation(_querier, config) { }); return providerConfigWithOIDCInfo; }, - exchangeAuthCodeForOAuthTokens: async function ({ providerConfig, redirectURIInfo }) { + exchangeAuthCodeForOAuthTokens: async function (redirectURIInfo, userContext) { + const providerConfig = await this.getProviderConfig(userContext); if (providerConfig.tokenEndpoint === undefined) { throw new Error("OAuth2Client provider's tokenEndpoint is not configured."); } @@ -96,7 +97,7 @@ function getRecipeImplementation(_querier, config) { if (redirectURIInfo.pkceCodeVerifier !== undefined) { accessTokenAPIParams["code_verifier"] = redirectURIInfo.pkceCodeVerifier; } - 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}` @@ -107,7 +108,8 @@ function getRecipeImplementation(_querier, config) { } return tokenResponse.jsonResponse; }, - getUserInfo: async function ({ providerConfig, oAuthTokens }) { + getUserInfo: async function (oAuthTokens, userContext) { + const providerConfig = await this.getProviderConfig(userContext); let jwks; const accessToken = oAuthTokens["access_token"]; const idToken = oAuthTokens["id_token"]; @@ -119,7 +121,7 @@ function getRecipeImplementation(_querier, config) { if (jwks === undefined) { jwks = jose_1.createRemoteJWKSet(new URL(providerConfig.jwksURI)); } - rawUserInfoFromProvider.fromIdTokenPayload = await utils_1.verifyIdTokenFromJWKSEndpointAndGetPayload( + rawUserInfoFromProvider.fromIdTokenPayload = await thirdpartyUtils_1.verifyIdTokenFromJWKSEndpointAndGetPayload( idToken, jwks, { @@ -132,7 +134,7 @@ function getRecipeImplementation(_querier, config) { Authorization: "Bearer " + accessToken, }; const queryParams = {}; - const userInfoFromAccessToken = await utils_1.doGetRequest( + const userInfoFromAccessToken = await thirdpartyUtils_1.doGetRequest( providerConfig.userInfoEndpoint, queryParams, headers diff --git a/lib/build/recipe/oauth2client/types.d.ts b/lib/build/recipe/oauth2client/types.d.ts index a322c3ef1..2a4b896fa 100644 --- a/lib/build/recipe/oauth2client/types.d.ts +++ b/lib/build/recipe/oauth2client/types.d.ts @@ -65,16 +65,17 @@ export declare type TypeNormalisedInput = { }; }; export declare type RecipeInterface = { - getAuthorisationRedirectURL(input: { - redirectURIOnProviderDashboard: string; - }): Promise<{ + getAuthorisationRedirectURL( + redirectURIOnProviderDashboard: string, + userContext: UserContext + ): Promise<{ urlWithQueryParams: string; pkceCodeVerifier?: string; }>; - getProviderConfig(input: { userContext: UserContext }): Promise; - signIn(input: { - userId: string; - oAuthTokens: OAuthTokens; + getProviderConfig(userContext: UserContext): Promise; + signIn( + userId: string, + oAuthTokens: OAuthTokens, rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any; @@ -82,10 +83,10 @@ export declare type RecipeInterface = { fromUserInfoAPI?: { [key: string]: any; }; - }; - tenantId: string; - userContext: UserContext; - }): Promise<{ + }, + tenantId: string, + userContext: UserContext + ): Promise<{ status: "OK"; recipeUserId: RecipeUserId; user: User; @@ -99,15 +100,15 @@ export declare type RecipeInterface = { }; }; }>; - exchangeAuthCodeForOAuthTokens(input: { - providerConfig: ProviderConfigWithOIDCInfo; + exchangeAuthCodeForOAuthTokens( redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; - }; - }): Promise; - getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: OAuthTokens }): Promise; + }, + userContext: UserContext + ): Promise; + getUserInfo(oAuthTokens: OAuthTokens, userContext: UserContext): Promise; }; export declare type APIOptions = { recipeImplementation: RecipeInterface; diff --git a/lib/build/recipe/thirdparty/providers/bitbucket.js b/lib/build/recipe/thirdparty/providers/bitbucket.js index eae1d6a0f..6c5a60d23 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 bb0f7e531..ddd2b7a00 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 dc451c880..1f3c86029 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 03730e039..defa0739c 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 3c8170fee..7a7078b51 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.js b/lib/build/recipe/thirdparty/providers/utils.js index bb38d84af..90640abce 100644 --- a/lib/build/recipe/thirdparty/providers/utils.js +++ b/lib/build/recipe/thirdparty/providers/utils.js @@ -1,10 +1,10 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.discoverOIDCEndpoints = void 0; -const utils_1 = require("../../utils"); +const thirdpartyUtils_1 = require("../../../thirdpartyUtils"); async function discoverOIDCEndpoints(config) { if (config.oidcDiscoveryEndpoint !== undefined) { - const oidcInfo = await utils_1.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/recipe/utils.d.ts b/lib/build/thirdpartyUtils.d.ts similarity index 100% rename from lib/build/recipe/utils.d.ts rename to lib/build/thirdpartyUtils.d.ts diff --git a/lib/build/recipe/utils.js b/lib/build/thirdpartyUtils.js similarity index 95% rename from lib/build/recipe/utils.js rename to lib/build/thirdpartyUtils.js index 4d064c39d..ee7a2fcb1 100644 --- a/lib/build/recipe/utils.js +++ b/lib/build/thirdpartyUtils.js @@ -43,10 +43,10 @@ var __importDefault = 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")); +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)}` diff --git a/lib/ts/recipe/oauth2client/api/implementation.ts b/lib/ts/recipe/oauth2client/api/implementation.ts index 56f82b447..8e7c6d287 100644 --- a/lib/ts/recipe/oauth2client/api/implementation.ts +++ b/lib/ts/recipe/oauth2client/api/implementation.ts @@ -4,10 +4,11 @@ import { OAuthTokens } from "../types"; export default function getAPIInterface(): APIInterface { return { - authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard }) { - const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL({ + authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard, userContext }) { + const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL( redirectURIOnProviderDashboard, - }); + userContext + ); return { status: "OK", ...authUrl, @@ -16,33 +17,31 @@ export default function getAPIInterface(): APIInterface { 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, - }); + oAuthTokensToUse = await options.recipeImplementation.exchangeAuthCodeForOAuthTokens( + 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, - }); + const { userId, rawUserInfoFromProvider } = await options.recipeImplementation.getUserInfo( + oAuthTokensToUse, + userContext + ); - const { user, recipeUserId } = await options.recipeImplementation.signIn({ + const { user, recipeUserId } = await options.recipeImplementation.signIn( userId, - tenantId, + oAuthTokensToUse, rawUserInfoFromProvider, - oAuthTokens: oAuthTokensToUse, - userContext, - }); + tenantId, + userContext + ); const session = await Session.createNewSession( options.req, diff --git a/lib/ts/recipe/oauth2client/index.ts b/lib/ts/recipe/oauth2client/index.ts index 45baf876a..6668f312e 100644 --- a/lib/ts/recipe/oauth2client/index.ts +++ b/lib/ts/recipe/oauth2client/index.ts @@ -13,29 +13,36 @@ * under the License. */ +import { UserContext } from "../../types"; import Recipe from "./recipe"; -import { RecipeInterface, APIInterface, APIOptions, ProviderConfigWithOIDCInfo, OAuthTokens } from "./types"; +import { RecipeInterface, APIInterface, APIOptions, OAuthTokens } from "./types"; export default class Wrapper { static init = Recipe.init; - static async getAuthorisationRedirectURL(input: { redirectURIOnProviderDashboard: string }) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getAuthorisationRedirectURL(input); + static async getAuthorisationRedirectURL(redirectURIOnProviderDashboard: string, userContext: UserContext) { + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getAuthorisationRedirectURL( + redirectURIOnProviderDashboard, + userContext + ); } - static async exchangeAuthCodeForOAuthTokens(input: { - providerConfig: ProviderConfigWithOIDCInfo; + static async exchangeAuthCodeForOAuthTokens( redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; - }; - }) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens(input); + }, + userContext: UserContext + ) { + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens( + redirectURIInfo, + userContext + ); } - static async getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: OAuthTokens }) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo(input); + static async getUserInfo(oAuthTokens: OAuthTokens, userContext: UserContext) { + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo(oAuthTokens, userContext); } } diff --git a/lib/ts/recipe/oauth2client/recipeImplementation.ts b/lib/ts/recipe/oauth2client/recipeImplementation.ts index ac6996e9e..eb8fce5dc 100644 --- a/lib/ts/recipe/oauth2client/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2client/recipeImplementation.ts @@ -8,13 +8,13 @@ import { } from "./types"; import { Querier } from "../../querier"; import RecipeUserId from "../../recipeUserId"; -import { User as UserType } from "../../types"; +import { UserContext, User as UserType } from "../../types"; import { doGetRequest, doPostRequest, getOIDCDiscoveryInfo, verifyIdTokenFromJWKSEndpointAndGetPayload, -} from "../utils"; +} from "../../thirdpartyUtils"; import pkceChallenge from "pkce-challenge"; import { getUser } from "../.."; import { logDebugMessage } from "../../logger"; @@ -24,8 +24,12 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN let providerConfigWithOIDCInfo: ProviderConfigWithOIDCInfo | null = null; return { - getAuthorisationRedirectURL: async function ({ redirectURIOnProviderDashboard }) { - const providerConfig = await this.getProviderConfig(); + getAuthorisationRedirectURL: async function ( + this: RecipeInterface, + redirectURIOnProviderDashboard, + userContext + ) { + const providerConfig = await this.getProviderConfig(userContext); const queryParams: { [key: string]: string } = { client_id: providerConfig.clientId, @@ -58,8 +62,14 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN }; }, signIn: async function ( - this: RecipeInterface, - { userId, tenantId, userContext, oAuthTokens, rawUserInfoFromProvider } + userId: string, + oAuthTokens: OAuthTokens, + rawUserInfoFromProvider: { + fromIdTokenPayload?: { [key: string]: any }; + fromUserInfoAPI?: { [key: string]: any }; + }, + tenantId: string, + userContext: UserContext ): Promise<{ status: "OK"; user: UserType; @@ -113,17 +123,17 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN }; return providerConfigWithOIDCInfo; }, - exchangeAuthCodeForOAuthTokens: async function ({ - providerConfig, - redirectURIInfo, - }: { - providerConfig: ProviderConfigWithOIDCInfo; + exchangeAuthCodeForOAuthTokens: async function ( + this: RecipeInterface, redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; - }; - }) { + }, + userContext: UserContext + ) { + const providerConfig = await this.getProviderConfig(userContext); + if (providerConfig.tokenEndpoint === undefined) { throw new Error("OAuth2Client provider's tokenEndpoint is not configured."); } @@ -154,13 +164,9 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN return tokenResponse.jsonResponse as OAuthTokenResponse; }, - getUserInfo: async function ({ - providerConfig, - oAuthTokens, - }: { - providerConfig: ProviderConfigWithOIDCInfo; - oAuthTokens: OAuthTokens; - }): Promise { + getUserInfo: async function (oAuthTokens: OAuthTokens, userContext: UserContext): Promise { + const providerConfig = await this.getProviderConfig(userContext); + let jwks: JWTVerifyGetKey | undefined; const accessToken = oAuthTokens["access_token"]; diff --git a/lib/ts/recipe/oauth2client/types.ts b/lib/ts/recipe/oauth2client/types.ts index 8199c2838..fa85a0cad 100644 --- a/lib/ts/recipe/oauth2client/types.ts +++ b/lib/ts/recipe/oauth2client/types.ts @@ -78,24 +78,25 @@ export type TypeNormalisedInput = { }; export type RecipeInterface = { - getAuthorisationRedirectURL(input: { - redirectURIOnProviderDashboard: string; - }): Promise<{ + getAuthorisationRedirectURL( + redirectURIOnProviderDashboard: string, + userContext: UserContext + ): Promise<{ urlWithQueryParams: string; pkceCodeVerifier?: string; }>; - getProviderConfig(input: { userContext: UserContext }): Promise; + getProviderConfig(userContext: UserContext): Promise; - signIn(input: { - userId: string; - oAuthTokens: OAuthTokens; + signIn( + userId: string, + oAuthTokens: OAuthTokens, rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any }; fromUserInfoAPI?: { [key: string]: any }; - }; - tenantId: string; - userContext: UserContext; - }): Promise<{ + }, + tenantId: string, + userContext: UserContext + ): Promise<{ status: "OK"; recipeUserId: RecipeUserId; user: User; @@ -105,15 +106,15 @@ export type RecipeInterface = { fromUserInfoAPI?: { [key: string]: any }; }; }>; - exchangeAuthCodeForOAuthTokens(input: { - providerConfig: ProviderConfigWithOIDCInfo; + exchangeAuthCodeForOAuthTokens( redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; - }; - }): Promise; - getUserInfo(input: { providerConfig: ProviderConfigWithOIDCInfo; oAuthTokens: OAuthTokens }): Promise; + }, + userContext: UserContext + ): Promise; + getUserInfo(oAuthTokens: OAuthTokens, userContext: UserContext): Promise; }; export type APIOptions = { diff --git a/lib/ts/recipe/thirdparty/providers/bitbucket.ts b/lib/ts/recipe/thirdparty/providers/bitbucket.ts index ce8cabd8f..6d5ace5e5 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 2c65eeb61..ba7226836 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 27d9c5204..e1f218143 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 2a3efb7bd..c179b0269 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 12ca06460..083cf821f 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 c3c46a0eb..347c04942 100644 --- a/lib/ts/recipe/thirdparty/providers/utils.ts +++ b/lib/ts/recipe/thirdparty/providers/utils.ts @@ -1,5 +1,5 @@ import { ProviderConfigForClientType } from "../types"; -import { getOIDCDiscoveryInfo } from "../../utils"; +import { getOIDCDiscoveryInfo } from "../../../thirdpartyUtils"; export async function discoverOIDCEndpoints(config: ProviderConfigForClientType): Promise { if (config.oidcDiscoveryEndpoint !== undefined) { diff --git a/lib/ts/recipe/utils.ts b/lib/ts/thirdpartyUtils.ts similarity index 94% rename from lib/ts/recipe/utils.ts rename to lib/ts/thirdpartyUtils.ts index 89a17c7d9..349d29ad0 100644 --- a/lib/ts/recipe/utils.ts +++ b/lib/ts/thirdpartyUtils.ts @@ -1,8 +1,8 @@ import * as jose from "jose"; -import { logDebugMessage } from "../logger"; -import { doFetch } from "../utils"; -import NormalisedURLDomain from "../normalisedURLDomain"; -import NormalisedURLPath from "../normalisedURLPath"; +import { logDebugMessage } from "./logger"; +import { doFetch } from "./utils"; +import NormalisedURLDomain from "./normalisedURLDomain"; +import NormalisedURLPath from "./normalisedURLPath"; export async function doGetRequest( url: string, From c5e69880436a4c3307c1221ce3dc85dfff0f46c2 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Mon, 15 Jul 2024 12:36:00 +0530 Subject: [PATCH 12/16] fix: PR changes --- lib/build/recipe/oauth2/recipe.js | 2 +- .../recipe/oauth2client/api/implementation.js | 37 +++++++++-------- lib/build/recipe/oauth2client/index.js | 24 +++++++---- .../oauth2client/recipeImplementation.js | 11 ++--- lib/build/recipe/oauth2client/types.d.ts | 40 +++++++++++-------- lib/ts/recipe/oauth2/recipe.ts | 2 +- .../recipe/oauth2client/api/implementation.ts | 39 ++++++++++-------- lib/ts/recipe/oauth2client/index.ts | 26 ++++++++---- .../oauth2client/recipeImplementation.ts | 40 +++++-------------- lib/ts/recipe/oauth2client/types.ts | 40 +++++++++++-------- test/test-server/src/index.ts | 18 --------- 11 files changed, 143 insertions(+), 136 deletions(-) diff --git a/lib/build/recipe/oauth2/recipe.js b/lib/build/recipe/oauth2/recipe.js index 9a7a01277..4e6d0d78c 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/implementation.js b/lib/build/recipe/oauth2client/api/implementation.js index 7ac101e0c..5b642629a 100644 --- a/lib/build/recipe/oauth2client/api/implementation.js +++ b/lib/build/recipe/oauth2client/api/implementation.js @@ -9,36 +9,41 @@ const session_1 = __importDefault(require("../../session")); function getAPIInterface() { return { authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard, userContext }) { - const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL( + const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL({ + providerConfig, redirectURIOnProviderDashboard, - userContext - ); + 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( - input.redirectURIInfo, - userContext - ); + 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( - oAuthTokensToUse, - userContext - ); - const { user, recipeUserId } = await options.recipeImplementation.signIn( + const { userId, rawUserInfoFromProvider } = await options.recipeImplementation.getUserInfo({ + providerConfig, + oAuthTokens: oAuthTokensToUse, + userContext, + }); + const { user, recipeUserId } = await options.recipeImplementation.signIn({ userId, - oAuthTokensToUse, - rawUserInfoFromProvider, tenantId, - userContext - ); + rawUserInfoFromProvider, + oAuthTokens: oAuthTokensToUse, + userContext, + }); const session = await session_1.default.createNewSession( options.req, options.res, diff --git a/lib/build/recipe/oauth2client/index.js b/lib/build/recipe/oauth2client/index.js index 45d0ec291..285af7656 100644 --- a/lib/build/recipe/oauth2client/index.js +++ b/lib/build/recipe/oauth2client/index.js @@ -23,19 +23,29 @@ exports.getUserInfo = exports.exchangeAuthCodeForOAuthTokens = exports.getAuthor const recipe_1 = __importDefault(require("./recipe")); class Wrapper { static async getAuthorisationRedirectURL(redirectURIOnProviderDashboard, userContext) { - return await recipe_1.default - .getInstanceOrThrowError() - .recipeInterfaceImpl.getAuthorisationRedirectURL(redirectURIOnProviderDashboard, userContext); + const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ userContext }); + return await recipeInterfaceImpl.getAuthorisationRedirectURL({ + providerConfig, + redirectURIOnProviderDashboard, + userContext, + }); } static async exchangeAuthCodeForOAuthTokens(redirectURIInfo, userContext) { - return await recipe_1.default - .getInstanceOrThrowError() - .recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens(redirectURIInfo, userContext); + const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ userContext }); + return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ + providerConfig, + redirectURIInfo, + userContext, + }); } static async getUserInfo(oAuthTokens, userContext) { + const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ userContext }); return await recipe_1.default .getInstanceOrThrowError() - .recipeInterfaceImpl.getUserInfo(oAuthTokens, userContext); + .recipeInterfaceImpl.getUserInfo({ providerConfig, oAuthTokens, userContext }); } } exports.default = Wrapper; diff --git a/lib/build/recipe/oauth2client/recipeImplementation.js b/lib/build/recipe/oauth2client/recipeImplementation.js index e1d7f6205..8343d5d61 100644 --- a/lib/build/recipe/oauth2client/recipeImplementation.js +++ b/lib/build/recipe/oauth2client/recipeImplementation.js @@ -14,8 +14,7 @@ const jose_1 = require("jose"); function getRecipeImplementation(_querier, config) { let providerConfigWithOIDCInfo = null; return { - getAuthorisationRedirectURL: async function (redirectURIOnProviderDashboard, userContext) { - const providerConfig = await this.getProviderConfig(userContext); + getAuthorisationRedirectURL: async function ({ providerConfig, redirectURIOnProviderDashboard }) { const queryParams = { client_id: providerConfig.clientId, redirect_uri: redirectURIOnProviderDashboard, @@ -40,7 +39,7 @@ function getRecipeImplementation(_querier, config) { pkceCodeVerifier: pkceCodeVerifier, }; }, - signIn: async function (userId, oAuthTokens, rawUserInfoFromProvider, tenantId, userContext) { + 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`); @@ -79,8 +78,7 @@ function getRecipeImplementation(_querier, config) { }); return providerConfigWithOIDCInfo; }, - exchangeAuthCodeForOAuthTokens: async function (redirectURIInfo, userContext) { - const providerConfig = await this.getProviderConfig(userContext); + exchangeAuthCodeForOAuthTokens: async function ({ providerConfig, redirectURIInfo }) { if (providerConfig.tokenEndpoint === undefined) { throw new Error("OAuth2Client provider's tokenEndpoint is not configured."); } @@ -108,8 +106,7 @@ function getRecipeImplementation(_querier, config) { } return tokenResponse.jsonResponse; }, - getUserInfo: async function (oAuthTokens, userContext) { - const providerConfig = await this.getProviderConfig(userContext); + getUserInfo: async function ({ providerConfig, oAuthTokens }) { let jwks; const accessToken = oAuthTokens["access_token"]; const idToken = oAuthTokens["id_token"]; diff --git a/lib/build/recipe/oauth2client/types.d.ts b/lib/build/recipe/oauth2client/types.d.ts index 2a4b896fa..d00a4a069 100644 --- a/lib/build/recipe/oauth2client/types.d.ts +++ b/lib/build/recipe/oauth2client/types.d.ts @@ -65,17 +65,18 @@ export declare type TypeNormalisedInput = { }; }; export declare type RecipeInterface = { - getAuthorisationRedirectURL( - redirectURIOnProviderDashboard: string, - userContext: UserContext - ): Promise<{ + getAuthorisationRedirectURL(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIOnProviderDashboard: string; + userContext: UserContext; + }): Promise<{ urlWithQueryParams: string; pkceCodeVerifier?: string; }>; - getProviderConfig(userContext: UserContext): Promise; - signIn( - userId: string, - oAuthTokens: OAuthTokens, + getProviderConfig(input: { userContext: UserContext }): Promise; + signIn(input: { + userId: string; + oAuthTokens: OAuthTokens; rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any; @@ -83,10 +84,10 @@ export declare type RecipeInterface = { fromUserInfoAPI?: { [key: string]: any; }; - }, - tenantId: string, - userContext: UserContext - ): Promise<{ + }; + tenantId: string; + userContext: UserContext; + }): Promise<{ status: "OK"; recipeUserId: RecipeUserId; user: User; @@ -100,15 +101,20 @@ export declare type RecipeInterface = { }; }; }>; - exchangeAuthCodeForOAuthTokens( + exchangeAuthCodeForOAuthTokens(input: { + providerConfig: ProviderConfigWithOIDCInfo; redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; - }, - userContext: UserContext - ): Promise; - getUserInfo(oAuthTokens: OAuthTokens, userContext: UserContext): Promise; + }; + userContext: UserContext; + }): Promise; + getUserInfo(input: { + providerConfig: ProviderConfigWithOIDCInfo; + oAuthTokens: OAuthTokens; + userContext: UserContext; + }): Promise; }; export declare type APIOptions = { recipeImplementation: RecipeInterface; diff --git a/lib/ts/recipe/oauth2/recipe.ts b/lib/ts/recipe/oauth2/recipe.ts index 5aa8a3888..a209cff1c 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/oauth2client/api/implementation.ts b/lib/ts/recipe/oauth2client/api/implementation.ts index 8e7c6d287..a6f14e0cb 100644 --- a/lib/ts/recipe/oauth2client/api/implementation.ts +++ b/lib/ts/recipe/oauth2client/api/implementation.ts @@ -5,10 +5,13 @@ import { OAuthTokens } from "../types"; export default function getAPIInterface(): APIInterface { return { authorisationUrlGET: async function ({ options, redirectURIOnProviderDashboard, userContext }) { - const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL( + const providerConfig = await options.recipeImplementation.getProviderConfig({ userContext }); + + const authUrl = await options.recipeImplementation.getAuthorisationRedirectURL({ + providerConfig, redirectURIOnProviderDashboard, - userContext - ); + userContext, + }); return { status: "OK", ...authUrl, @@ -17,31 +20,35 @@ export default function getAPIInterface(): APIInterface { 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( - input.redirectURIInfo, - userContext - ); + 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( - oAuthTokensToUse, - userContext - ); + const { userId, rawUserInfoFromProvider } = await options.recipeImplementation.getUserInfo({ + providerConfig, + oAuthTokens: oAuthTokensToUse, + userContext, + }); - const { user, recipeUserId } = await options.recipeImplementation.signIn( + const { user, recipeUserId } = await options.recipeImplementation.signIn({ userId, - oAuthTokensToUse, - rawUserInfoFromProvider, tenantId, - userContext - ); + rawUserInfoFromProvider, + oAuthTokens: oAuthTokensToUse, + userContext, + }); const session = await Session.createNewSession( options.req, diff --git a/lib/ts/recipe/oauth2client/index.ts b/lib/ts/recipe/oauth2client/index.ts index 6668f312e..4ae33ab34 100644 --- a/lib/ts/recipe/oauth2client/index.ts +++ b/lib/ts/recipe/oauth2client/index.ts @@ -21,10 +21,13 @@ export default class Wrapper { static init = Recipe.init; static async getAuthorisationRedirectURL(redirectURIOnProviderDashboard: string, userContext: UserContext) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getAuthorisationRedirectURL( + const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ userContext }); + return await recipeInterfaceImpl.getAuthorisationRedirectURL({ + providerConfig, redirectURIOnProviderDashboard, - userContext - ); + userContext, + }); } static async exchangeAuthCodeForOAuthTokens( @@ -35,14 +38,23 @@ export default class Wrapper { }, userContext: UserContext ) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens( + const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ userContext }); + return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ + providerConfig, redirectURIInfo, - userContext - ); + userContext, + }); } static async getUserInfo(oAuthTokens: OAuthTokens, userContext: UserContext) { - return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo(oAuthTokens, userContext); + const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const providerConfig = await recipeInterfaceImpl.getProviderConfig({ userContext }); + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ + providerConfig, + oAuthTokens, + userContext, + }); } } diff --git a/lib/ts/recipe/oauth2client/recipeImplementation.ts b/lib/ts/recipe/oauth2client/recipeImplementation.ts index eb8fce5dc..ef374b8f6 100644 --- a/lib/ts/recipe/oauth2client/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2client/recipeImplementation.ts @@ -8,7 +8,7 @@ import { } from "./types"; import { Querier } from "../../querier"; import RecipeUserId from "../../recipeUserId"; -import { UserContext, User as UserType } from "../../types"; +import { User as UserType } from "../../types"; import { doGetRequest, doPostRequest, @@ -26,11 +26,8 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN return { getAuthorisationRedirectURL: async function ( this: RecipeInterface, - redirectURIOnProviderDashboard, - userContext + { providerConfig, redirectURIOnProviderDashboard } ) { - const providerConfig = await this.getProviderConfig(userContext); - const queryParams: { [key: string]: string } = { client_id: providerConfig.clientId, redirect_uri: redirectURIOnProviderDashboard, @@ -61,16 +58,13 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN pkceCodeVerifier: pkceCodeVerifier, }; }, - signIn: async function ( - userId: string, - oAuthTokens: OAuthTokens, - rawUserInfoFromProvider: { - fromIdTokenPayload?: { [key: string]: any }; - fromUserInfoAPI?: { [key: string]: any }; - }, - tenantId: string, - userContext: UserContext - ): Promise<{ + signIn: async function ({ + userId, + tenantId, + userContext, + oAuthTokens, + rawUserInfoFromProvider, + }): Promise<{ status: "OK"; user: UserType; recipeUserId: RecipeUserId; @@ -123,17 +117,7 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN }; return providerConfigWithOIDCInfo; }, - exchangeAuthCodeForOAuthTokens: async function ( - this: RecipeInterface, - redirectURIInfo: { - redirectURIOnProviderDashboard: string; - redirectURIQueryParams: any; - pkceCodeVerifier?: string | undefined; - }, - userContext: UserContext - ) { - const providerConfig = await this.getProviderConfig(userContext); - + exchangeAuthCodeForOAuthTokens: async function (this: RecipeInterface, { providerConfig, redirectURIInfo }) { if (providerConfig.tokenEndpoint === undefined) { throw new Error("OAuth2Client provider's tokenEndpoint is not configured."); } @@ -164,9 +148,7 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN return tokenResponse.jsonResponse as OAuthTokenResponse; }, - getUserInfo: async function (oAuthTokens: OAuthTokens, userContext: UserContext): Promise { - const providerConfig = await this.getProviderConfig(userContext); - + getUserInfo: async function ({ providerConfig, oAuthTokens }): Promise { let jwks: JWTVerifyGetKey | undefined; const accessToken = oAuthTokens["access_token"]; diff --git a/lib/ts/recipe/oauth2client/types.ts b/lib/ts/recipe/oauth2client/types.ts index fa85a0cad..75d3a55c2 100644 --- a/lib/ts/recipe/oauth2client/types.ts +++ b/lib/ts/recipe/oauth2client/types.ts @@ -78,25 +78,26 @@ export type TypeNormalisedInput = { }; export type RecipeInterface = { - getAuthorisationRedirectURL( - redirectURIOnProviderDashboard: string, - userContext: UserContext - ): Promise<{ + getAuthorisationRedirectURL(input: { + providerConfig: ProviderConfigWithOIDCInfo; + redirectURIOnProviderDashboard: string; + userContext: UserContext; + }): Promise<{ urlWithQueryParams: string; pkceCodeVerifier?: string; }>; - getProviderConfig(userContext: UserContext): Promise; + getProviderConfig(input: { userContext: UserContext }): Promise; - signIn( - userId: string, - oAuthTokens: OAuthTokens, + signIn(input: { + userId: string; + oAuthTokens: OAuthTokens; rawUserInfoFromProvider: { fromIdTokenPayload?: { [key: string]: any }; fromUserInfoAPI?: { [key: string]: any }; - }, - tenantId: string, - userContext: UserContext - ): Promise<{ + }; + tenantId: string; + userContext: UserContext; + }): Promise<{ status: "OK"; recipeUserId: RecipeUserId; user: User; @@ -106,15 +107,20 @@ export type RecipeInterface = { fromUserInfoAPI?: { [key: string]: any }; }; }>; - exchangeAuthCodeForOAuthTokens( + exchangeAuthCodeForOAuthTokens(input: { + providerConfig: ProviderConfigWithOIDCInfo; redirectURIInfo: { redirectURIOnProviderDashboard: string; redirectURIQueryParams: any; pkceCodeVerifier?: string | undefined; - }, - userContext: UserContext - ): Promise; - getUserInfo(oAuthTokens: OAuthTokens, userContext: UserContext): Promise; + }; + userContext: UserContext; + }): Promise; + getUserInfo(input: { + providerConfig: ProviderConfigWithOIDCInfo; + oAuthTokens: OAuthTokens; + userContext: UserContext; + }): Promise; }; export type APIOptions = { diff --git a/test/test-server/src/index.ts b/test/test-server/src/index.ts index 7257fa1c0..7ae66ec90 100644 --- a/test/test-server/src/index.ts +++ b/test/test-server/src/index.ts @@ -316,24 +316,6 @@ function initST(config: any) { } recipeList.push(OAuth2.init(initConfig)); } - if (recipe.recipeId === "oauth2") { - let initConfig: OAuth2TypeInput = { - ...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(OAuth2.init(initConfig)); - } if (recipe.recipeId === "oauth2client") { let initConfig: OAuth2ClientTypeInput = { ...config, From 64bb9c7dd25d196d85a2935a73526c4152fa2c40 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Sun, 21 Jul 2024 17:46:15 +0530 Subject: [PATCH 13/16] feat: Add userInfoGET endpoint --- lib/build/recipe/oauth2/api/implementation.js | 12 +++ lib/build/recipe/oauth2/api/userInfo.d.ts | 8 ++ lib/build/recipe/oauth2/api/userInfo.js | 68 ++++++++++++++++ lib/build/recipe/oauth2/constants.d.ts | 1 + lib/build/recipe/oauth2/constants.js | 3 +- lib/build/recipe/oauth2/recipe.d.ts | 17 +++- lib/build/recipe/oauth2/recipe.js | 43 +++++++++- .../recipe/oauth2/recipeImplementation.d.ts | 5 +- .../recipe/oauth2/recipeImplementation.js | 6 +- lib/build/recipe/oauth2/types.d.ts | 30 ++++++- lib/build/recipe/oauth2client/index.js | 8 +- lib/ts/recipe/oauth2/api/implementation.ts | 13 +++ lib/ts/recipe/oauth2/api/userInfo.ts | 79 +++++++++++++++++++ lib/ts/recipe/oauth2/constants.ts | 1 + lib/ts/recipe/oauth2/recipe.ts | 68 +++++++++++++++- lib/ts/recipe/oauth2/recipeImplementation.ts | 8 +- lib/ts/recipe/oauth2/types.ts | 25 +++++- 17 files changed, 376 insertions(+), 19 deletions(-) create mode 100644 lib/build/recipe/oauth2/api/userInfo.d.ts create mode 100644 lib/build/recipe/oauth2/api/userInfo.js create mode 100644 lib/ts/recipe/oauth2/api/userInfo.ts diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index b4bdc6cad..7219aa72d 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -190,6 +190,18 @@ function getAPIImplementation() { }, }; }, + userInfoGET: async ({ accessTokenPayload, user, scopes, options, userContext }) => { + const userInfo = await options.recipeImplementation.buildUserInfo({ + user, + accessTokenPayload, + scopes, + userContext, + }); + return { + status: "OK", + info: userInfo, + }; + }, }; } exports.default = getAPIImplementation; diff --git a/lib/build/recipe/oauth2/api/userInfo.d.ts b/lib/build/recipe/oauth2/api/userInfo.d.ts new file mode 100644 index 000000000..44085e2d4 --- /dev/null +++ b/lib/build/recipe/oauth2/api/userInfo.d.ts @@ -0,0 +1,8 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function userInfoGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/oauth2/api/userInfo.js b/lib/build/recipe/oauth2/api/userInfo.js new file mode 100644 index 000000000..545df953f --- /dev/null +++ b/lib/build/recipe/oauth2/api/userInfo.js @@ -0,0 +1,68 @@ +"use strict"; +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const __1 = require("../../.."); +// TODO: Replace stub implementation by the actual implementation +async function validateOAuth2AccessToken(accessToken) { + const resp = await fetch(`http://localhost:4445/admin/oauth2/introspect`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ token: accessToken }), + }); + return await resp.json(); +} +async function userInfoGET(apiImplementation, options, userContext) { + var _a; + if (apiImplementation.userInfoGET === undefined) { + return false; + } + const authHeader = options.req.getHeaderValue("authorization") || options.req.getHeaderValue("Authorization"); + if (authHeader === undefined || !authHeader.startsWith("Bearer ")) { + utils_1.sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 401); + return true; + } + const accessToken = authHeader.replace(/^Bearer /, "").trim(); + let accessTokenPayload; + try { + accessTokenPayload = await validateOAuth2AccessToken(accessToken); + } catch (error) { + utils_1.sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token!", 401); + return true; + } + const userId = accessTokenPayload.sub; + const user = await __1.getUser(userId, userContext); + if (user === undefined) { + utils_1.sendNon200ResponseWithMessage( + options.res, + "Couldn't find any user associated with the access token", + 401 + ); + return true; + } + const response = await apiImplementation.userInfoGET({ + accessTokenPayload, + user, + scopes: ((_a = accessTokenPayload.scope) !== null && _a !== void 0 ? _a : "").split(" "), + options, + userContext, + }); + utils_1.send200Response(options.res, response); + return true; +} +exports.default = userInfoGET; diff --git a/lib/build/recipe/oauth2/constants.d.ts b/lib/build/recipe/oauth2/constants.d.ts index e5fea5b40..e5a5c4263 100644 --- a/lib/build/recipe/oauth2/constants.d.ts +++ b/lib/build/recipe/oauth2/constants.d.ts @@ -6,3 +6,4 @@ export declare const CONSENT_PATH = "/oauth2/consent"; export declare const AUTH_PATH = "/oauth2/auth"; export declare const TOKEN_PATH = "/oauth2/token"; export declare const LOGIN_INFO_PATH = "/oauth2/login/info"; +export declare const USER_INFO_PATH = "/oauth2/userinfo"; diff --git a/lib/build/recipe/oauth2/constants.js b/lib/build/recipe/oauth2/constants.js index f249f20d4..2ea314db4 100644 --- a/lib/build/recipe/oauth2/constants.js +++ b/lib/build/recipe/oauth2/constants.js @@ -14,7 +14,7 @@ * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.CONSENT_PATH = exports.LOGOUT_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; +exports.USER_INFO_PATH = exports.LOGIN_INFO_PATH = exports.TOKEN_PATH = exports.AUTH_PATH = exports.CONSENT_PATH = exports.LOGOUT_PATH = exports.LOGIN_PATH = exports.OAUTH2_BASE_PATH = void 0; exports.OAUTH2_BASE_PATH = "/oauth2/"; exports.LOGIN_PATH = "/oauth2/login"; exports.LOGOUT_PATH = "/oauth2/logout"; @@ -22,3 +22,4 @@ exports.CONSENT_PATH = "/oauth2/consent"; exports.AUTH_PATH = "/oauth2/auth"; exports.TOKEN_PATH = "/oauth2/token"; exports.LOGIN_INFO_PATH = "/oauth2/login/info"; +exports.USER_INFO_PATH = "/oauth2/userinfo"; diff --git a/lib/build/recipe/oauth2/recipe.d.ts b/lib/build/recipe/oauth2/recipe.d.ts index 8efd16f20..d4429d27d 100644 --- a/lib/build/recipe/oauth2/recipe.d.ts +++ b/lib/build/recipe/oauth2/recipe.d.ts @@ -4,12 +4,20 @@ import type { BaseRequest, BaseResponse } from "../../framework"; import NormalisedURLPath from "../../normalisedURLPath"; import RecipeModule from "../../recipeModule"; import { APIHandled, HTTPMethod, JSONObject, NormalisedAppinfo, RecipeListFunction, UserContext } from "../../types"; -import { APIInterface, RecipeInterface, TypeInput, TypeNormalisedInput } from "./types"; +import { + APIInterface, + RecipeInterface, + TypeInput, + TypeNormalisedInput, + UserInfo, + UserInfoBuilderFunction, +} from "./types"; import { User } from "../../user"; export default class Recipe extends RecipeModule { static RECIPE_ID: string; private static instance; private idTokenBuilders; + private userInfoBuilders; config: TypeNormalisedInput; recipeInterfaceImpl: RecipeInterface; apiImpl: APIInterface; @@ -19,6 +27,7 @@ export default class Recipe extends RecipeModule { static getInstanceOrThrowError(): Recipe; static init(config?: TypeInput): RecipeListFunction; static reset(): void; + addUserInfoBuilderFromOtherRecipe: (userInfoBuilderFn: UserInfoBuilderFunction) => void; getAPIsHandled(): APIHandled[]; handleAPIRequest: ( id: string, @@ -33,4 +42,10 @@ export default class Recipe extends RecipeModule { getAllCORSHeaders(): string[]; isErrorFromThisRecipe(err: any): err is error; getDefaultIdTokenPayload(user: User, scopes: string[], userContext: UserContext): Promise; + getDefaultUserInfoPayload( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + userContext: UserContext + ): Promise; } diff --git a/lib/build/recipe/oauth2/recipe.js b/lib/build/recipe/oauth2/recipe.js index 4e6d0d78c..a17cd76fe 100644 --- a/lib/build/recipe/oauth2/recipe.js +++ b/lib/build/recipe/oauth2/recipe.js @@ -34,10 +34,15 @@ const constants_1 = require("./constants"); const recipeImplementation_1 = __importDefault(require("./recipeImplementation")); const utils_1 = require("./utils"); const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); +const userInfo_1 = __importDefault(require("./api/userInfo")); class Recipe extends recipeModule_1.default { constructor(recipeId, appInfo, isInServerlessEnv, config) { super(recipeId, appInfo); this.idTokenBuilders = []; + this.userInfoBuilders = []; + this.addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn) => { + this.userInfoBuilders.push(userInfoBuilderFn); + }; this.handleAPIRequest = async (id, _tenantId, req, res, _path, _method, userContext) => { let options = { config: this.config, @@ -65,6 +70,9 @@ class Recipe extends recipeModule_1.default { if (id === constants_1.LOGIN_INFO_PATH) { return loginInfo_1.default(this.apiImpl, options, userContext); } + if (id === constants_1.USER_INFO_PATH) { + return userInfo_1.default(this.apiImpl, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; this.config = utils_1.validateAndNormaliseUserInput(this, appInfo, config); @@ -75,7 +83,8 @@ class Recipe extends recipeModule_1.default { querier_1.Querier.getNewInstanceOrThrowError(recipeId), this.config, appInfo, - this.getDefaultIdTokenPayload.bind(this) + this.getDefaultIdTokenPayload.bind(this), + this.getDefaultUserInfoPayload.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); @@ -168,6 +177,12 @@ class Recipe extends recipeModule_1.default { id: constants_1.LOGIN_INFO_PATH, disabled: this.apiImpl.loginInfoGET === undefined, }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.USER_INFO_PATH), + id: constants_1.USER_INFO_PATH, + disabled: this.apiImpl.userInfoGET === undefined, + }, ]; } handleError(error, _, __, _userContext) { @@ -200,6 +215,32 @@ class Recipe extends recipeModule_1.default { } return payload; } + async getDefaultUserInfoPayload(user, accessTokenPayload, scopes, userContext) { + let payload = { + sub: accessTokenPayload.sub, + }; + if (scopes.includes("email")) { + payload.email = user === null || user === void 0 ? void 0 : user.emails[0]; + payload.email_verified = user.loginMethods.some( + (lm) => lm.hasSameEmailAs(user === null || user === void 0 ? void 0 : user.emails[0]) && lm.verified + ); + } + if (scopes.includes("phoneNumber")) { + payload.phoneNumber = user === null || user === void 0 ? void 0 : user.phoneNumbers[0]; + payload.phoneNumber_verified = user.loginMethods.some( + (lm) => + lm.hasSamePhoneNumberAs(user === null || user === void 0 ? void 0 : user.phoneNumbers[0]) && + lm.verified + ); + } + for (const fn of this.userInfoBuilders) { + payload = Object.assign( + Object.assign({}, payload), + await fn(user, accessTokenPayload, scopes, userContext) + ); + } + return payload; + } } exports.default = Recipe; Recipe.RECIPE_ID = "oauth2"; diff --git a/lib/build/recipe/oauth2/recipeImplementation.d.ts b/lib/build/recipe/oauth2/recipeImplementation.d.ts index 0029a9016..273bef941 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.d.ts +++ b/lib/build/recipe/oauth2/recipeImplementation.d.ts @@ -1,10 +1,11 @@ // @ts-nocheck import { Querier } from "../../querier"; import { NormalisedAppinfo } from "../../types"; -import { RecipeInterface, TypeNormalisedInput, PayloadBuilderFunction } from "./types"; +import { RecipeInterface, TypeNormalisedInput, PayloadBuilderFunction, UserInfoBuilderFunction } from "./types"; export default function getRecipeInterface( querier: Querier, _config: TypeNormalisedInput, _appInfo: NormalisedAppinfo, - getDefaultIdTokenPayload: PayloadBuilderFunction + getDefaultIdTokenPayload: PayloadBuilderFunction, + getDefaultUserInfoPayload: UserInfoBuilderFunction ): RecipeInterface; diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index ac5e17e13..2c82dbb9a 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -24,7 +24,7 @@ const querier_1 = require("../../querier"); const utils_1 = require("../../utils"); const OAuth2Client_1 = require("./OAuth2Client"); const __1 = require("../.."); -function getRecipeInterface(querier, _config, _appInfo, getDefaultIdTokenPayload) { +function getRecipeInterface(querier, _config, _appInfo, getDefaultIdTokenPayload, getDefaultUserInfoPayload) { return { getLoginRequest: async function (input) { const resp = await querier.sendGetRequest( @@ -394,8 +394,8 @@ function getRecipeInterface(querier, _config, _appInfo, getDefaultIdTokenPayload buildIdTokenPayload: async function (input) { return input.defaultPayload; }, - buildUserInfo: async function (input) { - return input.user.toJson(); // Proper impl + buildUserInfo: async function ({ user, accessTokenPayload, scopes, userContext }) { + return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, userContext); }, }; } diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index b95d8f50e..63b690fe5 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -87,6 +87,14 @@ export declare type LoginInfo = { logoUri: string; metadata?: Record | null; }; +export declare type UserInfo = { + sub: string; + email?: string; + email_verified?: boolean; + phoneNumber?: string; + phoneNumber_verified?: boolean; + [key: string]: any; +}; export declare type RecipeInterface = { authorization(input: { params: any; @@ -224,7 +232,6 @@ export declare type RecipeInterface = { user: User; accessTokenPayload: JSONObject; scopes: string[]; - defaultInfo: JSONObject; userContext: UserContext; }): Promise; }; @@ -344,6 +351,21 @@ export declare type APIInterface = { } | GeneralErrorResponse >); + userInfoGET: + | undefined + | ((input: { + accessTokenPayload: JSONObject; + user: User; + scopes: string[]; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + info: JSONObject; + } + | GeneralErrorResponse + >); }; export declare type OAuth2ClientOptions = { clientId: string; @@ -445,3 +467,9 @@ export declare type PayloadBuilderFunction = ( scopes: string[], userContext: UserContext ) => Promise; +export declare type UserInfoBuilderFunction = ( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + userContext: UserContext +) => Promise; diff --git a/lib/build/recipe/oauth2client/index.js b/lib/build/recipe/oauth2client/index.js index 285af7656..bc34955ad 100644 --- a/lib/build/recipe/oauth2client/index.js +++ b/lib/build/recipe/oauth2client/index.js @@ -43,9 +43,11 @@ class Wrapper { static async getUserInfo(oAuthTokens, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; const providerConfig = await recipeInterfaceImpl.getProviderConfig({ userContext }); - return await recipe_1.default - .getInstanceOrThrowError() - .recipeInterfaceImpl.getUserInfo({ providerConfig, oAuthTokens, userContext }); + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ + providerConfig, + oAuthTokens, + userContext, + }); } } exports.default = Wrapper; diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index 69c865874..ee6d9dcd3 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -188,5 +188,18 @@ export default function getAPIImplementation(): APIInterface { }, }; }, + userInfoGET: async ({ accessTokenPayload, user, scopes, options, userContext }) => { + const userInfo = await options.recipeImplementation.buildUserInfo({ + user, + accessTokenPayload, + scopes, + userContext, + }); + + return { + status: "OK", + info: userInfo, + }; + }, }; } diff --git a/lib/ts/recipe/oauth2/api/userInfo.ts b/lib/ts/recipe/oauth2/api/userInfo.ts new file mode 100644 index 000000000..82d65d128 --- /dev/null +++ b/lib/ts/recipe/oauth2/api/userInfo.ts @@ -0,0 +1,79 @@ +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import { send200Response, sendNon200ResponseWithMessage } from "../../../utils"; +import { APIInterface, APIOptions } from ".."; +import { JSONObject, UserContext } from "../../../types"; +import { getUser } from "../../.."; + +// TODO: Replace stub implementation by the actual implementation +async function validateOAuth2AccessToken(accessToken: string) { + const resp = await fetch(`http://localhost:4445/admin/oauth2/introspect`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ token: accessToken }), + }); + return await resp.json(); +} + +export default async function userInfoGET( + apiImplementation: APIInterface, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.userInfoGET === undefined) { + return false; + } + + const authHeader = options.req.getHeaderValue("authorization") || options.req.getHeaderValue("Authorization"); + + if (authHeader === undefined || !authHeader.startsWith("Bearer ")) { + sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 401); + return true; + } + + const accessToken = authHeader.replace(/^Bearer /, "").trim(); + + let accessTokenPayload: JSONObject; + + try { + accessTokenPayload = await validateOAuth2AccessToken(accessToken); + } catch (error) { + sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token!", 401); + return true; + } + + const userId = accessTokenPayload.sub as string; + + const user = await getUser(userId, userContext); + + if (user === undefined) { + sendNon200ResponseWithMessage(options.res, "Couldn't find any user associated with the access token", 401); + return true; + } + + const response = await apiImplementation.userInfoGET({ + accessTokenPayload, + user, + scopes: ((accessTokenPayload.scope as string) ?? "").split(" "), + options, + userContext, + }); + + send200Response(options.res, response); + return true; +} diff --git a/lib/ts/recipe/oauth2/constants.ts b/lib/ts/recipe/oauth2/constants.ts index ddfdef4d6..1175abacf 100644 --- a/lib/ts/recipe/oauth2/constants.ts +++ b/lib/ts/recipe/oauth2/constants.ts @@ -21,3 +21,4 @@ export const CONSENT_PATH = "/oauth2/consent"; export const AUTH_PATH = "/oauth2/auth"; export const TOKEN_PATH = "/oauth2/token"; export const LOGIN_INFO_PATH = "/oauth2/login/info"; +export const USER_INFO_PATH = "/oauth2/userinfo"; diff --git a/lib/ts/recipe/oauth2/recipe.ts b/lib/ts/recipe/oauth2/recipe.ts index a209cff1c..076006e57 100644 --- a/lib/ts/recipe/oauth2/recipe.ts +++ b/lib/ts/recipe/oauth2/recipe.ts @@ -27,17 +27,35 @@ import loginAPI from "./api/login"; import logoutAPI from "./api/logout"; import tokenPOST from "./api/token"; import loginInfoGET from "./api/loginInfo"; -import { AUTH_PATH, CONSENT_PATH, LOGIN_INFO_PATH, LOGIN_PATH, LOGOUT_PATH, TOKEN_PATH } from "./constants"; +import { + AUTH_PATH, + CONSENT_PATH, + LOGIN_INFO_PATH, + LOGIN_PATH, + LOGOUT_PATH, + TOKEN_PATH, + USER_INFO_PATH, +} from "./constants"; import RecipeImplementation from "./recipeImplementation"; -import { APIInterface, PayloadBuilderFunction, RecipeInterface, TypeInput, TypeNormalisedInput } from "./types"; +import { + APIInterface, + PayloadBuilderFunction, + RecipeInterface, + TypeInput, + TypeNormalisedInput, + UserInfo, + UserInfoBuilderFunction, +} from "./types"; import { validateAndNormaliseUserInput } from "./utils"; import OverrideableBuilder from "supertokens-js-override"; import { User } from "../../user"; +import userInfoGET from "./api/userInfo"; export default class Recipe extends RecipeModule { static RECIPE_ID = "oauth2"; private static instance: Recipe | undefined = undefined; private idTokenBuilders: PayloadBuilderFunction[] = []; + private userInfoBuilders: UserInfoBuilderFunction[] = []; config: TypeNormalisedInput; recipeInterfaceImpl: RecipeInterface; @@ -55,7 +73,8 @@ export default class Recipe extends RecipeModule { Querier.getNewInstanceOrThrowError(recipeId), this.config, appInfo, - this.getDefaultIdTokenPayload.bind(this) + this.getDefaultIdTokenPayload.bind(this), + this.getDefaultUserInfoPayload.bind(this) ) ); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); @@ -96,6 +115,10 @@ export default class Recipe extends RecipeModule { Recipe.instance = undefined; } + addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn: UserInfoBuilderFunction) => { + this.userInfoBuilders.push(userInfoBuilderFn); + }; + /* RecipeModule functions */ getAPIsHandled(): APIHandled[] { @@ -154,6 +177,12 @@ export default class Recipe extends RecipeModule { id: LOGIN_INFO_PATH, disabled: this.apiImpl.loginInfoGET === undefined, }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(USER_INFO_PATH), + id: USER_INFO_PATH, + disabled: this.apiImpl.userInfoGET === undefined, + }, ]; } @@ -193,6 +222,9 @@ export default class Recipe extends RecipeModule { if (id === LOGIN_INFO_PATH) { return loginInfoGET(this.apiImpl, options, userContext); } + if (id === USER_INFO_PATH) { + return userInfoGET(this.apiImpl, options, userContext); + } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; @@ -230,4 +262,34 @@ export default class Recipe extends RecipeModule { return payload; } + + async getDefaultUserInfoPayload( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + userContext: UserContext + ) { + let payload: JSONObject = { + sub: accessTokenPayload.sub, + }; + if (scopes.includes("email")) { + payload.email = user?.emails[0]; + payload.email_verified = user.loginMethods.some((lm) => lm.hasSameEmailAs(user?.emails[0]) && lm.verified); + } + if (scopes.includes("phoneNumber")) { + payload.phoneNumber = user?.phoneNumbers[0]; + payload.phoneNumber_verified = user.loginMethods.some( + (lm) => lm.hasSamePhoneNumberAs(user?.phoneNumbers[0]) && lm.verified + ); + } + + for (const fn of this.userInfoBuilders) { + payload = { + ...payload, + ...(await fn(user, accessTokenPayload, scopes, userContext)), + }; + } + + return payload as UserInfo; + } } diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index 0f5e62274..9a5a40251 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -23,6 +23,7 @@ import { LoginRequest, LogoutRequest, PayloadBuilderFunction, + UserInfoBuilderFunction, } from "./types"; import { toSnakeCase, transformObjectKeys } from "../../utils"; import { OAuth2Client } from "./OAuth2Client"; @@ -32,7 +33,8 @@ export default function getRecipeInterface( querier: Querier, _config: TypeNormalisedInput, _appInfo: NormalisedAppinfo, - getDefaultIdTokenPayload: PayloadBuilderFunction + getDefaultIdTokenPayload: PayloadBuilderFunction, + getDefaultUserInfoPayload: UserInfoBuilderFunction ): RecipeInterface { return { getLoginRequest: async function (this: RecipeInterface, input): Promise { @@ -423,8 +425,8 @@ export default function getRecipeInterface( buildIdTokenPayload: async function (input) { return input.defaultPayload; }, - buildUserInfo: async function (input) { - return input.user.toJson(); // Proper impl + buildUserInfo: async function ({ user, accessTokenPayload, scopes, userContext }) { + return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, userContext); }, }; } diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index f2a79b025..ae088e9a2 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -188,6 +188,15 @@ export type LoginInfo = { metadata?: Record | null; }; +export type UserInfo = { + sub: string; + email?: string; + email_verified?: boolean; + phoneNumber?: string; + phoneNumber_verified?: boolean; + [key: string]: any; +}; + export type RecipeInterface = { authorization(input: { params: any; @@ -353,7 +362,6 @@ export type RecipeInterface = { user: User; accessTokenPayload: JSONObject; scopes: string[]; - defaultInfo: JSONObject; userContext: UserContext; }): Promise; }; @@ -437,6 +445,15 @@ export type APIInterface = { options: APIOptions; userContext: UserContext; }) => Promise<{ status: "OK"; info: LoginInfo } | GeneralErrorResponse>); + userInfoGET: + | undefined + | ((input: { + accessTokenPayload: JSONObject; + user: User; + scopes: string[]; + options: APIOptions; + userContext: UserContext; + }) => Promise<{ status: "OK"; info: JSONObject } | GeneralErrorResponse>); }; export type OAuth2ClientOptions = { @@ -547,3 +564,9 @@ export type DeleteOAuth2ClientInput = { }; export type PayloadBuilderFunction = (user: User, scopes: string[], userContext: UserContext) => Promise; +export type UserInfoBuilderFunction = ( + user: User, + accessTokenPayload: JSONObject, + scopes: string[], + userContext: UserContext +) => Promise; From 08183cee24ca948c4ed18e4489ad7d84167ce407 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Wed, 24 Jul 2024 20:00:49 +0530 Subject: [PATCH 14/16] fix: PR changes --- lib/build/recipe/oauth2/api/implementation.js | 3 +- lib/build/recipe/oauth2/api/userInfo.d.ts | 1 + lib/build/recipe/oauth2/api/userInfo.js | 26 ++++++++--- lib/build/recipe/oauth2/recipe.d.ts | 3 +- lib/build/recipe/oauth2/recipe.js | 8 ++-- .../recipe/oauth2/recipeImplementation.js | 4 +- lib/build/recipe/oauth2/types.d.ts | 9 ++-- .../oauth2client/recipeImplementation.js | 7 ++- .../recipe/openid/recipeImplementation.js | 1 + lib/build/recipe/userroles/recipe.js | 34 ++++++++++++++ lib/ts/recipe/oauth2/api/implementation.ts | 3 +- lib/ts/recipe/oauth2/api/userInfo.ts | 27 +++++++++-- lib/ts/recipe/oauth2/recipe.ts | 7 +-- lib/ts/recipe/oauth2/recipeImplementation.ts | 4 +- lib/ts/recipe/oauth2/types.ts | 9 ++-- .../oauth2client/recipeImplementation.ts | 7 ++- lib/ts/recipe/openid/recipeImplementation.ts | 3 +- lib/ts/recipe/userroles/recipe.ts | 46 +++++++++++++++++++ 18 files changed, 162 insertions(+), 40 deletions(-) diff --git a/lib/build/recipe/oauth2/api/implementation.js b/lib/build/recipe/oauth2/api/implementation.js index 7219aa72d..472c83d38 100644 --- a/lib/build/recipe/oauth2/api/implementation.js +++ b/lib/build/recipe/oauth2/api/implementation.js @@ -190,11 +190,12 @@ function getAPIImplementation() { }, }; }, - userInfoGET: async ({ accessTokenPayload, user, scopes, options, userContext }) => { + userInfoGET: async ({ accessTokenPayload, user, scopes, tenantId, options, userContext }) => { const userInfo = await options.recipeImplementation.buildUserInfo({ user, accessTokenPayload, scopes, + tenantId, userContext, }); return { diff --git a/lib/build/recipe/oauth2/api/userInfo.d.ts b/lib/build/recipe/oauth2/api/userInfo.d.ts index 44085e2d4..d0b8cdf4e 100644 --- a/lib/build/recipe/oauth2/api/userInfo.d.ts +++ b/lib/build/recipe/oauth2/api/userInfo.d.ts @@ -3,6 +3,7 @@ import { APIInterface, APIOptions } from ".."; import { UserContext } from "../../../types"; export default function userInfoGET( apiImplementation: APIInterface, + tenantId: string, options: APIOptions, userContext: UserContext ): Promise; diff --git a/lib/build/recipe/oauth2/api/userInfo.js b/lib/build/recipe/oauth2/api/userInfo.js index 545df953f..dadeb840c 100644 --- a/lib/build/recipe/oauth2/api/userInfo.js +++ b/lib/build/recipe/oauth2/api/userInfo.js @@ -27,14 +27,15 @@ async function validateOAuth2AccessToken(accessToken) { }); return await resp.json(); } -async function userInfoGET(apiImplementation, options, userContext) { - var _a; +async function userInfoGET(apiImplementation, tenantId, options, userContext) { if (apiImplementation.userInfoGET === undefined) { return false; } const authHeader = options.req.getHeaderValue("authorization") || options.req.getHeaderValue("Authorization"); if (authHeader === undefined || !authHeader.startsWith("Bearer ")) { - utils_1.sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 401); + // TODO: Returning a 400 instead of a 401 to prevent a potential refresh loop in the client SDK. + // When addressing this TODO, review other response codes in this function as well. + utils_1.sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 400); return true; } const accessToken = authHeader.replace(/^Bearer /, "").trim(); @@ -42,23 +43,36 @@ async function userInfoGET(apiImplementation, options, userContext) { try { accessTokenPayload = await validateOAuth2AccessToken(accessToken); } catch (error) { - utils_1.sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token!", 401); + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + utils_1.sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token", 400); + return true; + } + if ( + accessTokenPayload === null || + typeof accessTokenPayload !== "object" || + typeof accessTokenPayload.sub !== "string" || + typeof accessTokenPayload.scope !== "string" + ) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + utils_1.sendNon200ResponseWithMessage(options.res, "Malformed access token payload", 400); return true; } const userId = accessTokenPayload.sub; const user = await __1.getUser(userId, userContext); if (user === undefined) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); utils_1.sendNon200ResponseWithMessage( options.res, "Couldn't find any user associated with the access token", - 401 + 400 ); return true; } const response = await apiImplementation.userInfoGET({ accessTokenPayload, user, - scopes: ((_a = accessTokenPayload.scope) !== null && _a !== void 0 ? _a : "").split(" "), + tenantId, + scopes: accessTokenPayload.scope.split(" "), options, userContext, }); diff --git a/lib/build/recipe/oauth2/recipe.d.ts b/lib/build/recipe/oauth2/recipe.d.ts index d4429d27d..19a890e21 100644 --- a/lib/build/recipe/oauth2/recipe.d.ts +++ b/lib/build/recipe/oauth2/recipe.d.ts @@ -31,7 +31,7 @@ export default class Recipe extends RecipeModule { getAPIsHandled(): APIHandled[]; handleAPIRequest: ( id: string, - _tenantId: string | undefined, + tenantId: string, req: BaseRequest, res: BaseResponse, _path: NormalisedURLPath, @@ -46,6 +46,7 @@ export default class Recipe extends RecipeModule { user: User, accessTokenPayload: JSONObject, scopes: string[], + tenantId: string, userContext: UserContext ): Promise; } diff --git a/lib/build/recipe/oauth2/recipe.js b/lib/build/recipe/oauth2/recipe.js index a17cd76fe..048abe7dd 100644 --- a/lib/build/recipe/oauth2/recipe.js +++ b/lib/build/recipe/oauth2/recipe.js @@ -43,7 +43,7 @@ class Recipe extends recipeModule_1.default { this.addUserInfoBuilderFromOtherRecipe = (userInfoBuilderFn) => { this.userInfoBuilders.push(userInfoBuilderFn); }; - this.handleAPIRequest = async (id, _tenantId, req, res, _path, _method, userContext) => { + this.handleAPIRequest = async (id, tenantId, req, res, _path, _method, userContext) => { let options = { config: this.config, recipeId: this.getRecipeId(), @@ -71,7 +71,7 @@ class Recipe extends recipeModule_1.default { return loginInfo_1.default(this.apiImpl, options, userContext); } if (id === constants_1.USER_INFO_PATH) { - return userInfo_1.default(this.apiImpl, options, userContext); + return userInfo_1.default(this.apiImpl, tenantId, options, userContext); } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; @@ -215,7 +215,7 @@ class Recipe extends recipeModule_1.default { } return payload; } - async getDefaultUserInfoPayload(user, accessTokenPayload, scopes, userContext) { + async getDefaultUserInfoPayload(user, accessTokenPayload, scopes, tenantId, userContext) { let payload = { sub: accessTokenPayload.sub, }; @@ -236,7 +236,7 @@ class Recipe extends recipeModule_1.default { for (const fn of this.userInfoBuilders) { payload = Object.assign( Object.assign({}, payload), - await fn(user, accessTokenPayload, scopes, userContext) + await fn(user, accessTokenPayload, scopes, tenantId, userContext) ); } return payload; diff --git a/lib/build/recipe/oauth2/recipeImplementation.js b/lib/build/recipe/oauth2/recipeImplementation.js index 2c82dbb9a..a2158ccb9 100644 --- a/lib/build/recipe/oauth2/recipeImplementation.js +++ b/lib/build/recipe/oauth2/recipeImplementation.js @@ -394,8 +394,8 @@ function getRecipeInterface(querier, _config, _appInfo, getDefaultIdTokenPayload buildIdTokenPayload: async function (input) { return input.defaultPayload; }, - buildUserInfo: async function ({ user, accessTokenPayload, scopes, userContext }) { - return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, userContext); + buildUserInfo: async function ({ user, accessTokenPayload, scopes, tenantId, userContext }) { + return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, tenantId, userContext); }, }; } diff --git a/lib/build/recipe/oauth2/types.d.ts b/lib/build/recipe/oauth2/types.d.ts index 63b690fe5..d8db28595 100644 --- a/lib/build/recipe/oauth2/types.d.ts +++ b/lib/build/recipe/oauth2/types.d.ts @@ -1,7 +1,7 @@ // @ts-nocheck import type { BaseRequest, BaseResponse } from "../../framework"; import OverrideableBuilder from "supertokens-js-override"; -import { GeneralErrorResponse, JSONObject, NonNullableProperties, UserContext } from "../../types"; +import { GeneralErrorResponse, JSONObject, JSONValue, NonNullableProperties, UserContext } from "../../types"; import { SessionContainerInterface } from "../session/types"; import { OAuth2Client } from "./OAuth2Client"; import { User } from "../../user"; @@ -93,7 +93,7 @@ export declare type UserInfo = { email_verified?: boolean; phoneNumber?: string; phoneNumber_verified?: boolean; - [key: string]: any; + [key: string]: JSONValue; }; export declare type RecipeInterface = { authorization(input: { @@ -232,6 +232,7 @@ export declare type RecipeInterface = { user: User; accessTokenPayload: JSONObject; scopes: string[]; + tenantId: string; userContext: UserContext; }): Promise; }; @@ -357,6 +358,7 @@ export declare type APIInterface = { accessTokenPayload: JSONObject; user: User; scopes: string[]; + tenantId: string; options: APIOptions; userContext: UserContext; }) => Promise< @@ -471,5 +473,6 @@ export declare type UserInfoBuilderFunction = ( user: User, accessTokenPayload: JSONObject, scopes: string[], + tenantId: string, userContext: UserContext -) => Promise; +) => Promise; diff --git a/lib/build/recipe/oauth2client/recipeImplementation.js b/lib/build/recipe/oauth2client/recipeImplementation.js index 8343d5d61..012749192 100644 --- a/lib/build/recipe/oauth2client/recipeImplementation.js +++ b/lib/build/recipe/oauth2client/recipeImplementation.js @@ -63,10 +63,9 @@ function getRecipeImplementation(_querier, config) { if (oidcInfo.token_endpoint === undefined) { throw new Error("Failed to token_endpoint from the oidcDiscoveryEndpoint."); } - // TODO: We currently don't have this - // if (oidcInfo.userinfo_endpoint === undefined) { - // throw new Error("Failed to userinfo_endpoint from the oidcDiscoveryEndpoint."); - // } + if (oidcInfo.userinfo_endpoint === undefined) { + throw new Error("Failed to userinfo_endpoint from the oidcDiscoveryEndpoint."); + } if (oidcInfo.jwks_uri === undefined) { throw new Error("Failed to jwks_uri from the oidcDiscoveryEndpoint."); } diff --git a/lib/build/recipe/openid/recipeImplementation.js b/lib/build/recipe/openid/recipeImplementation.js index d3b582d2e..f01e8b272 100644 --- a/lib/build/recipe/openid/recipeImplementation.js +++ b/lib/build/recipe/openid/recipeImplementation.js @@ -24,6 +24,7 @@ function getRecipeInterface(config, jwtRecipeImplementation, appInfo) { jwks_uri, authorization_endpoint: apiBasePath + constants_2.AUTH_PATH, token_endpoint: apiBasePath + constants_2.TOKEN_PATH, + userinfo_endpoint: apiBasePath + constants_2.USER_INFO_PATH, subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], response_types_supported: ["code", "id_token", "id_token token"], diff --git a/lib/build/recipe/userroles/recipe.js b/lib/build/recipe/userroles/recipe.js index 53f5d56b6..4c1a8b531 100644 --- a/lib/build/recipe/userroles/recipe.js +++ b/lib/build/recipe/userroles/recipe.js @@ -27,6 +27,7 @@ const utils_1 = require("./utils"); const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); const postSuperTokensInitCallbacks_1 = require("../../postSuperTokensInitCallbacks"); const recipe_1 = __importDefault(require("../session/recipe")); +const recipe_2 = __importDefault(require("../oauth2/recipe")); const userRoleClaim_1 = require("./userRoleClaim"); const permissionClaim_1 = require("./permissionClaim"); class Recipe extends recipeModule_1.default { @@ -51,6 +52,39 @@ class Recipe extends recipeModule_1.default { if (!this.config.skipAddingPermissionsToAccessToken) { recipe_1.default.getInstanceOrThrowError().addClaimFromOtherRecipe(permissionClaim_1.PermissionClaim); } + recipe_2.default + .getInstanceOrThrowError() + .addUserInfoBuilderFromOtherRecipe(async (user, _accessTokenPayload, scopes, tenantId, userContext) => { + let userInfo = {}; + if (scopes.includes("roles")) { + const res = await this.recipeInterfaceImpl.getRolesForUser({ + userId: user.id, + tenantId, + userContext, + }); + if (res.status !== "OK") { + throw new Error("Failed to fetch roles for the user"); + } + userInfo.roles = res.roles; + if (scopes.includes("permissions")) { + const userPermissions = new Set(); + for (const role of userInfo.roles) { + const rolePermissions = await this.recipeInterfaceImpl.getPermissionsForRole({ + role, + userContext, + }); + if (rolePermissions.status !== "OK") { + throw new Error("Failed to fetch permissions for the role"); + } + for (const perm of rolePermissions.permissions) { + userPermissions.add(perm); + } + } + userInfo.permissons = Array.from(userPermissions); + } + } + return userInfo; + }); }); } /* Init functions */ diff --git a/lib/ts/recipe/oauth2/api/implementation.ts b/lib/ts/recipe/oauth2/api/implementation.ts index ee6d9dcd3..f3f880137 100644 --- a/lib/ts/recipe/oauth2/api/implementation.ts +++ b/lib/ts/recipe/oauth2/api/implementation.ts @@ -188,11 +188,12 @@ export default function getAPIImplementation(): APIInterface { }, }; }, - userInfoGET: async ({ accessTokenPayload, user, scopes, options, userContext }) => { + userInfoGET: async ({ accessTokenPayload, user, scopes, tenantId, options, userContext }) => { const userInfo = await options.recipeImplementation.buildUserInfo({ user, accessTokenPayload, scopes, + tenantId, userContext, }); diff --git a/lib/ts/recipe/oauth2/api/userInfo.ts b/lib/ts/recipe/oauth2/api/userInfo.ts index 82d65d128..b3d601697 100644 --- a/lib/ts/recipe/oauth2/api/userInfo.ts +++ b/lib/ts/recipe/oauth2/api/userInfo.ts @@ -32,6 +32,7 @@ async function validateOAuth2AccessToken(accessToken: string) { export default async function userInfoGET( apiImplementation: APIInterface, + tenantId: string, options: APIOptions, userContext: UserContext ): Promise { @@ -42,7 +43,9 @@ export default async function userInfoGET( const authHeader = options.req.getHeaderValue("authorization") || options.req.getHeaderValue("Authorization"); if (authHeader === undefined || !authHeader.startsWith("Bearer ")) { - sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 401); + // TODO: Returning a 400 instead of a 401 to prevent a potential refresh loop in the client SDK. + // When addressing this TODO, review other response codes in this function as well. + sendNon200ResponseWithMessage(options.res, "Missing or invalid Authorization header", 400); return true; } @@ -53,23 +56,37 @@ export default async function userInfoGET( try { accessTokenPayload = await validateOAuth2AccessToken(accessToken); } catch (error) { - sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token!", 401); + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + sendNon200ResponseWithMessage(options.res, "Invalid or expired OAuth2 access token", 400); return true; } - const userId = accessTokenPayload.sub as string; + if ( + accessTokenPayload === null || + typeof accessTokenPayload !== "object" || + typeof accessTokenPayload.sub !== "string" || + typeof accessTokenPayload.scope !== "string" + ) { + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + sendNon200ResponseWithMessage(options.res, "Malformed access token payload", 400); + return true; + } + + const userId = accessTokenPayload.sub; const user = await getUser(userId, userContext); if (user === undefined) { - sendNon200ResponseWithMessage(options.res, "Couldn't find any user associated with the access token", 401); + options.res.setHeader("WWW-Authenticate", 'Bearer error="invalid_token"', false); + sendNon200ResponseWithMessage(options.res, "Couldn't find any user associated with the access token", 400); return true; } const response = await apiImplementation.userInfoGET({ accessTokenPayload, user, - scopes: ((accessTokenPayload.scope as string) ?? "").split(" "), + tenantId, + scopes: accessTokenPayload.scope.split(" "), options, userContext, }); diff --git a/lib/ts/recipe/oauth2/recipe.ts b/lib/ts/recipe/oauth2/recipe.ts index 076006e57..bb0948421 100644 --- a/lib/ts/recipe/oauth2/recipe.ts +++ b/lib/ts/recipe/oauth2/recipe.ts @@ -188,7 +188,7 @@ export default class Recipe extends RecipeModule { handleAPIRequest = async ( id: string, - _tenantId: string | undefined, + tenantId: string, req: BaseRequest, res: BaseResponse, _path: NormalisedURLPath, @@ -223,7 +223,7 @@ export default class Recipe extends RecipeModule { return loginInfoGET(this.apiImpl, options, userContext); } if (id === USER_INFO_PATH) { - return userInfoGET(this.apiImpl, options, userContext); + return userInfoGET(this.apiImpl, tenantId, options, userContext); } throw new Error("Should never come here: handleAPIRequest called with unknown id"); }; @@ -267,6 +267,7 @@ export default class Recipe extends RecipeModule { user: User, accessTokenPayload: JSONObject, scopes: string[], + tenantId: string, userContext: UserContext ) { let payload: JSONObject = { @@ -286,7 +287,7 @@ export default class Recipe extends RecipeModule { for (const fn of this.userInfoBuilders) { payload = { ...payload, - ...(await fn(user, accessTokenPayload, scopes, userContext)), + ...(await fn(user, accessTokenPayload, scopes, tenantId, userContext)), }; } diff --git a/lib/ts/recipe/oauth2/recipeImplementation.ts b/lib/ts/recipe/oauth2/recipeImplementation.ts index 9a5a40251..db697e68a 100644 --- a/lib/ts/recipe/oauth2/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2/recipeImplementation.ts @@ -425,8 +425,8 @@ export default function getRecipeInterface( buildIdTokenPayload: async function (input) { return input.defaultPayload; }, - buildUserInfo: async function ({ user, accessTokenPayload, scopes, userContext }) { - return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, userContext); + buildUserInfo: async function ({ user, accessTokenPayload, scopes, tenantId, userContext }) { + return getDefaultUserInfoPayload(user, accessTokenPayload, scopes, tenantId, userContext); }, }; } diff --git a/lib/ts/recipe/oauth2/types.ts b/lib/ts/recipe/oauth2/types.ts index ae088e9a2..cf113b5d2 100644 --- a/lib/ts/recipe/oauth2/types.ts +++ b/lib/ts/recipe/oauth2/types.ts @@ -15,7 +15,7 @@ import type { BaseRequest, BaseResponse } from "../../framework"; import OverrideableBuilder from "supertokens-js-override"; -import { GeneralErrorResponse, JSONObject, NonNullableProperties, UserContext } from "../../types"; +import { GeneralErrorResponse, JSONObject, JSONValue, NonNullableProperties, UserContext } from "../../types"; import { SessionContainerInterface } from "../session/types"; import { OAuth2Client } from "./OAuth2Client"; import { User } from "../../user"; @@ -194,7 +194,7 @@ export type UserInfo = { email_verified?: boolean; phoneNumber?: string; phoneNumber_verified?: boolean; - [key: string]: any; + [key: string]: JSONValue; }; export type RecipeInterface = { @@ -362,6 +362,7 @@ export type RecipeInterface = { user: User; accessTokenPayload: JSONObject; scopes: string[]; + tenantId: string; userContext: UserContext; }): Promise; }; @@ -451,6 +452,7 @@ export type APIInterface = { accessTokenPayload: JSONObject; user: User; scopes: string[]; + tenantId: string; options: APIOptions; userContext: UserContext; }) => Promise<{ status: "OK"; info: JSONObject } | GeneralErrorResponse>); @@ -568,5 +570,6 @@ export type UserInfoBuilderFunction = ( user: User, accessTokenPayload: JSONObject, scopes: string[], + tenantId: string, userContext: UserContext -) => Promise; +) => Promise; diff --git a/lib/ts/recipe/oauth2client/recipeImplementation.ts b/lib/ts/recipe/oauth2client/recipeImplementation.ts index ef374b8f6..68748247b 100644 --- a/lib/ts/recipe/oauth2client/recipeImplementation.ts +++ b/lib/ts/recipe/oauth2client/recipeImplementation.ts @@ -100,10 +100,9 @@ export default function getRecipeImplementation(_querier: Querier, config: TypeN if (oidcInfo.token_endpoint === undefined) { throw new Error("Failed to token_endpoint from the oidcDiscoveryEndpoint."); } - // TODO: We currently don't have this - // if (oidcInfo.userinfo_endpoint === undefined) { - // throw new Error("Failed to userinfo_endpoint from the oidcDiscoveryEndpoint."); - // } + if (oidcInfo.userinfo_endpoint === undefined) { + throw new Error("Failed to userinfo_endpoint from the oidcDiscoveryEndpoint."); + } if (oidcInfo.jwks_uri === undefined) { throw new Error("Failed to jwks_uri from the oidcDiscoveryEndpoint."); } diff --git a/lib/ts/recipe/openid/recipeImplementation.ts b/lib/ts/recipe/openid/recipeImplementation.ts index 2ed40f6f5..bbb633768 100644 --- a/lib/ts/recipe/openid/recipeImplementation.ts +++ b/lib/ts/recipe/openid/recipeImplementation.ts @@ -17,7 +17,7 @@ import { RecipeInterface as JWTRecipeInterface, JsonWebKey } from "../jwt/types" import NormalisedURLPath from "../../normalisedURLPath"; import { GET_JWKS_API } from "../jwt/constants"; import { NormalisedAppinfo, UserContext } from "../../types"; -import { AUTH_PATH, TOKEN_PATH } from "../oauth2/constants"; +import { AUTH_PATH, TOKEN_PATH, USER_INFO_PATH } from "../oauth2/constants"; export default function getRecipeInterface( config: TypeNormalisedInput, @@ -38,6 +38,7 @@ export default function getRecipeInterface( jwks_uri, authorization_endpoint: apiBasePath + AUTH_PATH, token_endpoint: apiBasePath + TOKEN_PATH, + userinfo_endpoint: apiBasePath + USER_INFO_PATH, subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], response_types_supported: ["code", "id_token", "id_token token"], diff --git a/lib/ts/recipe/userroles/recipe.ts b/lib/ts/recipe/userroles/recipe.ts index 0f0176c7b..18aa7b8ba 100644 --- a/lib/ts/recipe/userroles/recipe.ts +++ b/lib/ts/recipe/userroles/recipe.ts @@ -27,6 +27,7 @@ import { validateAndNormaliseUserInput } from "./utils"; import OverrideableBuilder from "supertokens-js-override"; import { PostSuperTokensInitCallbacks } from "../../postSuperTokensInitCallbacks"; import SessionRecipe from "../session/recipe"; +import OAuth2Recipe from "../oauth2/recipe"; import { UserRoleClaim } from "./userRoleClaim"; import { PermissionClaim } from "./permissionClaim"; @@ -55,6 +56,51 @@ export default class Recipe extends RecipeModule { if (!this.config.skipAddingPermissionsToAccessToken) { SessionRecipe.getInstanceOrThrowError().addClaimFromOtherRecipe(PermissionClaim); } + + OAuth2Recipe.getInstanceOrThrowError().addUserInfoBuilderFromOtherRecipe( + async (user, _accessTokenPayload, scopes, tenantId, userContext) => { + let userInfo: { + roles?: string[]; + permissons?: string[]; + } = {}; + + if (scopes.includes("roles")) { + const res = await this.recipeInterfaceImpl.getRolesForUser({ + userId: user.id, + tenantId, + userContext, + }); + + if (res.status !== "OK") { + throw new Error("Failed to fetch roles for the user"); + } + + userInfo.roles = res.roles; + + if (scopes.includes("permissions")) { + const userPermissions = new Set(); + for (const role of userInfo.roles) { + const rolePermissions = await this.recipeInterfaceImpl.getPermissionsForRole({ + role, + userContext, + }); + + if (rolePermissions.status !== "OK") { + throw new Error("Failed to fetch permissions for the role"); + } + + for (const perm of rolePermissions.permissions) { + userPermissions.add(perm); + } + } + + userInfo.permissons = Array.from(userPermissions); + } + } + + return userInfo; + } + ); }); } From b0f62cb6cc0daa8117d51862d943a2ad8c8843d4 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Fri, 26 Jul 2024 11:35:05 +0530 Subject: [PATCH 15/16] fix: PR changes --- lib/build/recipe/oauth2client/index.js | 15 +++++++++------ lib/ts/recipe/oauth2client/index.ts | 15 +++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/build/recipe/oauth2client/index.js b/lib/build/recipe/oauth2client/index.js index e9481bbfa..cdf968aaf 100644 --- a/lib/build/recipe/oauth2client/index.js +++ b/lib/build/recipe/oauth2client/index.js @@ -25,35 +25,38 @@ const recipe_1 = __importDefault(require("./recipe")); class Wrapper { static async getAuthorisationRedirectURL(redirectURIOnProviderDashboard, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = utils_1.getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipeInterfaceImpl.getAuthorisationRedirectURL({ providerConfig, redirectURIOnProviderDashboard, - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); } static async exchangeAuthCodeForOAuthTokens(redirectURIInfo, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = utils_1.getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ providerConfig, redirectURIInfo, - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); } static async getUserInfo(oAuthTokens, userContext) { const recipeInterfaceImpl = recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = utils_1.getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ providerConfig, oAuthTokens, - userContext: utils_1.getUserContext(userContext), + userContext: normalisedUserContext, }); } } diff --git a/lib/ts/recipe/oauth2client/index.ts b/lib/ts/recipe/oauth2client/index.ts index 0ae790b4d..0ae16a19d 100644 --- a/lib/ts/recipe/oauth2client/index.ts +++ b/lib/ts/recipe/oauth2client/index.ts @@ -25,13 +25,14 @@ export default class Wrapper { userContext?: Record ) { const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipeInterfaceImpl.getAuthorisationRedirectURL({ providerConfig, redirectURIOnProviderDashboard, - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); } @@ -44,25 +45,27 @@ export default class Wrapper { userContext?: Record ) { const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); return await recipeInterfaceImpl.exchangeAuthCodeForOAuthTokens({ providerConfig, redirectURIInfo, - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); } static async getUserInfo(oAuthTokens: OAuthTokens, userContext?: Record) { const recipeInterfaceImpl = Recipe.getInstanceOrThrowError().recipeInterfaceImpl; + const normalisedUserContext = getUserContext(userContext); const providerConfig = await recipeInterfaceImpl.getProviderConfig({ - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.getUserInfo({ providerConfig, oAuthTokens, - userContext: getUserContext(userContext), + userContext: normalisedUserContext, }); } } From 09035a6c029dda58fee841b127972a9db6a2181a Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Fri, 26 Jul 2024 15:55:02 +0530 Subject: [PATCH 16/16] fix: PR changes --- lib/build/recipe/userroles/recipe.js | 36 +++++++++++++------------ lib/ts/recipe/userroles/recipe.ts | 39 ++++++++++++++++------------ 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/lib/build/recipe/userroles/recipe.js b/lib/build/recipe/userroles/recipe.js index 4c1a8b531..0587a295f 100644 --- a/lib/build/recipe/userroles/recipe.js +++ b/lib/build/recipe/userroles/recipe.js @@ -56,7 +56,8 @@ class Recipe extends recipeModule_1.default { .getInstanceOrThrowError() .addUserInfoBuilderFromOtherRecipe(async (user, _accessTokenPayload, scopes, tenantId, userContext) => { let userInfo = {}; - if (scopes.includes("roles")) { + let userRoles = []; + if (scopes.includes("roles") || scopes.includes("permissions")) { const res = await this.recipeInterfaceImpl.getRolesForUser({ userId: user.id, tenantId, @@ -65,23 +66,26 @@ class Recipe extends recipeModule_1.default { if (res.status !== "OK") { throw new Error("Failed to fetch roles for the user"); } - userInfo.roles = res.roles; - if (scopes.includes("permissions")) { - const userPermissions = new Set(); - for (const role of userInfo.roles) { - const rolePermissions = await this.recipeInterfaceImpl.getPermissionsForRole({ - role, - userContext, - }); - if (rolePermissions.status !== "OK") { - throw new Error("Failed to fetch permissions for the role"); - } - for (const perm of rolePermissions.permissions) { - userPermissions.add(perm); - } + userRoles = res.roles; + } + if (scopes.includes("roles")) { + userInfo.roles = userRoles; + } + if (scopes.includes("permissions")) { + const userPermissions = new Set(); + for (const role of userRoles) { + const rolePermissions = await this.recipeInterfaceImpl.getPermissionsForRole({ + role, + userContext, + }); + if (rolePermissions.status !== "OK") { + throw new Error("Failed to fetch permissions for the role"); + } + for (const perm of rolePermissions.permissions) { + userPermissions.add(perm); } - userInfo.permissons = Array.from(userPermissions); } + userInfo.permissons = Array.from(userPermissions); } return userInfo; }); diff --git a/lib/ts/recipe/userroles/recipe.ts b/lib/ts/recipe/userroles/recipe.ts index 18aa7b8ba..9a2c4ecff 100644 --- a/lib/ts/recipe/userroles/recipe.ts +++ b/lib/ts/recipe/userroles/recipe.ts @@ -64,7 +64,9 @@ export default class Recipe extends RecipeModule { permissons?: string[]; } = {}; - if (scopes.includes("roles")) { + let userRoles: string[] = []; + + if (scopes.includes("roles") || scopes.includes("permissions")) { const res = await this.recipeInterfaceImpl.getRolesForUser({ userId: user.id, tenantId, @@ -74,28 +76,31 @@ export default class Recipe extends RecipeModule { if (res.status !== "OK") { throw new Error("Failed to fetch roles for the user"); } + userRoles = res.roles; + } - userInfo.roles = res.roles; - - if (scopes.includes("permissions")) { - const userPermissions = new Set(); - for (const role of userInfo.roles) { - const rolePermissions = await this.recipeInterfaceImpl.getPermissionsForRole({ - role, - userContext, - }); + if (scopes.includes("roles")) { + userInfo.roles = userRoles; + } - if (rolePermissions.status !== "OK") { - throw new Error("Failed to fetch permissions for the role"); - } + if (scopes.includes("permissions")) { + const userPermissions = new Set(); + for (const role of userRoles) { + const rolePermissions = await this.recipeInterfaceImpl.getPermissionsForRole({ + role, + userContext, + }); - for (const perm of rolePermissions.permissions) { - userPermissions.add(perm); - } + if (rolePermissions.status !== "OK") { + throw new Error("Failed to fetch permissions for the role"); } - userInfo.permissons = Array.from(userPermissions); + for (const perm of rolePermissions.permissions) { + userPermissions.add(perm); + } } + + userInfo.permissons = Array.from(userPermissions); } return userInfo;