diff --git a/twake/backend/node/config/custom-environment-variables.json b/twake/backend/node/config/custom-environment-variables.json index 244d31c8..975ef1c8 100644 --- a/twake/backend/node/config/custom-environment-variables.json +++ b/twake/backend/node/config/custom-environment-variables.json @@ -1,4 +1,15 @@ { + "general": { + "accounts": { + "type": "ACCOUNTS_TYPE", + "remote": { + "authority": "SSO_AUTHORITY", + "client_id": "SSO_CLIENT_ID", + "issuer": "SSO_ISSUER", + "jwks_uri": "SSO_JWKS_URI" + } + } + }, "sentry": { "dsn": "SENTRY_DSN" }, diff --git a/twake/backend/node/package.json b/twake/backend/node/package.json index 19ee7ab8..ec5e3b36 100644 --- a/twake/backend/node/package.json +++ b/twake/backend/node/package.json @@ -120,7 +120,6 @@ "@fastify/formbody": "^6.0.0", "@fastify/static": "^5.0.1", "@ffprobe-installer/ffprobe": "^1.4.1", - "@okta/jwt-verifier": "^3.0.1", "@sentry/node": "^6.19.7", "@sentry/tracing": "^6.19.7", "@socket.io/redis-adapter": "^7.1.0", @@ -163,6 +162,7 @@ "html-to-text": "^8.2.1", "idtoken-verifier": "^2.2.3", "jsonwebtoken": "^8.5.1", + "jwks-rsa": "^3.0.1", "keyv": "^4.5.0", "lodash": "^4.17.21", "match-all": "^1.2.6", @@ -170,6 +170,7 @@ "moment": "^2.29.4", "mongodb": "^4.1.0", "multistream": "^4.1.0", + "njwt": "^2.0.0", "node-cache": "^5.1.2", "node-cron": "^3.0.0", "node-fetch": "^2.6.7", diff --git a/twake/backend/node/src/services/console/client-factory.ts b/twake/backend/node/src/services/console/client-factory.ts index 839a4d82..68b513e0 100644 --- a/twake/backend/node/src/services/console/client-factory.ts +++ b/twake/backend/node/src/services/console/client-factory.ts @@ -9,7 +9,7 @@ class StaticConsoleClientFactory { const type: ConsoleType = consoleInstance.consoleType; switch (type) { case "remote": - return new ConsoleRemoteClient(consoleInstance, false); + return new ConsoleRemoteClient(consoleInstance); case "internal": return new ConsoleInternalClient(consoleInstance); default: diff --git a/twake/backend/node/src/services/console/client-interface.ts b/twake/backend/node/src/services/console/client-interface.ts index 2663d065..af2d4eae 100644 --- a/twake/backend/node/src/services/console/client-interface.ts +++ b/twake/backend/node/src/services/console/client-interface.ts @@ -50,7 +50,7 @@ export interface ConsoleServiceClient { updateLocalCompanyFromConsole(companyDTO: ConsoleHookCompany): Promise; - updateLocalUserFromConsole(code: string): Promise; + updateLocalUserFromConsole(user: ConsoleHookUser): Promise; removeCompanyUser(consoleUserId: string, company: Company): Promise; diff --git a/twake/backend/node/src/services/console/clients/internal.ts b/twake/backend/node/src/services/console/clients/internal.ts index 13caf3b9..ade2763d 100644 --- a/twake/backend/node/src/services/console/clients/internal.ts +++ b/twake/backend/node/src/services/console/clients/internal.ts @@ -70,7 +70,7 @@ export class ConsoleInternalClient implements ConsoleServiceClient { throw new Error("Method should not be implemented."); } - updateLocalUserFromConsole(_code: string): Promise { + updateLocalUserFromConsole(_user: ConsoleHookUser): Promise { logger.info("Internal: updateLocalUserFromConsole"); throw new Error("Method should not be implemented."); } diff --git a/twake/backend/node/src/services/console/clients/remote-jwks-verifier.ts b/twake/backend/node/src/services/console/clients/remote-jwks-verifier.ts new file mode 100644 index 00000000..8b4e00a6 --- /dev/null +++ b/twake/backend/node/src/services/console/clients/remote-jwks-verifier.ts @@ -0,0 +1,97 @@ +import JwksClient from "jwks-rsa"; +import nJwt from "njwt"; + +function verifyAudience(expected: string, aud: string | Array) { + if (!expected) { + throw new Error("expected audience is required"); + } + + if (Array.isArray(aud) && !aud.includes(expected)) { + throw new Error( + `audience claim ${expected} does not match one of the expected audiences: ${aud.join(", ")}`, + ); + } + + if (!Array.isArray(aud) && aud !== expected) { + throw new Error(`audience claim ${aud} does not match expected audience: ${expected}`); + } +} + +function verifyIssuer(expected: string, issuer: string) { + if (issuer !== expected) { + throw new Error(`issuer ${issuer} does not match expected issuer: ${expected}`); + } +} + +export class OidcJwtVerifier { + public claimsToAssert: any; + private issuer: string; + private jwksUri: string; + private jwksClient: any; + private verifier: any; + + constructor(options: { [key: string]: any } = {}) { + // https://github.com/auth0/node-jwks-rsa/blob/master/CHANGELOG.md#request-agent-options + if (options.requestAgentOptions) { + // jwks-rsa no longer accepts 'requestAgentOptions' and instead requires a http(s).Agent be passed directly + const msg = `\`requestAgentOptions\` has been deprecated, use \`requestAgent\` instead. + For more info see https://github.com/auth0/node-jwks-rsa/blob/master/CHANGELOG.md#request-agent-options`; + throw new Error(msg); + } + + this.claimsToAssert = options.assertClaims || {}; + this.issuer = options.issuer; + this.jwksUri = options.jwksUri; + this.jwksClient = JwksClient({ + jwksUri: this.jwksUri, + cache: true, + cacheMaxAge: options.cacheMaxAge || 60 * 60 * 1000, + cacheMaxEntries: 3, + jwksRequestsPerMinute: options.jwksRequestsPerMinute || 10, + rateLimit: true, + requestAgent: options.requestAgent, + }); + this.verifier = nJwt + .createVerifier() + .setSigningAlgorithm("RS256") + .withKeyResolver((kid: string, cb: any) => { + if (kid) { + this.jwksClient.getSigningKey(kid, (err: any, key: any) => { + cb(err, key && (key.publicKey || key.rsaPublicKey)); + }); + } else { + cb("No KID specified", null); + } + return null; + }); + } + + async verifyAsPromise(tokenString: string): Promise { + return new Promise((resolve, reject) => { + // Convert to a promise + this.verifier.verify(tokenString, (err: any, jwt: any) => { + if (err) { + return reject(err); + } + + const jwtBodyProxy = new Proxy(jwt.body, {}); + Object.defineProperty(jwt, "claims", { + enumerable: true, + writable: false, + value: jwtBodyProxy, + }); + + delete jwt.body; + resolve(jwt); + }); + }); + } + + async verifyIdToken(idTokenString: string, expectedClientId: string) { + const jwt = await this.verifyAsPromise(idTokenString); + verifyAudience(expectedClientId, jwt.claims.aud); + verifyIssuer(this.issuer, jwt.claims.iss); + + return jwt; + } +} diff --git a/twake/backend/node/src/services/console/clients/remote.ts b/twake/backend/node/src/services/console/clients/remote.ts index 03749b67..78db34b1 100644 --- a/twake/backend/node/src/services/console/clients/remote.ts +++ b/twake/backend/node/src/services/console/clients/remote.ts @@ -1,5 +1,4 @@ import { AxiosInstance } from "axios"; -import { Issuer } from "openid-client"; import { ConsoleServiceClient } from "../client-interface"; import { ConsoleCompany, @@ -14,25 +13,33 @@ import { UpdatedConsoleUserRole, } from "../types"; -import OktaJwtVerifier from "@okta/jwt-verifier"; +import { OidcJwtVerifier } from "./remote-jwks-verifier"; import { CrudException } from "../../../core/platform/framework/api/crud-service"; import { logger } from "../../../core/platform/framework/logger"; import gr from "../../global-resolver"; import Company, { CompanySearchKey } from "../../user/entities/company"; -import User from "../../user/entities/user"; +import User, { getInstance } from "../../user/entities/user"; +import { getInstance as getCompanyInstance } from "../../user/entities/company"; import { ConsoleServiceImpl } from "../service"; +import coalesce from "../../../utils/coalesce"; export class ConsoleRemoteClient implements ConsoleServiceClient { version: "1"; client: AxiosInstance; private infos: ConsoleOptions; - private verifier: OktaJwtVerifier; + private verifier: OidcJwtVerifier; - constructor(consoleInstance: ConsoleServiceImpl, private dryRun: boolean) { + constructor(consoleInstance: ConsoleServiceImpl) { this.infos = consoleInstance.consoleOptions; - this.verifier = new OktaJwtVerifier({ - issuer: this.infos.issuer, + this.verifier = new OidcJwtVerifier({ + clientId: this.infos.client_id, + issuer: this.infos.issuer?.replace(/\/+$/, ""), + jwksUri: this.infos.jwks_uri, + // For local deployment create a https agent that ignore self signed certificate + requestAgent: new (require("https").Agent)({ + rejectUnauthorized: this.infos.issuer.includes("example.com") ? false : true, + }), }); } fetchCompanyInfo(consoleCompanyCode: string): Promise { @@ -77,8 +84,86 @@ export class ConsoleRemoteClient implements ConsoleServiceClient { return null; } - async updateLocalUserFromConsole(code: string): Promise { - return null; + async updateLocalUserFromConsole(userDTO: ConsoleHookUser): Promise { + logger.info("Remote: updateLocalUserFromConsole"); + + if (!userDTO) { + throw CrudException.badRequest("User not found on Console"); + } + + const roles = userDTO.roles.filter( + role => role.applications === undefined || role.applications.find(a => a.code === "twake"), + ); + + let user = await gr.services.users.getByConsoleId(userDTO.email); + + if (!user) { + if (!userDTO.email) { + throw CrudException.badRequest("Email is required"); + } + + let username = userDTO.email + .split("@")[0] + .toLocaleLowerCase() + .replace(/[^a-zA-Z0-9]/g, "") + .replace(/ +/g, "_"); + + if (await gr.services.users.isEmailAlreadyInUse(userDTO.email)) { + throw CrudException.badRequest("Console user not created because email already exists"); + } + + username = await gr.services.users.getAvailableUsername(username); + if (!username) { + throw CrudException.badRequest("Console user not created because username already exists"); + } + + user = getInstance({}); + user.username_canonical = (username || "").toLocaleLowerCase(); + user.email_canonical = userDTO.email; + user.deleted = false; + } + + user.email_canonical = coalesce(userDTO.email, user.email_canonical); + user.phone = ""; + user.first_name = coalesce(userDTO.name, user.first_name); + user.last_name = coalesce(userDTO.surname, user.last_name); + user.identity_provider = "console"; + user.identity_provider_id = userDTO.email; + user.mail_verified = coalesce(userDTO.isVerified, user.mail_verified); + if (userDTO.preference) { + user.preferences = user.preferences || {}; + user.preferences.allow_tracking = coalesce( + userDTO.preference.allowTrackingPersonalInfo, + user.preferences?.allow_tracking, + ); + user.preferences.language = coalesce(userDTO.preference.locale, user.preferences?.language); + user.preferences.timezone = coalesce(userDTO.preference.timeZone, user.preferences?.timezone); + } + + user.picture = userDTO.avatar.value; + + await gr.services.users.save(user); + + //For now TDrive works with only one company as we don't get it from the SSO + + let company = await gr.services.companies.getCompany({ + id: "00000000-0000-4000-0000-000000000000", + }); + if (!company) { + company = await gr.services.companies.createCompany( + getCompanyInstance({ + id: "00000000-0000-4000-0000-000000000000", + name: "Twake", + plan: { name: "Local", limits: undefined, features: undefined }, + }), + ); + } + + await gr.services.companies.setUserRole(company.id, user.id, "admin"); + + await gr.services.users.save(user, { user: { id: user.id, server_request: true } }); + + return user; } async removeCompanyUser(consoleUserId: string, company: Company): Promise { @@ -114,10 +199,17 @@ export class ConsoleRemoteClient implements ConsoleServiceClient { } async getUserByAccessToken(idToken: string): Promise { - const user = (await this.verifier.verifyIdToken(idToken, this.infos.audience)).claims as any; - console.log("user", user); + const user = (await this.verifier.verifyIdToken(idToken, this.infos.client_id))?.claims as { + sub: string; + email: string; + family_name: string; + given_name: string; + name: string; + locale?: string; + picture?: string; + }; return { - _id: user.sub, + _id: user.email || user.sub, roles: [] as any, email: user.email, name: user.given_name, diff --git a/twake/backend/node/src/services/console/service.ts b/twake/backend/node/src/services/console/service.ts index 0fa19e5d..92756d77 100644 --- a/twake/backend/node/src/services/console/service.ts +++ b/twake/backend/node/src/services/console/service.ts @@ -37,6 +37,7 @@ export class ConsoleServiceImpl implements TwakeServiceProvider { client_secret: s.client_secret, audience: s.audience, issuer: s.issuer, + jwks_uri: s.jwks_uri, redirect_uris: s.redirect_uris, disable_account_creation: s.disable_account_creation, }; diff --git a/twake/backend/node/src/services/console/types.ts b/twake/backend/node/src/services/console/types.ts index 3b0dcf4a..59ff0bd6 100644 --- a/twake/backend/node/src/services/console/types.ts +++ b/twake/backend/node/src/services/console/types.ts @@ -130,6 +130,7 @@ export type ConsoleOptions = { client_id: string; client_secret: string; issuer: string; + jwks_uri: string; audience: string; redirect_uris: string[]; disable_account_creation: boolean; diff --git a/twake/backend/node/src/services/console/web/controller.ts b/twake/backend/node/src/services/console/web/controller.ts index 524baa44..2fdbc35b 100644 --- a/twake/backend/node/src/services/console/web/controller.ts +++ b/twake/backend/node/src/services/console/web/controller.ts @@ -222,7 +222,7 @@ export class ConsoleController { private async userAdded(content: ConsoleHookBodyContent): Promise { const userDTO = content.user; - const user = await gr.services.console.getClient().updateLocalUserFromConsole(userDTO._id); + const user = await gr.services.console.getClient().updateLocalUserFromConsole(userDTO); await this.updateCompany(content.company); await gr.services.console.processPendingUser(user); } @@ -237,8 +237,8 @@ export class ConsoleController { } private async userUpdated(code: string) { - const user = await gr.services.console.getClient().updateLocalUserFromConsole(code); - await gr.services.console.processPendingUser(user); + //Not implemented yet + throw CrudException.notImplemented("Unimplemented"); } private async companyRemoved(content: ConsoleHookCompanyDeletedContent) { @@ -286,7 +286,7 @@ export class ConsoleController { private async authByToken(idToken: string): Promise { const client = gr.services.console.getClient(); const userDTO = await client.getUserByAccessToken(idToken); - const user = await client.updateLocalUserFromConsole(userDTO._id); + const user = await client.updateLocalUserFromConsole(userDTO); if (!user) { throw CrudException.notFound(`User details not found for access token ${idToken}`); } diff --git a/twake/backend/node/src/services/workspaces/web/controllers/workspace-invite-tokens.ts b/twake/backend/node/src/services/workspaces/web/controllers/workspace-invite-tokens.ts index e37da73b..46201568 100644 --- a/twake/backend/node/src/services/workspaces/web/controllers/workspace-invite-tokens.ts +++ b/twake/backend/node/src/services/workspaces/web/controllers/workspace-invite-tokens.ts @@ -169,14 +169,6 @@ export class WorkspaceInviteTokensCrudController inviterEmail: inviter.email_canonical, }, ); - - await gr.services.console - .getClient() - .updateLocalUserFromConsole(user.identity_provider_id); - companyUser = await gr.services.companies.getCompanyUser( - { id: company_id }, - { id: userId }, - ); } if (!companyUser) { throw CrudException.badRequest("Unable to add user to the company"); diff --git a/twake/backend/node/yarn.lock b/twake/backend/node/yarn.lock index 2d23c5f4..bc3a4165 100644 --- a/twake/backend/node/yarn.lock +++ b/twake/backend/node/yarn.lock @@ -1456,14 +1456,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@okta/jwt-verifier@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@okta/jwt-verifier/-/jwt-verifier-3.0.1.tgz#10facad0424052e0cfd6477304cdc51d84ac60be" - integrity sha512-8rNvCxAcgGlSJH9NilhYTrbZjyTPPF0ThJUDF/NI8XV/4NcqOFdO946meFsXpV1oFdZP6ASH/j2GjVhU4cHpPQ== - dependencies: - jwks-rsa "^3.0.0" - njwt "^2.0.0" - "@redis/bloom@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.0.2.tgz#42b82ec399a92db05e29fffcdfd9235a5fc15cdf" @@ -6760,7 +6752,7 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" -jwks-rsa@^3.0.0: +jwks-rsa@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/jwks-rsa/-/jwks-rsa-3.0.1.tgz#ba79ddca7ee7520f7bb26b942ef1aee91df8d7e4" integrity sha512-UUOZ0CVReK1QVU3rbi9bC7N5/le8ziUj0A2ef1Q0M7OPD2KvjEYizptqIxGIo6fSLYDkqBrazILS18tYuRc8gw== diff --git a/twake/frontend/src/app/features/auth/provider/oidc/oidc-auth-provider-service.ts b/twake/frontend/src/app/features/auth/provider/oidc/oidc-auth-provider-service.ts index 41800092..9709f000 100644 --- a/twake/frontend/src/app/features/auth/provider/oidc/oidc-auth-provider-service.ts +++ b/twake/frontend/src/app/features/auth/provider/oidc/oidc-auth-provider-service.ts @@ -44,15 +44,13 @@ export default class OIDCAuthProviderService Oidc.Log.logger = Logger.getLogger('OIDCClient'); Oidc.Log.level = EnvironmentService.isProduction() ? Oidc.Log.WARN : Oidc.Log.DEBUG; - const audience = ['https://dev-mxb6x0f2.eu.auth0.com/api/v2/']; - this.userManager = new Oidc.UserManager({ userStore: new Oidc.WebStorageStateStore({ store: window.localStorage }), authority: this.configuration?.authority || environment.api_root_url, client_id: this.configuration?.client_id, redirect_uri: getAsFrontUrl(OIDC_CALLBACK_URL), response_type: 'code', - scope: 'openid profile email address phone offline_access ' + audience.join(' '), + scope: 'openid profile email address phone offline_access', post_logout_redirect_uri: getAsFrontUrl(OIDC_SIGNOUT_URL), //silent_redirect_uri: getAsFrontUrl(OIDC_SILENT_URL), automaticSilentRenew: true, diff --git a/twake/frontend/src/app/features/console/services/console-service.ts b/twake/frontend/src/app/features/console/services/console-service.ts index 53c65644..593f86ce 100644 --- a/twake/frontend/src/app/features/console/services/console-service.ts +++ b/twake/frontend/src/app/features/console/services/console-service.ts @@ -117,7 +117,7 @@ class ConsoleService { ); Api.post( '/internal/services/console/v1/login', - { oidc_id_token: currentToken.id_token }, + { oidc_id_token: currentToken.access_token }, (response: { access_token: JWTDataType; message: string;