mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 03:36:55 +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:
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user