fix(auth): verify BetterAuth sessions via cookie headers
This commit is contained in:
@@ -410,7 +410,7 @@ describe("AuthService", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("should return session data for valid token", async () => {
|
||||
it("should validate session token using secure BetterAuth cookie header", async () => {
|
||||
const auth = service.getAuth();
|
||||
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
|
||||
auth.api = { getSession: mockGetSession } as any;
|
||||
@@ -418,7 +418,43 @@ describe("AuthService", () => {
|
||||
const result = await service.verifySession("valid-token");
|
||||
|
||||
expect(result).toEqual(mockSessionData);
|
||||
expect(mockGetSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetSession).toHaveBeenCalledWith({
|
||||
headers: {
|
||||
cookie: "__Secure-better-auth.session_token=valid-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to Authorization header when cookie-based lookups miss", async () => {
|
||||
const auth = service.getAuth();
|
||||
const mockGetSession = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(mockSessionData);
|
||||
auth.api = { getSession: mockGetSession } as any;
|
||||
|
||||
const result = await service.verifySession("valid-token");
|
||||
|
||||
expect(result).toEqual(mockSessionData);
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(1, {
|
||||
headers: {
|
||||
cookie: "__Secure-better-auth.session_token=valid-token",
|
||||
},
|
||||
});
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(2, {
|
||||
headers: {
|
||||
cookie: "better-auth.session_token=valid-token",
|
||||
},
|
||||
});
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(3, {
|
||||
headers: {
|
||||
cookie: "__Host-better-auth.session_token=valid-token",
|
||||
},
|
||||
});
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(4, {
|
||||
headers: {
|
||||
authorization: "Bearer valid-token",
|
||||
},
|
||||
@@ -517,14 +553,10 @@ describe("AuthService", () => {
|
||||
|
||||
it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => {
|
||||
const auth = service.getAuth();
|
||||
const mockGetSession = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("certificate has expired"));
|
||||
const mockGetSession = vi.fn().mockRejectedValue(new Error("certificate has expired"));
|
||||
auth.api = { getSession: mockGetSession } as any;
|
||||
|
||||
await expect(service.verifySession("any-token")).rejects.toThrow(
|
||||
"certificate has expired"
|
||||
);
|
||||
await expect(service.verifySession("any-token")).rejects.toThrow("certificate has expired");
|
||||
});
|
||||
|
||||
it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => {
|
||||
|
||||
@@ -21,6 +21,10 @@ interface VerifiedSession {
|
||||
session: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SessionHeaderCandidate {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
@@ -103,36 +107,27 @@ export class AuthService {
|
||||
* Only known-safe auth errors return null; everything else propagates as 500.
|
||||
*/
|
||||
async verifySession(token: string): Promise<VerifiedSession | null> {
|
||||
try {
|
||||
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
|
||||
const session = await this.auth.api.getSession({
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
let sawNonError = false;
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of this.buildSessionHeaderCandidates(token)) {
|
||||
try {
|
||||
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
|
||||
const session = await this.auth.api.getSession(candidate);
|
||||
|
||||
return {
|
||||
user: session.user as Record<string, unknown>,
|
||||
session: session.session as Record<string, unknown>,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Only known-safe auth errors return null
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
const isExpectedAuthError =
|
||||
msg.includes("invalid token") ||
|
||||
msg.includes("token expired") ||
|
||||
msg.includes("session expired") ||
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("invalid session") ||
|
||||
msg === "unauthorized" ||
|
||||
msg === "expired";
|
||||
if (!session) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
user: session.user as Record<string, unknown>,
|
||||
session: session.session as Record<string, unknown>,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (this.isExpectedAuthError(error.message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isExpectedAuthError) {
|
||||
// Infrastructure or unexpected — propagate as 500
|
||||
const safeMessage = (error.stack ?? error.message).replace(
|
||||
/Bearer\s+\S+/gi,
|
||||
@@ -141,14 +136,57 @@ export class AuthService {
|
||||
this.logger.error("Session verification failed due to unexpected error", safeMessage);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Non-Error thrown values — log once for observability, treat as auth failure
|
||||
if (!sawNonError) {
|
||||
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
|
||||
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
|
||||
sawNonError = true;
|
||||
}
|
||||
}
|
||||
// Non-Error thrown values — log for observability, treat as auth failure
|
||||
if (!(error instanceof Error)) {
|
||||
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
|
||||
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildSessionHeaderCandidates(token: string): SessionHeaderCandidate[] {
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
|
||||
return [
|
||||
{
|
||||
headers: {
|
||||
cookie: `__Secure-better-auth.session_token=${encodedToken}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
cookie: `better-auth.session_token=${encodedToken}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
cookie: `__Host-better-auth.session_token=${encodedToken}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private isExpectedAuthError(message: string): boolean {
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
normalized.includes("invalid token") ||
|
||||
normalized.includes("token expired") ||
|
||||
normalized.includes("session expired") ||
|
||||
normalized.includes("session not found") ||
|
||||
normalized.includes("invalid session") ||
|
||||
normalized === "unauthorized" ||
|
||||
normalized === "expired"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user