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

hypercerts-org / certified-group-service / 27077132210

06 Jun 2026 11:42PM UTC coverage: 92.656% (+0.6%) from 92.01%
27077132210

Pull #34

github

web-flow
Merge 5ced581e1 into 90d10b961
Pull Request #34: feat(auth): generalised API-key framework for CGS XRPCs (#26); serve did:web document (#29)

466 of 511 branches covered (91.19%)

Branch coverage included in aggregate %.

387 of 403 new or added lines in 18 files covered. (96.03%)

1 existing line in 1 file now uncovered.

1906 of 2049 relevant lines covered (93.02%)

49.96 hits per line

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

98.51
/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 { scopesCoverOperation } from '../auth/scopes.js'
13

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

25
export interface AuthedMethodConfig {
26
  opts?: RouteOptions
27
  handler: MethodHandler<GroupAuthResult>
28
}
29

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

35
export function jsonResponse<T>(body: T) {
1✔
36
  return { encoding: 'application/json' as const, body }
90✔
37
}
90✔
38

39
/**
40
 * Resolve the target group for an authed request.
41
 *
42
 * Queries set `groupDid` on the credential at the verifier (from the `repo`
43
 * querystring or the legacy `aud` overload). Body-input methods leave it
44
 * undefined on the new path and pass the group as `repo` in the body, which the
45
 * verifier cannot read; this resolves that body `repo` (handle or DID) to a
46
 * registered group DID.
47
 *
48
 * Precedence mirrors the verifier: an explicit body `repo` wins (new path); if
49
 * absent, the credential's `aud`-derived `groupDid` is used (legacy path). One
50
 * of the two must be present.
51
 */
52
export async function resolveGroupDid(
66✔
53
  ctx: AppContext,
66✔
54
  credentials: { groupDid?: string },
66✔
55
  bodyRepo: string | undefined,
66✔
56
): Promise<string> {
66✔
57
  if (bodyRepo !== undefined && bodyRepo.length > 0) {
66✔
58
    return ctx.authVerifier.resolveRepoToGroup(bodyRepo)
23✔
59
  }
23✔
60
  if (credentials.groupDid) return credentials.groupDid
46✔
61
  throw new XRPCError(400, 'Missing repo', 'InvalidRequest')
1✔
62
}
1✔
63

64
/** Convert a SQLite DATETIME string (no timezone) to ISO 8601. */
65
export function sqliteToIso(timestamp: string): string {
1✔
66
  return new Date(timestamp + 'Z').toISOString()
68✔
67
}
68✔
68

69
export function encodeCursor(payload: string): string {
1✔
70
  return Buffer.from(payload).toString('base64')
8✔
71
}
8✔
72

73
export function decodeCursor(cursor: string): string {
1✔
74
  return Buffer.from(cursor, 'base64').toString('utf8')
12✔
75
}
12✔
76

77
/**
78
 * Authorize an operation and audit a denial.
79
 *
80
 * For an `apiKey` principal two checks must BOTH pass (design: scopes ∩
81
 * role-perms): first the scope check (does the key's granted scope set cover
82
 * this operation, delegated to `@atproto/oauth-scopes`), then the existing role
83
 * check (the key acts as its issuing owner, so the role check naturally caps the
84
 * key at its issuer's role). A JWT principal is scope-unlimited — only the role
85
 * check applies. The specific key (`apiKeyRef`) is attached to the audit detail
86
 * so key-driven actions are attributable beyond the owner DID.
87
 */
88
export async function assertCanWithAudit(
82✔
89
  ctx: AppContext,
82✔
90
  groupDb: Kysely<GroupDatabase>,
82✔
91
  callerDid: string,
82✔
92
  operation: Operation,
82✔
93
  detail?: Omit<AuditEventDetail, 'reason'>,
82✔
94
  principal?: GatePrincipal,
82✔
95
): Promise<void> {
82✔
96
  const auditDetail: Omit<AuditEventDetail, 'reason'> | undefined =
82✔
97
    principal?.authKind === 'apiKey' && principal.apiKeyRef
82✔
98
      ? { ...detail, apiKeyRef: principal.apiKeyRef }
9✔
99
      : detail
73✔
100

101
  const denied = async (reason: string): Promise<never> => {
82✔
102
    await ctx.audit.log(groupDb, callerDid, operation, 'denied', { ...auditDetail, reason })
6✔
103
    throw new ForbiddenError(reason)
6✔
104
  }
6✔
105

106
  if (principal?.authKind === 'apiKey') {
82✔
107
    const scopes = principal.scopes ?? []
9!
108
    if (!scopesCoverOperation(scopes, operation, ctx.config.serviceDid)) {
9✔
109
      await denied(`API key scopes do not permit '${operation}'`)
6!
NEW
110
    }
×
111
  }
9✔
112

113
  try {
76✔
114
    await ctx.rbac.assertCan(groupDb, callerDid, operation)
76✔
115
  } catch (err) {
78✔
116
    await ctx.audit.log(groupDb, callerDid, operation, 'denied', {
16✔
117
      ...auditDetail,
16✔
118
      reason: (err as Error).message,
16✔
119
    })
16✔
120
    throw err
16✔
121
  }
16✔
122
}
82✔
123

124
/**
125
 * Proxy a call to the group's PDS.
126
 *
127
 * 4xx errors from the PDS are forwarded to the client so they can
128
 * distinguish e.g. "duplicate rkey" (400) from a server problem.
129
 * 401s are already handled by PdsAgentPool.withAgent (auto-retry),
130
 * so any 401 that reaches here is a genuine auth failure on our side
131
 * and gets wrapped as 502 along with 5xx and network errors.
132
 */
133
export async function proxyToPds<T>(
32✔
134
  pdsAgents: PdsAgentPool,
32✔
135
  groupDid: string,
32✔
136
  fn: (agent: import('@atproto/api').Agent) => Promise<T>,
32✔
137
): Promise<T> {
32✔
138
  try {
32✔
139
    return await pdsAgents.withAgent(groupDid, fn)
32✔
140
  } catch (err) {
32✔
141
    if (err instanceof UpstreamFailureError) throw err
11✔
142
    if (err instanceof ClientXRPCError) {
11✔
143
      // err.status is the ResponseType enum; coerce to its numeric HTTP
144
      // status code so we can range-check it.
145
      const status = Number(err.status)
8✔
146
      if (status >= 400 && status < 500 && status !== 401) {
8✔
147
        throw new XRPCError(status, err.message, err.error)
3✔
148
      }
3✔
149
    }
8✔
150
    const msg = err instanceof Error ? err.message : String(err)
11✔
151
    throw new UpstreamFailureError(`Upstream PDS error: ${msg}`)
11✔
152
  }
11✔
153
}
32✔
154

155
/**
156
 * Link to the deprecation explanation, surfaced in the RFC 8594 `Link` header
157
 * on legacy-`aud` responses.
158
 */
159
const DEPRECATION_INFO_URL = 'https://github.com/hypercerts-org/certified-group-service/issues/27'
1✔
160

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

167
/**
168
 * Per-key rate limiter backed by a bounded `Map<key, lastSeenMs>`. Returns true
169
 * (and records `now`) when `key` has not been seen within `windowMs`; false
170
 * otherwise.
171
 *
172
 * Memory is hard-bounded to `maxEntries`. Before inserting a new key at the cap
173
 * it first sweeps entries older than the window (cheap, and they'd fire again
174
 * anyway); if every entry is still fresh (a high-cardinality burst), it evicts
175
 * the oldest by insertion order (`Map` preserves it) so the cap is never
176
 * exceeded. Evicting a fresh entry only costs that key one extra warn later.
177
 */
178
export function rateLimitAllow(
1✔
179
  map: Map<string, number>,
114✔
180
  key: string,
114✔
181
  now: number,
114✔
182
  windowMs: number,
114✔
183
  maxEntries: number,
114✔
184
): boolean {
114✔
185
  const previous = map.get(key)
114✔
186
  if (previous !== undefined && now - previous < windowMs) return false
114✔
187
  if (map.size >= maxEntries && !map.has(key)) {
114✔
188
    for (const [k, ts] of map) {
2✔
189
      if (now - ts >= windowMs) map.delete(k)
6✔
190
    }
6✔
191
    // Still full of fresh entries: evict the oldest to keep a hard cap.
192
    if (map.size >= maxEntries) {
2✔
193
      const oldest: string | undefined = map.keys().next().value
1✔
194
      if (oldest !== undefined) map.delete(oldest)
1✔
195
    }
1✔
196
  }
2✔
197
  map.set(key, now)
26✔
198
  return true
26✔
199
}
26✔
200

201
/**
202
 * Signal the deprecated `aud`-as-group path (issue #27) on a per-request basis:
203
 * attach RFC 8594 headers so clients can detect it programmatically, and emit a
204
 * rate-limited warn so operators can see lingering legacy traffic. No `Sunset`
205
 * header — a removal date is not yet set.
206
 */
207
function signalLegacyAud(ctx: AppContext, res: ExpressResponse, callerDid: string, nsid: string) {
107✔
208
  res.setHeader('Deprecation', 'true')
107✔
209
  res.setHeader('Link', `<${DEPRECATION_INFO_URL}>; rel="deprecation"`)
107✔
210

211
  if (
107✔
212
    rateLimitAllow(
107✔
213
      lastLegacyWarn,
107✔
214
      callerDid,
107✔
215
      Date.now(),
107✔
216
      LEGACY_WARN_WINDOW_MS,
107✔
217
      LEGACY_WARN_MAX_ENTRIES,
107✔
218
    )
107✔
219
  ) {
107✔
220
    ctx.logger.warn(
20✔
221
      { callerDid, nsid },
20✔
222
      'Deprecated auth: group taken from JWT aud. Pass an explicit `repo` and set aud to the service DID (issue #27).',
20✔
223
    )
20✔
224
  }
20✔
225
}
107✔
226

227
export function registerAuthedMethod(
1✔
228
  server: Server,
422✔
229
  nsid: string,
422✔
230
  ctx: AppContext,
422✔
231
  config: AuthedMethodConfig,
422✔
232
): void {
422✔
233
  const handler: MethodHandler<GroupAuthResult> = async (reqCtx) => {
422✔
234
    if (reqCtx.auth.credentials.legacyAud) {
117✔
235
      signalLegacyAud(ctx, reqCtx.res, reqCtx.auth.credentials.callerDid, nsid)
107✔
236
    }
107✔
237
    return config.handler(reqCtx)
117✔
238
  }
117✔
239
  server.method(nsid, {
422✔
240
    auth: ctx.authVerifier.xrpcAuth(),
422✔
241
    opts: config.opts,
422✔
242
    handler,
422✔
243
  })
422✔
244
}
422✔
245

246
/**
247
 * Register a group-bootstrapping XRPC method (register, import) — one whose
248
 * audience is the service's own DID rather than a group DID, and whose target
249
 * group does not yet exist in the service. Unlike registerAuthedMethod, the
250
 * auth verifier does not open a per-group DB or check group membership; it only
251
 * proves the caller controls the issuing DID. The handler is responsible for
252
 * any ownerDid / authorship checks.
253
 */
254
export function registerServiceAuthMethod(
1✔
255
  server: Server,
36✔
256
  nsid: string,
36✔
257
  ctx: AppContext,
36✔
258
  config: ServiceAuthMethodConfig,
36✔
259
): void {
36✔
260
  server.method(nsid, {
36✔
261
    auth: ctx.authVerifier.xrpcServiceAuth(),
36✔
262
    opts: config.opts,
36✔
263
    handler: config.handler,
36✔
264
  })
36✔
265
}
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