Complete Better Auth migration, harden security, update all docs

Console:
- Rewrite auth from JWT decoding to Better Auth useSession()
- Add auth-client.ts with createAuthClient
- Remove elydora_token localStorage, dead auth API functions
- Fix login/register pages to use signIn.email/signUp.email

Server:
- Upgrade better-auth ^1.2.0 → ^1.5.5
- CORS: enforce allowlist on all environments
- Sync auth service and middleware with backend

SDKs:
- Deprecate login/register in Node, Python, Go SDKs
- Update READMEs to show direct token initialization

Config:
- Sanitize .env with dev-only placeholder values
- Update helm chart and install scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-25 00:48:57 -04:00
1 parent e8fa5e557e
commit 27a8f0667c
39 files changed
+1464 -643

No files matched your search

+4 -41
View File
@@ -18,10 +18,6 @@ import type {
CreateExportRequest,
CreateExportResponse,
GetExportResponse,
AuthLoginResponse,
AuthRegisterResponse,
AuthMeResponse,
AuthRefreshResponse,
IssueTokenResponse,
JWKSResponse,
ErrorResponse,
@@ -42,34 +38,26 @@ class ApiError extends Error {
}
}
function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('elydora_token');
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const token = getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers as Record<string, string> ?? {}),
};
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
credentials: 'include',
});
if (!response.ok) {
// On 401, clear stored tokens and redirect to login
// On 401, redirect to login (session expired or not authenticated)
if (response.status === 401 && typeof window !== 'undefined') {
localStorage.removeItem('elydora_token');
localStorage.removeItem('elydora_display_name');
localStorage.removeItem('elydora_email');
window.location.href = '/login';
return undefined as T;
}
let errorBody: ErrorResponse | null = null;
@@ -213,11 +201,10 @@ export const api = {
},
async download(exportId: string): Promise<Blob> {
const token = getAuthToken();
const response = await fetch(
`${API_BASE_URL}/v1/exports/${encodeURIComponent(exportId)}/download`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: 'include',
},
);
if (!response.ok) {
@@ -234,30 +221,6 @@ export const api = {
},
auth: {
login(email: string, password: string): Promise<AuthLoginResponse> {
return request<AuthLoginResponse>('/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
},
register(email: string, password: string, displayName: string, orgName: string): Promise<AuthRegisterResponse> {
return request<AuthRegisterResponse>('/v1/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, display_name: displayName, org_name: orgName }),
});
},
me(): Promise<AuthMeResponse> {
return request<AuthMeResponse>('/v1/auth/me');
},
refresh(): Promise<AuthRefreshResponse> {
return request<AuthRefreshResponse>('/v1/auth/refresh', {
method: 'POST',
});
},
issueToken(ttlSeconds: number | null): Promise<IssueTokenResponse> {
return request<IssueTokenResponse>('/v1/auth/token', {
method: 'POST',
+11
View File
@@ -0,0 +1,11 @@
import { createAuthClient } from 'better-auth/react';
import { organizationClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8787',
plugins: [
organizationClient(),
],
});
export const { signIn, signUp, signOut, useSession } = authClient;
+51 -69
View File
@@ -1,101 +1,83 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { createContext, useContext, useMemo, useCallback } from 'react';
import type { ReactNode } from 'react';
import { useSession, signOut } from '@/lib/auth-client';
import type { RbacRole } from '@elydora/shared';
interface AuthUser {
id: string;
sub: string;
org_id: string;
role: RbacRole;
display_name?: string;
email?: string;
onboarding_completed: boolean;
}
interface AuthContextValue {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (token: string, user?: { display_name?: string; email?: string }) => void;
logout: () => void;
isAdminRole: boolean;
canManageMembers: boolean;
canManageAgents: boolean;
canViewAudit: boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
function decodeJWTPayload(token: string): AuthUser | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
// base64url decode
let base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4 !== 0) base64 += '=';
const json = atob(base64);
const payload = JSON.parse(json);
// Check expiration
const nowSeconds = Math.floor(Date.now() / 1000);
if (typeof payload.exp === 'number' && payload.exp < nowSeconds) return null;
if (!payload.sub || !payload.org_id || !payload.role) return null;
return { sub: payload.sub, org_id: payload.org_id, role: payload.role };
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { data: session, isPending } = useSession();
useEffect(() => {
const stored = localStorage.getItem('elydora_token');
if (stored) {
const decoded = decodeJWTPayload(stored);
if (decoded) {
setToken(stored);
// Try to fetch full user profile from localStorage
const displayName = localStorage.getItem('elydora_display_name');
const email = localStorage.getItem('elydora_email');
if (displayName) decoded.display_name = displayName;
if (email) decoded.email = email;
setUser({ ...decoded });
} else {
// Token expired or invalid, clear it
localStorage.removeItem('elydora_token');
localStorage.removeItem('elydora_display_name');
localStorage.removeItem('elydora_email');
}
}
setIsLoading(false);
}, []);
const user: AuthUser | null = useMemo(() => {
if (!session?.user) return null;
const login = useCallback((newToken: string, userInfo?: { display_name?: string; email?: string }) => {
localStorage.setItem('elydora_token', newToken);
const decoded = decodeJWTPayload(newToken);
if (decoded) {
if (userInfo?.display_name) {
decoded.display_name = userInfo.display_name;
localStorage.setItem('elydora_display_name', userInfo.display_name);
}
if (userInfo?.email) {
decoded.email = userInfo.email;
localStorage.setItem('elydora_email', userInfo.email);
}
setUser(decoded);
}
setToken(newToken);
}, []);
const sessionUser = session.user as Record<string, unknown>;
const sessionData = session.session as Record<string, unknown>;
const logout = useCallback(() => {
localStorage.removeItem('elydora_token');
localStorage.removeItem('elydora_display_name');
localStorage.removeItem('elydora_email');
setUser(null);
setToken(null);
const orgId =
(sessionData.activeOrganizationId as string) ??
(sessionUser.org_id as string) ??
'';
const role =
(sessionUser.role as RbacRole) ?? 'readonly_investigator';
return {
id: session.user.id,
sub: session.user.id,
org_id: orgId,
role,
display_name: session.user.name || undefined,
email: session.user.email || undefined,
onboarding_completed: !!(sessionUser.onboarding_completed),
};
}, [session]);
const isAdminRole = user?.role === 'org_owner' || user?.role === 'security_admin';
const canManageMembers = isAdminRole;
const canManageAgents = isAdminRole || user?.role === 'integration_engineer';
const canViewAudit = isAdminRole || user?.role === 'compliance_auditor';
const logout = useCallback(async () => {
await signOut();
window.location.href = '/login';
}, []);
return (
<AuthContext.Provider value={{ user, token, isAuthenticated: !!user, isLoading, login, logout }}>
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading: isPending,
logout,
isAdminRole: !!isAdminRole,
canManageMembers: !!canManageMembers,
canManageAgents: !!canManageAgents,
canViewAudit: !!canViewAudit,
}}
>
{children}
</AuthContext.Provider>
);