Skip to content

Commit 6fca7ff

Browse files
committed
feat: enhance authentication with rate limiting and improve admin view styling
1 parent 64cb9c3 commit 6fca7ff

28 files changed

Lines changed: 674 additions & 22 deletions

packages/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"main": "index.js",
77
"scripts": {
8-
"test": "echo \"Error: no test specified\" && exit 1",
8+
"test": "node --import tsx --test src/**/*.test.ts",
99
"start": "tsx src/index.ts",
1010
"dev": "tsx src/index.ts",
1111
"db:migrate": "tsx src/meta/migrate.ts",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import test from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import type { AppConfig } from '../config/app-config.ts'
4+
import { createCorsOptions } from './cors.ts'
5+
6+
function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
7+
return {
8+
port: 3000,
9+
host: '127.0.0.1',
10+
publicUrl: undefined,
11+
corsAllowedOrigins: [],
12+
serverName: 'Hosted Server',
13+
mode: 'hosted',
14+
requestBodyLimit: '10mb',
15+
auth: {
16+
enabled: true,
17+
sessionTtlHours: 720,
18+
apiKeyTtlDays: 365,
19+
rateLimitWindowMs: 600000,
20+
rateLimitMaxAttempts: 10,
21+
oidcStateTtlMinutes: 15,
22+
},
23+
metaStore: {
24+
driver: 'sqlite',
25+
sqlitePath: '/tmp/starquery-auth-cors-test.sqlite',
26+
mysql: {
27+
host: '127.0.0.1',
28+
port: 3306,
29+
user: 'starquery',
30+
password: 'starquery',
31+
database: 'starquery',
32+
},
33+
},
34+
...overrides,
35+
}
36+
}
37+
38+
async function evaluateOrigin(config: AppConfig, origin?: string) {
39+
const options = createCorsOptions(config)
40+
if (typeof options.origin !== 'function') {
41+
return options.origin
42+
}
43+
44+
return await new Promise<boolean>((resolve, reject) => {
45+
options.origin!(origin, (error, allowed) => {
46+
if (error) {
47+
reject(error)
48+
return
49+
}
50+
51+
resolve(Boolean(allowed))
52+
})
53+
})
54+
}
55+
56+
test('hosted CORS allows the configured public origin', async () => {
57+
const config = createConfig({ publicUrl: 'https://app.example.com/' })
58+
assert.equal(await evaluateOrigin(config, 'https://app.example.com'), true)
59+
assert.equal(await evaluateOrigin(config, 'https://evil.example.com'), false)
60+
})
61+
62+
test('local mode keeps broad CORS for the desktop app flow', () => {
63+
const config = createConfig({ mode: 'local' })
64+
const options = createCorsOptions(config)
65+
assert.equal(options.origin, true)
66+
})

