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

UI: Test business logic for oidc callback params #19727

Merged
merged 7 commits into from
Mar 27, 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
48 changes: 27 additions & 21 deletions ui/app/routes/vault/cluster/oidc-callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,39 @@

import Route from '@ember/routing/route';

export function getParamsForCallback(qp, searchString) {
const queryString = decodeURIComponent(searchString);
let { path, code, state, namespace } = qp;
// 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 urlParams = new URLSearchParams(queryString);
const checkState = urlParams.get('state');
if (checkState?.includes(',ns=')) {
[state, namespace] = checkState.split(',ns=');
}
path = window.decodeURIComponent(path);
const payload = { source: 'oidc-callback', path: path || '', code: code || '', state: state || '' };
if (namespace) {
payload.namespace = namespace;
}
return payload;
}

export default Route.extend({
templateName: 'vault/cluster/oidc-callback',
model() {
// left blank so we render the template immediately
},
afterModel() {
let { auth_path: path, code, state } = this.paramsFor(this.routeName);
let { namespaceQueryParam: namespace } = this.paramsFor('vault.cluster');
// 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 || '' };
if (namespace) {
queryParams.namespace = namespace;
}
window.opener.postMessage(queryParams, window.origin);
const { auth_path: path, code, state } = this.paramsFor(this.routeName);
const { namespaceQueryParam: namespace } = this.paramsFor('vault.cluster');
const queryString = window.location.search;
const payload = getParamsForCallback({ path, code, state, namespace }, queryString);
window.opener.postMessage(payload, window.origin);
},
setupController(controller) {
this._super(...arguments);
Expand Down
254 changes: 114 additions & 140 deletions ui/tests/unit/routes/vault/cluster/oidc-callback-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
* SPDX-License-Identifier: MPL-2.0
*/

import { module, skip, test } from 'qunit';
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import sinon from 'sinon';
import { getParamsForCallback } from 'vault/routes/vault/cluster/oidc-callback';

module('Unit | Route | vault/cluster/oidc-callback', function (hooks) {
setupTest(hooks);
Expand Down Expand Up @@ -52,159 +53,132 @@ module('Unit | Route | vault/cluster/oidc-callback', function (hooks) {
assert.ok(this.route);
});

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',
code: this.code,
module('getParamsForCallback helper fn', function () {
test('it parses params correctly with regular inputs and no namespace', function (assert) {
const qp = {
state: 'my-state',
code: 'my-code',
path: 'oidc-path',
};
};
this.route.afterModel();
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: this.code,
path: 'oidc-dev',
namespace: 'test-ns',
state: this.state,
source: 'oidc-callback',
},
'ns from state not cluster'
);
});
const searchString = `?code=my-code&state=my-state`;
const results = getParamsForCallback(qp, searchString);
assert.deepEqual(results, { source: 'oidc-callback', ...qp });
});

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,
code: this.code,
test('it parses params correctly regular inputs and namespace param', function (assert) {
hashishaw marked this conversation as resolved.
Show resolved Hide resolved
const params = {
state: 'my-state',
code: 'my-code',
path: 'oidc-path',
namespace: 'my-namespace',
};
};
this.route.afterModel();
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: this.code,
path: this.path,
namespace: 'admin',
state: this.state,
source: 'oidc-callback',
},
`namespace is from cluster's namespaceQueryParam`
);
});
const results = getParamsForCallback(params, '?code=my-code&state=my-state&namespace=my-namespace');
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if the namespace in the search string should be different from the namespace in the params so we are testing it's correctly preferring the namespace from the query string? But I see below there's a test for that so I'm not exactly sure what we're testing for here 🤔 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The string is basically the expected URL, and the namespace in the params is what the Ember route parses from the URL. So we're not testing differences here, just trying to match the real world scenario :)

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah - so many params! I got mixed up because I thought the queryString passed to getParamsForCallback was always whatever was from window.location.search and I never saw namespace= as part of that search string

