diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index de169f41d4a8..800032c690fc 100755 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5294,5 +5294,17 @@ }, "generatedPassword": { "message": "Generated password" + }, + "useYourCompanyServer": { + "message": "Use your company server" + }, + "useUrl": { + "message": "Use my Twake URL" + }, + "companyEmail": { + "message": "Company email address" + }, + "companyServerError": { + "message": "Company server not found" } } diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index ce5b48a1f290..7f79ef53621a 100755 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -5293,5 +5293,17 @@ }, "generatedPassword": { "message": "Mot de passe généré" + }, + "useYourCompanyServer": { + "message": "Utiliser le serveur de votre entreprise" + }, + "useUrl": { + "message": "Utiliser l'URL de mon Twake" + }, + "companyEmail": { + "message": "Adresse e-mail de votre entreprise" + }, + "companyServerError": { + "message": "Serveur d'entreprise introuvable" } } diff --git a/apps/browser/src/auth/popup/home.component.html b/apps/browser/src/auth/popup/home.component.html index b38f51e93d01..7c4665503ff2 100644 --- a/apps/browser/src/auth/popup/home.component.html +++ b/apps/browser/src/auth/popup/home.component.html @@ -1,17 +1,23 @@
-
+

{{ "loginOrCreateNewAccount" | i18n }}

