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

+31 -116
View File
@@ -1,147 +1,62 @@
/**
* JWT authentication middleware.
* Better Auth authentication middleware.
*
* Verifies Bearer tokens in the Authorization header using HMAC-SHA256.
* On success, extracts `org_id`, `role`, and `sub` (actor) claims and
* stores them in the Hono context variables for downstream handlers.
* Validates requests via Better Auth sessions (cookie or bearer token).
* On success, extracts `org_id`, `role`, and user `id` (actor) from the
* session and stores them in the Hono context variables for downstream
* handlers.
*
* Token structure (payload):
* {
* "sub": "user_abc123", // actor identifier
* "org_id": "org_acme", // organization scope
* "role": "org_owner", // RBAC role
* "iat": 1700000000, // issued-at (seconds)
* "exp": 1700003600 // expiration (seconds)
* }
* Supports:
* - Session cookies (browser / console)
* - Bearer tokens via Better Auth's bearer plugin (SDK clients)
*/
import type { MiddlewareHandler } from 'hono';
import type { RbacRole } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js';
import { AppError } from './error-handler.js';
import { base64urlDecode } from '../utils/crypto.js';
/** Minimal JWT payload shape we require. */
interface JWTPayload {
sub: string;
org_id: string;
role: RbacRole;
iat: number;
exp: number;
}
/**
* Verify a JWT using HMAC-SHA256 and return its payload.
*/
async function verifyJWT(token: string, secret: string): Promise<JWTPayload> {
const parts = token.split('.');
if (parts.length !== 3) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
}
const headerB64 = parts[0]!;
const payloadB64 = parts[1]!;
const signatureB64 = parts[2]!;
// Validate JWT header — only HS256 is accepted
try {
const headerBytes = base64urlDecode(headerB64);
const header = JSON.parse(new TextDecoder().decode(headerBytes)) as { alg?: string; typ?: string };
if (header.alg !== 'HS256') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.unsupportedAlgorithm' });
}
} catch (e) {
if (e instanceof AppError) throw e;
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
}
// Import the HMAC key
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
// Verify signature over "header.payload"
const signedInput = encoder.encode(`${headerB64}.${payloadB64}`);
const signatureBytes = base64urlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes as unknown as Uint8Array<ArrayBuffer>, signedInput as unknown as Uint8Array<ArrayBuffer>);
if (!valid) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSignature' });
}
// Decode and validate payload
const payloadBytes = base64urlDecode(payloadB64);
const payloadJson = new TextDecoder().decode(payloadBytes);
let payload: JWTPayload;
try {
payload = JSON.parse(payloadJson);
} catch {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedPayload' });
}
// Validate required claims
if (!payload.sub || typeof payload.sub !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingSub' });
}
if (!payload.org_id || typeof payload.org_id !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingOrgId' });
}
if (!payload.role || typeof payload.role !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingRole' });
}
// Check expiration
const nowSeconds = Math.floor(Date.now() / 1000);
if (typeof payload.exp !== 'number') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingExp' });
}
if (payload.exp < nowSeconds) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.expired' });
}
return payload;
}
import { createAuth } from '../lib/auth.js';
/**
* Authentication middleware.
*
* Extracts the Bearer token, verifies it, and sets org_id, role, and
* actor on the context. Should be applied to all routes that require
* authentication.
* Uses Better Auth's getSession to validate the request via session
* cookie or bearer token. Sets org_id, role, and actor on the context.
*/
export const authMiddleware: MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> = async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
}
if (!authHeader.startsWith('Bearer ')) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.bearerRequired' });
let session: Awaited<ReturnType<ReturnType<typeof createAuth>['api']['getSession']>>;
try {
const betterAuthInstance = createAuth(
process.env.DATABASE_URL!,
c.env.BETTER_AUTH_SECRET,
c.env.BETTER_AUTH_URL,
c.env.ALLOWED_ORIGINS,
);
session = await betterAuthInstance.api.getSession({
headers: c.req.raw.headers,
});
} catch {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSession' });
}
const token = authHeader.slice(7).trim();
if (!token) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.emptyToken' });
if (!session) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSession' });
}
const payload = await verifyJWT(token, c.env.JWT_SECRET);
const user = session.user as { id: string; org_id?: string; role?: string };
c.set('org_id', payload.org_id);
c.set('role', payload.role);
c.set('actor', payload.sub);
c.set('org_id', user.org_id ?? '');
c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole));
c.set('actor', user.id);
await next();
};