assert.deepEqual(results, { source: 'oidc-callback', ...params });
});

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.propEqual(
this.windowStub.lastCall.args[0],
{
code: this.code,
path: this.path,
namespace: 'parent-ns/child-ns',
state: this.state,
source: 'oidc-callback',
},
'it has correct nested ns from state and sets as namespace param'
);
});
/*
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'

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: '',
code: '',
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' }
*/
test('it parses params correctly with regular inputs and namespace in state (unencoded)', function (assert) {
const searchString = '?code=my-code&state=my-state,ns=foo/bar';
const params = {
state: 'my-state,ns', // Ember parses the QP incorrectly if unencoded
Copy link
Contributor

Choose a reason for hiding this comment

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

🤩

code: 'my-code',
path: 'oidc-path',
};
};
this.route.afterModel();
assert.propEqual(
this.windowStub.lastCall.args[0],
{
path: '',
state: '',
code: '',
const results = getParamsForCallback(params, searchString);
assert.deepEqual(results, {
source: 'oidc-callback',
},
'model hook returns with empty params'
);
});
...params,
state: 'my-state',
namespace: 'foo/bar',
});
});

skip('the afterModel hook returns when state param does not exist', function (assert) {
this.routeName = 'vault.cluster.oidc-callback';
this.callbackUrlQueryParams('stateless');
this.route.afterModel();
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: this.code,
path: 'oidc',
state: '',
source: 'oidc-callback',
},
'model hook returns empty string when state param nonexistent'
);
});
test('it parses params correctly with regular inputs and namespace in state (encoded)', function (assert) {
const qp = {
state: 'my-state,ns=foo/bar', // Ember parses the QP correctly when encoded
code: 'my-code',
path: 'oidc-path',
};
const searchString = `?code=my-code&state=${encodeURIComponent(qp.state)}`;
const results = getParamsForCallback(qp, searchString);
assert.deepEqual(results, { source: 'oidc-callback', ...qp, state: 'my-state', namespace: 'foo/bar' });
});

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: '',
code: '',
test('namespace in state takes precedence over namespace in route (encoded)', function (assert) {
const qp = {
state: 'my-state,ns=foo/bar',
code: 'my-code',
path: 'oidc-path',
namespace: 'other/ns',
};
};
this.route.afterModel();
assert.propEqual(
this.windowStub.lastCall.args[0],
{
code: '',
namespace: 'ns1',
path: '',
const searchString = `?code=my-code&state=${encodeURIComponent(
qp.state
)}&namespace=${encodeURIComponent(qp.namespace)}`;
const results = getParamsForCallback(qp, searchString);
assert.deepEqual(results, {
source: 'oidc-callback',
state: '',
},
'model hook returns with empty parameters'
);
});
...qp,
state: 'my-state',
namespace: 'foo/bar',
});
});

/*
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'
test('namespace in state takes precedence over namespace in route (unencoded)', function (assert) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't seen the namespace sent in the route like this! When does this happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When the URL includes the namespace parameter that's the "in route" version. It might not be an actual case in the wild, but behaviorally the method overwrites the route namespace param with the one in state so I wanted to capture that behavior as expected

Copy link
Contributor

Choose a reason for hiding this comment

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

Gotcha! Thanks for explaining 😄

const qp = {
state: 'my-state,ns',
code: 'my-code',
path: 'oidc-path',
namespace: 'other/ns',
};
const searchString = `?code=${qp.code}&state=my-state,ns=foo/bar&namespace=${qp.namespace}`;
const results = getParamsForCallback(qp, searchString);
assert.deepEqual(results, {
source: 'oidc-callback',
...qp,
state: 'my-state',
namespace: 'foo/bar',
});
});

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,
test('it parses params correctly when window.location.search is empty (HCP scenario)', function (assert) {
hashishaw marked this conversation as resolved.
Show resolved Hide resolved
const params = {
state: 'some-state,ns=admin/child-ns',
code: 'my-code',
namespace: 'admin',
path: this.path,
path: 'oidc-path',
};
const results = getParamsForCallback(params, '');
assert.deepEqual(results, {
source: 'oidc-callback',
code: 'my-code',
path: 'oidc-path',
state: 'some-state',
namespace: 'admin/child-ns',
});
});

test('it defaults to reasonable values if query params are missing', function (assert) {
const params = {
path: 'oidc-path',
};
const results = getParamsForCallback(params, '');
assert.deepEqual(results, {
source: 'oidc-callback',
state: this.state,
},
'namespace is parsed correctly'
);
code: '',
path: 'oidc-path',
state: '',
});
});
});
});