diff --git a/changelog/27479.txt b/changelog/27479.txt new file mode 100644 index 0000000000..355fbbafbe --- /dev/null +++ b/changelog/27479.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui: Ensure token expired banner displays when batch token expires +``` \ No newline at end of file diff --git a/ui/app/components/token-expire-warning.js b/ui/app/components/token-expire-warning.js index f527e27804..c79f0a7681 100644 --- a/ui/app/components/token-expire-warning.js +++ b/ui/app/components/token-expire-warning.js @@ -46,6 +46,7 @@ export default class TokenExpireWarning extends Component { if ('vault.cluster.oidc-provider' === currentRoute) { return false; } + return !!this.args.expirationDate; } } diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 0230ca6c3b..cc369c819f 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -81,8 +81,10 @@ export default Service.extend({ if (!tokenName) { return; } + const { tokenExpirationEpoch } = this.getTokenData(tokenName); const expirationDate = new Date(0); + return tokenExpirationEpoch ? expirationDate.setUTCMilliseconds(tokenExpirationEpoch) : null; }), @@ -215,15 +217,20 @@ export default Service.extend({ return this.ajax(url, 'POST', { namespace }); }, - calculateExpiration(resp) { - const now = this.now(); + calculateExpiration(resp, now) { const ttl = resp.ttl || resp.lease_duration; - const tokenExpirationEpoch = now + ttl * 1e3; - this.set('expirationCalcTS', now); - return { - ttl, - tokenExpirationEpoch, - }; + const tokenExpirationEpoch = resp.expire_time ? new Date(resp.expire_time).getTime() : now + ttl * 1e3; + + return { ttl, tokenExpirationEpoch }; + }, + + setExpirationSettings(resp, now) { + if (resp.renewable) { + this.set('expirationCalcTS', now); + this.set('allowExpiration', false); + } else { + this.set('allowExpiration', true); + } }, calculateRootNamespace(currentNamespace, namespace_path, backend) { @@ -296,21 +303,22 @@ export default Service.extend({ resp.policies ); - if (resp.renewable) { - Object.assign(data, this.calculateExpiration(resp)); - } else if (resp.type === 'batch') { - // if it's a batch token, it's not renewable but has an expire time - // so manually set tokenExpirationEpoch and allow expiration - data.tokenExpirationEpoch = new Date(resp.expire_time).getTime(); - this.set('allowExpiration', true); - } + const now = this.now(); + + Object.assign(data, this.calculateExpiration(resp, now)); + this.setExpirationSettings(resp, now); + + // ensure we don't call renew-self within tests + // this is intentionally not included in setExpirationSettings so we can unit test that method + if (Ember.testing) this.set('allowExpiration', false); if (!data.displayName) { data.displayName = (this.getTokenData(tokenName) || {}).displayName; } + this.set('tokens', addToArray(this.tokens, tokenName)); - this.set('allowExpiration', false); this.setTokenData(tokenName, data); + return resolve({ namespace: currentNamespace || data.userRootNamespace, token: tokenName, @@ -333,9 +341,9 @@ export default Service.extend({ renew() { const tokenName = this.currentTokenName; const currentlyRenewing = this.isRenewing; - if (currentlyRenewing) { - return; - } + + if (currentlyRenewing) return; + this.isRenewing = true; return this.renewCurrentToken().then( (resp) => { diff --git a/ui/tests/integration/services/auth-test.js b/ui/tests/integration/services/auth-test.js index f3fa0bee72..68f06b1689 100644 --- a/ui/tests/integration/services/auth-test.js +++ b/ui/tests/integration/services/auth-test.js @@ -123,6 +123,90 @@ const GITHUB_RESPONSE = { }, }; +const BATCH_TOKEN_RESPONSE = { + request_id: '60bcef62-cc20-facf-8c0d-1418d05e9a42', + lease_id: '', + renewable: false, + lease_duration: 0, + data: { + accessor: '', + creation_time: 1718672331, + creation_ttl: 60, + display_name: 'token', + entity_id: '', + expire_time: '2024-06-17T17:59:51-07:00', + explicit_max_ttl: 0, + id: 'hvb.AAAAAQIUMVkhx9rnA', + issue_time: '2024-06-17T17:58:51-07:00', + meta: null, + num_uses: 0, + orphan: false, + path: 'auth/token/create', + policies: ['default'], + renewable: false, + ttl: 45, + type: 'batch', + }, + wrap_info: null, + warnings: null, + auth: null, + mount_type: 'token', +}; + +const USERPASS_BATCH_TOKEN_RESPONSE = { + request_id: 'eb4c31a0-1745-5701-cce7-1668f5839dbf', + lease_id: '', + renewable: false, + lease_duration: 0, + data: null, + wrap_info: null, + warnings: null, + auth: { + client_token: 'hvb.AAAAAQJ0eGwP5e48S61kBRYmR', + accessor: '', + policies: ['default'], + token_policies: ['default'], + metadata: { + username: 'bob', + }, + lease_duration: 360, + renewable: false, + entity_id: 'b52f8591-02b6-828b-7f36-620afa539126', + token_type: 'batch', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', +}; + +const USERPASS_SERVICE_TOKEN_RESPONSE = { + request_id: 'e735ffad-f2fe-5d1b-14b8-90aeb9d05976', + lease_id: '', + renewable: false, + lease_duration: 0, + data: null, + wrap_info: null, + warnings: null, + auth: { + client_token: 'hvs.CAESINY6Qbs8rm', + accessor: '9bDizzlcIHiXwEOK5mZ6gjHI', + policies: ['default'], + token_policies: ['default'], + metadata: { + username: 'bob', + }, + lease_duration: 360, + renewable: true, + entity_id: 'd9a0cac8-779c-e766-716a-6f80552f0e81', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', +}; + module('Integration | Service | auth', function (hooks) { setupTest(hooks); setupMirage(hooks); @@ -334,4 +418,72 @@ module('Integration | Service | auth', function (hooks) { }); }); }); + + module('token types', function (hooks) { + hooks.beforeEach(function () { + this.server.post('/auth/userpass/login/:username', (_, request) => { + const { username } = request.params; + const resp = + username === 'batch' + ? { ...USERPASS_BATCH_TOKEN_RESPONSE } + : { ...USERPASS_SERVICE_TOKEN_RESPONSE }; + resp.auth.metadata.username = username; + return resp; + }); + + this.service = this.owner.factoryFor('service:auth').create({ storage: () => this.store }); + }); + + module('batch tokens', function () { + test('batch tokens generated by token auth method', async function (assert) { + this.server.get('/auth/token/lookup-self', () => { + return { ...BATCH_TOKEN_RESPONSE }; + }); + + await this.service.authenticate({ + clusterId: '1', + backend: 'token', + data: { token: 'test' }, + }); + + // exact expiration time is calculated in unit tests + assert.notEqual( + this.service.tokenExpirationDate, + undefined, + 'expiration is calculated for batch tokens' + ); + }); + + test('batch tokens generated by auth methods', async function (assert) { + await this.service.authenticate({ + clusterId: '1', + backend: 'userpass', + data: { username: 'batch', password: 'password' }, + }); + + // exact expiration time is calculated in unit tests + assert.notEqual( + this.service.tokenExpirationDate, + undefined, + 'expiration is calculated for batch tokens' + ); + }); + }); + + test('service token authentication', async function (assert) { + await this.service.authenticate({ + clusterId: '1', + backend: 'userpass', + data: { username: 'service', password: 'password' }, + }); + + // exact expiration time is calculated in unit tests + assert.notEqual( + this.service.tokenExpirationDate, + undefined, + 'expiration is calculated for service tokens' + ); + assert.false(this.service.allowExpiration, 'allowExpiration is false for service tokens'); + }); + }); }); diff --git a/ui/tests/unit/services/auth-test.js b/ui/tests/unit/services/auth-test.js index fb6f41dcc9..a91c8c8277 100644 --- a/ui/tests/unit/services/auth-test.js +++ b/ui/tests/unit/services/auth-test.js @@ -9,26 +9,69 @@ import { setupTest } from 'ember-qunit'; module('Unit | Service | auth', function (hooks) { setupTest(hooks); - [ - ['#calculateExpiration w/ttl', { ttl: 30 }, 30], - ['#calculateExpiration w/lease_duration', { ttl: 15 }, 15], - ].forEach(([testName, response, ttlValue]) => { - test(testName, function (assert) { - const now = Date.now(); - const service = this.owner.factoryFor('service:auth').create({ - now() { - return now; - }, + hooks.beforeEach(function () { + this.service = this.owner.lookup('service:auth'); + }); + + module('#calculateExpiration', function () { + [ + ['#calculateExpiration w/ttl', { ttl: 30 }, 30], + ['#calculateExpiration w/lease_duration', { lease_duration: 15 }, 15], + ].forEach(([testName, response, ttlValue]) => { + test(testName, function (assert) { + const now = Date.now(); + + const resp = this.service.calculateExpiration(response, now); + + assert.strictEqual(resp.ttl, ttlValue, 'returns the ttl'); + assert.strictEqual( + resp.tokenExpirationEpoch, + now + ttlValue * 1e3, + 'calculates expiration from ttl as epoch timestamp' + ); }); + }); - const resp = service.calculateExpiration(response); + test('#calculateExpiration w/ expire_time', function (assert) { + const now = Date.now(); + const expirationString = '2024-06-13T09:10:21-07:00'; + const expectedExpirationEpoch = new Date(expirationString).getTime(); - assert.strictEqual(resp.ttl, ttlValue, 'returns the ttl'); + const resp = this.service.calculateExpiration( + { ttl: 30, expire_time: '2024-06-13T09:10:21-07:00' }, + now + ); + + assert.strictEqual(resp.ttl, 30, 'returns ttl'); assert.strictEqual( resp.tokenExpirationEpoch, - now + ttlValue * 1e3, - 'calculates expiration from ttl as epoch timestamp' + expectedExpirationEpoch, + 'calculates expiration from expire_time' ); }); }); + + module('#setExpirationSettings', function () { + test('#setExpirationSettings for a renewable token', function (assert) { + const now = Date.now(); + const ttl = 30; + const response = { ttl, renewable: true }; + + this.service.setExpirationSettings(response, now); + + assert.false(this.service.allowExpiration, 'sets allowExpiration to false'); + assert.strictEqual(this.service.expirationCalcTS, now, 'sets expirationCalcTS to now'); + }); + + test('#setExpirationSettings for a non-renewable token', function (assert) { + const now = Date.now(); + const ttl = 30; + const response = { ttl, renewable: false }; + + this.service.setExpirationSettings(response, now); + + assert.true(this.service.allowExpiration, 'sets allowExpiration to true'); + assert.strictEqual(this.service.expirationCalcTS, null, 'keeps expirationCalcTS as null'); + }); + }); });