-
+ +
+ + +
+
+
- -
-
-
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
diff --git a/apps/browser/src/auth/popup/home.component.ts b/apps/browser/src/auth/popup/home.component.ts index 84878cff8980..211d4c3d068c 100644 --- a/apps/browser/src/auth/popup/home.component.ts +++ b/apps/browser/src/auth/popup/home.component.ts @@ -14,9 +14,14 @@ import { ToastService } from "@bitwarden/components"; import { BrowserApi } from "../../platform/browser/browser-api"; import { CozySanitizeUrlService } from "../../popup/services/cozySanitizeUrl.service"; import { AccountSwitcherService } from "./account-switching/services/account-switcher.service"; +import { getLoginSuccessPageUri, extractDomain } from "../../../src/cozy/sso/helpers"; /* eslint-enable */ /* end Cozy imports */ +const DEV_STACK_OAUTHCALLBACK_URI = "https://oauthcallback.cozy.wtf"; +const INT_STACK_OAUTHCALLBACK_URI = "https://oauthcallback.cozy.works"; +const PROD_STACK_OAUTHCALLBACK_URI = "https://oauthcallback.mycozy.cloud"; + @Component({ selector: "app-home", templateUrl: "home.component.html", @@ -27,15 +32,22 @@ export class HomeComponent implements OnInit, OnDestroy { private destroyed$: Subject = new Subject(); loginInitiated = false; + loginMode = "DEFAULT"; formGroup = this.formBuilder.group({ /** Cozy custo email: ["", [Validators.required, Validators.email]], */ email: [""], + companyEmail: [""], /** end custo */ rememberEmail: [false], }); + // Cozy customization; to change stack URI + logoClickCount = 0; + baseUri = PROD_STACK_OAUTHCALLBACK_URI; + // Cozy customization + // TODO: remove when email verification flag is removed registerRoute$ = this.registerRouteService.registerRoute$(); @@ -65,6 +77,8 @@ export class HomeComponent implements OnInit, OnDestroy { } } + this.redirectIfSSOLoginSuccessTab(); + // Cozy customization /* this.environmentSelector.onOpenSelfHostedSettings @@ -148,6 +162,29 @@ export class HomeComponent implements OnInit, OnDestroy { } /* end custo */ + /* Cozy custo */ + openTwakeLogin() { + const extensionUri = this.platformUtilsService.getExtensionUri(); + const redirectUri = getLoginSuccessPageUri(extensionUri); + + BrowserApi.createNewTab(`${this.baseUri}/oidc/bitwarden/twake?redirect_uri=${redirectUri}`); + } + /* end custo */ + + /* Cozy custo */ + setDefaultMode() { + this.loginMode = "DEFAULT"; + } + + setCompanyMode() { + this.loginMode = "COMPANY"; + } + + setUrlMode() { + this.loginMode = "URL"; + } + /* end custo */ + // Cozy customization; check if Cozy exists before navigating to login page async cozyExist(cozyUrl: string) { const preloginCozyUrl = new URL("/public/prelogin", cozyUrl).toString(); @@ -163,4 +200,124 @@ export class HomeComponent implements OnInit, OnDestroy { } } // Cozy customization end + + async getLoginUri(companyEmail: string): Promise { + try { + const domain = extractDomain(companyEmail); + + if (!domain) { + throw new Error(); + } + + const extensionUri = this.platformUtilsService.getExtensionUri(); + const redirectUri = getLoginSuccessPageUri(extensionUri); + + const uriFromWellKnown = await this.fetchLoginUriWithWellKnown(domain); + + if (uriFromWellKnown) { + uriFromWellKnown.searchParams.append("redirect_uri", redirectUri); + return uriFromWellKnown; + } + + return null; + } catch { + return null; + } + } + + private async fetchLoginUriWithWellKnown(domain: string): Promise { + const url = `https://${domain}/.well-known/twake-configuration`; + + try { + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + if (response.ok) { + const twakeConfiguration = await response.json(); + + return new URL(twakeConfiguration["twake-pass-login-uri"]) || null; + } else { + return null; + } + } catch { + return null; + } + } + + async openCompanyLogin() { + const companyEmail = this.formGroup.value.companyEmail; + + if (!companyEmail) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccured"), + message: this.i18nService.t("emailRequired"), + }); + return; + } + + const loginUri = await this.getLoginUri(companyEmail); + + if (loginUri) { + BrowserApi.createNewTab(loginUri.toString()); + } else { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccured"), + message: this.i18nService.t("companyServerError"), + }); + } + } + + // Cozy customization + async redirectIfSSOLoginSuccessTab() { + chrome.tabs.query({}, (tabs) => { + const extensionUri = this.platformUtilsService.getExtensionUri(); + const redirectUri = getLoginSuccessPageUri(extensionUri); + + const SSOLoginSuccessTab = tabs.find( + (tab) => tab.status === "complete" && tab.url.startsWith(redirectUri), + ); + + if (SSOLoginSuccessTab) { + const url = new URL(SSOLoginSuccessTab.url); + const instance = url.searchParams.get("instance"); + const code = url.searchParams.get("code"); + + this.router.navigate(["login"], { + queryParams: { email: instance, cozyUrl: instance, code }, + }); + } + }); + } + // Cozy customization end + + // Cozy customization + async logoClicked() { + this.logoClickCount++; + + if (this.logoClickCount >= 6) { + const rest = this.logoClickCount % 3; + + if (rest === 0) { + this.baseUri = DEV_STACK_OAUTHCALLBACK_URI; + } else if (rest === 1) { + this.baseUri = INT_STACK_OAUTHCALLBACK_URI; + } else if (rest === 2) { + this.baseUri = PROD_STACK_OAUTHCALLBACK_URI; + } + + this.toastService.showToast({ + variant: "info", + title: "New base URI", + message: this.baseUri, + }); + } + } + // Cozy customization end } diff --git a/apps/browser/src/auth/popup/login-v1.component.ts b/apps/browser/src/auth/popup/login-v1.component.ts index 9e8825e395cc..837ec040d960 100644 --- a/apps/browser/src/auth/popup/login-v1.component.ts +++ b/apps/browser/src/auth/popup/login-v1.component.ts @@ -230,6 +230,7 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit { // This adds the scheme if missing await this.environmentService.setEnvironment(Region.SelfHosted, { base: this.cozyUrl + "/bitwarden", + oidc: this.cozyUrl + "/oidc/bitwarden", }); // The email is based on the URL and necessary for login @@ -240,6 +241,7 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit { data.masterPassword, null, null, + this.code, ); this.formPromise = this.loginStrategyService.logIn(credentials); diff --git a/apps/browser/src/content/oidcSuccess.html b/apps/browser/src/content/oidcSuccess.html new file mode 100644 index 000000000000..c4c8d5e48e89 --- /dev/null +++ b/apps/browser/src/content/oidcSuccess.html @@ -0,0 +1,242 @@ + + + + + Twake Pass Extension + + + + +
+
+
+ +
+ You have been logged in to your Twake Workplace successfully. +
+
+ + +
+ +
+
+ + Twake Pass Extension +
+ Open the extension and enter your password for Twake Pass to complete login. +
+ I need help? +
+
+
+ + + + diff --git a/apps/browser/src/content/oidcSuccess.js b/apps/browser/src/content/oidcSuccess.js new file mode 100644 index 000000000000..2cc96dbf2aff --- /dev/null +++ b/apps/browser/src/content/oidcSuccess.js @@ -0,0 +1,57 @@ +const translations = { + en: { + title: "Twake Pass Extension", + successMessage: "You have been logged in to your Twake Workplace successfully.", + instructionText: "Open the extension and enter your password for Twake Pass to complete login.", + helpLink: "I need help?", + logoAlt: "ID Logo", + twakePassAlt: "Twake Pass", + extensionAlt: "Twake Pass Extension", + }, + fr: { + title: "Extension Twake Pass", + successMessage: "Vous avez été connecté à votre espace de travail Twake avec succès.", + instructionText: + "Ouvrez l'extension et saisissez votre mot de passe pour Twake Pass afin de terminer la connexion.", + helpLink: "J'ai besoin d'aide ?", + logoAlt: "Logo ID", + twakePassAlt: "Twake Pass", + extensionAlt: "Extension Twake Pass", + }, +}; + +function detectLanguage() { + const browserLang = navigator.language || navigator.userLanguage; + const langCode = browserLang.split("-")[0].toLowerCase(); + + return translations[langCode] ? langCode : "en"; +} + +function applyTranslations(lang) { + const t = translations[lang]; + + document.title = t.title; + + document.querySelectorAll("[data-i18n]").forEach((element) => { + const key = element.getAttribute("data-i18n"); + if (t[key]) { + element.textContent = t[key]; + } + }); + + document.querySelectorAll("img[alt]").forEach((img) => { + const src = img.src; + if (src.includes("logo-id.png") && t.logoAlt) { + img.alt = t.logoAlt; + } else if (src.includes("logo-text.png") && t.twakePassAlt) { + img.alt = t.twakePassAlt; + } else if (src.includes("mockup.png") && t.extensionAlt) { + img.alt = t.extensionAlt; + } + }); +} + +document.addEventListener("DOMContentLoaded", function () { + const language = detectLanguage(); + applyTranslations(language); +}); diff --git a/apps/browser/src/cozy/sso/helpers.ts b/apps/browser/src/cozy/sso/helpers.ts new file mode 100644 index 000000000000..82c7aaaa8996 --- /dev/null +++ b/apps/browser/src/cozy/sso/helpers.ts @@ -0,0 +1,19 @@ +export const getLoginSuccessPageUri = (extensionUri: string) => { + return `${extensionUri}/content/oidcSuccess.html`; +}; + +export const extractDomain = (companyEmail: string): string | null => { + if (!companyEmail) { + return null; + } + + const email = companyEmail.trim(); + + const atIndex = email.lastIndexOf("@"); + + if (atIndex === -1) { + return null; + } + + return email.substring(atIndex + 1); +}; diff --git a/apps/browser/src/images/logo-id.png b/apps/browser/src/images/logo-id.png new file mode 100644 index 000000000000..b1eed2fe28d5 Binary files /dev/null and b/apps/browser/src/images/logo-id.png differ diff --git a/apps/browser/src/images/logo-text.png b/apps/browser/src/images/logo-text.png new file mode 100644 index 000000000000..195070d985dd Binary files /dev/null and b/apps/browser/src/images/logo-text.png differ diff --git a/apps/browser/src/images/mockup.png b/apps/browser/src/images/mockup.png new file mode 100644 index 000000000000..74015b914c79 Binary files /dev/null and b/apps/browser/src/images/mockup.png differ diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 17a34af1d878..95f31ff8a009 100755 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -129,7 +129,9 @@ "overlay/menu.html", "overlay/button.html", "overlay/list.html", - "popup/fonts/*" + "popup/fonts/*", + "content/oidcSuccess.html", + "content/oidcSuccess.js" ], "applications": { "gecko": { diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 536b92c7e6e6..156f826f25b2 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -134,7 +134,9 @@ "overlay/menu.html", "overlay/button.html", "overlay/list.html", - "popup/fonts/*" + "popup/fonts/*", + "content/oidcSuccess.html", + "content/oidcSuccess.js" ], "matches": [""] } diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index b47488bdd7d6..6ab1100ac8f7 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -338,4 +338,16 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return ""; } + + // Cozy customization + getExtensionUri(): string { + if (this.isFirefox()) { + return `moz-extension://${chrome.runtime.id}`; + } else if (this.isChrome) { + return `chrome-extension://${chrome.runtime.id}`; + } + + return null; + } + // Cozy customization end } diff --git a/apps/browser/src/popup/scss/pages.scss b/apps/browser/src/popup/scss/pages.scss index f313b7829980..4fc99fd32cef 100644 --- a/apps/browser/src/popup/scss/pages.scss +++ b/apps/browser/src/popup/scss/pages.scss @@ -185,6 +185,10 @@ body.body-full { padding: 30px 10px 0 10px; } +.backLink { + padding: 10px 10px 0 10px; +} + .display-cozy-url { position: absolute; bottom: 10px; diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index d9161b6cc674..81da28cda1a9 100755 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -193,6 +193,8 @@ let plugins = [ { from: "./src/popup/images", to: "popup/images" }, { from: "./src/autofill/content/autofill.css", to: "content" }, { from: "./src/content/notification.css", to: "content" }, + { from: "./src/content/oidcSuccess.html", to: "content" }, + { from: "./src/content/oidcSuccess.js", to: "content" }, ], }), new MiniCssExtractPlugin({ diff --git a/libs/angular/src/auth/components/login-v1.component.ts b/libs/angular/src/auth/components/login-v1.component.ts index 65cfef477d46..81c5880ea6b5 100644 --- a/libs/angular/src/auth/components/login-v1.component.ts +++ b/libs/angular/src/auth/components/login-v1.component.ts @@ -49,7 +49,10 @@ export class LoginComponentV1 extends CaptchaProtectedComponent implements OnIni showLoginWithDevice: boolean; validatedEmail = false; paramEmailSet = false; + // Cozy customization protected cozyUrl: string; + protected code: string; + // Cozy customization end get emailFormControl() { return this.formGroup.controls.email; @@ -112,13 +115,14 @@ export class LoginComponentV1 extends CaptchaProtectedComponent implements OnIni const queryParamsEmail = params.email; - // Cozy customization, do not check for @ because we use cozy URL + // Cozy customization, do not check for @ because we use cozy URL, save OIDC code //* if (queryParamsEmail != null) { this.formGroup.controls.email.setValue(queryParamsEmail); this.paramEmailSet = true; } this.cozyUrl = params.cozyUrl; + this.code = params.code; /*/ if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) { this.formGroup.controls.email.setValue(queryParamsEmail); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index 05faef1ba144..cfb00e515b47 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -76,7 +76,7 @@ export class PasswordLoginStrategy extends LoginStrategy { } override async logIn(credentials: PasswordLoginCredentials) { - const { email, masterPassword, captchaToken, twoFactor } = credentials; + const { email, masterPassword, captchaToken, twoFactor, code } = credentials; // Cozy customization const data = new PasswordLoginStrategyData(); data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email); @@ -96,6 +96,7 @@ export class PasswordLoginStrategy extends LoginStrategy { captchaToken, await this.buildTwoFactor(twoFactor, email), await this.buildDeviceRequest(), + code, // Cozy customization ); this.cache.next(data); diff --git a/libs/auth/src/common/models/domain/login-credentials.ts b/libs/auth/src/common/models/domain/login-credentials.ts index bfe01aea20f5..853af96dc431 100644 --- a/libs/auth/src/common/models/domain/login-credentials.ts +++ b/libs/auth/src/common/models/domain/login-credentials.ts @@ -14,6 +14,9 @@ export class PasswordLoginCredentials { public masterPassword: string, public captchaToken?: string, public twoFactor?: TokenTwoFactorRequest, + // Cozy customization + public code?: string, + // Cozy customization end ) {} } diff --git a/libs/common/src/auth/models/request/identity-token/password-token.request.ts b/libs/common/src/auth/models/request/identity-token/password-token.request.ts index 456e058a2347..12a7abd2144e 100644 --- a/libs/common/src/auth/models/request/identity-token/password-token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/password-token.request.ts @@ -7,14 +7,25 @@ import { TokenTwoFactorRequest } from "./token-two-factor.request"; import { TokenRequest } from "./token.request"; export class PasswordTokenRequest extends TokenRequest implements CaptchaProtectedRequest { + // Cozy customization + protected code?: string; + // Cozy customization end + constructor( public email: string, public masterPasswordHash: string, public captchaResponse: string, protected twoFactor: TokenTwoFactorRequest, device?: DeviceRequest, + // Cozy customization + code?: string, + // Cozy customization end ) { super(twoFactor, device); + + // Cozy customization + this.code = code; + // Cozy customization end } toIdentityToken(clientId: ClientType) { @@ -24,6 +35,10 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect obj.username = this.email; obj.password = this.masterPasswordHash; + // Cozy customization + obj.code = this.code; + // Cozy customization end + if (this.captchaResponse != null) { obj.captchaResponse = this.captchaResponse; } @@ -43,4 +58,10 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect : undefined, }); } + + // Cozy customization + isOidcRequest() { + return !!this.code; + } + // Cozy customization end } diff --git a/libs/common/src/platform/abstractions/environment.service.ts b/libs/common/src/platform/abstractions/environment.service.ts index 0293d68903c7..85bf4c9fde42 100644 --- a/libs/common/src/platform/abstractions/environment.service.ts +++ b/libs/common/src/platform/abstractions/environment.service.ts @@ -12,6 +12,9 @@ export type Urls = { events?: string; keyConnector?: string; scim?: string; + // Cozy customization + oidc?: string; + // Cozy customization end }; /** @@ -62,6 +65,9 @@ export interface Environment { getEventsUrl(): string; getIconsUrl(): string; getIdentityUrl(): string; + // Cozy customization + getOidcUrl(): string; + // Cozy customization end /** * @deprecated This is currently only used by the CLI. This functionality should be extracted since diff --git a/libs/common/src/platform/abstractions/platform-utils.service.ts b/libs/common/src/platform/abstractions/platform-utils.service.ts index fa0fc8f2501e..89cfc9a3021e 100644 --- a/libs/common/src/platform/abstractions/platform-utils.service.ts +++ b/libs/common/src/platform/abstractions/platform-utils.service.ts @@ -45,4 +45,7 @@ export abstract class PlatformUtilsService { abstract readFromClipboard(): Promise; abstract supportsSecureStorage(): boolean; abstract getAutofillKeyboardShortcut(): Promise; + // Cozy customization + abstract getExtensionUri(): string; + // Cozy customization end } diff --git a/libs/common/src/platform/services/default-environment.service.ts b/libs/common/src/platform/services/default-environment.service.ts index 8ed673d066ec..494dc2f948e2 100644 --- a/libs/common/src/platform/services/default-environment.service.ts +++ b/libs/common/src/platform/services/default-environment.service.ts @@ -216,6 +216,9 @@ export class DefaultEnvironmentService implements EnvironmentService { urls.notifications = formatUrl(urls.notifications); urls.events = formatUrl(urls.events); urls.keyConnector = formatUrl(urls.keyConnector); + // Cozy customization + urls.oidc = formatUrl(urls.oidc); + // Cozy customization end urls.scim = null; await this.globalState.update(() => ({ @@ -229,6 +232,9 @@ export class DefaultEnvironmentService implements EnvironmentService { notifications: urls.notifications, events: urls.events, keyConnector: urls.keyConnector, + // Cozy customization + oidc: urls.oidc, + // Cozy customization end }, })); @@ -382,6 +388,12 @@ abstract class UrlEnvironment implements Environment { return this.urls.keyConnector; } + // Cozy customization + getOidcUrl() { + return this.urls.oidc; + } + // Cozy customization end + getNotificationsUrl() { return this.getUrl("notifications", "/notifications"); } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index bc76bf9c1de5..15b2debe2364 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -230,24 +230,43 @@ export class ApiService implements ApiServiceAbstraction { const env = await firstValueFrom(this.environmentService.environment$); - const response = await this.fetch( - new Request(env.getIdentityUrl() + "/connect/token", { - // Cozy customization, we pass the client name to the stack, so in cozy-settings we can show - // "Cozy Password (browser name)" in the connected devices list. - //* - body: this.qsStringify({ - ...identityToken, - clientName: `Cozy Pass (${getDeviceName(this.device)})`, + let response; + + // Cozy customization; we call the OIDC login route from cozy-stack + if (request instanceof PasswordTokenRequest && request.isOidcRequest()) { + response = await this.fetch( + new Request(env.getOidcUrl() + "/unused", { + body: this.qsStringify({ + ...identityToken, + clientName: `Cozy Pass (${getDeviceName(this.device)})`, + }), + credentials: await this.getCredentials(), + cache: "no-store", + headers: headers, + method: "POST", }), - /*/ + ); + } else { + // Default login call + response = await this.fetch( + new Request(env.getIdentityUrl() + "/connect/token", { + // Cozy customization, we pass the client name to the stack, so in cozy-settings we can show + // "Cozy Password (browser name)" in the connected devices list. + //* + body: this.qsStringify({ + ...identityToken, + clientName: `Cozy Pass (${getDeviceName(this.device)})`, + }), + /*/ body: this.qsStringify(identityToken), //*/ - credentials: await this.getCredentials(), - cache: "no-store", - headers: headers, - method: "POST", - }), - ); + credentials: await this.getCredentials(), + cache: "no-store", + headers: headers, + method: "POST", + }), + ); + } let responseJson: any = null; if (this.isJsonResponse(response)) {