implement cache proxy with authentication
This commit is contained in:
parent
aa13287f67
commit
affaa13e4b
5 changed files with 419 additions and 15 deletions
30
nix-cache/package-lock.json
generated
30
nix-cache/package-lock.json
generated
|
|
@ -7,9 +7,14 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "nix-cache",
|
"name": "nix-cache",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"range-parser": "^1.2.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-pool-workers": "^0.12.4",
|
"@cloudflare/vitest-pool-workers": "^0.12.4",
|
||||||
"@types/node": "^25.3.1",
|
"@types/node": "^25.3.1",
|
||||||
|
"@types/range-parser": "^1.2.7",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.2",
|
||||||
"vitest": "~3.2.0",
|
"vitest": "~3.2.0",
|
||||||
"wrangler": "^4.68.1"
|
"wrangler": "^4.68.1"
|
||||||
|
|
@ -1562,6 +1567,13 @@
|
||||||
"undici-types": "~7.18.0"
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
"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": "^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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
|
|
@ -2056,6 +2077,15 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,13 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-pool-workers": "^0.12.4",
|
"@cloudflare/vitest-pool-workers": "^0.12.4",
|
||||||
"@types/node": "^25.3.1",
|
"@types/node": "^25.3.1",
|
||||||
|
"@types/range-parser": "^1.2.7",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.2",
|
||||||
"vitest": "~3.2.0",
|
"vitest": "~3.2.0",
|
||||||
"wrangler": "^4.68.1"
|
"wrangler": "^4.68.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"range-parser": "^1.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
234
nix-cache/src/index.ts
Normal file
234
nix-cache/src/index.ts
Normal file
|
|
@ -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 (<R2ObjectBody>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<typeof createRemoteJWKSet> | 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<boolean> {
|
||||||
|
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<Response> {
|
||||||
|
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<Env>;
|
||||||
|
|
@ -1,24 +1,151 @@
|
||||||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
|
import { env, createExecutionContext, fetchMock } from 'cloudflare:test';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { generateKeyPair, SignJWT, exportJWK } from 'jose';
|
||||||
import worker from '../src/index';
|
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<unknown, IncomingRequestCfProperties>;
|
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
|
||||||
|
|
||||||
describe('Hello World worker', () => {
|
let privateKey: CryptoKey;
|
||||||
it('responds with Hello World! (unit style)', async () => {
|
let jwksResponse: object;
|
||||||
const request = new IncomingRequest('http://example.com');
|
|
||||||
// Create an empty context to pass to `worker.fetch()`.
|
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<string, unknown> = {}): Promise<string> {
|
||||||
|
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 ctx = createExecutionContext();
|
||||||
const response = await worker.fetch(request, env, ctx);
|
const response = await worker.fetch(request, env, ctx);
|
||||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
expect(response.status).toBe(200);
|
||||||
await waitOnExecutionContext(ctx);
|
|
||||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('responds with Hello World! (integration style)', async () => {
|
it('GET with no Authorization header returns 401', async () => {
|
||||||
const response = await SELF.fetch('https://example.com');
|
const request = new IncomingRequest('http://example.com/nix-cache-info');
|
||||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@
|
||||||
},
|
},
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat"
|
"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
|
* Smart Placement
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue