Skip to content

Commit

Permalink
Fixed TOTP MFA
Browse files Browse the repository at this point in the history
  • Loading branch information
hopleus committed Jan 10, 2025
1 parent 8cb4f01 commit 180865b
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 162 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,6 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
});
}

if (user.requires_totp_mfa) {
throw await createMfaRequiredError({
project,
isNewUser: data.is_new_user,
userId: user.id,
});
}

const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: user.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,6 @@ export const passkeySignInVerificationCodeHandler = createVerificationCodeHandle

const user = passkey.projectUser;

if (user.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
isNewUser: false,
userId: user.projectUserId,
});
}

const { refreshToken, accessToken } = await createAuthTokens({
projectId: project.id,
projectUserId: user.projectUserId,
Expand Down
7 changes: 0 additions & 7 deletions apps/backend/src/oauth/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,6 @@ export class OAuthModel implements AuthorizationCodeModel {
},
},
});
if (projectUser.requiresTotpMfa) {
throw await createMfaRequiredError({
project: projectPrismaToCrud(projectUser.project),
userId: projectUser.projectUserId,
isNewUser: false,
});
}

await prismaClient.projectUserRefreshToken.create({
data: {
Expand Down
38 changes: 0 additions & 38 deletions apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,42 +160,4 @@ describe("with grant_type === 'authorization_code'", async () => {
}
`);
});

it("should fail when MFA is required", async ({ expect }) => {
await Auth.OAuth.signIn();
await Auth.Mfa.setupTotpMfa();
await Auth.signOut();

const getAuthorizationCodeResult = await Auth.OAuth.getAuthorizationCode();

const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");

const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", {
method: "POST",
accessType: "client",
body: {
client_id: projectKeys.projectId,
client_secret: projectKeys.publishableClientKey ?? throwErr("No publishable client key found in the backend context"),
code: getAuthorizationCodeResult.authorizationCode,
redirect_uri: localRedirectUrl,
code_verifier: "some-code-challenge",
grant_type: "authorization_code",
},
});
expect(tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
},
"headers": Headers {
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
});
33 changes: 0 additions & 33 deletions apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,39 +144,6 @@ it("should sign up a new user even if one already exists with email auth disable
`);
});

it("should not allow signing in when MFA is required", async ({ expect }) => {
await Auth.Otp.signIn();
await Auth.Mfa.setupTotpMfa();
await Auth.signOut();

const mailbox = backendContext.value.mailbox;
await Auth.Otp.sendSignInCode();
const messages = await mailbox.fetchMessages();
const message = messages.findLast((message) => message.subject.includes("Sign in to")) ?? throwErr("Sign-in code message not found");
const signInCode = message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Sign-in URL not found");
const response = await niceBackendFetch("/api/v1/auth/otp/sign-in", {
method: "POST",
accessType: "client",
body: {
code: signInCode,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
},
"headers": Headers {
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});

it("should sign in with otp code", async ({ expect }) => {
await Auth.Otp.sendSignInCode();
const mailbox = backendContext.value.mailbox;
Expand Down
40 changes: 25 additions & 15 deletions packages/stack-shared/src/interface/clientInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,29 +724,39 @@ export class StackClientInterface {
}
}

async totpMfa(
async signInWithTotpMfa(
attemptCode: string,
totp: string,
session: InternalSession
) {
const res = await this.sendClientRequest("/auth/mfa/sign-in", {
method: "POST",
headers: {
"Content-Type": "application/json"
): Promise<Result<{ accessToken: string, refreshToken: string, newUser: boolean }, KnownErrors["InvalidTotpCode"]>> {
const res = await this.sendClientRequestAndCatchKnownError(
"/auth/mfa/sign-in",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
code: attemptCode,
type: "totp",
totp: totp,
}),
},
body: JSON.stringify({
code: attemptCode,
type: "totp",
totp: totp,
}),
}, session);
session,
[KnownErrors.InvalidTotpCode],
);

const result = await res.json();
return {
if (res.status === "error") {
return Result.error(res.error);
}

const result = await res.data.json();

return Result.ok({
accessToken: result.access_token,
refreshToken: result.refresh_token,
newUser: result.is_new_user,
};
});
}

async signInWithCredential(
Expand Down
87 changes: 85 additions & 2 deletions packages/stack/src/components/credential-sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,84 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { passwordSchema, strictEmailSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Button, Input, Label, PasswordInput, StyledLink } from "@stackframe/stack-ui";
import { useState } from "react";
import {
Button,
Input,
InputOTP,
InputOTPGroup, InputOTPSlot,
Label,
PasswordInput,
StyledLink,
Typography
} from "@stackframe/stack-ui";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { useStackApp } from "..";
import { useTranslation } from "../lib/translations";
import { FormWarningText } from "./elements/form-warning";
import { KnownErrors } from "@stackframe/stack-shared";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";

function OTP(props: {
onBack: () => void,
nonce: string,
}) {
const { t } = useTranslation();
const [otp, setOtp] = useState<string>('');
const [submitting, setSubmitting] = useState<boolean>(false);
const stackApp = useStackApp();
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (otp.length === 6 && !submitting) {
setSubmitting(true);
stackApp.signInWithTotpMfa(props.nonce, otp)
.then(result => {
if (result.status === 'error') {
if (result.error instanceof KnownErrors.InvalidTotpCode) {
setError(t("Invalid TOTP code"));
} else {
throw result.error;
}
}
})
.catch(e => console.error(e))
.finally(() => {
setSubmitting(false);
setOtp('');
});
}
if (otp.length !== 0 && otp.length !== 6) {
setError(null);
}
}, [otp, submitting]);

return (
<div className="flex flex-col items-stretch stack-scope">
<form className='w-full flex flex-col items-center mb-2'>
<Typography className='mb-2 text-center' >{t('Enter the TOTP code from your authenticator app')}</Typography>
<InputOTP
maxLength={6}
type="text"
inputMode="text"
pattern={"^[0-9]+$"}
value={otp}
onChange={value => setOtp(value.toUpperCase())}
disabled={submitting}
>
<InputOTPGroup>
{[0, 1, 2, 3, 4, 5].map((index) => (
<InputOTPSlot key={index} index={index} size='lg' />
))}
</InputOTPGroup>
</InputOTP>
{error && <FormWarningText text={error} />}
</form>
<Button variant='link' onClick={props.onBack} className='underline'>{t('Cancel')}</Button>
</div>
);
}

export function CredentialSignIn() {
const { t } = useTranslation();
Expand All @@ -24,6 +95,7 @@ export function CredentialSignIn() {
});
const app = useStackApp();
const [loading, setLoading] = useState(false);
const [nonce, setNonce] = useState<string | null>(null);

const onSubmit = async (data: yup.InferType<typeof schema>) => {
setLoading(true);
Expand All @@ -34,14 +106,25 @@ export function CredentialSignIn() {
email,
password,
});

if (result.status === 'error') {
setError('email', { type: 'manual', message: result.error.message });
}
} catch (e) {
if (e instanceof KnownErrors.MultiFactorAuthenticationRequired) {
setNonce(e.details?.attempt_code ?? throwErr("attempt code missing"));
} else {
throw e;
}
} finally {
setLoading(false);
}
};

if (nonce) {
return <OTP nonce={nonce} onBack={() => setNonce(null)} />;
}

return (
<form
className="flex flex-col items-stretch stack-scope"
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function consumeOAuthCallbackQueryParams() {
export async function callOAuthCallback(
iface: StackClientInterface,
redirectUrl: string,
) {
): Promise<Result<any>> {
// note: this part of the function (until the return) needs
// to be synchronous, to prevent race conditions when
// callOAuthCallback is called multiple times in parallel
Expand Down
Loading

0 comments on commit 180865b

Please sign in to comment.