Skip to content

Commit 4e76ecd

Browse files
dnplkndllclaude
andcommitted
feat: enforce API token revocation at transactor level
Embed apiTokenId in JWT extra field and add a per-token revocation cache (60s TTL) in the transactor REST handler. Revoked tokens are now rejected within ~60 seconds instead of remaining valid until JWT expiry. Adds checkApiTokenRevoked account service method for the transactor to query individual token revocation status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 865fd71 commit 4e76ecd

3 files changed

Lines changed: 70 additions & 1 deletion

File tree

foundations/core/packages/account-client/src/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export interface AccountClient {
259259
revokeApiToken: (tokenId: string) => Promise<void>
260260
listWorkspaceApiTokens: (workspaceUuid: WorkspaceUuid) => Promise<ApiTokenInfo[]>
261261
revokeWorkspaceApiToken: (tokenId: string, workspaceUuid: WorkspaceUuid) => Promise<void>
262+
checkApiTokenRevoked: (apiTokenId: string) => Promise<boolean>
262263

263264
setCookie: () => Promise<void>
264265
deleteCookie: () => Promise<void>
@@ -1251,6 +1252,15 @@ class AccountClientImpl implements AccountClient {
12511252
await this.rpc(request)
12521253
}
12531254

1255+
async checkApiTokenRevoked (apiTokenId: string): Promise<boolean> {
1256+
const request = {
1257+
method: 'checkApiTokenRevoked' as const,
1258+
params: { apiTokenId }
1259+
}
1260+
1261+
return await this.rpc(request)
1262+
}
1263+
12541264
async setCookie (): Promise<void> {
12551265
const url = concatLink(this.url, '/cookie')
12561266
const response = await fetch(url, { ...this.request, method: 'PUT' })

pods/server/src/rpc.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,35 @@ async function sendJson (
129129
res.end(body)
130130
}
131131

132+
// ── API Token Revocation Cache ──────────────────────────────────────
133+
// Per-token cache with 60s TTL. Once a token is confirmed revoked it
134+
// stays cached permanently (revocation is irreversible). Non-revoked
135+
// tokens are re-checked every TTL interval.
136+
const REVOCATION_CACHE_TTL_MS = 60_000
137+
const revocationCache = new Map<string, { revoked: boolean, checkedAt: number }>()
138+
139+
async function isApiTokenRevoked (apiTokenId: string, accountClient: AccountClient): Promise<boolean> {
140+
const now = Date.now()
141+
const cached = revocationCache.get(apiTokenId)
142+
143+
// Permanently cached once revoked
144+
if (cached?.revoked === true) return true
145+
146+
// Re-check if stale or missing
147+
if (cached == null || now - cached.checkedAt > REVOCATION_CACHE_TTL_MS) {
148+
try {
149+
const revoked = await accountClient.checkApiTokenRevoked(apiTokenId)
150+
revocationCache.set(apiTokenId, { revoked, checkedAt: now })
151+
return revoked
152+
} catch {
153+
// If we can't reach the account service, use stale cache or allow
154+
return cached?.revoked ?? false
155+
}
156+
}
157+
158+
return cached.revoked
159+
}
160+
132161
export function registerRPC (app: Express, sessions: SessionManager, ctx: MeasureContext, accountsUrl: string): void {
133162
const rpcSessions = new Map<string, RPCClientInfo>()
134163

@@ -167,6 +196,15 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
167196
return
168197
}
169198

199+
// Reject revoked API tokens (cached check, ~60s TTL)
200+
const apiTokenId = decodedToken.extra?.apiTokenId
201+
if (apiTokenId !== undefined) {
202+
if (await isApiTokenRevoked(apiTokenId, getAccountClient(token))) {
203+
sendError(res, 401, { message: 'Token has been revoked' })
204+
return
205+
}
206+
}
207+
170208
// Roadmap: enforce token scopes here. When ApiToken gains a scopes field
171209
// (see server/account/src/types.ts), check decodedToken.extra?.scopes against
172210
// the `method` parameter to reject disallowed operations (e.g. reject 'tx'

server/account/src/operations.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2421,7 +2421,7 @@ async function createApiToken (
24212421
const expSec = Math.floor(expiresOn / 1000)
24222422

24232423
const id = randomUUID()
2424-
const apiToken = generateToken(account, workspaceUuid, undefined, undefined, { exp: expSec })
2424+
const apiToken = generateToken(account, workspaceUuid, { apiTokenId: id }, undefined, { exp: expSec })
24252425

24262426
await db.apiToken.insertOne({
24272427
id,
@@ -2499,6 +2499,25 @@ async function revokeApiToken (
24992499
ctx.info('API token revoked', { tokenId, account })
25002500
}
25012501

2502+
/**
2503+
* Checks if a specific API token has been revoked.
2504+
* Used by the transactor to enforce revocation at the request level.
2505+
*/
2506+
async function checkApiTokenRevoked (
2507+
ctx: MeasureContext,
2508+
db: AccountDB,
2509+
branding: Branding | null,
2510+
token: string,
2511+
params: { apiTokenId: string }
2512+
): Promise<boolean> {
2513+
const { apiTokenId } = params
2514+
const existing = await db.apiToken.findOne({ id: apiTokenId })
2515+
if (existing == null) {
2516+
return true // Unknown token treated as revoked
2517+
}
2518+
return existing.revoked
2519+
}
2520+
25022521
/**
25032522
* Lists all API tokens for a workspace. Requires OWNER role.
25042523
* Returns tokens from all members, with account UUIDs for attribution.
@@ -3264,6 +3283,7 @@ export type AccountMethods =
32643283
| 'revokeApiToken'
32653284
| 'listWorkspaceApiTokens'
32663285
| 'revokeWorkspaceApiToken'
3286+
| 'checkApiTokenRevoked'
32673287

32683288
/**
32693289
* @public
@@ -3331,6 +3351,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
33313351
revokeApiToken: wrap(revokeApiToken),
33323352
listWorkspaceApiTokens: wrap(listWorkspaceApiTokens),
33333353
revokeWorkspaceApiToken: wrap(revokeWorkspaceApiToken),
3354+
checkApiTokenRevoked: wrap(checkApiTokenRevoked),
33343355

33353356
/* READ OPERATIONS */
33363357
getRegionInfo: wrap(getRegionInfo),

0 commit comments

Comments
 (0)