mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-24 21:04:03 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Mirror main branch to main-mirror-for-wdb / lint_and_build (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Dev Environment Test With Custom Base Port / restart-dev-and-test-with-custom-base-port (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests with custom base port / setup-tests-with-custom-base-port (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
Sync Main to Dev / sync-commits (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
403 lines
14 KiB
TypeScript
403 lines
14 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("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);
|
|
}
|
|
});
|
|
});
|
|
});
|