• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hypercerts-org / certified-group-service / 27157694867

08 Jun 2026 06:14PM UTC coverage: 92.919% (+0.7%) from 92.257%
27157694867

Pull #34

github

web-flow
Merge 127f3ba97 into 9fe960968
Pull Request #34: feat(auth): generalised API-key framework for CGS XRPCs (#26); serve did:web document (#29)

538 of 589 branches covered (91.34%)

Branch coverage included in aggregate %.

483 of 502 new or added lines in 17 files covered. (96.22%)

1 existing line in 1 file now uncovered.

2047 of 2193 relevant lines covered (93.34%)

50.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.39
/src/api/util.ts
1
import type { Server, MethodHandler, RouteOptions } from '@atproto/xrpc-server'
1✔
2
import type { Response as ExpressResponse } from 'express'
3
import type { Kysely } from 'kysely'
4
import type { AppContext } from '../context.js'
5
import type { GroupAuthResult, ServiceAuthResult } from '../auth/verifier.js'
6
import type { AuditEventDetail } from '../audit.js'
7
import type { Operation } from '../rbac/permissions.js'
8
import type { GroupDatabase } from '../db/schema.js'
9
import { XRPCError as ClientXRPCError } from '@atproto/xrpc'
10
import { XRPCError, UpstreamFailureError, ForbiddenError } from '@atproto/xrpc-server'
11
import type { PdsAgentPool } from '../pds/agent.js'
12
import {
13
  scopesCoverOperation,
14
  repoActionForOperation,
15
  repoScopesCover,
16
  blobScopesCover,
17
} from '../auth/scopes.js'
18

19
/**
20
 * The auth-mode-dependent slice of the credential the gate needs: for an
21
 * `apiKey` principal it applies a scope check on top of the role check and
22
 * attributes the audit entry to the specific key.
23
 */
24
export interface GatePrincipal {
25
  authKind: 'jwt' | 'apiKey'
26
  scopes?: string[]
27
  apiKeyRef?: string
28
}
29

30
export interface AuthedMethodConfig {
31
  opts?: RouteOptions
32
  handler: MethodHandler<GroupAuthResult>
33
}
34

35
export interface ServiceAuthMethodConfig {
36
  opts?: RouteOptions
37
  handler: MethodHandler<ServiceAuthResult>
38
}
39

40
export function jsonResponse<T>(body: T) {
1✔
41
  return { encoding: 'application/json' as const, body }
96✔
42
}
96✔
43

44
/**
45
 * Resolve the target group for an authed request.
46
 *
47
 * JWT (and legacy) callers: queries set `groupDid` on the credential at the
48
 * verifier (from the querystring `repo` or the legacy `aud` overload); body-input
49
 * procedures leave it undefined and pass the group as `repo` in the body (the
50
 * verifier can't read the body), which this resolves. Precedence mirrors the
51
 * verifier: an explicit body `repo` wins; otherwise the credential's `groupDid`.
52
 *
53
 * **API-key callers are different and stricter.** `verifyApiKey` already resolved
54
 * the group from the **querystring** `repo` and authenticated the key against
55
 * *that* group's DB, so the credential's `groupDid` is authoritative. A body
56
 * `repo` cannot redirect the action to a different group — that would be a
57
 * confused deputy (auth bound to group A, action on group B). So for an apiKey
58
 * credential we use the credential's `groupDid` and reject a body `repo` that
59
 * resolves to anything else.
60
 */
