diff --git a/internal/api/me.go b/internal/api/me.go new file mode 100644 index 000000000..e58574df6 --- /dev/null +++ b/internal/api/me.go @@ -0,0 +1,49 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/go-hclog" +) + +func MeHandler( + l hclog.Logger, +) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + errResp := func(httpCode int, userErrMsg, logErrMsg string, err error) { + l.Error(logErrMsg, + "method", r.Method, + "path", r.URL.Path, + "error", err, + ) + errJSON := fmt.Sprintf(`{"error": "%s"}`, userErrMsg) + http.Error(w, errJSON, httpCode) + } + + // Authorize request. + userEmail := r.Context().Value("userEmail").(string) + if userEmail == "" { + errResp( + http.StatusUnauthorized, + "No authorization information for request", + "no user email found in request context", + nil, + ) + return + } + + switch r.Method { + // The HEAD method is used to determine if the user is currently + // authenticated. + case "HEAD": + w.WriteHeader(http.StatusOK) + return + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + }) +} diff --git a/internal/cmd/commands/server/server.go b/internal/cmd/commands/server/server.go index 0f1eb6e90..c18375b1e 100644 --- a/internal/cmd/commands/server/server.go +++ b/internal/cmd/commands/server/server.go @@ -266,6 +266,7 @@ func (c *Command) Run(args []string) int { api.DraftsHandler(cfg, c.Log, algoSearch, algoWrite, goog, db)}, {"/api/v1/drafts/", api.DraftsDocumentHandler(cfg, c.Log, algoSearch, algoWrite, goog, db)}, + {"/api/v1/me", api.MeHandler(c.Log)}, {"/api/v1/me/subscriptions", api.MeSubscriptionsHandler(cfg, c.Log, goog, db)}, {"/api/v1/people", api.PeopleDataHandler(cfg, c.Log, goog)}, diff --git a/web/app/components/notification.hbs b/web/app/components/notification.hbs index b40f151e4..f428f9d58 100644 --- a/web/app/components/notification.hbs +++ b/web/app/components/notification.hbs @@ -1,14 +1,31 @@
{{#each this.flashMessages.queue as |flash|}} - + - {{flash.title}} - {{flash.message}} + + {{flash.title}} + + + {{flash.message}} + + {{#if (and flash.buttonText flash.buttonAction)}} + + {{/if}} {{/each}} diff --git a/web/app/components/notification.js b/web/app/components/notification.js deleted file mode 100644 index 518a0f769..000000000 --- a/web/app/components/notification.js +++ /dev/null @@ -1,12 +0,0 @@ -import Component from '@glimmer/component'; -import { inject as service } from '@ember/service'; -import { action } from "@ember/object"; - -export default class Notification extends Component { - @service flashMessages; - - @action - dismiss() { - // Left empty as flash messages disappear on click - } -} diff --git a/web/app/components/notification.ts b/web/app/components/notification.ts new file mode 100644 index 000000000..a2c1ed953 --- /dev/null +++ b/web/app/components/notification.ts @@ -0,0 +1,7 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import FlashMessageService from "ember-cli-flash/services/flash-messages"; + +export default class Notification extends Component { + @service declare flashMessages: FlashMessageService; +} diff --git a/web/app/routes/authenticated.ts b/web/app/routes/authenticated.ts index a9df67cf7..8dd438b7a 100644 --- a/web/app/routes/authenticated.ts +++ b/web/app/routes/authenticated.ts @@ -10,6 +10,7 @@ export default class AuthenticatedRoute extends Route { async afterModel(): Promise { await this.authenticatedUser.loadInfo.perform(); + void this.session.pollForExpiredAuth.perform(); } async beforeModel(transition: any): Promise { diff --git a/web/app/services/fetch.ts b/web/app/services/fetch.ts index 39172d1eb..fff0a02f0 100644 --- a/web/app/services/fetch.ts +++ b/web/app/services/fetch.ts @@ -14,7 +14,7 @@ interface FetchOptions { export default class FetchService extends Service { @service declare session: SessionService; - async fetch(url: string, options: FetchOptions = {}) { + async fetch(url: string, options: FetchOptions = {}, isPollCall = false) { // Add the Google access token in a header (for auth) if the URL starts with // a frontslash, which will only target the application backend. if (Array.from(url)[0] == "/") { @@ -27,7 +27,20 @@ export default class FetchService extends Service { try { const resp = await fetch(url, options); + // if it's a poll call, tell the SessionService if the response was a 401 + if (isPollCall) { + this.session.pollResponseIs401 = resp.status === 401; + } + if (!resp.ok) { + if (resp.status === 401) { + if (isPollCall) { + // handle poll-call failures via the session service + return; + } else { + this.session.invalidate(); + } + } throw new Error(`Bad response: ${resp.statusText}`); } diff --git a/web/app/services/session.ts b/web/app/services/session.ts index ce2538e7a..7ad2ec4d1 100644 --- a/web/app/services/session.ts +++ b/web/app/services/session.ts @@ -2,6 +2,12 @@ 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 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 FetchService from "./fetch"; export const REDIRECT_STORAGE_KEY = "hermes.redirectTarget"; @@ -16,6 +22,75 @@ export function isJSON(str: string) { export default class SessionService extends EmberSimpleAuthSessionService { @service declare router: RouterService; + @service declare fetch: FetchService; + @service declare session: SessionService; + @service declare flashMessages: FlashMessageService; + + /** + * 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; + + /** + * Whether the last poll response was a 401. + * Updated by the fetch service on every pollCall. + */ + @tracked pollResponseIs401 = false; + + /** + * A persistent task that periodically checks if the user's + * session has expired, and shows a flash message if it has. + * Kicked off by the Authenticated route. + */ + pollForExpiredAuth = keepLatestTask(async () => { + await simpleTimeout(Ember.testing ? 100 : 10000); + + this.fetch.fetch( + "/api/v1/me", + { + method: "HEAD", + }, + true + ); + + let isLoggedIn = await this.requireAuthentication(null, () => {}); + + if (this.pollResponseIs401 || !isLoggedIn) { + this.tokenIsValid = false; + } + + if (this.tokenIsValid) { + this.preventReauthenticationMessage = false; + } else if (!this.preventReauthenticationMessage) { + 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; + }, + }); + } + + this.pollForExpiredAuth.perform(); + }); // 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 // diff --git a/web/app/styles/components/notification.scss b/web/app/styles/components/notification.scss index a8e0c27f7..e3a1c5d76 100644 --- a/web/app/styles/components/notification.scss +++ b/web/app/styles/components/notification.scss @@ -1,24 +1,24 @@ .notifications-container { - @apply fixed; - z-index: 20; - bottom: 8px; - right: 8px; + @apply fixed z-20 bottom-6 right-6; } -.notification { - // Animation example taken from: https://github.com/adopted-ember-addons/ember-cli-flash#animated-example - opacity: 0; - - transition: all 700ms cubic-bezier(0.68, -0.55, 0.265, 1.55); - margin: 16px; - - &.active { +@keyframes notificationIn { + from { + opacity: 0; + transform: translateX(8px); + } + to { opacity: 1; - @apply left-2; + transform: translateX(0); + } +} + +.notification { + animation: notificationIn 700ms cubic-bezier(0.68, -0.55, 0.265, 1.55) + forwards; - &.exiting { - opacity: 0; - @apply left-0; - } + &.exiting { + animation: notificationIn 300ms cubic-bezier(0.68, -0.55, 0.265, 1.55) + reverse forwards; } } diff --git a/web/app/utils/simple-timeout.ts b/web/app/utils/simple-timeout.ts new file mode 100644 index 000000000..4ce4c877a --- /dev/null +++ b/web/app/utils/simple-timeout.ts @@ -0,0 +1,14 @@ +/** + * A timeout function for polling tasks. + * Not registered with Ember's runloop + * (unlike ember-concurrency's timeout helper), + * so it doesn't hang in acceptance tests. + * + * See: https://ember-concurrency.com/docs/testing-debugging + */ + +export default function simpleTimeout(timeout: number) { + return new Promise((resolve) => { + setTimeout(resolve, timeout); + }); +} diff --git a/web/mirage/config.ts b/web/mirage/config.ts index 181063b8a..5bb606424 100644 --- a/web/mirage/config.ts +++ b/web/mirage/config.ts @@ -10,6 +10,22 @@ export default function (mirageConfig) { routes() { this.namespace = "api/v1"; + /************************************************************************* + * + * HEAD requests + * + *************************************************************************/ + + this.head("/me", (schema, _request) => { + let isLoggedIn = schema.db.mes[0].isLoggedIn; + + if (isLoggedIn) { + return new Response(200, {}); + } else { + return new Response(401, {}); + } + }); + /************************************************************************* * * POST requests diff --git a/web/tests/acceptance/application-test.ts b/web/tests/acceptance/application-test.ts new file mode 100644 index 000000000..f62892845 --- /dev/null +++ b/web/tests/acceptance/application-test.ts @@ -0,0 +1,117 @@ +import { click, teardownContext, visit, waitFor } from "@ember/test-helpers"; +import { setupApplicationTest } from "ember-qunit"; +import { module, test } from "qunit"; +import { authenticateSession } from "ember-simple-auth/test-support"; +import { MirageTestContext, setupMirage } from "ember-cli-mirage/test-support"; +import SessionService from "hermes/services/session"; + +module("Acceptance | application", function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + interface ApplicationTestContext extends MirageTestContext { + session: SessionService; + } + + hooks.beforeEach(function () { + this.set("session", this.owner.lookup("service:session")); + }); + + test("a message shows when the front-end auth token expires", async function (this: ApplicationTestContext, assert) { + await authenticateSession({}); + + await visit("/"); + + assert + .dom("[data-test-flash-notification]") + .doesNotExist("no flash notification when session is valid"); + + await this.session.invalidate(); + + await waitFor("[data-test-flash-notification]"); + + assert + .dom("[data-test-flash-notification]") + .exists("flash notification is shown when session is invalid"); + + assert + .dom("[data-test-flash-notification-title]") + .hasText("Login token expired"); + + assert + .dom("[data-test-flash-notification-description]") + .hasText("Please reauthenticate to keep using Hermes."); + + assert + .dom("[data-test-flash-notification-button]") + .hasText("Authenticate with Google"); + /** + * FIXME: Investigate unresolved promises + * + * For reasons not yet clear, this test has unresolved promises + * that prevent it from completing naturally. Because of this, + * we handle teardown manually. + * + */ + teardownContext(this); + }); + + test("a message shows when the back-end auth token expires", async function (this: ApplicationTestContext, assert) { + await authenticateSession({}); + + await visit("/"); + + assert + .dom("[data-test-flash-notification]") + .doesNotExist("no flash notification when session is valid"); + + this.server.db.mes[0] = this.server.create("me", { isLoggedIn: false }); + + await waitFor("[data-test-flash-notification]"); + + assert + .dom("[data-test-flash-notification]") + .exists("flash notification is shown when session is invalid"); + + /** + * FIXME: Investigate unresolved promises + * + * For reasons not yet clear, this test has unresolved promises + * that prevent it from completing naturally. Because of this, + * we handle teardown manually. + * + */ + teardownContext(this); + }); + + test("the authorize button works as expected", async function (this: ApplicationTestContext, assert) { + let authCount = 0; + + await authenticateSession({}); + + this.session.authenticate = () => { + authCount++; + }; + + await visit("/"); + await this.session.invalidate(); + await waitFor("[data-test-flash-notification]"); + await click("[data-test-flash-notification-button]"); + + assert + .dom("[data-test-flash-notification]") + .doesNotExist("flash notification is dismissed on buttonClick"); + + assert.equal(authCount, 1, "session.authenticate() was called"); + + /** + * FIXME: Investigate unresolved promises + * + * For reasons not yet clear, this test has unresolved promises + * that prevent it from completing naturally. Because of this, + * we handle teardown manually. + * + */ + teardownContext(this); + }); +}); diff --git a/web/types/ember-simple-auth/services/index.d.ts b/web/types/ember-simple-auth/services/index.d.ts index 6084465b7..dc4c4e47a 100644 --- a/web/types/ember-simple-auth/services/index.d.ts +++ b/web/types/ember-simple-auth/services/index.d.ts @@ -1,4 +1,7 @@ -declare module 'ember-simple-auth/test-support' { - import { SessionAuthenticatedData } from 'ember-simple-auth/services/session'; - export function authenticateSession(responseFromApi: SessionAuthenticatedData): void; +declare module "ember-simple-auth/test-support" { + import { SessionAuthenticatedData } from "ember-simple-auth/services/session"; + export function authenticateSession( + responseFromApi: SessionAuthenticatedData + ): Promise; + export function invalidateSession(): Promise; } diff --git a/web/types/ember-simple-auth/services/session.d.ts b/web/types/ember-simple-auth/services/session.d.ts index c45ce6384..f03513ba0 100644 --- a/web/types/ember-simple-auth/services/session.d.ts +++ b/web/types/ember-simple-auth/services/session.d.ts @@ -17,7 +17,7 @@ declare module "ember-simple-auth/services/session" { authenticate(...args: any[]): RSVP.Promise; invalidate(...args: any): RSVP.Promise; requireAuthentication( - transition: Transition, + transition: Transition | null, routeOrCallback: string | function ): RSVP.Promise; prohibitAuthentication(routeOrCallback: string | function): RSVP.Promise;