packages/backend/src/auth/cors.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { CorsOptions } from 'cors'
2+
import type { AppConfig } from '../config/app-config.ts'
3+
4+
function normalizeOrigin(value: string) {
5+
try {
6+
return new URL(value).origin
7+
} catch {
8+
return null
9+
}
10+
}
11+
12+
export function createCorsOptions(config: AppConfig): CorsOptions {
13+
if (config.mode === 'local') {
14+
return {
15+
origin: true,
16+
}
17+
}
18+
19+
const allowedOrigins = new Set(
20+
[
21+
...config.corsAllowedOrigins.map((origin) => normalizeOrigin(origin)).filter((origin): origin is string => Boolean(origin)),
22+
...(config.publicUrl ? [normalizeOrigin(config.publicUrl)] : []),
23+
].filter((origin): origin is string => Boolean(origin)),
24+
)
25+
26+
return {
27+
origin(origin, callback) {
28+
if (!origin) {
29+
callback(null, true)
30+
return
31+
}
32+
33+
const normalizedOrigin = normalizeOrigin(origin)
34+
callback(null, Boolean(normalizedOrigin && allowedOrigins.has(normalizedOrigin)))
35+
},
36+
}
37+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import test from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import type { AppContext } from '../app-context.ts'
4+
import type { AuthenticatedRequest } from './request.ts'
5+
import { authenticateRequest } from './middleware.ts'
6+
import { hashAuthToken } from './tokens.ts'
7+
8+
function createContext(overrides: Partial<AppContext['config']> = {}, metaStoreOverrides: Partial<AppContext['metaStore']> = {}) {
9+
return {
10+
config: {
11+
port: 3000,
12+
host: '127.0.0.1',
13+
publicUrl: undefined,
14+
serverName: 'Hosted Server',
15+
mode: 'hosted',
16+
requestBodyLimit: '10mb',
17+
auth: {
18+
enabled: true,
19+
sessionTtlHours: 720,
20+
apiKeyTtlDays: 365,
21+
rateLimitWindowMs: 600000,
22+
rateLimitMaxAttempts: 10,
23+
oidcStateTtlMinutes: 15,
24+
},
25+
metaStore: {
26+
driver: 'sqlite',
27+
sqlitePath: '/tmp/test.sqlite',
28+
mysql: {
29+
host: '127.0.0.1',
30+
port: 3306,
31+
user: 'starquery',
32+
password: 'starquery',
33+
database: 'starquery',
34+
},
35+
},
36+
...overrides,
37+
},
38+
metaStore: {
39+
getAuthTokenByHash: async () => null,
40+
deleteAuthToken: async () => undefined,
41+
getUserWithRoles: async () => null,
42+
updateAuthTokenLastUsed: async () => undefined,
43+
countUsers: async () => 0,
44+
...metaStoreOverrides,
45+
},
46+
} as unknown as AppContext
47+
}
48+
49+
function createRequest(token?: string) {
50+
return {
51+
headers: token ? { authorization: `Bearer ${token}` } : {},
52+
} as AuthenticatedRequest
53+
}
54+
55+
test('authenticateRequest grants full local access when auth is disabled', async () => {
56+
const context = createContext({
57+
mode: 'local',
58+
auth: {
59+
enabled: false,
60+
sessionTtlHours: 720,
61+
apiKeyTtlDays: 365,
62+
rateLimitWindowMs: 600000,
63+
rateLimitMaxAttempts: 10,
64+
oidcStateTtlMinutes: 15,
65+
},
66+
})
67+
const req = createRequest()
68+
69+
const auth = await authenticateRequest(context, req)
70+
71+
assert.equal(auth.kind, 'local')
72+
assert.deepEqual(auth.permissions, ['*'])
73+
})
74+
75+
test('authenticateRequest rejects expired tokens and deletes them', async () => {
76+
const deletions: string[] = []
77+
const rawToken = 'expired-token'
78+
const context = createContext(
79+
{},
80+
{
81+
getAuthTokenByHash: async (tokenHash: string) =>
82+
tokenHash === hashAuthToken(rawToken)
83+
? {
84+
id: 'token-1',
85+
userId: 'user-1',
86+
kind: 'session',
87+
name: 'Expired',
88+
tokenPrefix: 'expired',
89+
tokenHash,
90+
storage: 'local',
91+
expiresAt: '2000-01-01T00:00:00.000Z',
92+
lastUsedAt: null,
93+
createdAt: '2000-01-01T00:00:00.000Z',
94+
}
95+
: null,
96+
deleteAuthToken: async (tokenId: string) => {
97+
deletions.push(tokenId)
98+
},
99+
},
100+
)
101+
const req = createRequest(rawToken)
102+
103+
const auth = await authenticateRequest(context, req)
104+
105+
assert.equal(auth.kind, 'anonymous')
106+
assert.deepEqual(deletions, ['token-1'])
107+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import test from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import { clearRateLimit, consumeRateLimit, resetRateLimitState } from './rate-limit.ts'
4+
5+
test.afterEach(() => {
6+
resetRateLimitState()
7+
})
8+
9+
test('rate limiter blocks requests after the configured maximum', () => {
10+
const key = 'login:127.0.0.1:user@example.com'
11+
const now = 1_000
12+
13+
const first = consumeRateLimit(key, 60_000, 2, now)
14+
const second = consumeRateLimit(key, 60_000, 2, now + 1)
15+
const third = consumeRateLimit(key, 60_000, 2, now + 2)
16+
17+
assert.equal(first.allowed, true)
18+
assert.equal(second.allowed, true)
19+
assert.equal(third.allowed, false)
20+
assert.equal(third.remaining, 0)
21+
})
22+
23+
test('clearing a rate limit bucket resets the counter', () => {
24+
const key = 'login:127.0.0.1:user@example.com'
25+
const now = 2_000
26+
27+
consumeRateLimit(key, 60_000, 1, now)
28+
clearRateLimit(key)
29+
const next = consumeRateLimit(key, 60_000, 1, now + 1)
30+
31+
assert.equal(next.allowed, true)
32+
assert.equal(next.remaining, 0)
33+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
type RateLimitEntry = {
2+
count: number
3+
resetAt: number
4+
}
5+
6+
const authRateLimitState = new Map<string, RateLimitEntry>()
7+
8+
function cleanupExpiredEntries(now: number) {
9+
for (const [key, entry] of authRateLimitState.entries()) {
10+
if (entry.resetAt <= now) {
11+
authRateLimitState.delete(key)
12+
}
13+
}
14+
}
15+
16+
export function consumeRateLimit(key: string, windowMs: number, maxAttempts: number, now = Date.now()) {
17+
cleanupExpiredEntries(now)
18+
19+
const existing = authRateLimitState.get(key)
20+
if (!existing || existing.resetAt <= now) {
21+
authRateLimitState.set(key, {
22+
count: 1,
23+
resetAt: now + windowMs,
24+
})
25+
26+
return {
27+
allowed: true,
28+
remaining: Math.max(0, maxAttempts - 1),
29+
resetAt: now + windowMs,
30+
}
31+
}
32+
33+
existing.count += 1
34+
authRateLimitState.set(key, existing)
35+
36+
return {
37+
allowed: existing.count <= maxAttempts,
38+
remaining: Math.max(0, maxAttempts - existing.count),
39+
resetAt: existing.resetAt,
40+
}
41+
}
42+
43+
export function clearRateLimit(key: string) {
44+
authRateLimitState.delete(key)
45+
}
46+
47+
export function resetRateLimitState() {
48+
authRateLimitState.clear()
49+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import test from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import type { AppConfig } from '../config/app-config.ts'
4+
import { getAppBaseUrl, resolveSafeReturnTo } from './url.ts'
5+
import type { AuthenticatedRequest } from './request.ts'
6+
7+
function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
8+
return {
9+
port: 3000,
10+
host: '127.0.0.1',
11+
publicUrl: undefined,
12+
corsAllowedOrigins: [],
13+
serverName: 'Hosted Server',
14+
mode: 'hosted',
15+
requestBodyLimit: '10mb',
16+
auth: {
17+
enabled: true,
18+
sessionTtlHours: 720,
19+
apiKeyTtlDays: 365,
20+
rateLimitWindowMs: 600000,
21+
rateLimitMaxAttempts: 10,
22+
oidcStateTtlMinutes: 15,
23+
},
24+
metaStore: {
25+
driver: 'sqlite',
26+
sqlitePath: '/tmp/starquery-auth-url-test.sqlite',
27+
mysql: {
28+
host: '127.0.0.1',
29+
port: 3306,
30+
user: 'starquery',
31+
password: 'starquery',
32+
database: 'starquery',
33+
},
34+
},
35+
...overrides,
36+
}
37+
}
38+
39+
function createRequest(protocol = 'http', host = 'localhost:3000') {
40+
return {
41+
protocol,
42+
get(header: string) {
43+
return header.toLowerCase() === 'host' ? host : undefined
44+
},
45+
} as AuthenticatedRequest
46+
}
47+
48+
test('getAppBaseUrl prefers configured publicUrl over request headers', () => {
49+
const config = createConfig({ publicUrl: 'https://app.example.com/' })
50+
const req = createRequest('http', 'evil.example.com')
51+
52+
assert.equal(getAppBaseUrl(config, req), 'https://app.example.com/')
53+
})
54+
55+
test('resolveSafeReturnTo allows relative paths on the same origin', () => {
56+
const config = createConfig({ publicUrl: 'https://app.example.com/' })
57+
const req = createRequest()
58+
59+
assert.equal(resolveSafeReturnTo(config, req, '/workspaces/demo'), 'https://app.example.com/workspaces/demo')
60+
})
61+
62+
test('resolveSafeReturnTo rejects external redirect targets', () => {
63+
const config = createConfig({ publicUrl: 'https://app.example.com/' })
64+
const req = createRequest()
65+
66+
assert.throws(
67+
() => resolveSafeReturnTo(config, req, 'https://evil.example.com/steal'),
68+
/same origin/u,
69+
)
70+
})

0 commit comments

Comments
 (0)