Drive working with SSO

This commit is contained in:
Romaric Mourgues
2023-03-28 19:13:18 +02:00
parent 1b2a3cc4d9
commit 41b86c452c
14 changed files with 226 additions and 41 deletions
@@ -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"
},
+2 -1
View File
@@ -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",
@@ -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:
@@ -50,7 +50,7 @@ export interface ConsoleServiceClient {
updateLocalCompanyFromConsole(companyDTO: ConsoleHookCompany): Promise<Company>;
updateLocalUserFromConsole(code: string): Promise<User>;
updateLocalUserFromConsole(user: ConsoleHookUser): Promise<User>;
removeCompanyUser(consoleUserId: string, company: Company): Promise<void>;
@@ -70,7 +70,7 @@ export class ConsoleInternalClient implements ConsoleServiceClient {
throw new Error("Method should not be implemented.");
}
updateLocalUserFromConsole(_code: string): Promise<User> {
updateLocalUserFromConsole(_user: ConsoleHookUser): Promise<User> {
logger.info("Internal: updateLocalUserFromConsole");
throw new Error("Method should not be implemented.");
}
@@ -0,0 +1,97 @@
import JwksClient from "jwks-rsa";
import nJwt from "njwt";
function verifyAudience(expected: string, aud: string | Array<string>) {
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<any> {
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;
}
}
@@ -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<ConsoleHookCompany> {
@@ -77,8 +84,86 @@ export class ConsoleRemoteClient implements ConsoleServiceClient {
return null;
}
async updateLocalUserFromConsole(code: string): Promise<User> {
return null;
async updateLocalUserFromConsole(userDTO: ConsoleHookUser): Promise<User> {
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<void> {
@@ -114,10 +199,17 @@ export class ConsoleRemoteClient implements ConsoleServiceClient {
}
async getUserByAccessToken(idToken: string): Promise<ConsoleHookUser> {
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,
@@ -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,
};
@@ -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;
@@ -222,7 +222,7 @@ export class ConsoleController {
private async userAdded(content: ConsoleHookBodyContent): Promise<void> {
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<AccessToken> {
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}`);
}
@@ -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");
+1 -9
View File
@@ -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==
@@ -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,
@@ -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;