Skip to content

Commit 7490068

Browse files
committed
feat: website auth middleware
1 parent dda5ddc commit 7490068

File tree

8 files changed

+308
-230
lines changed

8 files changed

+308
-230
lines changed

apps/api/src/index.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const UNAUTHORIZED_RE = /unauthorized/i;
2+
const FORBIDDEN_RE = /forbidden/i;
13
import './polyfills/compression';
24
import { auth } from '@databuddy/auth';
35
import { appRouter, createTRPCContext } from '@databuddy/rpc';
@@ -6,7 +8,6 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
68
import { autumnHandler } from 'autumn-js/elysia';
79
import { Elysia } from 'elysia';
810
import { logger } from './lib/logger';
9-
import { createAuthMiddleware } from './middleware/auth';
1011
import { assistant } from './routes/assistant';
1112
import { health } from './routes/health';
1213
import { query } from './routes/query';
@@ -23,7 +24,6 @@ const app = new Elysia()
2324
],
2425
})
2526
)
26-
.use(createAuthMiddleware())
2727
.use(
2828
autumnHandler({
2929
identify: async ({ request }) => {
@@ -57,7 +57,10 @@ const app = new Elysia()
5757
const errorMessage = error instanceof Error ? error.message : String(error);
5858
logger.error(errorMessage, { error });
5959

60-
if (error instanceof Error && error.message === 'Unauthorized') {
60+
if (
61+
error instanceof Error &&
62+
(UNAUTHORIZED_RE.test(error.message) || error.message === 'Unauthorized')
63+
) {
6164
return new Response(
6265
JSON.stringify({
6366
success: false,
@@ -71,7 +74,45 @@ const app = new Elysia()
7174
);
7275
}
7376

74-
return { success: false, code };
77+
if (
78+
error instanceof Error &&
79+
(FORBIDDEN_RE.test(error.message) || error.message === 'Forbidden')
80+
) {
81+
return new Response(
82+
JSON.stringify({
83+
success: false,
84+
error: 'Insufficient permissions',
85+
code: 'FORBIDDEN',
86+
}),
87+
{
88+
status: 403,
89+
headers: { 'Content-Type': 'application/json' },
90+
}
91+
);
92+
}
93+
94+
if (error instanceof Error && error.message === 'Website not found') {
95+
return new Response(
96+
JSON.stringify({
97+
success: false,
98+
error: 'Website not found',
99+
code: 'NOT_FOUND',
100+
}),
101+
{
102+
status: 404,
103+
headers: { 'Content-Type': 'application/json' },
104+
}
105+
);
106+
}
107+
108+
return new Response(
109+
JSON.stringify({
110+
success: false,
111+
error: errorMessage,
112+
code: code ?? 'INTERNAL_SERVER_ERROR',
113+
}),
114+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
115+
);
75116
});
76117

77118
export default {

apps/api/src/lib/api-key.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { InferSelectModel } from '@databuddy/db';
2+
import { and, apikey, apikeyAccess, db, eq, isNull } from '@databuddy/db';
3+
import { cacheable } from '@databuddy/redis';
4+
5+
export type ApiKeyRow = InferSelectModel<typeof apikey>;
6+
export type ApiScope = InferSelectModel<typeof apikey>['scopes'][number];
7+
8+
const getCachedApiKeyBySecret = cacheable(
9+
async (secret: string): Promise<ApiKeyRow | null> => {
10+
try {
11+
const key = await db.query.apikey.findFirst({
12+
where: and(
13+
eq(apikey.key, secret),
14+
eq(apikey.enabled, true),
15+
isNull(apikey.revokedAt)
16+
),
17+
});
18+
return key ?? null;
19+
} catch {
20+
return null;
21+
}
22+
},
23+
{
24+
expireInSec: 60,
25+
prefix: 'api-key-by-secret',
26+
staleWhileRevalidate: true,
27+
staleTime: 30,
28+
}
29+
);
30+
31+
const getCachedAccessEntries = cacheable(
32+
async (keyId: string) => {
33+
try {
34+
return await db
35+
.select()
36+
.from(apikeyAccess)
37+
.where(eq(apikeyAccess.apikeyId, keyId));
38+
} catch {
39+
return [] as InferSelectModel<typeof apikeyAccess>[];
40+
}
41+
},
42+
{
43+
expireInSec: 60,
44+
prefix: 'api-key-access-entries',
45+
staleWhileRevalidate: true,
46+
staleTime: 30,
47+
}
48+
);
49+
50+
export async function getApiKeyFromHeader(
51+
headers: Headers
52+
): Promise<ApiKeyRow | null> {
53+
const secret = headers.get('x-api-key');
54+
if (!secret) {
55+
return null;
56+
}
57+
const key = await getCachedApiKeyBySecret(secret);
58+
if (!key) {
59+
return null;
60+
}
61+
if (key.expiresAt && new Date(key.expiresAt) <= new Date()) {
62+
return null;
63+
}
64+
return key;
65+
}
66+
67+
export async function resolveEffectiveScopesForWebsite(
68+
key: ApiKeyRow,
69+
websiteId: string
70+
): Promise<Set<ApiScope>> {
71+
const effective = new Set<ApiScope>();
72+
for (const s of key.scopes) {
73+
effective.add(s as ApiScope);
74+
}
75+
76+
const entries = await getCachedAccessEntries(key.id);
77+
for (const entry of entries) {
78+
const isGlobal = entry.resourceType === 'global';
79+
const isWebsiteMatch =
80+
entry.resourceType === 'website' && entry.resourceId === websiteId;
81+
if (isGlobal || isWebsiteMatch) {
82+
for (const s of entry.scopes) {
83+
effective.add(s as ApiScope);
84+
}
85+
}
86+
}
87+
return effective;
88+
}
89+
90+
export async function hasWebsiteScope(
91+
key: ApiKeyRow,
92+
websiteId: string,
93+
required: ApiScope
94+
): Promise<boolean> {
95+
if ((key.scopes || []).includes(required)) {
96+
return true;
97+
}
98+
const effective = await resolveEffectiveScopesForWebsite(key, websiteId);
99+
return effective.has(required);
100+
}

apps/api/src/lib/website-utils.ts

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { db, userPreferences, websites } from '@databuddy/db';
33
import { cacheable } from '@databuddy/redis';
44
import type { Website } from '@databuddy/shared';
55
import { eq } from 'drizzle-orm';
6+
import { getApiKeyFromHeader, hasWebsiteScope } from './api-key';
67

78
export interface WebsiteContext {
89
user: unknown;
@@ -93,19 +94,7 @@ const userPreferencesCache = cacheable(
9394
}
9495
);
9596

96-
const getCachedSession = cacheable(
97-
async (headers: Headers) => {
98-
return await auth.api.getSession({
99-
headers,
100-
});
101-
},
102-
{
103-
expireInSec: 60,
104-
prefix: 'auth-session',
105-
staleWhileRevalidate: true,
106-
staleTime: 30,
107-
}
108-
);
97+
// Removed unused cached session helper to satisfy linter rules
10998

11099
export async function getTimezone(
111100
request: Request,
@@ -126,39 +115,79 @@ export async function getTimezone(
126115
}
127116

128117
export async function deriveWebsiteContext({ request }: { request: Request }) {
118+
const apiKeyPresent = request.headers.get('x-api-key') != null;
119+
if (apiKeyPresent) {
120+
return await deriveWithApiKey(request);
121+
}
122+
return await deriveWithSession(request);
123+
}
124+
125+
async function deriveWithApiKey(request: Request) {
129126
const url = new URL(request.url);
130-
const website_id = url.searchParams.get('website_id');
127+
const siteId = url.searchParams.get('website_id');
128+
129+
const key = await getApiKeyFromHeader(request.headers);
130+
if (!key) {
131+
throw new Error('Unauthorized');
132+
}
131133

132-
const session = await getCachedSession(request.headers);
134+
if (!siteId) {
135+
const timezoneNoSite = await getTimezone(request, null);
136+
return { user: null, session: null, timezone: timezoneNoSite } as const;
137+
}
138+
139+
const [site, timezone] = await Promise.all([
140+
getCachedWebsite(siteId),
141+
getTimezone(request, null),
142+
]);
133143

134-
if (!website_id) {
144+
if (!site) {
145+
throw new Error('Website not found');
146+
}
147+
148+
if (site.isPublic) {
149+
return { user: null, session: null, website: site, timezone } as const;
150+
}
151+
152+
const canRead = await hasWebsiteScope(key, siteId, 'read:data');
153+
if (!canRead) {
154+
throw new Error('Forbidden');
155+
}
156+
157+
return { user: null, session: null, website: site, timezone } as const;
158+
}
159+
160+
async function deriveWithSession(request: Request) {
161+
const url = new URL(request.url);
162+
const websiteId = url.searchParams.get('website_id');
163+
const session = await auth.api.getSession({ headers: request.headers });
164+
165+
if (!websiteId) {
135166
if (!session?.user) {
136167
throw new Error('Unauthorized');
137168
}
138-
const timezone = await getTimezone(request, session);
139-
return { user: session.user, session, timezone };
169+
const tz = await getTimezone(request, session);
170+
return { user: session.user, session, timezone: tz } as const;
140171
}
141172

142-
const [website, timezone] = await Promise.all([
143-
getCachedWebsite(website_id),
144-
website_id && session?.user
145-
? getTimezone(request, session)
146-
: getTimezone(request, null),
147-
]);
173+
const tz = session?.user
174+
? await getTimezone(request, session)
175+
: await getTimezone(request, null);
176+
const site = await getCachedWebsite(websiteId);
148177

149-
if (!website) {
178+
if (!site) {
150179
throw new Error('Website not found');
151180
}
152181

153-
if (website.isPublic) {
154-
return { user: null, session: null, website, timezone };
182+
if (site.isPublic) {
183+
return { user: null, session: null, website: site, timezone: tz } as const;
155184
}
156185

157186
if (!session?.user) {
158187
throw new Error('Unauthorized');
159188
}
160189

161-
return { user: session.user, session, website, timezone };
190+
return { user: session.user, session, website: site, timezone: tz } as const;
162191
}
163192

164193
export async function validateWebsite(

0 commit comments

Comments
 (0)