mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import * as jose from 'jose';
|
|
import { describe } from "vitest";
|
|
import { it } from "../helpers";
|
|
import { createApp } from "./js-helpers";
|
|
|
|
function decodeAccessToken(token: string) {
|
|
return jose.decodeJwt(token);
|
|
}
|
|
|
|
describe("access token refresh on user property changes", () => {
|
|
describe("displayName changes", () => {
|
|
it("should return a new access token with updated name after setDisplayName", async ({ expect }) => {
|
|
const { clientApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
const initialToken = await user.getAccessToken();
|
|
const initialRefreshToken = await user.getRefreshToken();
|
|
expect(initialToken).toBeDefined();
|
|
expect(initialRefreshToken).toBeDefined(); // Must have refresh token for token refresh to work
|
|
|
|
const initialPayload = decodeAccessToken(initialToken!);
|
|
expect(initialPayload.name).toBeNull();
|
|
|
|
// Update display name
|
|
await user.setDisplayName("New Display Name");
|
|
|
|
// Verify the display name was updated on the user object
|
|
const userAfterUpdate = await clientApp.getUser({ or: "throw" });
|
|
expect(userAfterUpdate.displayName).toBe("New Display Name");
|
|
|
|
// Get a fresh access token - it should have the updated name claim
|
|
const updatedToken = await userAfterUpdate.getAccessToken();
|
|
expect(updatedToken).toBeDefined();
|
|
|
|
const updatedPayload = decodeAccessToken(updatedToken!);
|
|
expect(updatedPayload.name).toBe("New Display Name");
|
|
|
|
// Token should be different from the initial one since name changed
|
|
expect(updatedToken).not.toBe(initialToken);
|
|
});
|
|
|
|
it("should update access token when display name is set to null", async ({ expect }) => {
|
|
const { clientApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
|
|
// Set initial display name
|
|
await user.setDisplayName("Initial Name");
|
|
|
|
const tokenWithName = await user.getAccessToken();
|
|
expect(decodeAccessToken(tokenWithName!).name).toBe("Initial Name");
|
|
|
|
// Set display name to null
|
|
await user.setDisplayName(null);
|
|
|
|
const tokenWithNullName = await user.getAccessToken();
|
|
expect(decodeAccessToken(tokenWithNullName!).name).toBeNull();
|
|
|
|
expect(tokenWithNullName).not.toBe(tokenWithName);
|
|
});
|
|
});
|
|
|
|
describe("selectedTeam changes", () => {
|
|
it("should return a new access token with updated selected_team_id after setSelectedTeam", async ({ expect }) => {
|
|
const { clientApp, serverApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
clientTeamCreationEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
|
|
// Create a team
|
|
const team = await user.createTeam({ displayName: "Test Team" });
|
|
|
|
const initialToken = await user.getAccessToken();
|
|
expect(initialToken).toBeDefined();
|
|
|
|
const initialPayload = decodeAccessToken(initialToken!);
|
|
// Initially selected team may be null or different
|
|
const initialTeamId = initialPayload.selected_team_id;
|
|
|
|
// Select the new team
|
|
await user.setSelectedTeam(team);
|
|
|
|
// Get a fresh access token
|
|
const updatedToken = await user.getAccessToken();
|
|
expect(updatedToken).toBeDefined();
|
|
|
|
const updatedPayload = decodeAccessToken(updatedToken!);
|
|
expect(updatedPayload.selected_team_id).toBe(team.id);
|
|
|
|
// Token should be different if selected team changed
|
|
if (initialTeamId !== team.id) {
|
|
expect(updatedToken).not.toBe(initialToken);
|
|
}
|
|
});
|
|
|
|
it("should update access token when setSelectedTeam is called with null", async ({ expect }) => {
|
|
const { clientApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
clientTeamCreationEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
|
|
// Create and select a team
|
|
const team = await user.createTeam({ displayName: "Test Team" });
|
|
await user.setSelectedTeam(team);
|
|
|
|
const tokenWithTeam = await user.getAccessToken();
|
|
expect(decodeAccessToken(tokenWithTeam!).selected_team_id).toBe(team.id);
|
|
|
|
// Set selected team to null
|
|
await user.setSelectedTeam(null);
|
|
|
|
const tokenWithNullTeam = await user.getAccessToken();
|
|
expect(decodeAccessToken(tokenWithNullTeam!).selected_team_id).toBeNull();
|
|
|
|
expect(tokenWithNullTeam).not.toBe(tokenWithTeam);
|
|
});
|
|
});
|
|
|
|
describe("restrictedness changes", () => {
|
|
it("should return a new access token with updated is_restricted after client-side email verification", async ({ expect }) => {
|
|
const { clientApp, adminApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
// Enable email verification requirement
|
|
const project = await adminApp.getProject();
|
|
await project.updateConfig({
|
|
"onboarding.requireEmailVerification": true,
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
// Get restricted user
|
|
const restrictedUser = await clientApp.getUser({ includeRestricted: true, or: "throw" });
|
|
expect(restrictedUser.isRestricted).toBe(true);
|
|
|
|
const restrictedToken = await restrictedUser.getAccessToken();
|
|
expect(restrictedToken).toBeDefined();
|
|
|
|
const restrictedPayload = decodeAccessToken(restrictedToken!);
|
|
expect(restrictedPayload.is_restricted).toBe(true);
|
|
expect(restrictedPayload.restricted_reason).toEqual({ type: "email_not_verified" });
|
|
|
|
// Verify the email via admin app (simulating what would happen when user clicks verification link)
|
|
const adminUsers = await adminApp.listUsers({ query: "test@example.com", includeRestricted: true });
|
|
const adminContactChannels = await adminUsers[0].listContactChannels();
|
|
const adminEmailChannel = adminContactChannels.find(c => c.value === "test@example.com");
|
|
await adminEmailChannel!.update({ isVerified: true });
|
|
|
|
// Trigger a token refresh by calling update (this is what happens in real flows
|
|
// where the verification code handler calls update on the user)
|
|
await restrictedUser.update({});
|
|
|
|
// Get a fresh access token - the SAME user object's getAccessToken() should return updated tokens
|
|
const nonRestrictedToken = await restrictedUser.getAccessToken();
|
|
expect(nonRestrictedToken).toBeDefined();
|
|
|
|
const nonRestrictedPayload = decodeAccessToken(nonRestrictedToken!);
|
|
expect(nonRestrictedPayload.is_restricted).toBe(false);
|
|
expect(nonRestrictedPayload.restricted_reason).toBeNull();
|
|
|
|
// Token should be different
|
|
expect(nonRestrictedToken).not.toBe(restrictedToken);
|
|
});
|
|
|
|
it("should update access token claims when user transitions from anonymous to authenticated", async ({ expect }) => {
|
|
const { clientApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
// Sign up anonymously using the { or: "anonymous" } option
|
|
const anonUser = await clientApp.getUser({ or: "anonymous" });
|
|
expect(anonUser.isAnonymous).toBe(true);
|
|
|
|
const anonToken = await anonUser.getAccessToken();
|
|
expect(anonToken).toBeDefined();
|
|
|
|
const anonPayload = decodeAccessToken(anonToken!);
|
|
expect(anonPayload.is_anonymous).toBe(true);
|
|
expect(anonPayload.is_restricted).toBe(true);
|
|
expect(anonPayload.restricted_reason).toEqual({ type: "anonymous" });
|
|
|
|
// Upgrade anonymous user to authenticated
|
|
await clientApp.signUpWithCredential({
|
|
email: "upgraded@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
// Get a fresh access token
|
|
const upgradedUser = await clientApp.getUser({ or: "throw" });
|
|
expect(upgradedUser.isAnonymous).toBe(false);
|
|
|
|
const upgradedToken = await upgradedUser.getAccessToken();
|
|
expect(upgradedToken).toBeDefined();
|
|
|
|
const upgradedPayload = decodeAccessToken(upgradedToken!);
|
|
expect(upgradedPayload.is_anonymous).toBe(false);
|
|
expect(upgradedPayload.is_restricted).toBe(false);
|
|
expect(upgradedPayload.restricted_reason).toBeNull();
|
|
|
|
// Token should be different
|
|
expect(upgradedToken).not.toBe(anonToken);
|
|
});
|
|
});
|
|
|
|
describe("requires_totp_mfa changes", () => {
|
|
it("should have requires_totp_mfa=false for a new user without MFA", async ({ expect }) => {
|
|
const { clientApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
const token = await user.getAccessToken();
|
|
expect(token).toBeDefined();
|
|
|
|
const payload = decodeAccessToken(token!);
|
|
expect(payload.requires_totp_mfa).toBe(false);
|
|
});
|
|
|
|
it("should return a new access token with requires_totp_mfa=true after enabling TOTP MFA", async ({ expect }) => {
|
|
const { clientApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
const initialToken = await user.getAccessToken();
|
|
expect(decodeAccessToken(initialToken!).requires_totp_mfa).toBe(false);
|
|
|
|
const totpSecret = crypto.getRandomValues(new Uint8Array(20));
|
|
await user.update({ totpMultiFactorSecret: totpSecret });
|
|
|
|
const updatedToken = await user.getAccessToken();
|
|
expect(updatedToken).toBeDefined();
|
|
|
|
const updatedPayload = decodeAccessToken(updatedToken!);
|
|
expect(updatedPayload.requires_totp_mfa).toBe(true);
|
|
|
|
expect(updatedToken).not.toBe(initialToken);
|
|
});
|
|
|
|
it("should return a new access token with requires_totp_mfa=false after disabling TOTP MFA", async ({ expect }) => {
|
|
const { clientApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
|
|
const totpSecret = crypto.getRandomValues(new Uint8Array(20));
|
|
await user.update({ totpMultiFactorSecret: totpSecret });
|
|
|
|
const mfaEnabledToken = await user.getAccessToken();
|
|
expect(decodeAccessToken(mfaEnabledToken!).requires_totp_mfa).toBe(true);
|
|
|
|
await user.update({ totpMultiFactorSecret: null });
|
|
|
|
const mfaDisabledToken = await user.getAccessToken();
|
|
expect(mfaDisabledToken).toBeDefined();
|
|
|
|
const disabledPayload = decodeAccessToken(mfaDisabledToken!);
|
|
expect(disabledPayload.requires_totp_mfa).toBe(false);
|
|
|
|
expect(mfaDisabledToken).not.toBe(mfaEnabledToken);
|
|
});
|
|
|
|
it("should update requires_totp_mfa in access token when admin enables MFA for a user", async ({ expect }) => {
|
|
const { clientApp, adminApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
const initialToken = await user.getAccessToken();
|
|
expect(decodeAccessToken(initialToken!).requires_totp_mfa).toBe(false);
|
|
|
|
const adminUsers = await adminApp.listUsers({ query: "test@example.com" });
|
|
const totpSecret = crypto.getRandomValues(new Uint8Array(20));
|
|
await adminUsers[0].update({ totpMultiFactorSecret: totpSecret });
|
|
|
|
await user.update({});
|
|
|
|
const updatedToken = await user.getAccessToken();
|
|
expect(updatedToken).toBeDefined();
|
|
|
|
const updatedPayload = decodeAccessToken(updatedToken!);
|
|
expect(updatedPayload.requires_totp_mfa).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("signed_up_at_millis claim", () => {
|
|
it("should include signed_up_at_millis and keep it stable across token refreshes", async ({ expect }) => {
|
|
const { clientApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
const initialToken = await user.getAccessToken();
|
|
expect(initialToken).toBeDefined();
|
|
|
|
const initialPayload = decodeAccessToken(initialToken!);
|
|
expect(initialPayload.signed_up_at_millis).toBe(user.signedUpAt.getTime());
|
|
|
|
await user.setDisplayName("Updated display name");
|
|
|
|
const refreshedToken = await user.getAccessToken();
|
|
expect(refreshedToken).toBeDefined();
|
|
|
|
const refreshedPayload = decodeAccessToken(refreshedToken!);
|
|
expect(refreshedPayload.signed_up_at_millis).toBe(user.signedUpAt.getTime());
|
|
});
|
|
});
|
|
|
|
describe("getAccessToken reflects current state", () => {
|
|
it("should always return a token reflecting the current user state", async ({ expect }) => {
|
|
const { clientApp, serverApp } = await createApp({
|
|
config: {
|
|
credentialEnabled: true,
|
|
clientTeamCreationEnabled: true,
|
|
},
|
|
});
|
|
|
|
await clientApp.signUpWithCredential({
|
|
email: "test@example.com",
|
|
password: "password123",
|
|
verificationCallbackUrl: "http://localhost:3000",
|
|
});
|
|
|
|
const user = await clientApp.getUser({ or: "throw" });
|
|
|
|
// Make multiple changes and verify each change is reflected in the token
|
|
const changes = [
|
|
{ action: () => user.setDisplayName("Name 1"), check: (p: any) => p.name === "Name 1" },
|
|
{ action: () => user.setDisplayName("Name 2"), check: (p: any) => p.name === "Name 2" },
|
|
{ action: () => user.setDisplayName(null as any), check: (p: any) => p.name === null },
|
|
];
|
|
|
|
for (const { action, check } of changes) {
|
|
await action();
|
|
const token = await user.getAccessToken();
|
|
expect(token).toBeDefined();
|
|
const payload = decodeAccessToken(token!);
|
|
expect(check(payload)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
});
|