diff --git a/apps/web/src/app/auth/sso/redirecting-to-idp/redirecting-to-idp.component.html b/apps/web/src/app/auth/sso/redirecting-to-idp/redirecting-to-idp.component.html new file mode 100644 index 00000000000..ccaaa4360f6 --- /dev/null +++ b/apps/web/src/app/auth/sso/redirecting-to-idp/redirecting-to-idp.component.html @@ -0,0 +1,6 @@ +
+ +
Redirecting to your IdP…
+
+ + diff --git a/apps/web/src/app/auth/sso/redirecting-to-idp/redirecting-to-idp.component.ts b/apps/web/src/app/auth/sso/redirecting-to-idp/redirecting-to-idp.component.ts new file mode 100644 index 00000000000..b704a15a842 --- /dev/null +++ b/apps/web/src/app/auth/sso/redirecting-to-idp/redirecting-to-idp.component.ts @@ -0,0 +1,13 @@ +import { Component, OnInit } from "@angular/core"; + +@Component({ + selector: "app-redirecting-to-idp", + standalone: true, + templateUrl: "./redirecting-to-idp.component.html", +}) +export class RedirectingToIdpComponent implements OnInit { + ngOnInit(): void { + } +} + + diff --git a/apps/web/src/app/core/services/sso-flow-metrics.service.ts b/apps/web/src/app/core/services/sso-flow-metrics.service.ts new file mode 100644 index 00000000000..f9c5fbe93c5 --- /dev/null +++ b/apps/web/src/app/core/services/sso-flow-metrics.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@angular/core"; + +@Injectable({ providedIn: "root" }) +export class SsoFlowMetricsService { + mark(name: string): void { + try { + if (typeof performance !== "undefined" && typeof performance.mark === "function") { + performance.mark(name); + } + } catch { + // no-op + } + } + + measure(name: string, startMark: string, endMark: string): PerformanceMeasure | undefined { + try { + if ( + typeof performance !== "undefined" && + typeof performance.measure === "function" && + typeof performance.getEntriesByName === "function" + ) { + // Clear any previous measure with same name + (performance.getEntriesByName(name, "measure") as PerformanceMeasure[]).forEach((m) => + performance.clearMeasures(m.name), + ); + const measure = performance.measure(name, startMark, endMark); + return measure as PerformanceMeasure; + } + } catch { + // no-op + } + return undefined; + } +} + + diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 4db6e50bc6d..9f8470f1b8d 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -1,5 +1,5 @@ import { NgModule } from "@angular/core"; -import { Route, RouterModule, Routes } from "@angular/router"; +import { PreloadAllModules, Route, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; import { AuthRoute } from "@bitwarden/angular/auth/constants"; @@ -372,6 +372,24 @@ const routes: Routes = [ }, ], }, + { + path: "sso-redirecting", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { key: "singleSignOn" }, + titleId: "enterpriseSingleSignOn", + pageIcon: SsoKeyIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { + path: "", + loadComponent: () => + import("./auth/sso/redirecting-to-idp/redirecting-to-idp.component").then( + (m) => m.RedirectingToIdpComponent, + ), + }, + ], + }, { path: AuthRoute.TwoFactor, component: TwoFactorAuthComponent, @@ -757,6 +775,7 @@ const routes: Routes = [ RouterModule.forRoot(routes, { useHash: true, paramsInheritanceStrategy: "always", + preloadingStrategy: PreloadAllModules, // enableTracing: true, }), ], diff --git a/apps/web/src/assets/base-shell.css b/apps/web/src/assets/base-shell.css new file mode 100644 index 00000000000..4dafdb231f0 --- /dev/null +++ b/apps/web/src/assets/base-shell.css @@ -0,0 +1,69 @@ +/* Minimal base styles for SSO connector shell to align visuals with app */ + +:root { + color-scheme: light; +} + +html.theme_light { + background-color: #ffffff; +} + +body.layout_frontend { + margin: 0; + color: #0f172a; /* slate-900 */ + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, "Helvetica Neue", Arial, + "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +/* Utility approximations (subset) */ +.tw-flex { display: flex; } +.tw-flex-col { flex-direction: column; } +.tw-min-h\[100vh\] { min-height: 100vh; } +.tw-pt-5 { padding-top: 1.25rem; } +.tw-px-5 { padding-left: 1.25rem; padding-right: 1.25rem; } +.tw-fixed { position: fixed; } +.tw-relative { position: relative; } +.tw-grow { flex: 1 1 auto; } +.tw-justify-center { justify-content: center; } +.tw-text-muted { color: #6b7280; /* gray-500 */ } +.tw-hidden { display: none; } +.md\:tw-block { display: none; } + +@media (min-width: 768px) { + .md\:tw-block { display: block; } +} + +/* Logo sizing override */ +.!tw-w\[200px\] { width: 200px !important; } + +/* Center the spinner in viewport */ +.spinner-container { + display: flex; + align-items: center; + min-height: 60vh; +} + +/* Icon-like spinner fallback */ +.bwi { display: inline-block; box-sizing: border-box; width: 1.5em; height: 1.5em; border-radius: 50%; + border: 0.25em solid rgba(0, 0, 0, 0.15); border-top-color: #6b7280; } +.bwi-2x { width: 2em; height: 2em; border-width: 0.25em; } +.bwi-3x { width: 3em; height: 3em; border-width: 0.3em; } +.bwi-spin { animation: bw-spin 0.9s linear infinite; } + +@keyframes bw-spin { + to { transform: rotate(360deg); } +} + +/* Background illustration positioning */ +.tw-relative .tw-hidden svg { + position: absolute; + z-index: 1; + bottom: 0; + width: 35%; + max-width: 450px; + opacity: 0.11; +} +.tw-relative .tw-hidden:nth-of-type(1) svg { left: 0; } +.tw-relative .tw-hidden:nth-of-type(2) svg { right: 0; } + + diff --git a/apps/web/src/connectors/sso.html b/apps/web/src/connectors/sso.html index 55088c9f812..b37828f3354 100644 --- a/apps/web/src/connectors/sso.html +++ b/apps/web/src/connectors/sso.html @@ -5,26 +5,210 @@ - Bitwarden + Bitwarden Web vault + -
- Bitwarden -
-

- +

+ Bitwarden +
+
+ -

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/libs/auth/src/angular/sso/sso.component.html b/libs/auth/src/angular/sso/sso.component.html index be38f63987e..02a7c4299fc 100644 --- a/libs/auth/src/angular/sso/sso.component.html +++ b/libs/auth/src/angular/sso/sso.component.html @@ -1,7 +1,7 @@
-
- - {{ "loading" | i18n }} +
+ +
{{ "loading" | i18n }}
diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index 0b6bb1159f4..2e0d59e675d 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -323,15 +323,43 @@ export class SsoComponent implements OnInit { throw new Error("Client ID is required"); } - this.initiateSsoFormPromise = this.apiService.preValidateSso(this.identifier); - const response = await this.initiateSsoFormPromise; + // TTL cache for prevalidate to avoid repeated network calls + const ttlMs = 15 * 60 * 1000; // 15 minutes + const cacheKey = `sso_prevalidate_${this.identifier}`; + let response: SsoPreValidateResponse | undefined; + try { + const cached = localStorage.getItem(cacheKey); + if (cached) { + const parsed = JSON.parse(cached) as { token: string; expiresAt: number }; + if (parsed && typeof parsed.token === "string" && parsed.expiresAt > Date.now()) { + response = new SsoPreValidateResponse({ token: parsed.token }); + } else { + localStorage.removeItem(cacheKey); + } + } + } catch {} + + if (!response) { + this.initiateSsoFormPromise = this.apiService.preValidateSso(this.identifier); + response = await this.initiateSsoFormPromise; + try { + localStorage.setItem( + cacheKey, + JSON.stringify({ token: response.token, expiresAt: Date.now() + ttlMs }), + ); + } catch {} + } const authorizeUrl = await this.buildAuthorizeUrl( returnUri, includeUserIdentifier, response.token, ); - this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true }); + // Navigate to a lightweight interstitial to provide user feedback before external redirect + await this.router.navigate(["/sso-redirecting"]); + setTimeout(() => { + this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true }); + }, 0); } private async buildAuthorizeUrl( @@ -357,7 +385,7 @@ export class SsoComponent implements OnInit { if (codeChallenge == null) { const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); - codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash.buffer as ArrayBuffer); await this.ssoLoginService.setCodeVerifier(codeVerifier); }