mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-21 21:09:49 +08:00
test(sessions): cover access-only session keying and updateAccessToken
Unit-tests the two behaviors this PR changes in InternalSession: access-only sessions key by refresh_token_id (stable across re-minted tokens, distinct per session, raw-token fallback when undecodable), and updateAccessToken installs a same-session token in place while rejecting foreign tokens, null/unchanged/ undecodable no-ops, and never reviving an invalidated session.
This commit is contained in:
parent
804e380dea
commit
ef168ba49f
136
packages/shared/src/sessions.test.ts
Normal file
136
packages/shared/src/sessions.test.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InternalSession } from "./sessions";
|
||||
|
||||
/**
|
||||
* Builds a decodable (unsigned) access-token JWT with a valid payload. `refreshTokenId` controls the
|
||||
* `refresh_token_id` claim (the session identifier); `iatOffsetSeconds` lets two tokens for the same session
|
||||
* differ as strings while sharing a `refresh_token_id`.
|
||||
*/
|
||||
function createAccessTokenString(refreshTokenId: string, options?: { iatOffsetSeconds?: number, sub?: string }): string {
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const nowSeconds = Math.floor(Date.now() / 1000) + (options?.iatOffsetSeconds ?? 0);
|
||||
return [
|
||||
encode({ alg: "none", typ: "JWT" }),
|
||||
encode({
|
||||
sub: options?.sub ?? "user-id",
|
||||
exp: nowSeconds + 60,
|
||||
iat: nowSeconds,
|
||||
iss: "https://api.example.test",
|
||||
aud: "project-id",
|
||||
project_id: "project-id",
|
||||
branch_id: "main",
|
||||
refresh_token_id: refreshTokenId,
|
||||
role: "authenticated",
|
||||
name: null,
|
||||
email: null,
|
||||
email_verified: false,
|
||||
selected_team_id: null,
|
||||
signed_up_at: nowSeconds,
|
||||
is_anonymous: false,
|
||||
is_restricted: false,
|
||||
restricted_reason: null,
|
||||
requires_totp_mfa: false,
|
||||
}),
|
||||
"",
|
||||
].join(".");
|
||||
}
|
||||
|
||||
function createAccessOnlySession(accessToken: string): InternalSession {
|
||||
return new InternalSession({
|
||||
refreshAccessTokenCallback: async () => null,
|
||||
refreshToken: null,
|
||||
accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
const currentToken = (session: InternalSession) => session.getAccessTokenIfNotExpiredYet(20_000, null)?.token;
|
||||
|
||||
describe("InternalSession.calculateSessionKey", () => {
|
||||
it("keys by the refresh token when one is present (ignoring any access token)", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: "rt-abc" })).toBe("refresh-rt-abc");
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: "rt-abc", accessToken: createAccessTokenString("rtid-1") }))
|
||||
.toBe("refresh-rt-abc");
|
||||
});
|
||||
|
||||
it("returns not-logged-in when neither token is present", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null })).toBe("not-logged-in");
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: null })).toBe("not-logged-in");
|
||||
});
|
||||
|
||||
it("keys an access-only session by its refresh_token_id", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-1") }))
|
||||
.toBe("access-session-rtid-1");
|
||||
});
|
||||
|
||||
it("is stable across re-minted access tokens for the same session (the regression this fixes)", () => {
|
||||
const first = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
|
||||
const second = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
|
||||
expect(second).not.toBe(first);
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: second }))
|
||||
.toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: first }));
|
||||
});
|
||||
|
||||
it("distinguishes access-only sessions with different refresh_token_ids", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-1") }))
|
||||
.not.toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-2") }));
|
||||
});
|
||||
|
||||
it("falls back to the raw token when the access token can't be decoded", () => {
|
||||
expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: "not-a-jwt" })).toBe("access-not-a-jwt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InternalSession#updateAccessToken", () => {
|
||||
it("installs a fresh token for the same session in place", () => {
|
||||
const initial = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
|
||||
const refreshed = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
|
||||
const session = createAccessOnlySession(initial);
|
||||
|
||||
session.updateAccessToken(refreshed);
|
||||
expect(currentToken(session)).toBe(refreshed);
|
||||
// identity is unchanged — same session key, same object
|
||||
expect(session.sessionKey).toBe("access-session-rtid-1");
|
||||
});
|
||||
|
||||
it("rejects a token belonging to a different session", () => {
|
||||
const initial = createAccessTokenString("rtid-1");
|
||||
const foreign = createAccessTokenString("rtid-2", { sub: "other-user" });
|
||||
const session = createAccessOnlySession(initial);
|
||||
|
||||
session.updateAccessToken(foreign);
|
||||
expect(currentToken(session)).toBe(initial);
|
||||
});
|
||||
|
||||
it("is a no-op for an unchanged, null, or undecodable token", () => {
|
||||
const initial = createAccessTokenString("rtid-1");
|
||||
const session = createAccessOnlySession(initial);
|
||||
|
||||
session.updateAccessToken(initial);
|
||||
session.updateAccessToken(null);
|
||||
session.updateAccessToken("not-a-jwt");
|
||||
expect(currentToken(session)).toBe(initial);
|
||||
});
|
||||
|
||||
it("never revives an invalidated session", () => {
|
||||
const session = createAccessOnlySession(createAccessTokenString("rtid-1"));
|
||||
session.markInvalid();
|
||||
|
||||
session.updateAccessToken(createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 }));
|
||||
expect(session.isKnownToBeInvalid()).toBe(true);
|
||||
expect(currentToken(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates a refresh-token-backed session's access token in place", () => {
|
||||
const session = new InternalSession({
|
||||
refreshAccessTokenCallback: async () => null,
|
||||
refreshToken: "rt-abc",
|
||||
accessToken: createAccessTokenString("rtid-1"),
|
||||
});
|
||||
const refreshed = createAccessTokenString("rtid-2", { iatOffsetSeconds: 1 });
|
||||
|
||||
// a refresh-keyed session is identified by its refresh token, so any valid access token is accepted
|
||||
session.updateAccessToken(refreshed);
|
||||
expect(currentToken(session)).toBe(refreshed);
|
||||
expect(session.sessionKey).toBe("refresh-rt-abc");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user