server for refapp and refbot and other stuff
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(auth): accept refapp signed login JWT as Bearer for per-user attribution

verifyRefappToken validates refapp's HMAC-SHA256 login JWT (shared
REFAPP_TOKEN_SECRET) and maps it to a discord-type actor, so refserver audit
logs and participant checks attribute actions to the real user. Mirrors
refapp's token version check; documents the bounded revocation gap.

alpine (Jul 16, 2026, 7:32 PM +0200) 7325e464 8d777c16

+83 -4
+4 -1
.env.example
··· 43 43 # DISCORD_CLIENT_ID= 44 44 # DISCORD_CLIENT_SECRET= 45 45 # DISCORD_REDIRECT_URI= 46 - # DISCORD_ALLOWED_USERS= 46 + # DISCORD_ALLOWED_USERS= 47 + # Shared secret with refapp; must match refapp's AUTH_TOKEN_SECRET so refapp's 48 + # signed login JWT is accepted as `Bearer` for per-user attribution. 49 + # REFAPP_TOKEN_SECRET=
+5
src/config.ts
··· 62 62 // admin-scoped key from this token so clients (e.g. refapp) can authenticate 63 63 // before any key exists. Must match the token the client sends as its API key. 64 64 bootstrapApiKey: str('REFSERVER_BOOTSTRAP_API_KEY', ''), 65 + // Shared secret with refapp. When set, refapp's signed login JWT may be sent 66 + // as `Bearer` to attribute actions to the real Discord user (audit log + 67 + // participant checks) instead of the service admin ApiKey. Must match 68 + // refapp's AUTH_TOKEN_SECRET. 69 + refappTokenSecret: str('REFAPP_TOKEN_SECRET', ''), 65 70 }; 66 71 67 72 export type AppConfig = typeof config;
+74 -3
src/middleware/auth.ts
··· 1 - import { randomBytes, createHash } from 'node:crypto'; 1 + import { randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto'; 2 2 import type { RequestHandler } from 'express'; 3 3 import { ApiKey, type ApiKeyDoc, type ApiKeyScope } from '../models/index.js'; 4 4 import { config } from '../config.js'; ··· 55 55 }; 56 56 } 57 57 58 + // Verify a refapp-signed login JWT (HMAC-SHA256, base64url payload) sent as 59 + // `Bearer`. refapp issues these after Discord OAuth; the same identity is 60 + // reused here so refserver attributes actions to the real Discord user without 61 + // refapp needing to store/forward the Discord access token. Only available when 62 + // config.refappTokenSecret is set (must match refapp's AUTH_TOKEN_SECRET). 63 + // Must match refapp's TOKEN_VERSION in api/middleware/token.js. Both verifiers 64 + // must agree on the token format version so a refapp version bump that 65 + // invalidates old tokens is honored here too. 66 + // 67 + // Revocation note: refapp maintains its own logout denylist (by jti) that this 68 + // check does NOT consult. A token revoked in refapp stays valid here until its 69 + // `exp` (<= 1h). In practice this is bounded: the raw JWT only exists in 70 + // refapp's httpOnly cookie, which only refapp forwards as Bearer, and refapp's 71 + // requireAuth rejects revoked tokens before forwarding. The residual risk is a 72 + // stolen cookie value called directly against refserver — equivalent to any 73 + // session-token theft, mitigated by the short TTL and httpOnly storage. 74 + const REFAPP_TOKEN_VERSION = 1; 75 + 76 + function verifyRefappToken(token: string): AuthenticatedActor | null { 77 + const secret = config.refappTokenSecret; 78 + if (!secret) return null; 79 + const dot = token.indexOf('.'); 80 + if (dot <= 0) return null; 81 + const payloadB64 = token.slice(0, dot); 82 + const sig = token.slice(dot + 1); 83 + const expected = createHmac('sha256', secret).update(payloadB64).digest('base64url'); 84 + if (expected.length !== sig.length) return null; 85 + if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null; 86 + let payload: { v?: number; sub: string; username: string; globalName?: string | null; exp?: number }; 87 + try { 88 + payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')); 89 + } 90 + catch { 91 + return null; 92 + } 93 + if (payload.v !== REFAPP_TOKEN_VERSION) return null; 94 + const nowSec = Math.floor(Date.now() / 1000); 95 + if (!payload.exp || payload.exp < nowSec) return null; 96 + const isAllowed = config.discord.allowedUsers.includes(payload.sub); 97 + return { 98 + type: 'discord', 99 + discordId: payload.sub, 100 + // refapp tokens carry username; prefer it over globalName for display. 101 + name: payload.username ?? payload.globalName ?? undefined, 102 + scopes: isAllowed ? ['read', 'referee', 'admin'] : ['read'], 103 + }; 104 + } 105 + 106 + // Resolve a `Bearer` token to an actor, trying the refapp-signed JWT first 107 + // (per-user attribution from refapp) and falling back to a Discord OAuth 108 + // access token (community tooling). Returns null if neither applies. 109 + async function bearerActor(token: string): Promise<AuthenticatedActor | null> { 110 + const refapp = verifyRefappToken(token); 111 + if (refapp) return refapp; 112 + try { 113 + return await discordActor(token); 114 + } 115 + catch { 116 + return null; 117 + } 118 + } 119 + 58 120 export function requireAuth(...requiredScopes: ApiKeyScope[]): RequestHandler { 59 121 return async (req, _res, next) => { 60 122 const authHeader = req.headers.authorization; ··· 67 129 68 130 try { 69 131 if (bearerMatch) { 70 - req.actor = await discordActor(bearerMatch[1]!); 132 + const actor = await bearerActor(bearerMatch[1]!); 133 + if (!actor) { 134 + return next(new HttpError(401, 'unauthorized', 'invalid token')); 135 + } 136 + req.actor = actor; 71 137 } 72 138 else if (apiKeyMatch) { 73 139 const doc = await lookupApiKey(apiKeyMatch[1]!); ··· 115 181 116 182 try { 117 183 if (bearerMatch) { 118 - req.actor = await discordActor(bearerMatch[1]!); 184 + const actor = await bearerActor(bearerMatch[1]!); 185 + if (!actor) { 186 + req.actor = { type: 'anonymous', scopes: [] }; 187 + return next(); 188 + } 189 + req.actor = actor; 119 190 } 120 191 else if (apiKeyMatch) { 121 192 const doc = await lookupApiKey(apiKeyMatch[1]!);