Skip to content

Commit 9c6bd51

Browse files
authored
UI/OIDC provider (#12800)
* Add new route w/ controller oidc-provider * oidc-provider controller has params, template has success message (temporary), model requests correct endpoint * Move oidc-provider route to under identity * Do not redirect after poll if on oidc-provider page * WIP provider -- beforeModel handles prompt, logout, redirect * Auth service fetch method rejects with fetch response if status >= 300 * New component OidcConsentBlock * Fix redirect to/from auth with cluster name, show error and consent form if applicable * Show error and consent form on template * Add component test, update docs * Test for oidc-consent-block component * Add changelog * fix tests * Add authorize to end of router path * Remove unused tests * Update changelog with feature name * Add descriptions for OidcConsentBlock component * glimmerize token-expire-warning and don't override yield if on oidc-provider route * remove text on token-expire-warning * Fix null transition.to on cluster redirect * Hide nav links if oidc-provider route
1 parent 6f65a4a commit 9c6bd51

File tree

15 files changed

+414
-30
lines changed

15 files changed

+414
-30
lines changed

changelog/12800.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:feature
2+
**OIDC Authorization Code Flow**: The Vault UI now supports OIDC Authorization Code Flow
3+
```

ui/app/components/nav-header.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import Component from '@ember/component';
2+
import { inject as service } from '@ember/service';
3+
import { computed } from '@ember/object';
4+
25
export default Component.extend({
6+
router: service(),
37
'data-test-navheader': true,
48
classNameBindings: 'consoleFullscreen:panel-fullscreen',
59
tagName: 'header',
610
navDrawerOpen: false,
711
consoleFullscreen: false,
12+
hideLinks: computed('router.currentRouteName', function() {
13+
let currentRoute = this.router.currentRouteName;
14+
if ('vault.cluster.identity.oidc-provider' === currentRoute) {
15+
return true;
16+
}
17+
return false;
18+
}),
819
actions: {
920
toggleNavDrawer(isOpen) {
1021
if (isOpen !== undefined) {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @module OidcConsentBlock
3+
* OidcConsentBlock components are used to show the consent form for the OIDC Authorization Code Flow
4+
*
5+
* @example
6+
* ```js
7+
* <OidcConsentBlock @redirect="https://example.com/oidc-callback" @code="abcd1234" @state="string-for-state" />
8+
* ```
9+
* @param {string} redirect - redirect is the URL where successful consent will redirect to
10+
* @param {string} code - code is the string required to pass back to redirect on successful OIDC auth
11+
* @param {string} [state] - state is a string which is required to return on redirect if provided, but optional generally
12+
*/
13+
14+
import Ember from 'ember';
15+
import Component from '@glimmer/component';
16+
import { action } from '@ember/object';
17+
import { tracked } from '@glimmer/tracking';
18+
19+
const validParameters = ['code', 'state'];
20+
export default class OidcConsentBlockComponent extends Component {
21+
@tracked didCancel = false;
22+
23+
get win() {
24+
return this.window || window;
25+
}
26+
27+
buildUrl(urlString, params) {
28+
try {
29+
let url = new URL(urlString);
30+
Object.keys(params).forEach(key => {
31+
if (params[key] && validParameters.includes(key)) {
32+
url.searchParams.append(key, params[key]);
33+
}
34+
});
35+
return url;
36+
} catch (e) {
37+
console.debug('DEBUG: parsing url failed for', urlString);
38+
throw new Error('Invalid URL');
39+
}
40+
}
41+
42+
@action
43+
handleSubmit(evt) {
44+
evt.preventDefault();
45+
let { redirect, ...params } = this.args;
46+
let redirectUrl = this.buildUrl(redirect, params);
47+
if (Ember.testing) {
48+
this.args.testRedirect(redirectUrl.toString());
49+
} else {
50+
this.win.location.replace(redirectUrl);
51+
}
52+
}
53+
54+
@action
55+
handleCancel(evt) {
56+
evt.preventDefault();
57+
this.didCancel = true;
58+
}
59+
}
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import Component from '@ember/component';
1+
import Component from '@glimmer/component';
2+
import { inject as service } from '@ember/service';
23

3-
export default Component.extend({
4-
tagName: '',
5-
});
4+
export default class TokenExpireWarning extends Component {
5+
@service router;
6+
7+
get showWarning() {
8+
let currentRoute = this.router.currentRouteName;
9+
if ('vault.cluster.identity.oidc-provider' === currentRoute) {
10+
return false;
11+
}
12+
return !!this.args.expirationDate;
13+
}
14+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Controller from '@ember/controller';
2+
3+
export default class VaultClusterIdentityOidcProviderController extends Controller {
4+
queryParams = [
5+
'scope', // *
6+
'response_type', // *
7+
'client_id', // *
8+
'redirect_uri', // *
9+
'state', // *
10+
'nonce', // *
11+
'display',
12+
'prompt',
13+
'max_age',
14+
];
15+
scope = null;
16+
response_type = null;
17+
client_id = null;
18+
redirect_uri = null;
19+
state = null;
20+
nonce = null;
21+
display = null;
22+
prompt = null;
23+
max_age = null;
24+
}

ui/app/mixins/cluster-route.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const AUTH = 'vault.cluster.auth';
77
const CLUSTER = 'vault.cluster';
88
const CLUSTER_INDEX = 'vault.cluster.index';
99
const OIDC_CALLBACK = 'vault.cluster.oidc-callback';
10+
const OIDC_PROVIDER = 'vault.cluster.identity.oidc-provider';
1011
const DR_REPLICATION_SECONDARY = 'vault.cluster.replication-dr-promote';
1112
const DR_REPLICATION_SECONDARY_DETAILS = 'vault.cluster.replication-dr-promote.details';
1213
const EXCLUDED_REDIRECT_URLS = ['/vault/logout'];
@@ -20,7 +21,9 @@ export default Mixin.create({
2021

2122
transitionToTargetRoute(transition = {}) {
2223
const targetRoute = this.targetRouteName(transition);
23-
24+
if (OIDC_PROVIDER === this.router.currentRouteName || OIDC_PROVIDER === transition?.to?.name) {
25+
return RSVP.resolve();
26+
}
2427
if (
2528
targetRoute &&
2629
targetRoute !== this.routeName &&

ui/app/router.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ Router.map(function() {
139139
}
140140

141141
this.route('not-found', { path: '/*path' });
142+
143+
this.route('identity', function() {
144+
this.route('oidc-provider', { path: '/oidc/provider/:oidc_name/authorize' });
145+
});
142146
});
143147
this.route('not-found', { path: '/*path' });
144148
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import Route from '@ember/routing/route';
2+
import { inject as service } from '@ember/service';
3+
4+
const AUTH = 'vault.cluster.auth';
5+
const PROVIDER = 'vault.cluster.identity.oidc-provider';
6+
7+
export default class VaultClusterIdentityOidcProviderRoute extends Route {
8+
@service auth;
9+
@service router;
10+
11+
get win() {
12+
return this.window || window;
13+
}
14+
15+
_redirect(url, params) {
16+
let redir = this._buildUrl(url, params);
17+
this.win.location.replace(redir);
18+
}
19+
20+
beforeModel(transition) {
21+
const currentToken = this.auth.get('currentTokenName');
22+
let { redirect_to, ...qp } = transition.to.queryParams;
23+
console.debug('DEBUG: removing redirect_to', redirect_to);
24+
if (!currentToken && 'none' === qp.prompt?.toLowerCase()) {
25+
this._redirect(qp.redirect_uri, {
26+
state: qp.state,
27+
error: 'login_required',
28+
});
29+
} else if (!currentToken || 'login' === qp.prompt?.toLowerCase()) {
30+
if ('login' === qp.prompt?.toLowerCase()) {
31+
this.auth.deleteCurrentToken();
32+
qp.prompt = null;
33+
}
34+
let { cluster_name } = this.paramsFor('vault.cluster');
35+
let url = this.router.urlFor(transition.to.name, transition.to.params, { queryParams: qp });
36+
return this.transitionTo(AUTH, cluster_name, { queryParams: { redirect_to: url } });
37+
}
38+
}
39+
40+
_redirectToAuth(oidcName, queryParams, logout = false) {
41+
let { cluster_name } = this.paramsFor('vault.cluster');
42+
let currentRoute = this.router.urlFor(PROVIDER, oidcName, { queryParams });
43+
if (logout) {
44+
this.auth.deleteCurrentToken();
45+
}
46+
return this.transitionTo(AUTH, cluster_name, { queryParams: { redirect_to: currentRoute } });
47+
}
48+
49+
_buildUrl(urlString, params) {
50+
try {
51+
let url = new URL(urlString);
52+
Object.keys(params).forEach(key => {
53+
if (params[key]) {
54+
url.searchParams.append(key, params[key]);
55+
}
56+
});
57+
return url;
58+
} catch (e) {
59+
console.debug('DEBUG: parsing url failed for', urlString);
60+
throw new Error('Invalid URL');
61+
}
62+
}
63+
64+
_handleSuccess(response, baseUrl, state) {
65+
const { code } = response;
66+
let redirectUrl = this._buildUrl(baseUrl, { code, state });
67+
this.win.location.replace(redirectUrl);
68+
}
69+
_handleError(errorResp, baseUrl) {
70+
let redirectUrl = this._buildUrl(baseUrl, { ...errorResp });
71+
this.win.location.replace(redirectUrl);
72+
}
73+
74+
async model(params) {
75+
let { oidc_name, ...qp } = params;
76+
let decodedRedirect = decodeURI(qp.redirect_uri);
77+
let url = this._buildUrl(`${this.win.origin}/v1/identity/oidc/provider/${oidc_name}/authorize`, qp);
78+
try {
79+
const response = await this.auth.ajax(url, 'GET', {});
80+
if ('consent' === qp.prompt?.toLowerCase()) {
81+
return {
82+
consent: {
83+
code: response.code,
84+
redirect: decodedRedirect,
85+
state: qp.state,
86+
},
87+
};
88+
}
89+
this._handleSuccess(response, decodedRedirect, qp.state);
90+
} catch (errorRes) {
91+
let resp = await errorRes.json();
92+
let code = resp.error;
93+
if (code === 'max_age_violation') {
94+
this._redirectToAuth(oidc_name, qp, true);
95+
} else if (code === 'invalid_redirect_uri') {
96+
return {
97+
error: {
98+
title: 'Redirect URI mismatch',
99+
message:
100+
'The provided redirect_uri is not in the list of allowed redirect URIs. Please make sure you are sending a valid redirect URI from your application.',
101+
},
102+
};
103+
} else if (code === 'invalid_client_id') {
104+
return {
105+
error: {
106+
title: 'Invalid client ID',
107+
message: 'Your client ID is invalid. Please update your configuration and try again.',
108+
},
109+
};
110+
} else {
111+
this._handleError(resp, decodedRedirect);
112+
}
113+
}
114+
}
115+
}

ui/app/services/auth.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export default Service.extend({
9797
} else if (response.status >= 200 && response.status < 300) {
9898
return resolve(response.json());
9999
} else {
100-
return reject();
100+
return reject(response);
101101
}
102102
});
103103
},

ui/app/templates/components/nav-header.hbs

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,32 @@
99
</button>
1010
{{/unless}}
1111

12-
<div class="navbar-drawer{{if navDrawerOpen ' is-active'}}">
13-
<div class="navbar-drawer-scroll">
14-
<div data-test-navheader-main>
15-
{{yield (hash
16-
main=(component 'nav-header/main')
17-
closeDrawer=(action "toggleNavDrawer" false)
18-
)
19-
}}
12+
{{#unless hideLinks}}
13+
<div class="navbar-drawer{{if navDrawerOpen ' is-active'}}">
14+
<div class="navbar-drawer-scroll">
15+
<div data-test-navheader-main>
16+
{{yield (hash
17+
main=(component 'nav-header/main')
18+
closeDrawer=(action "toggleNavDrawer" false)
19+
)
20+
}}
21+
</div>
22+
<div class="navbar-end" data-test-navheader-items>
23+
{{yield (hash
24+
items=(component 'nav-header/items')
25+
closeDrawer=(action "toggleNavDrawer" false)
26+
)
27+
}}
28+
</div>
2029
</div>
21-
<div class="navbar-end" data-test-navheader-items>
22-
{{yield (hash
23-
items=(component 'nav-header/items')
24-
closeDrawer=(action "toggleNavDrawer" false)
25-
)
26-
}}
27-
</div>
28-
</div>
2930

30-
{{#if navDrawerOpen}}
31-
<button class=" navbar-drawer-toggle is-hidden-tablet" type="button" {{action "toggleNavDrawer" false}}>
32-
<Icon @glyph="cancel-plain" />
33-
</button>
34-
{{/if}}
35-
</div>
31+
{{#if navDrawerOpen}}
32+
<button class=" navbar-drawer-toggle is-hidden-tablet" type="button" {{action "toggleNavDrawer" false}}>
33+
<Icon @glyph="cancel-plain" />
34+
</button>
35+
{{/if}}
36+
</div>
37+
{{/unless}}
3638

3739
<div class="navbar-drawer-overlay{{if navDrawerOpen ' is-active'}}" onclick={{action "toggleNavDrawer" (not navDrawerOpen)}}></div>
3840
</nav>

0 commit comments

Comments
 (0)