Skip to content

Commit f5875c8

Browse files
Add personal API tokens and a public REST API (v1)
Authenticated automation for a user's own account, the grown-up version of Quick-codes: - ApiToken model (+ migration): only a SHA-256 hash of the ocl_… secret is stored; CSV scopes (read/write), optional single-folder restriction, optional expiry, lastUsedAt. - Bearer auth (app.tokenAuth(scope)) — no cookie, so not subject to CSRF. Throttled last-used. - /api/v1: GET /me, GET+POST /folders, GET+POST /files (multipart upload), GET /files/:id/download. Vault folders are rejected (server can't decrypt ZK). - Reusable storeUserFile() pipeline (quota + AV + server encryption + text versioning), shared with the app upload route's behaviour and ready for WebDAV. - Account ▸ API tokens UI: create (name/scopes/folder/expiry), shown-once secret, list with last-used, revoke. docs/API.md with curl examples. FR/EN parity (438).
1 parent d690472 commit f5875c8

15 files changed

Lines changed: 646 additions & 4 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- CreateTable
2+
CREATE TABLE "ApiToken" (
3+
"id" TEXT NOT NULL,
4+
"ownerId" TEXT NOT NULL,
5+
"name" TEXT NOT NULL,
6+
"tokenHash" TEXT NOT NULL,
7+
"prefix" TEXT NOT NULL,
8+
"scopes" TEXT NOT NULL,
9+
"folderId" TEXT,
10+
"expiresAt" TIMESTAMP(3),
11+
"lastUsedAt" TIMESTAMP(3),
12+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
13+
14+
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
15+
);
16+
17+
-- CreateIndex
18+
CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
19+
20+
-- CreateIndex
21+
CREATE INDEX "ApiToken_ownerId_idx" ON "ApiToken"("ownerId");
22+
23+
-- AddForeignKey
24+
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/api/prisma/schema.prisma

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ model User {
6969
auditLogs AuditLog[]
7070
shares ShareLink[]
7171
recoveryCodes RecoveryCode[]
72+
apiTokens ApiToken[]
7273
7374
@@index([email])
7475
}
@@ -275,6 +276,25 @@ model Setting {
275276
updatedAt DateTime @updatedAt
276277
}
277278

279+
// A personal access token for the public REST API (Authorization: Bearer ocl_…). Only a SHA-256
280+
// hash of the secret is stored; the plaintext is shown once at creation. `scopes` is a CSV of
281+
// "read"/"write"; `folderId` optionally restricts the token to one folder (and its subtree).
282+
model ApiToken {
283+
id String @id @default(cuid())
284+
ownerId String
285+
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
286+
name String
287+
tokenHash String @unique
288+
prefix String
289+
scopes String
290+
folderId String?
291+
expiresAt DateTime?
292+
lastUsedAt DateTime?
293+
createdAt DateTime @default(now())
294+
295+
@@index([ownerId])
296+
}
297+
278298
// A shareable link to a SERVER-mode file or folder. ZK targets can't be shared (the
279299
// server can't decrypt them). The opaque `token` is the URL slug.
280300
model ShareLink {

apps/api/src/fastify.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ declare module 'fastify' {
88
requireAuth: import('fastify').preHandlerHookHandler;
99
/** preHandler: require an authenticated ADMIN (+ CSRF on mutations). */
1010
requireAdmin: import('fastify').preHandlerHookHandler;
11+
/** preHandler factory: require a valid API token (Authorization: Bearer) with a scope. */
12+
tokenAuth: (scope: 'read' | 'write') => import('fastify').preHandlerHookHandler;
1113
}
1214

1315
interface FastifyRequest {
1416
user: { id: string; email: string; role: 'ADMIN' | 'USER'; disabled: boolean } | null;
1517
sessionData: { id: string; csrfSecret: string } | null;
18+
/** Set when the request authenticated via an API token instead of a session. */
19+
apiToken: { id: string; ownerId: string; scopes: string[]; folderId: string | null } | null;
1620
}
1721
}

