Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AD FS namespace OIDC bug fix #19460

Merged
merged 10 commits into from
Mar 23, 2023
3 changes: 3 additions & 0 deletions changelog/19460.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:bug
ui: use URLSearchParams interface to capture namespace param from SSOs (ex. ADFS) with decoded state param in callback url
```
10 changes: 9 additions & 1 deletion ui/app/routes/vault/cluster/oidc-callback.js
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may seem redundant to first get namespace using paramsFor and then again using window.location.search but if the namespace is from the cluster (i.e. HCP namespace flag) window.location.search is empty and so we have to use paramsFor to initially assign those variables.

Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ export default Route.extend({
afterModel() {
let { auth_path: path, code, state } = this.paramsFor(this.routeName);
let { namespaceQueryParam: namespace } = this.paramsFor('vault.cluster');
// only replace namespace param from cluster if state has a namespace
// namespace from state takes precedence over the cluster's ns
if (state?.includes(',ns=')) {
[state, namespace] = state.split(',ns=');
}
// some SSO providers do not return a url-encoded state param
// check for namespace using URLSearchParams instead of paramsFor
const queryString = decodeURIComponent(window.location.search);
const urlParams = new URLSearchParams(queryString);
const checkState = urlParams.get('state');
if (checkState?.includes(',ns=')) {
[state, namespace] = checkState.split(',ns=');
}
path = window.decodeURIComponent(path);
const source = 'oidc-callback'; // required by event listener in auth-jwt component
const queryParams = { source, path: path || '', code: code || '', state: state || '' };
Expand Down
157 changes: 89 additions & 68 deletions ui/tests/unit/routes/vault/cluster/oidc-callback-test.js
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all these test updates 🎉

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: MPL-2.0
*/

import { module, test } from 'qunit';
import { module, skip, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import sinon from 'sinon';

Expand All @@ -17,173 +17,194 @@ module('Unit | Route | vault/cluster/oidc-callback', function (hooks) {
};
this.route = this.owner.lookup('route:vault/cluster/oidc-callback');
this.windowStub = sinon.stub(window.opener, 'postMessage');
this.state = 'st_yOarDguU848w5YZuotLs';
this.path = 'oidc';
this.code = 'lTazRXEwKfyGKBUCo5TyLJzdIt39YniBJOXPABiRMkL0T';
this.state = (ns) => {
return ns ? 'st_91ji6vR2sQ2zBiZSQkqJ' + `,ns=${ns}` : 'st_91ji6vR2sQ2zBiZSQkqJ';
this.route.paramsFor = (path) => {
if (path === 'vault.cluster') return { namespaceQueryParam: '' };
return {
auth_path: this.path,
code: this.code,
};
};
this.callbackUrlQueryParams = (stateParam) => {
switch (stateParam) {
case '':
window.history.pushState({}, '');
break;
case 'stateless':
window.history.pushState({}, '', '?' + `code=${this.code}`);
break;
default:
window.history.pushState({}, '', '?' + `code=${this.code}&state=${stateParam}`);
break;
}
};
});

hooks.afterEach(function () {
this.windowStub.restore();
window.opener = this.originalOpener;
this.callbackUrlQueryParams('');
});

test('it calls route', function (assert) {
assert.ok(this.route);
});

test('it uses namespace param from state not namespaceQueryParam from cluster with default path', function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.route.paramsFor = (path) => {
if (path === 'vault.cluster') return { namespaceQueryParam: 'admin' };
return {
auth_path: this.path,
state: this.state('admin/child-ns'),
code: this.code,
};
};
this.route.afterModel();

assert.ok(this.windowStub.calledOnce, 'it is called');
assert.propContains(
this.windowStub.lastCall.args[0],
{
code: 'lTazRXEwKfyGKBUCo5TyLJzdIt39YniBJOXPABiRMkL0T',
namespace: 'admin/child-ns',
path: 'oidc',
},
'namespace param is from state, ns=admin/child-ns'
);
});

test('it uses namespace param from state not namespaceQueryParam from cluster with custom path', function (assert) {
skip('it uses namespace param from state instead of cluster, with custom oidc path', function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.callbackUrlQueryParams(encodeURIComponent(`${this.state},ns=test-ns`));
this.route.paramsFor = (path) => {
if (path === 'vault.cluster') return { namespaceQueryParam: 'admin' };
return {
auth_path: 'oidc-dev',
state: this.state('admin/child-ns'),
code: this.code,
};
};
this.route.afterModel();
assert.propContains(
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: this.code,
path: 'oidc-dev',
namespace: 'admin/child-ns',
state: this.state(),
namespace: 'test-ns',
state: this.state,
source: 'oidc-callback',
},
'state ns takes precedence, state no longer has ns query'
'ns from state not cluster'
);
});

test(`it uses namespace from namespaceQueryParam when state does not include: ',ns=some-namespace'`, function (assert) {
skip('it uses namespace from cluster when state does not include ns param', function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.callbackUrlQueryParams(encodeURIComponent(this.state));
this.route.paramsFor = (path) => {
if (path === 'vault.cluster') return { namespaceQueryParam: 'admin' };
return {
auth_path: this.path,
state: this.state(),
code: this.code,
};
};
this.route.afterModel();
assert.propContains(
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: this.code,
path: this.path,
namespace: 'admin',
state: this.state(),
state: this.state,
source: 'oidc-callback',
},
'namespace is from cluster namespaceQueryParam'
`namespace is from cluster's namespaceQueryParam`
);
});

