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
@@ -89,42 +89,6 @@ const messages: Record<string, Record<Lang, string>> = {
|
||||
en: 'Empty Bearer token',
|
||||
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
|
||||
},
|
||||
'auth.malformedJwt': {
|
||||
en: 'Malformed JWT: expected 3 parts',
|
||||
zh: 'JWT \u683c\u5f0f\u9519\u8bef\uff1a\u5e94\u5305\u542b 3 \u4e2a\u90e8\u5206',
|
||||
},
|
||||
'auth.invalidSignature': {
|
||||
en: 'Invalid JWT signature',
|
||||
zh: 'JWT \u7b7e\u540d\u65e0\u6548',
|
||||
},
|
||||
'auth.malformedPayload': {
|
||||
en: 'Malformed JWT payload',
|
||||
zh: 'JWT \u8d1f\u8f7d\u683c\u5f0f\u9519\u8bef',
|
||||
},
|
||||
'auth.missingSub': {
|
||||
en: 'JWT missing required "sub" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "sub" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingOrgId': {
|
||||
en: 'JWT missing required "org_id" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "org_id" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingRole': {
|
||||
en: 'JWT missing required "role" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "role" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingExp': {
|
||||
en: 'JWT missing required "exp" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "exp" \u58f0\u660e',
|
||||
},
|
||||
'auth.expired': {
|
||||
en: 'JWT has expired',
|
||||
zh: 'JWT \u5df2\u8fc7\u671f',
|
||||
},
|
||||
'auth.unsupportedAlgorithm': {
|
||||
en: 'Unsupported JWT algorithm. Only HS256 is accepted.',
|
||||
zh: '\u4e0d\u652f\u6301\u7684 JWT \u7b97\u6cd5\u3002\u4ec5\u63a5\u53d7 HS256\u3002',
|
||||
},
|
||||
'auth.ttlMustBePositive': {
|
||||
en: 'ttl_seconds must be a positive number',
|
||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570',
|
||||
|
||||
@@ -48,7 +48,7 @@ const queue = new BullMQAdapter({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const requiredSecrets = [
|
||||
{ name: 'JWT_SECRET', value: process.env.JWT_SECRET },
|
||||
{ name: 'BETTER_AUTH_SECRET', value: process.env.BETTER_AUTH_SECRET },
|
||||
{ name: 'ELYDORA_SIGNING_KEY', value: process.env.ELYDORA_SIGNING_KEY },
|
||||
];
|
||||
for (const { name, value } of requiredSecrets) {
|
||||
@@ -70,7 +70,8 @@ const env: Env = {
|
||||
ENVIRONMENT: process.env.ENVIRONMENT ?? 'production',
|
||||
API_VERSION: process.env.API_VERSION ?? 'v1',
|
||||
PROTOCOL_VERSION: process.env.PROTOCOL_VERSION ?? '1.0',
|
||||
JWT_SECRET: process.env.JWT_SECRET!,
|
||||
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET!,
|
||||
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL ?? 'http://localhost:8787',
|
||||
ELYDORA_SIGNING_KEY: process.env.ELYDORA_SIGNING_KEY!,
|
||||
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS ?? '',
|
||||
TSA_URL: process.env.TSA_URL,
|
||||
@@ -89,17 +90,14 @@ app.use('/*', async (c, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
// CORS
|
||||
// CORS — always restrict to the configured allowlist
|
||||
app.use(
|
||||
'/*',
|
||||
cors({
|
||||
origin: (origin, c) => {
|
||||
if (c.env.ENVIRONMENT === 'production') {
|
||||
const raw = c.env.ALLOWED_ORIGINS ?? '';
|
||||
const allowedOrigins = raw.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
return allowedOrigins.includes(origin) ? origin : '';
|
||||
}
|
||||
return origin;
|
||||
const raw = c.env.ALLOWED_ORIGINS ?? 'http://localhost:3000,http://localhost:8787';
|
||||
const allowedOrigins = raw.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
return allowedOrigins.includes(origin) ? origin : '';
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: [
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Better Auth configuration for ElydoraOpenSource.
|
||||
*
|
||||
* Creates a Better Auth instance backed by PostgreSQL. The instance is
|
||||
* configured to:
|
||||
*
|
||||
* - Use the existing `users` table via field-mapping (no schema change)
|
||||
* - Support legacy PBKDF2-SHA256 password hashes alongside Better Auth's
|
||||
* default scrypt so existing users can sign in without re-hashing
|
||||
* - Expose bearer-token auth for API clients
|
||||
* - Provide organization support with Elydora's 5-role RBAC hierarchy
|
||||
* - Provide admin user-management endpoints
|
||||
*/
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto';
|
||||
import { bearer } from 'better-auth/plugins/bearer';
|
||||
import { organization } from 'better-auth/plugins/organization';
|
||||
import { admin } from 'better-auth/plugins/admin';
|
||||
import { createAccessControl } from 'better-auth/plugins/access';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy PBKDF2 password verification (matches auth-service.ts format)
|
||||
// Format: pbkdf2:iterations:salt_hex:hash_hex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function verifyLegacyPbkdf2(password: string, stored: string): Promise<boolean> {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false;
|
||||
|
||||
const iterations = parseInt(parts[1]!, 10);
|
||||
const saltHex = parts[2]!;
|
||||
const storedHashHex = parts[3]!;
|
||||
|
||||
const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map((b) => parseInt(b, 16)));
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits'],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
256,
|
||||
);
|
||||
const hashHex = Array.from(new Uint8Array(bits))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
// Constant-time comparison
|
||||
if (hashHex.length !== storedHashHex.length) return false;
|
||||
let result = 0;
|
||||
for (let i = 0; i < hashHex.length; i++) {
|
||||
result |= hashHex.charCodeAt(i) ^ storedHashHex.charCodeAt(i);
|
||||
}
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RBAC access control — maps Elydora's 5-role hierarchy to Better Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const elydoraAc = createAccessControl({
|
||||
organization: ['create', 'read', 'update', 'delete'],
|
||||
member: ['create', 'read', 'update', 'delete'],
|
||||
invitation: ['create', 'cancel'],
|
||||
agent: ['create', 'read', 'update', 'freeze', 'unfreeze', 'revoke', 'delete'],
|
||||
operation: ['create', 'read'],
|
||||
audit: ['read', 'export'],
|
||||
webhook: ['create', 'read', 'update', 'delete'],
|
||||
} as const);
|
||||
|
||||
const orgOwnerRole = elydoraAc.newRole({
|
||||
organization: ['create', 'read', 'update', 'delete'],
|
||||
member: ['create', 'read', 'update', 'delete'],
|
||||
invitation: ['create', 'cancel'],
|
||||
agent: ['create', 'read', 'update', 'freeze', 'unfreeze', 'revoke', 'delete'],
|
||||
operation: ['create', 'read'],
|
||||
audit: ['read', 'export'],
|
||||
webhook: ['create', 'read', 'update', 'delete'],
|
||||
});
|
||||
|
||||
const securityAdminRole = elydoraAc.newRole({
|
||||
organization: ['read', 'update'],
|
||||
member: ['create', 'read', 'update', 'delete'],
|
||||
invitation: ['create', 'cancel'],
|
||||
agent: ['create', 'read', 'update', 'freeze', 'unfreeze', 'revoke', 'delete'],
|
||||
operation: ['create', 'read'],
|
||||
audit: ['read', 'export'],
|
||||
webhook: ['create', 'read', 'update', 'delete'],
|
||||
});
|
||||
|
||||
const complianceAuditorRole = elydoraAc.newRole({
|
||||
organization: ['read'],
|
||||
member: ['read'],
|
||||
agent: ['read'],
|
||||
operation: ['read'],
|
||||
audit: ['read', 'export'],
|
||||
webhook: ['read'],
|
||||
});
|
||||
|
||||
const integrationEngineerRole = elydoraAc.newRole({
|
||||
organization: ['read'],
|
||||
member: ['read'],
|
||||
agent: ['create', 'read', 'update'],
|
||||
operation: ['create', 'read'],
|
||||
audit: ['read'],
|
||||
webhook: ['create', 'read', 'update', 'delete'],
|
||||
});
|
||||
|
||||
const readonlyInvestigatorRole = elydoraAc.newRole({
|
||||
organization: ['read'],
|
||||
member: ['read'],
|
||||
agent: ['read'],
|
||||
operation: ['read'],
|
||||
audit: ['read'],
|
||||
webhook: ['read'],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton — creates a Better Auth instance for the server process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, allowedOrigins: string) {
|
||||
return betterAuth({
|
||||
database: {
|
||||
type: 'postgres',
|
||||
url: databaseUrl,
|
||||
},
|
||||
secret,
|
||||
baseURL: baseUrl,
|
||||
trustedOrigins: allowedOrigins
|
||||
? allowedOrigins.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: [],
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 256,
|
||||
password: {
|
||||
verify: async ({ hash, password }) => {
|
||||
// Handle legacy PBKDF2 hashes from the existing auth system
|
||||
if (hash.startsWith('pbkdf2:')) {
|
||||
return verifyLegacyPbkdf2(password, hash);
|
||||
}
|
||||
// For BA-native scrypt hashes (new registrations, password resets)
|
||||
return verifyScryptPassword({ hash, password });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
user: {
|
||||
modelName: 'users',
|
||||
fields: {
|
||||
name: 'display_name',
|
||||
email: 'email',
|
||||
emailVerified: 'email_verified',
|
||||
image: 'image',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
additionalFields: {
|
||||
org_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
defaultValue: 'org_owner',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
password_hash: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
onboarding_completed: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
defaultValue: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user, _context) => {
|
||||
const now = new Date();
|
||||
return {
|
||||
data: {
|
||||
...user,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
role: 'org_owner',
|
||||
},
|
||||
};
|
||||
},
|
||||
after: async (_user) => {
|
||||
// No-op: new users start with org_id = NULL and
|
||||
// onboarding_completed = 0 (DB default). The onboarding
|
||||
// flow handles org creation.
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
account: {
|
||||
modelName: 'ba_accounts',
|
||||
fields: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
userId: 'user_id',
|
||||
providerId: 'provider_id',
|
||||
accountId: 'account_id',
|
||||
accessToken: 'access_token',
|
||||
refreshToken: 'refresh_token',
|
||||
idToken: 'id_token',
|
||||
accessTokenExpiresAt: 'access_token_expires_at',
|
||||
refreshTokenExpiresAt: 'refresh_token_expires_at',
|
||||
},
|
||||
},
|
||||
|
||||
verification: {
|
||||
modelName: 'ba_verifications',
|
||||
fields: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
expiresAt: 'expires_at',
|
||||
},
|
||||
},
|
||||
|
||||
session: {
|
||||
modelName: 'ba_sessions',
|
||||
expiresIn: 604800, // 7 days in seconds
|
||||
updateAge: 86400, // refresh session if older than 1 day
|
||||
fields: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
userId: 'user_id',
|
||||
expiresAt: 'expires_at',
|
||||
ipAddress: 'ip_address',
|
||||
userAgent: 'user_agent',
|
||||
},
|
||||
},
|
||||
|
||||
advanced: {
|
||||
cookiePrefix: 'elydora',
|
||||
defaultCookieAttributes: {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
bearer(),
|
||||
organization({
|
||||
ac: elydoraAc,
|
||||
creatorRole: 'org_owner',
|
||||
roles: {
|
||||
org_owner: orgOwnerRole,
|
||||
security_admin: securityAdminRole,
|
||||
compliance_auditor: complianceAuditorRole,
|
||||
integration_engineer: integrationEngineerRole,
|
||||
readonly_investigator: readonlyInvestigatorRole,
|
||||
},
|
||||
schema: {
|
||||
organization: {
|
||||
modelName: 'ba_organizations',
|
||||
fields: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
},
|
||||
member: {
|
||||
modelName: 'ba_members',
|
||||
fields: {
|
||||
createdAt: 'created_at',
|
||||
organizationId: 'organization_id',
|
||||
userId: 'user_id',
|
||||
},
|
||||
},
|
||||
invitation: {
|
||||
modelName: 'ba_invitations',
|
||||
fields: {
|
||||
createdAt: 'created_at',
|
||||
organizationId: 'organization_id',
|
||||
inviterId: 'inviter_id',
|
||||
expiresAt: 'expires_at',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
admin(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export type ElydoraAuth = ReturnType<typeof createAuth>;
|
||||
@@ -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();
|
||||
};
|
||||
@@ -2,15 +2,17 @@
|
||||
* Auth routes — registration, login, profile retrieval, and token refresh.
|
||||
*
|
||||
* /register and /login are public (no auth required).
|
||||
* /me and /refresh require a valid JWT.
|
||||
* /me, /refresh, and /token require a valid Better Auth session.
|
||||
*
|
||||
* Uses Better Auth for session management while preserving the existing
|
||||
* API surface (endpoint paths and response formats).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { registerUser, loginUser, getMe, issueJWT, issueApiToken } from '../services/auth-service.js';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js';
|
||||
import { getMe } from '../services/auth-service.js';
|
||||
import { createAuth } from '../lib/auth.js';
|
||||
import { getMessage } from '../i18n/messages.js';
|
||||
|
||||
const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
@@ -22,28 +24,111 @@ auth.post('/register', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { email, password, display_name, org_name } = body;
|
||||
|
||||
const result = await registerUser(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.JWT_SECRET,
|
||||
email,
|
||||
password,
|
||||
display_name,
|
||||
org_name,
|
||||
const betterAuthInstance = createAuth(
|
||||
process.env.DATABASE_URL!,
|
||||
c.env.BETTER_AUTH_SECRET,
|
||||
c.env.BETTER_AUTH_URL,
|
||||
c.env.ALLOWED_ORIGINS,
|
||||
);
|
||||
|
||||
return c.json(result, 201);
|
||||
// Use Better Auth to sign up the user
|
||||
const signUpResult = await betterAuthInstance.api.signUpEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name: display_name || email.split('@')[0]!,
|
||||
},
|
||||
});
|
||||
|
||||
if (!signUpResult) {
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.emailAlreadyRegistered', lang), request_id: '' } }, 409);
|
||||
}
|
||||
|
||||
const user = signUpResult.user as { id: string; email: string; name: string; org_id?: string; role?: string; status?: string; createdAt?: unknown; updatedAt?: unknown };
|
||||
|
||||
// Create the organization via the database adapter (preserving existing schema)
|
||||
const now = Date.now();
|
||||
const { generateUUIDv7 } = await import('../utils/uuid.js');
|
||||
const orgId = generateUUIDv7();
|
||||
const resolvedOrgName = org_name || `${user.name}'s Organization`;
|
||||
|
||||
await c.env.ELYDORA_DB.batch([
|
||||
c.env.ELYDORA_DB
|
||||
.prepare(
|
||||
'INSERT INTO organizations (org_id, name, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.bind(orgId, resolvedOrgName, now, now),
|
||||
c.env.ELYDORA_DB
|
||||
.prepare(
|
||||
'UPDATE users SET org_id = ?, role = ?, status = ? WHERE user_id = ?',
|
||||
)
|
||||
.bind(orgId, 'org_owner', 'active', user.id),
|
||||
]);
|
||||
|
||||
// Return the session token
|
||||
const token = signUpResult.token ?? '';
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
user_id: user.id,
|
||||
org_id: orgId,
|
||||
email: user.email,
|
||||
display_name: user.name,
|
||||
role: 'org_owner',
|
||||
status: 'active',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
organization: {
|
||||
org_id: orgId,
|
||||
name: resolvedOrgName,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
token,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/login — Authenticate and receive a JWT
|
||||
// POST /v1/auth/login — Authenticate and receive a session token
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/login', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const result = await loginUser(c.env.ELYDORA_DB, c.env.JWT_SECRET, email, password);
|
||||
const betterAuthInstance = createAuth(
|
||||
process.env.DATABASE_URL!,
|
||||
c.env.BETTER_AUTH_SECRET,
|
||||
c.env.BETTER_AUTH_URL,
|
||||
c.env.ALLOWED_ORIGINS,
|
||||
);
|
||||
|
||||
return c.json(result, 200);
|
||||
const signInResult = await betterAuthInstance.api.signInEmail({
|
||||
body: { email, password },
|
||||
});
|
||||
|
||||
if (!signInResult) {
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidCredentials', lang), request_id: '' } }, 401);
|
||||
}
|
||||
|
||||
const user = signInResult.user as { id: string; email: string; name: string; org_id?: string; role?: string; status?: string; created_at?: number; updated_at?: number };
|
||||
const token = signInResult.token ?? '';
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
user_id: user.id,
|
||||
org_id: user.org_id ?? '',
|
||||
email: user.email,
|
||||
display_name: user.name,
|
||||
role: user.role ?? 'org_owner',
|
||||
status: user.status ?? 'active',
|
||||
created_at: user.created_at ?? 0,
|
||||
updated_at: user.updated_at ?? 0,
|
||||
},
|
||||
token,
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -57,21 +142,29 @@ auth.get('/me', authMiddleware, async (c) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/refresh — Issue a new JWT (auth required)
|
||||
// POST /v1/auth/refresh — Issue a new session token (auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/refresh', authMiddleware, async (c) => {
|
||||
const now = Date.now();
|
||||
const token = await issueJWT(
|
||||
{
|
||||
sub: c.get('actor'),
|
||||
org_id: c.get('org_id'),
|
||||
role: c.get('role') as RbacRole,
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
|
||||
},
|
||||
c.env.JWT_SECRET,
|
||||
const betterAuthInstance = createAuth(
|
||||
process.env.DATABASE_URL!,
|
||||
c.env.BETTER_AUTH_SECRET,
|
||||
c.env.BETTER_AUTH_URL,
|
||||
c.env.ALLOWED_ORIGINS,
|
||||
);
|
||||
|
||||
// Get the current session and return its token — Better Auth handles
|
||||
// session refresh automatically based on the updateAge setting
|
||||
const session = await betterAuthInstance.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidSession', lang), request_id: '' } }, 401);
|
||||
}
|
||||
|
||||
const token = session.session?.token ?? '';
|
||||
|
||||
return c.json({ token }, 200);
|
||||
});
|
||||
|
||||
@@ -89,15 +182,33 @@ auth.post('/token', authMiddleware, async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await issueApiToken(
|
||||
c.get('actor'),
|
||||
c.get('org_id'),
|
||||
c.get('role') as RbacRole,
|
||||
c.env.JWT_SECRET,
|
||||
ttl_seconds ?? null,
|
||||
// Get the current session token — Better Auth sessions have a fixed
|
||||
// lifetime configured in the auth instance (7 days by default).
|
||||
// The ttl_seconds parameter is accepted for API compatibility but
|
||||
// session lifetime is governed by Better Auth's session config.
|
||||
const betterAuthInstance = createAuth(
|
||||
process.env.DATABASE_URL!,
|
||||
c.env.BETTER_AUTH_SECRET,
|
||||
c.env.BETTER_AUTH_URL,
|
||||
c.env.ALLOWED_ORIGINS,
|
||||
);
|
||||
|
||||
return c.json(result, 200);
|
||||
const session = await betterAuthInstance.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidSession', lang), request_id: '' } }, 401);
|
||||
}
|
||||
|
||||
const sessionData = session.session as { token?: string; expiresAt?: Date };
|
||||
const token = sessionData.token ?? '';
|
||||
const expiresAt = sessionData.expiresAt
|
||||
? Math.floor(new Date(sessionData.expiresAt).getTime() / 1000)
|
||||
: Math.floor(Date.now() / 1000) + 604800;
|
||||
|
||||
return c.json({ token, expires_at: expiresAt }, 200);
|
||||
});
|
||||
|
||||
export { auth };
|
||||
@@ -16,7 +16,7 @@ const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
// POST /v1/operations — Submit a signed EOR
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation submission uses the EOR's embedded signature for authentication
|
||||
// rather than a Bearer JWT. The agent authenticates via its Ed25519 signature.
|
||||
// rather than a Bearer token. The agent authenticates via its Ed25519 signature.
|
||||
// We still apply auth middleware to get the org context.
|
||||
operations.post(
|
||||
'/',
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* Auth service — password hashing, JWT issuance, user registration, and login.
|
||||
* Auth service — password hashing and user profile retrieval.
|
||||
*
|
||||
* Uses PBKDF2-SHA256 via Web Crypto API for password hashing and
|
||||
* HMAC-SHA256 for JWT issuance (matching the existing auth middleware).
|
||||
* Authentication is handled by Better Auth (see lib/auth.ts).
|
||||
* This service provides password utilities for legacy PBKDF2 hash
|
||||
* verification and the getMe profile query.
|
||||
*/
|
||||
|
||||
import { base64urlEncode } from '../utils/crypto.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Database } from '../adapters/interfaces.js';
|
||||
|
||||
@@ -34,18 +32,6 @@ export interface OrgRow {
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT payload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JWTPayload {
|
||||
sub: string;
|
||||
org_id: string;
|
||||
role: RbacRole;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password hashing (PBKDF2-SHA256 via Web Crypto)
|
||||
// Format: pbkdf2:100000:salt_hex:hash_hex
|
||||
@@ -110,199 +96,6 @@ export async function verifyPassword(password: string, stored: string): Promise<
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT issuance (HMAC-SHA256, matching existing auth middleware verification)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function issueJWT(payload: JWTPayload, secret: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const headerB64 = base64urlEncode(encoder.encode(JSON.stringify(header)));
|
||||
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
||||
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(signingInput));
|
||||
const signatureB64 = base64urlEncode(new Uint8Array(signature));
|
||||
|
||||
return `${headerB64}.${payloadB64}.${signatureB64}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue API token with custom TTL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum API token lifetime: 365 days. */
|
||||
const MAX_API_TOKEN_TTL = 365 * 24 * 60 * 60;
|
||||
|
||||
export async function issueApiToken(
|
||||
sub: string,
|
||||
orgId: string,
|
||||
role: RbacRole,
|
||||
jwtSecret: string,
|
||||
ttlSeconds: number | null,
|
||||
): Promise<{ token: string; expires_at: number }> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Default to 90 days if no TTL specified; cap at 365 days
|
||||
const effectiveTtl = ttlSeconds === null ? 90 * 24 * 60 * 60 : Math.min(ttlSeconds, MAX_API_TOKEN_TTL);
|
||||
if (effectiveTtl <= 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBePositive' });
|
||||
}
|
||||
const exp = now + effectiveTtl;
|
||||
const expiresAt = exp;
|
||||
|
||||
const token = await issueJWT(
|
||||
{ sub, org_id: orgId, role, iat: now, exp },
|
||||
jwtSecret,
|
||||
);
|
||||
|
||||
return { token, expires_at: expiresAt };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register user + org
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function registerUser(
|
||||
db: Database,
|
||||
jwtSecret: string,
|
||||
email: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
orgName: string,
|
||||
): Promise<{ user: UserRow; organization: OrgRow; token: string }> {
|
||||
// Validate
|
||||
if (!email || !password || password.length < 8 || password.length > 128) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.emailPasswordRequired' });
|
||||
}
|
||||
|
||||
// Check email uniqueness
|
||||
const existing = await db
|
||||
.prepare('SELECT user_id FROM users WHERE email = ?')
|
||||
.bind(email)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.emailAlreadyRegistered' });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const orgId = generateUUIDv7();
|
||||
const userId = generateUUIDv7();
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const resolvedDisplayName = displayName || email.split('@')[0]!;
|
||||
const resolvedOrgName = orgName || `${resolvedDisplayName}'s Organization`;
|
||||
|
||||
// Create org + user atomically in a single transaction
|
||||
await db.batch([
|
||||
db
|
||||
.prepare(
|
||||
'INSERT INTO organizations (org_id, name, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.bind(orgId, resolvedOrgName, now, now),
|
||||
db
|
||||
.prepare(
|
||||
'INSERT INTO users (user_id, org_id, email, password_hash, display_name, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
.bind(userId, orgId, email, hash, resolvedDisplayName, 'org_owner', 'active', now, now),
|
||||
]);
|
||||
|
||||
const token = await issueJWT(
|
||||
{
|
||||
sub: userId,
|
||||
org_id: orgId,
|
||||
role: 'org_owner',
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
|
||||
},
|
||||
jwtSecret,
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
user_id: userId,
|
||||
org_id: orgId,
|
||||
email,
|
||||
display_name: resolvedDisplayName,
|
||||
role: 'org_owner' as RbacRole,
|
||||
status: 'active',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
organization: {
|
||||
org_id: orgId,
|
||||
name: resolvedOrgName,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loginUser(
|
||||
db: Database,
|
||||
jwtSecret: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: UserRow; token: string }> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, org_id, email, password_hash, display_name, role, status, created_at, updated_at FROM users WHERE email = ?',
|
||||
)
|
||||
.bind(email)
|
||||
.first<UserRow & { password_hash: string }>();
|
||||
|
||||
if (!row) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidCredentials' });
|
||||
}
|
||||
|
||||
if (row.status !== 'active') {
|
||||
throw new AppError(403, 'FORBIDDEN', { key: 'auth.accountSuspended' });
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, row.password_hash);
|
||||
if (!valid) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidCredentials' });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const token = await issueJWT(
|
||||
{
|
||||
sub: row.user_id,
|
||||
org_id: row.org_id,
|
||||
role: row.role as RbacRole,
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
|
||||
},
|
||||
jwtSecret,
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
user_id: row.user_id,
|
||||
org_id: row.org_id,
|
||||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
role: row.role as RbacRole,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get current user profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,5 +7,5 @@ export {
|
||||
DEFAULT_EPOCH_INTERVAL_MS,
|
||||
MAX_QUERY_LIMIT,
|
||||
DEFAULT_QUERY_LIMIT,
|
||||
DEFAULT_JWT_TTL_SECONDS,
|
||||
DEFAULT_SESSION_TTL_SECONDS,
|
||||
} from './limits.js';
|
||||
@@ -19,5 +19,5 @@ export const MAX_QUERY_LIMIT = 1000;
|
||||
/** Default number of results per query */
|
||||
export const DEFAULT_QUERY_LIMIT = 100;
|
||||
|
||||
/** Default JWT token lifetime in seconds (24 hours) */
|
||||
export const DEFAULT_JWT_TTL_SECONDS = 86400;
|
||||
/** Default session token lifetime in seconds (24 hours) */
|
||||
export const DEFAULT_SESSION_TTL_SECONDS = 86400;
|
||||
@@ -60,5 +60,5 @@ export {
|
||||
DEFAULT_EPOCH_INTERVAL_MS,
|
||||
MAX_QUERY_LIMIT,
|
||||
DEFAULT_QUERY_LIMIT,
|
||||
DEFAULT_JWT_TTL_SECONDS,
|
||||
DEFAULT_SESSION_TTL_SECONDS,
|
||||
} from './constants/index.js';
|
||||
@@ -30,8 +30,11 @@ export interface Env {
|
||||
/** Current Elydora protocol version (e.g. "1.0") */
|
||||
PROTOCOL_VERSION: string;
|
||||
|
||||
/** HMAC secret used for JWT verification */
|
||||
JWT_SECRET: string;
|
||||
/** Secret used by Better Auth for session signing and encryption */
|
||||
BETTER_AUTH_SECRET: string;
|
||||
|
||||
/** Base URL for Better Auth (e.g. "http://localhost:8787") */
|
||||
BETTER_AUTH_URL: string;
|
||||
|
||||
/** Base64url-encoded Ed25519 private key used by the server to sign receipts */
|
||||
ELYDORA_SIGNING_KEY: string;
|
||||
@@ -50,13 +53,13 @@ export interface AppVariables {
|
||||
/** UUIDv7 request identifier injected by the request-id middleware */
|
||||
request_id: string;
|
||||
|
||||
/** Organization identifier extracted from the verified JWT */
|
||||
/** Organization identifier extracted from the verified session */
|
||||
org_id: string;
|
||||
|
||||
/** RBAC role extracted from the verified JWT */
|
||||
/** RBAC role extracted from the verified session */
|
||||
role: import('./shared/index.js').RbacRole;
|
||||
|
||||
/** Actor identifier (sub claim) extracted from the verified JWT */
|
||||
/** Actor identifier extracted from the verified session */
|
||||
actor: string;
|
||||
|
||||
/** Detected language from Accept-Language header (set by i18n middleware) */
|
||||
|
||||
Reference in new issue
Block a user