Skip to content

Commit

Permalink
[SSO] Auth Methods and Mock OIDC Flow (#15155)
Browse files Browse the repository at this point in the history
* Big ol first pass at a redirect sign in flow

* dont recursively add queryparams on redirect

* Passing state and code qps

* In which I go off the deep end and embed a faux provider page in the nomad ui

* Buggy but self-contained flow

* Flow auto-delay added and a little more polish to resetting token

* secret passing turned to accessor passing

* Handle SSO Failure

* General cleanup and test fix

* Lintfix

* SSO flow acceptance tests

* Percy snapshots added

* Explicitly note the OIDC test route is mirage only

* Handling failure case for complete-auth

* Leentfeex
  • Loading branch information
philrenaud authored Nov 16, 2022
1 parent f141acb commit 4dc2421
Show file tree
Hide file tree
Showing 17 changed files with 521 additions and 107 deletions.
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 {
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;
}
}
}
94 changes: 94 additions & 0 deletions ui/app/controllers/settings/tokens.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
// @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;
@service flashMessages;

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

@reads('token.secret') secret;

tokenIsValid = false;
tokenIsInvalid = false;

@alias('token.selfToken') tokenRecord;

resetStore() {
Expand All @@ -34,6 +42,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 +79,85 @@ 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;
}
}

async validateSSO() {
const res = await 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,
}),
}
);

if (res.ok) {
const data = await res.json();
this.token.set('secret', data.ACLToken);
this.verifyToken();
this.code = null;
this.state = null;
} else {
this.flashMessages.add({
title: 'Error completing authentication',
message: res.statusText,
type: 'error',
destroyOnClick: false,
sticky: true,
});
this.code = null;
this.state = null;
}
}

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

0 comments on commit 4dc2421

Please sign in to comment.