Remove the deprecated basic auth paths

This commit is contained in:
Ravi Khadiwala 2026-03-26 16:38:13 -05:00 committed by ravi-signal
parent 48854c3b9a
commit 81312bead9
7 changed files with 505 additions and 716 deletions

View File

@ -8,7 +8,7 @@ import {afterAll, beforeAll, describe, expect, it, test} from 'vitest';
import * as tus from 'tus-js-client';
import {UploadOptions} from 'tus-js-client';
import {unstable_dev, Unstable_DevWorker} from 'wrangler';
import {attachmentsPath, AuthType, backupHeaderFor, backupsPath, headerFor, secret} from '../src/testutil';
import {attachmentsPath, backupHeaderFor, backupsPath, headerFor, secret} from '../src/testutil';
let worker: Unstable_DevWorker;
@ -39,26 +39,24 @@ async function tusClientUpload(name: string, pathPrefix: string, authHeader: str
});
}
for (const authType of ['basic', 'bearer'] as AuthType[]) {
describe(`tus-js-client (${authType})`, () => {
const name = 'test-client-obj';
describe('tus-js-client', () => {
const name = 'test-client-obj';
test.each([false, true])('uploads creation-with-upload=%s',
async (uploadDataDuringCreation: boolean) => {
const blob = Buffer.from('test', 'utf-8');
await tusClientUpload(name, attachmentsPath, await headerFor(name, authType), blob, {uploadDataDuringCreation: uploadDataDuringCreation});
const resp = await worker.fetch(`http://localhost/${attachmentsPath}/${name}`);
expect(await resp.text()).toBe('test');
});
it('accepts uploads with slashes', async () => {
test.each([false, true])('uploads creation-with-upload=%s',
async (uploadDataDuringCreation: boolean) => {
const blob = Buffer.from('test', 'utf-8');
const name = 'subdir/b/c';
await tusClientUpload(name, backupsPath, await backupHeaderFor(name, 'write', authType), blob);
const resp = await worker.fetch(`http://localhost/${backupsPath}/${name}`, {
headers: {'Authorization': await backupHeaderFor('subdir', 'read', authType)}
});
await tusClientUpload(name, attachmentsPath, await headerFor(name), blob, {uploadDataDuringCreation: uploadDataDuringCreation});
const resp = await worker.fetch(`http://localhost/${attachmentsPath}/${name}`);
expect(await resp.text()).toBe('test');
});
it('accepts uploads with slashes', async () => {
const blob = Buffer.from('test', 'utf-8');
const name = 'subdir/b/c';
await tusClientUpload(name, backupsPath, await backupHeaderFor(name, 'write'), blob);
const resp = await worker.fetch(`http://localhost/${backupsPath}/${name}`, {
headers: {'Authorization': await backupHeaderFor('subdir', 'read')}
});
expect(await resp.text()).toBe('test');
});
}
});

View File

@ -1,54 +0,0 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {describe, expect, it} from 'vitest';
import {createAuthWithClock} from './auth';
describe('Auth', async () => {
const user = 'test';
const secret = 'secret';
const maxAge = 10;
async function generatePassAt(user: string, time: number): Promise<string> {
const auth = await createAuthWithClock(secret, maxAge, () => time);
return await auth.generatePass(user);
}
async function validateAt(user: string, password: string, time: number): Promise<boolean> {
const auth = await createAuthWithClock(secret, maxAge, () => time);
return await auth.validateCredentials(user, password);
}
it('rejects expired credentials', async () => {
expect(await validateAt(user, await generatePassAt(user, 1), 12)).toBe(false);
});
it('passes valid credentials', async () => {
expect(await validateAt(user, await generatePassAt(user, 1), 11)).toBe(true);
});
it('rejects wrong-user credentials', async () => {
expect(await validateAt(user, await generatePassAt(user + 'a', 1), 1)).toBe(false);
});
it('rejects missing signature', async () => {
let pass = await generatePassAt(user, 1);
// pass is ts:hex-sig, remove the signature
pass = pass.substring(0, pass.indexOf(':'));
expect(await validateAt(user, pass, 11)).toBe(false);
});
it('rejects long signature', async () => {
let pass = await generatePassAt(user, 1);
// pass is ts:hex-sig, change the sig length
pass += 'aa';
expect(await validateAt(user, pass, 11)).toBe(false);
});
it('rejects short signature', async () => {
let pass = await generatePassAt(user, 1);
// pass is ts:hex-sig, change the sig length
pass = pass.slice(0, pass.length - 1);
expect(await validateAt(user, pass, 11)).toBe(false);
});
});

View File

@ -1,61 +0,0 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {Buffer} from 'node:buffer';
export interface Auth {
generatePass(username: string): Promise<string>;
validateCredentials(username: string, password: string): Promise<boolean>;
}
interface UnixTime {
(): number;
}
export async function createAuthWithClock(secret: string, maxAgeSeconds: number, clock: UnixTime): Promise<Auth> {
const keyBytes = Buffer.from(secret, 'base64');
const macKey = await crypto.subtle.importKey(
'raw',
keyBytes,
{name: 'HMAC', hash: 'SHA-256'},
false,
['sign', 'verify']
);
return {
async generatePass(username: string): Promise<string> {
const now = clock().toString();
const data = Buffer.from(username + ':' + now, 'utf-8');
const sig = (await crypto.subtle.sign('HMAC', macKey, data)).slice(0, 10);
return `${now}:${Buffer.from(sig).toString('hex')}`;
},
async validateCredentials(username: string, password: string): Promise<boolean> {
const truncatedSignatureLength = 10;
const [ts, sig] = password.split(':');
if (!ts || !sig) {
return false;
}
const actual = Buffer.from(sig, 'hex');
if (actual.length !== truncatedSignatureLength) {
// timingSafeEqual throws if the buffers are not the same length
return false;
}
const data = Buffer.from(username + ':' + ts, 'utf-8');
const expected = (await crypto.subtle.sign('HMAC', macKey, data)).slice(0, truncatedSignatureLength);
if (!crypto.subtle.timingSafeEqual(actual, expected)) {
return false;
}
const now = clock();
const tsSecs = parseInt(ts);
return tsSecs + maxAgeSeconds >= now;
}
};
}
export async function createAuth(secret: string, maxAgeSeconds: number): Promise<Auth> {
return await createAuthWithClock(secret, maxAgeSeconds, () => Math.floor(new Date().getTime() / 1000));
}

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import {error, IRequest, json, Router, StatusError} from 'itty-router';
import {Auth, createAuth} from './auth';
import {Buffer} from 'node:buffer';
import {jwtVerify, errors as joseErrors} from 'jose';
import {
MAX_UPLOAD_LENGTH_BYTES,
TUS_VERSION,
X_SIGNAL_CHECKSUM_SHA256,
X_SIGNAL_MAX_UPLOAD_LENGTH
@ -37,9 +35,6 @@ const ATTACHMENT_PREFIX = 'attachments';
const BACKUP_PREFIX = 'backups';
// lazy init because it requires env but is expensive to create
let auth: Auth | undefined;
const router = Router();
router
// Describes what TUS features we support
@ -362,31 +357,14 @@ interface ParseError {
error: Response
}
interface Credentials {
type: 'basic',
user: string,
password: string
}
interface Token {
type: 'bearer',
token: string,
}
function parseAuthHeader(auth: string): Credentials | Token | ParseError {
const basic = 'Basic ';
function parseAuthHeader(auth: string): Token | ParseError {
const bearer = 'Bearer ';
if (auth.startsWith(basic)) {
const cred = auth.slice(basic.length);
const decoded = Buffer.from(cred, 'base64').toString('utf8');
const [username, ...rest] = decoded.split(':');
const password = rest.join(':');
if (!username || !password) {
return {type: 'error', error: error(400, 'invalid auth format')};
}
return {type: 'basic', user: username, password: password};
} else if (auth.startsWith(bearer)) {
if (auth.startsWith(bearer)) {
return {type: 'bearer', token: auth.slice(bearer.length)};
} else {
return {type: 'error', error: error(400, 'invalid auth format')};
@ -395,7 +373,6 @@ function parseAuthHeader(auth: string): Credentials | Token | ParseError {
// Set request.authenticatedClaims if the credential passes authentication
async function withAuthenticatedClaims(request: IRequest, env: Env, _ctx: ExecutionContext): Promise<Response | undefined> {
auth = auth || await createAuth(env.SHARED_AUTH_SECRET, MAX_TOKEN_AGE);
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
return error(401, 'missing credentials');
@ -411,9 +388,7 @@ async function withAuthenticatedClaims(request: IRequest, env: Env, _ctx: Execut
if (parsed.type === 'error') {
return parsed.error;
}
const authenticatedClaims = await (parsed.type === 'basic'
? authenticateCredentials(namespace, auth, parsed)
: authenticateToken(namespace, env.SHARED_AUTH_SECRET, parsed));
const authenticatedClaims = await authenticateToken(namespace, env.SHARED_AUTH_SECRET, parsed);
if (authenticatedClaims === null) {
return error(401, 'invalid credentials');
@ -429,46 +404,6 @@ interface AuthenticatedClaims {
maxUploadLength?: number;
}
async function authenticateCredentials(namespace: Namespace, auth: Auth, credentials: Credentials): Promise<AuthenticatedClaims | null> {
// Auth usernames are of the form [permission$]namespace/entity.
let user = credentials.user;
const valid = await auth.validateCredentials(credentials.user, credentials.password);
if (!valid) {
return null;
}
const permissionSep = user.indexOf('$');
let scope: 'read' | 'write' | undefined;
if (permissionSep !== -1) {
const rawPermission = user.substring(0, permissionSep);
if (rawPermission !== 'write' && rawPermission !== 'read') {
return null;
}
scope = rawPermission;
user = user.substring(permissionSep + 1);
}
const audienceSep = user.indexOf('/');
if (audienceSep === -1) {
return null;
}
const audience = user.substring(0, audienceSep);
user = user.substring(audienceSep + 1);
if (namespace.name !== audience) {
return null;
}
return {
audience: audience,
subject: user,
scope: scope,
// Basic auth doesn't support an uploadLength claim, so use the hard-coded default
maxUploadLength: MAX_UPLOAD_LENGTH_BYTES
};
}
async function authenticateToken(namespace: Namespace, secretString: string, jwtToken: Token): Promise<AuthenticatedClaims | null> {
const secret = new Uint8Array(Buffer.from(secretString, 'base64'));

View File

@ -1,48 +1,31 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {createAuth} from './auth';
import {SignJWT} from 'jose';
import {MAX_UPLOAD_LENGTH_BYTES} from './uploadHandler';
export const attachmentsPath = 'attachments';
export const backupsPath = 'backups';
// Should match the secret in vitest.config.ts
export const secret = 'test';
export const auth = await createAuth(secret, 100);
const jwtSecret = new Uint8Array(Buffer.from(secret, 'base64'));
export type AuthType = 'basic' | 'bearer';
export async function headerFor(key: string, type: AuthType = 'bearer', maxLen: number = MAX_UPLOAD_LENGTH_BYTES): Promise<string> {
if (type === 'bearer') {
const token = await new SignJWT({maxLen})
.setProtectedHeader({alg: 'HS256'})
.setSubject(key)
.setAudience(attachmentsPath)
.setIssuedAt()
.sign(jwtSecret);
return `Bearer ${token}`;
} else {
const user = `${attachmentsPath}/${key}`;
const pass = await auth.generatePass(user);
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
}
export async function headerFor(key: string, maxLen: number = 1024 * 1024 * 100): Promise<string> {
const token = await new SignJWT({maxLen})
.setProtectedHeader({alg: 'HS256'})
.setSubject(key)
.setAudience(attachmentsPath)
.setIssuedAt()
.sign(jwtSecret);
return `Bearer ${token}`;
}
export async function backupHeaderFor(key: string, permission: string, type: AuthType = 'bearer', maxLen: number = MAX_UPLOAD_LENGTH_BYTES): Promise<string> {
if (type === 'bearer') {
const token = await new SignJWT({scope: permission, maxLen})
.setProtectedHeader({alg: 'HS256'})
.setSubject(key)
.setAudience(backupsPath)
.setIssuedAt()
.sign(jwtSecret);
return `Bearer ${token}`;
} else {
const user = `${permission}$${backupsPath}/${key}`;
const pass = await auth.generatePass(user);
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
}
export async function backupHeaderFor(key: string, permission: string, maxLen: number = 1024 * 1024 * 100): Promise<string> {
const token = await new SignJWT({scope: permission, maxLen})
.setProtectedHeader({alg: 'HS256'})
.setSubject(key)
.setAudience(backupsPath)
.setIssuedAt()
.sign(jwtSecret);
return `Bearer ${token}`;
}

View File

@ -14,16 +14,12 @@ import {
RetryBucket,
RetryMultipartUpload
} from './retry';
import {R2UploadedPart} from '@cloudflare/workers-types';
export const TUS_VERSION = '1.0.0';
// Set by the worker to indicate the maximum upload length we should allow
export const X_SIGNAL_MAX_UPLOAD_LENGTH = 'X-Signal-Max-Upload-Length';
// If no `X_SIGNAL_MAX_UPLOAD_LENGTH` is provided by the calling worker, we will default to this
export const MAX_UPLOAD_LENGTH_BYTES = 1024 * 1024 * 100;
export const X_SIGNAL_CHECKSUM_SHA256 = 'X-Signal-Checksum-Sha256';
// how long an unfinished upload lives in ms
@ -550,11 +546,7 @@ export class UploadHandler {
}
maxUploadLength(request: IRequest): number {
const maxUploadLength = readIntFromHeader(request.headers, X_SIGNAL_MAX_UPLOAD_LENGTH);
if (isNaN(maxUploadLength)) {
return MAX_UPLOAD_LENGTH_BYTES;
}
return maxUploadLength;
return readIntFromHeader(request.headers, X_SIGNAL_MAX_UPLOAD_LENGTH);
}
// Cleanup the state for this durable object. If r2Key is provided, the method will make