feat(app): add MFA and tsdocker release

This commit is contained in:
2026-05-19 06:20:38 +00:00
parent ddf4861e95
commit 1e563115d0
23 changed files with 1939 additions and 211 deletions
+162 -12
View File
@@ -32,6 +32,18 @@ export class IdpLoginPrompt extends DeesElement {
@state()
accessor oidcConsentError = '';
@state()
accessor mfaChallengeToken = '';
@state()
accessor availableMfaMethods: string[] = [];
@state()
accessor mfaMethod: 'totp' | 'backupCode' = 'totp';
@state()
accessor passkeyMessage = '';
@property()
accessor productOfInterest: string;
@@ -200,6 +212,22 @@ export class IdpLoginPrompt extends DeesElement {
return true;
}
private async completeLoginWithRefreshToken(refreshTokenArg: string) {
const idpState = await IdpState.getSingletonInstance();
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
loginForm?.setStatus('pending', 'obtained refreshToken...');
const jwt = await idpState.idpClient.refreshJwt(refreshTokenArg);
if (!jwt) {
loginForm?.setStatus('error', 'something went wrong');
return;
}
loginForm?.setStatus('success', 'obtained jwt.');
const oidcHandled = await this.handleOidcAfterLogin(jwt);
if (!oidcHandled) {
idpState.domtools.router.pushUrl('/dash/overview');
}
}
public static styles = [
cssManager.defaultStyles,
css`
@@ -324,6 +352,46 @@ export class IdpLoginPrompt extends DeesElement {
];
public render(): TemplateResult {
if (this.mfaChallengeToken) {
const passkeyAvailable = this.availableMfaMethods.includes('passkey');
const backupAvailable = this.availableMfaMethods.includes('backupCode');
return html`
<idp-centercontainer>
<div class="form-header">
<h2>Verify your sign-in</h2>
<p>Enter your authenticator code${passkeyAvailable ? ' or approve with a passkey' : ''}.</p>
</div>
<idp-form
id="mfaForm"
@idp-submit=${(eventArg: CustomEvent<plugins.idpCatalog.IIdpFormSubmitEventDetail>) => {
this.verifyMfaCode(String(eventArg.detail.data.mfaCode || ''));
}}
>
<idp-input
id="mfaCodeInput"
required
name="mfaCode"
label=${this.mfaMethod === 'backupCode' ? 'Backup code' : 'Authenticator code'}
autocomplete="one-time-code"
></idp-input>
<idp-form-submit id="mfaSubmitButton"></idp-form-submit>
</idp-form>
<div class="form-footer">
${backupAvailable ? html`
<a @click=${() => {
this.mfaMethod = this.mfaMethod === 'backupCode' ? 'totp' : 'backupCode';
}}>${this.mfaMethod === 'backupCode' ? 'Use authenticator code' : 'Use backup code'}</a>
` : null}
${passkeyAvailable ? html`
${backupAvailable ? html` · ` : null}
<a @click=${() => this.verifyMfaWithPasskey()}>Use passkey</a>
` : null}
</div>
${this.passkeyMessage ? html`<div class="form-footer">${this.passkeyMessage}</div>` : null}
</idp-centercontainer>
`;
}
if (this.oidcConsentState) {
return html`
<idp-centercontainer>
@@ -397,7 +465,7 @@ export class IdpLoginPrompt extends DeesElement {
required
name="emailAddress"
label="Email or Username"
autocomplete="username"
autocomplete="username webauthn"
></idp-input>
<idp-input
id="loginPasswordInput"
@@ -409,6 +477,8 @@ export class IdpLoginPrompt extends DeesElement {
<idp-form-submit id="loginSubmitButton"></idp-form-submit>
</idp-form>
<div class="form-footer">
<a @click=${() => this.loginWithPasskey()}>Sign in with passkey</a>
<br />
Don't have an account?
<a @click=${async () => {
const idpState = await IdpState.getSingletonInstance();
@@ -489,17 +559,11 @@ export class IdpLoginPrompt extends DeesElement {
return;
}
if (response.refreshToken) {
loginForm.setStatus('pending', 'obtained refreshToken...');
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
if (jwt) {
loginForm.setStatus('success', 'obtained jwt.');
const oidcHandled = await this.handleOidcAfterLogin(jwt);
if (!oidcHandled) {
idpState.domtools.router.pushUrl('/dash/overview');
}
} else {
loginForm.setStatus('error', 'something went wrong');
}
await this.completeLoginWithRefreshToken(response.refreshToken);
} else if (response.twoFaNeeded && response.mfaChallengeToken) {
this.mfaChallengeToken = response.mfaChallengeToken;
this.availableMfaMethods = response.availableMfaMethods || ['totp'];
this.mfaMethod = this.availableMfaMethods.includes('totp') ? 'totp' : 'backupCode';
}
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
loginForm.setStatus('pending', 'sending magic link...');
@@ -518,6 +582,92 @@ export class IdpLoginPrompt extends DeesElement {
loginSubmitButton.disabled = false;
};
private verifyMfaCode = async (codeArg: string) => {
const mfaForm = this.shadowRoot.querySelector('#mfaForm') as plugins.idpCatalog.IdpForm;
const submitButton = this.shadowRoot.querySelector('#mfaSubmitButton') as plugins.idpCatalog.IdpFormSubmit;
submitButton.disabled = true;
mfaForm.setStatus('pending', 'verifying...');
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('verifyMfaChallenge');
const response = await request.fire({
mfaChallengeToken: this.mfaChallengeToken,
method: this.mfaMethod,
code: codeArg,
}).catch(() => {
mfaForm.setStatus('error', 'invalid verification code');
return null;
});
if (response?.refreshToken) {
await this.completeLoginWithRefreshToken(response.refreshToken);
}
submitButton.disabled = false;
};
private verifyMfaWithPasskey = async () => {
this.passkeyMessage = 'Waiting for passkey approval...';
const idpState = await IdpState.getSingletonInstance();
const startRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('startPasskeyMfa');
const finishRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('finishPasskeyMfa');
const startResponse = await startRequest.fire({
mfaChallengeToken: this.mfaChallengeToken,
}).catch(() => null);
if (!startResponse?.options) {
this.passkeyMessage = 'Could not start passkey verification.';
return;
}
const assertion = await plugins.simpleWebAuthnBrowser.startAuthentication({
optionsJSON: startResponse.options,
}).catch(() => null);
if (!assertion) {
this.passkeyMessage = 'Passkey verification was cancelled.';
return;
}
const finishResponse = await finishRequest.fire({
mfaChallengeToken: this.mfaChallengeToken,
challengeId: startResponse.challengeId,
response: assertion,
}).catch(() => null);
if (!finishResponse?.refreshToken) {
this.passkeyMessage = 'Passkey verification failed.';
return;
}
await this.completeLoginWithRefreshToken(finishResponse.refreshToken);
};
private loginWithPasskey = async () => {
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
const emailInput = this.shadowRoot.querySelector('#loginEmailInput') as plugins.idpCatalog.IdpInput;
loginForm.setStatus('pending', 'starting passkey sign-in...');
const idpState = await IdpState.getSingletonInstance();
const startRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('startPasskeyLogin');
const finishRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('finishPasskeyLogin');
const startResponse = await startRequest.fire({
username: emailInput.value || undefined,
}).catch((errorArg) => {
loginForm.setStatus('error', errorArg?.message || 'could not start passkey sign-in');
return null;
});
if (!startResponse?.options) {
return;
}
const assertion = await plugins.simpleWebAuthnBrowser.startAuthentication({
optionsJSON: startResponse.options,
}).catch(() => null);
if (!assertion) {
loginForm.setStatus('error', 'passkey sign-in was cancelled');
return;
}
const finishResponse = await finishRequest.fire({
challengeId: startResponse.challengeId,
response: assertion,
}).catch(() => null);
if (!finishResponse?.refreshToken) {
loginForm.setStatus('error', 'passkey sign-in failed');
return;
}
await this.completeLoginWithRefreshToken(finishResponse.refreshToken);
};
public async dispatchJwt(jwtArg?: string) {
if (jwtArg !== undefined) {
this.jwt = jwtArg;