diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebe5fdcb..ae364629f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## v1.1.0 Release Notes +### Added the ability to configure auto sign-out after a period of inactivity, per user domain, if Job Manager is pointing at a CromIAM. + ### Labels that are set to be hidden on the Job List page will not be shown at the top of the Job Details page. #### The full list of labels associated with a job has been moved to a new 'Labels' tab. diff --git a/api/jobs.yaml b/api/jobs.yaml index 145aa20d1..b36e6b389 100644 --- a/api/jobs.yaml +++ b/api/jobs.yaml @@ -259,6 +259,14 @@ definitions: description: OAuth 2.0 requested scopes items: type: string + forcedLogoutDomains: + type: array + description: Domains where a forced logout will happen after an interval of inactivity has passed. + items: + type: string + forcedLogoutTime: + type: integer + description: Number of milliseconds for the interval of inactivity. DisplayField: description: Description of a display field diff --git a/servers/cromwell/README.md b/servers/cromwell/README.md index fc2c7c447..0f76cf2a9 100644 --- a/servers/cromwell/README.md +++ b/servers/cromwell/README.md @@ -119,8 +119,8 @@ Thin shim around [`cromwell`](https://github.com/broadinstitute/cromwell). - If the field is `editable`, then `fieldType` is required. - If the field is `editable`, then `filterable` will be ignored. -- (Required, CaaS only) Configure fields to display - - **Note:** If you want to use use Job Manager against Cromwell-as-a-Service, which is using SAM/Google OAuth for authZ/authN, the `capabilities_config.json` must also include some extra fields, as well as proper scopes, which are shown as below: +- (Required, CromIAM only) Configure fields to display + - **Note:** If you want to use use Job Manager against CromIAM, which is using SAM/Google OAuth for authZ/authN, the `capabilities_config.json` must also include some extra fields, as well as proper scopes, which are shown as below: ```json { "displayFields": [ @@ -188,6 +188,81 @@ Thin shim around [`cromwell`](https://github.com/broadinstitute/cromwell). } ``` +- (Required, CromIAM with automatic signout) Configure fields to display + - **Note:** If you want to use use Job Manager against CromIAM and you want inactive users to be signed out after a specific interval of time, the `capabilities_config.json` must also include some extra fields, which are shown as below: +```json +{ + "displayFields": [ + { + "field": "id", + "display": "Workflow ID" + }, + { + "field": "name", + "display": "Name", + "filterable": true + }, + { + "field": "status", + "display": "Status" + }, + { + "field": "submission", + "display": "Submitted", + "fieldType": "date" + }, + { + "field": "labels.label", + "display": "Label", + "fieldType": "text", + "editable": true, + "bulkEditable": true + }, + { + "field": "labels.flag", + "display": "Flag", + "editable": true, + "bulkEditable": true, + "fieldType": "list", + "validFieldValues": [ + "archive", + "follow-up" + ] + }, + { + "field": "labels.comment", + "display": "Comment", + "fieldType": "text", + "editable": true + } + ], + "commonLabels": [ + "id", + "name", + "label", + "comment", + "flag" + ], + "queryExtensions": [ + "hideArchived" + ], + "authentication": { + "isRequired": true, + "scopes": [ + "openid", + "email", + "profile" + ], + "forcedLogoutDomains": [ + "foo.bar" + ], + "forcedLogoutTime": 20000000 + } +} +``` + - The `forcedLogoutDomains` setting is an array of user domains where this should apply. + - The `forcedLogoutTime` is the amount of inactive time (in milliseconds) that will trigger an automatic sign-out. + - Link docker compose - **Note:** You may have completed this already if following the Job Manager [Development instructions](../../README.md#Development) - Symbolically link the cromwell docker compose file depending on your `CROMWELL_URL`. For Cromwell-as-a-Service, e.g. `https://cromwell.caas-dev.broadinstitute.org/api/workflows/v1`, use `cromwell-caas-compose.yaml` otherwise use `cromwell-instance-compose.yaml`, e.g: diff --git a/ui/src/app/core/auth.service.ts b/ui/src/app/core/auth.service.ts index 24e428f19..275577aac 100644 --- a/ui/src/app/core/auth.service.ts +++ b/ui/src/app/core/auth.service.ts @@ -4,6 +4,7 @@ import {MatSnackBar} from "@angular/material"; import {CapabilitiesService} from './capabilities.service'; import {ConfigLoaderService} from "../../environments/config-loader.service"; +import {Observable} from "rxjs"; declare const gapi: any; @@ -15,6 +16,12 @@ export class AuthService { public authToken: string; public userId: string; public userEmail: string; + public userDomain: string; + public forcedLogoutDomains: string[]; + private logoutTimer: number; + private warningTimer: number; + public logoutInterval: number; + readonly WARNING_INTERVAL = 10000; private initAuth(scopes: string[]): Promise { const clientId = this.configLoader.getEnvironmentConfigSynchronous()['clientId']; @@ -34,11 +41,17 @@ export class AuthService { this.authToken = user.getAuthResponse().access_token; this.userId = user.getId(); this.userEmail = user.getBasicProfile().getEmail(); + this.userDomain = user.getHostedDomain(); this.authenticated.next(true); + + if (this.forcedLogoutDomains && this.forcedLogoutDomains.includes(this.userDomain)) { + this.setUpEventListeners(); + } } else { this.authToken = undefined; this.userId = undefined; this.userEmail = undefined; + this.userDomain = undefined; this.authenticated.next(false); } } @@ -50,6 +63,12 @@ export class AuthService { if (!capabilities.authentication || !capabilities.authentication.isRequired) { return; } + + if (capabilities.authentication.forcedLogoutDomains && capabilities.authentication.forcedLogoutTime && capabilities.authentication.forcedLogoutTime > (this.WARNING_INTERVAL * 2)) { + this.forcedLogoutDomains = capabilities.authentication.forcedLogoutDomains; + this.logoutInterval = capabilities.authentication.forcedLogoutTime; + } + this.initAuthPromise = new Promise( (resolve, reject) => { gapi.load('client:auth2', { callback: () => this.initAuth(capabilities.authentication.scopes) @@ -83,7 +102,7 @@ export class AuthService { }) } - public signIn(): Promise { + public signIn(): Promise { return new Promise( (resolve, reject) => { gapi.auth2.getAuthInstance().signIn() .then(user => resolve(user)) @@ -91,12 +110,58 @@ export class AuthService { }); } - public signOut(): Promise { - const auth2 = gapi.auth2.getAuthInstance(); - return auth2.signOut(); + public signOut(): Promise { + return new Promise( (resolve, reject) => { + gapi.auth2.getAuthInstance().signOut() + .then(user => resolve(user)) + .catch(error => reject(error)) + }); + } + + private revokeToken(): Promise { + return new Promise( (resolve, reject) => { + gapi.auth2.getAuthInstance().disconnect() + .then(user => resolve(user)) + .catch(error => reject(error)) + }); } private handleError(error): void { this.snackBar.open('An error occurred: ' + error); } + + private setUpEventListeners(): void { + const mouseWheelStream = Observable.fromEvent(window, "mousewheel"); + mouseWheelStream.subscribe(() => this.resetTimers()); + + const mouseDownStream = Observable.fromEvent(window, "mousedown"); + mouseDownStream.subscribe(() => this.resetTimers()); + + const mouseMoveStream = Observable.fromEvent(window, "mousemove"); + mouseMoveStream.subscribe(() => this.resetTimers()); + + const keyDownStream = Observable.fromEvent(window, "keydown"); + keyDownStream.subscribe(() => this.resetTimers()); + + const keyUpStream = Observable.fromEvent(window, "keyup"); + keyUpStream.subscribe(() => this.resetTimers()); + + this.resetTimers(); + } + + public resetTimers(): void { + window.clearTimeout(this.logoutTimer); + window.clearTimeout(this.warningTimer); + this.snackBar.dismiss(); + + this.warningTimer = window.setTimeout(() => { + this.snackBar.open('You are about to be logged out due to inactivity'); + }, this.logoutInterval - this.WARNING_INTERVAL); + + this.logoutTimer = window.setTimeout(() => { + this.revokeToken().then(() => { + window.location.reload(); + }); + }, this.logoutInterval); + } } diff --git a/ui/src/app/sign-in/sign-in.component.ts b/ui/src/app/sign-in/sign-in.component.ts index 9b9587cea..fbb2cd47d 100644 --- a/ui/src/app/sign-in/sign-in.component.ts +++ b/ui/src/app/sign-in/sign-in.component.ts @@ -1,5 +1,5 @@ import {Component, OnInit, ViewContainerRef} from '@angular/core'; -import {MatSnackBar, MatSnackBarConfig} from '@angular/material' +import {MatSnackBar} from '@angular/material' import {ActivatedRoute, Router} from '@angular/router'; import {AuthService} from '../core/auth.service'; @@ -20,7 +20,9 @@ export class SignInComponent implements OnInit { let returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/'; this.authService.authenticated.subscribe( (authenticated) => { if (authenticated) { - this.router.navigateByUrl(returnUrl); + this.router.navigateByUrl(returnUrl).then(() => { + this.authService.resetTimers(); + }); } }); }