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

Poll for expired session #82

Merged
merged 28 commits into from
Apr 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
887b717
Add fetch to tsconfig
jeffdaley Mar 8, 2023
5712506
Fix type errors
jeffdaley Mar 8, 2023
b009947
Show reauthentication toast when logged out
jeffdaley Mar 8, 2023
b044898
Add test; add `basicTimeout`
jeffdaley Mar 8, 2023
dc76e7f
Set up basic 401 handler
jeffdaley Mar 9, 2023
5d9790b
Move variable out of class
jeffdaley Mar 10, 2023
04a7b82
Merge branch 'jeffdaley/redirect' into jeffdaley/google-pinger
jeffdaley Mar 10, 2023
67da9fd
Update Mirage's `me` handling; Update tests
jeffdaley Mar 10, 2023
047b776
Merge branch 'jeffdaley/fetch' into jeffdaley/google-pinger
jeffdaley Mar 10, 2023
f98893c
Add `isPollCall` argument to fetch
jeffdaley Mar 10, 2023
ca6014c
Add `me` to Mirage; Update tests
jeffdaley Mar 10, 2023
891cc65
Add `me` to Mirage
jeffdaley Mar 10, 2023
4de89da
Merge branch 'jeffdaley/mirage-me' of https://github.com/hashicorp-fo…
jeffdaley Mar 10, 2023
124594b
Merge branch 'jeffdaley/mirage-me' into jeffdaley/google-pinger
jeffdaley Mar 10, 2023
aa7a9dd
Revert `create('me')` changes
jeffdaley Mar 10, 2023
359916c
Merge branch 'main' into jeffdaley/google-pinger
jeffdaley Mar 13, 2023
47d0509
Merge branch 'main' into jeffdaley/google-pinger
jeffdaley Mar 14, 2023
09de392
Merge branch 'main' into jeffdaley/google-pinger
jeffdaley Mar 20, 2023
e00f8b1
Merge branch 'main' into jeffdaley/google-pinger
jeffdaley Mar 21, 2023
644a2b1
Add /me endpoint to determine if the user is currently authenticated
jfreda Mar 28, 2023
635feff
Revert stub endpoint
jeffdaley Mar 28, 2023
051ab08
Merge branch 'main' into jeffdaley/google-pinger
jeffdaley Mar 28, 2023
93f75b4
Merge remote-tracking branch 'origin/jfreda/add-me-endpoint' into jef…
jeffdaley Mar 28, 2023
7ac24fe
Update endpoint
jeffdaley Mar 28, 2023
8e907a9
Cleanup; documentation
jeffdaley Mar 28, 2023
63f8272
Merge branch 'main' into jeffdaley/google-pinger
jeffdaley Mar 31, 2023
513319f
Fix merge error
jeffdaley Mar 31, 2023
7c778cb
Remove redundant headers
jeffdaley Apr 5, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions internal/api/me.go
Original file line number Diff line number Diff line change
@@ -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
}
})
}
1 change: 1 addition & 0 deletions internal/cmd/commands/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down
25 changes: 21 additions & 4 deletions web/app/components/notification.hbs
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
<div class="notifications-container">
{{#each this.flashMessages.queue as |flash|}}
<FlashMessage @flash={{flash}} class="notification" as |component flash|>
<FlashMessage
data-test-flash-notification
@flash={{flash}}
class="notification"
as |component flash close|
>
<Hds::Toast
@color={{flash.type}}
@icon={{flash.icon}}
@onDismiss={{this.dismiss}}
@onDismiss={{close}}
Comment on lines -7 to +12
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used to pass in a no-op based on the now-outdated idea that dismissOnClick is always true.

as |T|
>
<T.Title>{{flash.title}}</T.Title>
<T.Description>{{flash.message}}</T.Description>
<T.Title data-test-flash-notification-title>
{{flash.title}}
</T.Title>
<T.Description data-test-flash-notification-description>
{{flash.message}}
</T.Description>
{{#if (and flash.buttonText flash.buttonAction)}}
<T.Button
data-test-flash-notification-button
@text={{flash.buttonText}}
@icon={{flash.buttonIcon}}
{{on "click" flash.buttonAction}}
/>
{{/if}}
</Hds::Toast>
</FlashMessage>
{{/each}}
Expand Down
12 changes: 0 additions & 12 deletions web/app/components/notification.js

This file was deleted.

7 changes: 7 additions & 0 deletions web/app/components/notification.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions web/app/routes/authenticated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default class AuthenticatedRoute extends Route {

async afterModel(): Promise<void> {
await this.authenticatedUser.loadInfo.perform();
void this.session.pollForExpiredAuth.perform();
}

async beforeModel(transition: any): Promise<void> {
Expand Down
15 changes: 14 additions & 1 deletion web/app/services/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] == "/") {
Expand All @@ -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}`);
}

Expand Down
75 changes: 75 additions & 0 deletions web/app/services/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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
//
Expand Down
34 changes: 17 additions & 17 deletions web/app/styles/components/notification.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
14 changes: 14 additions & 0 deletions web/app/utils/simple-timeout.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
16 changes: 16 additions & 0 deletions web/mirage/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading