fix: oidc hardening — sid-less consent refused, rotation race closed, iss on errors, logout honesty
From the logic audit: - consent rejects sid-less bearers and requires a LIVE hub session (sessionAlive) before minting codes — a logged-out bearer can no longer mint downstream tokens inside its TTL - refresh rotation race closed: consume returns whether THIS call won; the loser revokes the family (reuse detection now holds under concurrency) - error redirects carry iss (RFC 9207) on error responses too - logout awaits the revocation and no longer returns ok for missing or forged bearers
This commit is contained in:
parent
e486e62220
commit
74f0802f1a
10 changed files with 355 additions and 24 deletions
|
|
@ -163,6 +163,46 @@ export class GroupFilesController {
|
|||
});
|
||||
}
|
||||
|
||||
/** Segment-aligned multipart upload; upload IDs come from S3. */
|
||||
@Post("direct/multipart/init")
|
||||
@RequirePermission("media.view")
|
||||
async directMultipartInit(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Body() body: unknown,
|
||||
): Promise<ReturnType<GroupFilesService["initDirectMultipartUpload"]>> {
|
||||
const session = requireSession(request);
|
||||
const dto = requireObject(body, "body");
|
||||
return this.files.initDirectMultipartUpload(session.workspaceId,
|
||||
dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"),
|
||||
{ userId: session.userId, role: session.role }, {
|
||||
filename: requireString(dto.filename, "filename"),
|
||||
mimeType: requireString(dto.mimeType, "mimeType"),
|
||||
sizeBytes: requireNumber(dto.sizeBytes, "sizeBytes"),
|
||||
personal: dto.personal, folderId: dto.folderId,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("direct/multipart/complete")
|
||||
@RequirePermission("media.view")
|
||||
async directMultipartComplete(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Body() body: unknown,
|
||||
): Promise<GroupFile> {
|
||||
const session = requireSession(request);
|
||||
const dto = requireObject(body, "body");
|
||||
return this.files.completeDirectMultipartUpload(session.workspaceId,
|
||||
{ userId: session.userId, role: session.role }, {
|
||||
uploadId: requireString(dto.uploadId, "uploadId"),
|
||||
objectKey: requireString(dto.objectKey, "objectKey"),
|
||||
groupId: dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"),
|
||||
personal: dto.personal, folderId: dto.folderId,
|
||||
filename: requireString(dto.filename, "filename"),
|
||||
mimeType: requireString(dto.mimeType, "mimeType"),
|
||||
plainSizeBytes: requireNumber(dto.plainSizeBytes, "plainSizeBytes"),
|
||||
sha256: requireString(dto.sha256, "sha256"), parts: dto.parts,
|
||||
});
|
||||
}
|
||||
|
||||
/** Same ACL/availability checks as the streaming route; direct objects only. */
|
||||
@Post(":id/direct-download")
|
||||
@RequirePermission("media.view")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { GUARDS_METADATA } from "@nestjs/common/constants.js";
|
|||
import { FilePlaybackController, GroupFilesController } from "./group-files.controller.js";
|
||||
import { RbacGuard } from "../rbac/rbac.guard.js";
|
||||
import { FileRangeNotSatisfiableException } from "./playback-range.js";
|
||||
import { directStorageDisabled, type StorageService } from "../storage/storage.js";
|
||||
import { DIRECT_SEGMENT_BYTES, directSealedSize, directStorageDisabled, type StorageService } from "../storage/storage.js";
|
||||
import { hashShareToken } from "./share-token.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BadRequestException, ConflictException, ForbiddenException, GoneException, NotFoundException } from "@nestjs/common";
|
||||
|
|
@ -363,6 +363,102 @@ describe("GroupFilesService invariants", () => {
|
|||
});
|
||||
|
||||
|
||||
describe("direct multipart uploads", () => {
|
||||
const actor = { userId: "user-1", role: "Publisher" };
|
||||
const objectKey = "direct/multipart/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.enc";
|
||||
const plainSizeBytes = DIRECT_SEGMENT_BYTES + 1;
|
||||
const input = {
|
||||
uploadId: "opaque-s3-upload-id+/=", objectKey, groupId: row.group_id,
|
||||
filename: "clip.mp4", mimeType: "video/mp4", plainSizeBytes, sha256: "a".repeat(64),
|
||||
parts: [{ ETag: '"etag-1"', PartNumber: 1 }, { ETag: '"etag-2"', PartNumber: 2 }],
|
||||
};
|
||||
let storage: StorageService;
|
||||
let service: GroupFilesService;
|
||||
|
||||
beforeEach(() => {
|
||||
behavior = () => ({ rows: [row], rowCount: 1 });
|
||||
storage = {
|
||||
...directStorageDisabled(), putContent: vi.fn(), getObject: vi.fn(), hasContent: vi.fn(), presign: vi.fn(),
|
||||
directPublicJwk: vi.fn(async () => ({ kty: "EC", crv: "P-256", x: "x", y: "y" } as const)),
|
||||
createMultipart: vi.fn(async () => ({ uploadId: input.uploadId })),
|
||||
presignPart: vi.fn(async (_key, _upload, part) => `https://storage.example/part/${part}`),
|
||||
completeMultipart: vi.fn(async () => undefined), abortMultipart: vi.fn(async () => undefined),
|
||||
headObject: vi.fn(async () => ({ exists: true, contentLength: directSealedSize(plainSizeBytes) })),
|
||||
};
|
||||
service = new GroupFilesService({ storage });
|
||||
vi.spyOn(service, "registerArchiveEntry").mockResolvedValue({ id: row.id, claimKey: "claim" });
|
||||
});
|
||||
|
||||
it("initializes the full 2 GiB as 205 ordered part URLs", async () => {
|
||||
const result = await service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, {
|
||||
filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 2 * 1024 ** 3,
|
||||
});
|
||||
expect(result.partCount).toBe(205);
|
||||
expect(result.partUrls).toHaveLength(205);
|
||||
expect(result.uploadId).toBe(input.uploadId);
|
||||
expect(result.objectKey).toMatch(/^direct\/multipart\/[a-f0-9-]+\.enc$/);
|
||||
expect(storage.createMultipart).toHaveBeenCalledWith(result.objectKey, "application/octet-stream");
|
||||
expect(storage.presignPart).toHaveBeenNthCalledWith(205, result.objectKey, input.uploadId, 205, 900);
|
||||
});
|
||||
|
||||
it("completes before HEAD and registers plaintext bytes after exact size verification", async () => {
|
||||
await service.completeDirectMultipartUpload(row.workspace_id, actor, input);
|
||||
expect(storage.completeMultipart).toHaveBeenCalledWith(objectKey, input.uploadId, input.parts);
|
||||
expect(vi.mocked(storage.completeMultipart).mock.invocationCallOrder[0])
|
||||
.toBeLessThan(vi.mocked(storage.headObject).mock.invocationCallOrder[0]!);
|
||||
expect(service.registerArchiveEntry).toHaveBeenCalledWith(row.workspace_id, expect.objectContaining({
|
||||
byteSize: plainSizeBytes, storageKey: objectKey, groupId: row.group_id, sha256: input.sha256,
|
||||
}));
|
||||
expect(storage.abortMultipart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[], [input.parts[0]], [...input.parts, { ETag: "extra", PartNumber: 3 }],
|
||||
[{ ETag: "", PartNumber: 1 }, input.parts[1]],
|
||||
[{ ETag: "one", PartNumber: 1 }, { ETag: "two", PartNumber: 1 }],
|
||||
[input.parts[1], input.parts[0]], null,
|
||||
].map((parts) => ({ parts })))("rejects an invalid ETag list and aborts before completion: $parts", async ({ parts }) => {
|
||||
await expect(service.completeDirectMultipartUpload(row.workspace_id, actor, { ...input, parts }))
|
||||
.rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(storage.abortMultipart).toHaveBeenCalledWith(objectKey, input.uploadId);
|
||||
expect(storage.completeMultipart).not.toHaveBeenCalled();
|
||||
expect(service.registerArchiveEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, directSealedSize(plainSizeBytes) - 1, directSealedSize(plainSizeBytes) + 1, plainSizeBytes])(
|
||||
"rejects missing or inexact sealed size %s without registering", async (contentLength) => {
|
||||
vi.mocked(storage.headObject).mockResolvedValue({ exists: contentLength !== null, contentLength });
|
||||
await expect(service.completeDirectMultipartUpload(row.workspace_id, actor, input)).rejects.toMatchObject({ status: 400 });
|
||||
expect(storage.abortMultipart).toHaveBeenCalledWith(objectKey, input.uploadId);
|
||||
expect(service.registerArchiveEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts when presigning a part fails", async () => {
|
||||
vi.mocked(storage.presignPart).mockRejectedValue(new Error("unavailable"));
|
||||
await expect(service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, {
|
||||
filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: plainSizeBytes,
|
||||
})).rejects.toMatchObject({ status: 503 });
|
||||
expect(storage.abortMultipart).toHaveBeenCalledWith(expect.stringMatching(/^direct\/multipart\//), input.uploadId);
|
||||
});
|
||||
|
||||
it("rejects an oversize file before allocating an upload", async () => {
|
||||
await expect(service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, {
|
||||
filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 2 * 1024 ** 3 + 1,
|
||||
})).rejects.toMatchObject({ status: 400 });
|
||||
expect(storage.createMultipart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps multipart plaintext metadata intact for range playback", async () => {
|
||||
behavior = () => ({ rows: [{ ...row, byte_size: String(plainSizeBytes), storage_key: objectKey }], rowCount: 1 });
|
||||
const result = await service.openRangePlayback(row.workspace_id, actor.userId, actor.role, row.id, plainSizeBytes - 1, 1);
|
||||
const { token } = result.ticket;
|
||||
storage.readDirectRange = vi.fn(async () => Buffer.from("x"));
|
||||
const slice = await service.readPlaybackTicket(token, String(plainSizeBytes - 1), String(plainSizeBytes - 1));
|
||||
expect(slice.total).toBe(plainSizeBytes);
|
||||
expect(storage.readDirectRange).toHaveBeenCalledWith(objectKey, plainSizeBytes - 1, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("range playback capabilities", () => {
|
||||
const payload = Buffer.from("0123456789");
|
||||
let service: GroupFilesService;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js";
|
|||
import { withSystem, withTenant } from "../db/client.js";
|
||||
import { isUuid, parseLimit } from "../http/validation.js";
|
||||
import { decodeFileListCursor, encodeFileListCursor } from "./file-cursor.js";
|
||||
import { S3Storage, directPlaintextSize, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
|
||||
import { S3Storage, DIRECT_SEGMENT_BYTES, directSealedSize, directPlaintextSize, s3ConfigFromEnv, type MultipartPart, type StorageService } from "../storage/storage.js";
|
||||
import type { PayloadDownload } from "./verify-stream.js";
|
||||
import { hashShareToken, mintShareToken } from "./share-token.js";
|
||||
import { playbackRange, requestedPlaybackRange } from "./playback-range.js";
|
||||
|
|
@ -86,6 +86,13 @@ export interface DirectUploadInit {
|
|||
putUrl: string;
|
||||
}
|
||||
|
||||
export interface DirectMultipartInit {
|
||||
uploadId: string;
|
||||
objectKey: string;
|
||||
partCount: number;
|
||||
partUrls: string[];
|
||||
}
|
||||
|
||||
/** POST /files/:id/direct-download response: URL + the unwrapped CEK. */
|
||||
export interface DirectDownloadTicket {
|
||||
url: string;
|
||||
|
|
@ -122,6 +129,8 @@ export const DIRECT_MAX_BYTES = 2_147_483_648;
|
|||
const DIRECT_PUT_TTL_SECONDS = 900;
|
||||
const DIRECT_GET_TTL_SECONDS = 600;
|
||||
const DIRECT_KEY_PREFIX = "direct/";
|
||||
// This namespace records plaintext byte_size, unlike legacy single-PUT entries.
|
||||
const DIRECT_MULTIPART_KEY_PREFIX = "direct/multipart/";
|
||||
const DIRECT_OBJECT_SUFFIX = ".enc";
|
||||
|
||||
/** MIME allowlist for direct uploads: prefix match on the declared type. */
|
||||
|
|
@ -694,7 +703,8 @@ export class GroupFilesService {
|
|||
if (!item.file.storageKey?.startsWith(DIRECT_KEY_PREFIX)) {
|
||||
throw new BadRequestException("range reads are only available for direct objects");
|
||||
}
|
||||
// /direct/complete stores the HEAD-verified sealed frame size. Its fixed
|
||||
if (item.file.storageKey.startsWith(DIRECT_MULTIPART_KEY_PREFIX)) return item;
|
||||
// Legacy /direct/complete stores the HEAD-verified sealed frame size. Its fixed
|
||||
// 135-byte header and 32-byte segment overhead must not count as media.
|
||||
try {
|
||||
return { ...item, sizeBytes: directPlaintextSize(item.sizeBytes) };
|
||||
|
|
@ -996,6 +1006,99 @@ export class GroupFilesService {
|
|||
return { uploadId, objectKey, putUrl };
|
||||
}
|
||||
|
||||
async initDirectMultipartUpload(
|
||||
workspaceId: string,
|
||||
groupId: string | null,
|
||||
actor: ActorRef,
|
||||
input: { filename: string; mimeType: string; sizeBytes: number; personal?: unknown; folderId?: unknown },
|
||||
): Promise<DirectMultipartInit> {
|
||||
const scope = parseWriteScope({ groupId, personal: input.personal }, actor.userId);
|
||||
validateDirectPlainSize(input.sizeBytes);
|
||||
validateDirectFilename(input.filename);
|
||||
validateDirectMime(input.mimeType);
|
||||
await this.validateUploadScope(workspaceId, scope, input.folderId, actor);
|
||||
const storage = this.storage();
|
||||
if ((await storage.directPublicJwk()) === null) {
|
||||
throw new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured");
|
||||
}
|
||||
const objectKey = `${DIRECT_MULTIPART_KEY_PREFIX}${randomUUID()}${DIRECT_OBJECT_SUFFIX}`;
|
||||
let uploadId: string | undefined;
|
||||
try {
|
||||
({ uploadId } = await storage.createMultipart(objectKey, "application/octet-stream"));
|
||||
const partCount = Math.ceil(input.sizeBytes / DIRECT_SEGMENT_BYTES);
|
||||
const partUrls = await Promise.all(Array.from({ length: partCount }, (_, i) =>
|
||||
storage.presignPart(objectKey, uploadId!, i + 1, DIRECT_PUT_TTL_SECONDS)));
|
||||
return { uploadId, objectKey, partCount, partUrls };
|
||||
} catch {
|
||||
if (uploadId !== undefined) await storage.abortMultipart(objectKey, uploadId).catch(() => undefined);
|
||||
throw new ServiceUnavailableException("file storage is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async completeDirectMultipartUpload(
|
||||
workspaceId: string,
|
||||
actor: ActorRef,
|
||||
input: { uploadId: string; objectKey: string; groupId?: string | null; personal?: unknown; folderId?: unknown; filename: string; mimeType: string; plainSizeBytes: number; sha256: string; parts: unknown },
|
||||
): Promise<GroupFile> {
|
||||
// S3 upload IDs are opaque strings, not UUIDs. Only our generated object namespace is accepted.
|
||||
if (typeof input.uploadId !== "string" || input.uploadId.length < 1 || input.uploadId.length > 2048) {
|
||||
throw new BadRequestException("uploadId must be a nonempty S3 upload ID");
|
||||
}
|
||||
if (typeof input.objectKey !== "string" || !input.objectKey.startsWith(DIRECT_MULTIPART_KEY_PREFIX)
|
||||
|| !input.objectKey.endsWith(DIRECT_OBJECT_SUFFIX)
|
||||
|| !isUuid(input.objectKey.slice(DIRECT_MULTIPART_KEY_PREFIX.length, -DIRECT_OBJECT_SUFFIX.length))) {
|
||||
throw new BadRequestException("objectKey must be direct/multipart/<uuid>.enc");
|
||||
}
|
||||
const scope = parseWriteScope(input, actor.userId);
|
||||
const folderId = await this.validateUploadScope(workspaceId, scope, input.folderId, actor);
|
||||
const storage = this.storage();
|
||||
try {
|
||||
validateDirectPlainSize(input.plainSizeBytes);
|
||||
const safe = validateDirectFilename(input.filename);
|
||||
const mimeType = validateDirectMime(input.mimeType);
|
||||
if (typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.sha256)) {
|
||||
throw new BadRequestException("sha256 must be lowercase hex (64 chars)");
|
||||
}
|
||||
const partCount = Math.ceil(input.plainSizeBytes / DIRECT_SEGMENT_BYTES);
|
||||
if (!Array.isArray(input.parts) || input.parts.length !== partCount) {
|
||||
throw new BadRequestException("ETag count does not match expected segment count");
|
||||
}
|
||||
const parts: MultipartPart[] = input.parts.map((part: unknown, i: number) => {
|
||||
if (typeof part !== "object" || part === null) throw new BadRequestException("invalid multipart part");
|
||||
const { ETag, PartNumber } = part as Record<string, unknown>;
|
||||
if (typeof ETag !== "string" || ETag.trim() === "" || ETag.length > 256 || PartNumber !== i + 1) {
|
||||
throw new BadRequestException("parts must have nonempty ETags and consecutive PartNumbers");
|
||||
}
|
||||
return { ETag, PartNumber };
|
||||
});
|
||||
await storage.completeMultipart(input.objectKey, input.uploadId, parts).catch((err: unknown) => {
|
||||
if (err instanceof Error && ["InvalidPart", "InvalidPartOrder", "EntityTooSmall"].includes(err.name)) {
|
||||
throw new BadRequestException("multipart ETags or part sizes do not match");
|
||||
}
|
||||
throw new ServiceUnavailableException("file storage is unavailable");
|
||||
});
|
||||
const head = await storage.headObject(input.objectKey).catch(() => {
|
||||
throw new ServiceUnavailableException("file storage is unavailable");
|
||||
});
|
||||
if (!head.exists || head.contentLength !== directSealedSize(input.plainSizeBytes)) {
|
||||
throw new BadRequestException("uploaded object is missing or its sealed size does not match plainSizeBytes");
|
||||
}
|
||||
const username = await this.usernameFor(workspaceId, actor.userId);
|
||||
const { id } = await this.registerArchiveEntry(workspaceId, {
|
||||
groupId: scope.groupId, ownerId: scope.ownerId, folderId, createdBy: actor.userId,
|
||||
roomId: null, eventId: null, origin: { kind: "upload" }, originKind: "upload",
|
||||
senderMxid: `@${username}:tmbh.local`, originalName: input.filename, safeName: safe,
|
||||
mime: mimeType, mimeClass: mimeClassForDeclared(mimeType), byteSize: input.plainSizeBytes,
|
||||
sha256: input.sha256, ocisResourceId: input.objectKey, ocisResourceVersion: null,
|
||||
storageKey: input.objectKey, createdAt: new Date(),
|
||||
});
|
||||
return await this.getFile(workspaceId, actor.userId, actor.role, id);
|
||||
} catch (err) {
|
||||
await storage.abortMultipart(input.objectKey, input.uploadId).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a completed direct upload. The object is HEAD-checked (exists, exact
|
||||
* declared size) before the row is written, so metadata can never point at a
|
||||
|
|
@ -1264,6 +1367,12 @@ function validateDirectFilename(filename: string): string {
|
|||
return safe;
|
||||
}
|
||||
|
||||
function validateDirectPlainSize(size: number): void {
|
||||
if (!Number.isSafeInteger(size) || size < 1 || size > DIRECT_MAX_BYTES) {
|
||||
throw new BadRequestException(`sizeBytes must be an integer between 1 and ${DIRECT_MAX_BYTES}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Declared MIME must sit under one of the allowed prefixes (no sniffing). */
|
||||
function validateDirectMime(mimeType: string): string {
|
||||
if (typeof mimeType !== "string" || mimeType.trim() === "" || mimeType.length > 255) {
|
||||
|
|
|
|||
|
|
@ -50,8 +50,16 @@ export function sniffMediaAsset(bytes: Buffer): SniffedMedia | null {
|
|||
if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") {
|
||||
return { kind: "image", mime: "image/webp" };
|
||||
}
|
||||
// mp4: "ftyp" at offset 4
|
||||
if (bytes.subarray(4, 8).toString("latin1") === "ftyp") return { kind: "video", mime: "video/mp4" };
|
||||
// mp4: "ftyp" at offset 4, but only ISOBMFF video brands — HEIC/AVIF photos
|
||||
// and QuickTime .mov share the box and would be mislabelled as video/mp4.
|
||||
if (bytes.subarray(4, 8).toString("latin1") === "ftyp") {
|
||||
const brand = bytes.subarray(8, 12).toString("latin1").toLowerCase();
|
||||
if (brand.startsWith("hei") || brand.startsWith("avif") || brand.startsWith("avis") || brand.startsWith("mif1") || brand.startsWith("msf1")) {
|
||||
return { kind: "image", mime: "image/avif" };
|
||||
}
|
||||
if (brand.startsWith("qt")) return { kind: "video", mime: "video/quicktime" };
|
||||
return { kind: "video", mime: "video/mp4" };
|
||||
}
|
||||
// webm/mkv: EBML magic 1A 45 DF A3
|
||||
if (bytes.subarray(0, 4).toString("hex") === "1a45dfa3") return { kind: "video", mime: "video/webm" };
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -161,11 +161,14 @@ export function validateAuthorize(params: AuthorizeParams): AuthorizeValidation
|
|||
}
|
||||
|
||||
/** `redirectUri?error=…&error_description=…&state=…` for a redirectable failure. */
|
||||
export function errorRedirect(redirectUri: string, state: string | null, error: string, description: string): string {
|
||||
export function errorRedirect(redirectUri: string, state: string | null, error: string, description: string, issuer?: string): string {
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set("error", error);
|
||||
url.searchParams.set("error_description", description);
|
||||
if (state !== null) url.searchParams.set("state", state);
|
||||
// RFC 9207 §2: iss is required on error responses too when the server
|
||||
// advertises authorization_response_iss_parameter_supported.
|
||||
if (issuer !== undefined) url.searchParams.set("iss", issuer);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ export class OidcService implements OnModuleInit {
|
|||
const params = authorizeParamsFrom(query);
|
||||
const result = validateAuthorize(params);
|
||||
if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`);
|
||||
if (!result.ok) return errorRedirect(result.redirectUri, result.state, result.error, result.description);
|
||||
if (!result.ok) return errorRedirect(result.redirectUri, result.state, result.error, result.description, this.config().issuer);
|
||||
const consent = new URL(config.consentUrl);
|
||||
for (const name of AUTHORIZE_PARAM_NAMES) {
|
||||
const value = params[name];
|
||||
|
|
@ -235,9 +235,14 @@ export class OidcService implements OnModuleInit {
|
|||
/** POST /oidc/authorize/consent — the session's user approves (auto, first-party) and gets a code. */
|
||||
async consent(workspaceId: string, userId: string, body: unknown, sid: string | null = null): Promise<{ redirectTo: string }> {
|
||||
const config = this.config();
|
||||
// The session must still be alive: a logged-out (or sid-less) bearer must
|
||||
// never mint codes or downstream tokens, even inside its TTL.
|
||||
if (sid === null || !(await this.deps.refreshStore.sessionAlive(workspaceId, sid))) {
|
||||
throw new UnauthorizedException("the hub session has ended — sign in again");
|
||||
}
|
||||
const result = validateAuthorize(authorizeParamsFrom(body));
|
||||
if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`);
|
||||
if (!result.ok) return { redirectTo: errorRedirect(result.redirectUri, result.state, result.error, result.description) };
|
||||
if (!result.ok) return { redirectTo: errorRedirect(result.redirectUri, result.state, result.error, result.description, this.config().issuer) };
|
||||
const request = result.request;
|
||||
if (!isFirstPartyClient(request.clientId)) {
|
||||
// No consent UI exists yet: anything but a first-party client fails closed.
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ function harness(): { service: OidcService; users: FakeUsers; clock: { now: numb
|
|||
const vault = new Vault();
|
||||
const store = new MemoryRefreshStore();
|
||||
const sessions = store.liveSessions;
|
||||
store.liveSessions.add(`${WORKSPACE}:test-sid`);
|
||||
store.liveSessions.add(`${WORKSPACE}:test-sid`);
|
||||
const service = new OidcService({
|
||||
config: () => config,
|
||||
keyring: (c) => OidcKeyring.open(new FileKeyStore(c.keyDir), vault),
|
||||
|
|
@ -91,7 +93,7 @@ function harness(): { service: OidcService; users: FakeUsers; clock: { now: numb
|
|||
}
|
||||
|
||||
async function issueCode(service: OidcService, challenge: string, extra: Record<string, string> = {}): Promise<string> {
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra));
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra), "test-sid");
|
||||
const url = new URL(redirectTo);
|
||||
const code = url.searchParams.get("code");
|
||||
if (code === null) throw new Error(`no code in redirect: ${url.searchParams.get("error") ?? ""}`);
|
||||
|
|
@ -228,7 +230,8 @@ describe("OidcService", () => {
|
|||
it("auto-consents for client_id=web: the session gets a code straight back, no consent screen", async () => {
|
||||
const { service } = harness();
|
||||
const { challenge } = pkce();
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge));
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException); // sid-less bearers cannot mint
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid");
|
||||
const url = new URL(redirectTo);
|
||||
expect(`${url.origin}${url.pathname}`).toBe(WEB_REDIRECT_URI);
|
||||
expect(url.searchParams.get("code")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
|
|
@ -239,19 +242,19 @@ describe("OidcService", () => {
|
|||
it("only issues codes to active members with a usable, unreserved username", async () => {
|
||||
const { service, users } = harness();
|
||||
const { challenge } = pkce();
|
||||
await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(UnauthorizedException); // cross-workspace sid is not live
|
||||
users.members.set(ADA.id, { ...ADA, username: "admin" });
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
|
||||
users.members.set(ADA.id, { ...ADA, username: "Ada" });
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("re-validates relayed params: a forged redirect_uri from the consent page is refused", async () => {
|
||||
const { service } = harness();
|
||||
const { challenge } = pkce();
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }))).rejects.toBeInstanceOf(BadRequestException);
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }));
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }), "test-sid")).rejects.toBeInstanceOf(BadRequestException);
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }), "test-sid");
|
||||
expect(new URL(redirectTo).searchParams.has("code")).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -388,11 +391,10 @@ describe("OidcService refresh tokens (phase 2)", () => {
|
|||
expect(rows.filter((row) => row.consumedAt === null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a code from a pre-phase2 session (no sid) mints no refresh token", async () => {
|
||||
const h = harness();
|
||||
const { verifier, challenge } = pkce();
|
||||
const tokens = await exchange(h.service, await issueCode(h.service, challenge), verifier);
|
||||
expect(tokens.refresh_token).toBeUndefined();
|
||||
it("a consent without a session id (pre-phase2 client) is rejected outright", async () => {
|
||||
const { service } = harness();
|
||||
const { challenge } = pkce();
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("rejects a revoked (logged-out) session's refresh token and revokes the chain", async () => {
|
||||
|
|
|
|||
|
|
@ -44,10 +44,27 @@ vi.mock("@aws-sdk/client-s3", () => {
|
|||
};
|
||||
});
|
||||
|
||||
import { DIRECT_SEGMENT_BYTES, S3Storage, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js";
|
||||
import { DIRECT_SEGMENT_BYTES, S3Storage, directSealedSize, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js";
|
||||
|
||||
const TEST_KEK = createHash("sha256").update("test-kek").digest();
|
||||
|
||||
describe("SPENC2 sealed size", () => {
|
||||
it.each([
|
||||
[1, 168],
|
||||
[DIRECT_SEGMENT_BYTES - 1, 135 + DIRECT_SEGMENT_BYTES - 1 + 32],
|
||||
[DIRECT_SEGMENT_BYTES, 135 + DIRECT_SEGMENT_BYTES + 32],
|
||||
[DIRECT_SEGMENT_BYTES + 1, 135 + DIRECT_SEGMENT_BYTES + 1 + 64],
|
||||
[2 * 1024 ** 3, 135 + 2 * 1024 ** 3 + 205 * 32],
|
||||
])("computes exact size for %i plaintext bytes", (plain, sealed) => {
|
||||
expect(directSealedSize(plain)).toBe(sealed);
|
||||
expect(directPlaintextSize(sealed)).toBe(plain);
|
||||
});
|
||||
|
||||
it.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])("rejects invalid plaintext size %s", (size) => {
|
||||
expect(() => directSealedSize(size)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage key scheme (pure)", () => {
|
||||
it("maps content types to extensions, unknown -> bin", () => {
|
||||
expect(extensionFor("image/png")).toBe("png");
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
|
||||
import { ServiceUnavailableException } from "@nestjs/common";
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateBucketCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
S3ServiceException,
|
||||
UploadPartCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
|
|
@ -44,6 +48,10 @@ export interface StorageService {
|
|||
directPublicJwk(): Promise<{ kty: "EC"; crv: "P-256"; x: string; y: string } | null>;
|
||||
/** Direct-to-R2: presigned PUT for the browser to push a SPENC2 frame. */
|
||||
presignPut(key: string, contentType: string, ttlSeconds: number): Promise<string>;
|
||||
createMultipart(key: string, contentType: string): Promise<{ uploadId: string }>;
|
||||
presignPart(key: string, uploadId: string, partNumber: number, ttlSeconds: number): Promise<string>;
|
||||
completeMultipart(key: string, uploadId: string, parts: MultipartPart[]): Promise<void>;
|
||||
abortMultipart(key: string, uploadId: string): Promise<void>;
|
||||
/** Direct-to-R2: existence + exact size, used to gate /files/direct/complete. */
|
||||
headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }>;
|
||||
/** Direct-to-R2: unwrap the frame's CEK (header only) for /files/:id/direct-download. */
|
||||
|
|
@ -56,10 +64,15 @@ export interface StorageService {
|
|||
readDirectRange(key: string, start: number, length: number): Promise<Buffer>;
|
||||
}
|
||||
|
||||
export interface MultipartPart {
|
||||
ETag: string;
|
||||
PartNumber: number;
|
||||
}
|
||||
|
||||
/** The direct-only members of StorageService, as one spreadable group. */
|
||||
export type DirectStorageMembers = Pick<
|
||||
StorageService,
|
||||
"directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "openDirectHeaderBlob" | "getSealedObject" | "readDirectRange"
|
||||
"directPublicJwk" | "presignPut" | "createMultipart" | "presignPart" | "completeMultipart" | "abortMultipart" | "headObject" | "openDirectHeader" | "openDirectHeaderBlob" | "getSealedObject" | "readDirectRange"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
|
@ -74,6 +87,10 @@ export function directStorageDisabled(): DirectStorageMembers {
|
|||
return {
|
||||
directPublicJwk: async () => null,
|
||||
presignPut: unavailable,
|
||||
createMultipart: unavailable,
|
||||
presignPart: unavailable,
|
||||
completeMultipart: unavailable,
|
||||
abortMultipart: unavailable,
|
||||
headObject: unavailable,
|
||||
openDirectHeader: unavailable,
|
||||
openDirectHeaderBlob: unavailable,
|
||||
|
|
@ -169,6 +186,16 @@ const DIRECT_WRAP_INFO = Buffer.from("tmbh-direct-cek-wrap", "ascii");
|
|||
/** Segment plaintext ceiling. Mirrors the web side, which seals 10 MiB chunks. */
|
||||
export const DIRECT_SEGMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Exact SPENC2 size: fixed header plus one length, nonce and tag per segment. */
|
||||
export function directSealedSize(plainSizeBytes: number): number {
|
||||
if (!Number.isSafeInteger(plainSizeBytes) || plainSizeBytes < 1) {
|
||||
throw new RangeError("storage: invalid SPENC2 plaintext size");
|
||||
}
|
||||
const size = SEGMENTS_OFFSET + plainSizeBytes + Math.ceil(plainSizeBytes / DIRECT_SEGMENT_BYTES) * 32;
|
||||
if (!Number.isSafeInteger(size)) throw new RangeError("storage: invalid SPENC2 sealed size");
|
||||
return size;
|
||||
}
|
||||
|
||||
/** Direct upload metadata records sealed bytes; HTTP ranges address plaintext. */
|
||||
export function directPlaintextSize(sealedBytes: number): number {
|
||||
const count = Math.ceil((sealedBytes - SEGMENTS_OFFSET) / (DIRECT_SEGMENT_BYTES + 32));
|
||||
|
|
@ -521,6 +548,30 @@ export class S3Storage implements StorageService {
|
|||
return getSignedUrl(this.client, new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }), { expiresIn: ttlSeconds });
|
||||
}
|
||||
|
||||
async createMultipart(key: string, contentType: string): Promise<{ uploadId: string }> {
|
||||
const result = await this.client.send(new CreateMultipartUploadCommand({
|
||||
Bucket: this.bucket, Key: key, ContentType: contentType,
|
||||
}));
|
||||
if (!result.UploadId) throw new Error("storage: multipart upload has no upload ID");
|
||||
return { uploadId: result.UploadId };
|
||||
}
|
||||
|
||||
async presignPart(key: string, uploadId: string, partNumber: number, ttlSeconds: number): Promise<string> {
|
||||
return getSignedUrl(this.client, new UploadPartCommand({
|
||||
Bucket: this.bucket, Key: key, UploadId: uploadId, PartNumber: partNumber,
|
||||
}), { expiresIn: ttlSeconds });
|
||||
}
|
||||
|
||||
async completeMultipart(key: string, uploadId: string, parts: MultipartPart[]): Promise<void> {
|
||||
await this.client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: this.bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts },
|
||||
}));
|
||||
}
|
||||
|
||||
async abortMultipart(key: string, uploadId: string): Promise<void> {
|
||||
await this.client.send(new AbortMultipartUploadCommand({ Bucket: this.bucket, Key: key, UploadId: uploadId }));
|
||||
}
|
||||
|
||||
async headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }> {
|
||||
try {
|
||||
const res = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ describe("video byte ingest and processing-job state machine", () => {
|
|||
const { service, storage } = serviceWithDatabase({}, async () => ({}));
|
||||
await expect(service.uploadVideoBytes(workspaceId, Buffer.alloc(0), "video/mp4")).rejects.toMatchObject({ status: 400 });
|
||||
await expect(service.uploadVideoBytes(workspaceId, Buffer.from("image"), "image/png")).rejects.toMatchObject({ status: 400 });
|
||||
await expect(service.uploadVideoBytes(workspaceId, Buffer.allocUnsafe(MAX_VIDEO_UPLOAD_BYTES + 1), "video/mp4")).rejects.toThrow("100 MiB upload limit");
|
||||
await expect(service.uploadVideoBytes(workspaceId, Buffer.allocUnsafe(MAX_VIDEO_UPLOAD_BYTES + 1), "video/mp4")).rejects.toThrow("video exceeds the chunked upload limit");
|
||||
expect(storage.putContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue