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

[SSO] Auth Methods and Mock OIDC Flow #15155

Merged
merged 15 commits into from
Nov 16, 2022
Merged
Show file tree
Hide file tree
Changes from 13 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
41 changes: 41 additions & 0 deletions ui/app/adapters/auth-method.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// @ts-check
import { default as ApplicationAdapter, namespace } from './application';
import { dasherize } from '@ember/string';
import classic from 'ember-classic-decorator';

@classic
export default class AuthMethodAdapter extends ApplicationAdapter {
philrenaud marked this conversation as resolved.
Show resolved Hide resolved
namespace = `${namespace}/acl`;

/**
* @param {string} modelName
* @returns {string}
*/
urlForFindAll(modelName) {
return dasherize(this.buildURL(modelName));
}

/**
* @typedef {Object} ACLOIDCAuthURLParams
* @property {string} AuthMethod
* @property {string} RedirectUri
* @property {string} ClientNonce
* @property {Object[]} Meta // NOTE: unsure if array of objects or kv pairs
*/

/**
* @param {ACLOIDCAuthURLParams} params
* @returns
*/
getAuthURL({ AuthMethod, RedirectUri, ClientNonce, Meta }) {
const url = `/${this.namespace}/oidc/auth-url`;
return this.ajax(url, 'POST', {
data: {
AuthMethod,
RedirectUri,
ClientNonce,
Meta,
},
});
}
}
32 changes: 32 additions & 0 deletions ui/app/controllers/oidc-mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import Ember from 'ember';

export default class OidcMockController extends Controller {
@service router;

queryParams = ['auth_method', 'client_nonce', 'redirect_uri', 'meta'];

@action
signIn(fakeAccount) {
const url = `${this.redirect_uri.split('?')[0]}?code=${
fakeAccount.accessor
}&state=success`;
if (Ember.testing) {
this.router.transitionTo(url);
} else {
window.location = url;
}
}

@action
failToSignIn() {
const url = `${this.redirect_uri.split('?')[0]}?state=failure`;
if (Ember.testing) {
this.router.transitionTo(url);
} else {
window.location = url;
}
}
philrenaud marked this conversation as resolved.
Show resolved Hide resolved
}
82 changes: 82 additions & 0 deletions ui/app/controllers/settings/tokens.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
// @ts-check
import { inject as service } from '@ember/service';
import { reads } from '@ember/object/computed';
import Controller from '@ember/controller';
import { getOwner } from '@ember/application';
import { alias } from '@ember/object/computed';
import { action } from '@ember/object';
import classic from 'ember-classic-decorator';
import { tracked } from '@glimmer/tracking';
import Ember from 'ember';

@classic
export default class Tokens extends Controller {
@service token;
@service store;
@service router;

queryParams = ['code', 'state'];

@reads('token.secret') secret;

tokenIsValid = false;
tokenIsInvalid = false;

@alias('token.selfToken') tokenRecord;

resetStore() {
Expand All @@ -34,6 +41,11 @@ export default class Tokens extends Controller {
// Clear out all data to ensure only data the anonymous token is privileged to see is shown
this.resetStore();
this.token.reset();
this.store.findAll('auth-method');
}

get authMethods() {
return this.store.peekAll('auth-method');
}

@action
Expand Down Expand Up @@ -66,4 +78,74 @@ export default class Tokens extends Controller {
}
);
}

// Generate a 20-char nonce, using window.crypto to
// create a sufficiently-large output then trimming
generateNonce() {
let randomArray = new Uint32Array(10);
crypto.getRandomValues(randomArray);
return randomArray.join('').slice(0, 20);
}

@action redirectToSSO(method) {
const provider = method.name;
const nonce = this.generateNonce();

window.localStorage.setItem('nomadOIDCNonce', nonce);
window.localStorage.setItem('nomadOIDCAuthMethod', provider);

method
.getAuthURL({
AuthMethod: provider,
ClientNonce: nonce,
RedirectUri: Ember.testing
? this.router.currentURL
: window.location.toString(),
})
.then(({ AuthURL }) => {
if (Ember.testing) {
this.router.transitionTo(AuthURL.split('/ui')[1]);
} else {
window.location = AuthURL;
}
});
}

@tracked code = null;
@tracked state = null;

get isValidatingToken() {
if (this.code && this.state === 'success') {
this.validateSSO();
return true;
} else {
return false;
}
}

validateSSO() {
this.token
.authorizedRequest('/v1/acl/oidc/complete-auth', {
method: 'POST',
body: JSON.stringify({
AuthMethod: window.localStorage.getItem('nomadOIDCAuthMethod'),
ClientNonce: window.localStorage.getItem('nomadOIDCNonce'),
Code: this.code,
State: this.state,
}),
})
.then(async (response) => {
if (response.ok) {
let json = await response.json();
this.token.set('secret', json.ACLToken);
this.verifyToken();
this.code = null;
this.state = null;
}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

question: Because we're using .then instead of async/await this callback will be fired once the request resolves and the rest of the thread of execution will resume? However, we have an isValidatingToken that will cause a conditional part of the template to render. I wonder what would happen if we simulate a slow network request. Could we trigger an unhandled error as a result? Would this be a good argument to advocate for async/await instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great minds think alike! A slow network request is exactly what we're simulating here:

}, {timing: 1000});

(timing: 1000 will wait a second before returning our token)

As for .thenning vs async/awaiting, I will make that switch! good suggestion.

}

get SSOFailure() {
return this.state === 'failure';
}
}
19 changes: 19 additions & 0 deletions ui/app/models/auth-method.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// @ts-check
import Model from '@ember-data/model';
import { attr } from '@ember-data/model';

export default class AuthMethodModel extends Model {
@attr('string') name;
@attr('string') type;
@attr('string') tokenLocality;
@attr('string') maxTokenTTL;
@attr('boolean') default;
@attr('date') createTime;
@attr('number') createIndex;
@attr('date') modifyTime;
@attr('number') modifyIndex;

getAuthURL(params) {
return this.store.adapterFor('authMethod').getAuthURL(params);
}
}
4 changes: 4 additions & 0 deletions ui/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,8 @@ Router.map(function () {
path: '/path/*absolutePath',
});
});
// Mirage-only route for testing OIDC flow
if (config['ember-cli-mirage']) {
this.route('oidc-mock');
}
philrenaud marked this conversation as resolved.
Show resolved Hide resolved
});
9 changes: 9 additions & 0 deletions ui/app/routes/oidc-mock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Route from '@ember/routing/route';

export default class OidcMockRoute extends Route {
// This route only exists for testing SSO/OIDC flow in development, backed by our mirage server.
// This route won't load outside of a mirage environment, nor will the model hook here return anything meaningful.
model() {
return this.store.findAll('token');
}
}
12 changes: 12 additions & 0 deletions ui/app/routes/settings/tokens.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// @ts-check
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

export default class SettingsTokensRoute extends Route {
@service store;
model() {
return {
authMethods: this.store.findAll('auth-method'),
};
}
}
2 changes: 2 additions & 0 deletions ui/app/services/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export default class TokenService extends Service {
this.fetchSelfTokenPolicies.cancelAll({ resetState: true });
this.fetchSelfTokenAndPolicies.cancelAll({ resetState: true });
this.monitorTokenTime.cancelAll({ resetState: true });
window.localStorage.removeItem('nomadOIDCNonce');
window.localStorage.removeItem('nomadOIDCAuthMethod');
}

kickoffTokenTTLMonitoring() {
Expand Down
32 changes: 32 additions & 0 deletions ui/app/styles/core/forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,35 @@
font-weight: $weight-medium;
}
}


.mock-sso-provider {
margin: 25vh auto;
width: 500px;
top: 25vh;
height: auto;
max-height: 50vh;
box-shadow: 0 0 0 100vw rgba(0, 2, 30, 0.8);
padding: 1rem;
text-align: center;
background-color: white;
h1 {
font-size: 2rem;
font-weight: 400;
}
h2 {
margin-bottom: 1rem;
font-size: 1rem;
}
.providers {
display: grid;
gap: 0.5rem;
button {
background-color: #444;
color: white;
&.error {
background-color: darkred;
}
}
}
}
17 changes: 17 additions & 0 deletions ui/app/templates/oidc-mock.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{{page-title "Mock OIDC Test Page"}}

<section class="mock-sso-provider">
<h1>OIDC Test route: {{this.auth_method}}</h1>
<h2>(Mirage only)</h2>
<div class="providers">
{{#each this.model as |fakeAccount|}}
<button type="button" class="button" {{on "click" (fn this.signIn fakeAccount)}}>
Sign In as {{fakeAccount.name}}
</button>
{{/each}}
<button type="button" class="button error" {{on "click" this.failToSignIn}}>
Simulate Failure
</button>
</div>
</section>
{{outlet}}
Loading