mirror of
https://github.com/Nezumi-2711/google-drive-s3.git
synced 2026-09-22 13:38:30 +00:00
feat: add the config section for integrate
This commit is contained in:
@@ -71,8 +71,8 @@ https://developers.cloudflare.com/workers/configuration/secrets/#via-the-dashboa
|
|||||||
|
|
||||||
| Key | Description |
|
| Key | Description |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| `ACCESS_KEY` | Any access key used by the S3 client. |
|
| `ACCESS_KEY` | **Bootstrap only.** Seeded into the dashboard-managed access-key store on first use and superseded by it afterwards — manage keys from the dashboard's Integration page. |
|
||||||
| `SECRET_KEY` | A secure secret key used by the S3 client. |
|
| `SECRET_KEY` | **Bootstrap only.** Pairs with `ACCESS_KEY` for the initial seed; ignored once the store exists. |
|
||||||
| `REGION` | The region used by the S3 client. |
|
| `REGION` | The region used by the S3 client. |
|
||||||
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` | Google API credentials obtained from rclone. |
|
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` | Google API credentials obtained from rclone. |
|
||||||
| `DASHBOARD_PASSWORD` | *(Optional)* Plaintext password for dashboard management API authentication. |
|
| `DASHBOARD_PASSWORD` | *(Optional)* Plaintext password for dashboard management API authentication. |
|
||||||
|
|||||||
+13
-1
@@ -5,7 +5,19 @@ The Worker accepts AWS Signature Version 4 in either form:
|
|||||||
- an `Authorization: AWS4-HMAC-SHA256 …` header with `x-amz-date`; or
|
- an `Authorization: AWS4-HMAC-SHA256 …` header with `x-amz-date`; or
|
||||||
- a presigned request with `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-SignedHeaders`, and `X-Amz-Signature` query parameters.
|
- a presigned request with `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-SignedHeaders`, and `X-Amz-Signature` query parameters.
|
||||||
|
|
||||||
The `Credential` access-key ID must equal the Worker's `ACCESS_KEY`; the signing key is derived from `SECRET_KEY`, `REGION`, service `s3`, and the `YYYYMMDD` request date.
|
The `Credential` access-key ID is looked up in the gateway's access-key store (managed from the dashboard under `/api/integration/keys`); the signing key is derived from that key's secret, `REGION`, service `s3`, and the `YYYYMMDD` request date. Unknown or expired key IDs are rejected with `403 AccessDenied`.
|
||||||
|
|
||||||
|
## Access keys
|
||||||
|
|
||||||
|
S3 credentials are named key pairs stored in `AUTH_KV` (`s3-credentials`), managed via the dashboard:
|
||||||
|
|
||||||
|
- Up to 5 live keys — one per integration, revocable independently.
|
||||||
|
- Rotation creates a replacement with the same label; the old key either dies immediately (`graceSeconds: 0`) or keeps authenticating for a grace period of 1 hour, 24 hours, or 7 days.
|
||||||
|
- The legacy `ACCESS_KEY`/`SECRET_KEY` secrets act as **bootstrap only**: on first use they are seeded into the store and are superseded by dashboard-managed keys afterwards.
|
||||||
|
|
||||||
|
Key changes are edge-cached for up to **60 seconds**. A rotated-away or revoked key may continue to authenticate for at most one minute after the change.
|
||||||
|
|
||||||
|
Secrets are stored readable in KV because SigV4 verification must derive the signing key from the raw secret — they cannot be hashed. Treat dashboard sessions as full credential access.
|
||||||
|
|
||||||
## Presigned URL expiry
|
## Presigned URL expiry
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,157 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/AuthError' }
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
/api/integration:
|
||||||
|
get:
|
||||||
|
tags: [Dashboard Status]
|
||||||
|
operationId: getIntegrationInfo
|
||||||
|
summary: Get S3 connection details and access keys
|
||||||
|
description: Everything an external S3 client needs — endpoint, region, path-style flag, bucket list and the current access keys (metadata only, no secrets).
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Integration info with access key metadata.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/IntegrationInfo' }
|
||||||
|
'401':
|
||||||
|
description: Invalid or expired session.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'503':
|
||||||
|
description: Storage root folder or authentication not configured.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
/api/integration/keys:
|
||||||
|
post:
|
||||||
|
tags: [Dashboard Status]
|
||||||
|
operationId: createAccessKey
|
||||||
|
summary: Create a named S3 access key pair
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/CreateAccessKeyRequest' }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Key pair created. The secret is only returned here.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/S3AccessKeyFull' }
|
||||||
|
'400':
|
||||||
|
description: Invalid label or the maximum of 5 live keys was reached.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'401':
|
||||||
|
description: Invalid or expired session.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
/api/integration/keys/{id}/rotate:
|
||||||
|
post:
|
||||||
|
tags: [Dashboard Status]
|
||||||
|
operationId: rotateAccessKey
|
||||||
|
summary: Rotate an access key with an optional grace period
|
||||||
|
description: Creates a replacement key carrying the same label. The old key is deleted immediately when graceSeconds is 0, otherwise it keeps authenticating until the grace period elapses. Changes propagate within 60 seconds due to edge caching.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/RotateAccessKeyRequest' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rotation result with the new full pair and the old key's retirement timestamp.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/RotateAccessKeyResponse' }
|
||||||
|
'400':
|
||||||
|
description: Invalid graceSeconds value.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'401':
|
||||||
|
description: Invalid or expired session.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'404':
|
||||||
|
description: Access key not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
/api/integration/keys/{id}/secret:
|
||||||
|
get:
|
||||||
|
tags: [Dashboard Status]
|
||||||
|
operationId: revealAccessKeySecret
|
||||||
|
summary: Reveal an access key secret
|
||||||
|
description: Rate-limited to 20 reveals per IP per 60 seconds.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The secret access key.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AccessKeySecret' }
|
||||||
|
'401':
|
||||||
|
description: Invalid or expired session.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'404':
|
||||||
|
description: Access key not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'429':
|
||||||
|
description: Too many reveal attempts.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
/api/integration/keys/{id}:
|
||||||
|
delete:
|
||||||
|
tags: [Dashboard Status]
|
||||||
|
operationId: revokeAccessKey
|
||||||
|
summary: Revoke an access key immediately
|
||||||
|
description: Deleting the last remaining key locks out all S3 clients until a new key is created. Propagates within 60 seconds.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Access key revoked.
|
||||||
|
'401':
|
||||||
|
description: Invalid or expired session.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
|
'404':
|
||||||
|
description: Access key not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/AuthError' }
|
||||||
/api/buckets:
|
/api/buckets:
|
||||||
get:
|
get:
|
||||||
tags: [Dashboard Status]
|
tags: [Dashboard Status]
|
||||||
@@ -1216,3 +1367,79 @@ components:
|
|||||||
NextUploadIdMarker: { type: string }
|
NextUploadIdMarker: { type: string }
|
||||||
MaxUploads: { type: integer, example: 1000 }
|
MaxUploads: { type: integer, example: 1000 }
|
||||||
IsTruncated: { type: boolean, example: false }
|
IsTruncated: { type: boolean, example: false }
|
||||||
|
S3AccessKeyMetadata:
|
||||||
|
type: object
|
||||||
|
required: [accessKeyId, label, createdAt, expiresAt]
|
||||||
|
properties:
|
||||||
|
accessKeyId: { type: string, example: GDS7Q2JXK4M9VBTZRWEA }
|
||||||
|
label: { type: string, example: rclone-backup }
|
||||||
|
createdAt: { type: string, format: date-time }
|
||||||
|
expiresAt: { type: string, format: date-time, nullable: true, description: Set when the key is retiring after a rotation. }
|
||||||
|
S3AccessKeyFull:
|
||||||
|
type: object
|
||||||
|
required: [accessKeyId, secretAccessKey, label, createdAt, expiresAt]
|
||||||
|
properties:
|
||||||
|
accessKeyId: { type: string, example: GDS7Q2JXK4M9VBTZRWEA }
|
||||||
|
secretAccessKey: { type: string, description: Only returned on creation and reveal. }
|
||||||
|
label: { type: string, example: rclone-backup }
|
||||||
|
createdAt: { type: string, format: date-time }
|
||||||
|
expiresAt: { type: string, format: date-time, nullable: true }
|
||||||
|
CreateAccessKeyRequest:
|
||||||
|
type: object
|
||||||
|
required: [label]
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 32
|
||||||
|
pattern: '^[a-zA-Z0-9 _-]+$'
|
||||||
|
example: n8n-media-uploads
|
||||||
|
RotateAccessKeyRequest:
|
||||||
|
type: object
|
||||||
|
required: [graceSeconds]
|
||||||
|
properties:
|
||||||
|
graceSeconds:
|
||||||
|
type: integer
|
||||||
|
enum: [0, 3600, 86400, 604800]
|
||||||
|
description: 0 revokes the old key immediately; otherwise the old key keeps working for this long.
|
||||||
|
default: 86400
|
||||||
|
RotateAccessKeyResponse:
|
||||||
|
type: object
|
||||||
|
required: [created, previous]
|
||||||
|
properties:
|
||||||
|
created: { $ref: '#/components/schemas/S3AccessKeyFull' }
|
||||||
|
previous:
|
||||||
|
type: object
|
||||||
|
required: [accessKeyId, expiresAt]
|
||||||
|
properties:
|
||||||
|
accessKeyId: { type: string }
|
||||||
|
expiresAt: { type: string, format: date-time, nullable: true }
|
||||||
|
AccessKeySecret:
|
||||||
|
type: object
|
||||||
|
required: [secretAccessKey]
|
||||||
|
properties:
|
||||||
|
secretAccessKey: { type: string }
|
||||||
|
IntegrationInfo:
|
||||||
|
type: object
|
||||||
|
required: [endpoint, region, forcePathStyle, buckets, publicReadBuckets, multipartEnabled, etagStyle, corsOrigins, docsUrl, openApiUrl, accessKeys, limits]
|
||||||
|
properties:
|
||||||
|
endpoint: { type: string, format: uri, example: https://s3-google-drive.example.workers.dev }
|
||||||
|
region: { type: string, example: auto }
|
||||||
|
forcePathStyle: { type: boolean, const: true }
|
||||||
|
buckets: { type: array, items: { type: string } }
|
||||||
|
publicReadBuckets: { type: array, items: { type: string } }
|
||||||
|
multipartEnabled: { type: boolean }
|
||||||
|
etagStyle: { type: string, enum: [md5, multipart] }
|
||||||
|
corsOrigins: { type: array, items: { type: string } }
|
||||||
|
docsUrl: { type: string, nullable: true }
|
||||||
|
openApiUrl: { type: string, nullable: true }
|
||||||
|
accessKeys:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/S3AccessKeyMetadata' }
|
||||||
|
limits:
|
||||||
|
type: object
|
||||||
|
required: [maxAccessKeys, keyPropagationSeconds, presignExpiryMaxSeconds]
|
||||||
|
properties:
|
||||||
|
maxAccessKeys: { type: integer, example: 5 }
|
||||||
|
keyPropagationSeconds: { type: integer, example: 60, description: Edge-cache TTL — revoke/rotate take up to this long to propagate. }
|
||||||
|
presignExpiryMaxSeconds: { type: integer, example: 604800 }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { findAccessKey } from "./credentials";
|
||||||
import type { S3ErrorCode } from "./s3-errors";
|
import type { S3ErrorCode } from "./s3-errors";
|
||||||
import type { Env } from "./types";
|
import type { Env } from "./types";
|
||||||
|
|
||||||
@@ -172,17 +173,20 @@ export async function verifySignature(request: Request, env: Env): Promise<Verif
|
|||||||
const date = datetime.substring(0, 8);
|
const date = datetime.substring(0, 8);
|
||||||
const credential = isQueryAuth ? (url.searchParams.get("X-Amz-Credential") ?? "") : (/Credential=([^,\s]+)/.exec(headers.get("Authorization") ?? "")?.[1] ?? "");
|
const credential = isQueryAuth ? (url.searchParams.get("X-Amz-Credential") ?? "") : (/Credential=([^,\s]+)/.exec(headers.get("Authorization") ?? "")?.[1] ?? "");
|
||||||
const credentialParts = credential.split("/");
|
const credentialParts = credential.split("/");
|
||||||
if (credentialParts.length !== 5 || credentialParts[0] !== env.ACCESS_KEY || credentialParts.slice(1).join("/") !== `${date}/${env.REGION}/s3/aws4_request`) {
|
if (credentialParts.length !== 5 || credentialParts.slice(1).join("/") !== `${date}/${env.REGION}/s3/aws4_request`) {
|
||||||
return { ok: false, code: "AccessDenied" };
|
return { ok: false, code: "AccessDenied" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = await findAccessKey(env, credentialParts[0]);
|
||||||
|
if (!key) return { ok: false, code: "AccessDenied" };
|
||||||
|
|
||||||
const canonicalRequest = await createCanonicalRequest(request, isQueryAuth);
|
const canonicalRequest = await createCanonicalRequest(request, isQueryAuth);
|
||||||
const hashedCanonicalRequest = await sha256(canonicalRequest);
|
const hashedCanonicalRequest = await sha256(canonicalRequest);
|
||||||
|
|
||||||
const credentialScope = `${date}/${env.REGION}/s3/aws4_request`;
|
const credentialScope = `${date}/${env.REGION}/s3/aws4_request`;
|
||||||
const stringToSign = ["AWS4-HMAC-SHA256", datetime, credentialScope, hashedCanonicalRequest].join("\n");
|
const stringToSign = ["AWS4-HMAC-SHA256", datetime, credentialScope, hashedCanonicalRequest].join("\n");
|
||||||
|
|
||||||
const signingKey = await getSigningKey(env.SECRET_KEY, date, env.REGION, "s3");
|
const signingKey = await getSigningKey(key.secretAccessKey, date, env.REGION, "s3");
|
||||||
const signature = await hmacSha256(signingKey, stringToSign);
|
const signature = await hmacSha256(signingKey, stringToSign);
|
||||||
const signatureHex = bufToHex(signature);
|
const signatureHex = bufToHex(signature);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import type { Env } from "./types";
|
||||||
|
|
||||||
|
export interface S3AccessKey {
|
||||||
|
accessKeyId: string; // "GDS" + 17 chars of [A-Z2-7] (no "/" — the credential scope is split on it)
|
||||||
|
secretAccessKey: string; // base64url of 30 random bytes = 40 chars
|
||||||
|
label: string; // 1-32 chars, [a-zA-Z0-9 _-]
|
||||||
|
createdAt: string; // ISO
|
||||||
|
expiresAt: string | null; // ISO when retiring after a rotation, else null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialStore {
|
||||||
|
version: 1;
|
||||||
|
keys: S3AccessKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CREDENTIALS_KV_KEY = "s3-credentials";
|
||||||
|
export const MAX_ACCESS_KEYS = 5;
|
||||||
|
// KV cacheTtl minimum is 60s. Signature verification reads this store on every S3 request,
|
||||||
|
// so it must be edge-cached — consequence: revoke/rotate take up to 60s to propagate globally.
|
||||||
|
export const CREDENTIALS_CACHE_TTL = 60;
|
||||||
|
|
||||||
|
const ACCESS_KEY_ID_PREFIX = "GDS";
|
||||||
|
const ACCESS_KEY_ID_RANDOM_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; // RFC 4648 base32 alphabet, no "/"
|
||||||
|
const LABEL_PATTERN = /^[a-zA-Z0-9 _-]{1,32}$/;
|
||||||
|
/** Grace periods accepted by rotateAccessKey: revoke now, 1h, 24h, 7d. */
|
||||||
|
export const ALLOWED_GRACE_SECONDS = [0, 3600, 86400, 604800] as const;
|
||||||
|
export type GraceSeconds = (typeof ALLOWED_GRACE_SECONDS)[number];
|
||||||
|
|
||||||
|
export class CredentialsError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CredentialsError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAccessKeyId(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(17));
|
||||||
|
let id = ACCESS_KEY_ID_PREFIX;
|
||||||
|
for (const byte of bytes) {
|
||||||
|
id += ACCESS_KEY_ID_RANDOM_CHARS[byte % ACCESS_KEY_ID_RANDOM_CHARS.length];
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSecretAccessKey(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(30));
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLabel(label: unknown): label is string {
|
||||||
|
return typeof label === "string" && LABEL_PATTERN.test(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpiredKeys(keys: S3AccessKey[]): S3AccessKey[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return keys.filter((key) => key.expiresAt === null || new Date(key.expiresAt).getTime() > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistCredentials(env: Env, keys: S3AccessKey[]): Promise<void> {
|
||||||
|
const store: CredentialStore = { version: 1, keys };
|
||||||
|
await env.AUTH_KV.put(CREDENTIALS_KV_KEY, JSON.stringify(store));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the credential store from KV with a 60s edge cache.
|
||||||
|
*
|
||||||
|
* On first use (no stored value) the store is seeded from the legacy `ACCESS_KEY`/`SECRET_KEY`
|
||||||
|
* secrets so existing deployments keep working unchanged. On a KV read *throw* the error
|
||||||
|
* propagates — fail closed so a revoked key can never silently come back to life via env fallback.
|
||||||
|
*/
|
||||||
|
export async function loadCredentials(env: Env): Promise<S3AccessKey[]> {
|
||||||
|
let raw: string | null;
|
||||||
|
try {
|
||||||
|
raw = await env.AUTH_KV.get(CREDENTIALS_KV_KEY, { type: "text", cacheTtl: CREDENTIALS_CACHE_TTL });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
message: "failed to load s3-credentials from KV",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw !== null) {
|
||||||
|
try {
|
||||||
|
const store = JSON.parse(raw) as CredentialStore;
|
||||||
|
if (!Array.isArray(store.keys)) throw new Error("malformed credential store");
|
||||||
|
const live = pruneExpiredKeys(store.keys);
|
||||||
|
if (live.length !== store.keys.length) {
|
||||||
|
await persistCredentials(env, live);
|
||||||
|
}
|
||||||
|
return live;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
message: "corrupt s3-credentials store in KV",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
throw new CredentialsError("Credential store is corrupt", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed from legacy bootstrap secrets on first use.
|
||||||
|
if (env.ACCESS_KEY && env.SECRET_KEY) {
|
||||||
|
const seeded: S3AccessKey = {
|
||||||
|
accessKeyId: env.ACCESS_KEY,
|
||||||
|
secretAccessKey: env.SECRET_KEY,
|
||||||
|
label: "bootstrap",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
await persistCredentials(env, [seeded]);
|
||||||
|
return [seeded];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves an access key by id, rejecting keys whose grace period has elapsed. */
|
||||||
|
export async function findAccessKey(env: Env, accessKeyId: string): Promise<S3AccessKey | null> {
|
||||||
|
const keys = await loadCredentials(env);
|
||||||
|
const key = keys.find((candidate) => candidate.accessKeyId === accessKeyId);
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.expiresAt && new Date(key.expiresAt).getTime() <= Date.now()) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists access keys without their secrets. */
|
||||||
|
export async function listAccessKeys(env: Env): Promise<Omit<S3AccessKey, "secretAccessKey">[]> {
|
||||||
|
const keys = await loadCredentials(env);
|
||||||
|
return keys.map(({ secretAccessKey: _secret, ...meta }) => meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the full key record including its secret. */
|
||||||
|
export async function revealAccessKey(env: Env, accessKeyId: string): Promise<S3AccessKey | null> {
|
||||||
|
const keys = await loadCredentials(env);
|
||||||
|
return keys.find((candidate) => candidate.accessKeyId === accessKeyId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccessKey(env: Env, label: string): Promise<S3AccessKey> {
|
||||||
|
if (!validateLabel(label)) {
|
||||||
|
throw new CredentialsError("Label must be 1-32 characters using letters, numbers, spaces, '_' or '-'");
|
||||||
|
}
|
||||||
|
const keys = await loadCredentials(env);
|
||||||
|
if (keys.length >= MAX_ACCESS_KEYS) {
|
||||||
|
throw new CredentialsError(`Maximum of ${MAX_ACCESS_KEYS} access keys reached. Revoke or rotate an existing key first.`);
|
||||||
|
}
|
||||||
|
const key: S3AccessKey = {
|
||||||
|
accessKeyId: generateAccessKeyId(),
|
||||||
|
secretAccessKey: generateSecretAccessKey(),
|
||||||
|
label,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
await persistCredentials(env, [...keys, key]);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a replacement key carrying the same label and retires the old one:
|
||||||
|
* `graceSeconds === 0` deletes it immediately, otherwise it keeps authenticating until
|
||||||
|
* `expiresAt` elapses. Allowed grace values are 0 / 1h / 24h / 7d.
|
||||||
|
*/
|
||||||
|
export async function rotateAccessKey(env: Env, accessKeyId: string, graceSeconds: GraceSeconds): Promise<{ created: S3AccessKey; previous: { accessKeyId: string; expiresAt: string | null } }> {
|
||||||
|
if (!ALLOWED_GRACE_SECONDS.includes(graceSeconds)) {
|
||||||
|
throw new CredentialsError(`graceSeconds must be one of ${ALLOWED_GRACE_SECONDS.join(", ")}`);
|
||||||
|
}
|
||||||
|
const keys = await loadCredentials(env);
|
||||||
|
const index = keys.findIndex((candidate) => candidate.accessKeyId === accessKeyId);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new CredentialsError("Access key not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = keys[index];
|
||||||
|
const created: S3AccessKey = {
|
||||||
|
accessKeyId: generateAccessKeyId(),
|
||||||
|
secretAccessKey: generateSecretAccessKey(),
|
||||||
|
label: previous.label,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextKeys = [...keys];
|
||||||
|
if (graceSeconds === 0) {
|
||||||
|
nextKeys.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
nextKeys[index] = { ...previous, expiresAt: new Date(Date.now() + graceSeconds * 1000).toISOString() };
|
||||||
|
}
|
||||||
|
nextKeys.push(created);
|
||||||
|
|
||||||
|
await persistCredentials(env, nextKeys);
|
||||||
|
return {
|
||||||
|
created,
|
||||||
|
previous: { accessKeyId: previous.accessKeyId, expiresAt: nextKeys[index]?.expiresAt ?? null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAccessKey(env: Env, accessKeyId: string): Promise<void> {
|
||||||
|
const keys = await loadCredentials(env);
|
||||||
|
const index = keys.findIndex((candidate) => candidate.accessKeyId === accessKeyId);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new CredentialsError("Access key not found", 404);
|
||||||
|
}
|
||||||
|
const nextKeys = keys.filter((_, i) => i !== index);
|
||||||
|
await persistCredentials(env, nextKeys);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { jsonResponse } from "./auth-api";
|
||||||
|
import { getBucketRegistry } from "./bucket-registry";
|
||||||
|
import { ALLOWED_GRACE_SECONDS, CredentialsError, createAccessKey, type GraceSeconds, listAccessKeys, loadCredentials, revealAccessKey, revokeAccessKey, rotateAccessKey } from "./credentials";
|
||||||
|
import type { Env } from "./types";
|
||||||
|
|
||||||
|
const REVEAL_FAIL_PREFIX = "reveal-fail:";
|
||||||
|
const REVEAL_MAX_ATTEMPTS = 20;
|
||||||
|
const REVEAL_WINDOW_SECONDS = 60;
|
||||||
|
|
||||||
|
interface CreateKeyBody {
|
||||||
|
label?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RotateKeyBody {
|
||||||
|
graceSeconds?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationAccessKeyMetadata {
|
||||||
|
accessKeyId: string;
|
||||||
|
label: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationInfoResponse {
|
||||||
|
endpoint: string;
|
||||||
|
region: string;
|
||||||
|
forcePathStyle: true;
|
||||||
|
buckets: string[];
|
||||||
|
publicReadBuckets: string[];
|
||||||
|
multipartEnabled: boolean;
|
||||||
|
etagStyle: "md5" | "multipart";
|
||||||
|
corsOrigins: string[];
|
||||||
|
docsUrl: string | null;
|
||||||
|
openApiUrl: string | null;
|
||||||
|
accessKeys: IntegrationAccessKeyMetadata[];
|
||||||
|
limits: {
|
||||||
|
maxAccessKeys: number;
|
||||||
|
keyPropagationSeconds: number;
|
||||||
|
presignExpiryMaxSeconds: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRevealRateLimit(request: Request, env: Env): Promise<Response | null> {
|
||||||
|
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
|
||||||
|
const failKey = `${REVEAL_FAIL_PREFIX}${ip}`;
|
||||||
|
const attemptsStr = await env.AUTH_KV.get(failKey);
|
||||||
|
const attempts = attemptsStr ? parseInt(attemptsStr, 10) : 0;
|
||||||
|
|
||||||
|
if (attempts >= REVEAL_MAX_ATTEMPTS) {
|
||||||
|
return jsonResponse({ message: "Too many secret reveals. Please try again later." }, 429, { "Retry-After": REVEAL_WINDOW_SECONDS.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
await env.AUTH_KV.put(failKey, (attempts + 1).toString(), { expirationTtl: REVEAL_WINDOW_SECONDS });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialsErrorResponse(err: unknown): Response {
|
||||||
|
if (err instanceof CredentialsError) {
|
||||||
|
return jsonResponse({ message: err.message }, err.status);
|
||||||
|
}
|
||||||
|
const status = (err as { status?: number }).status || 500;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return jsonResponse({ message }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGraceSeconds(body: RotateKeyBody): GraceSeconds | null {
|
||||||
|
const value = body.graceSeconds;
|
||||||
|
if (typeof value !== "number" || !Number.isInteger(value) || !ALLOWED_GRACE_SECONDS.includes(value as GraceSeconds)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as GraceSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handles every `/api/integration*` route. Session verification already happened in status-api. */
|
||||||
|
export async function handleIntegrationRoutes(request: Request, env: Env, subSegments: string[]): Promise<Response> {
|
||||||
|
const method = request.method;
|
||||||
|
|
||||||
|
// GET /api/integration
|
||||||
|
if (method === "GET" && subSegments.length === 0) {
|
||||||
|
try {
|
||||||
|
let buckets: string[] = [];
|
||||||
|
let publicReadBuckets: string[] = [];
|
||||||
|
try {
|
||||||
|
const registry = await getBucketRegistry(env);
|
||||||
|
buckets = registry.map((b) => b.name);
|
||||||
|
publicReadBuckets = registry.filter((b) => b.publicRead).map((b) => b.name);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load bucket registry for integration info", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const corsOrigins = env.CORS_ALLOWED_ORIGINS
|
||||||
|
? env.CORS_ALLOWED_ORIGINS.split(",")
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const docsEnabled = env.ENABLE_DOCS !== "false";
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
|
||||||
|
const payload: IntegrationInfoResponse = {
|
||||||
|
endpoint: origin,
|
||||||
|
region: env.REGION || "auto",
|
||||||
|
forcePathStyle: true,
|
||||||
|
buckets,
|
||||||
|
publicReadBuckets,
|
||||||
|
multipartEnabled: env.ALLOW_MULTIPART === "true",
|
||||||
|
etagStyle: env.ETAG_STYLE === "multipart" ? "multipart" : "md5",
|
||||||
|
corsOrigins,
|
||||||
|
docsUrl: docsEnabled ? `${origin}/docs` : null,
|
||||||
|
openApiUrl: docsEnabled ? `${origin}/openapi.yaml` : null,
|
||||||
|
accessKeys: await listAccessKeys(env),
|
||||||
|
limits: {
|
||||||
|
maxAccessKeys: 5,
|
||||||
|
keyPropagationSeconds: 60,
|
||||||
|
presignExpiryMaxSeconds: 7 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return jsonResponse(payload, 200);
|
||||||
|
} catch (err) {
|
||||||
|
return credentialsErrorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/integration/keys
|
||||||
|
if (method === "POST" && subSegments[0] === "keys" && subSegments.length === 1) {
|
||||||
|
let body: CreateKeyBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as CreateKeyBody;
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ message: "Invalid JSON body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = await createAccessKey(env, typeof body.label === "string" ? body.label : "");
|
||||||
|
return jsonResponse(key, 201);
|
||||||
|
} catch (err) {
|
||||||
|
return credentialsErrorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/integration/keys/:id/rotate
|
||||||
|
if (method === "POST" && subSegments[0] === "keys" && subSegments[2] === "rotate" && subSegments.length === 3) {
|
||||||
|
let body: RotateKeyBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as RotateKeyBody;
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ message: "Invalid JSON body" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const graceSeconds = parseGraceSeconds(body);
|
||||||
|
if (graceSeconds === null) {
|
||||||
|
return jsonResponse({ message: `graceSeconds must be one of ${ALLOWED_GRACE_SECONDS.join(", ")}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await rotateAccessKey(env, decodeURIComponent(subSegments[1]), graceSeconds);
|
||||||
|
return jsonResponse(result, 200);
|
||||||
|
} catch (err) {
|
||||||
|
return credentialsErrorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/integration/keys/:id/secret
|
||||||
|
if (method === "GET" && subSegments[0] === "keys" && subSegments[2] === "secret" && subSegments.length === 3) {
|
||||||
|
try {
|
||||||
|
const limited = await checkRevealRateLimit(request, env);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const key = await revealAccessKey(env, decodeURIComponent(subSegments[1]));
|
||||||
|
if (!key) return jsonResponse({ message: "Access key not found" }, 404);
|
||||||
|
return jsonResponse({ secretAccessKey: key.secretAccessKey }, 200);
|
||||||
|
} catch (err) {
|
||||||
|
return credentialsErrorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/integration/keys/:id
|
||||||
|
if (method === "DELETE" && subSegments[0] === "keys" && subSegments.length === 2) {
|
||||||
|
try {
|
||||||
|
await revokeAccessKey(env, decodeURIComponent(subSegments[1]));
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (err) {
|
||||||
|
return credentialsErrorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ message: "Method Not Allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live-key count for the dashboard status card — computed from the store rather than env. */
|
||||||
|
export async function hasLiveS3Keys(env: Env): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await loadCredentials(env)).length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -2,6 +2,7 @@ import { jsonResponse, verifySessionToken } from "./auth-api";
|
|||||||
import { handleBucketRoutes, handleImportCandidatesRoute, handleImportRoute } from "./bucket-api";
|
import { handleBucketRoutes, handleImportCandidatesRoute, handleImportRoute } from "./bucket-api";
|
||||||
import { getBucketRegistry } from "./bucket-registry";
|
import { getBucketRegistry } from "./bucket-registry";
|
||||||
import { getAccessToken, getDriveAbout, getRootFolderId, listObjects } from "./google-drive";
|
import { getAccessToken, getDriveAbout, getRootFolderId, listObjects } from "./google-drive";
|
||||||
|
import { handleIntegrationRoutes, hasLiveS3Keys } from "./integration-api";
|
||||||
import { handleObjectRoutes, handleTicketDownload } from "./object-api";
|
import { handleObjectRoutes, handleTicketDownload } from "./object-api";
|
||||||
import type { DriveAbout, Env } from "./types";
|
import type { DriveAbout, Env } from "./types";
|
||||||
|
|
||||||
@@ -100,6 +101,10 @@ export async function handleApi(request: Request, env: Env, subPath: string): Pr
|
|||||||
return await handleStatus(env);
|
return await handleStatus(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "integration") {
|
||||||
|
return await handleIntegrationRoutes(request, env, remainingSegments);
|
||||||
|
}
|
||||||
|
|
||||||
if (firstSegment === "buckets") {
|
if (firstSegment === "buckets") {
|
||||||
if (request.method === "GET" && remainingSegments.length === 0) {
|
if (request.method === "GET" && remainingSegments.length === 0) {
|
||||||
const forceRefresh = url.searchParams.get("refresh") === "1";
|
const forceRefresh = url.searchParams.get("refresh") === "1";
|
||||||
@@ -181,6 +186,8 @@ async function handleStatus(env: Env): Promise<Response> {
|
|||||||
|
|
||||||
const driveConnected = driveAbout !== null && driveError === null;
|
const driveConnected = driveAbout !== null && driveError === null;
|
||||||
|
|
||||||
|
const s3KeysConfigured = await hasLiveS3Keys(env);
|
||||||
|
|
||||||
const payload: GatewayStatusResponse = {
|
const payload: GatewayStatusResponse = {
|
||||||
gateway: {
|
gateway: {
|
||||||
status: driveConnected ? "ok" : "degraded",
|
status: driveConnected ? "ok" : "degraded",
|
||||||
@@ -197,7 +204,7 @@ async function handleStatus(env: Env): Promise<Response> {
|
|||||||
},
|
},
|
||||||
corsOrigins,
|
corsOrigins,
|
||||||
credentials: {
|
credentials: {
|
||||||
s3Keys: Boolean(env.ACCESS_KEY && env.SECRET_KEY),
|
s3Keys: s3KeysConfigured,
|
||||||
googleOAuth: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET && env.GOOGLE_REFRESH_TOKEN),
|
googleOAuth: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET && env.GOOGLE_REFRESH_TOKEN),
|
||||||
dashboardPassword: Boolean(env.DASHBOARD_PASSWORD),
|
dashboardPassword: Boolean(env.DASHBOARD_PASSWORD),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { AwsClient } from "aws4fetch";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { sha256 } from "../src/aws-signature";
|
||||||
|
import { CREDENTIALS_KV_KEY } from "../src/credentials";
|
||||||
|
import worker from "../src/index";
|
||||||
|
import type { IntegrationInfoResponse } from "../src/integration-api";
|
||||||
|
import type { Env, S3AccessKey } from "../src/types";
|
||||||
|
import { FakeDrive } from "./fake-drive";
|
||||||
|
|
||||||
|
import { env } from "cloudflare:test";
|
||||||
|
|
||||||
|
const ENV = env as unknown as Env;
|
||||||
|
const ENDPOINT = "https://s3-api.example.com";
|
||||||
|
const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
async function getValidToken(): Promise<string> {
|
||||||
|
const correctHash = await sha256("test-dashboard-password");
|
||||||
|
const loginRes = await worker.fetch(
|
||||||
|
new Request(`${ENDPOINT}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": "127.0.0.1" },
|
||||||
|
body: JSON.stringify({ passwordHash: correctHash }),
|
||||||
|
}),
|
||||||
|
ENV,
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(loginRes.status).toBe(200);
|
||||||
|
const data = (await loginRes.json()) as { token: string };
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authed(path: string, method: string, token: string, body?: unknown): Request {
|
||||||
|
return new Request(`${ENDPOINT}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs an S3 request with an arbitrary key pair. */
|
||||||
|
async function signedWith(accessKeyId: string, secretAccessKey: string, path: string, init: RequestInit): Promise<Request> {
|
||||||
|
const aws = new AwsClient({ accessKeyId, secretAccessKey, region: ENV.REGION, service: "s3" });
|
||||||
|
return aws.sign(`${ENDPOINT}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { "x-amz-content-sha256": "UNSIGNED-PAYLOAD", ...init.headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signed(path: string, init: RequestInit): Promise<Request> {
|
||||||
|
return signedWith(ENV.ACCESS_KEY, ENV.SECRET_KEY, path, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: string;
|
||||||
|
let drive: FakeDrive;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await ENV.AUTH_KV.delete(CREDENTIALS_KV_KEY);
|
||||||
|
for (const { name } of (await ENV.AUTH_KV.list({ prefix: "reveal-fail:" })).keys) await ENV.AUTH_KV.delete(name);
|
||||||
|
token = await getValidToken();
|
||||||
|
|
||||||
|
drive = new FakeDrive();
|
||||||
|
const rootFolderId = "folder-root";
|
||||||
|
drive.folders.set(rootFolderId, { id: rootFolderId, name: "s3-storage", parent: "root" });
|
||||||
|
const testBucketId = "folder-test-bucket";
|
||||||
|
drive.folders.set(testBucketId, { id: testBucketId, name: "test-bucket", parent: rootFolderId });
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((input, init) => drive.handle(input, init)),
|
||||||
|
);
|
||||||
|
await ENV.AUTH_KV.delete("google_access_token");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Integration API routes (/api/integration*)", () => {
|
||||||
|
it("returns 401 without a session token", async () => {
|
||||||
|
const res = await worker.fetch(new Request(`${ENDPOINT}/api/integration`), ENV, CTX);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
|
||||||
|
const invalidRes = await worker.fetch(new Request(`${ENDPOINT}/api/integration`, { headers: { Authorization: "Bearer nope" } }), ENV, CTX);
|
||||||
|
expect(invalidRes.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds the store from bootstrap env secrets on first use and keeps them working", async () => {
|
||||||
|
// A signed request with the legacy env pair must pass before anything exists in KV.
|
||||||
|
const res = await worker.fetch(await signed("/test-bucket/bootstrap.txt", { method: "PUT", body: "hi" }), ENV, CTX);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// The store is now seeded with the bootstrap key.
|
||||||
|
const stored = await ENV.AUTH_KV.get(CREDENTIALS_KV_KEY);
|
||||||
|
expect(stored).not.toBeNull();
|
||||||
|
const keys = (JSON.parse(stored as string) as { keys: S3AccessKey[] }).keys;
|
||||||
|
expect(keys).toHaveLength(1);
|
||||||
|
expect(keys[0].accessKeyId).toBe(ENV.ACCESS_KEY);
|
||||||
|
expect(keys[0].secretAccessKey).toBe(ENV.SECRET_KEY);
|
||||||
|
expect(keys[0].label).toBe("bootstrap");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns integration info with metadata-only access keys", async () => {
|
||||||
|
const createRes = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "info-test" }), ENV, CTX);
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
|
||||||
|
const res = await worker.fetch(authed("/api/integration", "GET", token), ENV, CTX);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as IntegrationInfoResponse;
|
||||||
|
expect(data.endpoint).toBe(ENDPOINT);
|
||||||
|
expect(data.region).toBe("auto");
|
||||||
|
expect(data.forcePathStyle).toBe(true);
|
||||||
|
expect(Array.isArray(data.buckets)).toBe(true);
|
||||||
|
expect(data.multipartEnabled).toBe(true);
|
||||||
|
expect(data.docsUrl).toBe(`${ENDPOINT}/docs`);
|
||||||
|
expect(data.openApiUrl).toBe(`${ENDPOINT}/openapi.yaml`);
|
||||||
|
expect(data.limits.maxAccessKeys).toBe(5);
|
||||||
|
expect(data.limits.keyPropagationSeconds).toBe(60);
|
||||||
|
// Creating a key seeds the bootstrap key from env secrets, so both are listed.
|
||||||
|
expect(data.accessKeys).toHaveLength(2);
|
||||||
|
expect(data.accessKeys.map((k) => k.label)).toContain("info-test");
|
||||||
|
expect(data.accessKeys.find((k) => k.label === "info-test")?.accessKeyId.startsWith("GDS")).toBe(true);
|
||||||
|
for (const accessKey of data.accessKeys) {
|
||||||
|
expect((accessKey as unknown as Record<string, unknown>).secretAccessKey).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a key that authenticates against the gateway", async () => {
|
||||||
|
const createRes = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "cli-key" }), ENV, CTX);
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
const key = (await createRes.json()) as S3AccessKey;
|
||||||
|
expect(key.accessKeyId.startsWith("GDS")).toBe(true);
|
||||||
|
expect(key.secretAccessKey).toHaveLength(40);
|
||||||
|
|
||||||
|
const res = await worker.fetch(await signedWith(key.accessKeyId, key.secretAccessKey, "/test-bucket/new-key.txt", { method: "PUT", body: "from new key" }), ENV, CTX);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid labels and enforces MAX_ACCESS_KEYS", async () => {
|
||||||
|
const badLabel = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "bad label!" }), ENV, CTX);
|
||||||
|
expect(badLabel.status).toBe(400);
|
||||||
|
|
||||||
|
const emptyLabel = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "" }), ENV, CTX);
|
||||||
|
expect(emptyLabel.status).toBe(400);
|
||||||
|
|
||||||
|
// Bootstrap seeding happens on first load; fill the rest of the slots.
|
||||||
|
const probe = await worker.fetch(await signed("/test-bucket/probe.txt", { method: "PUT", body: "x" }), ENV, CTX);
|
||||||
|
expect(probe.status).toBe(200);
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const res = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: `key-${i}` }), ENV, CTX);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overflow = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "one-too-many" }), ENV, CTX);
|
||||||
|
expect(overflow.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rotates with a grace period so both keys work, then expires the old one", async () => {
|
||||||
|
const probe = await worker.fetch(await signed("/test-bucket/grace-probe.txt", { method: "PUT", body: "x" }), ENV, CTX);
|
||||||
|
expect(probe.status).toBe(200);
|
||||||
|
|
||||||
|
const createRes = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "rotating" }), ENV, CTX);
|
||||||
|
const original = (await createRes.json()) as S3AccessKey;
|
||||||
|
|
||||||
|
const rotateRes = await worker.fetch(authed(`/api/integration/keys/${original.accessKeyId}/rotate`, "POST", token, { graceSeconds: 3600 }), ENV, CTX);
|
||||||
|
expect(rotateRes.status).toBe(200);
|
||||||
|
const rotation = (await rotateRes.json()) as { created: S3AccessKey; previous: { accessKeyId: string; expiresAt: string | null } };
|
||||||
|
expect(rotation.created.label).toBe("rotating");
|
||||||
|
expect(rotation.previous.expiresAt).not.toBeNull();
|
||||||
|
|
||||||
|
// Both pairs work during the grace window.
|
||||||
|
const oldRes = await worker.fetch(await signedWith(original.accessKeyId, original.secretAccessKey, "/test-bucket/during-grace.txt", { method: "PUT", body: "old" }), ENV, CTX);
|
||||||
|
expect(oldRes.status).toBe(200);
|
||||||
|
const newRes = await worker.fetch(await signedWith(rotation.created.accessKeyId, rotation.created.secretAccessKey, "/test-bucket/during-grace.txt", { method: "PUT", body: "new" }), ENV, CTX);
|
||||||
|
expect(newRes.status).toBe(200);
|
||||||
|
|
||||||
|
// Force-expire the old key; it must stop authenticating.
|
||||||
|
const stored = JSON.parse((await ENV.AUTH_KV.get(CREDENTIALS_KV_KEY)) as string) as { keys: S3AccessKey[] };
|
||||||
|
stored.keys = stored.keys.map((k) => (k.accessKeyId === original.accessKeyId ? { ...k, expiresAt: new Date(Date.now() - 1000).toISOString() } : k));
|
||||||
|
await ENV.AUTH_KV.put(CREDENTIALS_KV_KEY, JSON.stringify(stored));
|
||||||
|
|
||||||
|
const expiredRes = await worker.fetch(await signedWith(original.accessKeyId, original.secretAccessKey, "/test-bucket/after-grace.txt", { method: "PUT", body: "late" }), ENV, CTX);
|
||||||
|
expect(expiredRes.status).toBe(403);
|
||||||
|
const stillNewRes = await worker.fetch(await signedWith(rotation.created.accessKeyId, rotation.created.secretAccessKey, "/test-bucket/after-grace.txt", { method: "PUT", body: "fine" }), ENV, CTX);
|
||||||
|
expect(stillNewRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rotates with graceSeconds 0 so the old key is rejected immediately", async () => {
|
||||||
|
const probe = await worker.fetch(await signed("/test-bucket/revoke-now-probe.txt", { method: "PUT", body: "x" }), ENV, CTX);
|
||||||
|
expect(probe.status).toBe(200);
|
||||||
|
|
||||||
|
const createRes = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "instant-rotate" }), ENV, CTX);
|
||||||
|
const original = (await createRes.json()) as S3AccessKey;
|
||||||
|
|
||||||
|
const rotateRes = await worker.fetch(authed(`/api/integration/keys/${original.accessKeyId}/rotate`, "POST", token, { graceSeconds: 0 }), ENV, CTX);
|
||||||
|
expect(rotateRes.status).toBe(200);
|
||||||
|
const rotation = (await rotateRes.json()) as { created: S3AccessKey; previous: { accessKeyId: string; expiresAt: string | null } };
|
||||||
|
expect(rotation.previous.expiresAt).toBeNull();
|
||||||
|
|
||||||
|
const oldRes = await worker.fetch(await signedWith(original.accessKeyId, original.secretAccessKey, "/test-bucket/gone.txt", { method: "PUT", body: "nope" }), ENV, CTX);
|
||||||
|
expect(oldRes.status).toBe(403);
|
||||||
|
const newRes = await worker.fetch(await signedWith(rotation.created.accessKeyId, rotation.created.secretAccessKey, "/test-bucket/here.txt", { method: "PUT", body: "yes" }), ENV, CTX);
|
||||||
|
expect(newRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid graceSeconds values", async () => {
|
||||||
|
const probe = await worker.fetch(await signed("/test-bucket/bad-grace-probe.txt", { method: "PUT", body: "x" }), ENV, CTX);
|
||||||
|
expect(probe.status).toBe(200);
|
||||||
|
|
||||||
|
const createRes = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "grace-check" }), ENV, CTX);
|
||||||
|
const key = (await createRes.json()) as S3AccessKey;
|
||||||
|
|
||||||
|
const res = await worker.fetch(authed(`/api/integration/keys/${key.accessKeyId}/rotate`, "POST", token, { graceSeconds: 1234 }), ENV, CTX);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes a key so its S3 requests are rejected and it disappears from the list", async () => {
|
||||||
|
const probe = await worker.fetch(await signed("/test-bucket/revoke-probe.txt", { method: "PUT", body: "x" }), ENV, CTX);
|
||||||
|
expect(probe.status).toBe(200);
|
||||||
|
|
||||||
|
const createRes = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "short-lived" }), ENV, CTX);
|
||||||
|
const key = (await createRes.json()) as S3AccessKey;
|
||||||
|
|
||||||
|
const deleteRes = await worker.fetch(authed(`/api/integration/keys/${key.accessKeyId}`, "DELETE", token), ENV, CTX);
|
||||||
|
expect(deleteRes.status).toBe(204);
|
||||||
|
|
||||||
|
const revokedRes = await worker.fetch(await signedWith(key.accessKeyId, key.secretAccessKey, "/test-bucket/revoked.txt", { method: "PUT", body: "nope" }), ENV, CTX);
|
||||||
|
expect(revokedRes.status).toBe(403);
|
||||||
|
|
||||||
|
const listRes = await worker.fetch(authed("/api/integration", "GET", token), ENV, CTX);
|
||||||
|
const data = (await listRes.json()) as IntegrationInfoResponse;
|
||||||
|
expect(data.accessKeys.find((k) => k.accessKeyId === key.accessKeyId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals a secret through the dedicated route", async () => {
|
||||||
|
const probe = await worker.fetch(await signed("/test-bucket/reveal-probe.txt", { method: "PUT", body: "x" }), ENV, CTX);
|
||||||
|
expect(probe.status).toBe(200);
|
||||||
|
|
||||||
|
const createRes = await worker.fetch(authed("/api/integration/keys", "POST", token, { label: "reveal-me" }), ENV, CTX);
|
||||||
|
const key = (await createRes.json()) as S3AccessKey;
|
||||||
|
|
||||||
|
const revealRes = await worker.fetch(authed(`/api/integration/keys/${key.accessKeyId}/secret`, "GET", token), ENV, CTX);
|
||||||
|
expect(revealRes.status).toBe(200);
|
||||||
|
const data = (await revealRes.json()) as { secretAccessKey: string };
|
||||||
|
expect(data.secretAccessKey).toBe(key.secretAccessKey);
|
||||||
|
|
||||||
|
const missingRes = await worker.fetch(authed("/api/integration/keys/GDSUNKNOWNKEY000000/secret", "GET", token), ENV, CTX);
|
||||||
|
expect(missingRes.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 405 for unsupported method/path combinations", async () => {
|
||||||
|
const res = await worker.fetch(authed("/api/integration/keys/some-id", "PATCH", token), ENV, CTX);
|
||||||
|
expect(res.status).toBe(405);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user