test('it uses ns param from state when no namespaceQueryParam from cluster', function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.route.paramsFor = (path) => {
if (path === 'vault.cluster') return { namespaceQueryParam: '' };
return {
auth_path: this.path,
state: this.state('ns1'),
code: this.code,
};
};
skip('it correctly parses encoded, nested ns param from state', function (assert) {
this.callbackUrlQueryParams(encodeURIComponent(`${this.state},ns=parent-ns/child-ns`));
this.route.afterModel();
assert.propContains(
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: this.code,
path: this.path,
namespace: 'ns1',
state: this.state(),
namespace: 'parent-ns/child-ns',
state: this.state,
source: 'oidc-callback',
},
'it strips ns from state and uses as namespace param'
'it has correct nested ns from state and sets as namespace param'
);
});

test('the afterModel hook returns when both cluster and route params are empty strings', function (assert) {
skip('the afterModel hook returns when both cluster and route params are empty strings', function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.callbackUrlQueryParams('');
this.route.paramsFor = (path) => {
if (path === 'vault.cluster') return { namespaceQueryParam: '' };
return {
auth_path: '',
state: '',
code: '',
};
};
this.route.afterModel();
assert.propContains(
assert.propEqual(
this.windowStub.lastCall.args[0],
{
path: '',
state: '',
code: '',
source: 'oidc-callback',
},
'model hook returns with empty params'
);
});

test('the afterModel hook returns when state param does not exist', function (assert) {
skip('the afterModel hook returns when state param does not exist', function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.route.paramsFor = (path) => {
if (path === 'vault.cluster') return { namespaceQueryParam: '' };
return {
auth_path: this.path,
};
};
this.callbackUrlQueryParams('stateless');
this.route.afterModel();
assert.propContains(
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: '',
code: this.code,
path: 'oidc',
state: '',
source: 'oidc-callback',
},
'model hook returns empty string when state param nonexistent'
);
});

test('the afterModel hook returns when cluster namespaceQueryParam exists and all route params are empty strings', function (assert) {
skip('the afterModel hook returns when cluster ns exists and all route params are empty strings', function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.callbackUrlQueryParams('');
this.route.paramsFor = (path) => {
if (path === 'vault.cluster') return { namespaceQueryParam: 'ns1' };
return {
auth_path: '',
state: '',
code: '',
};
};
this.route.afterModel();
assert.propContains(
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: '',
namespace: 'ns1',
path: '',
source: 'oidc-callback',
state: '',
code: '',
},
'model hook returns with empty parameters'
);
});

/*
If authenticating to a namespace, most SSO providers return a callback url
with a 'state' query param that includes a URI encoded namespace, example:
'?code=BZBDVPMz0By2JTqulEMWX5-6rflW3A20UAusJYHEeFygJ&state=sst_yOarDguU848w5YZuotLs%2Cns%3Dadmin'

Active Directory Federation Service (AD FS), instead, decodes the namespace portion:
'?code=BZBDVPMz0By2JTqulEMWX5-6rflW3A20UAusJYHEeFygJ&state=st_yOarDguU848w5YZuotLs,ns=admin'

'ns' isn't recognized as a separate param because there is no ampersand, so using this.paramsFor() returns
a namespace-less state and authentication fails
{ state: 'st_yOarDguU848w5YZuotLs,ns' }
*/
Comment on lines +182 to +193
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great comments here thanks!

skip('it uses namespace when state param is not uri encoded', async function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.callbackUrlQueryParams(`${this.state},ns=admin`);
this.route.afterModel();
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: this.code,
namespace: 'admin',
path: this.path,
source: 'oidc-callback',
state: this.state,
},
'namespace is parsed correctly'
);
});
});