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
+131 -1
View File
@@ -77,6 +77,18 @@ export class IdpAccountContent extends DeesElement {
@state()
private accessor passportEnrollment: plugins.idpCatalog.IIdpAdminPassportEnrollment | null = null;
@state()
private accessor totpEnabled = false;
@state()
private accessor backupCodesRemaining = 0;
@state()
private accessor totpEnrollment: any = null;
@state()
private accessor passkeys: any[] = [];
@state()
private accessor credentialMessage = '';
@@ -124,6 +136,10 @@ export class IdpAccountContent extends DeesElement {
.adminApps=${this.adminApps}
.passportDevices=${this.passportDevices}
.passportEnrollment=${this.passportEnrollment}
.totpEnabled=${this.totpEnabled}
.backupCodesRemaining=${this.backupCodesRemaining}
.totpEnrollment=${this.totpEnrollment}
.passkeys=${this.passkeys}
.credentialMessage=${this.credentialMessage}
@idp-admin-navigate=${this.handleAdminNavigate}
@idp-admin-org-select=${this.handleOrgSelect}
@@ -136,6 +152,12 @@ export class IdpAccountContent extends DeesElement {
@idp-admin-password-change=${this.handlePasswordChange}
@idp-admin-passport-enroll=${this.handlePassportEnroll}
@idp-admin-passport-revoke=${this.handlePassportRevoke}
@idp-admin-totp-start=${this.handleTotpStart}
@idp-admin-totp-verify=${this.handleTotpVerify}
@idp-admin-totp-disable=${this.handleTotpDisable}
@idp-admin-backup-codes-regenerate=${this.handleBackupCodesRegenerate}
@idp-admin-passkey-register=${this.handlePasskeyRegister}
@idp-admin-passkey-revoke=${this.handlePasskeyRevoke}
@idp-admin-member-invite=${this.handleMemberInvite}
@idp-admin-member-remove=${this.handleMemberRemove}
@idp-admin-member-roles-update=${this.handleMemberRolesUpdate}
@@ -495,6 +517,26 @@ export class IdpAccountContent extends DeesElement {
}));
}
private async loadMfaStatus(idpStateArg: IdpState, jwtArg: string) {
const request = idpStateArg.idpClient.typedsocket.createTypedRequest<any>('getMfaStatus');
const response = await request.fire({ jwt: jwtArg });
return {
totpEnabled: Boolean(response.totpEnabled),
backupCodesRemaining: Number(response.backupCodesRemaining || 0),
passkeys: (response.passkeys || []).map((passkeyArg: any) => ({
id: passkeyArg.id,
label: passkeyArg.data.label,
credentialId: passkeyArg.data.credentialId,
status: passkeyArg.data.status,
backedUp: passkeyArg.data.backedUp,
deviceType: passkeyArg.data.deviceType,
transports: passkeyArg.data.transports || [],
createdAt: passkeyArg.data.createdAt,
lastUsedAt: passkeyArg.data.lastUsedAt,
})),
};
}
private async loadAdminShellData() {
const currentRun = ++this.dataLoadRun;
this.dataLoading = true;
@@ -506,7 +548,7 @@ export class IdpAccountContent extends DeesElement {
const selectedOrg = this.getSelectedOrganization();
const orgId = selectedOrg?.id || '';
const [sessions, activities, members, invitations, orgApps, adminApps, passportDevices] = await Promise.all([
const [sessions, activities, members, invitations, orgApps, adminApps, passportDevices, mfaStatus] = await Promise.all([
this.loadSessions(idpState, jwt).catch((error) => {
console.error('Error loading sessions:', error);
return this.sessions;
@@ -535,6 +577,14 @@ export class IdpAccountContent extends DeesElement {
console.error('Error loading passport devices:', error);
return this.passportDevices;
}),
this.loadMfaStatus(idpState, jwt).catch((error) => {
console.error('Error loading MFA status:', error);
return {
totpEnabled: this.totpEnabled,
backupCodesRemaining: this.backupCodesRemaining,
passkeys: this.passkeys,
};
}),
]);
if (currentRun !== this.dataLoadRun) {
@@ -548,6 +598,9 @@ export class IdpAccountContent extends DeesElement {
this.orgApps = orgApps;
this.adminApps = adminApps;
this.passportDevices = passportDevices;
this.totpEnabled = mfaStatus.totpEnabled;
this.backupCodesRemaining = mfaStatus.backupCodesRemaining;
this.passkeys = mfaStatus.passkeys;
} catch (error) {
console.error('Error loading admin shell data:', error);
if (currentRun === this.dataLoadRun) {
@@ -657,6 +710,83 @@ export class IdpAccountContent extends DeesElement {
});
}
private async handleTotpStart() {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('startTotpEnrollment');
this.totpEnrollment = await request.fire({ jwt: await idpState.idpClient.getJwt() });
this.credentialMessage = 'Authenticator app enrollment started.';
});
}
private async handleTotpVerify(eventArg: CustomEvent<any>) {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('finishTotpEnrollment');
const response = await request.fire({
jwt: await idpState.idpClient.getJwt(),
credentialId: eventArg.detail.credentialId,
code: eventArg.detail.code,
});
this.totpEnrollment = null;
this.credentialMessage = `Authenticator app enabled. Save these backup codes now: ${(response.backupCodes || []).join(', ')}`;
});
}
private async handleTotpDisable(eventArg: CustomEvent<any>) {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('disableTotp');
await request.fire({ jwt: await idpState.idpClient.getJwt(), code: eventArg.detail.code });
this.totpEnrollment = null;
this.credentialMessage = 'Authenticator app disabled.';
});
}
private async handleBackupCodesRegenerate(eventArg: CustomEvent<any>) {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('regenerateBackupCodes');
const response = await request.fire({ jwt: await idpState.idpClient.getJwt(), code: eventArg.detail.code });
this.credentialMessage = `New backup codes generated. Save them now: ${(response.backupCodes || []).join(', ')}`;
});
}
private async handlePasskeyRegister(eventArg: CustomEvent<any>) {
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const startRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('startPasskeyRegistration');
const finishRequest = idpState.idpClient.typedsocket.createTypedRequest<any>('finishPasskeyRegistration');
const startResponse = await startRequest.fire({
jwt: await idpState.idpClient.getJwt(),
label: eventArg.detail.label,
});
const registrationResponse = await plugins.simpleWebAuthnBrowser.startRegistration({
optionsJSON: startResponse.options,
});
await finishRequest.fire({
jwt: await idpState.idpClient.getJwt(),
challengeId: startResponse.challengeId,
label: eventArg.detail.label,
response: registrationResponse,
});
this.credentialMessage = 'Passkey registered.';
});
}
private async handlePasskeyRevoke(eventArg: CustomEvent<any>) {
const passkey = this.passkeys.find((passkeyArg) => passkeyArg.id === eventArg.detail.passkeyId);
if (!passkey || !confirm(`Revoke passkey ${passkey.label}?`)) {
return;
}
await this.runAdminAction(async () => {
const idpState = await IdpState.getSingletonInstance();
const request = idpState.idpClient.typedsocket.createTypedRequest<any>('revokePasskey');
await request.fire({ jwt: await idpState.idpClient.getJwt(), passkeyId: eventArg.detail.passkeyId });
this.credentialMessage = 'Passkey revoked.';
});
}
private async handleMemberInvite() {
const selectedOrg = this.getSelectedOrganization();
if (!selectedOrg) {
+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;