feat(app): add MFA and tsdocker release
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user