mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-24 16:11:08 +02:00
* updates api client vars to snake_case for custom messages * updates api client vars to snake_case for tools * updates api client vars to snake_case for sync * updates api client vars to snake_case for secrets engine * updates api client vars to snake_case for auth * updates api client vars to snake_case for usage * updates api client dep to point to gh repo * fixes custom-messages service unit tests * fixes configure-ssh test * fixes configure-ssh test...again
292 lines
9.5 KiB
TypeScript
292 lines
9.5 KiB
TypeScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
import AuthBase from './base';
|
|
import Ember from 'ember';
|
|
import { action } from '@ember/object';
|
|
import { dasherize } from '@ember/string';
|
|
import {
|
|
DOMAIN_PROVIDER_MAP,
|
|
ERROR_JWT_LOGIN,
|
|
ERROR_MISSING_PARAMS,
|
|
ERROR_POPUP_FAILED,
|
|
ERROR_WINDOW_CLOSED,
|
|
} from 'vault/utils/auth-form-helpers';
|
|
import { restartableTask, task, timeout, waitForEvent } from 'ember-concurrency';
|
|
import { service } from '@ember/service';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import parseURL from 'core/utils/parse-url';
|
|
|
|
import type { HTMLElementEvent } from 'vault/forms';
|
|
import type { JwtOidcAuthUrlResponse, JwtOidcLoginApiResponse } from 'vault/vault/auth/methods';
|
|
import type RouterService from '@ember/routing/router-service';
|
|
|
|
/**
|
|
* @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 UrlParseData {
|
|
hostname: string;
|
|
}
|
|
|
|
export default class AuthFormOidcJwt extends AuthBase {
|
|
@service declare readonly router: RouterService;
|
|
|
|
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 authUrl: string | null = null;
|
|
@tracked errorMessage = '';
|
|
@tracked isOIDC = true;
|
|
|
|
get icon() {
|
|
// Right now there is a bug in HDS where the name includes a space, this line can be removed when we
|
|
// upgrade to an HDS version with the corrected icon name
|
|
if (this.provider === 'Ping Identity') return 'ping-identity ';
|
|
return this.provider ? dasherize(this.provider.toLowerCase()) : '';
|
|
}
|
|
|
|
get providerName() {
|
|
return `with ${this.provider || 'OIDC Provider'}`;
|
|
}
|
|
|
|
get provider() {
|
|
const { hostname } = parseURL(this.authUrl) as UrlParseData;
|
|
if (hostname) {
|
|
const firstMatch = Object.keys(DOMAIN_PROVIDER_MAP).find((name) => hostname.includes(name));
|
|
return firstMatch ? DOMAIN_PROVIDER_MAP[firstMatch as keyof typeof DOMAIN_PROVIDER_MAP] : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@action
|
|
initializeFormData(element: HTMLFormElement) {
|
|
this._formData = new FormData(element);
|
|
this.fetchAuthUrl.perform();
|
|
}
|
|
|
|
@action
|
|
updateFormData(event: HTMLElementEvent<HTMLInputElement>) {
|
|
const { name, value } = event.target;
|
|
// the selectedAuthMethod dropdown is unrelated to login data so no need to track it in the form state.
|
|
if (name === 'selectedAuthMethod') return;
|
|
this._formData?.set(name, value);
|
|
|
|
// re-request auth_url 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.fetchAuthUrl.perform(500);
|
|
}
|
|
}
|
|
|
|
fetchAuthUrl = 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 redirect_uri = this.generateRedirectUri(namespace, path);
|
|
|
|
// reset state
|
|
this.authUrl = null;
|
|
this.errorMessage = '';
|
|
|
|
try {
|
|
const { data } = (await this.api.auth.jwtOidcRequestAuthorizationUrl(path, {
|
|
role,
|
|
redirect_uri,
|
|
})) as JwtOidcAuthUrlResponse;
|
|
this.authUrl = data.auth_url;
|
|
this.isOIDC = true;
|
|
} catch (e) {
|
|
const { status, message } = await this.api.parseError(e);
|
|
// errors are tracked here but they only display on submit
|
|
this.errorMessage =
|
|
// A 400 is returned if OIDC is configured but does not have a default role set.
|
|
status === 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.includes(ERROR_JWT_LOGIN);
|
|
}
|
|
});
|
|
|
|
generateRedirectUri(namespace = '', path = '') {
|
|
const origin = window.location.origin;
|
|
const qp = namespace ? { namespace } : {};
|
|
const routeUrl = this.router.urlFor(
|
|
'vault.cluster.oidc-callback',
|
|
{ auth_path: path },
|
|
{ queryParams: qp }
|
|
);
|
|
return `${origin}${routeUrl}`;
|
|
}
|
|
|
|
// * LOGIN WORKFLOW BEGINS
|
|
async loginRequest(formData: JwtLoginData) {
|
|
if (this.isOIDC) {
|
|
return await this.loginOidc();
|
|
} else {
|
|
return await this.loginJwt(formData);
|
|
}
|
|
}
|
|
|
|
async loginJwt(formData: JwtLoginData) {
|
|
const { path, jwt, role } = formData;
|
|
const { auth } = (await this.api.auth.jwtLogin(path, { jwt, role })) as JwtOidcLoginApiResponse;
|
|
// displayName is not returned by auth response and is set in persistAuthData
|
|
return this.normalizeAuthResponse(auth, {
|
|
authMountPath: path,
|
|
token: auth.client_token,
|
|
ttl: auth.lease_duration,
|
|
});
|
|
}
|
|
|
|
async loginOidc() {
|
|
const oidcWindow = await this.startOIDCAuth();
|
|
if (oidcWindow) {
|
|
try {
|
|
// Initiate watching for the popup and current window
|
|
this.watchPopup.perform(oidcWindow);
|
|
this.watchCurrent.perform(oidcWindow);
|
|
const eventData = await this.prepareForOIDC();
|
|
const { auth, path } = await this.exchangeOIDC(eventData);
|
|
// displayName is not returned by auth response and is set in persistAuthData
|
|
return this.normalizeAuthResponse(auth, {
|
|
authMountPath: path,
|
|
token: auth.client_token,
|
|
ttl: auth.lease_duration,
|
|
});
|
|
} finally {
|
|
this.closeWindow(oidcWindow);
|
|
}
|
|
} else {
|
|
throw `Failed to open OIDC popup window. ${ERROR_POPUP_FAILED}`;
|
|
}
|
|
}
|
|
|
|
// * 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.fetchAuthUrl.perform();
|
|
|
|
if (!this.authUrl) {
|
|
const error =
|
|
// authUrl is an empty string if the request succeeds but a role is not properly configured.
|
|
this.authUrl === ''
|
|
? 'Missing auth_url. Please check that allowed_redirect_uris for the role include this mount path.'
|
|
: this.errorMessage || 'Unknown OIDC error. Check the Vault logs and try again.';
|
|
throw error;
|
|
}
|
|
|
|
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.authUrl,
|
|
'vaultOIDCWindow',
|
|
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}`
|
|
);
|
|
|
|
return oidcWindow;
|
|
}
|
|
|
|
// * OIDC AUTH PART 2
|
|
// 3. watch popups for premature closure
|
|
// 4. wait for message event from window.postMessage() in oidc-callback route
|
|
async prepareForOIDC() {
|
|
// 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;
|
|
|
|
// 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 event.data;
|
|
}
|
|
}
|
|
}
|
|
|
|
// * 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
|
|
|
|
async exchangeOIDC(oidcState: { path: string; state: string; code: string }) {
|
|
const { path, state, code } = oidcState;
|
|
if (!path || !state || !code) {
|
|
throw ERROR_MISSING_PARAMS;
|
|
}
|
|
|
|
// do the OIDC exchange, set the token and continue login flow
|
|
const { auth } = (await this.api.auth.jwtOidcCallback(
|
|
path,
|
|
undefined,
|
|
code,
|
|
state
|
|
)) as JwtOidcLoginApiResponse;
|
|
return { auth, path };
|
|
}
|
|
//* END LOGIN METHODS
|
|
|
|
// 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) {
|
|
// Since watchPopup isn't awaited, errors thrown here won't bubble up
|
|
// and so we must call onError directly instead.
|
|
this.onError(ERROR_WINDOW_CLOSED);
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
watchCurrent = task(async (oidcWindow) => {
|
|
// when user is about to change pages, close the popup window
|
|
await waitForEvent(window, 'beforeunload');
|
|
oidcWindow.close();
|
|
});
|
|
|
|
cancelLogin() {
|
|
this.login.cancelAll();
|
|
}
|
|
|
|
closeWindow(oidcWindow: Window) {
|
|
this.watchPopup.cancelAll();
|
|
this.watchCurrent.cancelAll();
|
|
oidcWindow.close();
|
|
}
|
|
}
|