Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 76 additions & 39 deletions api/middlewares/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from 'crypto';
import type { Context } from 'hono';
import { createMiddleware } from 'hono/factory';
import { auth } from '../auth';
import prisma from '../lib/db';
Expand All @@ -10,6 +11,61 @@ type Env = {
};
};

type ApiKeyAuthResult = { user: typeof auth.$Infer.Session.user } | { error: string; status: 401 };
type AuthContext = Context<Env>;

async function authenticateApiKeyHeader(authHeader: string | undefined): Promise<ApiKeyAuthResult> {
if (!authHeader?.startsWith('Bearer ')) {
return { error: 'Unauthorized', status: 401 };
}

const apiKey = authHeader.substring(7);
if (!apiKey.startsWith('hemmelig_')) {
return { error: 'Invalid API key format', status: 401 };
}

try {
const keyHash = createHash('sha256').update(apiKey).digest('hex');

const apiKeyRecord = await prisma.apiKey.findUnique({
where: { keyHash },
include: { user: true },
});

if (!apiKeyRecord) {
return { error: 'Invalid API key', status: 401 };
}

if (apiKeyRecord.expiresAt && new Date() > apiKeyRecord.expiresAt) {
return { error: 'API key has expired', status: 401 };
}

prisma.apiKey
.update({
where: { id: apiKeyRecord.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});

return { user: apiKeyRecord.user as typeof auth.$Infer.Session.user };
} catch (error) {
console.error('API key auth error:', error);
return { error: 'Authentication failed', status: 401 };
}
}

async function setApiKeyUserFromHeader(c: AuthContext, authHeader: string | undefined) {
const result = await authenticateApiKeyHeader(authHeader);
if ('error' in result) {
return c.json({ error: result.error }, result.status);
}

c.set('user', result.user);
c.set('session', null);

return null;
}

export const authMiddleware = createMiddleware<Env>(async (c, next) => {
const user = c.get('user');
if (!user) {
Expand Down Expand Up @@ -43,49 +99,30 @@ export const apiKeyOrAuthMiddleware = createMiddleware<Env>(async (c, next) => {
return next();
}

// Check for API key in Authorization header
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}

const apiKey = authHeader.substring(7);
if (!apiKey.startsWith('hemmelig_')) {
return c.json({ error: 'Invalid API key format' }, 401);
const authResponse = await setApiKeyUserFromHeader(c, c.req.header('Authorization'));
if (authResponse) {
return authResponse;
}

try {
const keyHash = createHash('sha256').update(apiKey).digest('hex');

const apiKeyRecord = await prisma.apiKey.findUnique({
where: { keyHash },
include: { user: true },
});

if (!apiKeyRecord) {
return c.json({ error: 'Invalid API key' }, 401);
}

// Check if key is expired
if (apiKeyRecord.expiresAt && new Date() > apiKeyRecord.expiresAt) {
return c.json({ error: 'API key has expired' }, 401);
}

// Update last used timestamp (fire and forget)
prisma.apiKey
.update({
where: { id: apiKeyRecord.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
return next();
});

// Set user from API key
c.set('user', apiKeyRecord.user as typeof auth.$Infer.Session.user);
c.set('session', null);
// Middleware that accepts session auth OR API key auth, but also allows anonymous access
export const optionalApiKeyOrAuthMiddleware = createMiddleware<Env>(async (c, next) => {
const sessionUser = c.get('user');
if (sessionUser) {
return next();
}

const authHeader = c.req.header('Authorization');
if (!authHeader) {
return next();
} catch (error) {
console.error('API key auth error:', error);
return c.json({ error: 'Authentication failed' }, 401);
}

const authResponse = await setApiKeyUserFromHeader(c, authHeader);
if (authResponse) {
return authResponse;
}

return next();
});
29 changes: 25 additions & 4 deletions api/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ const spec = {
tags: ['Secrets'],
summary: 'List user secrets',
description: 'Get paginated list of secrets created by the authenticated user',
security: [{ cookieAuth: [] }],
security: [{ cookieAuth: [] }, { bearerAuth: [] }],
parameters: [
{
name: 'page',
Expand Down Expand Up @@ -176,7 +176,8 @@ const spec = {
tags: ['Secrets'],
summary: 'Create a new secret',
description:
'Create a new encrypted secret. The secret content should be encrypted client-side before sending.',
'Create a new encrypted secret. Authentication is optional: anonymous creation works when the instance does not require registered users, and authenticated creation supports either a session cookie or Authorization: Bearer hemmelig_... . The secret and title fields must be sent as JSON-serialized Uint8Array objects.',
security: [{}, { cookieAuth: [] }, { bearerAuth: [] }],
requestBody: {
required: true,
content: {
Expand Down Expand Up @@ -1369,8 +1370,28 @@ const spec = {
type: 'object',
required: ['secret', 'salt', 'expiresAt'],
properties: {
secret: { type: 'string', description: 'Encrypted secret content' },
title: { type: 'string', nullable: true },
secret: {
type: 'object',
additionalProperties: {
type: 'integer',
minimum: 0,
maximum: 255,
},
description:
'Encrypted secret content sent as a JSON-serialized Uint8Array',
example: { '0': 116, '1': 101, '2': 115, '3': 116 },
},
title: {
type: 'object',
nullable: true,
additionalProperties: {
type: 'integer',
minimum: 0,
maximum: 255,
},
description: 'Encrypted title as a JSON-serialized Uint8Array',
example: { '0': 78, '1': 111, '2': 116, '3': 101 },
},
salt: { type: 'string', description: 'Salt used for encryption' },
password: { type: 'string', description: 'Optional password protection' },
expiresAt: {
Expand Down
112 changes: 61 additions & 51 deletions api/routes/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { buildPaginationMeta } from '../lib/route-utils';
import { resolveSettings } from '../lib/settings';
import { handleNotFound } from '../lib/utils';
import { sendWebhook } from '../lib/webhook';
import { apiKeyOrAuthMiddleware } from '../middlewares/auth';
import { apiKeyOrAuthMiddleware, optionalApiKeyOrAuthMiddleware } from '../middlewares/auth';
import { ipRestriction } from '../middlewares/ip-restriction';
import {
createSecretsSchema,
Expand Down Expand Up @@ -231,68 +231,78 @@ const app = new Hono<{
);
}
})
.post('/', zValidator('json', createSecretsSchema), async (c) => {
try {
const user = c.get('user');

// Check if only registered users can create secrets
const settings = await resolveSettings();
if (settings?.requireRegisteredUser && !user) {
return c.json({ error: 'Only registered users can create secrets' }, 401);
}

const validatedData = c.req.valid('json');
.post(
'/',
optionalApiKeyOrAuthMiddleware,
zValidator('json', createSecretsSchema),
async (c) => {
try {
const user = c.get('user');

// Enforce dynamic maxSecretSize from instance settings (in KB)
const maxSizeKB = settings?.maxSecretSize ?? 1024;
const maxSizeBytes = maxSizeKB * 1024;
if (validatedData.secret.length > maxSizeBytes) {
return c.json({ error: `Secret exceeds maximum size of ${maxSizeKB} KB` }, 413);
}
// Check if only registered users can create secrets
const settings = await resolveSettings();
if (settings?.requireRegisteredUser && !user) {
return c.json({ error: 'Only registered users can create secrets' }, 401);
}

const { expiresAt, password, fileIds, salt, title, ...rest } = validatedData;

const data: SecretCreateData = {
...rest,
salt,
// Title is required by the database, default to empty Uint8Array if not provided
title: title ?? new Uint8Array(0),
password: password ? await hash(password) : null,
expiresAt: new Date(Date.now() + expiresAt * 1000),
...(fileIds && {
files: { connect: fileIds.map((id: string) => ({ id })) },
}),
};
const validatedData = c.req.valid('json');

if (user) {
data.userId = user.id;
}
// Enforce dynamic maxSecretSize from instance settings (in KB)
const maxSizeKB = settings?.maxSecretSize ?? 1024;
const maxSizeBytes = maxSizeKB * 1024;
if (validatedData.secret.length > maxSizeBytes) {
return c.json({ error: `Secret exceeds maximum size of ${maxSizeKB} KB` }, 413);
}

const item = await prisma.secrets.create({ data });
const { expiresAt, password, fileIds, salt, title, ...rest } = validatedData;

const data: SecretCreateData = {
...rest,
salt,
// Title is required by the database, default to empty Uint8Array if not provided
title: title ?? new Uint8Array(0),
password: password ? await hash(password) : null,
expiresAt: new Date(Date.now() + expiresAt * 1000),
...(fileIds && {
files: { connect: fileIds.map((id: string) => ({ id })) },
}),
};

if (user) {
data.userId = user.id;
}

return c.json({ id: item.id }, 201);
} catch (error: unknown) {
console.error('Failed to create secrets:', error);
const item = await prisma.secrets.create({ data });

return c.json({ id: item.id }, 201);
} catch (error: unknown) {
console.error('Failed to create secrets:', error);

if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'P2002'
) {
const prismaError = error as { meta?: { target?: string } };
return c.json(
{
error: 'Could not create secrets',
details: prismaError.meta?.target,
},
409
);
}

if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
const prismaError = error as { meta?: { target?: string } };
return c.json(
{
error: 'Could not create secrets',
details: prismaError.meta?.target,
error: 'Failed to create secret',
},
409
500
);
}

return c.json(
{
error: 'Failed to create secret',
},
500
);
}
})
)
.delete('/:id', zValidator('param', secretsIdParamSchema), async (c) => {
try {
const { id } = c.req.valid('param');
Expand Down
Loading