Skip to content
This repository has been archived by the owner on Jan 8, 2024. It is now read-only.

UI/OIDC auth #2688

Merged
merged 16 commits into from
Nov 10, 2021
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/2688.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:feature
ui/auth: users can now authenticate through the UI using an OIDC provider
```
3 changes: 1 addition & 2 deletions internal/auth/oidc/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ func ProviderConfig(am *pb.AuthMethod_OIDC, sc *pb.ServerConfig) (*oidc.Config,
)

// We also add all the addresses our UI might use with the server advertise addrs.
// TODO(mitchellh): once the UI path is determined, we have to update the path
if sc != nil {
for _, addr := range sc.AdvertiseAddrs {
var u url.URL
Expand All @@ -49,7 +48,7 @@ func ProviderConfig(am *pb.AuthMethod_OIDC, sc *pb.ServerConfig) (*oidc.Config,
if !addr.Tls {
u.Scheme = "http"
}
u.Path = "/oidc/callback"
u.Path = "/auth/oidc-callback"

allowedUris = append(allowedUris, u.String())
}
Expand Down
48 changes: 48 additions & 0 deletions ui/app/authenticators/oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import OAuth2ImplicitGrantAuthenticator, {
parseResponse as ESAparseResponse,
} from 'ember-simple-auth/authenticators/oauth2-implicit-grant';
import { reject, resolve } from 'rsvp';

import classic from 'ember-classic-decorator';

interface SessionData {
token: string;
}
interface parseResponseObject {
authuser: string;
prompt: string;
scope: string;
code: string;
state: string;
}

@classic
export default class OIDCAuthenticator extends OAuth2ImplicitGrantAuthenticator {
restore(data: SessionData): Promise<SessionData> {
if (data.token) {
return resolve(data);
} else {
return reject();
}
}

authenticate(hash: SessionData): Promise<SessionData> {
if (hash.token !== '') {
this._cleanUpLocalStorage();
return resolve(hash);
} else {
return reject();
}
}

// Used to clean up OIDC information stored in LocalStorage
// during the authentication flow
_cleanUpLocalStorage(): void {
window.localStorage.removeItem('waypointOIDCAuthMethod');
window.localStorage.removeItem('waypointOIDCNonce');
}
}

export function parseResponse(args: string): parseResponseObject {
return ESAparseResponse(args);
}
1 change: 0 additions & 1 deletion ui/app/authenticators/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ interface SessionData {

@classic
export default class TokenAuthenticator extends BaseAuthenticator {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
restore(data: SessionData): Promise<SessionData> {
if (data.token) {
return resolve(data);
Expand Down
19 changes: 19 additions & 0 deletions ui/app/components/oidc-auth-buttons/index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{#each @model.authMethodsList as |authMethod|}}
<Pds::Button
@variant="primary"
disabled={{this.initializeOIDCFlow.isRunning}}
data-test-oidc-provider={{authMethod.name}}
{{on "click" (perform this.initializeOIDCFlow authMethod)}}>
{{#if this.initializeOIDCFlow.isRunning}}
<Spinner @size="16"/>
{{/if}}
{{authMethod.displayName}}
</Pds::Button>
{{/each}}
<hr>
<small>
<LinkTo @route="auth.token">
{{t 'auth.token_link'}}
</LinkTo>
</small>

56 changes: 56 additions & 0 deletions ui/app/components/oidc-auth-buttons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
AuthMethod,
GetAuthMethodRequest,
GetOIDCAuthURLRequest,
ListOIDCAuthMethodsResponse,
Ref,
} from 'waypoint-pb';

import ApiService from 'waypoint/services/api';
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';

interface OIDCAuthButtonsArgs {
model: ListOIDCAuthMethodsResponse.AsObject;
}

export default class OIDCAuthButtonsComponent extends Component<OIDCAuthButtonsArgs> {
model!: ListOIDCAuthMethodsResponse.AsObject;
@service api!: ApiService;

@task
async initializeOIDCFlow(authMethodProvider: AuthMethod.AsObject): Promise<void> {
let authMethodProviderName = authMethodProvider.name;
let urlRequest = new GetOIDCAuthURLRequest();
let authMethodrequest = new GetAuthMethodRequest();

let authMethodRef = new Ref.AuthMethod();
authMethodRef.setName(authMethodProviderName);
authMethodrequest.setAuthMethod(authMethodRef);
urlRequest.setAuthMethod(authMethodRef);

let nonce = this._generateNonce();
urlRequest.setNonce(nonce);

this._storeOIDCAuthData(nonce, authMethodProviderName);
let redirectUri = `${window.location.origin}/auth/oidc-callback`;
urlRequest.setRedirectUri(redirectUri);
let authUrl = await this.api.client.getOIDCAuthURL(urlRequest, this.api.WithMeta());
await window.location.replace(authUrl.getUrl());
}

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

// Store OIDC Data in LocalStorage, this gets cleaned up on authentication
_storeOIDCAuthData(nonce: string, authMethod: string): void {
window.localStorage.setItem('waypointOIDCNonce', nonce);
window.localStorage.setItem('waypointOIDCAuthMethod', authMethod);
}
}
1 change: 1 addition & 0 deletions ui/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Router.map(function () {
this.route('auth', function () {
this.route('invite');
this.route('token');
this.route('oidc-callback');
});
this.route('onboarding', function () {
this.route('install', function () {
Expand Down
21 changes: 21 additions & 0 deletions ui/app/routes/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import ApiService from 'waypoint/services/api';
import { Empty } from 'google-protobuf/google/protobuf/empty_pb';
import { ListOIDCAuthMethodsResponse } from 'waypoint-pb';
import Route from '@ember/routing/route';
import SessionService from 'ember-simple-auth/services/session';
import { inject as service } from '@ember/service';

export default class AuthIndex extends Route {
@service session!: SessionService;
@service api!: ApiService;

async model(): Promise<ListOIDCAuthMethodsResponse.AsObject | undefined> {
let authMethods = await this.api.client.listOIDCAuthMethods(new Empty(), this.api.WithMeta());
if (authMethods.getAuthMethodsList().length) {
let providers = authMethods.toObject();
return providers;
} else {
return;
}
}
}
33 changes: 33 additions & 0 deletions ui/app/routes/auth/oidc-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CompleteOIDCAuthRequest, Ref } from 'waypoint-pb';

import ApiService from 'waypoint/services/api';
import Route from '@ember/routing/route';
import SessionService from 'ember-simple-auth/services/session';
import { parseResponse } from 'waypoint/authenticators/oidc';
import { inject as service } from '@ember/service';

export default class AuthIndex extends Route {
@service session!: SessionService;
@service api!: ApiService;

async model(): Promise<void> {
let oidcParams = parseResponse(window.location.search);
let completeAuthRequest = new CompleteOIDCAuthRequest();
completeAuthRequest.setCode(oidcParams.code);
let authMethodName = window.localStorage.getItem('waypointOIDCAuthMethod');
let authMethodRef = new Ref.AuthMethod();
if (authMethodName) {
authMethodRef.setName(authMethodName);
}
completeAuthRequest.setAuthMethod(authMethodRef);
completeAuthRequest.setRedirectUri(window.location.origin + window.location.pathname);
let nonce = window.localStorage.getItem('waypointOIDCNonce');
if (nonce) {
completeAuthRequest.setNonce(nonce);
}
completeAuthRequest.setState(oidcParams.state);
let resp = await this.api.client.completeOIDCAuth(completeAuthRequest, this.api.WithMeta());
let respObject = resp.toObject();
await this.session.authenticate('authenticator:oidc', respObject);
gregone marked this conversation as resolved.
Show resolved Hide resolved
}
}
14 changes: 13 additions & 1 deletion ui/app/styles/pages/auth-page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
}
}

h2 {
color: rgb(var(--text-muted));
@include Typography.Interface(L);
margin-bottom: scale.$lg-2;
}

hr {
border: none;
border-top: 1px solid rgb(var(--border));
Expand All @@ -59,7 +65,13 @@
color: rgb(var(--text-muted));
}

.button {
.button, .pds-button {
width: 100%;
}
.oidc-callback {
.spinner {
font-size: 48px;
margin: 24px 80px;
}
}
}
25 changes: 18 additions & 7 deletions ui/app/templates/auth/index.hbs
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
<h1>{{t 'auth.title'}}</h1>
<p>{{t 'auth.subtitle'}}</p>

<p>
<LinkTo @route="auth.token" class="button button--primary">
{{t 'auth.button'}}
</LinkTo>
</p>
{{#if @model.authMethodsList}}
<OidcAuthButtons @model={{@model}}/>
{{else}}
<p>
<LinkTo @route="auth.token" class="button button--primary">
{{t 'auth.button'}}
</LinkTo>
</p>
<hr />
<p>
<small>
<ExternalLink href="https://www.waypointproject.io/docs/server/auth/oidc">
{{t 'auth.oidc_docs_link'}}
</ExternalLink>
</small>
</p>
{{/if}}
<hr />
<p>
<small>
Received an invite?
<LinkTo @route="auth.invite">Redeem invite</LinkTo>
</small>
</p>
</p>
4 changes: 4 additions & 0 deletions ui/app/templates/auth/oidc-callback-loading.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="oidc-callback">
<h2>{{t 'auth.loading'}}</h2>
<Spinner @size="48"/>
</div>
4 changes: 4 additions & 0 deletions ui/app/templates/auth/oidc-callback.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="oidc-callback">
<h2>{{t 'auth.loading'}}</h2>
<Spinner @size="48"/>
</div>
11 changes: 8 additions & 3 deletions ui/app/templates/auth/token.hbs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
<h1>Authenticate Waypoint</h1>
<p>To get started, authenticate with a token</p>
<h1>{{t 'login.title'}}</h1>
<p>{{t 'login.subtitle'}}</p>

<LoginToken />

<hr />
<small>
<LinkTo @route="auth">{{t 'login.oidc_link'}}</LinkTo>
</small>
<hr />
<small>
Received an invite?
<LinkTo @route="auth.invite">Redeem invite</LinkTo>
</small>
</small>

24 changes: 13 additions & 11 deletions ui/mirage/config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import Ember from 'ember';
import { logRequestConsole } from './utils';
import failUnhandledRequest from './helpers/fail-unhandled-request';
import { Server } from 'miragejs';

import * as OIDCAuthMethods from './services/oidc-auth-methods';
import * as build from './services/build';
import * as project from './services/project';
import * as config from './services/config';
import * as deployment from './services/deployment';
import * as token from './services/token';
import * as inviteToken from './services/invite-token';
import * as release from './services/release';
import * as versionInfo from './services/version-info';
import * as statusReport from './services/status-report';
import * as job from './services/job';
import * as log from './services/log';
import * as project from './services/project';
import * as pushedArtifact from './services/pushed-artifact';
import * as config from './services/config';
import * as release from './services/release';
import * as statusReport from './services/status-report';
import * as token from './services/token';
import * as versionInfo from './services/version-info';

import Ember from 'ember';
import { Server } from 'miragejs';
import failUnhandledRequest from './helpers/fail-unhandled-request';
import { logRequestConsole } from './utils';

export default function (this: Server): void {
this.namespace = 'hashicorp.waypoint.Waypoint';
Expand Down Expand Up @@ -57,6 +58,7 @@ export default function (this: Server): void {
this.post('/ExpediteStatusReport', statusReport.expediteStatusReport);
this.post('/GetConfig', config.get);
this.post('/SetConfig', config.set);
this.post('/ListOIDCAuthMethods', OIDCAuthMethods.list);

if (!Ember.testing) {
// Pass through all other requests
Expand Down
13 changes: 13 additions & 0 deletions ui/mirage/factories/auth-method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Factory, trait } from 'ember-cli-mirage';

import faker from '../faker';

export default Factory.extend({
name: () => faker.company.companyName(),
displayName: () => faker.company.companyName(),
kind: 0,
google: trait({
name: 'google',
displayName: 'google',
}),
});
13 changes: 13 additions & 0 deletions ui/mirage/models/auth-method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Model } from 'miragejs';
import { OIDCAuthMethod } from 'waypoint-pb';

export default Model.extend({
toProtobuf(): OIDCAuthMethod {
let result = new OIDCAuthMethod();

result.setDisplayName(this.displayName);
result.setKind(this.kind);
result.setName(this.name);
return result;
},
});
13 changes: 13 additions & 0 deletions ui/mirage/services/oidc-auth-methods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Request, Response, RouteHandler } from 'miragejs';

import { ListOIDCAuthMethodsResponse } from 'waypoint-pb';

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
export function list(this: RouteHandler, schema: any, { requestBody }: Request): Response {
let authMethods = schema.authMethods.all();
let authMethodsProtos = authMethods.models?.map((model) => model?.toProtobuf());

let resp = new ListOIDCAuthMethodsResponse();
resp.setAuthMethodsList(authMethodsProtos);
return this.serialize(resp, 'application');
}
Loading