Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve pollForExpiredAuth and reauthenticate UX #141

Merged
merged 14 commits into from
May 17, 2023
2 changes: 1 addition & 1 deletion web/app/components/document/sidebar.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -579,4 +579,4 @@
</:default>
</Document::Modal>
{{/if}}
{{/in-element}}
{{/in-element}}
1 change: 1 addition & 0 deletions web/app/components/notification.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
{{#each this.flashMessages.queue as |flash|}}
<FlashMessage
data-test-flash-notification
data-test-flash-notification-type={{flash.type}}
@flash={{flash}}
class="notification"
as |component flash close|
Expand Down
12 changes: 7 additions & 5 deletions web/app/controllers/authenticate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Controller from "@ember/controller";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import SessionService from "hermes/services/session";
import { dropTask } from "ember-concurrency";

export default class AuthenticateController extends Controller {
@service declare session: SessionService;
Expand All @@ -10,11 +10,13 @@ export default class AuthenticateController extends Controller {
return new Date().getFullYear();
}

@action protected authenticate(): void {
this.session.authenticate("authenticator:torii", "google-oauth2-bearer");
}
protected authenticate = dropTask(async () => {
await this.session.authenticate(
"authenticator:torii",
"google-oauth2-bearer"
);
});
}

declare module "@ember/controller" {
interface Registry {
authenticate: AuthenticateController;
Expand Down
6 changes: 3 additions & 3 deletions web/app/services/authenticated-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ export default class AuthenticatedUserService extends Service {

/**
* Loads the user's info from the Google API.
* Called by the `authenticated` route on load.
* Ensures `authenticatedUser.info` is always defined.
* On error, will bubble up to the application route.
* Called by `session.handleAuthentication` and `authenticated.afterModel`.
* Ensures `authenticatedUser.info` is always defined and up-to-date
* in any route that needs it. On error, bubbles up to the application route.
*/
loadInfo = task(async () => {
try {
Expand Down
181 changes: 127 additions & 54 deletions web/app/services/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { inject as service } from "@ember/service";
import RouterService from "@ember/routing/router-service";
import EmberSimpleAuthSessionService from "ember-simple-auth/services/session";
import window from "ember-window-mock";
import { keepLatestTask } from "ember-concurrency";
import { dropTask, keepLatestTask, timeout } from "ember-concurrency";
import FlashMessageService from "ember-cli-flash/services/flash-messages";
import Ember from "ember";
import { tracked } from "@glimmer/tracking";
import simpleTimeout from "hermes/utils/simple-timeout";
import ConfigService from "hermes/services/config";
import FetchService from "./fetch";
import AuthenticatedUserService from "./authenticated-user";
import { capitalize } from "@ember/string";
import FlashObject from "ember-cli-flash/flash/object";

export const REDIRECT_STORAGE_KEY = "hermes.redirectTarget";

Expand All @@ -27,26 +30,43 @@ export default class SessionService extends EmberSimpleAuthSessionService {
@service declare fetch: FetchService;
@service declare session: SessionService;
@service declare flashMessages: FlashMessageService;
@service declare authenticatedUser: AuthenticatedUserService;

/**
* Whether the current session is valid.
* Set false if our poll response is 401, and when the
* user requires authentication with EmberSimpleAuth.
*/
@tracked tokenIsValid = true;

/**
* Whether the service should show a reauthentication message.
* True when the user has dismissed a previous re-auth message.
*/
@tracked preventReauthenticationMessage = false;
@tracked preventReauthMessage = false;

/**
* Whether the last poll response was a 401.
* Updated by the fetch service on every pollCall.
*/
@tracked pollResponseIs401 = false;

/**
* The flash object for the reauthentication message.
* Partly dictates if we poll the back end for a 401.
* Removed when the user successfully reauthenticates.
*/
@tracked reauthFlashMessage: FlashObject | null = null;

/**
* Whether the app is configured to use Okta.
* Dictates reauthButton text and behavior.
* Determines whether we poll the back end for a 401
* while the reauthentication message is shown.
*/
get isUsingOkta(): boolean {
return this.configSvc.config.skip_google_auth;
}

/**
* A persistent task that periodically checks if the user's
* session has expired, and shows a flash message if it has.
Expand All @@ -55,75 +75,128 @@ export default class SessionService extends EmberSimpleAuthSessionService {
pollForExpiredAuth = keepLatestTask(async () => {
await simpleTimeout(Ember.testing ? 100 : 10000);

this.fetch.fetch(
"/api/v1/me",
{
method: "HEAD",
},
true
);

if (!this.configSvc.config.skip_google_auth) {
let isLoggedIn = await this.requireAuthentication(null, () => {});
// Make a HEAD request to the back end.
// On 401, the fetch service will set `this.pollResponseIs401` true.
await this.fetch.fetch("/api/v1/me", { method: "HEAD" }, true);

if (this.isUsingOkta) {
this.tokenIsValid = !this.pollResponseIs401;
} else {
let isLoggedIn = this.requireAuthentication(null, () => {});
if (this.pollResponseIs401 || !isLoggedIn) {
this.tokenIsValid = false;
}
} else {
this.tokenIsValid = !this.pollResponseIs401;
}

if (this.tokenIsValid) {
this.preventReauthenticationMessage = false;
} else if (!this.preventReauthenticationMessage) {
if (!this.configSvc.config.skip_google_auth) {
this.flashMessages.add({
title: "Login token expired",
message: "Please reauthenticate to keep using Hermes.",
type: "warning",
sticky: true,
destroyOnClick: false,
preventDuplicates: true,
buttonText: "Authenticate with Google",
buttonIcon: "google",
buttonAction: () => {
this.authenticate("authenticator:torii", "google-oauth2-bearer");
this.flashMessages.clearMessages();
},
onDestroy: () => {
this.preventReauthenticationMessage = true;
},
});
} else {
this.flashMessages.add({
title: "Session expired",
message: "Please reauthenticate to keep using Hermes.",
type: "warning",
sticky: true,
destroyOnClick: false,
preventDuplicates: true,
buttonText: "Authenticate with Okta",
buttonIcon: "okta",
buttonAction: () => {
// Reload to redirect to Okta login.
window.location.reload();
this.flashMessages.clearMessages();
},
onDestroy: () => {
this.preventReauthenticationMessage = true;
},
});
this.preventReauthMessage = false;

// In case the user reauthenticates while the message is shown,
// e.g., in another tab, destroy the message.
if (this.reauthFlashMessage) {
this.reauthFlashMessage.destroyMessage();
}
} else if (!this.preventReauthMessage) {
this.showReauthMessage(
"Session expired",
"Please reauthenticate to keep using Hermes.",
"warning",
() => {
this.preventReauthMessage = true;
}
);
}

// Restart this very task.
this.pollForExpiredAuth.perform();
});

/**
* Triggers a flash message with a button to reauthenticate.
* Used when the user's session has expired, or when the user
* unsuccessfully attempts to reauthenticate.
* Functions in accordance with the `skip_google_auth` config.
*/
private showReauthMessage(
title: string,
message: string,
type: "warning" | "critical",
onDestroy?: () => void
) {
const buttonIcon = this.isUsingOkta ? "okta" : "google";

const buttonText = `Authenticate with ${capitalize(buttonIcon)}`;

this.reauthFlashMessage = this.flashMessages
.add({
title,
message,
type,
sticky: true,
destroyOnClick: false,
preventDuplicates: true,
buttonText,
buttonIcon,
buttonAction: async () => await this.reauthenticate.perform(),
onDestroy,
})
.getFlashObject();
}

/**
* Makes an attempt to reauthenticate the user. Triggered by the button in the
* "session expired" flash message. On re-auth, shows a success message
* and resets the locally tracked parameters. On failure, shows a "critical"
* error message with a button to retry.
*/
protected reauthenticate = dropTask(async () => {
try {
if (this.isUsingOkta) {
// Reload to redirect to Okta login.
window.location.reload();
} else {
await this.authenticate("authenticator:torii", "google-oauth2-bearer");
}

this.reauthFlashMessage?.destroyMessage();

// Wait a bit to show the success message.
await timeout(Ember.testing ? 0 : 1000);

this.flashMessages.add({
title: "Login successful",
message: `Welcome back${
this.authenticatedUser.info.given_name
? `, ${this.authenticatedUser.info.given_name}`
: ""
}!`,
type: "success",
timeout: 1500,
extendedTimeout: 1000,
destroyOnClick: true,
});

this.preventReauthMessage = false;
} catch (error: unknown) {
this.reauthFlashMessage?.destroyMessage();
this.showReauthMessage("Login failed", error as string, "critical");
}
});

// ember-simple-auth only uses a cookie to track redirect target if you're using fastboot, otherwise it keeps track of the redirect target as a parameter on the session service. See the source here: https://github.com/mainmatter/ember-simple-auth/blob/a7e583cf4d04d6ebc96b198a8fa6dde7445abf0e/packages/ember-simple-auth/addon/-internals/routing.js#L33-L50
//
// Because we redirect as part of the authentication flow, the parameter storing the transition gets reset. Instead, we keep track of the redirectTarget in browser sessionStorage and override the handleAuthentication method as recommended by ember-simple-auth.

handleAuthentication(routeAfterAuthentication: string) {
if (this.authenticatedUser.info) {
/**
* This will be true when reauthenticating via the "token expired" message.
* Since we already have cached userInfo, we don't need to await it.
*/
void this.authenticatedUser.loadInfo.perform();
void this.pollForExpiredAuth.perform();
}

let redirectStorageValue =
window.sessionStorage.getItem(REDIRECT_STORAGE_KEY) ||
window.localStorage.getItem(REDIRECT_STORAGE_KEY);
Expand Down
2 changes: 1 addition & 1 deletion web/app/templates/authenticate.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
@text="Authenticate with Google"
@size="large"
@icon="google"
{{on "click" this.authenticate}}
{{on "click" (perform this.authenticate)}}
/>

</Hds::Card::Container>
Expand Down
Loading