This repository has been archived on 2026-03-27. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drive/server/wopi/token.ts
Sienna Meridian Satterwhite 58237d9e44 Initial commit — Drive, an S3 file browser with WOPI editing
Lightweight replacement for the upstream La Suite Numérique drive
(Django/Celery/Next.js) built as a single Deno binary.

Server (Deno + Hono):
- S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs
- WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks)
- Ory Kratos session auth + CSRF protection
- Ory Keto permission model (OPL namespaces, not yet wired to routes)
- PostgreSQL metadata with recursive folder sizes
- S3 backfill API for registering files uploaded outside the UI
- OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED)

Frontend (React 19 + Cunningham v4 + react-aria):
- File browser with GridList, keyboard nav, multi-select
- Collabora editor iframe (full-screen, form POST, postMessage)
- Profile menu, waffle menu, drag-drop upload, asset type badges
- La Suite integration service theming (runtime CSS)

Testing (549 tests):
- 235 server unit tests (Deno) — 90%+ coverage
- 278 UI unit tests (Vitest) — 90%+ coverage
- 11 E2E tests (Playwright)
- 12 integration service tests (Playwright)
- 13 WOPI integration tests (Playwright + Docker Compose + Collabora)

MIT licensed.
2026-03-25 18:28:37 +00:00

133 lines
4.1 KiB
TypeScript

/**
* JWT-based WOPI access tokens using Web Crypto (HMAC-SHA256).
*/
const TEST_MODE = Deno.env.get("DRIVER_TEST_MODE") === "1";
const WOPI_JWT_SECRET = Deno.env.get("WOPI_JWT_SECRET") ?? (TEST_MODE ? "test-wopi-secret" : "");
if (!WOPI_JWT_SECRET && !TEST_MODE) {
throw new Error("WOPI_JWT_SECRET must be set in production");
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// ── Base64url helpers ───────────────────────────────────────────────────────
function base64urlEncode(data: Uint8Array): string {
const binString = Array.from(data, (b) => String.fromCharCode(b)).join("");
return btoa(binString).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64urlDecode(str: string): Uint8Array {
let s = str.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
const binString = atob(s);
return Uint8Array.from(binString, (c) => c.charCodeAt(0));
}
// ── HMAC helpers ────────────────────────────────────────────────────────────
async function hmacSign(data: Uint8Array, secret: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, data as unknown as BufferSource);
return new Uint8Array(sig);
}
async function hmacVerify(data: Uint8Array, signature: Uint8Array, secret: string): Promise<boolean> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
return crypto.subtle.verify("HMAC", key, signature as unknown as BufferSource, data as unknown as BufferSource);
}
// ── Token types ─────────────────────────────────────────────────────────────
export interface WopiTokenPayload {
/** File UUID */
fid: string;
/** User ID (Kratos identity) */
uid: string;
/** User display name */
unm: string;
/** Can write */
wr: boolean;
/** Issued at (unix seconds) */
iat: number;
/** Expires at (unix seconds) */
exp: number;
}
// ── Public API ──────────────────────────────────────────────────────────────
const DEFAULT_EXPIRES_SECONDS = 8 * 3600; // 8 hours
export async function generateWopiToken(
fileId: string,
userId: string,
userName: string,
canWrite: boolean,
expiresInSeconds = DEFAULT_EXPIRES_SECONDS,
secret = WOPI_JWT_SECRET,
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload: WopiTokenPayload = {
fid: fileId,
uid: userId,
unm: userName,
wr: canWrite,
iat: now,
exp: now + expiresInSeconds,
};
const header = base64urlEncode(
encoder.encode(JSON.stringify({ alg: "HS256", typ: "JWT" })),
);
const body = base64urlEncode(
encoder.encode(JSON.stringify(payload)),
);
const sigInput = encoder.encode(`${header}.${body}`);
const sig = await hmacSign(sigInput, secret);
return `${header}.${body}.${base64urlEncode(sig)}`;
}
export async function verifyWopiToken(
token: string,
secret = WOPI_JWT_SECRET,
): Promise<WopiTokenPayload | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [header, body, sig] = parts;
// Verify signature
const sigInput = encoder.encode(`${header}.${body}`);
const sigBytes = base64urlDecode(sig);
const valid = await hmacVerify(sigInput, sigBytes, secret);
if (!valid) return null;
// Parse payload
let payload: WopiTokenPayload;
try {
payload = JSON.parse(decoder.decode(base64urlDecode(body)));
} catch {
return null;
}
// Check expiry
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
}