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:
1 parent
e8fa5e557e
commit
27a8f0667c
39 files changed
+1464
-643
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user