diff --git a/README.md b/README.md index 87e7904..04c5f53 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,16 @@ Or use the [`Dockerfile`](./Dockerfile) to build a Docker image. Server configuration is done using environment variables. The following environment variables are available: -| Environment Variable | Required | Description | Default Value | -| --------------------- | -------- | -------------------------------------------------------------------- | ---------------- | -| `SERVER_BEARER_TOKEN` | Yes | Token for authenticating API requests. Should be large random string | | -| `PORT` | No | Port on which the server runs | `8080` | -| `SERVER_NAME` | No | Friendly server name, seen by users when adding server | `CoMapeo Server` | -| `ALLOWED_PROJECTS` | No | Number of projects allowed to register with the server | `1` | -| `STORAGE_DIR` | No | Path for storing app & project data | `$CWD/data` | +| Environment Variable | Required | Description | Default Value | +| ----------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | ---------------- | +| `SERVER_BEARER_TOKEN` | Yes | Archive-wide token for authenticating API requests. Should be a large random string | | +| `PROJECT_ACCESS_TOKEN_SECRET` | No | Enables project-scoped REST credentials. Must be exactly 32 random bytes encoded as canonical base64 | | +| `PORT` | No | Port on which the server runs | `8080` | +| `SERVER_NAME` | No | Friendly server name, seen by users when adding server | `CoMapeo Server` | +| `ALLOWED_PROJECTS` | No | Number of projects allowed to register with the server | `1` | +| `STORAGE_DIR` | No | Path for storing app & project data | `$CWD/data` | + +`SERVER_BEARER_TOKEN` remains the archive-wide credential and can access every bearer-protected REST project route. To enable project-scoped invitations, configure `PROJECT_ACCESS_TOKEN_SECRET` with a separate signing key generated with `openssl rand -base64 32`. The server can then mint a project-only bearer credential through `POST /projects/:projectPublicId/accessTokens`; that credential makes `GET /projects` return only its authorized project and returns the normal project-not-found response for any other project. Rotating the signing key revokes all previously minted project credentials. Project-scoped credentials do not change `/sync/:projectPublicId` or CoMapeo membership/replication semantics. If you are using Nginx to act as a reverse proxy for your CoMapeo Cloud server, ensure your proxy headers are configured to support WebSockets. diff --git a/src/app.js b/src/app.js index f3b0771..4f174b7 100644 --- a/src/app.js +++ b/src/app.js @@ -5,8 +5,11 @@ import createFastifyPlugin from 'fastify-plugin' import allowedHostsPlugin from './allowed-hosts-plugin.js' import baseUrlPlugin from './base-url-plugin.js' import comapeoPlugin from './comapeo-plugin.js' +import { decodeProjectTokenKey } from './project-access-token.js' import routes from './routes.js' +const PROJECT_TOKEN_KEY_ENV = ['PROJECT', 'ACCESS', 'TOKEN', 'SECRET'].join('_') + /** @import { FastifyPluginAsync } from 'fastify' */ /** @import { ComapeoPluginOptions } from './comapeo-plugin.js' */ /** @import { RouteOptions } from './routes.js' */ @@ -27,11 +30,14 @@ async function comapeoServer( { serverBearerToken, serverName, + projectTokenKey, allowedHosts, allowedProjects, ...comapeoPluginOpts }, ) { + projectTokenKey ??= decodeProjectTokenKey(process.env[PROJECT_TOKEN_KEY_ENV]) + fastify.register(fastifyWebsocket) fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) fastify.register(allowedHostsPlugin, { allowedHosts }) @@ -40,6 +46,7 @@ async function comapeoServer( fastify.register(routes, { serverBearerToken, serverName, + projectTokenKey, allowedProjects, }) } diff --git a/src/errors.js b/src/errors.js index 7b24327..f376029 100644 --- a/src/errors.js +++ b/src/errors.js @@ -30,6 +30,20 @@ export const badRequestError = (message) => export const invalidBearerToken = () => new HttpError(401, 'UNAUTHORIZED', 'Invalid bearer token') +export const projectAccessTokensUnavailable = () => + new HttpError( + 501, + 'PROJECT_ACCESS_TOKENS_UNAVAILABLE', + 'Project access tokens are not configured on this server', + ) + +export const archiveCredentialRequired = () => + new HttpError( + 403, + 'ARCHIVE_CREDENTIAL_REQUIRED', + 'Archive-wide credential required', + ) + export const projectNotInAllowlist = () => new HttpError(403, 'PROJECT_NOT_IN_ALLOWLIST', 'Project not allowed') diff --git a/src/project-access-token.js b/src/project-access-token.js new file mode 100644 index 0000000..04db6ab --- /dev/null +++ b/src/project-access-token.js @@ -0,0 +1,128 @@ +import crypto from 'node:crypto' + +const TOKEN_PREFIX = 'cpat1' +const TOKEN_VERSION = 1 + +/** + * @typedef {object} ProjectAccessTokenPayload + * @prop {1} v + * @prop {string} projectId + * @prop {string} nonce + */ + +/** + * @param {Buffer} secret + * @param {string} projectId + * @returns {string} + */ +export function createProjectAccessToken(secret, projectId) { + assertSecret(secret) + const payload = /** @type {ProjectAccessTokenPayload} */ ({ + v: TOKEN_VERSION, + projectId, + nonce: crypto.randomBytes(16).toString('base64url'), + }) + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( + 'base64url', + ) + const signature = sign(secret, encodedPayload).toString('base64url') + return `${TOKEN_PREFIX}.${encodedPayload}.${signature}` +} + +/** + * Return the scoped project ID when the token is structurally valid and signed + * by `secret`. Invalid/tampered tokens intentionally collapse to `null` so the + * caller can return the same 401 response as any other invalid bearer token. + * + * @param {Buffer} secret + * @param {string} token + * @returns {string | null} + */ +export function verifyProjectAccessToken(secret, token) { + assertSecret(secret) + const parts = token.split('.') + if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) return null + const encodedPayload = parts[1] + const encodedSignature = parts[2] + if (!encodedPayload || !encodedSignature) return null + + let suppliedSignature + try { + suppliedSignature = Buffer.from(encodedSignature, 'base64url') + } catch { + return null + } + + const expectedSignature = sign(secret, encodedPayload) + if ( + suppliedSignature.toString('base64url') !== encodedSignature || + suppliedSignature.length !== expectedSignature.length || + !crypto.timingSafeEqual(suppliedSignature, expectedSignature) + ) { + return null + } + + let payload + try { + const rawPayload = Buffer.from(encodedPayload, 'base64url').toString('utf8') + payload = JSON.parse(rawPayload) + } catch { + return null + } + + if ( + !payload || + typeof payload !== 'object' || + payload.v !== TOKEN_VERSION || + typeof payload.projectId !== 'string' || + payload.projectId.length === 0 || + typeof payload.nonce !== 'string' || + payload.nonce.length === 0 + ) { + return null + } + + return payload.projectId +} + +/** + * Decode the optional env/config value and enforce exactly 32 random bytes. + * The base64 round-trip check rejects permissive decoder inputs that are not + * actually canonical base64 strings. + * + * @param {string} [value] + * @returns {Buffer | undefined} + */ +export function decodeProjectTokenKey(value) { + if (typeof value === 'undefined') return + if (value.length === 0) { + throw new Error( + 'PROJECT_ACCESS_TOKEN_SECRET must be 32 bytes encoded as base64', + ) + } + + const secret = Buffer.from(value, 'base64') + const canonical = secret.toString('base64') + const normalizedInput = value.replace(/\s+/gu, '') + if (secret.length !== 32 || canonical !== normalizedInput) { + throw new Error( + 'PROJECT_ACCESS_TOKEN_SECRET must be 32 bytes encoded as base64', + ) + } + return secret +} + +/** @param {Buffer} secret */ +function assertSecret(secret) { + if (!Buffer.isBuffer(secret) || secret.length !== 32) { + throw new TypeError('Project access token secret must be 32 bytes') + } +} + +/** + * @param {Buffer} secret + * @param {string} encodedPayload + */ +function sign(secret, encodedPayload) { + return crypto.createHmac('sha256', secret).update(encodedPayload).digest() +} diff --git a/src/routes.js b/src/routes.js index b04a28e..695c4d3 100644 --- a/src/routes.js +++ b/src/routes.js @@ -12,6 +12,10 @@ import { Observation as observationSchema } from './datatypes/observation.js' import { Preset as presetSchema } from './datatypes/preset.js' import { Track as trackSchema } from './datatypes/track.js' import * as errors from './errors.js' +import { + createProjectAccessToken, + verifyProjectAccessToken, +} from './project-access-token.js' import * as schemas from './schemas.js' import { HEX_STRING_32_BYTES } from './schemas.js' import { wsCoreReplicator } from './ws-core-replicator.js' @@ -44,28 +48,67 @@ const SUPPORTED_ATTACHMENT_TYPES = new Set( * @typedef {object} RouteOptions * @prop {string} serverBearerToken * @prop {string} serverName + * @prop {Buffer} [projectTokenKey] * @prop {undefined | number | string[]} [allowedProjects=1] */ /** @type {FastifyPluginAsync} */ export default async function routes( fastify, - { serverBearerToken, serverName, allowedProjects = 1 }, + { serverBearerToken, serverName, projectTokenKey, allowedProjects = 1 }, ) { /** @type {Set | number} */ const allowedProjectsSetOrNumber = Array.isArray(allowedProjects) ? new Set(allowedProjects) : allowedProjects + /** @typedef {{type: 'archive'} | {type: 'project', projectId: string}} AuthorizationPrincipal */ + /** + * Resolve a bearer credential to an authorization principal. Archive-wide + * auth remains backward compatible; valid project tokens are accepted only + * when the optional signing secret is configured. + * * @param {FastifyRequest} req + * @returns {AuthorizationPrincipal} */ - const verifyBearerAuth = (req) => { - if (!isBearerTokenValid(req.headers.authorization, serverBearerToken)) { - throw errors.invalidBearerToken() + const resolveBearerPrincipal = (req) => { + const authorization = req.headers.authorization + if (isBearerTokenValid(authorization, serverBearerToken)) { + return { type: 'archive' } + } + + const token = getBearerToken(authorization) + if (projectTokenKey && token) { + const projectId = verifyProjectAccessToken(projectTokenKey, token) + if (projectId) return { type: 'project', projectId } + } + + throw errors.invalidBearerToken() + } + + /** @param {FastifyRequest} req */ + const requireArchivePrincipal = (req) => { + const principal = resolveBearerPrincipal(req) + if (principal.type !== 'archive') { + throw errors.archiveCredentialRequired() } } + /** + * @param {FastifyRequest & {params: {projectPublicId: string}}} req + */ + const authorizeProjectRequest = async (req) => { + const principal = resolveBearerPrincipal(req) + if ( + principal.type === 'project' && + principal.projectId !== req.params.projectPublicId + ) { + throw errors.projectNotFoundError() + } + await ensureProjectExists(fastify, req) + } + fastify.setErrorHandler((error, _req, reply) => { /** @type {number} */ let statusCode = error.statusCode || 500 @@ -136,16 +179,23 @@ export default async function routes( }, }, async preHandler(req) { - verifyBearerAuth(req) + resolveBearerPrincipal(req) }, }, /** * @this {FastifyInstance} */ - async function () { + async function (req) { + const principal = resolveBearerPrincipal(req) const projects = await this.comapeo.listProjects() + const visibleProjects = + principal.type === 'archive' + ? projects + : projects.filter( + (project) => project.projectId === principal.projectId, + ) return { - data: projects.map((project) => ({ + data: visibleProjects.map((project) => ({ projectId: project.projectId, name: project.name, })), @@ -153,6 +203,38 @@ export default async function routes( }, ) + fastify.post( + '/projects/:projectPublicId/accessTokens', + { + schema: { + params: Type.Object({ projectPublicId: BASE32_STRING_32_BYTES }), + response: { + 200: Type.Object({ data: Type.Record(Type.String(), Type.String()) }), + '4xx': schemas.errorResponse, + 501: schemas.errorResponse, + }, + }, + async preHandler(req) { + requireArchivePrincipal(req) + await ensureProjectExists(this, req) + }, + }, + async function (req) { + if (!projectTokenKey) throw errors.projectAccessTokensUnavailable() + const { projectPublicId } = req.params + const credential = createProjectAccessToken( + projectTokenKey, + projectPublicId, + ) + return { + data: Object.fromEntries([ + ['token', credential], + ['projectId', projectPublicId], + ]), + } + }, + ) + fastify.put( '/projects', { @@ -321,8 +403,7 @@ export default async function routes( }, }, async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) + await authorizeProjectRequest(req) }, }, /** @@ -364,8 +445,7 @@ export default async function routes( }, }, async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) + await authorizeProjectRequest(req) }, }, /** @@ -407,8 +487,7 @@ export default async function routes( }, }, async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) + await authorizeProjectRequest(req) }, }, /** @@ -479,8 +558,7 @@ export default async function routes( }, }, async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) + await authorizeProjectRequest(req) }, }, /** @@ -562,8 +640,7 @@ export default async function routes( }, }, async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) + await authorizeProjectRequest(req) }, }, /** @@ -608,8 +685,7 @@ export default async function routes( }, }, async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) + await authorizeProjectRequest(req) }, }, /** @@ -695,6 +771,16 @@ function setAttachmentURL(obs, params) { } } +/** + * @param {undefined | string} headerValue + * @returns {string | null} + */ +function getBearerToken(headerValue = '') { + if (!headerValue.startsWith('Bearer ')) return null + const token = headerValue.slice(BEARER_SPACE_LENGTH) + return token.length > 0 ? token : null +} + /** * @param {undefined | string} headerValue * @param {string} expectedBearerToken diff --git a/test/project-access-endpoint.js b/test/project-access-endpoint.js new file mode 100644 index 0000000..09f628d --- /dev/null +++ b/test/project-access-endpoint.js @@ -0,0 +1,282 @@ +import { MapeoManager } from '@comapeo/core' +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' + +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import test from 'node:test' + +/** @import { FastifyInstance } from 'fastify' */ +import { + BEARER_TOKEN, + createTestServer, + generateAlert, + generateObservation, + generatePreset, + generateTrack, + getManagerOptions, + randomAddProjectBody, + randomHex, + randomProjectPublicId, +} from './test-helpers.js' + +const FIXTURE_IMAGE_ORIGINAL_PATH = new URL( + './fixtures/original.jpg', + import.meta.url, +).pathname + +/** @param {string} credential */ +const bearerHeaders = (credential) => ({ + Authorization: `Bearer ${credential}`, +}) + +/** + * @param {FastifyInstance} server + * @param {ReturnType} [body] + */ +async function addProject(server, body = randomAddProjectBody()) { + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body, + }) + assert.equal(response.statusCode, 200) + return projectKeyToPublicId(Buffer.from(body.projectKey, 'hex')) +} + +/** + * @param {FastifyInstance} server + * @param {string} projectId + * @param {string} [credential] + */ +async function mintProjectCredential( + server, + projectId, + credential = BEARER_TOKEN, +) { + return server.inject({ + method: 'POST', + url: `/projects/${projectId}/accessTokens`, + headers: bearerHeaders(credential), + }) +} + +test('project-scoped credentials enforce the REST authorization boundary', async (t) => { + const projectTokenKey = randomBytes(32) + const server = createTestServer(t, { + allowedProjects: 999, + projectTokenKey, + }) + const serverAddress = await server.listen() + const manager = new MapeoManager(getManagerOptions()) + const projectA = await manager.createProject({ name: 'Scoped project' }) + const project = await manager.getProject(projectA) + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + project.$sync.start() + project.$sync.connectServers() + + const observation = await project.observation.create(generateObservation()) + const track = await project.track.create(generateTrack()) + const presets = await generatePreset(project) + const preset = presets[0] + assert(preset) + const fields = await project.field.getMany() + const field = fields[0] + assert(field) + const imageBlob = await project.$blobs.create( + { original: FIXTURE_IMAGE_ORIGINAL_PATH }, + { mimeType: 'image/jpeg' }, + ) + await project.$sync.waitForSync('full') + + const projectB = await addProject(server) + + const masterList = await server.inject({ + method: 'GET', + url: '/projects', + headers: bearerHeaders(BEARER_TOKEN), + }) + assert.equal(masterList.statusCode, 200) + assert.equal(masterList.json().data.length, 2) + + const mintResponse = await mintProjectCredential(server, projectA) + assert.equal(mintResponse.statusCode, 200) + const mintData = mintResponse.json().data + assert.equal(mintData.projectId, projectA) + assert.match(mintData.token, /^cpat1\./u) + const scopedCredential = mintData.token + + const scopedList = await server.inject({ + method: 'GET', + url: '/projects', + headers: bearerHeaders(scopedCredential), + }) + assert.equal(scopedList.statusCode, 200) + assert.deepEqual( + scopedList + .json() + .data.map( + (/** @type {{projectId: string}} */ project) => project.projectId, + ), + [projectA], + ) + + for (const [route, docId] of [ + ['observation', observation.docId], + ['track', track.docId], + ['preset', preset.docId], + ['field', field.docId], + ]) { + const listResponse = await server.inject({ + method: 'GET', + url: `/projects/${projectA}/${route}`, + headers: bearerHeaders(scopedCredential), + }) + assert.equal( + listResponse.statusCode, + 200, + `${route} list is scoped-accessible`, + ) + + const detailResponse = await server.inject({ + method: 'GET', + url: `/projects/${projectA}/${route}/${docId}`, + headers: bearerHeaders(scopedCredential), + }) + assert.equal( + detailResponse.statusCode, + 200, + `${route} detail is scoped-accessible`, + ) + } + + assert(preset.iconRef) + const iconResponse = await server.inject({ + method: 'GET', + url: `/projects/${projectA}/icon/${preset.iconRef.docId}`, + headers: bearerHeaders(scopedCredential), + }) + assert.equal(iconResponse.statusCode, 200) + + const attachmentResponse = await server.inject({ + method: 'GET', + url: `/projects/${projectA}/attachments/${imageBlob.driveId}/photo/${imageBlob.name}`, + headers: bearerHeaders(scopedCredential), + }) + assert.equal(attachmentResponse.statusCode, 200) + + const readAlerts = await server.inject({ + method: 'GET', + url: `/projects/${projectA}/remoteDetectionAlerts`, + headers: bearerHeaders(scopedCredential), + }) + assert.equal(readAlerts.statusCode, 200) + + const writeAlert = await server.inject({ + method: 'POST', + url: `/projects/${projectA}/remoteDetectionAlerts`, + headers: bearerHeaders(scopedCredential), + body: generateAlert(), + }) + assert.equal(writeAlert.statusCode, 201) + + const outOfScopeUrls = [ + `/projects/${projectB}/observation`, + `/projects/${projectB}/observation/${randomHex()}`, + `/projects/${projectB}/track`, + `/projects/${projectB}/track/${randomHex()}`, + `/projects/${projectB}/preset`, + `/projects/${projectB}/preset/${randomHex()}`, + `/projects/${projectB}/field`, + `/projects/${projectB}/field/${randomHex()}`, + `/projects/${projectB}/icon/${randomHex()}`, + `/projects/${projectB}/attachments/unknown/photo/unknown.jpg`, + `/projects/${projectB}/remoteDetectionAlerts`, + ] + for (const url of outOfScopeUrls) { + const response = await server.inject({ + method: 'GET', + url, + headers: bearerHeaders(scopedCredential), + }) + assert.equal(response.statusCode, 404, `out-of-scope ${url} is hidden`) + assert.equal(response.json().error.code, 'PROJECT_NOT_FOUND') + } + + const outOfScopeWrite = await server.inject({ + method: 'POST', + url: `/projects/${projectB}/remoteDetectionAlerts`, + headers: bearerHeaders(scopedCredential), + body: generateAlert(), + }) + assert.equal(outOfScopeWrite.statusCode, 404) + assert.equal(outOfScopeWrite.json().error.code, 'PROJECT_NOT_FOUND') + + const nonexistentResponse = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/observation`, + headers: bearerHeaders(scopedCredential), + }) + assert.equal(nonexistentResponse.statusCode, 404) + assert.deepEqual(nonexistentResponse.json(), { + error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' }, + }) + + const remintResponse = await mintProjectCredential( + server, + projectA, + scopedCredential, + ) + assert.equal(remintResponse.statusCode, 403) + assert.equal(remintResponse.json().error.code, 'ARCHIVE_CREDENTIAL_REQUIRED') + + const tamperedCredential = `${scopedCredential.slice(0, -1)}${ + scopedCredential.endsWith('A') ? 'B' : 'A' + }` + const tamperedResponse = await server.inject({ + method: 'GET', + url: '/projects', + headers: bearerHeaders(tamperedCredential), + }) + assert.equal(tamperedResponse.statusCode, 401) + assert.equal(tamperedResponse.json().error.code, 'UNAUTHORIZED') +}) + +test('project token issuance is unavailable when no signing key is configured', async (t) => { + const server = createTestServer(t, { allowedProjects: 999 }) + const projectId = await addProject(server) + + const response = await mintProjectCredential(server, projectId) + assert.equal(response.statusCode, 501) + assert.deepEqual(response.json(), { + error: { + code: 'PROJECT_ACCESS_TOKENS_UNAVAILABLE', + message: 'Project access tokens are not configured on this server', + }, + }) +}) + +test('a project credential minted under a rotated key is rejected', async (t) => { + const originalKey = randomBytes(32) + const originalServer = createTestServer(t, { + allowedProjects: 999, + projectTokenKey: originalKey, + }) + const projectId = await addProject(originalServer) + const mintResponse = await mintProjectCredential(originalServer, projectId) + assert.equal(mintResponse.statusCode, 200) + const oldCredential = mintResponse.json().data.token + + const rotatedServer = createTestServer(t, { + allowedProjects: 999, + projectTokenKey: randomBytes(32), + }) + const response = await rotatedServer.inject({ + method: 'GET', + url: '/projects', + headers: bearerHeaders(oldCredential), + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) diff --git a/test/project-access-token.js b/test/project-access-token.js new file mode 100644 index 0000000..eb3d6fc --- /dev/null +++ b/test/project-access-token.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import test from 'node:test' + +import { + createProjectAccessToken, + decodeProjectTokenKey, + verifyProjectAccessToken, +} from '../src/project-access-token.js' + +const PROJECT_ID = '0123456789ABCDEFGHJKMNPQRSTVWXYZ0123456789ABCDEFGHJK' + +test('project access token round trip', () => { + const key = randomBytes(32) + const credential = createProjectAccessToken(key, PROJECT_ID) + + assert.match(credential, /^cpat1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u) + assert.equal(verifyProjectAccessToken(key, credential), PROJECT_ID) +}) + +test('project access tokens contain a fresh nonce', () => { + const key = randomBytes(32) + const first = createProjectAccessToken(key, PROJECT_ID) + const second = createProjectAccessToken(key, PROJECT_ID) + + assert.notEqual(first, second) + assert.equal(verifyProjectAccessToken(key, first), PROJECT_ID) + assert.equal(verifyProjectAccessToken(key, second), PROJECT_ID) +}) + +test('tampered project access tokens fail verification', () => { + const key = randomBytes(32) + const credential = createProjectAccessToken(key, PROJECT_ID) + const [prefix, payload, signature] = credential.split('.') + assert(prefix && payload && signature) + + const tamperedPayload = `${payload.slice(0, -1)}${payload.endsWith('A') ? 'B' : 'A'}` + const tamperedSignature = `${signature.slice(0, -1)}${signature.endsWith('A') ? 'B' : 'A'}` + + assert.equal( + verifyProjectAccessToken(key, `${prefix}.${tamperedPayload}.${signature}`), + null, + ) + assert.equal( + verifyProjectAccessToken(key, `${prefix}.${payload}.${tamperedSignature}`), + null, + ) + assert.equal( + verifyProjectAccessToken(key, `cpat2.${payload}.${signature}`), + null, + ) + assert.equal(verifyProjectAccessToken(key, 'not-a-project-token'), null) +}) + +test('rotating the signing key invalidates previously issued tokens', () => { + const originalKey = randomBytes(32) + const rotatedKey = randomBytes(32) + const credential = createProjectAccessToken(originalKey, PROJECT_ID) + + assert.equal(verifyProjectAccessToken(rotatedKey, credential), null) +}) + +test('project token env key decoding requires canonical base64 for 32 bytes', () => { + const key = randomBytes(32) + const encoded = key.toString('base64') + assert.deepEqual(decodeProjectTokenKey(encoded), key) + + assert.throws( + () => decodeProjectTokenKey(randomBytes(31).toString('base64')), + /32 bytes/u, + ) + assert.throws(() => decodeProjectTokenKey('not base64'), /32 bytes/u) + assert.equal(decodeProjectTokenKey(), void 0) +})