mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-26 00:51:08 +02:00
* UI: Move `wrapped_token` login functionality to route (#30465) * move token unwrap functionality to page component * update mfa test * remove wrapped_token logic from page component * more cleanup to relocate unwrap logic * move wrapped_token to route * move unwrap tests to acceptance * move mfa form back * add some padding * update mfa-form tests * get param from params * wait for auth form on back * run rests * UI: Add MFA support for SSO methods (#30489) * initial implementation of mfa validation for sso methods * update typescript interfaces * add stopgap changes to auth service * switch order backend is defined * update login form for tests even though it will be deleted * attempt to stabilize wrapped_query test * =update login form test why not * Update ui/app/components/auth/form/saml.ts Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com> --------- Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com> * Move CSP error to page component (#30492) * initial implementation of mfa validation for sso methods * update typescript interfaces * add stopgap changes to auth service * switch order backend is defined * update login form for tests even though it will be deleted * attempt to stabilize wrapped_query test * =update login form test why not * move csp error to page component * move csp error to page component * Move fetching unauthenticated mounts to the route (#30509) * rename namespace arg to namespaceQueryParam * move fetch mounts to route * add margin to sign in button spacing * update selectors for oidc provider test * add todo delete comments * fix arg typo in test * change method name * fix args handling tab click * remove tests that no longer relate to components functionality * add tests for preselectedAuthType functionality * move typescript interfaces, fix selector * add await * oops * move format method down, make private * move tab formatting to the route * move to page object * fix token unwrap aborting transition * not sure what that is doing there.. * add comments * rename to presetAuthType * use did-insert instead * UI: Implement `Auth::FormTemplate` (#30521) * replace Auth::LoginForm with Auth::FormTemplate * first round of test updates * return null if mounts object is empty * add comment and test for empty sys/internal/mounts data * more test updates * delete listing_visibility test, delete login-form component test * update divs to Hds::Card::Container * add overflow class * remove unused getters * move requesting stored auth type to page component * fix typo * Update ui/app/components/auth/form/oidc-jwt.ts make comment make more sense * small cleanup items, update imports * Delete old auth components (#30527) * delete old components * update codeowners * Update `with` query param functionality (#30537) * update path input to type=hidden * add test coverage * update page test * update auth route * delete login form * update ent test * consolidate logic in getter * add more comments * more comments.. * rename selector * refresh model as well * redirect for invalid query params * move unwrap to redirect * only redirect on invalid query params * add tests for query param * test selector updates * remove todos, update relevant ones with initials * add changelog --------- Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
284 lines
9.5 KiB
TypeScript
284 lines
9.5 KiB
TypeScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
import AuthBase from './base';
|
|
import Ember from 'ember';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import { service } from '@ember/service';
|
|
import { restartableTask, task, timeout, waitForEvent } from 'ember-concurrency';
|
|
import { action } from '@ember/object';
|
|
import { waitFor } from '@ember/test-waiters';
|
|
import errorMessage from 'vault/utils/error-message';
|
|
|
|
import type AdapterError from 'vault/@ember-data/adapter/error';
|
|
import type AuthService from 'vault/vault/services/auth';
|
|
import type FlagsService from 'vault/services/flags';
|
|
import type RoleJwtModel from 'vault/models/role-jwt';
|
|
import type Store from '@ember-data/store';
|
|
import type { HTMLElementEvent } from 'vault/forms';
|
|
|
|
/**
|
|
* @module Auth::Form::OidcJwt
|
|
* see Auth::Base
|
|
*
|
|
* OIDC can be configured at 'jwt' or 'oidc', see https://developer.hashicorp.com/vault/docs/auth/jwt
|
|
* we use the same template because displaying the JWT token input depends on the error message
|
|
* returned when fetching :path/oidc/auth_url
|
|
*/
|
|
|
|
interface JwtLoginData {
|
|
namespace?: string;
|
|
path?: string;
|
|
role?: string;
|
|
jwt?: string;
|
|
}
|
|
|
|
interface OidcLoginData {
|
|
token: string;
|
|
}
|
|
|
|
const ERROR_WINDOW_CLOSED =
|
|
'The provider window was closed before authentication was complete. Your web browser may have blocked or closed a pop-up window. Please check your settings and click Sign In to try again.';
|
|
const ERROR_MISSING_PARAMS =
|
|
'The callback from the provider did not supply all of the required parameters. Please click Sign In to try again. If the problem persists, you may want to contact your administrator.';
|
|
const ERROR_JWT_LOGIN = 'OIDC login is not configured for this mount';
|
|
export { ERROR_WINDOW_CLOSED, ERROR_MISSING_PARAMS, ERROR_JWT_LOGIN };
|
|
|
|
export default class AuthFormOidcJwt extends AuthBase {
|
|
@service declare readonly auth: AuthService;
|
|
@service declare readonly flags: FlagsService;
|
|
@service declare readonly store: Store;
|
|
|
|
loginFields = [
|
|
{
|
|
name: 'role',
|
|
helperText: 'Vault will use the default role to sign in if this field is left blank.',
|
|
},
|
|
];
|
|
|
|
// set by form inputs
|
|
_formData: FormData = new FormData();
|
|
|
|
// set during auth prep and login workflow
|
|
@tracked fetchedRole: RoleJwtModel | null = null;
|
|
@tracked errorMessage = '';
|
|
@tracked isOIDC = true;
|
|
|
|
get tasksAreRunning() {
|
|
return this.prepareForOIDC.isRunning || this.exchangeOIDC.isRunning;
|
|
}
|
|
|
|
get icon() {
|
|
return this?.fetchedRole?.providerIcon || '';
|
|
}
|
|
|
|
get providerName() {
|
|
return `with ${this?.fetchedRole?.providerName || 'OIDC Provider'}`;
|
|
}
|
|
|
|
@action
|
|
initializeFormData(element: HTMLFormElement) {
|
|
this._formData = new FormData(element);
|
|
this.fetchRole.perform();
|
|
}
|
|
|
|
@action
|
|
updateFormData(event: HTMLElementEvent<HTMLInputElement>) {
|
|
const { name, value } = event.target;
|
|
this._formData?.set(name, value);
|
|
|
|
// re-fetch role if the following inputs have changed. namespace is not included because
|
|
// when it changes the route model refreshes and a new component instantiates.
|
|
if (['path', 'role'].includes(name)) {
|
|
this.fetchRole.perform(500);
|
|
}
|
|
}
|
|
|
|
fetchRole = restartableTask(async (wait = 0) => {
|
|
// task is restartable so if the user starts typing again,
|
|
// it will cancel and restart from the beginning.
|
|
if (wait) await timeout(wait);
|
|
|
|
const { namespace = '', path = '', role = '' } = this.parseFormData(this._formData);
|
|
const id = JSON.stringify([path, role]);
|
|
|
|
// reset state
|
|
this.fetchedRole = null;
|
|
this.errorMessage = '';
|
|
|
|
try {
|
|
this.fetchedRole = await this.store.findRecord('role-jwt', id, {
|
|
adapterOptions: { namespace },
|
|
});
|
|
this.isOIDC = true;
|
|
} catch (e) {
|
|
const { httpStatus } = e as AdapterError;
|
|
const message = errorMessage(e);
|
|
// track errors but they only display on submit
|
|
this.errorMessage =
|
|
httpStatus === 400 ? 'Invalid role. Please try again.' : `Error fetching role: ${message}`;
|
|
// if the mount is configured for JWT authentication via static keys, JWKS, or OIDC discovery
|
|
// this specific error is returned. Flip the isOIDC boolean accordingly, otherwise assume OIDC.
|
|
this.isOIDC = message !== ERROR_JWT_LOGIN;
|
|
}
|
|
});
|
|
|
|
login = task(
|
|
waitFor(async (submitData) => {
|
|
if (this.isOIDC) {
|
|
this.startOIDCAuth();
|
|
} else {
|
|
this.continueLogin(submitData);
|
|
}
|
|
})
|
|
);
|
|
|
|
async continueLogin(data: JwtLoginData | OidcLoginData) {
|
|
try {
|
|
// TODO CMB backend should probably be path, but holding off refactor since api service may remove need all together
|
|
// OIDC callback returns a token so authenticate with that
|
|
const backend = this.isOIDC && 'token' in data ? 'token' : this.args.authType;
|
|
|
|
const authResponse = await this.auth.authenticate({
|
|
clusterId: this.args.cluster.id,
|
|
backend,
|
|
data,
|
|
selectedAuth: this.args.authType,
|
|
});
|
|
|
|
// responsible for redirect after auth data is persisted
|
|
this.handleAuthResponse(authResponse);
|
|
} catch (error) {
|
|
this.onError(error as Error);
|
|
}
|
|
}
|
|
|
|
// * OIDC AUTH PART 1
|
|
// 1. request oidc/auth_url to check for config errors, if none continue
|
|
// 2. open popup window at auth_url
|
|
async startOIDCAuth() {
|
|
await this.fetchRole.perform();
|
|
|
|
const error =
|
|
this.fetchedRole && !this.fetchedRole.authUrl
|
|
? 'Missing auth_url. Please check that allowed_redirect_uris for the role include this mount path.'
|
|
: this.errorMessage || null;
|
|
|
|
if (error) {
|
|
this.onError(error);
|
|
} else {
|
|
const win = window;
|
|
const POPUP_WIDTH = 500;
|
|
const POPUP_HEIGHT = 600;
|
|
const left = win.screen.width / 2 - POPUP_WIDTH / 2;
|
|
const top = win.screen.height / 2 - POPUP_HEIGHT / 2;
|
|
const oidcWindow = win.open(
|
|
this.fetchedRole?.authUrl,
|
|
'vaultOIDCWindow',
|
|
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}`
|
|
);
|
|
|
|
this.prepareForOIDC.perform(oidcWindow);
|
|
}
|
|
}
|
|
|
|
// * OIDC AUTH PART 2
|
|
// 3. watch popups for premature closure
|
|
// 4. wait message event from window.postMessage() in oidc-callback route
|
|
prepareForOIDC = task(async (oidcWindow) => {
|
|
// NOTE TO DEVS: Be careful when updating the OIDC flow and ensure the updates
|
|
// work with implicit flow. See issue https://github.com/hashicorp/vault-plugin-auth-jwt/pull/192
|
|
const thisWindow = window;
|
|
|
|
// start watching the popup window and the current one
|
|
this.watchPopup.perform(oidcWindow);
|
|
this.watchCurrent.perform(oidcWindow);
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
// wait for message posted from oidc callback, see issue https://github.com/hashicorp/vault/issues/12436
|
|
// ensure that postMessage event is from expected source
|
|
const event = (await waitForEvent(thisWindow, 'message')) as unknown as MessageEvent;
|
|
if (event.origin === thisWindow.origin && event.isTrusted && event.data.source === 'oidc-callback') {
|
|
// event.data are params from the oidc callback url parsed by getParamsForCallback in the oidc-callback route
|
|
return this.exchangeOIDC.perform(event.data, oidcWindow);
|
|
}
|
|
}
|
|
});
|
|
|
|
// * OIDC AUTH PART 3
|
|
// 5. check parsed url for expected state params
|
|
// 6. if successful, request client_token from oidc/callback
|
|
// 7. close popups and continue login with client_token
|
|
exchangeOIDC = task(async (oidcState, oidcWindow) => {
|
|
if (oidcState === null || oidcState === undefined) {
|
|
return;
|
|
}
|
|
|
|
const { path, state, code } = oidcState;
|
|
if (!path || !state || !code) {
|
|
return this.cancelLogin(oidcWindow, ERROR_MISSING_PARAMS);
|
|
}
|
|
|
|
let resp;
|
|
// do the OIDC exchange, set the token and continue login flow
|
|
try {
|
|
const adapter = this.store.adapterFor('auth-method');
|
|
resp = await adapter.exchangeOIDC(path, state, code);
|
|
this.closeWindow(oidcWindow);
|
|
} catch (e) {
|
|
// If there was an error on Vault's end, close the popup
|
|
// and show the error on the login screen
|
|
return this.cancelLogin(oidcWindow, errorMessage(e));
|
|
}
|
|
|
|
const { client_token, mfa_requirement } = resp.auth;
|
|
if (mfa_requirement) {
|
|
return this.handleMfa(mfa_requirement, path);
|
|
} else if (client_token) {
|
|
return this.continueLogin({ token: client_token });
|
|
} else {
|
|
// If there's a problem with the OIDC exchange the auth workflow should fail earlier.
|
|
// Including this catch just in case, though it's unlikely this will be hit.
|
|
this.handleOIDCError('Missing token. Please try again.');
|
|
}
|
|
});
|
|
|
|
// MANAGE POPUPS
|
|
watchPopup = task(async (oidcWindow) => {
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const WAIT_TIME = Ember.testing ? 50 : 500;
|
|
|
|
await timeout(WAIT_TIME);
|
|
if (!oidcWindow || oidcWindow.closed) {
|
|
return this.handleOIDCError(ERROR_WINDOW_CLOSED);
|
|
}
|
|
}
|
|
});
|
|
|
|
watchCurrent = task(async (oidcWindow) => {
|
|
// when user is about to change pages, close the popup window
|
|
await waitForEvent(window, 'beforeunload');
|
|
oidcWindow.close();
|
|
});
|
|
|
|
cancelLogin(oidcWindow: Window, errorMessage: string) {
|
|
this.closeWindow(oidcWindow);
|
|
this.handleOIDCError(errorMessage);
|
|
}
|
|
|
|
closeWindow(oidcWindow: Window) {
|
|
this.watchPopup.cancelAll();
|
|
this.watchCurrent.cancelAll();
|
|
oidcWindow.close();
|
|
}
|
|
|
|
handleOIDCError(err: string) {
|
|
this.prepareForOIDC.cancelAll();
|
|
this.exchangeOIDC.cancelAll();
|
|
this.onError(err);
|
|
}
|
|
}
|