diff --git a/nix-cache/package-lock.json b/nix-cache/package-lock.json index 5b27ec2..d186fa3 100644 --- a/nix-cache/package-lock.json +++ b/nix-cache/package-lock.json @@ -7,9 +7,14 @@ "": { "name": "nix-cache", "version": "0.0.0", + "dependencies": { + "jose": "^6.1.3", + "range-parser": "^1.2.1" + }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.12.4", "@types/node": "^25.3.1", + "@types/range-parser": "^1.2.7", "typescript": "^5.5.2", "vitest": "~3.2.0", "wrangler": "^4.68.1" @@ -1562,6 +1567,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -1902,6 +1914,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -2056,6 +2077,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", diff --git a/nix-cache/package.json b/nix-cache/package.json index 8ed61ff..d4ee974 100644 --- a/nix-cache/package.json +++ b/nix-cache/package.json @@ -12,8 +12,13 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.12.4", "@types/node": "^25.3.1", + "@types/range-parser": "^1.2.7", "typescript": "^5.5.2", "vitest": "~3.2.0", "wrangler": "^4.68.1" + }, + "dependencies": { + "jose": "^6.1.3", + "range-parser": "^1.2.1" } -} \ No newline at end of file +} diff --git a/nix-cache/src/index.ts b/nix-cache/src/index.ts new file mode 100644 index 0000000..393ad3b --- /dev/null +++ b/nix-cache/src/index.ts @@ -0,0 +1,234 @@ +import parseRange from 'range-parser'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +interface Env { + R2_BUCKET: R2Bucket; + CACHE_CONTROL: string; + KEYCLOAK_ISSUER: string; + KEYCLOAK_AUDIENCE: string; + REQUIRED_GROUP: string; +} + +function hasBody(object: R2Object | R2ObjectBody): object is R2ObjectBody { + return (object).body !== undefined; +} + +const resolvedKeyHeader = 'x-resolved-r2-key'; + +type Range = { offset: number; length?: number } | { offset?: number; length: number }; + +function rangeToHeader(range: Range | undefined, file: R2Object | R2ObjectBody): string { + if (range) { + const offset = range.offset ?? 0; + const length = range.length ?? 0; + return `bytes ${offset}-${offset + length - 1}/${file.size}`; + } else { + return ''; + } +} + +let JWKS: ReturnType | null = null; + +function getJWKS(issuer: string) { + if (!JWKS) { + const url = new URL(`${issuer}/protocol/openid-connect/certs`); + JWKS = createRemoteJWKSet(url); + } + return JWKS; +} + +async function authenticate(request: Request, env: Env): Promise { + const authHeader = request.headers.get('Authorization'); + if (!authHeader) return false; + + let token: string | null = null; + + if (authHeader.startsWith('Bearer ')) { + token = authHeader.slice(7); + } else if (authHeader.startsWith('Basic ')) { + try { + const decoded = atob(authHeader.slice(6)); + const colon = decoded.indexOf(':'); + token = colon !== -1 ? decoded.slice(colon + 1) : decoded; + } catch { + return false; + } + } + + if (!token) return false; + + try { + const jwks = getJWKS(env.KEYCLOAK_ISSUER); + const { payload } = await jwtVerify(token, jwks, { + issuer: env.KEYCLOAK_ISSUER, + audience: env.KEYCLOAK_AUDIENCE, + algorithms: ['RS256'], + }); + + const groups = payload.groups; + if (!Array.isArray(groups) || !groups.includes(env.REQUIRED_GROUP)) { + return false; + } + + return true; + } catch { + return false; + } +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const allowedMethods = ['GET', 'HEAD', 'OPTIONS']; + if (allowedMethods.indexOf(request.method) === -1) return new Response('Method Not Allowed', { status: 405 }); + + if (request.method === 'OPTIONS') { + return new Response(null, { headers: { allow: allowedMethods.join(', ') } }); + } + + const authenticated = await authenticate(request, env); + if (!authenticated) { + return new Response('Unauthorized', { + status: 401, + headers: { 'WWW-Authenticate': 'Bearer realm="nix-cache"' }, + }); + } + + const url = new URL(request.url); + if (url.pathname === '/') { + url.pathname = '/index.html'; + } + + const cache = caches.default; + let response = await cache.match(request); + let range: Range | undefined; + + if (!response || !response.ok) { + console.warn('Cache miss'); + const path = decodeURIComponent(url.pathname.substring(1)); + + let file: R2Object | R2ObjectBody | null | undefined; + + // Range handling + if (request.method === 'GET') { + const rangeHeader = request.headers.get('range'); + if (rangeHeader) { + file = await env.R2_BUCKET.head(path); + if (file === null) + return new Response('File Not Found', { + status: 404, + headers: { + [resolvedKeyHeader]: path, + }, + }); + const parsedRanges = parseRange(file.size, rangeHeader); + // R2 only supports 1 range at the moment, reject if there is more than one + if (parsedRanges !== -1 && parsedRanges !== -2 && parsedRanges.length === 1 && parsedRanges.type === 'bytes') { + let firstRange = parsedRanges[0]; + range = { + offset: firstRange.start, + length: firstRange.end - firstRange.start + 1, + }; + } else { + return new Response('Range Not Satisfiable', { + status: 416, + headers: { + [resolvedKeyHeader]: path, + }, + }); + } + } + } + + // Etag/If-(Not)-Match handling + // R2 requires that etag checks must not contain quotes, and the S3 spec only allows one etag + // This silently ignores invalid or weak (W/) headers + const getHeaderEtag = (header: string | null) => header?.trim().replace(/^['"]|['"]$/g, ''); + const ifMatch = getHeaderEtag(request.headers.get('if-match')); + const ifNoneMatch = getHeaderEtag(request.headers.get('if-none-match')); + + const ifModifiedSince = Date.parse(request.headers.get('if-modified-since') || ''); + const ifUnmodifiedSince = Date.parse(request.headers.get('if-unmodified-since') || ''); + + const ifRange = request.headers.get('if-range'); + if (range && ifRange && file) { + const maybeDate = Date.parse(ifRange); + + if (isNaN(maybeDate) || new Date(maybeDate) > file.uploaded) { + // httpEtag already has quotes, no need to use getHeaderEtag + if (ifRange !== file.httpEtag) range = undefined; + } + } + + if (ifMatch || ifUnmodifiedSince) { + file = await env.R2_BUCKET.get(path, { + onlyIf: { + etagMatches: ifMatch, + uploadedBefore: ifUnmodifiedSince ? new Date(ifUnmodifiedSince) : undefined, + }, + range, + }); + + if (file && !hasBody(file)) { + return new Response('Precondition Failed', { + status: 412, + headers: { + [resolvedKeyHeader]: path, + }, + }); + } + } + + if (ifNoneMatch || ifModifiedSince) { + // if-none-match overrides if-modified-since completely + if (ifNoneMatch) { + file = await env.R2_BUCKET.get(path, { onlyIf: { etagDoesNotMatch: ifNoneMatch }, range }); + } else if (ifModifiedSince) { + file = await env.R2_BUCKET.get(path, { onlyIf: { uploadedAfter: new Date(ifModifiedSince) }, range }); + } + if (file && !hasBody(file)) { + return new Response(null, { status: 304 }); + } + } + + file = + request.method === 'HEAD' + ? await env.R2_BUCKET.head(path) + : file && hasBody(file) + ? file + : await env.R2_BUCKET.get(path, { range }); + + if (file === null) { + return new Response('File Not Found', { + status: 404, + headers: { + [resolvedKeyHeader]: path, + }, + }); + } + + response = new Response(hasBody(file) ? file.body : null, { + status: (file?.size || 0) === 0 ? 204 : range ? 206 : 200, + headers: { + 'accept-ranges': 'bytes', + + etag: file.httpEtag, + 'cache-control': file.httpMetadata.cacheControl ?? (env.CACHE_CONTROL || ''), + expires: file.httpMetadata.cacheExpiry?.toUTCString() ?? '', + 'last-modified': file.uploaded.toUTCString(), + + 'content-encoding': file.httpMetadata?.contentEncoding ?? '', + 'content-type': file.httpMetadata?.contentType ?? 'application/octet-stream', + 'content-language': file.httpMetadata?.contentLanguage ?? '', + 'content-disposition': file.httpMetadata?.contentDisposition ?? '', + 'content-range': rangeToHeader(range, file), + + [resolvedKeyHeader]: path, + }, + }); + } + + if (request.method === 'GET' && !range) ctx.waitUntil(cache.put(request, response.clone())); + + return response; + }, +} satisfies ExportedHandler; diff --git a/nix-cache/test/index.spec.ts b/nix-cache/test/index.spec.ts index 5197296..8f14a11 100644 --- a/nix-cache/test/index.spec.ts +++ b/nix-cache/test/index.spec.ts @@ -1,24 +1,151 @@ -import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test'; -import { describe, it, expect } from 'vitest'; +import { env, createExecutionContext, fetchMock } from 'cloudflare:test'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { generateKeyPair, SignJWT, exportJWK } from 'jose'; import worker from '../src/index'; -// For now, you'll need to do something like this to get a correctly-typed -// `Request` to pass to `worker.fetch()`. const IncomingRequest = Request; -describe('Hello World worker', () => { - it('responds with Hello World! (unit style)', async () => { - const request = new IncomingRequest('http://example.com'); - // Create an empty context to pass to `worker.fetch()`. +let privateKey: CryptoKey; +let jwksResponse: object; + +beforeAll(async () => { + const { publicKey, privateKey: pk } = await generateKeyPair('RS256'); + privateKey = pk; + + const publicJwk = await exportJWK(publicKey); + publicJwk.alg = 'RS256'; + publicJwk.use = 'sig'; + publicJwk.kid = 'test-key'; + jwksResponse = { keys: [publicJwk] }; + + fetchMock.activate(); + fetchMock.disableNetConnect(); + + fetchMock + .get('https://id.guardianproject.info') + .intercept({ path: '/realms/gp/protocol/openid-connect/certs' }) + .reply(200, JSON.stringify(jwksResponse), { + headers: { 'content-type': 'application/json' }, + }) + .persist(); +}); + +async function makeJWT(overrides: Record = {}): Promise { + const now = Math.floor(Date.now() / 1000); + const defaults = { + iss: env.KEYCLOAK_ISSUER, + aud: env.KEYCLOAK_AUDIENCE, + sub: 'test-user', + groups: [env.REQUIRED_GROUP], + iat: now, + exp: now + 3600, + }; + + const claims = { ...defaults, ...overrides }; + + let builder = new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }); + + if (claims.iss !== undefined) builder = builder.setIssuer(claims.iss as string); + if (claims.aud !== undefined) builder = builder.setAudience(claims.aud as string); + if (claims.exp !== undefined) builder = builder.setExpirationTime(claims.exp as number); + + return builder.sign(privateKey); +} + +describe('nix-cache auth', () => { + it('OPTIONS without auth returns 200', async () => { + const request = new IncomingRequest('http://example.com/', { method: 'OPTIONS' }); const ctx = createExecutionContext(); const response = await worker.fetch(request, env, ctx); - // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions - await waitOnExecutionContext(ctx); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); + expect(response.status).toBe(200); }); - it('responds with Hello World! (integration style)', async () => { - const response = await SELF.fetch('https://example.com'); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); + it('GET with no Authorization header returns 401', async () => { + const request = new IncomingRequest('http://example.com/nix-cache-info'); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe('Bearer realm="nix-cache"'); + }); + + it('GET with unrecognized auth scheme returns 401', async () => { + const request = new IncomingRequest('http://example.com/nix-cache-info', { + headers: { Authorization: 'Token xyz' }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(401); + }); + + it('GET with Bearer + valid JWT passes auth', async () => { + const token = await makeJWT(); + const request = new IncomingRequest('http://example.com/nix-cache-info', { + headers: { Authorization: `Bearer ${token}` }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).not.toBe(401); + }); + + it('GET with Basic auth :token format passes auth', async () => { + const token = await makeJWT(); + const basic = btoa(':' + token); + const request = new IncomingRequest('http://example.com/nix-cache-info', { + headers: { Authorization: `Basic ${basic}` }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).not.toBe(401); + }); + + it('GET with Basic auth user:token format passes auth', async () => { + const token = await makeJWT(); + const basic = btoa('user:' + token); + const request = new IncomingRequest('http://example.com/nix-cache-info', { + headers: { Authorization: `Basic ${basic}` }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).not.toBe(401); + }); + + it('GET with invalid Bearer token returns 401', async () => { + const request = new IncomingRequest('http://example.com/nix-cache-info', { + headers: { Authorization: 'Bearer garbage-token' }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(401); + }); + + it('GET with expired JWT returns 401', async () => { + const token = await makeJWT({ exp: Math.floor(Date.now() / 1000) - 3600 }); + const request = new IncomingRequest('http://example.com/nix-cache-info', { + headers: { Authorization: `Bearer ${token}` }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(401); + }); + + it('GET with JWT missing groups claim returns 401', async () => { + const token = await makeJWT({ groups: undefined }); + const request = new IncomingRequest('http://example.com/nix-cache-info', { + headers: { Authorization: `Bearer ${token}` }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(401); + }); + + it('GET with JWT where groups does not contain required group returns 401', async () => { + const token = await makeJWT({ groups: ['some-other-group'] }); + const request = new IncomingRequest('http://example.com/nix-cache-info', { + headers: { Authorization: `Bearer ${token}` }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(401); }); }); diff --git a/nix-cache/wrangler.jsonc b/nix-cache/wrangler.jsonc index 9ac91dc..b4a51dc 100644 --- a/nix-cache/wrangler.jsonc +++ b/nix-cache/wrangler.jsonc @@ -12,6 +12,14 @@ }, "compatibility_flags": [ "nodejs_compat" + ], + "vars": { + "KEYCLOAK_ISSUER": "https://id.guardianproject.info/realms/gp", + "KEYCLOAK_AUDIENCE": "nix-cache", + "REQUIRED_GROUP": "nix-cache-users" + }, + "r2_buckets": [ + { "binding": "R2_BUCKET", "bucket_name": "nix-cache" } ] /** * Smart Placement