Merge remote-tracking branch 'origin/main' into feature/trashed-context
This commit is contained in:
@@ -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",
|
||||
},
|
||||
},
|
||||
[...]
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
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';
|
||||
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);
|
||||
@@ -23,14 +16,8 @@ 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.timeoutSignal,
|
||||
() => {
|
||||
this.#firstTimeLoggingIn = false;
|
||||
this.makeAuthorizationRequest('timedOut');
|
||||
},
|
||||
'_authState',
|
||||
@@ -66,11 +53,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));
|
||||
|
||||
@@ -80,7 +62,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;
|
||||
@@ -91,7 +73,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, true);
|
||||
return this.#updateState();
|
||||
}
|
||||
|
||||
@@ -103,12 +85,12 @@ export class UmbAppAuthController extends UmbControllerBase {
|
||||
|
||||
if (redirectProvider) {
|
||||
// Redirect directly to the provider
|
||||
this.#authContext.makeAuthorizationRequest(redirectProvider.forProviderName);
|
||||
await this.#authContext.makeAuthorizationRequest(redirectProvider.forProviderName, true);
|
||||
return this.#updateState();
|
||||
}
|
||||
|
||||
// Show the provider selection screen
|
||||
const selected = await this.#showLoginModal(userLoginState, availableProviders);
|
||||
const selected = await this.#showLoginModal(userLoginState);
|
||||
|
||||
if (!selected) {
|
||||
return false;
|
||||
@@ -117,45 +99,32 @@ export class UmbAppAuthController extends UmbControllerBase {
|
||||
return this.#updateState();
|
||||
}
|
||||
|
||||
async #showLoginModal(
|
||||
userLoginState: UmbUserLoginState,
|
||||
availableProviders: Array<ManifestAuthProvider>,
|
||||
): Promise<boolean> {
|
||||
async #showLoginModal(userLoginState: UmbUserLoginState): Promise<boolean> {
|
||||
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.unregister('Umb.AuthProviders.Umbraco');
|
||||
}
|
||||
|
||||
// Show the provider selection screen
|
||||
const authModalKey = 'umbAuthModal';
|
||||
const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
|
||||
modalManager.remove('umbAuthModal');
|
||||
|
||||
const selected = await modalManager
|
||||
.open(this._host, UMB_MODAL_APP_AUTH, {
|
||||
data: {
|
||||
userLoginState,
|
||||
},
|
||||
modal: {
|
||||
key: 'umbAuthModal',
|
||||
backdropBackground: this.#firstTimeLoggingIn
|
||||
? 'var(--umb-auth-backdrop, rgb(244, 244, 244))'
|
||||
: 'var(--umb-auth-backdrop-timedout, rgba(244, 244, 244, 0.75))',
|
||||
key: authModalKey,
|
||||
backdropBackground: 'var(--umb-auth-backdrop, rgb(244, 244, 244))',
|
||||
},
|
||||
})
|
||||
.onSubmit()
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!selected?.providerName) {
|
||||
if (!selected?.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#authContext.makeAuthorizationRequest(selected.providerName, selected.loginHint);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -164,9 +133,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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
<div id="container" class="uui-text">
|
||||
<uui-box id="box" headline-variant="h1">
|
||||
<uui-button
|
||||
slot="header-actions"
|
||||
label=${this.localize.term('general_back')}
|
||||
look="secondary"
|
||||
@click=${() => (location.href = '')}></uui-button>
|
||||
${this.hideBackButton
|
||||
? nothing
|
||||
: html`
|
||||
<uui-button
|
||||
slot="header-actions"
|
||||
label=${this.localize.term('general_back')}
|
||||
look="secondary"
|
||||
@click=${() => (location.href = '')}></uui-button>
|
||||
`}
|
||||
<div slot="headline">
|
||||
${this.errorHeadline
|
||||
? this.errorHeadline
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
UmbAppEntryPointExtensionInitializer,
|
||||
umbExtensionsRegistry,
|
||||
} from '@umbraco-cms/backoffice/extension-registry';
|
||||
import { filter, first, firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
|
||||
@customElement('umb-app')
|
||||
export class UmbAppElement extends UmbLitElement {
|
||||
@@ -57,6 +58,27 @@ export class UmbAppElement extends UmbLitElement {
|
||||
path: 'install',
|
||||
component: () => import('../installer/installer.element.js'),
|
||||
},
|
||||
{
|
||||
path: 'oauth_complete',
|
||||
component: () => import('./app-error.element.js'),
|
||||
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')
|
||||
: 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, '', '');
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'upgrade',
|
||||
component: () => import('../upgrader/upgrader.element.js'),
|
||||
@@ -67,6 +89,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, '', '');
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -86,7 +119,6 @@ export class UmbAppElement extends UmbLitElement {
|
||||
OpenAPI.BASE = window.location.origin;
|
||||
|
||||
new UmbBundleExtensionInitializer(this, umbExtensionsRegistry);
|
||||
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
|
||||
|
||||
new UUIIconRegistryEssential().attach(this);
|
||||
|
||||
@@ -109,6 +141,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 {
|
||||
@@ -161,15 +195,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)
|
||||
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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,4 +18,5 @@ export {
|
||||
filter,
|
||||
startWith,
|
||||
skip,
|
||||
first,
|
||||
} from 'rxjs';
|
||||
|
||||
@@ -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<void>(1);
|
||||
loaded = this.#loaded.asObservable();
|
||||
|
||||
constructor(host: UmbElement, extensionRegistry: UmbExtensionRegistry<T>, manifestType: Key) {
|
||||
super(host);
|
||||
this.host = host;
|
||||
this.extensionRegistry = extensionRegistry;
|
||||
this.observe(extensionRegistry.byType<Key, T>(manifestType), (extensions) => {
|
||||
this.observe(extensionRegistry.byType<Key, T>(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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* License for the specific language governing permissions and limitations under
|
||||
* the License.
|
||||
*/
|
||||
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,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
} from '@umbraco-cms/backoffice/external/openid';
|
||||
import { Subject } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
|
||||
const requestor = new FetchRequestor();
|
||||
|
||||
@@ -92,19 +93,28 @@ export class UmbAuthFlow {
|
||||
readonly #postLogoutRedirectUri: string;
|
||||
readonly #clientId: string;
|
||||
readonly #scope: string;
|
||||
readonly #timeoutSignal;
|
||||
|
||||
// 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.
|
||||
*/
|
||||
readonly authorizationSignal = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
openIdConnectUrl: string,
|
||||
redirectUri: string,
|
||||
postLogoutRedirectUri: string,
|
||||
timeoutSignal: Subject<void>,
|
||||
clientId = 'umbraco-back-office',
|
||||
scope = 'offline_access',
|
||||
) {
|
||||
this.#redirectUri = redirectUri;
|
||||
this.#postLogoutRedirectUri = postLogoutRedirectUri;
|
||||
this.#timeoutSignal = timeoutSignal;
|
||||
this.#clientId = clientId;
|
||||
this.#scope = scope;
|
||||
|
||||
@@ -118,11 +128,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(),
|
||||
window.location,
|
||||
);
|
||||
this.#authorizationHandler = new RedirectRequestHandler(this.#storageBackend, new UmbNoHashQueryStringUtils());
|
||||
|
||||
// set notifier to deliver responses
|
||||
this.#authorizationHandler.setAuthorizationNotifier(this.#notifier);
|
||||
@@ -131,6 +137,7 @@ export class UmbAuthFlow {
|
||||
this.#notifier.setAuthorizationListener(async (request, response, error) => {
|
||||
if (error) {
|
||||
console.error('Authorization error', error);
|
||||
this.authorizationSignal.next();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -143,16 +150,9 @@ 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);
|
||||
}
|
||||
|
||||
this.authorizationSignal.next();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,8 +173,6 @@ export class UmbAuthFlow {
|
||||
const response = new TokenResponse(JSON.parse(tokenResponseJson));
|
||||
if (response.isValid()) {
|
||||
this.#tokenResponse = response;
|
||||
} else {
|
||||
this.signOut();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +194,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.
|
||||
@@ -223,7 +221,7 @@ export class UmbAuthFlow {
|
||||
true,
|
||||
);
|
||||
|
||||
this.#authorizationHandler.performAuthorizationRequest(this.#configuration, request);
|
||||
return this.#authorizationHandler.performAuthorizationRequest(this.#configuration, request);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,9 +309,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();
|
||||
this.#timeoutSignal.next();
|
||||
return Promise.reject('Missing refreshToken.');
|
||||
}
|
||||
|
||||
@@ -328,9 +326,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,128 @@
|
||||
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, Subject, firstValueFrom, switchMap } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
|
||||
export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
|
||||
#isAuthorized = new UmbBooleanState<boolean>(false);
|
||||
readonly isAuthorized = this.#isAuthorized.asObservable();
|
||||
|
||||
#isInitialized = new ReplaySubject<boolean>(1);
|
||||
readonly isInitialized = this.#isInitialized.asObservable().pipe(filter((isInitialized) => isInitialized));
|
||||
|
||||
// Timeout is different from `isAuthorized` because it can occur repeatedly
|
||||
#isTimeout = new Subject<void>();
|
||||
/**
|
||||
* Observable that emits true when the auth context is initialized.
|
||||
* @remark It will only emit once and then complete itself.
|
||||
*/
|
||||
#isInitialized = new ReplaySubject<void>(1);
|
||||
#isBypassed = false;
|
||||
#serverUrl;
|
||||
#backofficePath;
|
||||
#authFlow;
|
||||
|
||||
#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;
|
||||
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(
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
makeAuthorizationRequest(identityProvider = 'Umbraco', usernameHint?: string) {
|
||||
return this.#authFlow.makeAuthorizationRequest(identityProvider, usernameHint);
|
||||
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 = 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, popupFeatures);
|
||||
} else if (this.#previousAuthUrl !== redirectUrl) {
|
||||
this.#authWindowProxy = window.open(redirectUrl, popupTarget);
|
||||
this.#authWindowProxy?.focus();
|
||||
}
|
||||
|
||||
this.#previousAuthUrl = redirectUrl;
|
||||
|
||||
return firstValueFrom(this.authorizationSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +190,7 @@ export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
|
||||
timeOut() {
|
||||
this.clearTokenStorage();
|
||||
this.#isAuthorized.setValue(false);
|
||||
this.#isTimeout.next();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,17 +247,18 @@ export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
|
||||
}
|
||||
|
||||
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')),
|
||||
);
|
||||
}
|
||||
|
||||
getRedirectUrl() {
|
||||
return `${window.location.origin}${this.#backofficePath}`;
|
||||
return `${window.location.origin}${this.#backofficePath}oauth_complete`;
|
||||
}
|
||||
|
||||
getPostLogoutRedirectUrl() {
|
||||
|
||||
@@ -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`
|
||||
<uui-button
|
||||
type="button"
|
||||
@click=${() => 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'}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
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 } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
|
||||
@customElement('umb-app-auth-modal')
|
||||
export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthConfig, UmbModalAppAuthValue> {
|
||||
get props() {
|
||||
@state()
|
||||
private _error?: string;
|
||||
|
||||
get props(): UmbAuthProviderDefaultProps {
|
||||
return {
|
||||
userLoginState: this.data?.userLoginState ?? 'loggingIn',
|
||||
onSubmit: this.onSubmit,
|
||||
onSubmit: this.onSubmit.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +36,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthC
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div id="layout" modal-on-top=${this.data?.userLoginState === 'timedOut' ? 'true' : 'false'}>
|
||||
<div id="layout">
|
||||
<img
|
||||
id="logo-on-background"
|
||||
src="/umbraco/backoffice/assets/umbraco_logo_blue.svg"
|
||||
@@ -63,6 +69,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthC
|
||||
<header id="header">
|
||||
<h1 id="greeting">${this.headline}</h1>
|
||||
</header>
|
||||
${this._error ? html`<p style="margin-top:0;color:red">${this._error}</p>` : ''}
|
||||
${this.data?.userLoginState === 'timedOut'
|
||||
? html`<p style="margin-top:0">${this.localize.term('login_timeout')}</p>`
|
||||
: ''}
|
||||
@@ -77,9 +84,30 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthC
|
||||
`;
|
||||
}
|
||||
|
||||
private onSubmit = (providerName: string, loginHint?: string) => {
|
||||
this.value = { providerName, loginHint };
|
||||
this._submitModal();
|
||||
private onSubmit = async (providerOrManifest: string | ManifestAuthProvider, loginHint?: string) => {
|
||||
try {
|
||||
const authContext = await this.getContext(UMB_AUTH_CONTEXT);
|
||||
if (!authContext) {
|
||||
throw new Error('Auth context not available');
|
||||
}
|
||||
|
||||
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)';
|
||||
}
|
||||
};
|
||||
|
||||
static styles = [
|
||||
@@ -182,23 +210,6 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement<UmbModalAppAuthC
|
||||
gap: var(--uui-size-space-5);
|
||||
}
|
||||
|
||||
#layout[modal-on-top='true'] {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 327px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--uui-color-border);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[modal-on-top='true'] #graphic {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[modal-on-top='true'] #greeting {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#graphic {
|
||||
display: none;
|
||||
|
||||
@@ -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<UmbModalAppAuthConfig, UmbModalAppAuthValue>('Umb.Modal.AppAuth', {
|
||||
|
||||
@@ -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, loginHint?: string): void;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,17 +54,26 @@ 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
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
Reference in New Issue
Block a user