apps/api/src/lib/serialize.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* response by accident — only the fields listed here are ever serialised.
55
*/
66
import type {
7+
ApiToken,
78
FileObject,
89
Folder,
910
QuickUploadCode,
@@ -12,6 +13,7 @@ import type {
1213
User,
1314
} from '@prisma/client';
1415
import type {
16+
PublicApiToken,
1517
PublicFile,
1618
PublicFolder,
1719
PublicQuickCode,
@@ -20,6 +22,19 @@ import type {
2022
PublicUser,
2123
} from '@opencoperlock/shared';
2224

25+
export function toPublicApiToken(t: ApiToken): PublicApiToken {
26+
return {
27+
id: t.id,
28+
name: t.name,
29+
prefix: t.prefix,
30+
scopes: t.scopes ? t.scopes.split(',').filter(Boolean) : [],
31+
folderId: t.folderId,
32+
expiresAt: t.expiresAt ? t.expiresAt.toISOString() : null,
33+
lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null,
34+
createdAt: t.createdAt.toISOString(),
35+
};
36+
}
37+
2338
export function toPublicUser(u: User): PublicUser {
2439
return {
2540
id: u.id,

apps/api/src/plugins/auth.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
1010
import fp from 'fastify-plugin';
1111
import { CSRF_HEADER, SESSION_COOKIE, safeEqual } from '@opencoperlock/shared';
1212
import { getSession, touchSession } from '../services/session.js';
13+
import { authenticateToken, type ApiScope } from '../services/apiToken.js';
1314

1415
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
1516

1617
const authPlugin: FastifyPluginAsync = async (app) => {
1718
app.decorateRequest('user', null);
1819
app.decorateRequest('sessionData', null);
20+
app.decorateRequest('apiToken', null);
1921

2022
// Resolve the session for every request (non-blocking; routes decide if it's required).
2123
app.addHook('onRequest', async (req) => {
@@ -53,6 +55,14 @@ const authPlugin: FastifyPluginAsync = async (app) => {
5355
if (req.user.role !== 'ADMIN') return reply.code(403).send({ error: 'Admin access required' });
5456
if (!enforceCsrf(req, reply)) return reply;
5557
});
58+
59+
// Bearer-token auth for the public REST API. No cookie is involved, so CSRF does not apply.
60+
app.decorate('tokenAuth', (scope: ApiScope) => async (req: FastifyRequest, reply: FastifyReply) => {
61+
const result = await authenticateToken(req.headers.authorization, scope);
62+
if (!result.ok) return reply.code(result.status).send({ error: result.error });
63+
req.user = result.owner;
64+
req.apiToken = result.token;
65+
});
5666
};
5767

5868
export default fp(authPlugin, { name: 'auth' });

apps/api/src/routes/account.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
* permanently delete your account. Both are scoped strictly to the requesting user.
44
*/
55
import type { FastifyPluginAsync } from 'fastify';
6-
import { passwordConfirmSchema, createQuickCodeSchema } from '@opencoperlock/shared';
6+
import { passwordConfirmSchema, createQuickCodeSchema, createApiTokenSchema } from '@opencoperlock/shared';
77
import { prisma } from '../db.js';
88
import { parseOr400 } from '../lib/validate.js';
99
import { verifyPassword, hashPassword } from '../services/password.js';
1010
import { remainingRecoveryCodes } from '../services/recovery.js';
11-
import { toPublicFile, toPublicFolder, toPublicShare, toPublicUser, toPublicQuickCode } from '../lib/serialize.js';
11+
import { toPublicFile, toPublicFolder, toPublicShare, toPublicUser, toPublicQuickCode, toPublicApiToken } from '../lib/serialize.js';
1212
import { clearSessionCookie } from '../lib/cookies.js';
1313
import { audit } from '../services/audit.js';
1414
import { generateUniqueQuickCode, isCodeTakenError } from '../services/quickCode.js';
15+
import { generateToken } from '../services/apiToken.js';
1516

1617
export const accountRoutes: FastifyPluginAsync = async (app) => {
1718
app.addHook('preHandler', app.requireAuth);
@@ -82,6 +83,51 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
8283
return { ok: true };
8384
});
8485

86+
// ── Personal API tokens ──────────────────────────────────────────────────────
87+
app.get('/api-tokens', async (req) => {
88+
const tokens = await prisma.apiToken.findMany({
89+
where: { ownerId: req.user!.id },
90+
orderBy: { createdAt: 'desc' },
91+
});
92+
return { tokens: tokens.map(toPublicApiToken) };
93+
});
94+
95+
app.post('/api-tokens', async (req, reply) => {
96+
const body = parseOr400(reply, createApiTokenSchema, req.body);
97+
if (!body) return;
98+
99+
if (body.folderId) {
100+
const folder = await prisma.folder.findFirst({ where: { id: body.folderId, ownerId: req.user!.id } });
101+
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
102+
if (folder.isZeroKnowledge) return reply.code(400).send({ error: 'A vault cannot be an API target' });
103+
}
104+
105+
const { token, hash, prefix } = generateToken();
106+
const created = await prisma.apiToken.create({
107+
data: {
108+
ownerId: req.user!.id,
109+
name: body.name,
110+
tokenHash: hash,
111+
prefix,
112+
scopes: body.scopes.join(','),
113+
folderId: body.folderId ?? null,
114+
expiresAt: body.expiresInDays ? new Date(Date.now() + body.expiresInDays * 86_400_000) : null,
115+
},
116+
});
117+
await audit(req, 'account.apitoken.create', { target: created.id });
118+
// The plaintext token is returned ONCE here and never stored or shown again.
119+
return reply.code(201).send({ token, apiToken: toPublicApiToken(created) });
120+
});
121+
122+
app.delete('/api-tokens/:id', async (req, reply) => {
123+
const { id } = req.params as { id: string };
124+
const existing = await prisma.apiToken.findFirst({ where: { id, ownerId: req.user!.id } });
125+
if (!existing) return reply.code(404).send({ error: 'Token not found' });
126+
await prisma.apiToken.delete({ where: { id } });
127+
await audit(req, 'account.apitoken.delete', { target: id });
128+
return { ok: true };
129+
});
130+
85131
// GET /account/export — a JSON copy of the user's data (metadata, not file contents).
86132
app.get('/export', async (req, reply) => {
87133
const userId = req.user!.id;

apps/api/src/routes/api-v1.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Public REST API (v1), authenticated by a personal API token: `Authorization: Bearer ocl_…`.
3+
* Lets a user's own scripts/automations list folders, upload files into a folder, and download
4+
* them — scoped to the token's permissions (read/write) and optional folder restriction.
5+
*
6+
* Zero-Knowledge vaults are encrypted in the browser, so the server API cannot read or write
7+
* them; only normal (SERVER-encrypted) folders are accessible here.
8+
*/
9+
import type { FastifyPluginAsync } from 'fastify';
10+
import { prisma } from '../db.js';
11+
import { decryptServerFile } from '../services/download.js';
12+
import { FileTooLargeError, InfectedFileError } from '../services/ingest.js';
13+
import { storeUserFile, QuotaExhaustedError } from '../services/upload.js';
14+
import { toPublicFile, toPublicFolder } from '../lib/serialize.js';
15+
import { audit } from '../services/audit.js';
16+
17+
export const apiV1Routes: FastifyPluginAsync = async (app) => {
18+
// A token may be confined to one folder; everything it touches must match (MVP: exact folder).
19+
const folderAllowed = (req: { apiToken: { folderId: string | null } | null }, folderId: string | null) =>
20+
!req.apiToken?.folderId || req.apiToken.folderId === folderId;
21+
22+
// GET /me — confirm a token works and show what it can do.
23+
app.get('/me', { preHandler: app.tokenAuth('read') }, async (req) => ({
24+
user: { id: req.user!.id, email: req.user!.email },
25+
token: { scopes: req.apiToken!.scopes, folderId: req.apiToken!.folderId },
26+
}));
27+
28+
// GET /folders — list the user's normal folders.
29+
app.get('/folders', { preHandler: app.tokenAuth('read') }, async (req) => {
30+
const folders = await prisma.folder.findMany({
31+
where: { ownerId: req.user!.id },
32+
orderBy: { name: 'asc' },
33+
});
34+
return { folders: folders.map(toPublicFolder) };
35+
});
36+
37+
// POST /folders — create a folder ({ name, parentId? }).
38+
app.post('/folders', { preHandler: app.tokenAuth('write') }, async (req, reply) => {
39+
const body = (req.body ?? {}) as { name?: unknown; parentId?: unknown };
40+
const name = typeof body.name === 'string' ? body.name.trim() : '';
41+
if (!name) return reply.code(400).send({ error: 'name is required' });
42+
const parentId = typeof body.parentId === 'string' ? body.parentId : null;
43+
44+
if (parentId) {
45+
const parent = await prisma.folder.findFirst({ where: { id: parentId, ownerId: req.user!.id } });
46+
if (!parent) return reply.code(404).send({ error: 'Parent folder not found' });
47+
if (parent.isZeroKnowledge) return reply.code(400).send({ error: 'Vault folders are not accessible via the API' });
48+
}
49+
if (!folderAllowed(req, parentId)) return reply.code(403).send({ error: 'Token is restricted to another folder' });
50+
51+
const folder = await prisma.folder.create({
52+
data: { ownerId: req.user!.id, name, parentId, isZeroKnowledge: false },
53+
});
54+
await audit(req, 'api.folder.create', { actorId: req.user!.id, target: folder.id });
55+
return reply.code(201).send({ folder: toPublicFolder(folder) });
56+
});
57+
58+
// GET /files?folderId= — list files in a folder (omit folderId for the account root).
59+
app.get('/files', { preHandler: app.tokenAuth('read') }, async (req, reply) => {
60+
const { folderId } = req.query as { folderId?: string };
61+
const target = folderId ?? null;
62+
if (!folderAllowed(req, target)) return reply.code(403).send({ error: 'Token is restricted to another folder' });
63+
if (target) {
64+
const folder = await prisma.folder.findFirst({ where: { id: target, ownerId: req.user!.id } });
65+
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
66+
}
67+
const files = await prisma.fileObject.findMany({
68+
where: { ownerId: req.user!.id, folderId: target, encMode: 'SERVER', deletedAt: null },
69+
orderBy: { name: 'asc' },
70+
});
71+
return { files: files.map(toPublicFile) };
72+
});
73+
74+
// POST /files?folderId= — upload a single file (multipart/form-data, field "file").
75+
app.post('/files', { preHandler: app.tokenAuth('write') }, async (req, reply) => {
76+
const { folderId } = req.query as { folderId?: string };
77+
const target = folderId ?? req.apiToken!.folderId ?? null;
78+
if (!folderAllowed(req, target)) return reply.code(403).send({ error: 'Token is restricted to another folder' });
79+
80+
if (target) {
81+
const folder = await prisma.folder.findFirst({ where: { id: target, ownerId: req.user!.id } });
82+
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
83+
if (folder.isZeroKnowledge) return reply.code(400).send({ error: 'Vault folders are not accessible via the API' });
84+
}
85+
86+
const part = await req.file();
87+
if (!part) return reply.code(400).send({ error: 'No file provided (multipart field "file")' });
88+
89+
try {
90+
const { file } = await storeUserFile(app.ctx, {
91+
ownerId: req.user!.id,
92+
folderId: target,
93+
stream: part.file,
94+
filename: part.filename,
95+
mimetype: part.mimetype,
96+
});
97+
await audit(req, 'api.file.upload', { actorId: req.user!.id, target: file.id });
98+
return reply.code(201).send({ file: toPublicFile(file) });
99+
} catch (err) {
100+
if (err instanceof QuotaExhaustedError || err instanceof FileTooLargeError) {
101+
return reply.code(413).send({ error: 'Upload exceeds your available quota' });
102+
}
103+
if (err instanceof InfectedFileError) {
104+
return reply.code(422).send({ error: `File rejected: ${err.signature}`, code: 'INFECTED' });
105+
}
106+
throw err;
107+
}
108+
});
109+
110+
// GET /files/:id/download — stream a file's decrypted contents.
111+
app.get('/files/:id/download', { preHandler: app.tokenAuth('read') }, async (req, reply) => {
112+
const { id } = req.params as { id: string };
113+
const file = await prisma.fileObject.findFirst({
114+
where: { id, ownerId: req.user!.id, encMode: 'SERVER', deletedAt: null },
115+
});
116+
if (!file) return reply.code(404).send({ error: 'File not found' });
117+
if (!folderAllowed(req, file.folderId)) return reply.code(403).send({ error: 'Token is restricted to another folder' });
118+
119+
reply
120+
.header('Content-Type', file.mimeType)
121+
.header('Content-Length', Number(file.sizeBytes))
122+
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`);
123+
return reply.send(decryptServerFile(app.ctx, file));
124+
});
125+
};

apps/api/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { sharePublicRoutes } from './routes/share-public.js';
2020
import { twoFactorRoutes } from './routes/twofa.js';
2121
import { accountRoutes } from './routes/account.js';
2222
import { adminRoutes } from './routes/admin.js';
23+
import { apiV1Routes } from './routes/api-v1.js';
2324

2425
export async function buildServer(ctx: AppContext): Promise<FastifyInstance> {
2526
const app = Fastify({
@@ -82,6 +83,7 @@ export async function buildServer(ctx: AppContext): Promise<FastifyInstance> {
8283
await app.register(twoFactorRoutes, { prefix: '/2fa' });
8384
await app.register(accountRoutes, { prefix: '/account' });
8485
await app.register(adminRoutes, { prefix: '/admin' });
86+
await app.register(apiV1Routes, { prefix: '/api/v1' });
8587

8688
app.setErrorHandler((err: { statusCode?: number; message?: string }, req, reply) => {
8789
req.log.error({ err }, 'request failed');

0 commit comments

Comments
 (0)