console: sync the registration wizard and issue real API tokens
Mirror the Console registration feature: native hook catalog with activation plans, one-command install that embeds both credentials in exclusively created owner-only files (mktemp on POSIX, icacls on Windows), sh/PowerShell tabs, never-expiring default token, focus-visible styles, merged i18n keys, and the matching e2e specs. The server previously answered /v1/auth/token with the 7-day Better Auth session token and ignored ttl_seconds. It now issues opaque 32-byte API tokens stored as SHA-256 hashes (migration 003), honours ttl_seconds with null meaning no expiry, and resolves bearer values as API tokens before session validation. Issuance requires a session credential. Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
1 parent
1bb9d4d49d
commit
edf9d73c3e
21 files changed
+967
-163
No files matched your search
@@ -0,0 +1,24 @@
|
||||
-- Opaque API tokens stored as SHA-256 hashes.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
token_id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (user_id),
|
||||
org_id TEXT NOT NULL REFERENCES organizations (org_id),
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
issued_at BIGINT NOT NULL,
|
||||
expires_at BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens (user_id);
|
||||
|
||||
INSERT INTO schema_versions (version, applied_at, description)
|
||||
VALUES (
|
||||
3,
|
||||
EXTRACT(EPOCH FROM NOW())::BIGINT * 1000,
|
||||
'Opaque API tokens'
|
||||
)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -90,8 +90,8 @@ const messages: Record<string, Record<Lang, string>> = {
|
||||
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
|
||||
},
|
||||
'auth.ttlMustBePositive': {
|
||||
en: 'ttl_seconds must be a positive number',
|
||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570',
|
||||
en: 'ttl_seconds must be a whole number of seconds from 1 to 31536000',
|
||||
zh: 'ttl_seconds 必须是 1 到 31536000 之间的整数秒',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -121,6 +121,14 @@ const messages: Record<string, Record<Lang, string>> = {
|
||||
en: 'ttl_seconds must be a number or null',
|
||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null',
|
||||
},
|
||||
'auth.issueRequiresSession': {
|
||||
en: 'API token issuance requires a session credential.',
|
||||
zh: '签发 API 令牌需要使用会话凭据。',
|
||||
},
|
||||
'auth.organizationRequiredForApiToken': {
|
||||
en: 'Organization setup is required before issuing an API token.',
|
||||
zh: '签发 API 令牌前需要完成组织设置。',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent routes (src/routes/agents.ts)
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* Better Auth authentication middleware.
|
||||
* Authentication middleware for opaque API tokens and Better Auth sessions.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Supports:
|
||||
* - Session cookies (browser / console)
|
||||
* - Bearer tokens via Better Auth's bearer plugin (SDK clients)
|
||||
* A bearer value is resolved as a hashed API token first; anything else is
|
||||
* validated as a Better Auth session (cookie or bearer session token).
|
||||
* On success the org_id, role, actor, and credential kind are stored in the
|
||||
* Hono context variables for downstream handlers.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
@@ -16,13 +12,8 @@ import type { RbacRole } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { AppError } from './error-handler.js';
|
||||
import { createAuth } from '../lib/auth.js';
|
||||
import { authenticateApiToken } from '../services/api-token-service.js';
|
||||
|
||||
/**
|
||||
* Authentication middleware.
|
||||
*
|
||||
* 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;
|
||||
@@ -32,6 +23,19 @@ export const authMiddleware: MiddlewareHandler<{
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
|
||||
}
|
||||
|
||||
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
||||
if (bearerToken) {
|
||||
const apiToken = await authenticateApiToken(c.env.ELYDORA_DB, bearerToken);
|
||||
if (apiToken) {
|
||||
c.set('org_id', apiToken.org_id);
|
||||
c.set('role', apiToken.role);
|
||||
c.set('actor', apiToken.user_id);
|
||||
c.set('auth_token_type', 'api');
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let session: Awaited<ReturnType<ReturnType<typeof createAuth>['api']['getSession']>>;
|
||||
|
||||
try {
|
||||
@@ -57,6 +61,7 @@ export const authMiddleware: MiddlewareHandler<{
|
||||
c.set('org_id', user.org_id ?? '');
|
||||
c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole));
|
||||
c.set('actor', user.id);
|
||||
c.set('auth_token_type', 'session');
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
* Auth routes — registration, login, profile retrieval, and token refresh.
|
||||
*
|
||||
* /register and /login are public (no auth required).
|
||||
* /me, /refresh, and /token require a valid Better Auth session.
|
||||
* /me and /refresh require a valid Better Auth session or API token; /token requires a session.
|
||||
*
|
||||
* Uses Better Auth for session management while preserving the existing
|
||||
* API surface (endpoint paths and response formats).
|
||||
@@ -11,6 +11,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import { issueApiToken, MAX_TTL_SECONDS } from '../services/api-token-service.js';
|
||||
import { getMe } from '../services/auth-service.js';
|
||||
import { createAuth } from '../lib/auth.js';
|
||||
import { getMessage } from '../i18n/messages.js';
|
||||
@@ -169,46 +171,32 @@ auth.post('/refresh', authMiddleware, async (c) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/token — Issue an API token with custom TTL (auth required)
|
||||
// POST /v1/auth/token — Issue an opaque API token (session required)
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/token', authMiddleware, async (c) => {
|
||||
if (c.get('auth_token_type') !== 'session') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.issueRequiresSession' });
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const { ttl_seconds } = body;
|
||||
|
||||
if (ttl_seconds !== null && ttl_seconds !== undefined) {
|
||||
if (typeof ttl_seconds !== 'number' || ttl_seconds <= 0) {
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.ttlMustBeNumber', lang), request_id: '' } }, 400);
|
||||
if (typeof ttl_seconds !== 'number') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBeNumber' });
|
||||
}
|
||||
if (!Number.isInteger(ttl_seconds) || ttl_seconds <= 0 || ttl_seconds > MAX_TTL_SECONDS) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBePositive' });
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
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 orgId = c.get('org_id');
|
||||
if (!orgId) {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.organizationRequiredForApiToken' });
|
||||
}
|
||||
|
||||
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);
|
||||
const issued = await issueApiToken(c.env.ELYDORA_DB, c.get('actor'), orgId, ttl_seconds ?? null);
|
||||
return c.json({ token: issued.token, expires_at: issued.expires_at }, 200);
|
||||
});
|
||||
|
||||
export { auth };
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Database } from '../adapters/interfaces.js';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
|
||||
export const MAX_TTL_SECONDS = 31_536_000;
|
||||
|
||||
interface ApiTokenAuthRow {
|
||||
readonly token_id: string;
|
||||
readonly user_id: string;
|
||||
readonly org_id: string;
|
||||
readonly expires_at: number | null;
|
||||
readonly user_org_id: string | null;
|
||||
readonly user_role: RbacRole;
|
||||
readonly user_status: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedApiToken {
|
||||
readonly token_id: string;
|
||||
readonly user_id: string;
|
||||
readonly org_id: string;
|
||||
readonly role: RbacRole;
|
||||
}
|
||||
|
||||
export interface IssuedApiToken {
|
||||
readonly token: string;
|
||||
readonly token_id: string;
|
||||
readonly expires_at: number | null;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function authenticateApiToken(
|
||||
db: Database,
|
||||
rawToken: string,
|
||||
): Promise<AuthenticatedApiToken | null> {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT t.token_id, t.user_id, t.org_id, t.expires_at,
|
||||
u.org_id AS user_org_id, u.role AS user_role, u.status AS user_status
|
||||
FROM api_tokens AS t
|
||||
JOIN users AS u ON u.user_id = t.user_id
|
||||
WHERE t.token_hash = ?`,
|
||||
)
|
||||
.bind(hashToken(rawToken))
|
||||
.first<ApiTokenAuthRow>();
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at !== null && row.expires_at <= nowSeconds) return null;
|
||||
if (row.user_status !== 'active' || row.user_org_id !== row.org_id) return null;
|
||||
|
||||
return {
|
||||
token_id: row.token_id,
|
||||
user_id: row.user_id,
|
||||
org_id: row.org_id,
|
||||
role: row.user_role,
|
||||
};
|
||||
}
|
||||
|
||||
export async function issueApiToken(
|
||||
db: Database,
|
||||
userId: string,
|
||||
orgId: string,
|
||||
ttlSeconds: number | null,
|
||||
): Promise<IssuedApiToken> {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const expiresAt = ttlSeconds === null ? null : nowSeconds + ttlSeconds;
|
||||
const tokenId = generateUUIDv7();
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO api_tokens (token_id, user_id, org_id, token_hash, issued_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(tokenId, userId, orgId, hashToken(token), nowSeconds, expiresAt)
|
||||
.run();
|
||||
|
||||
return { token, token_id: tokenId, expires_at: expiresAt };
|
||||
}
|
||||
@@ -62,6 +62,9 @@ export interface AppVariables {
|
||||
/** Actor identifier extracted from the verified session */
|
||||
actor: string;
|
||||
|
||||
/** Credential kind that authenticated the request */
|
||||
auth_token_type: 'api' | 'session';
|
||||
|
||||
/** Detected language from Accept-Language header (set by i18n middleware) */
|
||||
lang: import('./i18n/messages.js').Lang;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { authMiddleware } from '../dist/middleware/auth.js';
|
||||
import { auth } from '../dist/routes/auth.js';
|
||||
import { authenticateApiToken, issueApiToken } from '../dist/services/api-token-service.js';
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
class FakeDatabase {
|
||||
constructor(firstResult = null) {
|
||||
this.firstResult = firstResult;
|
||||
this.statements = [];
|
||||
}
|
||||
|
||||
prepare(sql) {
|
||||
const database = this;
|
||||
const statement = {
|
||||
sql,
|
||||
values: [],
|
||||
bind(...values) {
|
||||
statement.values = values;
|
||||
return statement;
|
||||
},
|
||||
async first() {
|
||||
database.statements.push({ sql, values: statement.values });
|
||||
return database.firstResult;
|
||||
},
|
||||
async run() {
|
||||
database.statements.push({ sql, values: statement.values });
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
return statement;
|
||||
}
|
||||
}
|
||||
|
||||
function liveRow(overrides = {}) {
|
||||
return {
|
||||
token_id: 'token-1',
|
||||
user_id: 'user-1',
|
||||
org_id: 'org-1',
|
||||
expires_at: null,
|
||||
user_org_id: 'org-1',
|
||||
user_role: 'integration_engineer',
|
||||
user_status: 'active',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ENV = { ELYDORA_DB: null, BETTER_AUTH_SECRET: 'secret', BETTER_AUTH_URL: 'http://localhost', ALLOWED_ORIGINS: '' };
|
||||
|
||||
test('issues a 32-byte token and stores only its hash', async () => {
|
||||
const db = new FakeDatabase();
|
||||
const issued = await issueApiToken(db, 'user-1', 'org-1', 3600);
|
||||
|
||||
assert.equal(Buffer.from(issued.token, 'base64url').length, 32);
|
||||
assert.ok(issued.expires_at >= NOW + 3600);
|
||||
const [insert] = db.statements;
|
||||
assert.match(insert.sql, /INSERT INTO api_tokens/);
|
||||
assert.equal(insert.values[3], createHash('sha256').update(issued.token).digest('hex'));
|
||||
assert.equal(insert.values[5], issued.expires_at);
|
||||
assert.ok(!insert.values.includes(issued.token));
|
||||
});
|
||||
|
||||
test('a null ttl issues a token without expiry', async () => {
|
||||
const db = new FakeDatabase();
|
||||
const issued = await issueApiToken(db, 'user-1', 'org-1', null);
|
||||
assert.equal(issued.expires_at, null);
|
||||
assert.equal(db.statements[0].values[5], null);
|
||||
});
|
||||
|
||||
test('authenticates a live token through its hash', async () => {
|
||||
const db = new FakeDatabase(liveRow());
|
||||
const result = await authenticateApiToken(db, 'raw-token');
|
||||
assert.deepEqual(result, { token_id: 'token-1', user_id: 'user-1', org_id: 'org-1', role: 'integration_engineer' });
|
||||
assert.equal(db.statements[0].values[0], createHash('sha256').update('raw-token').digest('hex'));
|
||||
});
|
||||
|
||||
test('rejects unknown, expired, suspended, and re-homed tokens', async () => {
|
||||
for (const row of [
|
||||
null,
|
||||
liveRow({ expires_at: NOW - 1 }),
|
||||
liveRow({ user_status: 'suspended' }),
|
||||
liveRow({ user_org_id: 'org-2' }),
|
||||
]) {
|
||||
assert.equal(await authenticateApiToken(new FakeDatabase(row), 'raw-token'), null);
|
||||
}
|
||||
assert.notEqual(await authenticateApiToken(new FakeDatabase(liveRow({ expires_at: NOW + 60 })), 'raw-token'), null);
|
||||
});
|
||||
|
||||
test('the middleware resolves an API bearer token before Better Auth', async () => {
|
||||
const app = new Hono();
|
||||
app.get('/whoami', authMiddleware, (c) => c.json({
|
||||
org_id: c.get('org_id'),
|
||||
role: c.get('role'),
|
||||
actor: c.get('actor'),
|
||||
auth_token_type: c.get('auth_token_type'),
|
||||
}));
|
||||
|
||||
const response = await app.request(
|
||||
'/whoami',
|
||||
{ headers: { Authorization: 'Bearer raw-token' } },
|
||||
{ ...ENV, ELYDORA_DB: new FakeDatabase(liveRow()) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
org_id: 'org-1',
|
||||
role: 'integration_engineer',
|
||||
actor: 'user-1',
|
||||
auth_token_type: 'api',
|
||||
});
|
||||
});
|
||||
|
||||
test('an API token cannot issue another API token', async () => {
|
||||
const app = new Hono();
|
||||
app.onError((error, c) => c.json({ error: { code: error.errorCode, key: error.messageKey } }, error.statusCode));
|
||||
app.route('/v1/auth', auth);
|
||||
|
||||
const response = await app.request(
|
||||
'/v1/auth/token',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer raw-token', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ttl_seconds: null }),
|
||||
},
|
||||
{ ...ENV, ELYDORA_DB: new FakeDatabase(liveRow()) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: { code: 'VALIDATION_ERROR', key: 'auth.issueRequiresSession' } });
|
||||
});
|
||||
Reference in new issue
Block a user