mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

access.ts

Language TypeScript7.4 KB206 lines

apps/worker/src/access.ts

  1. import { errorMessage } from '@meridian/shared'
  2. /**
  3. * Verifying who Cloudflare Access says is asking.
  4. *
  5. * The JWT and not the email header. Access sets `Cf-Access-Authenticated-User-Email`, and it is tempting to read
  6. * it and move on. That header is only trustworthy if every request reaches this
  7. * Worker through Access, and the Worker is published on a `workers.dev`
  8. * hostname, which is publicly routable. Access sits in front of it by
  9. * configuration, not by physics. A misconfigured application, a preview URL left
  10. * uncovered, or a direct request would all deliver a header this Worker cannot
  11. * distinguish from the real thing, and the header is trivially settable by
  12. * whoever sends the request.
  13. *
  14. * The executive briefing has exactly one reader, enforced rather than
  15. * preferred, and trusting a spoofable header would make it a preference. So
  16. * the signed assertion is verified instead: `Cf-Access-Jwt-Assertion` is an RS256 JWT signed
  17. * by the Access team's keys, and only Access can mint one.
  18. *
  19. * Fail closed, always. Every failure path here returns null and the caller must
  20. * refuse the request. That includes the case where Access has not been configured
  21. * yet: a freshly deployed Worker with no application in front of it serves
  22. * nothing at all rather than serving everything to everyone. That is the correct
  23. * order: the board carries client names and order numbers.
  24. */
  25. export interface AccessIdentity {
  26. email: string
  27. /** Subject claim, for logging a denial without logging an address. */
  28. sub: string
  29. }
  30. interface Jwk {
  31. kid: string
  32. kty: string
  33. n: string
  34. e: string
  35. alg?: string
  36. }
  37. const base64UrlToBytes = (s: string): Uint8Array => {
  38. const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4))
  39. const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad)
  40. const out = new Uint8Array(bin.length)
  41. for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
  42. return out
  43. }
  44. const decodeJson = (s: string): Record<string, unknown> =>
  45. JSON.parse(new TextDecoder().decode(base64UrlToBytes(s))) as Record<string, unknown>
  46. /**
  47. * Access's signing keys, cached for a few minutes.
  48. *
  49. * Cached because a key fetch on every request would put a network round trip on
  50. * the read path; kept short because key rotation must take effect without a
  51. * deploy. A stale key set fails closed; the signature simply will not verify.
  52. */
  53. let keyCache: { at: number; keys: Map<string, CryptoKey> } | null = null
  54. const KEY_TTL_MS = 5 * 60_000
  55. /**
  56. * Drop the cached signing keys. For tests only. Production rotation is handled
  57. * by the short TTL above, and nothing in the request path should need this.
  58. */
  59. export function __resetKeyCache(): void {
  60. keyCache = null
  61. }
  62. async function signingKeys(teamDomain: string): Promise<Map<string, CryptoKey>> {
  63. const now = Date.now()
  64. if (keyCache && now - keyCache.at < KEY_TTL_MS) return keyCache.keys
  65. const res = await fetch(`https://${teamDomain}/cdn-cgi/access/certs`)
  66. if (!res.ok) throw new Error(`Access certs returned ${res.status}`)
  67. const body = (await res.json()) as { keys?: Jwk[] }
  68. const keys = new Map<string, CryptoKey>()
  69. for (const jwk of body.keys ?? []) {
  70. if (jwk.kty !== 'RSA') continue
  71. keys.set(
  72. jwk.kid,
  73. await crypto.subtle.importKey(
  74. 'jwk',
  75. { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: 'RS256', ext: true },
  76. { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
  77. false,
  78. ['verify'],
  79. ),
  80. )
  81. }
  82. keyCache = { at: now, keys }
  83. return keys
  84. }
  85. /**
  86. * The verified identity, or null.
  87. *
  88. * Null is the only failure signal and it never carries a reason to the caller on
  89. * purpose: a request that fails verification gets one answer, so nothing about
  90. * why can be probed from outside. The reason is logged, not returned.
  91. */
  92. export async function identify(
  93. req: Request,
  94. teamDomain: string | undefined,
  95. audience: string | undefined,
  96. log: (why: string) => void = () => {},
  97. ): Promise<AccessIdentity | null> {
  98. if (!teamDomain || !audience) {
  99. log('ACCESS_TEAM_DOMAIN or ACCESS_AUD is not set on the Worker')
  100. return null
  101. }
  102. const token = req.headers.get('Cf-Access-Jwt-Assertion')
  103. if (!token) {
  104. log('no Cf-Access-Jwt-Assertion header; request did not come through Access')
  105. return null
  106. }
  107. const parts = token.split('.')
  108. if (parts.length !== 3) {
  109. log('assertion is not a three-part JWT')
  110. return null
  111. }
  112. const [rawHeader, rawPayload, rawSig] = parts as [string, string, string]
  113. let header: Record<string, unknown>
  114. let claims: Record<string, unknown>
  115. try {
  116. header = decodeJson(rawHeader)
  117. claims = decodeJson(rawPayload)
  118. } catch {
  119. log('assertion header or payload is not valid base64url JSON')
  120. return null
  121. }
  122. // Algorithm is pinned. Accepting whatever `alg` claims is the classic JWT
  123. // failure: "none" verifies trivially, and an HMAC alg would let the public key
  124. // be used as a shared secret.
  125. if (header.alg !== 'RS256') {
  126. log(`assertion alg is ${String(header.alg)}, not RS256`)
  127. return null
  128. }
  129. // A key fetch that fails is a denial, not an error. Letting this throw would
  130. // surface as a 500: still closed, but it reads as the board being broken and
  131. // invites someone to "fix" it by loosening the check. Refusing cleanly says
  132. // what it is: we cannot confirm who you are, so we will not answer.
  133. let keys: Map<string, CryptoKey>
  134. try {
  135. keys = await signingKeys(teamDomain)
  136. } catch (err) {
  137. log(`could not fetch Access signing keys: ${errorMessage(err)}`)
  138. return null
  139. }
  140. const key = keys.get(String(header.kid))
  141. if (!key) {
  142. log('assertion kid matches none of the current Access signing keys')
  143. return null
  144. }
  145. const ok = await crypto.subtle.verify(
  146. { name: 'RSASSA-PKCS1-v1_5' },
  147. key,
  148. base64UrlToBytes(rawSig),
  149. new TextEncoder().encode(`${rawHeader}.${rawPayload}`),
  150. )
  151. if (!ok) {
  152. log('assertion signature does not verify')
  153. return null
  154. }
  155. // Audience. Without this, a token minted by the same Access team for a
  156. // different application would be accepted here. Access teams commonly protect
  157. // several apps, and a token is only meant for the one it was issued for.
  158. const aud = claims.aud
  159. const audList = Array.isArray(aud) ? aud.map(String) : [String(aud)]
  160. if (!audList.includes(audience)) {
  161. log('assertion audience is for a different Access application')
  162. return null
  163. }
  164. const now = Math.floor(Date.now() / 1000)
  165. if (typeof claims.exp !== 'number' || claims.exp < now) {
  166. log('assertion is expired')
  167. return null
  168. }
  169. if (typeof claims.nbf === 'number' && claims.nbf > now) {
  170. log('assertion is not valid yet')
  171. return null
  172. }
  173. // Exact, not `includes`. A substring test is satisfied by any issuer merely
  174. // containing the team domain: `https://attacker.example/your-team.cloudflareaccess.com`
  175. // would pass. The signature still has to verify against keys fetched from
  176. // `teamDomain` itself, so a forged token cannot be signed either way. But a check that only holds because a different check is
  177. // doing the work is not a check, and this one is meant to be defence in depth.
  178. if (typeof claims.iss === 'string' && claims.iss !== `https://${teamDomain}`) {
  179. log('assertion issuer is not exactly this Access team')
  180. return null
  181. }
  182. const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : ''
  183. if (!email) {
  184. log('assertion carries no email claim')
  185. return null
  186. }
  187. return { email, sub: typeof claims.sub === 'string' ? claims.sub : '' }
  188. }