From 62a028dff93f900f42ceac204df7f31e3e8a0205 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:22:34 +0200 Subject: [PATCH 01/36] initial work to log in without losing context --- .../src/apps/app/app-auth.controller.ts | 15 ++-- .../src/apps/app/app.element.ts | 14 ++++ .../src/packages/core/auth/auth-flow.ts | 31 +++++++- .../src/packages/core/auth/auth.context.ts | 4 + .../auth/modals/umb-app-auth-modal.element.ts | 74 +++++++++++++++++-- .../auth/modals/umb-app-auth-modal.token.ts | 10 +-- .../umbraco-news-dashboard.element.ts | 58 +++++---------- 7 files changed, 145 insertions(+), 61 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 78f61dd40b..d30e2e3cf0 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -85,7 +85,14 @@ export class UmbAppAuthController extends UmbControllerBase { throw new Error('[Fatal] No auth providers available'); } - if (availableProviders.length === 1) { + // Show the provider selection screen + const selected = await this.#showLoginModal(userLoginState); + + if (!selected) { + return false; + } + + /*if (availableProviders.length === 1) { // One provider available (most likely the Umbraco provider), so initiate the authorization request to the default provider this.#authContext.makeAuthorizationRequest(); } else { @@ -103,7 +110,7 @@ export class UmbAppAuthController extends UmbControllerBase { return false; } } - } + }*/ return this.#updateState(); } @@ -129,12 +136,10 @@ export class UmbAppAuthController extends UmbControllerBase { .onSubmit() .catch(() => undefined); - if (!selected?.providerName) { + if (!selected) { return false; } - this.#authContext.makeAuthorizationRequest(selected.providerName, selected.loginHint); - return true; } diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index e5a2a100ab..a6813a6310 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -51,6 +51,17 @@ export class UmbAppElement extends UmbLitElement { path: 'install', component: () => import('../installer/installer.element.js'), }, + { + path: 'oauth_complete', + resolve: () => { + // Complete oauth + console.log('congrats you hit the oauth complete'); + this.#authContext?.completeAuthorizationRequest(); + + // Let the opener know that we are done + window.opener?.postMessage('oauth_complete', '*'); + }, + }, { path: 'upgrade', component: () => import('../upgrader/upgrader.element.js'), @@ -152,6 +163,9 @@ export class UmbAppElement extends UmbLitElement { const queryParams = new URLSearchParams(window.location.search); if (queryParams.has('code')) { this.#authContext?.completeAuthorizationRequest(); + + // Let the opener know that we are done + window.opener?.postMessage('oauth_complete', '*'); return; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts index 01b20582f9..06bed95bdb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts @@ -13,6 +13,7 @@ * License for the specific language governing permissions and limitations under * the License. */ +import { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UMB_STORAGE_REDIRECT_URL, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js'; import type { LocationLike, StringMap } from '@umbraco-cms/backoffice/external/openid'; import { @@ -30,6 +31,7 @@ import { TokenRequest, TokenResponse, } from '@umbraco-cms/backoffice/external/openid'; +import { UmbStringState } from '@umbraco-cms/backoffice/observable-api'; const requestor = new FetchRequestor(); @@ -42,6 +44,22 @@ class UmbNoHashQueryStringUtils extends BasicQueryStringUtils { } } +class UmbLocationInterceptor implements LocationLike { + public redirect = new UmbStringState(undefined); + hash = ''; + host = ''; + origin = ''; + hostname = ''; + pathname = ''; + port = ''; + protocol = ''; + search = ''; + + assign(url: string | URL): void { + this.redirect.setValue(url.toString()); + } +} + /** * This class is used to handle the auth flow through any backend supporting OpenID Connect. * It needs to know the server url, the client id, the redirect uri and the scope. @@ -95,6 +113,7 @@ export class UmbAuthFlow { // tokens #refreshToken: string | undefined; #accessTokenResponse: TokenResponse | undefined; + #locationInterceptor = new UmbLocationInterceptor(); constructor( openIdConnectUrl: string, @@ -119,7 +138,7 @@ export class UmbAuthFlow { this.#authorizationHandler = new RedirectRequestHandler( this.#storageBackend, new UmbNoHashQueryStringUtils(), - window.location, + this.#locationInterceptor, ); // set notifier to deliver responses @@ -149,11 +168,15 @@ export class UmbAuthFlow { sessionStorage.removeItem(UMB_STORAGE_REDIRECT_URL); currentRoute = savedRoute; } - history.replaceState(null, '', currentRoute); + //history.replaceState(null, '', currentRoute); } }); } + authRedirect() { + return this.#locationInterceptor.redirect.asObservable(); + } + /** * This method will initialize all the state needed for the auth flow. * @@ -193,7 +216,7 @@ export class UmbAuthFlow { * @param identityProvider The identity provider to use for the authorization request. * @param usernameHint (Optional) The username to use for the authorization request. It will be provided to the OpenID server as a hint. */ - makeAuthorizationRequest(identityProvider: string, usernameHint?: string): void { + makeAuthorizationRequest(identityProvider: string, usernameHint?: string) { const extras: StringMap = { prompt: 'consent', access_type: 'offline' }; // If the identity provider is not 'Umbraco', we will add it to the extras. @@ -221,6 +244,8 @@ export class UmbAuthFlow { ); this.#authorizationHandler.performAuthorizationRequest(this.#configuration, request); + + return this.#locationInterceptor.redirect.asObservable(); } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index cf99b23c35..328a8bbd18 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -15,6 +15,10 @@ export class UmbAuthContext extends UmbContextBase { #isInitialized = new ReplaySubject(1); readonly isInitialized = this.#isInitialized.asObservable().pipe(filter((isInitialized) => isInitialized)); + get authRedirect() { + return this.#authFlow.authRedirect(); + } + #isBypassed = false; #serverUrl; #backofficePath; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index 9fb5122908..8a0caf8a3c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -1,14 +1,18 @@ import { UmbModalBaseElement } from '../../modal/index.js'; +import { UMB_AUTH_CONTEXT } from '../auth.context.token.js'; import type { UmbModalAppAuthConfig, UmbModalAppAuthValue } from './umb-app-auth-modal.token.js'; -import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; @customElement('umb-app-auth-modal') export class UmbAppAuthModalElement extends UmbModalBaseElement { + @state() + private _error?: string; + get props() { return { userLoginState: this.data?.userLoginState ?? 'loggingIn', - onSubmit: this.onSubmit, + onSubmit: this.onSubmit.bind(this), }; } @@ -27,6 +31,35 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement { + this.#authContext = authContext; + + // Observe the auth redirects + this.observe( + authContext.authRedirect, + (url) => { + if (url) { + this.#openWindow = window.open(url, '_blank'); + } + }, + '_redirect', + ); + }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener('message', this.#onMessage); + } render() { return html` @@ -35,6 +68,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement${this.localize.term('login_timeout')}

` : ''} + ${this._error ? html`

${this._error}

` : ''} { - this.value = { providerName }; - this._submitModal(); + private onSubmit = async (providerName: string) => { + const authContext = await this.getContext(UMB_AUTH_CONTEXT); + const redirect = authContext.makeAuthorizationRequest(providerName); + + this.observe( + redirect, + (url) => { + if (url) { + this.#openWindow = window.open(url, '_blank'); + } + }, + '_redirect', + ); }; + async #onMessage(evt: MessageEvent) { + if (evt.data === 'oauth_complete') { + if (this.#openWindow) { + this.#openWindow.close(); + } + + // Refresh the state + await this.#authContext?.setInitialState(); + + // Test if we are authorized + const isAuthed = this.#authContext?.getIsAuthorized(); + this.value = { success: isAuthed }; + if (isAuthed) { + this._submitModal(); + } else { + this._error = 'Failed to authenticate'; + } + } + } + static styles = [ UmbTextStyles, css` diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.token.ts index 97dd990b68..c51eacd4bc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.token.ts @@ -7,16 +7,10 @@ export type UmbModalAppAuthConfig = { export type UmbModalAppAuthValue = { /** - * The name of the provider that the user has selected to authenticate with. + * An indicator of whether the authentication was successful. * @required */ - providerName?: string; - - /** - * The login hint that the user has provided to the provider. - * @optional - */ - loginHint?: string; + success?: boolean; }; export const UMB_MODAL_APP_AUTH = new UmbModalToken('Umb.Modal.AppAuth', { diff --git a/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/umbraco-news-dashboard.element.ts b/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/umbraco-news-dashboard.element.ts index d9a5135dbb..3f605ebf38 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/umbraco-news-dashboard.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/umbraco-news/umbraco-news-dashboard.element.ts @@ -1,61 +1,39 @@ -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; -import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { UMB_AUTH_CONTEXT } from '../core/auth/auth.context.token.js'; +import { tryExecuteAndNotify } from '../core/resources/tryExecuteAndNotify.function.js'; +import { html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UserGroupResource, type UserGroupResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; @customElement('umb-umbraco-news-dashboard') export class UmbUmbracoNewsDashboardElement extends UmbLitElement { - #currentUserContext?: typeof UMB_CURRENT_USER_CONTEXT.TYPE; - @state() - private _name = ''; + _groups: UserGroupResponseModel[] = []; - constructor() { - super(); - this.consumeContext(UMB_CURRENT_USER_CONTEXT, (instance) => { - this.#currentUserContext = instance; - this.#observeCurrentUser(); - }); + async clearToken() { + const authContext = await this.getContext(UMB_AUTH_CONTEXT); + authContext.clearTokenStorage(); } - #observeCurrentUser(): void { - if (!this.#currentUserContext) return; - this.observe(this.#currentUserContext.currentUser, (user) => { - this._name = user?.name ?? ''; - }); + async makeAuthorizedRequest() { + const { data } = await tryExecuteAndNotify(this, UserGroupResource.getUserGroup({ skip: 0, take: 10 })); + + if (data) { + this._groups = data.items; + } } render() { return html` -

- Welcome, ${this._name} -

-

- This is the beta version of Umbraco 14, where you can have a first-hand look at the new Backoffice. -

- Please refer to the - documentation to learn - more about what is possible. Here you will find excellent tutorials, guides, and references to help you get - started extending the Backoffice. + Clear all tokens + Make authorized request + + ${this._groups.map((group) => html`

${group.name}

`)}

`; } - - static styles = [ - UmbTextStyles, - css` - :host { - display: block; - padding: var(--uui-size-layout-1); - } - p { - position: relative; - } - `, - ]; } export default UmbUmbracoNewsDashboardElement; From 262f2ba2745fbb01b0041f4026aad56d85bd0a68 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:05:59 +0200 Subject: [PATCH 02/36] wait to refresh the state --- .../src/apps/app/app-auth.controller.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 29bbf70527..951965918b 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -91,7 +91,7 @@ export class UmbAppAuthController extends UmbControllerBase { if (availableProviders.length === 1) { // One provider available (most likely the Umbraco provider), so initiate the authorization request to the default provider - this.#authContext.makeAuthorizationRequest(availableProviders[0].forProviderName); + await this.#authContext.makeAuthorizationRequest(availableProviders[0].forProviderName); return this.#updateState(); } @@ -103,7 +103,7 @@ export class UmbAppAuthController extends UmbControllerBase { if (redirectProvider) { // Redirect directly to the provider - this.#authContext.makeAuthorizationRequest(redirectProvider.forProviderName); + await this.#authContext.makeAuthorizationRequest(redirectProvider.forProviderName); return this.#updateState(); } @@ -129,7 +129,7 @@ export class UmbAppAuthController extends UmbControllerBase { const denyLocalLogin = availableProviders.some((provider) => provider.meta?.behavior?.denyLocalLogin); if (denyLocalLogin) { // Unregister the Umbraco provider - umbExtensionsRegistry.unregister('Umb.AuthProviders.Umbraco'); + umbExtensionsRegistry.exclude('Umb.AuthProviders.Umbraco'); } // Show the provider selection screen @@ -150,7 +150,7 @@ export class UmbAppAuthController extends UmbControllerBase { .onSubmit() .catch(() => undefined); - if (!selected) { + if (!selected?.success) { return false; } @@ -162,9 +162,6 @@ export class UmbAppAuthController extends UmbControllerBase { throw new Error('[Fatal] Auth context is not available'); } - // Reinitialize the auth flow (load the state from local storage) - this.#authContext.setInitialState(); - // The authorization flow is finished, so let the caller know if the user is authorized return this.#authContext.getIsAuthorized(); } From e10fb61c3a72866e451246a070e55881a1fc7bcb Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:09:03 +0200 Subject: [PATCH 03/36] add route for oauth_complete and complete the authorization flow --- .../src/apps/app/app.element.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 50974b1f58..ad2c17af0b 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -59,13 +59,9 @@ export class UmbAppElement extends UmbLitElement { }, { path: 'oauth_complete', - resolve: () => { + resolve: async () => { // Complete oauth - console.log('congrats you hit the oauth complete'); this.#authContext?.completeAuthorizationRequest(); - - // Let the opener know that we are done - window.opener?.postMessage('oauth_complete', '*'); }, }, { @@ -172,15 +168,15 @@ export class UmbAppElement extends UmbLitElement { } #redirect() { - // If there is a ?code parameter in the url, then we are in the middle of the oauth flow - // and we need to complete the login (the authorization notifier will redirect after this is done - // essentially hitting this method again) + /** If there is a ?code parameter in the url, then we are in the middle of the oauth flow + * and we need to complete the login (the authorization notifier will redirect after this is done + * essentially hitting this method again) + * @deprecated This is a legacy way of handling oauth flow, it should be removed once the backend starts sending the oauth_complete route + * TODO: Remove this when the backend sends the oauth_complete route + */ const queryParams = new URLSearchParams(window.location.search); if (queryParams.has('code')) { this.#authContext?.completeAuthorizationRequest(); - - // Let the opener know that we are done - window.opener?.postMessage('oauth_complete', '*'); return; } From 9002f3f74a5921e0d3b3ad49c3480cb7ebf3b122 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:27:53 +0200 Subject: [PATCH 04/36] wait for authorization requests before proceeding --- src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 951965918b..293bc916ed 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -91,7 +91,7 @@ export class UmbAppAuthController extends UmbControllerBase { if (availableProviders.length === 1) { // One provider available (most likely the Umbraco provider), so initiate the authorization request to the default provider - await this.#authContext.makeAuthorizationRequest(availableProviders[0].forProviderName); + await this.#authContext.makeAuthorizationRequest(availableProviders[0].forProviderName, true); return this.#updateState(); } @@ -103,7 +103,7 @@ export class UmbAppAuthController extends UmbControllerBase { if (redirectProvider) { // Redirect directly to the provider - await this.#authContext.makeAuthorizationRequest(redirectProvider.forProviderName); + await this.#authContext.makeAuthorizationRequest(redirectProvider.forProviderName, true); return this.#updateState(); } From 289cbc2512f281ab27c240903f122ea713bb7d51 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:28:09 +0200 Subject: [PATCH 05/36] return the url to be able to make a choice --- .../src/external/openid/redirect_based_handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/external/openid/redirect_based_handler.ts b/src/Umbraco.Web.UI.Client/src/external/openid/redirect_based_handler.ts index 8344c28cf2..e9bb94a8a7 100644 --- a/src/Umbraco.Web.UI.Client/src/external/openid/redirect_based_handler.ts +++ b/src/Umbraco.Web.UI.Client/src/external/openid/redirect_based_handler.ts @@ -69,11 +69,11 @@ export class RedirectRequestHandler extends AuthorizationRequestHandler { this.storageBackend.setItem(authorizationServiceConfigurationKey(handle), JSON.stringify(configuration.toJson())), ]); - persisted.then(() => { + return persisted.then(() => { // make the redirect request const url = this.buildRequestUrl(configuration, request); log('Making a request to ', request, url); - this.locationLike.assign(url); + return url; }); } From 3294d8c98333c72ed78364071f54b96ef6d38b3b Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:28:25 +0200 Subject: [PATCH 06/36] wait for the authorization signal after submitting a login --- .../auth/modals/umb-app-auth-modal.element.ts | 65 ++++--------------- 1 file changed, 11 insertions(+), 54 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index 32df7c60e6..88d04d69d2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -31,36 +31,15 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement { - this.#authContext = authContext; - - // Observe the auth redirects - this.observe( - authContext.authRedirect, - (url) => { - if (url) { - this.#openWindow = window.open(url, '_blank'); - } - }, - '_redirect', - ); + this.consumeContext(UMB_AUTH_CONTEXT, (context) => { + this.observe(context.authorizationSignal, () => {}, '_authorizationSignal'); }); } - disconnectedCallback(): void { - super.disconnectedCallback(); - window.removeEventListener('message', this.#onMessage); - } - render() { return html` @@ -80,38 +59,16 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement { const authContext = await this.getContext(UMB_AUTH_CONTEXT); - const redirect = authContext.makeAuthorizationRequest(providerName); - - this.observe( - redirect, - (url) => { - if (url) { - this.#openWindow = window.open(url, '_blank'); - } - }, - '_redirect', - ); - }; - - async #onMessage(evt: MessageEvent) { - if (evt.data === 'oauth_complete') { - if (this.#openWindow) { - this.#openWindow.close(); - } - - // Refresh the state - await this.#authContext?.setInitialState(); - - // Test if we are authorized - const isAuthed = this.#authContext?.getIsAuthorized(); - this.value = { success: isAuthed }; - if (isAuthed) { - this._submitModal(); - } else { - this._error = 'Failed to authenticate'; - } + await authContext.makeAuthorizationRequest(providerName); + console.log('[AuthModal] Received authorization signal'); + const isAuthed = authContext.getIsAuthorized(); + this.value = { success: isAuthed }; + if (isAuthed) { + this._submitModal(); + } else { + this._error = 'Failed to authenticate'; } - } + }; static styles = [ UmbTextStyles, From 315e05b8555a08f8a984e30842c7eec187fd7d8b Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:29:07 +0200 Subject: [PATCH 07/36] add an authorization signal --- .../src/packages/core/auth/auth-flow.ts | 50 ++++--------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts index e9be945df9..1b07218c91 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts @@ -13,8 +13,7 @@ * License for the specific language governing permissions and limitations under * the License. */ -import { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UMB_STORAGE_REDIRECT_URL, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js'; +import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js'; import type { LocationLike, StringMap } from '@umbraco-cms/backoffice/external/openid'; import { BaseTokenRequestHandler, @@ -31,7 +30,7 @@ import { TokenRequest, TokenResponse, } from '@umbraco-cms/backoffice/external/openid'; -import { UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { Subject } from '@umbraco-cms/backoffice/external/rxjs'; const requestor = new FetchRequestor(); @@ -44,22 +43,6 @@ class UmbNoHashQueryStringUtils extends BasicQueryStringUtils { } } -class UmbLocationInterceptor implements LocationLike { - public redirect = new UmbStringState(undefined); - hash = ''; - host = ''; - origin = ''; - hostname = ''; - pathname = ''; - port = ''; - protocol = ''; - search = ''; - - assign(url: string | URL): void { - this.redirect.setValue(url.toString()); - } -} - /** * This class is used to handle the auth flow through any backend supporting OpenID Connect. * It needs to know the server url, the client id, the redirect uri and the scope. @@ -113,7 +96,8 @@ export class UmbAuthFlow { // tokens #tokenResponse?: TokenResponse; - #locationInterceptor = new UmbLocationInterceptor(); + + authorizationSignal = new Subject(); constructor( openIdConnectUrl: string, @@ -137,11 +121,7 @@ export class UmbAuthFlow { this.#notifier = new AuthorizationNotifier(); this.#tokenHandler = new BaseTokenRequestHandler(requestor); this.#storageBackend = new LocalStorageBackend(); - this.#authorizationHandler = new RedirectRequestHandler( - this.#storageBackend, - new UmbNoHashQueryStringUtils(), - this.#locationInterceptor, - ); + this.#authorizationHandler = new RedirectRequestHandler(this.#storageBackend, new UmbNoHashQueryStringUtils()); // set notifier to deliver responses this.#authorizationHandler.setAuthorizationNotifier(this.#notifier); @@ -150,6 +130,7 @@ export class UmbAuthFlow { this.#notifier.setAuthorizationListener(async (request, response, error) => { if (error) { console.error('Authorization error', error); + this.authorizationSignal.next(); throw error; } @@ -162,21 +143,10 @@ export class UmbAuthFlow { await this.#makeTokenRequest(response.code, codeVerifier); await this.performWithFreshTokens(); await this.#saveTokenState(); - - // Redirect to the saved state or root - let currentRoute = '/'; - const savedRoute = sessionStorage.getItem(UMB_STORAGE_REDIRECT_URL); - if (savedRoute) { - sessionStorage.removeItem(UMB_STORAGE_REDIRECT_URL); - currentRoute = savedRoute; - } - //history.replaceState(null, '', currentRoute); } - }); - } - authRedirect() { - return this.#locationInterceptor.redirect.asObservable(); + this.authorizationSignal.next(); + }); } /** @@ -246,9 +216,7 @@ export class UmbAuthFlow { true, ); - this.#authorizationHandler.performAuthorizationRequest(this.#configuration, request); - - return this.#locationInterceptor.redirect.asObservable(); + return this.#authorizationHandler.performAuthorizationRequest(this.#configuration, request); } /** From 094e5538f35aac875325981023ef155cc61b7706 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:29:31 +0200 Subject: [PATCH 08/36] listen for authorization signals before re-initialising the token + open popup for login --- .../src/packages/core/auth/auth.context.ts | 68 +++++++++++++++++-- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index 12422b77f4..51fe39049b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -1,12 +1,12 @@ import type { UmbBackofficeExtensionRegistry, ManifestAuthProvider } from '../extension-registry/index.js'; import { UmbAuthFlow } from './auth-flow.js'; -import { UMB_AUTH_CONTEXT } from './auth.context.token.js'; +import { UMB_AUTH_CONTEXT, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js'; import type { UmbOpenApiConfiguration } from './models/openApiConfiguration.js'; import { OpenAPI } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; -import { ReplaySubject, filter, switchMap } from '@umbraco-cms/backoffice/external/rxjs'; +import { ReplaySubject, filter, firstValueFrom, switchMap } from '@umbraco-cms/backoffice/external/rxjs'; export class UmbAuthContext extends UmbContextBase { #isAuthorized = new UmbBooleanState(false); @@ -15,8 +15,8 @@ export class UmbAuthContext extends UmbContextBase { #isInitialized = new ReplaySubject(1); readonly isInitialized = this.#isInitialized.asObservable().pipe(filter((isInitialized) => isInitialized)); - get authRedirect() { - return this.#authFlow.authRedirect(); + get authorizationSignal() { + return this.#authFlow.authorizationSignal; } #isBypassed = false; @@ -24,6 +24,9 @@ export class UmbAuthContext extends UmbContextBase { #backofficePath; #authFlow; + #authWindowProxy?: WindowProxy | null; + #previousAuthUrl?: string; + constructor(host: UmbControllerHost, serverUrl: string, backofficePath: string, isBypassed: boolean) { super(host, UMB_AUTH_CONTEXT); this.#isBypassed = isBypassed; @@ -31,6 +34,37 @@ export class UmbAuthContext extends UmbContextBase { this.#backofficePath = backofficePath; this.#authFlow = new UmbAuthFlow(serverUrl, this.getRedirectUrl(), this.getPostLogoutRedirectUrl()); + + // Observe the authorization signal and close the auth window + this.observe( + this.authorizationSignal, + () => { + // Update the authorization state + this.getIsAuthorized(); + }, + '_authFlowAuthorizationSignal', + ); + + // Observe changes to local storage and update the authorization state + // This establishes the tab-to-tab communication + window.addEventListener('storage', this.#onStorageEvent.bind(this)); + } + + destroy(): void { + super.destroy(); + window.removeEventListener('storage', this.#onStorageEvent.bind(this)); + } + + async #onStorageEvent(evt: StorageEvent) { + console.log('[AuthContext] Storage event', evt); + if (evt.key === UMB_STORAGE_TOKEN_RESPONSE_NAME) { + // Close any open auth windows + this.#authWindowProxy?.close(); + // Refresh the local storage state into memory + await this.setInitialState(); + // Let any auth listeners (such as the auth modal) know that the auth state has changed + this.authorizationSignal.next(); + } } /** @@ -38,8 +72,28 @@ export class UmbAuthContext extends UmbContextBase { * @param identityProvider The provider to use for login. Default is 'Umbraco'. * @param usernameHint The username hint to use for login. */ - makeAuthorizationRequest(identityProvider = 'Umbraco', usernameHint?: string) { - return this.#authFlow.makeAuthorizationRequest(identityProvider, usernameHint); + async makeAuthorizationRequest(identityProvider = 'Umbraco', redirect?: boolean, usernameHint?: string) { + const redirectUrl = await this.#authFlow.makeAuthorizationRequest(identityProvider, usernameHint); + if (redirect) { + location.href = redirectUrl; + return; + } + + if (!this.#authWindowProxy || this.#authWindowProxy.closed) { + // TODO: Add popup behavior configuration to the authProvider's manifest + this.#authWindowProxy = window.open( + redirectUrl, + 'umbracoAuthPopup', + 'popup,width=600,height=600,menubar=no,location=no,resizable=yes,scrollbars=yes,status=no,toolbar=no', + ); + } else if (this.#previousAuthUrl !== redirectUrl) { + this.#authWindowProxy = window.open(redirectUrl, 'umbracoAuthPopup'); + this.#authWindowProxy?.focus(); + } + + this.#previousAuthUrl = redirectUrl; + + return firstValueFrom(this.authorizationSignal); } /** @@ -173,7 +227,7 @@ export class UmbAuthContext extends UmbContextBase { } getRedirectUrl() { - return `${window.location.origin}${this.#backofficePath}`; + return `${window.location.origin}${this.#backofficePath}oauth_complete`; } getPostLogoutRedirectUrl() { From b2a7234248fdf6037d7e0b84bb961eae1a19d329 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 14:56:03 +0200 Subject: [PATCH 09/36] cleanup modal --- .../core/auth/modals/umb-app-auth-modal.element.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index 88d04d69d2..5338fb397e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -32,14 +32,6 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement { - this.observe(context.authorizationSignal, () => {}, '_authorizationSignal'); - }); - } - render() { return html` @@ -60,7 +52,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement { const authContext = await this.getContext(UMB_AUTH_CONTEXT); await authContext.makeAuthorizationRequest(providerName); - console.log('[AuthModal] Received authorization signal'); + const isAuthed = authContext.getIsAuthorized(); this.value = { success: isAuthed }; if (isAuthed) { From 1b4ca8dd68056820ed83e6d5e9a268356cdd232c Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 14:56:38 +0200 Subject: [PATCH 10/36] cleanup popup --- .../src/packages/core/auth/auth.context.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index 51fe39049b..f6496a3382 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -79,15 +79,16 @@ export class UmbAuthContext extends UmbContextBase { return; } + const popupTarget = 'umbracoAuthPopup'; + if (!this.#authWindowProxy || this.#authWindowProxy.closed) { - // TODO: Add popup behavior configuration to the authProvider's manifest this.#authWindowProxy = window.open( redirectUrl, - 'umbracoAuthPopup', - 'popup,width=600,height=600,menubar=no,location=no,resizable=yes,scrollbars=yes,status=no,toolbar=no', + popupTarget, + 'width=600,height=600,menubar=no,location=no,resizable=yes,scrollbars=yes,status=no,toolbar=no', ); } else if (this.#previousAuthUrl !== redirectUrl) { - this.#authWindowProxy = window.open(redirectUrl, 'umbracoAuthPopup'); + this.#authWindowProxy = window.open(redirectUrl, popupTarget); this.#authWindowProxy?.focus(); } From 9088ea1129a7681d02c0133fdd0861b1b4e7f993 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 15:00:18 +0200 Subject: [PATCH 11/36] let the logout page listen for authorized events and redirect back to the frontpage in case an event occurs --- .../src/apps/app/app.element.ts | 12 ++++++++++++ src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index ad2c17af0b..0f258c022d 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -17,6 +17,7 @@ import { UmbAppEntryPointExtensionInitializer, umbExtensionsRegistry, } from '@umbraco-cms/backoffice/extension-registry'; +import { filter, first } from '@umbraco-cms/backoffice/external/rxjs'; @customElement('umb-app') export class UmbAppElement extends UmbLitElement { @@ -74,6 +75,17 @@ export class UmbAppElement extends UmbLitElement { resolve: () => { this.#authContext?.clearTokenStorage(); this.#authController.makeAuthorizationRequest('loggedOut'); + + // Listen for the user to be authorized + this.#authContext?.isAuthorized + .pipe( + filter((x) => !!x), + first(), + ) + .subscribe(() => { + // Redirect to the root + history.replaceState(null, '', ''); + }); }, }, { diff --git a/src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts b/src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts index cc5b1fcd73..24fa9d1a5a 100644 --- a/src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts +++ b/src/Umbraco.Web.UI.Client/src/external/rxjs/index.ts @@ -18,4 +18,5 @@ export { filter, startWith, skip, + first, } from 'rxjs'; From 743ca6be04fd11ab64f507ddb06ac34a474086b3 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 15:37:31 +0200 Subject: [PATCH 12/36] resolve to let the flow go through, but will of course fail because the token is invalid --- .../src/packages/core/auth/auth-flow.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts index 1b07218c91..58f547d8a8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts @@ -304,10 +304,9 @@ export class UmbAuthFlow { return Promise.resolve(this.#tokenResponse.accessToken); } - // if the refresh token is not set (maybe the provider doesn't support them), sign out + // if the refresh token is not set (maybe the provider doesn't support them) if (!this.#tokenResponse?.refreshToken) { - this.signOut(); - return Promise.reject('Missing refreshToken.'); + return Promise.resolve('Missing refreshToken.'); } const request = new TokenRequest({ From bc6b6f2e99944b366a5e9601d4843e3a0b353d76 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 15:37:44 +0200 Subject: [PATCH 13/36] remove stored url --- .../src/apps/app/app-auth.controller.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 293bc916ed..462e05ef6b 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -1,9 +1,4 @@ -import { - UMB_AUTH_CONTEXT, - UMB_MODAL_APP_AUTH, - UMB_STORAGE_REDIRECT_URL, - type UmbUserLoginState, -} from '@umbraco-cms/backoffice/auth'; +import { UMB_AUTH_CONTEXT, UMB_MODAL_APP_AUTH, type UmbUserLoginState } from '@umbraco-cms/backoffice/auth'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { filter, firstValueFrom, skip } from '@umbraco-cms/backoffice/external/rxjs'; @@ -66,11 +61,6 @@ export class UmbAppAuthController extends UmbControllerBase { throw new Error('[Fatal] Auth context is not available'); } - // Save location.href so we can redirect to it after login - if (location.href !== this.#authContext.getPostLogoutRedirectUrl()) { - window.sessionStorage.setItem(UMB_STORAGE_REDIRECT_URL, location.href); - } - // Figure out which providers are available const availableProviders = await firstValueFrom(this.#authContext.getAuthProviders(umbExtensionsRegistry)); From b6a623922fefa703e8a62d73aa52e7b62bada327 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 15:50:24 +0200 Subject: [PATCH 14/36] destroy the auth modal if repeated events occur --- .../src/apps/app/app-auth.controller.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 462e05ef6b..197c6b12be 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -123,15 +123,17 @@ export class UmbAppAuthController extends UmbControllerBase { } // Show the provider selection screen + const authModalKey = 'umbAuthModal'; const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); - modalManager.remove('umbAuthModal'); + modalManager.close(authModalKey); + modalManager.remove(authModalKey); const selected = await modalManager .open(this._host, UMB_MODAL_APP_AUTH, { data: { userLoginState, }, modal: { - key: 'umbAuthModal', + key: authModalKey, backdropBackground: this.#firstTimeLoggingIn ? 'var(--umb-auth-backdrop, url("/umbraco/backoffice/assets/umbraco_logo_white.svg") 20px 20px / 200px no-repeat, radial-gradient(circle, rgba(2,0,36,1) 0%, rgba(40,58,151,.9) 50%, rgba(0,212,255,1) 100%))' : 'var(--umb-auth-backdrop-timedout, rgba(0,0,0,0.75))', From af91f95f1c00ac39bf2e263ac010d13cd94807a5 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Apr 2024 15:54:09 +0200 Subject: [PATCH 15/36] use a timeout subject to consistently open the auth modal --- .../src/apps/app/app-auth.controller.ts | 10 ++-------- .../src/packages/core/auth/auth.context.ts | 7 ++++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 197c6b12be..982824f5c2 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -18,12 +18,7 @@ export class UmbAppAuthController extends UmbControllerBase { // Observe the user's authorization state and start the authorization flow if the user is not authorized this.observe( - context.isAuthorized.pipe( - // Skip the first since it is always false - skip(1), - // Only continue if the value is false - filter((x) => !x), - ), + context.isTimeout, () => { this.#firstTimeLoggingIn = false; this.makeAuthorizationRequest('timedOut'); @@ -125,8 +120,7 @@ export class UmbAppAuthController extends UmbControllerBase { // Show the provider selection screen const authModalKey = 'umbAuthModal'; const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); - modalManager.close(authModalKey); - modalManager.remove(authModalKey); + const selected = await modalManager .open(this._host, UMB_MODAL_APP_AUTH, { data: { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index f6496a3382..e366d31955 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -6,12 +6,16 @@ import { OpenAPI } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; -import { ReplaySubject, filter, firstValueFrom, switchMap } from '@umbraco-cms/backoffice/external/rxjs'; +import { ReplaySubject, Subject, filter, firstValueFrom, switchMap } from '@umbraco-cms/backoffice/external/rxjs'; export class UmbAuthContext extends UmbContextBase { #isAuthorized = new UmbBooleanState(false); readonly isAuthorized = this.#isAuthorized.asObservable(); + // Timeout is different from `isAuthorized` because it can occur repeatedly + #isTimeout = new Subject(); + readonly isTimeout = this.#isTimeout.asObservable(); + #isInitialized = new ReplaySubject(1); readonly isInitialized = this.#isInitialized.asObservable().pipe(filter((isInitialized) => isInitialized)); @@ -162,6 +166,7 @@ export class UmbAuthContext extends UmbContextBase { timeOut() { this.clearTokenStorage(); this.#isAuthorized.setValue(false); + this.#isTimeout.next(); } /** From 058ca6acc2cc63c6fff7805aabbf7aaf0d8d004a Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:20:56 +0200 Subject: [PATCH 16/36] add jsdoc --- .../src/packages/core/auth/auth.context.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index e366d31955..a9cc8ad18c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -6,18 +6,32 @@ import { OpenAPI } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; -import { ReplaySubject, Subject, filter, firstValueFrom, switchMap } from '@umbraco-cms/backoffice/external/rxjs'; +import { ReplaySubject, Subject, firstValueFrom, switchMap } from '@umbraco-cms/backoffice/external/rxjs'; export class UmbAuthContext extends UmbContextBase { #isAuthorized = new UmbBooleanState(false); + + /** + * Observable that emits true if the user is authorized, otherwise false. + * @remark It will only emit when the authorization state changes. + */ readonly isAuthorized = this.#isAuthorized.asObservable(); // Timeout is different from `isAuthorized` because it can occur repeatedly #isTimeout = new Subject(); + + /** + * Observable that emits when the user has timed out, i.e. the token has expired. + * This can be used to show a timeout message to the user. + * @remark It can emit multiple times if more than one request is made after the token has expired. + */ readonly isTimeout = this.#isTimeout.asObservable(); - #isInitialized = new ReplaySubject(1); - readonly isInitialized = this.#isInitialized.asObservable().pipe(filter((isInitialized) => isInitialized)); + /** + * Observable that emits true when the auth context is initialized. + * @remark It will only emit once and then complete itself. + */ + #isInitialized = new ReplaySubject(1); get authorizationSignal() { return this.#authFlow.authorizationSignal; @@ -223,11 +237,12 @@ export class UmbAuthContext extends UmbContextBase { } setInitialized() { - this.#isInitialized.next(true); + this.#isInitialized.next(); + this.#isInitialized.complete(); } getAuthProviders(extensionsRegistry: UmbBackofficeExtensionRegistry) { - return this.isInitialized.pipe( + return this.#isInitialized.pipe( switchMap(() => extensionsRegistry.byType<'authProvider', ManifestAuthProvider>('authProvider')), ); } From 4177b038fd6d42a198e6fb436738e29d3fac57da Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:23:48 +0200 Subject: [PATCH 17/36] order properties and rename isTimeout to timeoutSignal --- .../src/apps/app/app-auth.controller.ts | 2 +- .../src/packages/core/auth/auth-flow.ts | 4 ++ .../src/packages/core/auth/auth.context.ts | 40 +++++++++---------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 982824f5c2..19d7e98dc3 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -18,7 +18,7 @@ export class UmbAppAuthController extends UmbControllerBase { // Observe the user's authorization state and start the authorization flow if the user is not authorized this.observe( - context.isTimeout, + context.timeoutSignal, () => { this.#firstTimeLoggingIn = false; this.makeAuthorizationRequest('timedOut'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts index 58f547d8a8..89e4f0a1e0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts @@ -97,6 +97,10 @@ export class UmbAuthFlow { // tokens #tokenResponse?: TokenResponse; + /** + * This signal will emit when the authorization flow is complete. + * @remark It will also emit if there is an error during the authorization flow. + */ authorizationSignal = new Subject(); constructor( diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index a9cc8ad18c..a9aeae560e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -10,33 +10,13 @@ import { ReplaySubject, Subject, firstValueFrom, switchMap } from '@umbraco-cms/ export class UmbAuthContext extends UmbContextBase { #isAuthorized = new UmbBooleanState(false); - - /** - * Observable that emits true if the user is authorized, otherwise false. - * @remark It will only emit when the authorization state changes. - */ - readonly isAuthorized = this.#isAuthorized.asObservable(); - // Timeout is different from `isAuthorized` because it can occur repeatedly #isTimeout = new Subject(); - - /** - * Observable that emits when the user has timed out, i.e. the token has expired. - * This can be used to show a timeout message to the user. - * @remark It can emit multiple times if more than one request is made after the token has expired. - */ - readonly isTimeout = this.#isTimeout.asObservable(); - /** * Observable that emits true when the auth context is initialized. * @remark It will only emit once and then complete itself. */ #isInitialized = new ReplaySubject(1); - - get authorizationSignal() { - return this.#authFlow.authorizationSignal; - } - #isBypassed = false; #serverUrl; #backofficePath; @@ -45,6 +25,26 @@ export class UmbAuthContext extends UmbContextBase { #authWindowProxy?: WindowProxy | null; #previousAuthUrl?: string; + /** + * Observable that emits true if the user is authorized, otherwise false. + * @remark It will only emit when the authorization state changes. + */ + readonly isAuthorized = this.#isAuthorized.asObservable(); + + /** + * Observable that acts as a signal and emits when the user has timed out, i.e. the token has expired. + * This can be used to show a timeout message to the user. + * @remark It can emit multiple times if more than one request is made after the token has expired. + */ + readonly timeoutSignal = this.#isTimeout.asObservable(); + + /** + * Observable that acts as a signal for when the authorization state changes. + */ + get authorizationSignal() { + return this.#authFlow.authorizationSignal; + } + constructor(host: UmbControllerHost, serverUrl: string, backofficePath: string, isBypassed: boolean) { super(host, UMB_AUTH_CONTEXT); this.#isBypassed = isBypassed; From a8f299d8a88f903fa1f2f86aa530db3e34b26145 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:24:07 +0200 Subject: [PATCH 18/36] remove console log --- src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index a9aeae560e..b36f832037 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -74,7 +74,6 @@ export class UmbAuthContext extends UmbContextBase { } async #onStorageEvent(evt: StorageEvent) { - console.log('[AuthContext] Storage event', evt); if (evt.key === UMB_STORAGE_TOKEN_RESPONSE_NAME) { // Close any open auth windows this.#authWindowProxy?.close(); From 7caa2d30e22f879bf741dc39299256b419212ef3 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:27:59 +0200 Subject: [PATCH 19/36] add login hints --- .../packages/core/auth/modals/umb-app-auth-modal.element.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index 5338fb397e..be1ec0a969 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -49,9 +49,9 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement { + private onSubmit = async (providerName: string, loginHint?: string) => { const authContext = await this.getContext(UMB_AUTH_CONTEXT); - await authContext.makeAuthorizationRequest(providerName); + await authContext.makeAuthorizationRequest(providerName, false, loginHint); const isAuthed = authContext.getIsAuthorized(); this.value = { success: isAuthed }; From 244ce6a9daa0149a6e7a4a4a2dbb2f1929f413e3 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:42:59 +0200 Subject: [PATCH 20/36] let the manifest decide a few things about the popup --- .../src/packages/core/auth/auth.context.ts | 20 ++++++++++++------- .../auth-provider-default.element.ts | 11 +++++----- .../auth/modals/umb-app-auth-modal.element.ts | 11 +++++++--- .../src/packages/core/auth/types.ts | 9 ++++++++- .../models/auth-provider.model.ts | 15 ++++++++++++++ 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index b36f832037..3ce24cd508 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -87,23 +87,29 @@ export class UmbAuthContext extends UmbContextBase { /** * Initiates the login flow. * @param identityProvider The provider to use for login. Default is 'Umbraco'. + * @param redirect If true, the user will be redirected to the login page. * @param usernameHint The username hint to use for login. + * @param manifest The manifest for the registered provider. */ - async makeAuthorizationRequest(identityProvider = 'Umbraco', redirect?: boolean, usernameHint?: string) { + async makeAuthorizationRequest( + identityProvider = 'Umbraco', + redirect?: boolean, + usernameHint?: string, + manifest?: ManifestAuthProvider, + ) { const redirectUrl = await this.#authFlow.makeAuthorizationRequest(identityProvider, usernameHint); if (redirect) { location.href = redirectUrl; return; } - const popupTarget = 'umbracoAuthPopup'; + const popupTarget = manifest?.meta?.behavior?.popupTarget ?? 'umbracoAuthPopup'; + const popupFeatures = + manifest?.meta?.behavior?.popupFeatures ?? + 'width=600,height=600,menubar=no,location=no,resizable=yes,scrollbars=yes,status=no,toolbar=no'; if (!this.#authWindowProxy || this.#authWindowProxy.closed) { - this.#authWindowProxy = window.open( - redirectUrl, - popupTarget, - 'width=600,height=600,menubar=no,location=no,resizable=yes,scrollbars=yes,status=no,toolbar=no', - ); + this.#authWindowProxy = window.open(redirectUrl, popupTarget, popupFeatures); } else if (this.#previousAuthUrl !== redirectUrl) { this.#authWindowProxy = window.open(redirectUrl, popupTarget); this.#authWindowProxy?.focus(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts index c6c10745c1..eaa9cfe801 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/components/auth-provider-default.element.ts @@ -1,5 +1,5 @@ import type { ManifestAuthProvider } from '../../extension-registry/models/index.js'; -import type { UmbAuthProviderDefaultProps } from '../types.js'; +import type { UmbAuthProviderDefaultProps, UmbUserLoginState } from '../types.js'; import { UmbLitElement } from '../../lit-element/lit-element.element.js'; import { UmbTextStyles } from '../../style/index.js'; import { css, customElement, html, nothing, property } from '@umbraco-cms/backoffice/external/lit'; @@ -7,10 +7,11 @@ import { css, customElement, html, nothing, property } from '@umbraco-cms/backof @customElement('umb-auth-provider-default') export class UmbAuthProviderDefaultElement extends UmbLitElement implements UmbAuthProviderDefaultProps { @property({ attribute: false }) - manifest!: ManifestAuthProvider; - + userLoginState?: UmbUserLoginState | undefined; @property({ attribute: false }) - onSubmit!: (providerName: string, loginHint?: string) => void; + manifest!: ManifestAuthProvider; + @property({ attribute: false }) + onSubmit!: (manifestOrProviderName: string | ManifestAuthProvider, loginHint?: string) => void; connectedCallback(): void { super.connectedCallback(); @@ -21,7 +22,7 @@ export class UmbAuthProviderDefaultElement extends UmbLitElement implements UmbA return html` this.onSubmit(this.manifest.forProviderName)} + @click=${() => this.onSubmit(this.manifest)} id="auth-provider-button" .label=${this.manifest.meta?.label ?? this.manifest.forProviderName} .look=${this.manifest.meta?.defaultView?.look ?? 'outline'} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index be1ec0a969..3f136123cc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -1,6 +1,8 @@ +import type { ManifestAuthProvider } from '../../extension-registry/models/auth-provider.model.js'; import { UmbModalBaseElement } from '../../modal/index.js'; import { UmbTextStyles } from '../../style/text-style.style.js'; import { UMB_AUTH_CONTEXT } from '../auth.context.token.js'; +import type { UmbAuthProviderDefaultProps } from '../types.js'; import type { UmbModalAppAuthConfig, UmbModalAppAuthValue } from './umb-app-auth-modal.token.js'; import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit'; @@ -9,7 +11,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement { + private onSubmit = async (providerOrManifest: string | ManifestAuthProvider, loginHint?: string) => { const authContext = await this.getContext(UMB_AUTH_CONTEXT); - await authContext.makeAuthorizationRequest(providerName, false, loginHint); + const manifest = typeof providerOrManifest === 'string' ? undefined : providerOrManifest; + const providerName = + typeof providerOrManifest === 'string' ? providerOrManifest : providerOrManifest.forProviderName; + await authContext.makeAuthorizationRequest(providerName, false, loginHint, manifest); const isAuthed = authContext.getIsAuthorized(); this.value = { success: isAuthed }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts index 7074b49380..9ee412c6f1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts @@ -21,6 +21,13 @@ export interface UmbAuthProviderDefaultProps { * Callback that is called when the user selects a provider. * @param providerName The name of the provider that the user selected. * @param loginHint The login hint to use for login if available. + * @deprecated Use the manifest parameter instead. */ - onSubmit: (providerName: string, loginHint?: string) => void; + onSubmit(providerName: string, loginHint?: string): void; + + /** + * Callback that is called when the user selects a provider. + * @param manifest The manifest of the provider that the user selected. + */ + onSubmit(manifest: ManifestAuthProvider): void; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/auth-provider.model.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/auth-provider.model.ts index 116be49a73..85771d103b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/auth-provider.model.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/auth-provider.model.ts @@ -65,6 +65,21 @@ export interface MetaAuthProvider { * @default false */ autoRedirect?: boolean; + + /** + * The target of the popup that is opened when the user logs in. + * @default 'umbracoAuthPopup' + * @remarks This is the name of the window that is opened when the user logs in, use `_blank` to open in a new tab. + */ + popupTarget?: string; + + /** + * The features of the popup that is opened when the user logs in. + * @default 'width=600,height=600,menubar=no,location=no,resizable=yes,scrollbars=yes,status=no,toolbar=no' + * @remarks This is the features of the window that is opened when the user logs in. + * @seehref https://developer.mozilla.org/en-US/docs/Web/API/Window/open#features + */ + popupFeatures?: string; }; /** From a0528197faf5da1342aca845637684516c7af3e5 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:45:29 +0200 Subject: [PATCH 21/36] add loginhint to second impl --- src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts index 9ee412c6f1..b8b01291d6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/types.ts @@ -29,5 +29,5 @@ export interface UmbAuthProviderDefaultProps { * Callback that is called when the user selects a provider. * @param manifest The manifest of the provider that the user selected. */ - onSubmit(manifest: ManifestAuthProvider): void; + onSubmit(manifest: ManifestAuthProvider, loginHint?: string): void; } From 90baa782deb18d9b1fae9a63737cead0d670a7bf Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:45:34 +0200 Subject: [PATCH 22/36] add a try/catch --- .../auth/modals/umb-app-auth-modal.element.ts | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index 3f136123cc..3245dee788 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -52,18 +52,28 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement { - const authContext = await this.getContext(UMB_AUTH_CONTEXT); - const manifest = typeof providerOrManifest === 'string' ? undefined : providerOrManifest; - const providerName = - typeof providerOrManifest === 'string' ? providerOrManifest : providerOrManifest.forProviderName; - await authContext.makeAuthorizationRequest(providerName, false, loginHint, manifest); + try { + const authContext = await this.getContext(UMB_AUTH_CONTEXT); + if (!authContext) { + throw new Error('Auth context not available'); + } - const isAuthed = authContext.getIsAuthorized(); - this.value = { success: isAuthed }; - if (isAuthed) { - this._submitModal(); - } else { - this._error = 'Failed to authenticate'; + const manifest = typeof providerOrManifest === 'string' ? undefined : providerOrManifest; + const providerName = + typeof providerOrManifest === 'string' ? providerOrManifest : providerOrManifest.forProviderName; + + await authContext.makeAuthorizationRequest(providerName, false, loginHint, manifest); + + const isAuthed = authContext.getIsAuthorized(); + this.value = { success: isAuthed }; + if (isAuthed) { + this._submitModal(); + } else { + this._error = 'Failed to authenticate'; + } + } catch (error) { + console.error('[AuthModal] Error submitting auth request', error); + this._error = error instanceof Error ? error.message : 'Unknown error (see console)'; } }; From 8ef4b98dba47a3138951a26a25fc44abdb69fb00 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:58:34 +0200 Subject: [PATCH 23/36] do not use the "oauth_complete" page before its implemented on the backend --- .../src/packages/core/auth/auth.context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index 3ce24cd508..a0839a2712 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -253,7 +253,7 @@ export class UmbAuthContext extends UmbContextBase { } getRedirectUrl() { - return `${window.location.origin}${this.#backofficePath}oauth_complete`; + return `${window.location.origin}${this.#backofficePath}`; } getPostLogoutRedirectUrl() { From 5fd24e6a86248047ee7984151629b7007a74d49f Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Mon, 29 Apr 2024 16:51:11 +0200 Subject: [PATCH 24/36] use oauth_complete to handle authentication process --- .../src/apps/app/app.element.ts | 24 ++++++++----------- .../src/packages/core/auth/auth.context.ts | 2 +- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 0f258c022d..64720dea81 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -60,8 +60,16 @@ export class UmbAppElement extends UmbLitElement { }, { path: 'oauth_complete', - resolve: async () => { - // Complete oauth + component: () => import('./app-error.element.js'), + setup: (component) => { + const searchParams = new URLSearchParams(window.location.search); + const hasCode = searchParams.has('code'); + (component as UmbAppErrorElement).errorHeadline = 'Umbraco Authorization'; + (component as UmbAppErrorElement).errorMessage = hasCode + ? 'Authorization completed. You may now close this window.' + : 'Authorization failed. Please try again.'; + + // Complete the authorization request this.#authContext?.completeAuthorizationRequest(); }, }, @@ -180,18 +188,6 @@ export class UmbAppElement extends UmbLitElement { } #redirect() { - /** If there is a ?code parameter in the url, then we are in the middle of the oauth flow - * and we need to complete the login (the authorization notifier will redirect after this is done - * essentially hitting this method again) - * @deprecated This is a legacy way of handling oauth flow, it should be removed once the backend starts sending the oauth_complete route - * TODO: Remove this when the backend sends the oauth_complete route - */ - const queryParams = new URLSearchParams(window.location.search); - if (queryParams.has('code')) { - this.#authContext?.completeAuthorizationRequest(); - return; - } - switch (this.#serverConnection?.getStatus()) { case RuntimeLevelModel.INSTALL: history.replaceState(null, '', 'install'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index a0839a2712..3ce24cd508 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -253,7 +253,7 @@ export class UmbAuthContext extends UmbContextBase { } getRedirectUrl() { - return `${window.location.origin}${this.#backofficePath}`; + return `${window.location.origin}${this.#backofficePath}oauth_complete`; } getPostLogoutRedirectUrl() { From 827e41c0aaeaa241edbbc0e8a7afb57176f88cb5 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:33:00 +0200 Subject: [PATCH 25/36] add case if there is no opener --- src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 64720dea81..7912440acb 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -70,7 +70,12 @@ export class UmbAppElement extends UmbLitElement { : 'Authorization failed. Please try again.'; // Complete the authorization request - this.#authContext?.completeAuthorizationRequest(); + this.#authContext?.completeAuthorizationRequest().then(() => { + // If we don't have an opener, redirect to the root + if (!window.opener) { + history.replaceState(null, '', '/'); + } + }); }, }, { From c5efe5b49eeede2f66661383cf872572b3033f63 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:33:17 +0200 Subject: [PATCH 26/36] redirect in every case --- src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 7912440acb..a4c9e3507a 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -70,7 +70,7 @@ export class UmbAppElement extends UmbLitElement { : 'Authorization failed. Please try again.'; // Complete the authorization request - this.#authContext?.completeAuthorizationRequest().then(() => { + this.#authContext?.completeAuthorizationRequest().finally(() => { // If we don't have an opener, redirect to the root if (!window.opener) { history.replaceState(null, '', '/'); From 227e48bc9e3b6c2fb5fdbb2e3dc5f4b5ec769ad0 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:35:36 +0200 Subject: [PATCH 27/36] add docs for `oauth_complete` --- src/Umbraco.Web.UI.Client/docs/authentication.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/docs/authentication.md b/src/Umbraco.Web.UI.Client/docs/authentication.md index eaf53f619b..a713a9a19e 100644 --- a/src/Umbraco.Web.UI.Client/docs/authentication.md +++ b/src/Umbraco.Web.UI.Client/docs/authentication.md @@ -36,7 +36,9 @@ There are two ways to use this: "CMS": { "Security":{ "BackOfficeHost": "http://localhost:5173", - "AuthorizeCallbackPathName": "/" + "AuthorizeCallbackPathName": "/oauth_complete", + "AuthorizeCallbackLogoutPathName": "/logout", + "AuthorizeCallbackErrorPathName": "/error", }, }, [...] From 0329fb78f7fefcc6d550ac402c07942149119c0d Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:42:57 +0200 Subject: [PATCH 28/36] remove option 'denyLocalLogin' since packages can do that by excluding the umbraco provider in a backofficeEntryPoint --- .../src/apps/app/app-auth.controller.ts | 16 +++------------- .../models/auth-provider.model.ts | 6 ------ 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index e7a6354505..0d77446596 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -65,7 +65,7 @@ export class UmbAppAuthController extends UmbControllerBase { // If the user is timed out, we can show the login modal directly if (userLoginState === 'timedOut') { - const selected = await this.#showLoginModal(userLoginState, availableProviders); + const selected = await this.#showLoginModal(userLoginState); if (!selected) { return false; @@ -93,7 +93,7 @@ export class UmbAppAuthController extends UmbControllerBase { } // Show the provider selection screen - const selected = await this.#showLoginModal(userLoginState, availableProviders); + const selected = await this.#showLoginModal(userLoginState); if (!selected) { return false; @@ -102,21 +102,11 @@ export class UmbAppAuthController extends UmbControllerBase { return this.#updateState(); } - async #showLoginModal( - userLoginState: UmbUserLoginState, - availableProviders: Array, - ): Promise { + async #showLoginModal(userLoginState: UmbUserLoginState): Promise { if (!this.#authContext) { throw new Error('[Fatal] Auth context is not available'); } - // Check if any provider denies local login - const denyLocalLogin = availableProviders.some((provider) => provider.meta?.behavior?.denyLocalLogin); - if (denyLocalLogin) { - // Unregister the Umbraco provider - umbExtensionsRegistry.exclude('Umb.AuthProviders.Umbraco'); - } - // Show the provider selection screen const authModalKey = 'umbAuthModal'; const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/auth-provider.model.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/auth-provider.model.ts index 85771d103b..9bd0244c57 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/auth-provider.model.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/auth-provider.model.ts @@ -54,12 +54,6 @@ export interface MetaAuthProvider { * The behavior of the provider when it is used. */ behavior?: { - /** - * If true, the Umbraco backoffice login will be disabled. - * @default false - */ - denyLocalLogin?: boolean; - /** * If true, the user will be redirected to the provider's login page immediately. * @default false From cafb606e65181a8ec3e8733f1164b80ba8519c5b Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:11:42 +0200 Subject: [PATCH 29/36] do not distinguish between layouts --- .../src/apps/app/app-auth.controller.ts | 9 ++------- .../src/apps/app/app.element.ts | 2 +- .../auth/modals/umb-app-auth-modal.element.ts | 19 +------------------ 3 files changed, 4 insertions(+), 26 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 0d77446596..ef690d37fc 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -1,14 +1,12 @@ import { UMB_AUTH_CONTEXT, UMB_MODAL_APP_AUTH, type UmbUserLoginState } from '@umbraco-cms/backoffice/auth'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { filter, firstValueFrom, skip } from '@umbraco-cms/backoffice/external/rxjs'; +import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; -import type { ManifestAuthProvider } from '@umbraco-cms/backoffice/extension-registry'; export class UmbAppAuthController extends UmbControllerBase { #authContext?: typeof UMB_AUTH_CONTEXT.TYPE; - #firstTimeLoggingIn = true; constructor(host: UmbControllerHost) { super(host); @@ -20,7 +18,6 @@ export class UmbAppAuthController extends UmbControllerBase { this.observe( context.timeoutSignal, () => { - this.#firstTimeLoggingIn = false; this.makeAuthorizationRequest('timedOut'); }, '_authState', @@ -118,9 +115,7 @@ export class UmbAppAuthController extends UmbControllerBase { }, modal: { key: authModalKey, - backdropBackground: this.#firstTimeLoggingIn - ? 'var(--umb-auth-backdrop, rgb(244, 244, 244))' - : 'var(--umb-auth-backdrop-timedout, rgba(244, 244, 244, 0.75))', + backdropBackground: 'var(--umb-auth-backdrop, rgb(244, 244, 244))', }, }) .onSubmit() diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index a4c9e3507a..8805c793c8 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -67,7 +67,7 @@ export class UmbAppElement extends UmbLitElement { (component as UmbAppErrorElement).errorHeadline = 'Umbraco Authorization'; (component as UmbAppErrorElement).errorMessage = hasCode ? 'Authorization completed. You may now close this window.' - : 'Authorization failed. Please try again.'; + : 'Authorization failed. Please close this window and try again.'; // Complete the authorization request this.#authContext?.completeAuthorizationRequest().finally(() => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index 80be46d65b..d652b7b065 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -36,7 +36,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement +
Date: Tue, 30 Apr 2024 11:37:30 +0200 Subject: [PATCH 30/36] implement a way to listen if entrypoints have been loaded --- .../extension-initializer-base.ts | 19 +++++++++++++------ .../app-entry-point-extension-initializer.ts | 2 +- ...ffice-entry-point-extension-initializer.ts | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/libs/extension-api/initializers/extension-initializer-base.ts b/src/Umbraco.Web.UI.Client/src/libs/extension-api/initializers/extension-initializer-base.ts index 39b67f29c4..d79d971922 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/extension-api/initializers/extension-initializer-base.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/extension-api/initializers/extension-initializer-base.ts @@ -4,6 +4,7 @@ import type { SpecificManifestTypeOrManifestBase } from '../types/map.types.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbElement } from '@umbraco-cms/backoffice/element-api'; import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; +import { ReplaySubject } from '@umbraco-cms/backoffice/external/rxjs'; /** * Base class for extension initializers, which are responsible for loading and unloading extensions. @@ -15,12 +16,14 @@ export abstract class UmbExtensionInitializerBase< protected host; protected extensionRegistry; #extensionMap = new Map(); + #loaded = new ReplaySubject(1); + loaded = this.#loaded.asObservable(); constructor(host: UmbElement, extensionRegistry: UmbExtensionRegistry, manifestType: Key) { super(host); this.host = host; this.extensionRegistry = extensionRegistry; - this.observe(extensionRegistry.byType(manifestType), (extensions) => { + this.observe(extensionRegistry.byType(manifestType), async (extensions) => { this.#extensionMap.forEach((existingExt) => { if (!extensions.find((b) => b.alias === existingExt.alias)) { this.unloadExtension(existingExt); @@ -28,11 +31,15 @@ export abstract class UmbExtensionInitializerBase< } }); - extensions.forEach((extension) => { - if (this.#extensionMap.has(extension.alias)) return; - this.#extensionMap.set(extension.alias, extension); - this.instantiateExtension(extension); - }); + await Promise.all( + extensions.map((extension) => { + if (this.#extensionMap.has(extension.alias)) return; + this.#extensionMap.set(extension.alias, extension); + return this.instantiateExtension(extension); + }), + ); + + this.#loaded.next(); }); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/initializers/app-entry-point-extension-initializer.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/initializers/app-entry-point-extension-initializer.ts index 877b118b79..022fb492c2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/initializers/app-entry-point-extension-initializer.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/initializers/app-entry-point-extension-initializer.ts @@ -32,7 +32,7 @@ export class UmbAppEntryPointExtensionInitializer extends UmbExtensionInitialize // If the extension has known exports, be sure to run those if (hasInitExport(moduleInstance)) { - moduleInstance.onInit(this.host, this.extensionRegistry); + await moduleInstance.onInit(this.host, this.extensionRegistry); } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/initializers/backoffice-entry-point-extension-initializer.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/initializers/backoffice-entry-point-extension-initializer.ts index 29cec8c445..b62e743da6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/initializers/backoffice-entry-point-extension-initializer.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/initializers/backoffice-entry-point-extension-initializer.ts @@ -32,7 +32,7 @@ export class UmbBackofficeEntryPointExtensionInitializer extends UmbExtensionIni // If the extension has known exports, be sure to run those if (hasInitExport(moduleInstance)) { - moduleInstance.onInit(this.host, this.extensionRegistry); + await moduleInstance.onInit(this.host, this.extensionRegistry); } } } From 4e63a1d974e91d9b7626a28fc18c282c76cd82a3 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:38:03 +0200 Subject: [PATCH 31/36] wait for entrypoints to be loaded through the extension initializer before trying to load them --- src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 8805c793c8..79cf22f85e 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -17,7 +17,7 @@ import { UmbAppEntryPointExtensionInitializer, umbExtensionsRegistry, } from '@umbraco-cms/backoffice/extension-registry'; -import { filter, first } from '@umbraco-cms/backoffice/external/rxjs'; +import { filter, first, firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; @customElement('umb-app') export class UmbAppElement extends UmbLitElement { @@ -118,7 +118,6 @@ export class UmbAppElement extends UmbLitElement { OpenAPI.BASE = window.location.origin; new UmbBundleExtensionInitializer(this, umbExtensionsRegistry); - new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry); new UUIIconRegistryEssential().attach(this); @@ -141,6 +140,8 @@ export class UmbAppElement extends UmbLitElement { // Register public extensions (login extensions) await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions(); + const initializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry); + await firstValueFrom(initializer.loaded); // Try to initialise the auth flow and get the runtime status try { From 280a149637196d0419f03d1146bfaefc83599baa Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 30 Apr 2024 16:08:10 +0200 Subject: [PATCH 32/36] add localization --- src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts | 8 ++++---- src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts | 4 +++- src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts | 4 +++- src/Umbraco.Web.UI.Client/src/assets/lang/en.ts | 4 +++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 79cf22f85e..9ec240e899 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -64,16 +64,16 @@ export class UmbAppElement extends UmbLitElement { setup: (component) => { const searchParams = new URLSearchParams(window.location.search); const hasCode = searchParams.has('code'); - (component as UmbAppErrorElement).errorHeadline = 'Umbraco Authorization'; + (component as UmbAppErrorElement).errorHeadline = this.localize.term('general_login'); (component as UmbAppErrorElement).errorMessage = hasCode - ? 'Authorization completed. You may now close this window.' - : 'Authorization failed. Please close this window and try again.'; + ? this.localize.term('errors_externalLoginSuccess') + : this.localize.term('errors_externalLoginFailed'); // Complete the authorization request this.#authContext?.completeAuthorizationRequest().finally(() => { // If we don't have an opener, redirect to the root if (!window.opener) { - history.replaceState(null, '', '/'); + //history.replaceState(null, '', ''); } }); }, diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts index a823ed5684..5db74e6d7e 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts @@ -712,7 +712,9 @@ export default { unauthorized: 'Du har ikke tilladelse til at udføre denne handling', userNotFound: 'Den angivne bruger blev ikke fundet i databasen', externalInfoNotFound: 'Serveren kunne ikke kommunikere med den eksterne loginudbyder', - externalLoginFailed: 'Serveren mislykkedes i at logge ind med den eksterne loginudbyder', + externalLoginFailed: + 'Serveren mislykkedes i at logge ind med den eksterne loginudbyder. Luk dette vindue og prøv igen.', + externalLoginSuccess: 'Du er nu logget ind. Du kan nu lukke dette vindue.', }, openidErrors: { accessDenied: 'Access denied', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index 4a2d45e517..a47a038f95 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -717,7 +717,9 @@ export default { unauthorized: 'You were not authorized before performing this action', userNotFound: 'The local user was not found in the database', externalInfoNotFound: 'The server did not succeed in communicating with the external login provider', - externalLoginFailed: 'The server failed to authorize you against the external login provider', + externalLoginFailed: + 'The server failed to authorize you against the external login provider. Please close the window and try again.', + externalLoginSuccess: 'You have successfully logged in. You may now close this window.', }, openidErrors: { accessDenied: 'Access denied', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 8a6ec29075..4d5bbcb5c4 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -727,7 +727,9 @@ export default { unauthorized: 'You were not authorized before performing this action', userNotFound: 'The local user was not found in the database', externalInfoNotFound: 'The server did not succeed in communicating with the external login provider', - externalLoginFailed: 'The server failed to authorize you against the external login provider', + externalLoginFailed: + 'The server failed to authorize you against the external login provider. Please close the window and try again.', + externalLoginSuccess: 'You have successfully logged in. You may now close this window.', }, openidErrors: { accessDenied: 'Access denied', From 7d5fc5d14fad2212a7f36d3a772b59c002a0a201 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 30 Apr 2024 16:08:57 +0200 Subject: [PATCH 33/36] hide the back button on external login --- .../src/apps/app/app-error.element.ts | 22 ++++++++++++++----- .../src/apps/app/app.element.ts | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-error.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-error.element.ts index 92027afa18..049e093ddc 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-error.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-error.element.ts @@ -32,6 +32,14 @@ export class UmbAppErrorElement extends UmbLitElement { @property() error?: unknown; + /** + * Hide the back button + * + * @attr + */ + @property({ type: Boolean, attribute: 'hide-back-button' }) + hideBackButton = false; + constructor() { super(); @@ -168,11 +176,15 @@ export class UmbAppErrorElement extends UmbLitElement {
- (location.href = '')}> + ${this.hideBackButton + ? nothing + : html` + (location.href = '')}> + `}
${this.errorHeadline ? this.errorHeadline diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 9ec240e899..598f8e2ad6 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -64,6 +64,7 @@ export class UmbAppElement extends UmbLitElement { setup: (component) => { const searchParams = new URLSearchParams(window.location.search); const hasCode = searchParams.has('code'); + (component as UmbAppErrorElement).hideBackButton = true; (component as UmbAppErrorElement).errorHeadline = this.localize.term('general_login'); (component as UmbAppErrorElement).errorMessage = hasCode ? this.localize.term('errors_externalLoginSuccess') From ddaebc8c8ab57a04e61cacf4650c55b8a79de227 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 2 May 2024 11:09:50 +0200 Subject: [PATCH 34/36] send the timeout signal even earlier if UmbAuthFlow detects that the token has expired or it failed to use the refresh token --- .../src/packages/core/auth/auth-flow.ts | 17 ++++++++++++----- .../src/packages/core/auth/auth.context.ts | 7 ++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts index 89e4f0a1e0..42073f1f9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts @@ -93,6 +93,7 @@ export class UmbAuthFlow { readonly #postLogoutRedirectUri: string; readonly #clientId: string; readonly #scope: string; + readonly #timeoutSignal; // tokens #tokenResponse?: TokenResponse; @@ -101,17 +102,19 @@ export class UmbAuthFlow { * This signal will emit when the authorization flow is complete. * @remark It will also emit if there is an error during the authorization flow. */ - authorizationSignal = new Subject(); + readonly authorizationSignal = new Subject(); constructor( openIdConnectUrl: string, redirectUri: string, postLogoutRedirectUri: string, + timeoutSignal: Subject, clientId = 'umbraco-back-office', scope = 'offline_access', ) { this.#redirectUri = redirectUri; this.#postLogoutRedirectUri = postLogoutRedirectUri; + this.#timeoutSignal = timeoutSignal; this.#clientId = clientId; this.#scope = scope; @@ -310,7 +313,8 @@ export class UmbAuthFlow { // if the refresh token is not set (maybe the provider doesn't support them) if (!this.#tokenResponse?.refreshToken) { - return Promise.resolve('Missing refreshToken.'); + this.#timeoutSignal.next(); + return Promise.reject('Missing refreshToken.'); } const request = new TokenRequest({ @@ -324,9 +328,12 @@ export class UmbAuthFlow { await this.#performTokenRequest(request); - return this.#tokenResponse - ? Promise.resolve(this.#tokenResponse.accessToken) - : Promise.reject('Missing accessToken.'); + if (!this.#tokenResponse) { + this.#timeoutSignal.next(); + return Promise.reject('Missing tokenResponse.'); + } + + return Promise.resolve(this.#tokenResponse.accessToken); } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index 3ce24cd508..f38c2d8853 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -51,7 +51,12 @@ export class UmbAuthContext extends UmbContextBase { this.#serverUrl = serverUrl; this.#backofficePath = backofficePath; - this.#authFlow = new UmbAuthFlow(serverUrl, this.getRedirectUrl(), this.getPostLogoutRedirectUrl()); + this.#authFlow = new UmbAuthFlow( + serverUrl, + this.getRedirectUrl(), + this.getPostLogoutRedirectUrl(), + this.#isTimeout, + ); // Observe the authorization signal and close the auth window this.observe( From b23e63094f7e226821167658e7af662dd4dd7525 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 2 May 2024 11:19:08 +0200 Subject: [PATCH 35/36] add extra check to prevent vanilla 'validate' request to fail on ApiErrors --- .../context/server-model-validation.context.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context.ts index 21f0d827fc..6feba69aa1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validation.context.ts @@ -60,9 +60,12 @@ export class UmbServerModelValidationContext if (!this.#isValid) { // We are missing some typing here, but we will just go wild with 'as any': [NL] const readErrorBody = (error as any).body; - Object.keys(readErrorBody.errors).forEach((path) => { - this.#serverFeedback.push({ path, messages: readErrorBody.errors[path] }); - }); + // Check if there are validation errors, since the error might be a generic ApiError + if (readErrorBody?.errors) { + Object.keys(readErrorBody.errors).forEach((path) => { + this.#serverFeedback.push({ path, messages: readErrorBody.errors[path] }); + }); + } } this.#validatePromiseResolve?.(); From adf8aa21c08cffc65a16d58ce2fa40c4598f2c1c Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 2 May 2024 11:53:09 +0200 Subject: [PATCH 36/36] do not automatically call the `signOut` method since we now handle it with the timeoutSignal --- .../src/packages/core/auth/auth-flow.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts index 42073f1f9e..3d1091854d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts @@ -173,8 +173,6 @@ export class UmbAuthFlow { const response = new TokenResponse(JSON.parse(tokenResponseJson)); if (response.isValid()) { this.#tokenResponse = response; - } else { - this.signOut(); } } } @@ -376,9 +374,8 @@ export class UmbAuthFlow { try { this.#tokenResponse = await this.#tokenHandler.performTokenRequest(this.#configuration, request); } catch (error) { - // If the token request fails, it means the refresh token is invalid, so we sign the user out. + // If the token request fails, it means the refresh token is invalid console.error('Token request error', error); - this.signOut(); } } }