github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/services/token.js (about) 1 import Service, { inject as service } from '@ember/service'; 2 import { computed } from '@ember/object'; 3 import { alias, reads } from '@ember/object/computed'; 4 import { getOwner } from '@ember/application'; 5 import { assign } from '@ember/polyfills'; 6 import { task, timeout } from 'ember-concurrency'; 7 import queryString from 'query-string'; 8 import fetch from 'nomad-ui/utils/fetch'; 9 import classic from 'ember-classic-decorator'; 10 import moment from 'moment'; 11 12 const MINUTES_LEFT_AT_WARNING = 10; 13 const EXPIRY_NOTIFICATION_TITLE = 'Your access is about to expire'; 14 @classic 15 export default class TokenService extends Service { 16 @service store; 17 @service system; 18 @service router; 19 @service flashMessages; 20 21 aclEnabled = true; 22 23 tokenNotFound = false; 24 25 @computed 26 get secret() { 27 return window.localStorage.nomadTokenSecret; 28 } 29 30 set secret(value) { 31 if (value == null) { 32 window.localStorage.removeItem('nomadTokenSecret'); 33 } else { 34 window.localStorage.nomadTokenSecret = value; 35 } 36 } 37 38 @task(function* () { 39 const TokenAdapter = getOwner(this).lookup('adapter:token'); 40 try { 41 var token = yield TokenAdapter.findSelf(); 42 this.secret = token.secret; 43 return token; 44 } catch (e) { 45 const errors = e.errors ? e.errors.mapBy('detail') : []; 46 if (errors.find((error) => error === 'ACL support disabled')) { 47 this.set('aclEnabled', false); 48 } 49 if (errors.find((error) => error === 'ACL token not found')) { 50 this.set('tokenNotFound', true); 51 } 52 return null; 53 } 54 }) 55 fetchSelfToken; 56 57 @reads('fetchSelfToken.lastSuccessful.value') selfToken; 58 59 async exchangeOneTimeToken(oneTimeToken) { 60 const TokenAdapter = getOwner(this).lookup('adapter:token'); 61 62 const token = await TokenAdapter.exchangeOneTimeToken(oneTimeToken); 63 this.secret = token.secret; 64 } 65 66 @task(function* () { 67 try { 68 if (this.selfToken) { 69 return yield this.selfToken.get('policies'); 70 } else { 71 let policy = yield this.store.findRecord('policy', 'anonymous'); 72 return [policy]; 73 } 74 } catch (e) { 75 return []; 76 } 77 }) 78 fetchSelfTokenPolicies; 79 80 @alias('fetchSelfTokenPolicies.lastSuccessful.value') selfTokenPolicies; 81 82 @task(function* () { 83 yield this.fetchSelfToken.perform(); 84 this.kickoffTokenTTLMonitoring(); 85 if (this.aclEnabled) { 86 yield this.fetchSelfTokenPolicies.perform(); 87 } 88 }) 89 fetchSelfTokenAndPolicies; 90 91 // All non Ember Data requests should go through authorizedRequest. 92 // However, the request that gets regions falls into that category. 93 // This authorizedRawRequest is necessary in order to fetch data 94 // with the guarantee of a token but without the automatic region 95 // param since the region cannot be known at this point. 96 authorizedRawRequest(url, options = {}) { 97 const credentials = 'include'; 98 const headers = {}; 99 const token = this.secret; 100 101 if (token) { 102 headers['X-Nomad-Token'] = token; 103 } 104 105 return fetch(url, assign(options, { headers, credentials })); 106 } 107 108 authorizedRequest(url, options) { 109 if (this.get('system.shouldIncludeRegion')) { 110 const region = this.get('system.activeRegion'); 111 if (region && url.indexOf('region=') === -1) { 112 url = addParams(url, { region }); 113 } 114 } 115 116 return this.authorizedRawRequest(url, options); 117 } 118 119 reset() { 120 this.fetchSelfToken.cancelAll({ resetState: true }); 121 this.fetchSelfTokenPolicies.cancelAll({ resetState: true }); 122 this.fetchSelfTokenAndPolicies.cancelAll({ resetState: true }); 123 this.monitorTokenTime.cancelAll({ resetState: true }); 124 window.localStorage.removeItem('nomadOIDCNonce'); 125 window.localStorage.removeItem('nomadOIDCAuthMethod'); 126 } 127 128 kickoffTokenTTLMonitoring() { 129 this.monitorTokenTime.perform(); 130 } 131 132 @task(function* () { 133 while (this.selfToken?.expirationTime) { 134 const diff = new Date(this.selfToken.expirationTime) - new Date(); 135 // Let the user know at the 10 minute mark, 136 // or any time they refresh with under 10 minutes left 137 if (diff < 1000 * 60 * MINUTES_LEFT_AT_WARNING) { 138 const existingNotification = this.flashMessages.queue?.find( 139 (m) => m.title === EXPIRY_NOTIFICATION_TITLE 140 ); 141 // For the sake of updating the "time left" message, we keep running the task down to the moment of expiration 142 if (diff > 0) { 143 if (existingNotification) { 144 existingNotification.set( 145 'message', 146 `Your token access expires ${moment( 147 this.selfToken.expirationTime 148 ).fromNow()}` 149 ); 150 } else { 151 if (!this.expirationNotificationDismissed) { 152 this.flashMessages.add({ 153 title: EXPIRY_NOTIFICATION_TITLE, 154 message: `Your token access expires ${moment( 155 this.selfToken.expirationTime 156 ).fromNow()}`, 157 type: 'error', 158 destroyOnClick: false, 159 sticky: true, 160 customCloseAction: () => { 161 this.set('expirationNotificationDismissed', true); 162 }, 163 customAction: { 164 label: 'Re-authenticate', 165 action: () => { 166 this.router.transitionTo('settings.tokens'); 167 }, 168 }, 169 }); 170 } 171 } 172 } else { 173 if (existingNotification) { 174 existingNotification.setProperties({ 175 title: 'Your access has expired', 176 message: `Your token will need to be re-authenticated`, 177 }); 178 } 179 this.monitorTokenTime.cancelAll(); // Stop updating time left after expiration 180 } 181 } 182 yield timeout(1000); 183 } 184 }) 185 monitorTokenTime; 186 } 187 188 function addParams(url, params) { 189 const paramsStr = queryString.stringify(params); 190 const delimiter = url.includes('?') ? '&' : '?'; 191 return `${url}${delimiter}${paramsStr}`; 192 }