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
@@ -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");