61
export async function resolveGroupDid(
81✔
62
  ctx: AppContext,
81✔
63
  credentials: { groupDid?: string; authKind?: 'jwt' | 'apiKey' },
81✔
64
  bodyRepo: string | undefined,
81✔
65
): Promise<string> {
81✔
66
  if (credentials.authKind === 'apiKey') {
81✔
67
    // The key was verified against credentials.groupDid (querystring repo).
68
    if (!credentials.groupDid) {
10✔
69
      throw new XRPCError(400, 'Missing repo', 'InvalidRequest')
1✔
70
    }
1✔
71
    if (bodyRepo !== undefined && bodyRepo.length > 0) {
10✔
72
      const bodyGroup = await ctx.authVerifier.resolveRepoToGroup(bodyRepo)
7✔
73
      if (bodyGroup !== credentials.groupDid) {
7✔
74
        throw new XRPCError(
1✔
75
          400,
1✔
76
          'API-key request: body `repo` must match the querystring `repo` the key was authenticated against',
1✔
77
          'InvalidRequest',
1✔
78
        )
1✔
79
      }
1✔
80
    }
7✔
81
    return credentials.groupDid
8✔
82
  }
8✔
83

84
  if (bodyRepo !== undefined && bodyRepo.length > 0) {
81✔
85
    return ctx.authVerifier.resolveRepoToGroup(bodyRepo)
24✔
86
  }
24✔
87
  if (credentials.groupDid) return credentials.groupDid
55✔
88
  throw new XRPCError(400, 'Missing repo', 'InvalidRequest')
2✔
89
}
2✔
90

91
/** Convert a SQLite DATETIME string (no timezone) to ISO 8601. */
92
export function sqliteToIso(timestamp: string): string {
1✔
93
  return new Date(timestamp + 'Z').toISOString()
71✔
94
}
71✔
95

96
export function encodeCursor(payload: string): string {
1✔
97
  return Buffer.from(payload).toString('base64')
8✔
98
}
8✔
99

100
export function decodeCursor(cursor: string): string {
1✔
101
  return Buffer.from(cursor, 'base64').toString('utf8')
12✔
102
}
12✔
103

104
/**
105
 * Authorize an operation and audit a denial.
106
 *
107
 * For an `apiKey` principal two checks must BOTH pass (design: scopes ∩
108
 * role-perms): first the scope check (does the key's granted scope set cover
109
 * this operation, delegated to `@atproto/oauth-scopes`), then the existing role
110
 * check (the key acts as its issuing owner, so the role check naturally caps the
111
 * key at its issuer's role). A JWT principal is scope-unlimited — only the role
112
 * check applies. The specific key (`apiKeyRef`) is attached to the audit detail
113
 * so key-driven actions are attributable beyond the owner DID.
114
 */
115
export async function assertCanWithAudit(
100✔
116
  ctx: AppContext,
100✔
117
  groupDb: Kysely<GroupDatabase>,
100✔
118
  callerDid: string,
100✔
119
  operation: Operation,
100✔
120
  detail?: Omit<AuditEventDetail, 'reason'>,
100✔
121
  principal?: GatePrincipal,
100✔
122
): Promise<void> {
100✔
123
  const auditDetail: Omit<AuditEventDetail, 'reason'> | undefined =
100✔
124
    principal?.authKind === 'apiKey' && principal.apiKeyRef
100✔
125
      ? { ...detail, apiKeyRef: principal.apiKeyRef }
24✔
126
      : detail
76✔
127

128
  const denied = async (reason: string): Promise<never> => {
100✔
129
    await ctx.audit.log(groupDb, callerDid, operation, 'denied', { ...auditDetail, reason })
14✔
130
    throw new ForbiddenError(reason)
14✔
131
  }
14✔
132

133
  if (principal?.authKind === 'apiKey') {
100✔
134
    const scopes = principal.scopes ?? []
24!
135
    const repoAction = repoActionForOperation(operation)
24✔
136
    let covered: boolean
24✔
137
    if (operation === 'uploadBlob') {
24✔
138
      // Blob upload: gated by a `blob:<mime>` scope against the upload's
139
      // Content-Type (carried in the audit detail). No mime -> deny.
140
      const mime = typeof detail?.mime === 'string' ? detail.mime : undefined
3!
141
      covered = mime !== undefined && blobScopesCover(scopes, mime)
3✔
142
    } else if (repoAction !== undefined) {
24✔
143
      // PDS-repo write op: gated by a `repo:<collection>?action=…` scope. The
144
      // collection comes from the request (carried in the audit detail by the
145
      // handler). With no collection we cannot match a scope, so deny.
146
      const collection = typeof detail?.collection === 'string' ? detail.collection : undefined
12✔
147
      covered = collection !== undefined && repoScopesCover(scopes, operation, collection)
12✔
148
    } else {
21✔
149
      // Service method: gated by an `rpc:` scope.
150
      covered = scopesCoverOperation(scopes, operation, ctx.config.serviceDid)
9✔
151
    }
9✔
152
    if (!covered) {
24✔
153
      await denied(`API key scopes do not permit '${operation}'`)
14!
NEW
154
    }
×
155
  }
24✔
156

157
  try {
86✔
158
    await ctx.rbac.assertCan(groupDb, callerDid, operation)
86✔
159
  } catch (err) {
100✔
160
    await ctx.audit.log(groupDb, callerDid, operation, 'denied', {
17✔
161
      ...auditDetail,
17✔
162
      reason: (err as Error).message,
17✔
163
    })
17✔
164
    throw err
17✔
165
  }
17✔
166
}
100✔
167

