Files
stack/apps/web/src/lib/auth-client.ts
Jason Woltje 27c4c8edf3 fix(#411): QA-010 — fix minor JSDoc and comment issues across auth files
Fix response.ok JSDoc (2xx not 200), remove stale token refresh claim,
remove non-actionable comment, fix CSRF comment placement, add 403 mapping rationale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:50:04 -06:00

117 lines
3.2 KiB
TypeScript

/**
* BetterAuth client for frontend authentication.
*
* This client handles:
* - Sign in/out operations
* - Session management
* - Cookie-based session lifecycle
*/
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
import { API_BASE_URL } from "./config";
import { parseAuthError } from "./auth/auth-errors";
/**
* Auth client instance configured for Mosaic Stack.
*/
export const authClient = createAuthClient({
baseURL: API_BASE_URL,
basePath: "/auth",
plugins: [genericOAuthClient()],
});
/**
* Export commonly used auth functions.
*/
export const { signIn, signOut, useSession, getSession } = authClient;
/**
* Sign in with email and password.
* Returns the session on success, throws on failure.
*
* Uses direct fetch to POST credentials to BetterAuth's sign-in endpoint.
* The email parameter accepts an email address used as the credential identifier.
*/
export async function signInWithCredentials(email: string, password: string): Promise<unknown> {
const response = await fetch(`${API_BASE_URL}/auth/sign-in/credentials`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include", // Include cookies
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
let errorBody: { message?: string } = {};
try {
errorBody = (await response.json()) as { message?: string };
} catch (jsonError: unknown) {
console.error(
`[Auth] Failed to parse error response body (HTTP ${String(response.status)}):`,
jsonError
);
}
const parsed = parseAuthError(errorBody.message ? new Error(errorBody.message) : response);
throw new Error(parsed.message);
}
const data = (await response.json()) as unknown;
return data;
}
/**
* Get the current access token for API calls.
* Returns null if not authenticated.
*/
export async function getAccessToken(): Promise<string | null> {
try {
const session = await getSession();
if (!session.data?.user) {
return null;
}
// Type assertion for custom user fields
const user = session.data.user as {
accessToken?: string;
tokenExpiresAt?: number;
};
if (!user.accessToken) {
console.warn("[Auth] Session exists but no accessToken found");
return null;
}
// Check if token is expired (with 1 minute buffer)
if (user.tokenExpiresAt && user.tokenExpiresAt - Date.now() < 60000) {
// Token is expired or about to expire
// The session will be refreshed automatically by BetterAuth
// but we should return null to trigger a re-auth if needed
return null;
}
return user.accessToken;
} catch (error: unknown) {
console.error("[Auth] Failed to get access token:", error);
return null;
}
}
/**
* Check if the current user is an admin.
*/
export async function isAdmin(): Promise<boolean> {
try {
const session = await getSession();
if (!session.data?.user) {
return false;
}
const user = session.data.user as { isAdmin?: boolean };
return user.isAdmin === true;
} catch (error: unknown) {
console.error("[Auth] Failed to check admin status:", error);
return false;
}
}