Remove the deprecated basic auth paths
This commit is contained in:
parent
48854c3b9a
commit
81312bead9
@ -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');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
61
src/auth.ts
61
src/auth.ts
@ -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
71
src/index.ts
71
src/index.ts
@ -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'));
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user