Skip to content
Merged
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
6 changes: 3 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
"lint:fix": "eslint . --ext .ts --fix"
},
"dependencies": {
"@better-auth/passkey": "^1.4.17",
"@better-auth/passkey": "^1.7.12",
"@hono/node-server": "^1.19.17",
"@hono/node-ws": "^1.3.1",
"@opencode-manager/shared": "workspace:*",
"archiver": "^7.0.1",
"better-auth": "^1.4.17",
"better-auth": "^1.7.12",
"croner": "^10.0.1",
"eventsource": "^4.1.0",
"hono": "^4.11.7",
"hono": "^4.13.13",
"jsonc-parser": "^3.3.1",
"web-push": "^3.6.7",
"zod": "^4.1.12"
Expand Down
6 changes: 1 addition & 5 deletions backend/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,13 @@ export function createAuth(db: Database) {
origin: ENV.AUTH.PASSKEY_ORIGIN,
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
userVerification: 'required',
},
}),
],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
maxAge: 60 * 5,
},
},
user: {
additionalFields: {
Expand Down
7 changes: 1 addition & 6 deletions backend/src/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export function createAuthMiddleware(
user: Session['user']
}
}>(async (c, next) => {
const cookies = c.req.header('cookie')
const origin = c.req.header('origin')

const fetchSite = c.req.header('sec-fetch-site')
Expand All @@ -30,11 +29,7 @@ export function createAuthMiddleware(
return c.json({ error: 'Cross-site request rejected' }, 403)
}

logger.debug(`Auth check - Path: ${c.req.path}, Origin: ${origin}, Has cookies: ${!!cookies}`)
if (cookies) {
const cookieNames = cookies.split(';').map(c => c.trim().split('=')[0]).join(', ')
logger.debug(`Cookie names: ${cookieNames}`)
}
logger.debug(`Auth check - Path: ${c.req.path}, Origin: ${origin}`)

let session
try {
Expand Down
30 changes: 11 additions & 19 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,7 @@ import { hashPassword } from 'better-auth/crypto'
export function createAuthRoutes(auth: AuthInstance): Hono {
const app = new Hono()

app.all('/*', async (c) => {
const response = await auth.handler(c.req.raw)

const setCookie = response.headers.get('set-cookie')
if (c.req.path.includes('sign-in')) {
logger.info(`Sign-in response - Status: ${response.status}, Set-Cookie: ${setCookie ? 'present' : 'missing'}`)
if (setCookie) {
logger.debug(`Set-Cookie header: ${setCookie.substring(0, 100)}...`)
}
}

return response
})
app.all('/*', (c) => auth.handler(c.req.raw))

return app
}
Expand All @@ -31,16 +19,20 @@ export async function syncAdminFromEnv(auth: AuthInstance, db: Database): Promis
const adminEmail = ENV.AUTH.ADMIN_EMAIL!
const adminPassword = ENV.AUTH.ADMIN_PASSWORD!

const existingUser = db.prepare('SELECT id, email FROM "user" WHERE email = ?').get(adminEmail) as { id: string; email: string } | undefined
const existingUser = db.prepare('SELECT id, email FROM "user" WHERE lower(email) = lower(?)').get(adminEmail) as { id: string; email: string } | undefined

if (existingUser) {
if (ENV.AUTH.ADMIN_PASSWORD_RESET) {
const hashedPassword = await hashPassword(adminPassword)
db.prepare('UPDATE "account" SET password = ? WHERE "userId" = ? AND "providerId" = ?').run(
hashedPassword,
existingUser.id,
'credential'
)
const resetAdminCredentials = db.transaction(() => {
db.prepare('UPDATE "account" SET password = ? WHERE "userId" = ? AND "providerId" = ?').run(
hashedPassword,
existingUser.id,
'credential'
)
db.prepare('DELETE FROM "session" WHERE "userId" = ?').run(existingUser.id)
Comment thread
chriswritescode-dev marked this conversation as resolved.
})
resetAdminCredentials()
logger.info(`Admin password reset from environment for ${adminEmail}`)
logger.warn('Remove ADMIN_PASSWORD_RESET=true from environment after password reset')
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/repo-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function createRepoGitRoutes(
logMessage: string,
handler: (repo: Repo, id: number) => Promise<Response>,
): Promise<Response> {
const id = parseInt(c.req.param('id'))
const id = Number.parseInt(c.req.param('id') ?? '', 10)
const repo = getRepoById(database, id)

if (!repo) {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/repo-terminal-socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function createRepoTerminalSocketRoutes(
upgradeWebSocket((c) => {
const directory = c.get('terminalDirectory')
const cursor = c.get('terminalCursor')
const ptyID = c.req.param('ptyID')
const ptyID = c.req.param('ptyID') ?? ''

return createBridgedSocketEvents((peer) => terminalService.connect(directory, ptyID, cursor, peer))
}),
Expand Down
39 changes: 33 additions & 6 deletions backend/test/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createTestDb } from '../helpers/assistant-workspace'
import { createAuth } from '../../src/auth'
import { DatabaseSync } from 'node:sqlite'
import type { Database } from 'bun:sqlite'
Expand Down Expand Up @@ -120,8 +119,36 @@ describe('createAuth', () => {
}
})

it('rejects a session cookie immediately after its session row is deleted', async () => {
const db = createAuthDatabase()
const auth = createAuth(db)

try {
const signUpResponse = await auth.handler(new Request('http://localhost:5173/api/auth/sign-up/email', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' },
body: JSON.stringify({ email: 'revoked@example.com', password: 'test-password', name: 'Revoked' }),
}))
expect(signUpResponse.status).toBe(200)

const setCookies = signUpResponse.headers.getSetCookie()
expect(setCookies.length).toBeGreaterThan(0)
const headers = new Headers({ cookie: setCookies.map((cookie) => cookie.split(';')[0]).join('; ') })

const session = await auth.api.getSession({ headers })
expect(session?.user.email).toBe('revoked@example.com')

db.prepare('DELETE FROM "session" WHERE "userId" = ?').run(session!.user.id)
expect(db.prepare('SELECT COUNT(*) AS count FROM "session"').get()).toEqual({ count: 0 })

expect(await auth.api.getSession({ headers })).toBeNull()
} finally {
db.close()
}
}, 30000)

it('creates an auth instance without social providers and responds to requests', async () => {
const db = createTestDb()
const db = createAuthDatabase()
const auth = createAuth(db)

expect(typeof auth.api.getSession).toBe('function')
Expand All @@ -141,7 +168,7 @@ describe('createAuth', () => {
ENV.AUTH.DISCORD_CLIENT_ID = 'discord-id'
ENV.AUTH.DISCORD_CLIENT_SECRET = 'discord-secret'

const db = createTestDb()
const db = createAuthDatabase()
const auth = createAuth(db)

expect(typeof auth.api.getSession).toBe('function')
Expand All @@ -157,7 +184,7 @@ describe('createAuth', () => {
ENV.AUTH.GOOGLE_CLIENT_SECRET = 'google-secret'
ENV.AUTH.DISCORD_CLIENT_ID = 'discord-id'

const db = createTestDb()
const db = createAuthDatabase()
const auth = createAuth(db)

const res = await auth.handler(new Request('http://localhost/api/auth/ok'))
Expand All @@ -171,7 +198,7 @@ describe('createAuth', () => {
ENV.SERVER.PORT = 4321
ENV.AUTH.SECURE_COOKIES = true

const db = createTestDb()
const db = createAuthDatabase()
const auth = createAuth(db)

const res = await auth.handler(new Request('http://localhost/api/auth/ok'))
Expand All @@ -183,7 +210,7 @@ describe('createAuth', () => {
it('uses the first trusted origin as the base URL', async () => {
ENV.AUTH.TRUSTED_ORIGINS = ' http://example.test ,http://localhost:5003'

const db = createTestDb()
const db = createAuthDatabase()
const auth = createAuth(db)

const res = await auth.handler(new Request('http://example.test/api/auth/ok'))
Expand Down
11 changes: 11 additions & 0 deletions backend/test/auth/middleware-same-site.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ describe('createAuthMiddleware same-site guard', () => {
expect(res.status).toBe(403)
expect(getSession).not.toHaveBeenCalled()
})

it('does not log cookie names or values during the auth check', async () => {
const app = createApp(getSession)

const res = await app.request('/', { headers: { cookie: 'opencode.session=secret-token' } })

expect(res.status).toBe(200)
const debugMessages = loggerMock.debug.mock.calls.flat().join(' ')
expect(debugMessages).not.toContain('opencode.session')
expect(debugMessages).not.toContain('secret-token')
})
})

describe('getTrustedOrigins', () => {
Expand Down
84 changes: 59 additions & 25 deletions backend/test/routes/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ function insertCredentialAccount(db: ReturnType<typeof createTestDb>, id: string
).run(id, userId, 'credential', userId, password, Date.now(), Date.now())
}

function insertSession(db: ReturnType<typeof createTestDb>, id: string, userId: string, token: string): void {
db.prepare(
'INSERT INTO "session" (id, expiresAt, token, createdAt, updatedAt, userId) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, Date.now() + 86_400_000, token, Date.now(), Date.now(), userId)
}

function listSessionIds(db: ReturnType<typeof createTestDb>, userId: string): string[] {
const rows = db.prepare('SELECT id FROM "session" WHERE "userId" = ? ORDER BY id').all(userId) as { id: string }[]
return rows.map((row) => row.id)
}

function createSession(): Session {
return {
session: {
Expand Down Expand Up @@ -127,7 +138,7 @@ describe('createAuthRoutes', () => {
expect(await res.text()).toBe('created')
})

it('logs sign-in responses when a set-cookie header is present', async () => {
it('does not log sign-in response headers', async () => {
const handler = vi.fn(async () => new Response('ok', {
status: 200,
headers: { 'set-cookie': 'opencode.session=abc; Path=/; HttpOnly' },
Expand All @@ -136,30 +147,6 @@ describe('createAuthRoutes', () => {

await app.fetch(new Request('http://localhost/api/auth/sign-in/email', { method: 'POST', body: '{}' }))

expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Sign-in response'))
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('present'))
expect(mockLoggerDebug).toHaveBeenCalledWith(expect.stringContaining('Set-Cookie header'))
})

it('logs sign-in responses when no set-cookie header is present', async () => {
const handler = vi.fn(async () => new Response('unauthorized', { status: 401 }))
const app = createAuthRoutes({ handler } as unknown as AuthInstance)

await app.fetch(new Request('http://localhost/api/auth/sign-in/email', { method: 'POST', body: '{}' }))

expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('missing'))
expect(mockLoggerDebug).not.toHaveBeenCalled()
})

it('does not log for non sign-in paths', async () => {
const handler = vi.fn(async () => new Response('ok', {
status: 200,
headers: { 'set-cookie': 'opencode.session=abc' },
}))
const app = createAuthRoutes({ handler } as unknown as AuthInstance)

await app.fetch(new Request('http://localhost/api/auth/get-session'))

expect(mockLoggerInfo).not.toHaveBeenCalled()
expect(mockLoggerDebug).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -204,6 +191,7 @@ describe('syncAdminFromEnv', () => {
ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
insertUser(db, 'user-1', 'admin@example.com')
insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash')
insertSession(db, 'session-1', 'user-1', 'token-1')
const auth = { api: { signUpEmail } } as unknown as AuthInstance

await syncAdminFromEnv(auth, db)
Expand All @@ -212,6 +200,19 @@ describe('syncAdminFromEnv', () => {
expect(hashPasswordMock).not.toHaveBeenCalled()
const account = db.prepare('SELECT password FROM "account" WHERE "userId" = ?').get('user-1') as { password: string }
expect(account.password).toBe('old-hash')
expect(listSessionIds(db, 'user-1')).toEqual(['session-1'])
})

it('finds the existing admin user when ADMIN_EMAIL differs only by case', async () => {
ENV.AUTH.ADMIN_EMAIL = 'Admin@Example.com'
ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
insertUser(db, 'user-1', 'admin@example.com')
const auth = { api: { signUpEmail } } as unknown as AuthInstance

await syncAdminFromEnv(auth, db)

expect(signUpEmail).not.toHaveBeenCalled()
expect(db.prepare('SELECT COUNT(*) AS count FROM "user"').get()).toEqual({ count: 1 })
})

it('resets the credential account password for an existing user', async () => {
Expand All @@ -232,6 +233,39 @@ describe('syncAdminFromEnv', () => {
expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('ADMIN_PASSWORD_RESET'))
})

it('revokes the existing sessions of the admin whose password was reset', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
insertUser(db, 'user-1', 'admin@example.com')
insertUser(db, 'user-2', 'other@example.com')
insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash')
insertSession(db, 'session-1', 'user-1', 'token-1')
insertSession(db, 'session-2', 'user-1', 'token-2')
insertSession(db, 'session-3', 'user-2', 'token-3')
const auth = { api: { signUpEmail } } as unknown as AuthInstance

await syncAdminFromEnv(auth, db)

expect(listSessionIds(db, 'user-1')).toEqual([])
expect(listSessionIds(db, 'user-2')).toEqual(['session-3'])
})

it('rolls back the password reset when revoking the sessions fails', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'new-password'
ENV.AUTH.ADMIN_PASSWORD_RESET = true
insertUser(db, 'user-1', 'admin@example.com')
insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash')
db.exec('DROP TABLE "session"')
const auth = { api: { signUpEmail } } as unknown as AuthInstance

await expect(syncAdminFromEnv(auth, db)).rejects.toThrow()

const account = db.prepare('SELECT password FROM "account" WHERE "userId" = ?').get('user-1') as { password: string }
expect(account.password).toBe('old-hash')
})

it('creates a new admin user when none exists', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
Expand Down
4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"test:run": "vitest run"
},
"dependencies": {
"@better-auth/passkey": "^1.4.17",
"@better-auth/passkey": "^1.7.12",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
Expand All @@ -32,7 +32,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"better-auth": "^1.4.17",
"better-auth": "^1.7.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cronstrue": "^3.13.0",
Expand Down
Loading
Loading