Skip to content

Commit

Permalink
Authentication using OpenID (#654)
Browse files Browse the repository at this point in the history
* install app auth

* add service to handle auth

* add openid handling to general app

* move props to constructor

* update default api url to https

* add support for adjusting umbracoUrl from outside

* use origin url for redirect if empty from env

* merge origin/main

* merge origin/main

* add redirect uri support

* only check auth on firstUpdated

* merge origin/main

* fix redirect_uri

* listen for auth-success events before anything else

* save current route to restore after login

* set token function for all OpenAPI requests

* include credentials after login

* update openapi-typescript-codegen

* generate new models with bugfixes for CancelablePromise and request

* remove auth-success event

* wait with fetchServiceConfiguration until we actually need to query the server

* revert change where service configuration was delayed

* use LocalStorageBackend to save/restore token state

* improve documentation

* cleanup todos

* improve docs

* update documentation and set everything to private fields

* remove undefined

* add a token to provide server url

* add more docs

* provide the base url of the server through a token

* add more docs

* fix import

* allow to override the backoffice base url through a property

* use private modifier

* duplicate login image

* make generic error element to use as error page

* check for initialisation errors and show error page if necessary

* rename class to UmbAuthFlow

* control the notification manually with runtime status call

* add styling

* add stack to problemdetails

* forward all errors

* support problemdetails rendering

* allow passthrough without a token

* move error logic to function

* add support for BOOT_FAILED and default errors

* rename background img

* remove false character

* check for isMocking to simplify auth flow

* add support for generic ApiErrors

* make sure all errors from api controllers are ApiError or CancelError to be able to fine-tune the handling of them

* remove unused legacy method

* show notifications (for now) after session expiration

* break early on CancelErrors

* revert options argument

* remove login token after a 401 is detected

* catch api errors

* prefix class with Umb

* throw errors instead of using ProblemDetailsModel

* add TODO

* add TODO

---------

Co-authored-by: Mads Rasmussen <[email protected]>
  • Loading branch information
iOvergaard and madsrasmussen authored Apr 24, 2023
1 parent 16eddfa commit 426eb58
Show file tree
Hide file tree
Showing 49 changed files with 2,615 additions and 18,041 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Copy this to .env.local and change what you want to test.
VITE_UMBRACO_USE_MSW=on # on = turns on MSW, off = disables all mock handlers
VITE_UMBRACO_API_URL=http://localhost:11000
VITE_UMBRACO_API_URL=https://localhost:44339
VITE_UMBRACO_INSTALL_STATUS=running # running or must-install or must-upgrade
VITE_MSW_QUIET=off # on = turns off MSW console logs, off = turns on MSW console logs
VITE_UMBRACO_EXTENSION_MOCKS=off # on = turns on extension mocks, off = turns off extension mocks
76 changes: 39 additions & 37 deletions libs/backend-api/src/core/CancelablePromise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,13 @@ export interface OnCancel {
}

export class CancelablePromise<T> implements Promise<T> {
readonly [Symbol.toStringTag]!: string;

private _isResolved: boolean;
private _isRejected: boolean;
private _isCancelled: boolean;
private readonly _cancelHandlers: (() => void)[];
private readonly _promise: Promise<T>;
private _resolve?: (value: T | PromiseLike<T>) => void;
private _reject?: (reason?: any) => void;
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;

constructor(
executor: (
Expand All @@ -39,90 +37,94 @@ export class CancelablePromise<T> implements Promise<T> {
onCancel: OnCancel
) => void
) {
this._isResolved = false;
this._isRejected = false;
this._isCancelled = false;
this._cancelHandlers = [];
this._promise = new Promise<T>((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
this.#isResolved = false;
this.#isRejected = false;
this.#isCancelled = false;
this.#cancelHandlers = [];
this.#promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;

const onResolve = (value: T | PromiseLike<T>): void => {
if (this._isResolved || this._isRejected || this._isCancelled) {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this._isResolved = true;
this._resolve?.(value);
this.#isResolved = true;
this.#resolve?.(value);
};

const onReject = (reason?: any): void => {
if (this._isResolved || this._isRejected || this._isCancelled) {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this._isRejected = true;
this._reject?.(reason);
this.#isRejected = true;
this.#reject?.(reason);
};

const onCancel = (cancelHandler: () => void): void => {
if (this._isResolved || this._isRejected || this._isCancelled) {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this._cancelHandlers.push(cancelHandler);
this.#cancelHandlers.push(cancelHandler);
};

Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this._isResolved,
get: (): boolean => this.#isResolved,
});

Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this._isRejected,
get: (): boolean => this.#isRejected,
});

Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this._isCancelled,
get: (): boolean => this.#isCancelled,
});

return executor(onResolve, onReject, onCancel as OnCancel);
});
}