168
/**
169
 * Proxy a call to the group's PDS.
170
 *
171
 * 4xx errors from the PDS are forwarded to the client so they can
172
 * distinguish e.g. "duplicate rkey" (400) from a server problem.
173
 * 401s are already handled by PdsAgentPool.withAgent (auto-retry),
174
 * so any 401 that reaches here is a genuine auth failure on our side
175
 * and gets wrapped as 502 along with 5xx and network errors.
176
 */
177
export async function proxyToPds<T>(
35✔
178
  pdsAgents: PdsAgentPool,
35✔
179
  groupDid: string,
35✔
180
  fn: (agent: import('@atproto/api').Agent) => Promise<T>,
35✔
181
): Promise<T> {
35✔
182
  try {
35✔
183
    return await pdsAgents.withAgent(groupDid, fn)
35✔
184
  } catch (err) {
35✔
185
    if (err instanceof UpstreamFailureError) throw err
11✔
186
    if (err instanceof ClientXRPCError) {
11✔
187
      // err.status is the ResponseType enum; coerce to its numeric HTTP
188
      // status code so we can range-check it.
189
      const status = Number(err.status)
8✔
190
      if (status >= 400 && status < 500 && status !== 401) {
8✔
191
        throw new XRPCError(status, err.message, err.error)
3✔
192
      }
3✔
193
    }
8✔
194
    const msg = err instanceof Error ? err.message : String(err)
11✔
195
    throw new UpstreamFailureError(`Upstream PDS error: ${msg}`)
11✔
196
  }
11✔
197
}
35✔
198

199
/**
200
 * Link to the deprecation explanation, surfaced in the RFC 8594 `Link` header
201
 * on legacy-`aud` responses.
202
 */
203
const DEPRECATION_INFO_URL = 'https://github.com/hypercerts-org/certified-group-service/issues/27'
1✔
204

205
/** One warn per caller-DID per this window, to keep legacy traffic from flooding logs. */
206
const LEGACY_WARN_WINDOW_MS = 15 * 60 * 1000
1✔
207
/** Cap on distinct callers tracked; above this we sweep expired entries first. */
208
const LEGACY_WARN_MAX_ENTRIES = 10_000
1✔
209
const lastLegacyWarn = new Map<string, number>()
1✔
210

211
/**
212
 * Per-key rate limiter backed by a bounded `Map<key, lastSeenMs>`. Returns true
213
 * (and records `now`) when `key` has not been seen within `windowMs`; false
214
 * otherwise.
215
 *
216
 * Memory is hard-bounded to `maxEntries`. Before inserting a new key at the cap
217
 * it first sweeps entries older than the window (cheap, and they'd fire again
218
 * anyway); if every entry is still fresh (a high-cardinality burst), it evicts
219
 * the oldest by insertion order (`Map` preserves it) so the cap is never
220
 * exceeded. Evicting a fresh entry only costs that key one extra warn later.
221
 */
