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 1 commit
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
49 changes: 28 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,40 @@

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=')) {
// TODO: does it ever get here, since Ember params strips out `=` and after?
hashishaw marked this conversation as resolved.
Show resolved Hide resolved
[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
101 changes: 99 additions & 2 deletions ui/tests/unit/routes/vault/cluster/oidc-callback-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { module, skip, 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 @@ -48,6 +49,102 @@ module('Unit | Route | vault/cluster/oidc-callback', function (hooks) {
this.callbackUrlQueryParams('');
});

module('getParamsForCallback helper fn', function () {
test('it parses params correctly with regular inputs no namespace', function (assert) {
const params = {
state: 'my-state',
code: 'my-code',
path: 'oidc-path',
};
const results = getParamsForCallback(params, '?code=my-code&state=my-state');
assert.deepEqual(results, { source: 'oidc-callback', ...params });
});

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/nested',
};
const results = getParamsForCallback(params, '?code=my-code&state=my-state');
assert.deepEqual(results, { source: 'oidc-callback', ...params });
});

test('it parses params correctly regular inputs and namespace in state', function (assert) {
hashishaw marked this conversation as resolved.
Show resolved Hide resolved
const queryString = '?code=my-code&state=my-state,ns=blah';
const params = {
state: 'my-state,ns', // mock what Ember does with the state QP
hashishaw marked this conversation as resolved.
Show resolved Hide resolved
code: 'my-code',
path: 'oidc-path',
};
const results = getParamsForCallback(params, queryString);
assert.deepEqual(results, { source: 'oidc-callback', ...params, state: 'my-state', namespace: 'blah' });
});

test('namespace in state takes precedence over namespace in route', function (assert) {
const queryString = '?code=my-code&state=my-state,ns=some-ns/foo';
const params = {
state: 'my-state,ns', // mock what Ember does with the state QP
code: 'my-code',
path: 'oidc-path',
namespace: 'some-ns',
};
const results = getParamsForCallback(params, queryString);
assert.deepEqual(results, {
source: 'oidc-callback',
...params,
state: 'my-state',
namespace: 'some-ns/foo',
});
});

test('correctly decodes path and state', function (assert) {
const searchString = '?code=my-code&state=spaces%20state,ns=some.ns%2Fchild';
const params = {
state: 'spaces state,ns',
code: 'my-code',
path: 'oidc-path',
};
const results = getParamsForCallback(params, searchString);
assert.deepEqual(results, {
source: 'oidc-callback',
state: 'spaces state',
code: 'my-code',
path: 'oidc-path',
namespace: 'some.ns/child',
});
});

test('correctly decodes namespace in state', function (assert) {
const queryString = '?code=my-code&state=spaces%20state,ns=spaces%20ns';
hashishaw marked this conversation as resolved.
Show resolved Hide resolved
const params = {
state: 'spaces state,ns', // mock what Ember does with the state QP
hashishaw marked this conversation as resolved.
Show resolved Hide resolved
code: 'my-code',
path: 'oidc-path',
};
const results = getParamsForCallback(params, queryString);
assert.deepEqual(results, {
source: 'oidc-callback',
state: 'spaces state',
code: 'my-code',
path: 'oidc-path',
namespace: 'spaces ns',
});
});

test('it parses params correctly when window.location.search is empty', function (assert) {
const params = {
state: 'my-state',
code: 'my-code',
path: 'oidc-path',
namespace: 'ns1',
};
const results = getParamsForCallback(params, '');
assert.deepEqual(results, { source: 'oidc-callback', ...params, state: 'my-state' });
});
});

test('it calls route', function (assert) {
assert.ok(this.route);
});
Expand Down Expand Up @@ -182,11 +279,11 @@ module('Unit | Route | vault/cluster/oidc-callback', function (hooks) {
/*
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'
'?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' }
Expand Down