feat(catalog): add MFA security controls
This commit is contained in:
@@ -163,6 +163,24 @@ export interface IIdpAdminPassportEnrollment {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IIdpAdminPasskey {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
credentialId?: string;
|
||||||
|
status: string;
|
||||||
|
backedUp?: boolean;
|
||||||
|
deviceType?: string;
|
||||||
|
transports?: string[];
|
||||||
|
createdAt: number;
|
||||||
|
lastUsedAt?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIdpAdminTotpEnrollment {
|
||||||
|
credentialId: string;
|
||||||
|
secret: string;
|
||||||
|
otpauthUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IIdpAdminSessionEventDetail {
|
export interface IIdpAdminSessionEventDetail {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
@@ -237,6 +255,23 @@ export interface IIdpAdminPassportDeviceEventDetail {
|
|||||||
deviceId: string;
|
deviceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IIdpAdminTotpVerifyEventDetail {
|
||||||
|
credentialId: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIdpAdminTotpCodeEventDetail {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIdpAdminPasskeyRegistrationEventDetail {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIdpAdminPasskeyEventDetail {
|
||||||
|
passkeyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
@customElement('idp-admin-shell')
|
@customElement('idp-admin-shell')
|
||||||
export class IdpAdminShell extends DeesElement {
|
export class IdpAdminShell extends DeesElement {
|
||||||
public static demo = () => html`<idp-admin-shell global-admin></idp-admin-shell>`;
|
public static demo = () => html`<idp-admin-shell global-admin></idp-admin-shell>`;
|
||||||
@@ -1239,6 +1274,18 @@ export class IdpAdminShell extends DeesElement {
|
|||||||
@property({ type: Object })
|
@property({ type: Object })
|
||||||
public accessor passportEnrollment: IIdpAdminPassportEnrollment | null = null;
|
public accessor passportEnrollment: IIdpAdminPassportEnrollment | null = null;
|
||||||
|
|
||||||
|
@property({ type: Boolean, attribute: 'totp-enabled' })
|
||||||
|
public accessor totpEnabled = false;
|
||||||
|
|
||||||
|
@property({ type: Number, attribute: 'backup-codes-remaining' })
|
||||||
|
public accessor backupCodesRemaining = 0;
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
public accessor totpEnrollment: IIdpAdminTotpEnrollment | null = null;
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
public accessor passkeys: IIdpAdminPasskey[] = [];
|
||||||
|
|
||||||
@property({ type: String, attribute: 'credential-message' })
|
@property({ type: String, attribute: 'credential-message' })
|
||||||
public accessor credentialMessage = '';
|
public accessor credentialMessage = '';
|
||||||
|
|
||||||
@@ -1523,6 +1570,51 @@ export class IdpAdminShell extends DeesElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private requestTotpEnrollment() {
|
||||||
|
this.dispatchShellEvent('idp-admin-totp-start', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyTotpEnrollment() {
|
||||||
|
if (!this.totpEnrollment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = globalThis.prompt?.('Authenticator code')?.trim();
|
||||||
|
if (!code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dispatchShellEvent<IIdpAdminTotpVerifyEventDetail>('idp-admin-totp-verify', {
|
||||||
|
credentialId: this.totpEnrollment.credentialId,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private disableTotp() {
|
||||||
|
const code = globalThis.prompt?.('Enter your current authenticator code to disable TOTP')?.trim();
|
||||||
|
if (!code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dispatchShellEvent<IIdpAdminTotpCodeEventDetail>('idp-admin-totp-disable', { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
private regenerateBackupCodes() {
|
||||||
|
const code = globalThis.prompt?.('Enter your current authenticator code to regenerate backup codes')?.trim();
|
||||||
|
if (!code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dispatchShellEvent<IIdpAdminTotpCodeEventDetail>('idp-admin-backup-codes-regenerate', { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestPasskeyRegistration() {
|
||||||
|
const fallbackLabel = typeof navigator !== 'undefined'
|
||||||
|
? navigator.userAgent.includes('Mobile') ? 'Mobile passkey' : 'Desktop passkey'
|
||||||
|
: 'Passkey';
|
||||||
|
const label = globalThis.prompt?.('Passkey label', fallbackLabel)?.trim();
|
||||||
|
if (!label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dispatchShellEvent<IIdpAdminPasskeyRegistrationEventDetail>('idp-admin-passkey-register', { label });
|
||||||
|
}
|
||||||
|
|
||||||
private setPage(pageArg: TIdpAdminPage) {
|
private setPage(pageArg: TIdpAdminPage) {
|
||||||
this.page = pageArg;
|
this.page = pageArg;
|
||||||
this.orgMenuOpen = false;
|
this.orgMenuOpen = false;
|
||||||
@@ -2172,6 +2264,23 @@ export class IdpAdminShell extends DeesElement {
|
|||||||
html`<div class="cell-actions"><button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminPassportDeviceEventDetail>('idp-admin-passport-revoke', { deviceId: deviceArg.id })}>Revoke</button></div>`,
|
html`<div class="cell-actions"><button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminPassportDeviceEventDetail>('idp-admin-passport-revoke', { deviceId: deviceArg.id })}>Revoke</button></div>`,
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
const passkeyRows = this.passkeys.map((passkeyArg) => ({
|
||||||
|
cells: [
|
||||||
|
html`
|
||||||
|
<div class="identity-cell">
|
||||||
|
<span class="identity-avatar">${this.getInitials(passkeyArg.label)}</span>
|
||||||
|
<div>
|
||||||
|
<div class="identity-primary">${passkeyArg.label}</div>
|
||||||
|
<div class="identity-secondary">${passkeyArg.deviceType || 'passkey'}${passkeyArg.backedUp ? ' - backed up' : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
html`<idp-badge variant=${passkeyArg.status === 'active' ? 'ok' : 'error'}>${passkeyArg.status}</idp-badge>`,
|
||||||
|
(passkeyArg.transports || []).join(', ') || '-',
|
||||||
|
passkeyArg.lastUsedAt ? this.formatTimeAgo(passkeyArg.lastUsedAt) : 'never',
|
||||||
|
html`<div class="cell-actions"><button class="table-action destructive" @click=${() => this.dispatchShellEvent<IIdpAdminPasskeyEventDetail>('idp-admin-passkey-revoke', { passkeyId: passkeyArg.id })}>Revoke</button></div>`,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${this.renderPageHeader('Security', 'Manage how you authenticate and protect your account.')}
|
${this.renderPageHeader('Security', 'Manage how you authenticate and protect your account.')}
|
||||||
@@ -2190,6 +2299,45 @@ export class IdpAdminShell extends DeesElement {
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<button class="plain-button primary" @click=${() => this.submitPasswordChange()}>Update password</button>
|
<button class="plain-button primary" @click=${() => this.submitPasswordChange()}>Update password</button>
|
||||||
`)}
|
`)}
|
||||||
|
${this.renderSectionCard('Authenticator app', 'TOTP protects password and magic-link sign-ins with a six-digit code from an authenticator app.', html`
|
||||||
|
<div class="split-row"><div><div class="section-title">Status</div><div class="muted">${this.totpEnabled ? `${this.backupCodesRemaining} backup codes remaining.` : 'No authenticator app is enrolled.'}</div></div><idp-badge variant=${this.totpEnabled ? 'ok' : 'warn'}>${this.totpEnabled ? 'Enabled' : 'Disabled'}</idp-badge></div>
|
||||||
|
${this.totpEnrollment ? html`
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="notice-card"><idp-icon name="qr" size="16"></idp-icon><div><div class="section-title">TOTP setup pending</div><div class="muted">Scan the otpauth URL or enter the manual secret, then verify the current code.</div></div></div>
|
||||||
|
${this.renderFormRow('Manual secret', '', this.renderCodeBlock(this.totpEnrollment.secret))}
|
||||||
|
${this.renderFormRow('otpauth URL', '', this.renderCodeBlock(this.totpEnrollment.otpauthUrl))}
|
||||||
|
<button class="plain-button primary" @click=${() => this.verifyTotpEnrollment()}>Verify setup code</button>
|
||||||
|
` : html`
|
||||||
|
<div class="divider"></div>
|
||||||
|
${this.totpEnabled ? html`
|
||||||
|
<div class="cell-actions" style="justify-content:flex-start">
|
||||||
|
<button class="plain-button outline" @click=${() => this.regenerateBackupCodes()}>Regenerate backup codes</button>
|
||||||
|
<button class="plain-button ghost" style="color:var(--idp-error)" @click=${() => this.disableTotp()}>Disable TOTP</button>
|
||||||
|
</div>
|
||||||
|
` : html`<button class="plain-button primary" @click=${() => this.requestTotpEnrollment()}>Enable authenticator app</button>`}
|
||||||
|
`}
|
||||||
|
`)}
|
||||||
|
<idp-data-table
|
||||||
|
title="Passkeys"
|
||||||
|
subtitle="WebAuthn credentials for passwordless login and MFA step-up."
|
||||||
|
badge=${`${this.passkeys.length} total`}
|
||||||
|
empty-title="No passkeys"
|
||||||
|
empty-description="Register a passkey to sign in with platform authenticators or security keys."
|
||||||
|
.columns=${[
|
||||||
|
{ label: 'Passkey' },
|
||||||
|
{ label: 'Status' },
|
||||||
|
{ label: 'Transports', hideBelow: 'mobile' },
|
||||||
|
{ label: 'Last used', mono: true, hideBelow: 'mobile' },
|
||||||
|
{ label: 'Action', align: 'right' },
|
||||||
|
]}
|
||||||
|
.rows=${passkeyRows}
|
||||||
|
></idp-data-table>
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-headline">
|
||||||
|
<div><div class="section-title">Register passkey</div><div class="section-description">Creates a WebAuthn registration challenge in this browser.</div></div>
|
||||||
|
<button class="plain-button outline" @click=${() => this.requestPasskeyRegistration()}>Register passkey</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<idp-data-table
|
<idp-data-table
|
||||||
title="Passport devices"
|
title="Passport devices"
|
||||||
subtitle="Cryptographic IDP Passport devices registered for this account."
|
subtitle="Cryptographic IDP Passport devices registered for this account."
|
||||||
@@ -2217,10 +2365,6 @@ export class IdpAdminShell extends DeesElement {
|
|||||||
${this.renderFormRow('Signing payload', '', this.renderCodeBlock(this.passportEnrollment.signingPayload))}
|
${this.renderFormRow('Signing payload', '', this.renderCodeBlock(this.passportEnrollment.signingPayload))}
|
||||||
` : html`<div class="muted">No active enrollment challenge.</div>`}
|
` : html`<div class="muted">No active enrollment challenge.</div>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="primary-grid">
|
|
||||||
${this.renderStateCard('TOTP controls not connected', 'No TOTP secret, enrollment, or verification endpoints exist in this backend yet, so no fake TOTP toggle is shown.', 'lock')}
|
|
||||||
${this.renderStateCard('WebAuthn passkeys not connected', 'No WebAuthn passkey credential model or assertion endpoints exist yet. Passport devices are the available cryptographic credential path.', 'key')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user