222
export function rateLimitAllow(
1✔
223
  map: Map<string, number>,
118✔
224
  key: string,
118✔
225
  now: number,
118✔
226
  windowMs: number,
118✔
227
  maxEntries: number,
118✔
228
): boolean {
118✔
229
  const previous = map.get(key)
118✔
230
  if (previous !== undefined && now - previous < windowMs) return false
118✔
231
  if (map.size >= maxEntries && !map.has(key)) {
118✔
232
    for (const [k, ts] of map) {
2✔
233
      if (now - ts >= windowMs) map.delete(k)
6✔
234
    }
6✔
235
    // Still full of fresh entries: evict the oldest to keep a hard cap.
236
    if (map.size >= maxEntries) {
2✔
237
      const oldest: string | undefined = map.keys().next().value
1✔
238
      if (oldest !== undefined) map.delete(oldest)
1✔
239
    }
1✔
240
  }
2✔
241
  map.set(key, now)
26✔
242
  return true
26✔
243
}
26✔
244

245
/**
246
 * Signal the deprecated `aud`-as-group path (issue #27) on a per-request basis:
247
 * attach RFC 8594 headers so clients can detect it programmatically, and emit a
248
 * rate-limited warn so operators can see lingering legacy traffic. No `Sunset`
249
 * header — a removal date is not yet set.
250
 */
251
function signalLegacyAud(ctx: AppContext, res: ExpressResponse, callerDid: string, nsid: string) {
111✔
252
  res.setHeader('Deprecation', 'true')
111✔
253
  res.setHeader('Link', `<${DEPRECATION_INFO_URL}>; rel="deprecation"`)
111✔
254

255
  if (
111✔
256
    rateLimitAllow(
111✔
257
      lastLegacyWarn,
111✔
258
      callerDid,
111✔
259
      Date.now(),
111✔
260
      LEGACY_WARN_WINDOW_MS,
111✔
261
      LEGACY_WARN_MAX_ENTRIES,
111✔
262
    )
111✔
263
  ) {
111✔
264
    ctx.logger.warn(
20✔
265
      { callerDid, nsid },
20✔
266
      'Deprecated auth: group taken from JWT aud. Pass an explicit `repo` and set aud to the service DID (issue #27).',
20✔
267
    )
20✔
268
  }
20✔
269
}
111✔
270

271
export function registerAuthedMethod(
1✔
272
  server: Server,
442✔
273
  nsid: string,
442✔
274
  ctx: AppContext,
442✔
275
  config: AuthedMethodConfig,
442✔
276
): void {
442✔
277
  const handler: MethodHandler<GroupAuthResult> = async (reqCtx) => {
442✔
278
    if (reqCtx.auth.credentials.legacyAud) {
129✔
279
      signalLegacyAud(ctx, reqCtx.res, reqCtx.auth.credentials.callerDid, nsid)
111✔
280
    }
111✔
281
    return config.handler(reqCtx)
129✔
282
  }
129✔
283
  server.method(nsid, {
442✔
284
    auth: ctx.authVerifier.xrpcAuth(),
442✔
285
    opts: config.opts,
442✔
286
    handler,
442✔
287
  })
442✔
288
}
442✔
289

290
/**
291
 * Register a group-bootstrapping XRPC method (register, import) — one whose
292
 * audience is the service's own DID rather than a group DID, and whose target
293
 * group does not yet exist in the service. Unlike registerAuthedMethod, the
294
 * auth verifier does not open a per-group DB or check group membership; it only
295
 * proves the caller controls the issuing DID. The handler is responsible for
296
 * any ownerDid / authorship checks.
297
 */
298
export function registerServiceAuthMethod(
1✔
299
  server: Server,
36✔
300
  nsid: string,
36✔
301
  ctx: AppContext,
36✔
302
  config: ServiceAuthMethodConfig,
36✔
303
): void {
36✔
304
  server.method(nsid, {
36✔
305
    auth: ctx.authVerifier.xrpcServiceAuth(),
36✔
306
    opts: config.opts,
36✔
307
    handler: config.handler,
36✔
308
  })
36✔
309
}
36✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc