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

Backport 1.11.x: AD FS namespace OIDC bug fix #19460 #19694

Merged
merged 1 commit into from
Mar 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,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
let queryParams = { source, path, code, state };
Expand Down
164 changes: 95 additions & 69 deletions ui/tests/unit/routes/vault/cluster/oidc-callback-test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { module, test } from 'qunit';
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

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

Expand All @@ -12,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: undefined,
code: this.code,
path: 'oidc',
state: undefined,
state: '',
source: 'oidc-callback',
},
'model hook returns non-existent state param'
);
});

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' }
*/
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'
);
});
});