Jordan Reimer 8700becc45
[UI] Ember Data Migration - API Property Casing (#31325)
* 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
2025-07-18 09:32:01 -06:00

194 lines
6.8 KiB
TypeScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import AuthBase from './base';
import Ember from 'ember';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { SamlWriteSsoServiceUrlRequestClientTypeEnum } from '@hashicorp/vault-client-typescript';
import { sanitizePath } from 'core/utils/sanitize-path';
import { task, timeout, waitForEvent } from 'ember-concurrency';
import uuid from 'core/utils/uuid';
import { ERROR_POPUP_FAILED, ERROR_TIMEOUT, ERROR_WINDOW_CLOSED } from 'vault/utils/auth-form-helpers';
import type { SamlLoginApiResponse, SamlSsoServiceUrlApiResponse } from 'vault/vault/auth/methods';
/**
* @module Auth::Form::Saml
* see Auth::Base
*/
interface SamlRole {
sso_service_url: string;
token_poll_id: string;
client_verifier: string;
}
export default class AuthFormSaml extends AuthBase {
loginFields = [
{
name: 'role',
helperText: 'Vault will use the default role to sign in if this field is left blank.',
},
];
get canLoginSaml() {
return window.isSecureContext;
}
/* Saml auth flow on login button click:
* 1. find role-saml record which returns role info
* 2. open popup at url defined returned from role
* 3. watch popup window for close (and cancel polling if it closes)
* 4. poll vault for 200 token response
* 5. close popup, stop polling, and trigger onSubmit with token data
*/
async loginRequest(formData: { namespace: string; path: string; role: string }) {
// submit data is parsed by base.ts and a path will always have a value.
// either the default of auth type, or the custom inputted path
const { namespace, path, role } = formData;
const fetchedRole = await this.fetchSamlRole({ namespace, path, role });
const samlWindow = await this.startSAMLAuth(fetchedRole.sso_service_url);
if (samlWindow) {
try {
// start watching the popup window and the current one
this.watchPopup.perform(samlWindow);
this.watchCurrent.perform(samlWindow);
const { auth } = await this.exchangeSAMLTokenPollID(fetchedRole, { path });
// displayName is not included in auth response - it is set in persistAuthData
return this.normalizeAuthResponse(auth, {
authMountPath: path,
token: auth.client_token,
ttl: auth.lease_duration,
});
} finally {
this.closeWindow(samlWindow);
}
} else {
throw `Failed to open SAML popup window. ${ERROR_POPUP_FAILED}`;
}
}
cancelLogin() {
this.login.cancelAll();
}
// Fetch role to get sso_service_url which is where popup is opened
async fetchSamlRole({ namespace = '', path = '', role = '' }): Promise<SamlRole> {
// Create the client verifier and challenge
const verifier = uuid();
const client_challenge = await this.generateClientChallenge(verifier);
const acs_url = this.generateAcsUrl(path, namespace);
const client_type = SamlWriteSsoServiceUrlRequestClientTypeEnum.BROWSER; // 'browser'
// Kick off the authentication flow by generating the SSO service URL
// It requires the client challenge generated from the verifier. We'll
// later provide the verifier to match up with the challenge on the server
// when we poll for the Vault token by its returned token poll ID.
const { data } = (await this.api.auth.samlWriteSsoServiceUrl(path, {
acs_url,
client_challenge,
client_type,
role,
})) as SamlSsoServiceUrlApiResponse;
return {
...data,
client_verifier: verifier,
};
}
async startSAMLAuth(ssoServiceUrl: string): Promise<Window | null> {
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 samlWindow = win.open(
ssoServiceUrl,
'vaultSAMLWindow',
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}`
);
return samlWindow;
}
async exchangeSAMLTokenPollID(fetchedRole: SamlRole, { path = '' }) {
const WAIT_TIME = Ember.testing ? 50 : 1000;
const MAX_TRIES = Ember.testing ? 3 : 180; // 180 is 3 minutes in seconds
// Wait up to 3 minutes for a token to become available
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
// Poll every one second for the token to become available
await timeout(WAIT_TIME);
try {
const { client_verifier, token_poll_id } = fetchedRole;
// Exit loop if there's a response
return (await this.api.auth.samlWriteToken(path, {
client_verifier,
token_poll_id,
})) as SamlLoginApiResponse;
} catch (e) {
const { message, status } = await this.api.parseError(e);
if (status === 401) {
// Continue to retry on 401 Unauthorized
continue;
}
// Just throw the message string because parent onError method will fail if it attempts to re-parse an error.
throw message;
}
}
throw ERROR_TIMEOUT;
}
// MANAGE POPUPS
watchPopup = task(async (samlWindow) => {
// eslint-disable-next-line no-constant-condition
while (true) {
const WAIT_TIME = Ember.testing ? 50 : 500;
await timeout(WAIT_TIME);
if (!samlWindow || samlWindow.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 (samlWindow) => {
// when user is about to change pages, close the popup window
await waitForEvent(window, 'beforeunload');
samlWindow?.close();
});
closeWindow(samlWindow: Window) {
this.watchPopup.cancelAll();
this.watchCurrent.cancelAll();
samlWindow.close();
}
// generates a client challenge from a verifier for PKCE (Proof Key for Code Exchange).
// The client challenge is the base64(sha256(verifier)). The verifier is
// later presented to the server to obtain the resulting Vault token.
async generateClientChallenge(verifier: string) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = new Uint8Array(hashBuffer);
return btoa(String.fromCharCode(...hashArray));
}
generateAcsUrl(path: string, namespace: string) {
const baseUrl = `${window.location.origin}/v1`;
const ns = namespace ? `${encodePath(sanitizePath(namespace))}/` : '';
const mountPath = encodePath(sanitizePath(path));
// example with "admin" namespace: '${VAULT_ADDR}/v1/admin/auth/saml/callback';
// example with "root" namespace: '${VAULT_ADDR}/v1/auth/saml/callback';
return `${baseUrl}/${ns}auth/${mountPath}/callback`;
}
}