Skip to content

Commit 79afeee

Browse files
authored
[ui, epic] SSO and Auth improvements (#15110)
* Top nav auth dropdown (#15055) * Basic dropdown styles * Some cleanup * delog * Default nomad hover state styles * Component separation-of-concerns and acceptance tests for auth dropdown * lintfix * [ui, sso] Handle token expiry 500s (#15073) * Handle error states generally * Dont direct, just redirect * no longer need explicit error on controller * Redirect on token-doesnt-exist * Forgot to import our time lib * Linting on _blank * Redirect tests * changelog * [ui, sso] warn user about pending token expiry (#15091) * Handle error states generally * Dont direct, just redirect * no longer need explicit error on controller * Linting on _blank * Custom notification actions and shift the template to within an else block * Lintfix * Make the closeAction optional * changelog * Add a mirage token that will always expire in 11 minutes * Test for token expiry with ember concurrency waiters * concurrency handling for earlier test, and button redirect test * [ui] if ACLs are disabled, remove the Sign In link from the top of the UI (#15114) * Remove top nav link if ACLs disabled * Change to an enabled-by-default model since you get no agent config when ACLs are disabled but you lack a token * PR feedback addressed; down with double negative conditionals * lintfix * ember getter instead of ?.prop * [SSO] Auth Methods and Mock OIDC Flow (#15155) * Big ol first pass at a redirect sign in flow * dont recursively add queryparams on redirect * Passing state and code qps * In which I go off the deep end and embed a faux provider page in the nomad ui * Buggy but self-contained flow * Flow auto-delay added and a little more polish to resetting token * secret passing turned to accessor passing * Handle SSO Failure * General cleanup and test fix * Lintfix * SSO flow acceptance tests * Percy snapshots added * Explicitly note the OIDC test route is mirage only * Handling failure case for complete-auth * Leentfeex * Tokens page styles (#15273) * styling and moving columns around * autofocus and enter press handling * Styles refined * Split up manager and regular tests * Standardizing to a binary status state * Serialize auth-methods response to use "name" as primary key (#15380) * Serializer for unique-by-name * Use @classic because of class extension
1 parent 6a14192 commit 79afeee

35 files changed

+1010
-95
lines changed

.changelog/15073.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:improvement
2+
ui: redirect users to Sign In should their tokens ever come back expired or not-found
3+
```

.changelog/15091.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:improvement
2+
ui: give users a notification if their token is going to expire within the next 10 minutes
3+
```

ui/app/adapters/auth-method.js

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// @ts-check
2+
import { default as ApplicationAdapter, namespace } from './application';
3+
import { dasherize } from '@ember/string';
4+
import classic from 'ember-classic-decorator';
5+
6+
@classic
7+
export default class AuthMethodAdapter extends ApplicationAdapter {
8+
namespace = `${namespace}/acl`;
9+
10+
/**
11+
* @param {string} modelName
12+
* @returns {string}
13+
*/
14+
urlForFindAll(modelName) {
15+
return dasherize(this.buildURL(modelName));
16+
}
17+
18+
/**
19+
* @typedef {Object} ACLOIDCAuthURLParams
20+
* @property {string} AuthMethod
21+
* @property {string} RedirectUri
22+
* @property {string} ClientNonce
23+
* @property {Object[]} Meta // NOTE: unsure if array of objects or kv pairs
24+
*/
25+
26+
/**
27+
* @param {ACLOIDCAuthURLParams} params
28+
* @returns
29+
*/
30+
getAuthURL({ AuthMethod, RedirectUri, ClientNonce, Meta }) {
31+
const url = `/${this.namespace}/oidc/auth-url`;
32+
return this.ajax(url, 'POST', {
33+
data: {
34+
AuthMethod,
35+
RedirectUri,
36+
ClientNonce,
37+
Meta,
38+
},
39+
});
40+
}
41+
}

ui/app/components/global-header.js

+11
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,15 @@ export default class GlobalHeader extends Component {
1111

1212
'data-test-global-header' = true;
1313
onHamburgerClick() {}
14+
15+
// Show sign-in if:
16+
// - User can't load agent config (meaning ACLs are enabled but they're not signed in)
17+
// - User can load agent config in and ACLs are enabled (meaning ACLs are enabled and they're signed in)
18+
// The excluded case here is if there is both an agent config and ACLs are disabled
19+
get shouldShowProfileNav() {
20+
return (
21+
!this.system.agent?.get('config') ||
22+
this.system.agent?.get('config.ACL.Enabled') === true
23+
);
24+
}
1425
}
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{{#if this.token.selfToken}}
2+
<PowerSelect
3+
data-test-header-profile-dropdown
4+
{{keyboard-shortcut menuLevel=true pattern=(array "g" "p") }}
5+
@options={{this.profileOptions}}
6+
@onChange={{action (queue
7+
(fn (mut this.profileSelection))
8+
this.profileSelection.action
9+
)}}
10+
@dropdownClass="dropdown-options"
11+
@matchTriggerWidth={{false}}
12+
@selected={{get this.profileSelection "key"}}
13+
class="profile-dropdown navbar-item"
14+
as |option|>
15+
<span class="ember-power-select-prefix">Profile</span>
16+
<span class="dropdown-label" data-test-dropdown-option={{option.key}}>{{option.label}}</span>
17+
</PowerSelect>
18+
{{else}}
19+
<LinkTo data-test-header-signin-link @route="settings.tokens" class="navbar-item" {{keyboard-shortcut menuLevel=true pattern=(array "g" "p") }}>
20+
Sign In
21+
</LinkTo>
22+
{{/if}}
23+
24+
{{yield}}
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// @ts-check
2+
3+
import Component from '@glimmer/component';
4+
import { inject as service } from '@ember/service';
5+
6+
export default class ProfileNavbarItemComponent extends Component {
7+
@service token;
8+
@service router;
9+
@service store;
10+
11+
profileOptions = [
12+
{
13+
label: 'Authorization',
14+
key: 'authorization',
15+
action: () => {
16+
this.router.transitionTo('settings.tokens');
17+
},
18+
},
19+
{
20+
label: 'Sign Out',
21+
key: 'sign-out',
22+
action: () => {
23+
this.token.setProperties({
24+
secret: undefined,
25+
});
26+
27+
// Clear out all data to ensure only data the anonymous token is privileged to see is shown
28+
this.store.unloadAll();
29+
this.token.reset();
30+
this.router.transitionTo('jobs.index');
31+
},
32+
},
33+
];
34+
35+
profileSelection = this.profileOptions[0];
36+
}

ui/app/controllers/oidc-mock.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Controller from '@ember/controller';
2+
import { action } from '@ember/object';
3+
import { inject as service } from '@ember/service';
4+
import Ember from 'ember';
5+
6+
export default class OidcMockController extends Controller {
7+
@service router;
8+
9+
queryParams = ['auth_method', 'client_nonce', 'redirect_uri', 'meta'];
10+
11+
@action
12+
signIn(fakeAccount) {
13+
const url = `${this.redirect_uri.split('?')[0]}?code=${
14+
fakeAccount.accessor
15+
}&state=success`;
16+
if (Ember.testing) {
17+
this.router.transitionTo(url);
18+
} else {
19+
window.location = url;
20+
}
21+
}
22+
23+
@action
24+
failToSignIn() {
25+
const url = `${this.redirect_uri.split('?')[0]}?state=failure`;
26+
if (Ember.testing) {
27+
this.router.transitionTo(url);
28+
} else {
29+
window.location = url;
30+
}
31+
}
32+
}

ui/app/controllers/settings/tokens.js

+106-14
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
1+
// @ts-check
12
import { inject as service } from '@ember/service';
23
import { reads } from '@ember/object/computed';
34
import Controller from '@ember/controller';
45
import { getOwner } from '@ember/application';
56
import { alias } from '@ember/object/computed';
67
import { action } from '@ember/object';
78
import classic from 'ember-classic-decorator';
9+
import { tracked } from '@glimmer/tracking';
10+
import Ember from 'ember';
811

912
@classic
1013
export default class Tokens extends Controller {
1114
@service token;
1215
@service store;
16+
@service router;
17+
18+
queryParams = ['code', 'state'];
1319

1420
@reads('token.secret') secret;
1521

16-
tokenIsValid = false;
17-
tokenIsInvalid = false;
22+
/**
23+
* @type {(null | "success" | "failure")} signInStatus
24+
*/
25+
@tracked
26+
signInStatus = null;
27+
1828
@alias('token.selfToken') tokenRecord;
1929

2030
resetStore() {
@@ -25,22 +35,27 @@ export default class Tokens extends Controller {
2535
clearTokenProperties() {
2636
this.token.setProperties({
2737
secret: undefined,
38+
tokenNotFound: false,
2839
});
29-
this.setProperties({
30-
tokenIsValid: false,
31-
tokenIsInvalid: false,
32-
});
40+
this.signInStatus = null;
3341
// Clear out all data to ensure only data the anonymous token is privileged to see is shown
3442
this.resetStore();
3543
this.token.reset();
44+
this.store.findAll('auth-method');
45+
}
46+
47+
get authMethods() {
48+
return this.store.peekAll('auth-method');
3649
}
3750

3851
@action
3952
verifyToken() {
4053
const { secret } = this;
54+
this.clearTokenProperties();
4155
const TokenAdapter = getOwner(this).lookup('adapter:token');
4256

4357
this.set('token.secret', secret);
58+
this.set('secret', null);
4459

4560
TokenAdapter.findSelf().then(
4661
() => {
@@ -50,18 +65,95 @@ export default class Tokens extends Controller {
5065
// Refetch the token and associated policies
5166
this.get('token.fetchSelfTokenAndPolicies').perform().catch();
5267

53-
this.setProperties({
54-
tokenIsValid: true,
55-
tokenIsInvalid: false,
56-
});
68+
this.signInStatus = 'success';
69+
this.token.set('tokenNotFound', false);
5770
},
5871
() => {
5972
this.set('token.secret', undefined);
60-
this.setProperties({
61-
tokenIsValid: false,
62-
tokenIsInvalid: true,
63-
});
73+
this.signInStatus = 'failure';
6474
}
6575
);
6676
}
77+
78+
// Generate a 20-char nonce, using window.crypto to
79+
// create a sufficiently-large output then trimming
80+
generateNonce() {
81+
let randomArray = new Uint32Array(10);
82+
crypto.getRandomValues(randomArray);
83+
return randomArray.join('').slice(0, 20);
84+
}
85+
86+
@action redirectToSSO(method) {
87+
const provider = method.name;
88+
const nonce = this.generateNonce();
89+
90+
window.localStorage.setItem('nomadOIDCNonce', nonce);
91+
window.localStorage.setItem('nomadOIDCAuthMethod', provider);
92+
93+
method
94+
.getAuthURL({
95+
AuthMethod: provider,
96+
ClientNonce: nonce,
97+
RedirectUri: Ember.testing
98+
? this.router.currentURL
99+
: window.location.toString(),
100+
})
101+
.then(({ AuthURL }) => {
102+
if (Ember.testing) {
103+
this.router.transitionTo(AuthURL.split('/ui')[1]);
104+
} else {
105+
window.location = AuthURL;
106+
}
107+
});
108+
}
109+
110+
@tracked code = null;
111+
@tracked state = null;
112+
113+
get isValidatingToken() {
114+
if (this.code && this.state === 'success') {
115+
this.validateSSO();
116+
return true;
117+
} else {
118+
return false;
119+
}
120+
}
121+
122+
async validateSSO() {
123+
const res = await this.token.authorizedRequest(
124+
'/v1/acl/oidc/complete-auth',
125+
{
126+
method: 'POST',
127+
body: JSON.stringify({
128+
AuthMethod: window.localStorage.getItem('nomadOIDCAuthMethod'),
129+
ClientNonce: window.localStorage.getItem('nomadOIDCNonce'),
130+
Code: this.code,
131+
State: this.state,
132+
}),
133+
}
134+
);
135+
136+
if (res.ok) {
137+
const data = await res.json();
138+
this.token.set('secret', data.ACLToken);
139+
this.verifyToken();
140+
this.state = null;
141+
this.code = null;
142+
} else {
143+
this.state = 'failure';
144+
this.code = null;
145+
}
146+
}
147+
148+
get SSOFailure() {
149+
return this.state === 'failure';
150+
}
151+
152+
get canSignIn() {
153+
return !this.tokenRecord || this.tokenRecord.isExpired;
154+
}
155+
156+
get shouldShowPolicies() {
157+
return this.tokenRecord;
158+
}
67159
}

ui/app/models/auth-method.js

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// @ts-check
2+
import Model from '@ember-data/model';
3+
import { attr } from '@ember-data/model';
4+
5+
export default class AuthMethodModel extends Model {
6+
@attr('string') name;
7+
@attr('string') type;
8+
@attr('string') tokenLocality;
9+
@attr('string') maxTokenTTL;
10+
@attr('boolean') default;
11+
@attr('date') createTime;
12+
@attr('number') createIndex;
13+
@attr('date') modifyTime;
14+
@attr('number') modifyIndex;
15+
16+
getAuthURL(params) {
17+
return this.store.adapterFor('authMethod').getAuthURL(params);
18+
}
19+
}

ui/app/models/token.js

+5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export default class Token extends Model {
1111
@attr('string') type;
1212
@hasMany('policy') policies;
1313
@attr() policyNames;
14+
@attr('date') expirationTime;
1415

1516
@alias('id') accessor;
17+
18+
get isExpired() {
19+
return this.expirationTime && this.expirationTime < new Date();
20+
}
1621
}

ui/app/router.js

+4
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,8 @@ Router.map(function () {
9898
path: '/path/*absolutePath',
9999
});
100100
});
101+
// Mirage-only route for testing OIDC flow
102+
if (config['ember-cli-mirage']) {
103+
this.route('oidc-mock');
104+
}
101105
});

ui/app/routes/application.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default class ApplicationRoute extends Route {
1313
@service system;
1414
@service store;
1515
@service token;
16+
@service router;
1617

1718
queryParams = {
1819
region: {
@@ -140,7 +141,17 @@ export default class ApplicationRoute extends Route {
140141
@action
141142
error(error) {
142143
if (!(error instanceof AbortError)) {
143-
this.controllerFor('application').set('error', error);
144+
if (
145+
error.errors?.any(
146+
(e) =>
147+
e.detail === 'ACL token expired' ||
148+
e.detail === 'ACL token not found'
149+
)
150+
) {
151+
this.router.transitionTo('settings.tokens');
152+
} else {
153+
this.controllerFor('application').set('error', error);
154+
}
144155
}
145156
}
146157
}

0 commit comments

Comments
 (0)