mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
feat(api-tokens): switch to sen_sk_ prefixed opaque keys (#1062)
* feat(api-tokens): switch to sen_sk_ prefixed opaque keys
Replace JWT-shaped API tokens with 56-char opaque keys of the form
`sen_sk_<43-char base62 random><6-char base62 checksum>` (256-bit
entropy, sha256-truncated checksum). Node-proxy tokens stay JWTs.
Why:
* The api_token path was already a sha256 DB lookup; the JWT signature
was wasted work and the 400d JWT ceiling vs DB expires_at was a
confusing dual bound.
* Opaque tokens carry a verifiable checksum so malformed/typoed values
are rejected before any SQLite lookup.
* `sen_sk_` prefix is recognizable to GitHub, TruffleHog, GitGuardian
and makes the on-wire shape visually distinct from node_proxy JWTs.
Changes:
* New `utils/apiTokenFormat.ts` (generate + checksum-verify, CSPRNG via
randomInt, timingSafeEqual on the checksum compare).
* `middleware/auth.ts` and `websocket/upgradeHandler.ts` route opaque
tokens before any jwt.verify; 401 messages unified to avoid a
token-existence oracle.
* `middleware/rateLimiters.ts` short-circuits opaque tokens in the
node_proxy detection and keys per-token via a non-reversible sha256
slice so each token keeps its own bucket without a DB hit.
* All six existing tests migrated from jwt.sign({scope:'api_token'})
to generateApiToken(); new format-only test suite covering prefix,
length, alphabet, checksum reject paths, and a 10k-iteration
collision/integrity loop.
* Docs (features/api-tokens.mdx, api-reference/overview.mdx) describe
the shape and drop the obsolete JWT-ceiling note.
* fix(api-tokens): clear CI lint and CodeQL false positives
* Drop unused TEST_USERNAME import in remote-console-session.test.ts;
the migration to generateApiToken() left it orphaned.
* Add a CodeQL barrier model so `generateApiToken`'s ReturnValue does
not flow into the `insufficient-password-hash` query. The function
emits 256-bit CSPRNG opaque keys; sha256 of the raw token is the
correct construction for high-entropy API tokens (bcrypt-class
hashes target low-entropy human passwords). CodeQL's name heuristic
was treating "Token" as a password source and flagging the standard
sha256 wrapping at all 9 call sites.
* ci(codeql): exclude js/insufficient-password-hash for token paths
The previous barrierModel data extension was a no-op for this rule: the
js/insufficient-password-hash query identifies its "password" sources via
SensitiveExpr's name heuristic ("token", "secret", "key" substrings),
which is upstream of the taint-tracking layer where barrierModel applies.
Verified by post-push re-analysis: 9 alerts still open, all undismissed.
Replace the dead extension with a path-scoped query-filter in
codeql-config.yml so the rule no longer fires on apiTokenFormat,
apiTokens, and the test directory. Real user-password hashing code
elsewhere in the repo (auth, users, setup routes) remains analyzed.
The 9 existing alerts on PR #1062 are dismissed via API as false
positives with a justification pointing at this config. Future runs
will not re-flag them because of the path filter.
This commit is contained in:
@@ -1,2 +1,15 @@
|
||||
data_extensions:
|
||||
- .github/codeql/extensions/safeLog.model.yml
|
||||
|
||||
query-filters:
|
||||
# API tokens are 256-bit CSPRNG random; sha256 of the raw token is the
|
||||
# correct construction. js/insufficient-password-hash exists to catch weak
|
||||
# hashing of low-entropy human passwords, which is irrelevant for these
|
||||
# high-entropy opaque keys. Scoped to the token-handling files only, so
|
||||
# real user-password code (bcrypt-hashed elsewhere) is still analyzed.
|
||||
- exclude:
|
||||
id: js/insufficient-password-hash
|
||||
paths:
|
||||
- backend/src/utils/apiTokenFormat.ts
|
||||
- backend/src/routes/apiTokens.ts
|
||||
- backend/src/__tests__/**
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
API_TOKEN_PREFIX,
|
||||
API_TOKEN_TOTAL_LEN,
|
||||
API_TOKEN_REGEX,
|
||||
generateApiToken,
|
||||
looksLikeApiToken,
|
||||
verifyApiTokenChecksum,
|
||||
} from '../utils/apiTokenFormat';
|
||||
|
||||
describe('apiTokenFormat.generateApiToken', () => {
|
||||
it('emits tokens with the sen_sk_ prefix', () => {
|
||||
const token = generateApiToken();
|
||||
expect(token.startsWith(API_TOKEN_PREFIX)).toBe(true);
|
||||
});
|
||||
|
||||
it('emits tokens of exactly 56 characters', () => {
|
||||
expect(generateApiToken().length).toBe(API_TOKEN_TOTAL_LEN);
|
||||
expect(API_TOKEN_TOTAL_LEN).toBe(56);
|
||||
});
|
||||
|
||||
it('emits tokens that match the canonical regex', () => {
|
||||
expect(API_TOKEN_REGEX.test(generateApiToken())).toBe(true);
|
||||
});
|
||||
|
||||
it('uses only base62 characters in the body (no dashes, no underscores)', () => {
|
||||
const body = generateApiToken().slice(API_TOKEN_PREFIX.length);
|
||||
expect(/^[A-Za-z0-9]+$/.test(body)).toBe(true);
|
||||
expect(body.includes('-')).toBe(false);
|
||||
expect(body.includes('_')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiTokenFormat.verifyApiTokenChecksum (accept path)', () => {
|
||||
it('accepts a freshly generated token', () => {
|
||||
expect(verifyApiTokenChecksum(generateApiToken())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiTokenFormat.looksLikeApiToken / verifyApiTokenChecksum (reject path)', () => {
|
||||
it('rejects an empty string', () => {
|
||||
expect(looksLikeApiToken('')).toBe(false);
|
||||
expect(verifyApiTokenChecksum('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a JWT-shaped token', () => {
|
||||
const jwtLike = 'eyJhbGciOiJIUzI1NiJ9.eyJzY29wZSI6ImFwaV90b2tlbiJ9.signature';
|
||||
expect(looksLikeApiToken(jwtLike)).toBe(false);
|
||||
expect(verifyApiTokenChecksum(jwtLike)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong prefix', () => {
|
||||
const token = generateApiToken();
|
||||
const swapped = 'sen_pk_' + token.slice(API_TOKEN_PREFIX.length);
|
||||
expect(looksLikeApiToken(swapped)).toBe(false);
|
||||
expect(verifyApiTokenChecksum(swapped)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token with one character truncated', () => {
|
||||
const truncated = generateApiToken().slice(0, -1);
|
||||
expect(verifyApiTokenChecksum(truncated)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token with one extra character', () => {
|
||||
const longer = generateApiToken() + 'A';
|
||||
expect(verifyApiTokenChecksum(longer)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token containing a dash', () => {
|
||||
const token = generateApiToken();
|
||||
const mutated = token.slice(0, 20) + '-' + token.slice(21);
|
||||
expect(verifyApiTokenChecksum(mutated)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token containing an underscore in the body', () => {
|
||||
const token = generateApiToken();
|
||||
const mutated = token.slice(0, 30) + '_' + token.slice(31);
|
||||
expect(verifyApiTokenChecksum(mutated)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a single-char mutation inside the random portion', () => {
|
||||
const token = generateApiToken();
|
||||
const mutateAt = API_TOKEN_PREFIX.length + 5;
|
||||
const original = token[mutateAt];
|
||||
const replacement = original === 'a' ? 'b' : 'a';
|
||||
const mutated = token.slice(0, mutateAt) + replacement + token.slice(mutateAt + 1);
|
||||
expect(mutated).not.toBe(token);
|
||||
expect(verifyApiTokenChecksum(mutated)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a single-char mutation inside the checksum portion', () => {
|
||||
const token = generateApiToken();
|
||||
const mutateAt = API_TOKEN_TOTAL_LEN - 3;
|
||||
const original = token[mutateAt];
|
||||
const replacement = original === 'a' ? 'b' : 'a';
|
||||
const mutated = token.slice(0, mutateAt) + replacement + token.slice(mutateAt + 1);
|
||||
expect(mutated).not.toBe(token);
|
||||
expect(verifyApiTokenChecksum(mutated)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiTokenFormat: bulk generator integrity', () => {
|
||||
it('produces 10000 tokens with no collisions and all valid checksums', () => {
|
||||
const seen = new Set<string>();
|
||||
const iterations = 10_000;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const token = generateApiToken();
|
||||
expect(verifyApiTokenChecksum(token)).toBe(true);
|
||||
seen.add(token);
|
||||
}
|
||||
expect(seen.size).toBe(iterations);
|
||||
});
|
||||
});
|
||||
@@ -4,23 +4,23 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let authCookie: string;
|
||||
|
||||
/** Create an API token directly in the DB and return its raw JWT string. */
|
||||
/** Create an API token directly in the DB and return its raw value. */
|
||||
function createTestApiToken(
|
||||
scope: 'read-only' | 'deploy-only' | 'full-admin',
|
||||
expiresAt: number | null = null,
|
||||
userId?: number,
|
||||
): string {
|
||||
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
const rawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const db = DatabaseService.getInstance();
|
||||
const resolvedUserId = userId ?? db.getUserByUsername('testadmin')!.id;
|
||||
|
||||
@@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
|
||||
let tmpDir: string;
|
||||
@@ -18,7 +19,7 @@ function userToken(username: string): string {
|
||||
}
|
||||
|
||||
function createApiToken(scope: 'read-only' | 'deploy-only' | 'full-admin'): string {
|
||||
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
||||
const rawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const admin = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME);
|
||||
if (!admin?.id) throw new Error('missing seeded admin');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import crypto from 'crypto';
|
||||
import { generateApiToken } from '../../utils/apiTokenFormat';
|
||||
import type { DatabaseService } from '../../services/DatabaseService';
|
||||
|
||||
type DbClass = typeof DatabaseService;
|
||||
|
||||
export interface CreateTestApiTokenOptions {
|
||||
db: DbClass;
|
||||
scope: 'read-only' | 'deploy-only' | 'full-admin';
|
||||
userId: number;
|
||||
name?: string;
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a `sen_sk_` API token and insert a backing row into `api_tokens`,
|
||||
* mirroring what `routes/apiTokens.ts` does in production. Returns the raw
|
||||
* token (only secret value the test sees; the DB stores its sha256).
|
||||
*/
|
||||
export function createTestApiToken(opts: CreateTestApiTokenOptions): string {
|
||||
const raw = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(raw).digest('hex');
|
||||
opts.db.getInstance().addApiToken({
|
||||
token_hash: tokenHash,
|
||||
name: opts.name ?? `test-${opts.scope}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
scope: opts.scope,
|
||||
user_id: opts.userId,
|
||||
created_at: Date.now(),
|
||||
expires_at: opts.expiresAt ?? null,
|
||||
});
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a well-formed `sen_sk_` token that has no matching DB row. The
|
||||
* auth layer should reject it at the row-lookup step — useful for asserting
|
||||
* unbacked tokens are not honoured.
|
||||
*/
|
||||
export function unbackedApiToken(): string {
|
||||
return generateApiToken();
|
||||
}
|
||||
@@ -16,8 +16,9 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { mintConsoleSession } from '../helpers/consoleSession';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
|
||||
describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
let tmpDir: string;
|
||||
@@ -85,10 +86,11 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
|
||||
it('rejects API-token callers of POST /api/system/console-token (rejectApiTokenScope)', async () => {
|
||||
// Mint a JWT that claims the api_token scope but is not backed by a real
|
||||
// row in the database. The authMiddleware rejects this at the api_token
|
||||
// branch before the route handler runs, which is the behavior we want:
|
||||
// an API token should never be allowed to mint a console session.
|
||||
const fakeApiToken = jwt.sign({ scope: 'api_token', username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
// row in the database. authMiddleware accepts the sen_sk_ format and
|
||||
// rejects at the row-lookup step before the route handler runs, which
|
||||
// is the behavior we want: an API token should never be allowed to mint
|
||||
// a console session.
|
||||
const fakeApiToken = generateApiToken();
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${fakeApiToken}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import supertest from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import type { Express } from 'express';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: Express;
|
||||
@@ -583,7 +584,7 @@ describe('SSO Config - API Token Denied', () => {
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
apiRawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
apiRawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(apiRawToken).digest('hex');
|
||||
const admin = db.getUserByUsername('testadmin');
|
||||
db.addApiToken({ token_hash: tokenHash, name: `sso-scope-${Date.now()}`, scope: 'full-admin', user_id: admin!.id, created_at: Date.now(), expires_at: null });
|
||||
|
||||
@@ -14,6 +14,7 @@ import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
|
||||
describe('WebSocket upgrade dispatch order', () => {
|
||||
let tmpDir: string;
|
||||
@@ -170,7 +171,7 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
|
||||
it('accepts a full-admin api_token at the upgrade and reaches the handler', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
const rawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id;
|
||||
DatabaseService.getInstance().addApiToken({
|
||||
@@ -192,7 +193,7 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
|
||||
it('rejects a read-only api_token at the upgrade with HTTP 403', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
const rawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id;
|
||||
DatabaseService.getInstance().addApiToken({
|
||||
|
||||
@@ -8,6 +8,7 @@ import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
@@ -112,7 +113,7 @@ describe('POST /api/users', () => {
|
||||
});
|
||||
|
||||
it('blocks API tokens (403 SCOPE_DENIED)', async () => {
|
||||
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
const rawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(TEST_USERNAME);
|
||||
@@ -473,7 +474,7 @@ describe('PUT /api/auth/password', () => {
|
||||
});
|
||||
|
||||
it('blocks API tokens (403)', async () => {
|
||||
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
const rawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(TEST_USERNAME);
|
||||
|
||||
@@ -23,14 +23,15 @@ import {
|
||||
MFA_PENDING_TTL_MS,
|
||||
} from '../helpers/constants';
|
||||
import { getCookieOptions } from '../helpers/cookies';
|
||||
import { looksLikeApiToken, verifyApiTokenChecksum } from '../utils/apiTokenFormat';
|
||||
|
||||
/**
|
||||
* Authenticate a request via cookie session or Bearer token.
|
||||
*
|
||||
* Handles five scopes: user-session (cookie or bearer), api_token,
|
||||
* mfa_pending, node_proxy, pilot_tunnel. Bearer token is preferred when both
|
||||
* are present so node-to-node proxy calls aren't shadowed by a stale
|
||||
* cross-instance cookie.
|
||||
* Handles five auth modes: opaque sen_sk_ API tokens (routed before any JWT
|
||||
* work) plus the JWT-backed user-session, mfa_pending, node_proxy, and
|
||||
* pilot_tunnel scopes. Bearer token is preferred over cookie so node-to-node
|
||||
* proxy calls aren't shadowed by a stale cross-instance cookie.
|
||||
*/
|
||||
export const authMiddleware: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const cookieToken = req.cookies[COOKIE_NAME];
|
||||
@@ -45,25 +46,29 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean };
|
||||
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session');
|
||||
|
||||
// API token path: scope-based programmatic access
|
||||
if (decoded.scope === 'api_token') {
|
||||
// Opaque sen_sk_ API tokens: scope-based programmatic access. Routed
|
||||
// before jwt.verify so the JWT path stays focused on session, mfa_pending,
|
||||
// node_proxy, and pilot_tunnel. Steps 1-3 (prefix, length, checksum)
|
||||
// reject malformed/typoed keys without touching SQLite.
|
||||
if (looksLikeApiToken(token)) {
|
||||
// Uniform 401 message across malformed-checksum, unknown-hash, and
|
||||
// expired/revoked paths so the response body is not a token-existence
|
||||
// oracle. Debug logs still capture the specific reason.
|
||||
if (!verifyApiTokenChecksum(token)) {
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: checksum');
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
|
||||
if (!apiToken || apiToken.revoked_at) {
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: not found or revoked');
|
||||
res.status(401).json({ error: 'API token not found or revoked' });
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
if (apiToken.expires_at && apiToken.expires_at < Date.now()) {
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: expired');
|
||||
res.status(401).json({ error: 'API token has expired' });
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
|
||||
@@ -79,6 +84,13 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean };
|
||||
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session');
|
||||
|
||||
// Partial-auth session: a password/SSO credential has verified, but the
|
||||
// TOTP second factor is still required. Such a token can only be used to
|
||||
// complete the MFA challenge or to abort the flow by logging out. Every
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Request } from 'express';
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { createHash } from 'crypto';
|
||||
import { COOKIE_NAME } from '../helpers/constants';
|
||||
import { WEBHOOK_TRIGGER_RE } from '../helpers/routePatterns';
|
||||
import { looksLikeApiToken } from '../utils/apiTokenFormat';
|
||||
|
||||
// ── Rate Limiting ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -42,8 +44,15 @@ function isNodeProxyRequest(req: Request): boolean {
|
||||
cached._isNodeProxy = false;
|
||||
return false;
|
||||
}
|
||||
const bearer = auth.slice(7);
|
||||
// Opaque API tokens are never node_proxy credentials, and they are not
|
||||
// JWTs — short-circuit so `jwt.decode` is never invoked on them.
|
||||
if (looksLikeApiToken(bearer)) {
|
||||
cached._isNodeProxy = false;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.decode(auth.slice(7)) as { scope?: string } | null;
|
||||
const decoded = jwt.decode(bearer) as { scope?: string } | null;
|
||||
const result = decoded?.scope === 'node_proxy';
|
||||
cached._isNodeProxy = result;
|
||||
return result;
|
||||
@@ -68,8 +77,16 @@ function rateLimitKeyGenerator(req: Request): string {
|
||||
}
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
const bearer = auth.slice(7);
|
||||
// Opaque API tokens key by a non-reversible hash slice of the token
|
||||
// itself: each token gets its own rate-limit budget without a DB hit on
|
||||
// the hot path (this runs before authMiddleware).
|
||||
if (looksLikeApiToken(bearer)) {
|
||||
const slice = createHash('sha256').update(bearer).digest('hex').slice(0, 16);
|
||||
return `user:sk:${slice}`;
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.decode(auth.slice(7)) as { username?: string; sub?: string } | null;
|
||||
const decoded = jwt.decode(bearer) as { username?: string; sub?: string } | null;
|
||||
if (decoded?.username) return `user:${decoded.username}`;
|
||||
if (decoded?.sub) return `user:${decoded.sub}`;
|
||||
} catch { /* fall through to IP */ }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService, type ApiTokenScope } from '../services/DatabaseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
@@ -7,10 +6,8 @@ import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
|
||||
// JWT ceiling exceeds the longest user-selectable expiry (365d) so the DB
|
||||
// check (expires_at) is always the tighter bound.
|
||||
const API_TOKEN_JWT_CEILING = '400d';
|
||||
const MAX_ACTIVE_TOKENS_PER_USER = 25;
|
||||
|
||||
const API_TOKEN_SCOPE_MESSAGE = 'API tokens cannot manage other API tokens.';
|
||||
@@ -43,13 +40,6 @@ apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): P
|
||||
}
|
||||
const expiresAt = typeof expires_in === 'number' ? Date.now() + expires_in * 24 * 60 * 60 * 1000 : null;
|
||||
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) {
|
||||
res.status(500).json({ error: 'No JWT secret configured.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(req.user!.username);
|
||||
if (!user) {
|
||||
@@ -68,7 +58,7 @@ apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): P
|
||||
return;
|
||||
}
|
||||
|
||||
const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret, { expiresIn: API_TOKEN_JWT_CEILING });
|
||||
const rawToken = generateApiToken();
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
|
||||
const id = db.addApiToken({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createHash, randomInt, timingSafeEqual } from 'crypto';
|
||||
|
||||
// Sencho secret key prefix. Tokens issued from Settings → API are opaque
|
||||
// (not JWTs): a base62 random body plus a base62 checksum so malformed or
|
||||
// typoed keys are rejected before any SQLite lookup, and so secret scanners
|
||||
// (GitHub, TruffleHog, GitGuardian) have a recognisable signature.
|
||||
export const API_TOKEN_PREFIX = 'sen_sk_';
|
||||
|
||||
const RANDOM_LEN = 43;
|
||||
const CHECKSUM_LEN = 6;
|
||||
|
||||
export const API_TOKEN_BODY_LEN = RANDOM_LEN + CHECKSUM_LEN;
|
||||
export const API_TOKEN_TOTAL_LEN = API_TOKEN_PREFIX.length + API_TOKEN_BODY_LEN;
|
||||
export const API_TOKEN_REGEX = /^sen_sk_[A-Za-z0-9]{49}$/;
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
function base62Encode32(value: number): string {
|
||||
let n = value >>> 0;
|
||||
let out = '';
|
||||
for (let i = 0; i < CHECKSUM_LEN; i++) {
|
||||
out = ALPHABET[n % 62] + out;
|
||||
n = Math.floor(n / 62);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeChecksum(random: string): string {
|
||||
const hash = createHash('sha256').update(random).digest();
|
||||
return base62Encode32(hash.readUInt32BE(0));
|
||||
}
|
||||
|
||||
export function generateApiToken(): string {
|
||||
let random = '';
|
||||
for (let i = 0; i < RANDOM_LEN; i++) {
|
||||
random += ALPHABET[randomInt(0, 62)];
|
||||
}
|
||||
return API_TOKEN_PREFIX + random + computeChecksum(random);
|
||||
}
|
||||
|
||||
export function looksLikeApiToken(token: string): boolean {
|
||||
return token.length === API_TOKEN_TOTAL_LEN && API_TOKEN_REGEX.test(token);
|
||||
}
|
||||
|
||||
export function verifyApiTokenChecksum(token: string): boolean {
|
||||
if (!looksLikeApiToken(token)) return false;
|
||||
const random = token.slice(API_TOKEN_PREFIX.length, API_TOKEN_PREFIX.length + RANDOM_LEN);
|
||||
const checksum = token.slice(API_TOKEN_PREFIX.length + RANDOM_LEN);
|
||||
const expected = computeChecksum(random);
|
||||
return timingSafeEqual(Buffer.from(checksum, 'utf8'), Buffer.from(expected, 'utf8'));
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { handleLogsWs } from './logs';
|
||||
import { handleHostConsoleWs } from './hostConsole';
|
||||
import { handleGenericWs, attachGenericConnectionHandlers } from './generic';
|
||||
import { rejectUpgrade as reject } from './reject';
|
||||
import { looksLikeApiToken, verifyApiTokenChecksum } from '../utils/apiTokenFormat';
|
||||
|
||||
function parseCookies(req: IncomingMessage): Record<string, string> {
|
||||
const header = req.headers.cookie || '';
|
||||
@@ -72,25 +73,30 @@ export function attachUpgrade(
|
||||
if (!token) return reject(socket, 401, 'Unauthorized');
|
||||
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number };
|
||||
|
||||
// Node proxy tokens are machine-to-machine credentials and must never be
|
||||
// granted interactive terminal access (host console or container exec).
|
||||
const isProxyToken = decoded.scope === 'node_proxy';
|
||||
|
||||
// Opaque sen_sk_ API tokens: handled before jwt.verify. Prefix +
|
||||
// length + checksum reject malformed keys without touching SQLite.
|
||||
let decoded: { username?: string; scope?: string; role?: string; tv?: number };
|
||||
let wsApiTokenScope: string | null = null;
|
||||
if (decoded.scope === 'api_token') {
|
||||
if (looksLikeApiToken(token)) {
|
||||
if (!verifyApiTokenChecksum(token)) return reject(socket, 401, 'Unauthorized');
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
|
||||
if (!apiToken || apiToken.revoked_at) return reject(socket, 401, 'Unauthorized');
|
||||
if (apiToken.expires_at && apiToken.expires_at < Date.now()) return reject(socket, 401, 'Unauthorized');
|
||||
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
|
||||
wsApiTokenScope = apiToken.scope;
|
||||
decoded = { scope: 'api_token' };
|
||||
} else {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number };
|
||||
}
|
||||
|
||||
// Node proxy tokens are machine-to-machine credentials and must never be
|
||||
// granted interactive terminal access (host console or container exec).
|
||||
const isProxyToken = decoded.scope === 'node_proxy';
|
||||
|
||||
// For user session tokens (no scope), resolve against DB for up-to-date
|
||||
// role and token_version checks. Scoped tokens (api_token, node_proxy,
|
||||
// console_session) skip this: they are validated by their own logic
|
||||
|
||||
@@ -22,6 +22,8 @@ curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://your-sencho-instance:1852/api/stacks
|
||||
```
|
||||
|
||||
API tokens are 56-character opaque strings that begin with `sen_sk_`, for example `sen_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`. The prefix makes them easy to identify in logs and is recognized by secret-scanning tools (GitHub, TruffleHog, GitGuardian).
|
||||
|
||||
<Note>
|
||||
API Tokens require a Sencho **Admiral** license. Community and Skipper editions do not include this feature.
|
||||
</Note>
|
||||
|
||||
@@ -105,8 +105,8 @@ Click the trash icon next to any token in the API Tokens settings tab. A confirm
|
||||
|
||||
## Security model
|
||||
|
||||
- **Recognizable format**: Tokens are 56 characters and begin with the `sen_sk_` prefix, followed by 49 base62 (alphanumeric) characters. The last six characters are a checksum, so malformed or typoed values are rejected before any database lookup. The prefix makes Sencho tokens easy to spot in logs, code, and secret-scanning tools (GitHub, TruffleHog, GitGuardian).
|
||||
- **Hashed storage**: Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted.
|
||||
- **JWT-level expiry ceiling**: Every token includes a built-in expiry at the JWT level as defense-in-depth, independent of the user-configured expiration. The database-level expiry is always the tighter constraint.
|
||||
- **Audit trail**: All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username.
|
||||
- **Optional expiry**: Tokens can be created with an expiration period (30 days, 60 days, 90 days, or 1 year). Tokens without an expiry must be revoked manually when no longer needed.
|
||||
- **Per-user limits**: Each user can create up to 25 active tokens. Token names must be unique among active tokens for the same user.
|
||||
|
||||
Reference in New Issue
Block a user