Skip to content

Commit c8b5168

Browse files
committed
Add requires_totp_mfa to JWT
1 parent 1232132 commit c8b5168

10 files changed

Lines changed: 124 additions & 0 deletions

File tree

apps/backend/src/lib/tokens.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres
312312
is_anonymous: user.is_anonymous,
313313
is_restricted: user.is_restricted,
314314
restricted_reason: user.restricted_reason,
315+
requires_totp_mfa: user.requires_totp_mfa,
315316
};
316317

317318
// Validate the payload matches the accessTokenSchema before signing, to catch inconsistencies early

apps/e2e/tests/backend/backend-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export namespace Auth {
282282
"iss": expectedIssuer,
283283
"branch_id": "main",
284284
"refresh_token_id": expect.any(String),
285+
"requires_totp_mfa": expect.any(Boolean),
285286
"aud": backendContext.value.projectKeys === "no-project" ? expect.any(String) : backendContext.value.projectKeys.projectId,
286287
"sub": expect.any(String),
287288
"role": "authenticated",

apps/e2e/tests/js/access-token-refresh.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,120 @@ describe("access token refresh on user property changes", () => {
252252
});
253253
});
254254

255+
describe("requires_totp_mfa changes", () => {
256+
it("should have requires_totp_mfa=false for a new user without MFA", async ({ expect }) => {
257+
const { clientApp } = await createApp({
258+
config: {
259+
credentialEnabled: true,
260+
},
261+
});
262+
263+
await clientApp.signUpWithCredential({
264+
email: "test@example.com",
265+
password: "password123",
266+
verificationCallbackUrl: "http://localhost:3000",
267+
});
268+
269+
const user = await clientApp.getUser({ or: "throw" });
270+
const token = await user.getAccessToken();
271+
expect(token).toBeDefined();
272+
273+
const payload = decodeAccessToken(token!);
274+
expect(payload.requires_totp_mfa).toBe(false);
275+
});
276+
277+
it("should return a new access token with requires_totp_mfa=true after enabling TOTP MFA", async ({ expect }) => {
278+
const { clientApp } = await createApp({
279+
config: {
280+
credentialEnabled: true,
281+
},
282+
});
283+
284+
await clientApp.signUpWithCredential({
285+
email: "test@example.com",
286+
password: "password123",
287+
verificationCallbackUrl: "http://localhost:3000",
288+
});
289+
290+
const user = await clientApp.getUser({ or: "throw" });
291+
const initialToken = await user.getAccessToken();
292+
expect(decodeAccessToken(initialToken!).requires_totp_mfa).toBe(false);
293+
294+
const totpSecret = crypto.getRandomValues(new Uint8Array(20));
295+
await user.update({ totpMultiFactorSecret: totpSecret });
296+
297+
const updatedToken = await user.getAccessToken();
298+
expect(updatedToken).toBeDefined();
299+
300+
const updatedPayload = decodeAccessToken(updatedToken!);
301+
expect(updatedPayload.requires_totp_mfa).toBe(true);
302+
303+
expect(updatedToken).not.toBe(initialToken);
304+
});
305+
306+
it("should return a new access token with requires_totp_mfa=false after disabling TOTP MFA", async ({ expect }) => {
307+
const { clientApp } = await createApp({
308+
config: {
309+
credentialEnabled: true,
310+
},
311+
});
312+
313+
await clientApp.signUpWithCredential({
314+
email: "test@example.com",
315+
password: "password123",
316+
verificationCallbackUrl: "http://localhost:3000",
317+
});
318+
319+
const user = await clientApp.getUser({ or: "throw" });
320+
321+
const totpSecret = crypto.getRandomValues(new Uint8Array(20));
322+
await user.update({ totpMultiFactorSecret: totpSecret });
323+
324+
const mfaEnabledToken = await user.getAccessToken();
325+
expect(decodeAccessToken(mfaEnabledToken!).requires_totp_mfa).toBe(true);
326+
327+
await user.update({ totpMultiFactorSecret: null });
328+
329+
const mfaDisabledToken = await user.getAccessToken();
330+
expect(mfaDisabledToken).toBeDefined();
331+
332+
const disabledPayload = decodeAccessToken(mfaDisabledToken!);
333+
expect(disabledPayload.requires_totp_mfa).toBe(false);
334+
335+
expect(mfaDisabledToken).not.toBe(mfaEnabledToken);
336+
});
337+
338+
it("should update requires_totp_mfa in access token when admin enables MFA for a user", async ({ expect }) => {
339+
const { clientApp, adminApp } = await createApp({
340+
config: {
341+
credentialEnabled: true,
342+
},
343+
});
344+
345+
await clientApp.signUpWithCredential({
346+
email: "test@example.com",
347+
password: "password123",
348+
verificationCallbackUrl: "http://localhost:3000",
349+
});
350+
351+
const user = await clientApp.getUser({ or: "throw" });
352+
const initialToken = await user.getAccessToken();
353+
expect(decodeAccessToken(initialToken!).requires_totp_mfa).toBe(false);
354+
355+
const adminUsers = await adminApp.listUsers({ query: "test@example.com" });
356+
const totpSecret = crypto.getRandomValues(new Uint8Array(20));
357+
await adminUsers[0].update({ totpMultiFactorSecret: totpSecret });
358+
359+
await user.update({});
360+
361+
const updatedToken = await user.getAccessToken();
362+
expect(updatedToken).toBeDefined();
363+
364+
const updatedPayload = decodeAccessToken(updatedToken!);
365+
expect(updatedPayload.requires_totp_mfa).toBe(true);
366+
});
367+
});
368+
255369
describe("getAccessToken reflects current state", () => {
256370
it("should always return a token reflecting the current user state", async ({ expect }) => {
257371
const { clientApp, serverApp } = await createApp({

docs/content/docs/(guides)/concepts/jwt.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ Here's what a typical Stack Auth JWT payload looks like:
6565
"project_id": "project_abcdef",
6666
"branch_id": "main",
6767
"refresh_token_id": "refresh_xyz789",
68+
"requires_totp_mfa": false,
6869
"role": "authenticated",
6970
"name": "John Doe",
7071
"email": "john@example.com",

packages/stack-shared/src/schema-fields.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,7 @@ export const accessTokenPayloadSchema = yupObject({
755755
is_anonymous: yupBoolean().defined(),
756756
is_restricted: yupBoolean().defined(),
757757
restricted_reason: restrictedReasonSchema.defined().nullable(),
758+
requires_totp_mfa: yupBoolean().defined(),
758759
});
759760
export const signInEmailSchema = strictEmailSchema(undefined).meta({ openapiField: { description: 'The email to sign in with.', exampleValue: 'johndoe@example.com' } });
760761
export const emailOtpSignInCallbackUrlSchema = urlSchema.meta({ openapiField: { description: 'The base callback URL to construct the magic link from. A query parameter `code` with the verification code will be appended to it. The page should then make a request to the `/auth/otp/sign-in` endpoint.', exampleValue: 'https://example.com/handler/magic-link-callback' } });

packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2418,6 +2418,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
24182418
displayName: accessToken.payload.name,
24192419
primaryEmailVerified: accessToken.payload.email_verified,
24202420
isAnonymous,
2421+
isMultiFactorRequired: accessToken.payload.requires_totp_mfa,
24212422
isRestricted: accessToken.payload.is_restricted,
24222423
restrictedReason: accessToken.payload.restricted_reason,
24232424
} satisfies TokenPartialUser;
@@ -2434,6 +2435,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
24342435
primaryEmail: auth.email ?? null,
24352436
primaryEmailVerified: auth.email_verified as boolean,
24362437
isAnonymous: auth.is_anonymous as boolean,
2438+
isMultiFactorRequired: auth.requires_totp_mfa as boolean,
24372439
isRestricted: auth.is_restricted as boolean,
24382440
restrictedReason: (auth.restricted_reason as RestrictedReason | null) ?? null,
24392441
};

packages/template/src/lib/stack-app/users/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ export type TokenPartialUser = Pick<
289289
| "primaryEmail"
290290
| "primaryEmailVerified"
291291
| "isAnonymous"
292+
| "isMultiFactorRequired"
292293
| "isRestricted"
293294
| "restrictedReason"
294295
>

sdks/implementations/swift/Sources/StackAuth/Models/User.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public struct TokenPartialUser: Sendable {
7676
public let primaryEmail: String?
7777
public let primaryEmailVerified: Bool
7878
public let isAnonymous: Bool
79+
public let isMultiFactorRequired: Bool
7980
public let isRestricted: Bool
8081
public let restrictedReason: User.RestrictedReason?
8182
}

sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,7 @@ public actor StackClientApp {
713713
primaryEmail: json["email"] as? String,
714714
primaryEmailVerified: json["email_verified"] as? Bool ?? false,
715715
isAnonymous: json["is_anonymous"] as? Bool ?? false,
716+
isMultiFactorRequired: json["requires_totp_mfa"] as? Bool ?? false,
716717
isRestricted: json["is_restricted"] as? Bool ?? false,
717718
restrictedReason: restrictedReason
718719
)

sdks/spec/src/apps/client-app.spec.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ TokenPartialUser:
378378
primaryEmail: string | null
379379
primaryEmailVerified: bool
380380
isAnonymous: bool
381+
isMultiFactorRequired: bool
381382
isRestricted: bool
382383
restrictedReason: { type: "anonymous" | "email_not_verified" | "restricted_by_administrator" } | null
383384

0 commit comments

Comments
 (0)