diff --git a/src/controllers/ScanController.ts b/src/controllers/ScanController.ts
index 1691185..a2179b2 100644
--- a/src/controllers/ScanController.ts
+++ b/src/controllers/ScanController.ts
@@ -80,9 +80,8 @@ export class ScanController {
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
@OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead).
Please remember that to provide the scan\'s card\'s station\'s id.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) {
- const station_id = req.headers["station_id"];
- if (station_id) {
- createScan.station = parseInt(station_id.toString());
+ if (req.isStationAuth) {
+ createScan.station = req.stationId;
}
let scan = await createScan.toEntity();
scan = await this.trackScanRepository.save(scan);
diff --git a/src/controllers/ScanStationController.ts b/src/controllers/ScanStationController.ts
index 0740a20..6a779ae 100644
--- a/src/controllers/ScanStationController.ts
+++ b/src/controllers/ScanStationController.ts
@@ -3,6 +3,7 @@ import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { Repository, getConnectionManager } from 'typeorm';
import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors';
import { TrackNotFoundError } from '../errors/TrackErrors';
+import { deleteStationEntry } from '../nats/StationKV';
import { CreateScanStation } from '../models/actions/create/CreateScanStation';
import { UpdateScanStation } from '../models/actions/update/UpdateScanStation';
import { ScanStation } from '../models/entities/ScanStation';
@@ -85,6 +86,7 @@ export class ScanStationController {
}
await this.stationRepository.save(await station.update(oldStation));
+ await deleteStationEntry(oldStation.prefix);
return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse();
}
@@ -109,6 +111,7 @@ export class ScanStationController {
}
const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] });
+ await deleteStationEntry(station.prefix);
await this.stationRepository.delete(station);
return responseStation.toResponse();
}
diff --git a/src/middlewares/ScanAuth.ts b/src/middlewares/ScanAuth.ts
index 13ef05e..77dc09b 100644
--- a/src/middlewares/ScanAuth.ts
+++ b/src/middlewares/ScanAuth.ts
@@ -2,73 +2,128 @@ import crypto from 'crypto';
import { Request, Response } from 'express';
import { getConnectionManager } from 'typeorm';
import { config } from '../config';
+import { deleteStationEntry, getStationEntry, setStationEntry, StationKVEntry } from '../nats/StationKV';
import { ScanStation } from '../models/entities/ScanStation';
import authchecker from './authchecker';
+/**
+ * Computes the HMAC-SHA256 of the provided token using the station token secret.
+ */
+function computeHmac(token: string): string {
+ return crypto.createHmac('sha256', config.station_token_secret).update(token).digest('hex');
+}
+
+/**
+ * Constant-time comparison of two hex HMAC strings.
+ * Returns true if they match.
+ */
+function verifyHmac(provided_token: string, storedHash: string): boolean {
+ const expectedHash = computeHmac(provided_token);
+ const expectedBuf = Buffer.from(expectedHash);
+ const storedBuf = Buffer.from(storedHash);
+ return expectedBuf.length === storedBuf.length && crypto.timingSafeEqual(expectedBuf, storedBuf);
+}
+
/**
* This middleware handles the authentication of scan station api tokens.
* The tokens have to be provided via Bearer authorization header.
+ *
+ * Auth flow:
+ * 1. Extract prefix from token (PREFIX.KEY format)
+ * 2. Try NATS KV cache lookup by prefix — warm path: HMAC verify, no DB
+ * 3. On cache miss: DB lookup → HMAC verify → write to KV cache
+ * 4. On no station match at all: fall back to JWT auth (SCAN:CREATE permission)
+ *
+ * On success sets req.isStationAuth = true and req.stationId on the request object.
+ * These are internal server-side properties — not HTTP headers, not spoofable by clients.
+ *
* You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized().
* @param req Express request object.
* @param res Express response object.
* @param next Next function to call on success.
*/
const ScanAuth = async (req: Request, res: Response, next: () => void) => {
- let provided_token: string = req.headers["authorization"];
- if (provided_token == "" || provided_token === undefined || provided_token === null) {
- res.status(401).send({ http_code: 401, short: "no_token", message: "No api token provided." });
+ let provided_token: string = req.headers['authorization'];
+ if (!provided_token) {
+ res.status(401).send({ http_code: 401, short: 'no_token', message: 'No api token provided.' });
return;
}
- try {
- provided_token = provided_token.replace("Bearer ", "");
- } catch (error) {
- res.status(401).send({ http_code: 401, short: "no_token", message: "No valid jwt or api token provided." });
+ provided_token = provided_token.replace('Bearer ', '');
+
+ const prefix = provided_token.split('.')[0];
+ if (!prefix) {
+ res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
return;
}
- let prefix = "";
- try {
- prefix = provided_token.split(".")[0];
- }
- finally {
- if (prefix == "" || prefix == undefined || prefix == null) {
- res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
+ // --- KV cache lookup (warm path) ---
+ const cached = await getStationEntry(prefix);
+ if (cached) {
+ if (!cached.enabled) {
+ res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' });
return;
}
+ if (!verifyHmac(provided_token, cached.tokenHash)) {
+ res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
+ return;
+ }
+ req.isStationAuth = true;
+ req.stationId = cached.id;
+ next();
+ return;
}
- const station = await getConnectionManager().get().getRepository(ScanStation).findOne({ prefix: prefix });
+ // --- DB lookup (cold path) ---
+ const station = await getConnectionManager().get().getRepository(ScanStation).findOne({ prefix }, { relations: ['track'] });
+
if (!station) {
+ // No station with this prefix — fall back to JWT auth
let user_authorized = false;
try {
- let action = { request: req, response: res, context: null, next: next }
- user_authorized = await authchecker(action, ["SCAN:CREATE"]);
- }
- finally {
- if (user_authorized == false) {
- res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
+ const action = { request: req, response: res, context: null, next };
+ user_authorized = await authchecker(action, ['SCAN:CREATE']);
+ } finally {
+ if (!user_authorized) {
+ res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
return;
}
- else {
- next();
- }
+ next();
}
+ return;
}
- else {
- if (station.enabled == false) {
- res.status(401).send({ http_code: 401, short: "station_disabled", message: "Station is disabled." });
- return;
- }
- const expectedHash = crypto.createHmac("sha256", config.station_token_secret).update(provided_token).digest('hex');
- const expectedHashBuf = Buffer.from(expectedHash);
- const providedHashBuf = Buffer.from(station.key);
- if (expectedHashBuf.length !== providedHashBuf.length || !crypto.timingSafeEqual(expectedHashBuf, providedHashBuf)) {
- res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
- return;
- }
- req.headers["station_id"] = station.id.toString();
- next();
+
+ // Station found — verify token before caching
+ const tokenHash = computeHmac(provided_token);
+ const storedBuf = Buffer.from(station.key);
+ const computedBuf = Buffer.from(tokenHash);
+ const valid = computedBuf.length === storedBuf.length && crypto.timingSafeEqual(computedBuf, storedBuf);
+
+ if (!valid) {
+ res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
+ return;
}
-}
-export default ScanAuth;
\ No newline at end of file
+
+ if (!station.enabled) {
+ res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' });
+ return;
+ }
+
+ // Write to KV cache for subsequent requests
+ const entry: StationKVEntry = {
+ id: station.id,
+ enabled: station.enabled,
+ tokenHash,
+ trackId: station.track.id,
+ trackDistance: station.track.distance,
+ minimumLapTime: station.track.minimumLapTime ?? 0,
+ };
+ await setStationEntry(prefix, entry);
+
+ req.isStationAuth = true;
+ req.stationId = station.id;
+ next();
+};
+
+export default ScanAuth;
+export { deleteStationEntry };
diff --git a/src/nats/StationKV.ts b/src/nats/StationKV.ts
new file mode 100644
index 0000000..439ba69
--- /dev/null
+++ b/src/nats/StationKV.ts
@@ -0,0 +1,69 @@
+import { KvEntry } from 'nats';
+import NatsClient from './NatsClient';
+
+const BUCKET = 'station_state';
+
+/**
+ * Cached station data stored in NATS KV.
+ * Keyed by station prefix — the same prefix embedded in the station token.
+ * Carries all fields needed for auth and scan validation so no DB read
+ * is required on the hot path after the first request from a station.
+ */
+export interface StationKVEntry {
+ id: number;
+ enabled: boolean;
+ /** HMAC-SHA256 of the full station token, for re-verification on cache hit. */
+ tokenHash: string;
+ trackId: number;
+ /** Track distance in metres. */
+ trackDistance: number;
+ /** Minimum lap time in seconds. 0 means no minimum (DB null mapped to 0). */
+ minimumLapTime: number;
+}
+
+async function getBucket() {
+ return NatsClient.getKV(BUCKET);
+}
+
+function entryKey(prefix: string): string {
+ return `station.${prefix}`;
+}
+
+/**
+ * Returns the cached StationKVEntry for the given prefix, or null on a cache miss.
+ */
+export async function getStationEntry(prefix: string): Promise {
+ const bucket = await getBucket();
+ let entry: KvEntry | null = null;
+ try {
+ entry = await bucket.get(entryKey(prefix));
+ } catch {
+ return null;
+ }
+ if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') {
+ return null;
+ }
+ return JSON.parse(entry.string()) as StationKVEntry;
+}
+
+/**
+ * Writes a StationKVEntry for the given prefix.
+ * No TTL — entries are permanent until explicitly deleted.
+ */
+export async function setStationEntry(prefix: string, entry: StationKVEntry): Promise {
+ const bucket = await getBucket();
+ await bucket.put(entryKey(prefix), JSON.stringify(entry));
+}
+
+/**
+ * Removes the cached entry for the given prefix.
+ * Call this on station update or delete so the next request re-fetches from DB.
+ */
+export async function deleteStationEntry(prefix: string): Promise {
+ const bucket = await getBucket();
+ try {
+ await bucket.delete(entryKey(prefix));
+ } catch {
+ // Entry may not exist in KV yet — that's fine
+ }
+}