get [Symbol.toStringTag]() {
return "Cancellable Promise";
}

public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): Promise<TResult1 | TResult2> {
return this._promise.then(onFulfilled, onRejected);
return this.#promise.then(onFulfilled, onRejected);
}

public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
): Promise<T | TResult> {
return this._promise.catch(onRejected);
return this.#promise.catch(onRejected);
}

public finally(onFinally?: (() => void) | null): Promise<T> {
return this._promise.finally(onFinally);
return this.#promise.finally(onFinally);
}

public cancel(): void {
if (this._isResolved || this._isRejected || this._isCancelled) {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this._isCancelled = true;
if (this._cancelHandlers.length) {
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this._cancelHandlers) {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this._cancelHandlers.length = 0;
this._reject?.(new CancelError('Request aborted'));
this.#cancelHandlers.length = 0;
this.#reject?.(new CancelError('Request aborted'));
}

public get isCancelled(): boolean {
return this._isCancelled;
return this.#isCancelled;
}
}
5 changes: 3 additions & 2 deletions libs/backend-api/src/core/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Pr
};

const getRequestBody = (options: ApiRequestOptions): any => {
if (options.body) {
if (options.body !== undefined) {
if (options.mediaType?.includes('/json')) {
return JSON.stringify(options.body)
} else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) {
Expand Down Expand Up @@ -231,7 +231,8 @@ const getResponseBody = async (response: Response): Promise<any> => {
try {
const contentType = response.headers.get('Content-Type');
if (contentType) {
const isJSON = contentType.toLowerCase().startsWith('application/json');
const jsonTypes = ['application/json', 'application/problem+json']
const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type));
if (isJSON) {
return await response.json();
} else {
Expand Down
4 changes: 2 additions & 2 deletions libs/repository/data-source/data-source-response.interface.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { ProblemDetailsModel } from '@umbraco-cms/backoffice/backend-api';
import type { ApiError, CancelError } from '@umbraco-cms/backoffice/backend-api';

export interface DataSourceResponse<T = undefined> extends UmbDataSourceErrorResponse {
data?: T;
}

export interface UmbDataSourceErrorResponse {
error?: ProblemDetailsModel;
error?: ApiError | CancelError;
}
1 change: 1 addition & 0 deletions libs/resources/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './resource.controller';
export * from './serverUrl.token';
export * from './tryExecute.function';
export * from './tryExecuteAndNotify.function';
98 changes: 54 additions & 44 deletions libs/resources/resource.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
UmbNotificationOptions,
UmbNotificationContext,
UMB_NOTIFICATION_CONTEXT_TOKEN,
UmbNotificationOptions,
} from '@umbraco-cms/backoffice/notification';
import { ApiError, CancelablePromise, ProblemDetailsModel } from '@umbraco-cms/backoffice/backend-api';
import { ApiError, CancelError, CancelablePromise } from '@umbraco-cms/backoffice/backend-api';
import { UmbController, UmbControllerHostElement } from '@umbraco-cms/backoffice/controller';
import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api';
import type { DataSourceResponse } from '@umbraco-cms/backoffice/repository';
Expand Down Expand Up @@ -32,65 +32,75 @@ export class UmbResourceController extends UmbController {
this.cancel();
}

/**
* Extract the ProblemDetailsModel object from an ApiError.
*
* This assumes that all ApiErrors contain a ProblemDetailsModel object in their body.
*/
static toProblemDetailsModel(error: unknown): ProblemDetailsModel | undefined {
if (error instanceof ApiError) {
try {
const errorDetails = (
typeof error.body === 'string' ? JSON.parse(error.body) : error.body
) as ProblemDetailsModel;
return errorDetails;
} catch {
return {
title: error.name,
detail: error.message,
};
}
} else if (error instanceof Error) {
return {
title: error.name,
detail: error.message,
};
}

return undefined;
}

/**
* Base execute function with a try/catch block and return a tuple with the result and the error.
*/
static async tryExecute<T>(promise: Promise<T>): Promise<DataSourceResponse<T>> {
try {
return { data: await promise };
} catch (e) {
return { error: UmbResourceController.toProblemDetailsModel(e) };
} catch (error) {
if (error instanceof ApiError || error instanceof CancelError) {
return { error };
}

console.error('Unknown error', error);
throw new Error('Unknown error');
}
}

/**
* Wrap the {execute} function in a try/catch block and return the result.
* Wrap the {tryExecute} function in a try/catch block and return the result.
* If the executor function throws an error, then show the details in a notification.
*/
async tryExecuteAndNotify<T>(options?: UmbNotificationOptions): Promise<DataSourceResponse<T>> {
const { data, error } = await UmbResourceController.tryExecute<T>(this.#promise);

if (error) {
if (this.#notificationContext) {
this.#notificationContext?.peek('danger', {
data: {
headline: error.title ?? 'Server Error',
message: error.detail ?? 'Something went wrong',
},
...options,
});
/**
* Determine if we want to show a notification or just log the error to the console.
* If the error is not a recognizable system error (i.e. a HttpError), then we will show a notification
* with the error details using the default notification options.
*/
if (error instanceof CancelError) {
// Cancelled - do nothing
return {};
} else {
console.group('UmbResourceController');
console.error(error);
console.groupEnd();
// ApiError - body could hold a ProblemDetailsModel from the server
(error as any).body = typeof error.body === 'string' ? JSON.parse(error.body) : error.body;

// Go through the error status codes and act accordingly
switch (error.status ?? 0) {
case 401:
// Unauthorized
console.log('Unauthorized');

// TODO: Do not remove the token here but instead let whatever is listening to the event decide what to do
localStorage.removeItem('tokenResponse');

// TODO: Show a modal dialog to login either by bubbling an event to UmbAppElement or by showing a modal directly
this.#notificationContext?.peek('warning', {
data: {
headline: 'Session Expired',
message: 'Your session has expired. Please refresh the page.',
},
});
break;
default:
// Other errors
if (this.#notificationContext) {
this.#notificationContext.peek('danger', {
data: {
headline: error.body.title ?? error.name ?? 'Server Error',
message: error.body.detail ?? error.message ?? 'Something went wrong',
},
...options,
});
} else {
console.group('UmbResourceController');
console.error(error);
console.groupEnd();
}
}
}
}

Expand Down
16 changes: 16 additions & 0 deletions libs/resources/serverUrl.token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';

/**
* The base URL of the configured Umbraco server.
* If the server is local, this will be an empty string.
*
* @remarks This is the base URL of the Umbraco server, not the base URL of the backoffice.
*
* @example https://localhost:44300
* @example https://my-umbraco-site.com
* @example ''
*/
export const UMB_SERVER_URL = new UmbContextToken<string>(
'UmbServerUrl',
'The base URL of the configured Umbraco server.'
);
2 changes: 1 addition & 1 deletion libs/resources/tryExecuteAndNotify.function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { UmbNotificationOptions } from '@umbraco-cms/backoffice/notificatio
export function tryExecuteAndNotify<T>(
host: UmbControllerHostElement,
resource: Promise<T>,
options?: UmbNotificationOptions<any>
options?: UmbNotificationOptions
) {
return new UmbResourceController(host, resource).tryExecuteAndNotify<T>(options);
}
Loading

0 comments on commit 426eb58

Please sign in to comment.