From e3dcb150f38e7d7af9687b431adbe76ea97dc69b Mon Sep 17 00:00:00 2001 From: IkedaNoritaka <50833174+NoritakaIkeda@users.noreply.github.com> Date: Fri, 17 Oct 2025 09:48:38 +0900 Subject: [PATCH 01/34] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Migrate=20GitHub=20O?= =?UTF-8?q?Auth=20token=20management=20to=20encrypted=20HttpOnly=20cookies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrated GitHub OAuth token storage from database sessions to encrypted HttpOnly cookies using AES-256-GCM encryption. This improves security and simplifies the authentication flow. Key changes: - Created @liam-hq/security package with AES-256-GCM encryption utilities - Added encrypted cookie storage for GitHub access/refresh tokens - Implemented automatic token refresh flow with client-side trigger - Updated middleware to exclude API routes from session checks - Modified GitHub API calls to use token-based auth instead of session-based - Added logout endpoint to clear OAuth cookies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.template | 5 + .../apps/app/app/api/auth/logout/route.ts | 11 ++ .../app/app/api/github/token/refresh/route.ts | 162 ++++++++++++++++++ .../app/app/auth/callback/[provider]/route.ts | 42 ++++- frontend/apps/app/app/projects/new/page.tsx | 22 ++- .../CommonLayout/AppBar/UserDropdown.tsx | 14 ++ .../ProjectNewPage/ProjectNewPage.tsx | 4 + .../InstallationSelector.tsx | 9 +- .../TokenRefreshKick/TokenRefreshKick.tsx | 47 +++++ .../app/components/TokenRefreshKick/index.ts | 1 + frontend/apps/app/eslint-suppressions.json | 5 + frontend/apps/app/libs/github/cookie.ts | 159 +++++++++++++++++ frontend/apps/app/libs/github/token.ts | 29 ++++ frontend/apps/app/middleware.ts | 3 +- frontend/apps/app/package.json | 1 + frontend/internal-packages/db/src/index.ts | 1 + .../internal-packages/github/package.json | 3 +- .../github/src/api.browser.ts | 14 -- .../internal-packages/github/src/api.oauth.ts | 57 ++++++ .../internal-packages/github/src/index.ts | 2 +- .../internal-packages/neverthrow/src/index.ts | 3 + frontend/packages/security/README.md | 18 ++ frontend/packages/security/eslint.config.mjs | 13 ++ frontend/packages/security/package.json | 48 ++++++ frontend/packages/security/src/cryptoBox.ts | 91 ++++++++++ frontend/packages/security/src/index.ts | 1 + frontend/packages/security/tsconfig.json | 19 ++ frontend/packages/security/vitest.config.ts | 8 + pnpm-lock.yaml | 31 ++++ 29 files changed, 795 insertions(+), 28 deletions(-) create mode 100644 frontend/apps/app/app/api/auth/logout/route.ts create mode 100644 frontend/apps/app/app/api/github/token/refresh/route.ts create mode 100644 frontend/apps/app/components/TokenRefreshKick/TokenRefreshKick.tsx create mode 100644 frontend/apps/app/components/TokenRefreshKick/index.ts create mode 100644 frontend/apps/app/libs/github/cookie.ts create mode 100644 frontend/apps/app/libs/github/token.ts delete mode 100644 frontend/internal-packages/github/src/api.browser.ts create mode 100644 frontend/internal-packages/github/src/api.oauth.ts create mode 100644 frontend/packages/security/README.md create mode 100644 frontend/packages/security/eslint.config.mjs create mode 100644 frontend/packages/security/package.json create mode 100644 frontend/packages/security/src/cryptoBox.ts create mode 100644 frontend/packages/security/src/index.ts create mode 100644 frontend/packages/security/tsconfig.json create mode 100644 frontend/packages/security/vitest.config.ts diff --git a/.env.template b/.env.template index 7af45277f5..fab724a118 100644 --- a/.env.template +++ b/.env.template @@ -24,3 +24,8 @@ SENTRY_ORG="" SENTRY_PROJECT="" SUPABASE_SERVICE_ROLE_KEY="" NEXT_PUBLIC_ALLOWED_DOMAINS="" + +# KEYRING +# Usage: Secret for app-side encryption/signing. +# Generate (Node.js): node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +KEYRING="" diff --git a/frontend/apps/app/app/api/auth/logout/route.ts b/frontend/apps/app/app/api/auth/logout/route.ts new file mode 100644 index 0000000000..859a91cbe1 --- /dev/null +++ b/frontend/apps/app/app/api/auth/logout/route.ts @@ -0,0 +1,11 @@ +import { fromAsyncThrowable } from '@liam-hq/neverthrow' +import { NextResponse } from 'next/server' +import { clearTokens } from '../../../../libs/github/cookie' + +export async function POST() { + await fromAsyncThrowable(clearTokens)().match( + () => undefined, + () => undefined, + ) + return NextResponse.json({ ok: true }) +} diff --git a/frontend/apps/app/app/api/github/token/refresh/route.ts b/frontend/apps/app/app/api/github/token/refresh/route.ts new file mode 100644 index 0000000000..3a8b9b40ea --- /dev/null +++ b/frontend/apps/app/app/api/github/token/refresh/route.ts @@ -0,0 +1,162 @@ +import { err, ok, type Result, ResultAsync } from '@liam-hq/neverthrow' +import * as Sentry from '@sentry/nextjs' +import { NextResponse } from 'next/server' +import * as v from 'valibot' +import { + readRefreshToken, + writeAccessToken, + writeRefreshToken, +} from '../../../../../libs/github/cookie' + +const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token' +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 + +const GitHubTokenSuccessSchema = v.object({ + access_token: v.string(), + refresh_token: v.string(), + token_type: v.string(), + expires_in: v.number(), // seconds +}) + +const GitHubTokenErrorSchema = v.object({ + error: v.string(), + error_description: v.optional(v.string()), + error_uri: v.optional(v.string()), +}) + +function getClientCredentials(): Result< + { clientId: string; clientSecret: string }, + Error +> { + const clientId = process.env.GITHUB_CLIENT_ID + const clientSecret = process.env.GITHUB_CLIENT_SECRET + if (!clientId || !clientSecret) { + return err(new Error('Missing GitHub OAuth client credentials')) + } + return ok({ clientId, clientSecret }) +} + +function parseGitHubRefreshResponse( + json: unknown, +): Result, Error> { + const success = v.safeParse(GitHubTokenSuccessSchema, json) + if (success.success) return ok(success.output) + const ghError = v.safeParse(GitHubTokenErrorSchema, json) + if (ghError.success) { + const { error: code, error_description } = ghError.output + return err( + new Error( + `GitHub token refresh failed: ${code}${error_description ? ` - ${error_description}` : ''}`, + ), + ) + } + return err(new Error('GitHub token refresh returned invalid response')) +} + +function refreshAccessToken( + refreshToken: string, + clientId: string, + clientSecret: string, +): ResultAsync< + { + access_token: string + refresh_token: string + newExpiresAt: string + }, + Error +> { + return ResultAsync.fromPromise( + (async () => { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }) + const res = await fetch(GITHUB_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + cache: 'no-store', + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + return Promise.reject( + new Error(`GitHub token refresh failed: ${res.status} ${text}`), + ) + } + const json = await res.json().catch(() => null) + const parsed = parseGitHubRefreshResponse(json) + if (parsed.isErr()) return Promise.reject(parsed.error) + const next = parsed.value + const newExpiresAt = new Date( + Date.now() + next.expires_in * 1000, + ).toISOString() + return { + access_token: next.access_token, + refresh_token: next.refresh_token, + newExpiresAt, + } + })(), + (e) => (e instanceof Error ? e : new Error(String(e))), + ) +} + +async function handler(): Promise { + // production flag was only used for dev logging; removed + // 1) Load refresh token from cookie + const refresh = await readRefreshToken() + const refreshToken = refresh?.token ?? '' + if (!refreshToken) { + return NextResponse.json( + { ok: false, error: 'missing_refresh_token' }, + { status: 401 }, + ) + } + + // 2) Load client credentials + const creds = getClientCredentials() + if (creds.isErr()) { + Sentry.captureException(creds.error, { tags: { area: 'refresh_api' } }) + return NextResponse.json( + { ok: false, error: 'server_misconfigured' }, + { status: 500 }, + ) + } + const { clientId, clientSecret } = creds.value + + // 3) Refresh via GitHub + const refreshed = await refreshAccessToken( + refreshToken, + clientId, + clientSecret, + ).match( + (v) => v, + (e) => { + Sentry.captureException(e, { tags: { area: 'refresh_api' } }) + return null + }, + ) + if (!refreshed) { + return NextResponse.json( + { ok: false, error: 'refresh_failed' }, + { status: 401 }, + ) + } + + // 4) Persist into cookies (access + refresh) + await writeAccessToken(refreshed.access_token, refreshed.newExpiresAt) + const refreshExpiresAt = new Date(Date.now() + THIRTY_DAYS_MS).toISOString() + await writeRefreshToken(refreshed.refresh_token, refreshExpiresAt) + // refreshed successfully; cookies updated + + // 5) return OK + return NextResponse.json({ ok: true }) +} + +export async function POST() { + return handler() +} diff --git a/frontend/apps/app/app/auth/callback/[provider]/route.ts b/frontend/apps/app/app/auth/callback/[provider]/route.ts index f070dbf09f..8106676ff0 100644 --- a/frontend/apps/app/app/auth/callback/[provider]/route.ts +++ b/frontend/apps/app/app/auth/callback/[provider]/route.ts @@ -1,19 +1,59 @@ +import { fromAsyncThrowable } from '@liam-hq/neverthrow' +import * as Sentry from '@sentry/nextjs' import { NextResponse } from 'next/server' import { ensureUserHasOrganization } from '../../../../components/LoginPage/services/ensureUserHasOrganization' import { sanitizeReturnPath } from '../../../../components/LoginPage/services/validateReturnPath' import { createClient } from '../../../../libs/db/server' +import { + writeAccessToken, + writeRefreshToken, +} from '../../../../libs/github/cookie' export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url) const code = searchParams.get('code') // Use query parameter "next" to redirect after auth, sanitize for security const next = sanitizeReturnPath(searchParams.get('next'), '/') + // production flag was only used for dev logging; removed if (code) { const supabase = await createClient() - const { error } = await supabase.auth.exchangeCodeForSession(code) + const { data, error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { await ensureUserHasOrganization() + // Persist provider tokens into encrypted HttpOnly cookies (best-effort) + const { session } = data + const access = session?.provider_token ?? '' + const refresh = session?.provider_refresh_token ?? '' + // received provider tokens for cookie write + const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000 + const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 + + await fromAsyncThrowable(async () => { + if (access) { + const accessExpiresAt = new Date( + Date.now() + EIGHT_HOURS_MS, + ).toISOString() + await writeAccessToken(access, accessExpiresAt) + } + if (refresh) { + const refreshExpiresAt = new Date( + Date.now() + THIRTY_DAYS_MS, + ).toISOString() + await writeRefreshToken(refresh, refreshExpiresAt) + } + })().match( + () => undefined, + (e) => { + const err = e instanceof Error ? e : new Error(String(e)) + Sentry.captureException(err, { + tags: { + area: 'auth_cookie_write', + }, + }) + return undefined + }, + ) const forwardedHost = request.headers.get('x-forwarded-host') const isLocalEnv = process.env.NODE_ENV === 'development' diff --git a/frontend/apps/app/app/projects/new/page.tsx b/frontend/apps/app/app/projects/new/page.tsx index 69236ba8a3..3684e51407 100644 --- a/frontend/apps/app/app/projects/new/page.tsx +++ b/frontend/apps/app/app/projects/new/page.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation' import { ProjectNewPage } from '../../../components/ProjectNewPage' import { getOrganizationId } from '../../../features/organizations/services/getOrganizationId' import { createClient } from '../../../libs/db/server' +import { getUserAccessToken } from '../../../libs/github/token' import { urlgen } from '../../../libs/routes' export default async function NewProjectPage() { @@ -25,18 +26,31 @@ export default async function NewProjectPage() { redirect(urlgen('login')) } - const { data } = await supabase.auth.getSession() - - if (data.session === null) { + const tokenResult = await getUserAccessToken(user.id) + if (tokenResult.isErr()) { + console.error('Failed to get user access token:', tokenResult.error) + redirect(urlgen('login')) + } + const token = tokenResult.value + if (!token) { redirect(urlgen('login')) } - const { installations } = await getInstallations(data.session) + const installationsResult = await getInstallations(token) + const { installations } = await installationsResult.match( + (v) => v, + (e) => { + console.error('Failed to fetch installations:', e) + return { installations: [] } + }, + ) + const needsRefresh = !token || installations.length === 0 return ( ) } diff --git a/frontend/apps/app/components/CommonLayout/AppBar/UserDropdown.tsx b/frontend/apps/app/components/CommonLayout/AppBar/UserDropdown.tsx index 362bebbd74..32ccb106aa 100644 --- a/frontend/apps/app/components/CommonLayout/AppBar/UserDropdown.tsx +++ b/frontend/apps/app/components/CommonLayout/AppBar/UserDropdown.tsx @@ -1,5 +1,6 @@ 'use client' +import { fromPromise } from '@liam-hq/neverthrow' import { Avatar, AvatarWithImage, @@ -63,6 +64,19 @@ export const UserDropdown: FC = ({ avatarUrl, userName, userEmail }) => { // Delete organizationId cookie deleteCookie('organizationId') + // Best-effort call to logout endpoint to clear OAuth cookies + type HttpResponse = { + ok: boolean + headers: { get(name: string): string | null } + json: () => Promise + } + await fromPromise( + fetch('/api/auth/logout', { method: 'POST', cache: 'no-store' }), + ).match( + () => {}, + () => {}, + ) + // Redirect with success parameter router.push('/login?logout=success') } else { diff --git a/frontend/apps/app/components/ProjectNewPage/ProjectNewPage.tsx b/frontend/apps/app/components/ProjectNewPage/ProjectNewPage.tsx index 38a9fd6833..8c5a939c60 100644 --- a/frontend/apps/app/components/ProjectNewPage/ProjectNewPage.tsx +++ b/frontend/apps/app/components/ProjectNewPage/ProjectNewPage.tsx @@ -1,19 +1,23 @@ import type { Installation } from '@liam-hq/github' import type { FC } from 'react' +import { TokenRefreshKick } from '../TokenRefreshKick' import { InstallationSelector } from './components/InstallationSelector' import styles from './ProjectNewPage.module.css' type Props = { installations: Installation[] organizationId: string + needsRefresh?: boolean } export const ProjectNewPage: FC = ({ installations, organizationId, + needsRefresh, }) => { return (
+

Add a Project

= ({ const [selectedInstallation, setSelectedInstallation] = useState(null) const [isAddingProject, startAddingProjectTransition] = useTransition() - const [, startTransition] = useTransition() const [repositoriesState, repositoriesAction, isRepositoriesLoading] = useActionState(getRepositories, { repositories: [], loading: false }) @@ -42,11 +41,9 @@ export const InstallationSelector: FC = ({ const handleSelectInstallation = (installation: Installation) => { setSelectedInstallation(installation) - startTransition(() => { - const formData = new FormData() - formData.append('installationId', installation.id.toString()) - repositoriesAction(formData) - }) + const formData = new FormData() + formData.append('installationId', installation.id.toString()) + repositoriesAction(formData) } const handleClick = useCallback( diff --git a/frontend/apps/app/components/TokenRefreshKick/TokenRefreshKick.tsx b/frontend/apps/app/components/TokenRefreshKick/TokenRefreshKick.tsx new file mode 100644 index 0000000000..c77a2c51e6 --- /dev/null +++ b/frontend/apps/app/components/TokenRefreshKick/TokenRefreshKick.tsx @@ -0,0 +1,47 @@ +'use client' + +import { fromPromise } from '@liam-hq/neverthrow' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' + +type Props = { + trigger?: boolean +} + +export function TokenRefreshKick({ trigger = false }: Props) { + const router = useRouter() + + useEffect(() => { + if (!trigger) return + const run = async () => { + type HttpResponse = { + ok: boolean + headers: { get(name: string): string | null } + json: () => Promise + } + const hasOk = (v: unknown): v is { ok: unknown } => + typeof v === 'object' && v !== null && 'ok' in v + const onOk = async (res: HttpResponse) => { + let ok = res.ok + if (res.headers.get('content-type')?.includes('application/json')) { + const json = await res.json().catch(() => null) + if (hasOk(json)) { + ok = ok && Boolean(json.ok) + } + } + if (ok) { + router.refresh() + } + } + await fromPromise( + fetch('/api/github/token/refresh', { + method: 'POST', + cache: 'no-store', + }), + ).match(onOk, () => {}) + } + run() + }, [trigger, router]) + + return null +} diff --git a/frontend/apps/app/components/TokenRefreshKick/index.ts b/frontend/apps/app/components/TokenRefreshKick/index.ts new file mode 100644 index 0000000000..29dbbe457f --- /dev/null +++ b/frontend/apps/app/components/TokenRefreshKick/index.ts @@ -0,0 +1 @@ +export { TokenRefreshKick } from './TokenRefreshKick' diff --git a/frontend/apps/app/eslint-suppressions.json b/frontend/apps/app/eslint-suppressions.json index c216d451e1..9c67622e89 100644 --- a/frontend/apps/app/eslint-suppressions.json +++ b/frontend/apps/app/eslint-suppressions.json @@ -29,6 +29,11 @@ "count": 5 } }, + "components/ProjectNewPage/components/InstallationSelector/actions/getRepositories.ts": { + "@typescript-eslint/consistent-type-assertions": { + "count": 3 + } + }, "components/ProjectsPage/ProjectsPage.module.css": { "css-modules-kit/no-unused-class-names": { "count": 9 diff --git a/frontend/apps/app/libs/github/cookie.ts b/frontend/apps/app/libs/github/cookie.ts new file mode 100644 index 0000000000..0472ce1171 --- /dev/null +++ b/frontend/apps/app/libs/github/cookie.ts @@ -0,0 +1,159 @@ +// Server-only utility for encrypted GitHub tokens stored in HttpOnly cookies + +import { err, fromThrowable, ok, type Result } from '@liam-hq/neverthrow' +import { decryptAesGcm, encryptAesGcm } from '@liam-hq/security/cryptoBox' +import { cookies } from 'next/headers' +import * as v from 'valibot' + +const ACCESS_COOKIE = 'liam_at' +const REFRESH_COOKIE = 'liam_rt' + +// Plain payload stored (before encryption) +const TokenPayloadSchema = v.object({ + token: v.string(), + expiresAt: v.string(), // ISO8601 +}) +type TokenPayload = v.InferOutput + +// Encrypted bundle packed for cookie value +const PackedSchema = v.object({ + key_id: v.string(), + ciphertext: v.string(), // base64 + initialization_vector: v.string(), // base64 + authentication_tag: v.string(), // base64 +}) +type Packed = v.InferOutput + +function isProd(): boolean { + return process.env.NODE_ENV === 'production' +} + +function toMaxAgeSeconds(expiresAtIso: string): number { + const now = Date.now() + const t = new Date(expiresAtIso).getTime() + if (Number.isNaN(t)) return 0 + const diffMs = Math.max(0, t - now) + return Math.floor(diffMs / 1000) +} + +function packCookieValue(plaintext: string): Result { + const enc = encryptAesGcm(plaintext) + if (enc.isErr()) return err(enc.error) + const { keyId, ciphertext, initializationVector, authenticationTag } = + enc.value + const packed: Packed = { + key_id: keyId, + ciphertext: ciphertext.toString('base64'), + initialization_vector: initializationVector.toString('base64'), + authentication_tag: authenticationTag.toString('base64'), + } + const toJson = fromThrowable(JSON.stringify) + const json = toJson(packed) + return json.isOk() ? ok(json.value) : err(json.error) +} + +function unpackCookieValue(value: string): Result { + const parseJson = fromThrowable(JSON.parse) + const json = parseJson(value) + if (json.isErr()) return err(json.error) + const parsed = v.safeParse(PackedSchema, json.value) + if (!parsed.success) return err(new Error('Invalid packed cookie schema')) + const p = parsed.output + const dec = decryptAesGcm( + p.key_id, + Buffer.from(p.ciphertext, 'base64'), + Buffer.from(p.initialization_vector, 'base64'), + Buffer.from(p.authentication_tag, 'base64'), + ) + return dec.isOk() ? ok(dec.value) : err(dec.error) +} + +async function readTokenFrom(name: string): Promise { + const store = await cookies() + const raw = store.get(name)?.value + if (!raw) { + return null + } + const plaintext = unpackCookieValue(raw) + if (plaintext.isErr()) { + return null + } + const parsePayload = fromThrowable(JSON.parse) + const payloadJson = parsePayload(plaintext.value) + if (payloadJson.isErr()) { + return null + } + const parsed = v.safeParse(TokenPayloadSchema, payloadJson.value) + if (!parsed.success) { + return null + } + return parsed.output +} + +async function writeTokenTo( + name: string, + token: string, + expiresAt: string, +): Promise { + const payload: TokenPayload = { token, expiresAt } + const toJson = fromThrowable(JSON.stringify) + const plaintext = toJson(payload) + if (plaintext.isErr()) { + throw plaintext.error + } + const value = packCookieValue(plaintext.value) + if (value.isErr()) { + throw value.error + } + const maxAge = toMaxAgeSeconds(expiresAt) + const store = await cookies() + store.set(name, value.value, { + httpOnly: true, + secure: isProd(), + sameSite: 'lax', + path: '/', + maxAge, + // Set expires for broader compatibility + expires: new Date(Date.now() + maxAge * 1000), + }) +} + +async function deleteCookie(name: string): Promise { + const store = await cookies() + // Explicitly expire the cookie; some clients respect attributes + store.set(name, '', { + httpOnly: true, + secure: isProd(), + sameSite: 'lax', + path: '/', + maxAge: 0, + expires: new Date(0), + }) +} + +export async function readAccessToken(): Promise { + return readTokenFrom(ACCESS_COOKIE) +} + +export async function readRefreshToken(): Promise { + return readTokenFrom(REFRESH_COOKIE) +} + +export async function writeAccessToken( + token: string, + expiresAt: string, +): Promise { + await writeTokenTo(ACCESS_COOKIE, token, expiresAt) +} + +export async function writeRefreshToken( + token: string, + expiresAt: string, +): Promise { + await writeTokenTo(REFRESH_COOKIE, token, expiresAt) +} + +export async function clearTokens(): Promise { + await deleteCookie(ACCESS_COOKIE) + await deleteCookie(REFRESH_COOKIE) +} diff --git a/frontend/apps/app/libs/github/token.ts b/frontend/apps/app/libs/github/token.ts new file mode 100644 index 0000000000..e970a6bbb0 --- /dev/null +++ b/frontend/apps/app/libs/github/token.ts @@ -0,0 +1,29 @@ +// Server-only GitHub token utilities: read access token from Cookie (no refresh in RSC) + +import { ResultAsync } from '@liam-hq/neverthrow' +import { readAccessToken } from './cookie' + +const SAFETY_MS = 3 * 60 * 1000 // 3 minutes safety window +// No schema/refresh handling here; handled in Route Handler + +function isExpiringSoon(expiresAtIso: string | null | undefined): boolean { + if (!expiresAtIso) return true + const expiresAt = new Date(expiresAtIso).getTime() + return Date.now() + SAFETY_MS >= expiresAt +} + +// No refresh here; Route Handler performs refresh and cookie writes + +export function getUserAccessToken( + _userId: string, +): ResultAsync { + return ResultAsync.fromPromise(readAccessToken(), (e) => + e instanceof Error ? e : new Error(String(e)), + ).map((access) => { + if (access && !isExpiringSoon(access.expiresAt)) { + return access.token + } + // Do not refresh here; return null so callers decide how to refresh. + return null + }) +} diff --git a/frontend/apps/app/middleware.ts b/frontend/apps/app/middleware.ts index a7f6efb763..5149152da0 100644 --- a/frontend/apps/app/middleware.ts +++ b/frontend/apps/app/middleware.ts @@ -135,11 +135,12 @@ export const config = { matcher: [ /* * Match all request paths except for the ones starting with: + * - api (api routes - we'll run authentication manually in each route) * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * Feel free to modify this pattern to include more paths. */ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + '/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } diff --git a/frontend/apps/app/package.json b/frontend/apps/app/package.json index 52b3de48e3..889f18dd36 100644 --- a/frontend/apps/app/package.json +++ b/frontend/apps/app/package.json @@ -19,6 +19,7 @@ "@liam-hq/github": "workspace:*", "@liam-hq/neverthrow": "workspace:*", "@liam-hq/schema": "workspace:*", + "@liam-hq/security": "workspace:*", "@liam-hq/ui": "workspace:*", "@next/third-parties": "15.3.5", "@sentry/nextjs": "9", diff --git a/frontend/internal-packages/db/src/index.ts b/frontend/internal-packages/db/src/index.ts index d641eceb9e..8de697c23a 100644 --- a/frontend/internal-packages/db/src/index.ts +++ b/frontend/internal-packages/db/src/index.ts @@ -8,6 +8,7 @@ import type { AppDatabaseOverrides } from './types' export type { EmailOtpType, QueryData, + Session, SupabaseClient, } from '@supabase/supabase-js' export type { Database, Json, Tables } from '../supabase/database.types' diff --git a/frontend/internal-packages/github/package.json b/frontend/internal-packages/github/package.json index 03d6c43fe6..d8ff8ec6af 100644 --- a/frontend/internal-packages/github/package.json +++ b/frontend/internal-packages/github/package.json @@ -9,7 +9,8 @@ "@octokit/openapi-types": "25.1.0", "@octokit/rest": "21.1.1", "@supabase/supabase-js": "2.49.8", - "neverthrow": "8.2.0" + "neverthrow": "8.2.0", + "valibot": "1.1.0" }, "devDependencies": { "@biomejs/biome": "2.2.4", diff --git a/frontend/internal-packages/github/src/api.browser.ts b/frontend/internal-packages/github/src/api.browser.ts deleted file mode 100644 index 3f3ac41f83..0000000000 --- a/frontend/internal-packages/github/src/api.browser.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Octokit } from '@octokit/rest' -import type { Session } from '@supabase/supabase-js' - -export async function getInstallations(session: Session) { - const octokit = new Octokit({ - auth: session.provider_token, - headers: { - 'X-GitHub-Api-Version': '2022-11-28', - }, - }) - const res = await octokit.request('GET /user/installations') - - return res.data -} diff --git a/frontend/internal-packages/github/src/api.oauth.ts b/frontend/internal-packages/github/src/api.oauth.ts new file mode 100644 index 0000000000..6486790187 --- /dev/null +++ b/frontend/internal-packages/github/src/api.oauth.ts @@ -0,0 +1,57 @@ +import { Octokit } from '@octokit/rest' +import { ResultAsync } from 'neverthrow' +import * as v from 'valibot' +import type { Installation } from './types' + +type InstallationsResponse = { + installations: Installation[] + total_count?: number +} + +// Minimal schema for validation only +const MinimalInstallationSchema = v.object({ + id: v.number(), + account: v.object({ login: v.string() }), +}) + +const MinimalResponseSchema = v.object({ + installations: v.array(MinimalInstallationSchema), + total_count: v.optional(v.number()), +}) + +/** + * Fetch user installations with a user OAuth access token. + * Does not depend on app/session types; token string only. + */ +export function getInstallations( + token: string, +): ResultAsync { + return ResultAsync.fromPromise( + (async () => { + const octokit = new Octokit({ + auth: token, + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + const res = await octokit.request('GET /user/installations') + + // Runtime sanity-check: ensure minimal expected shape + const data = res.data + const parsed = v.safeParse(MinimalResponseSchema, data) + if (!parsed.success) { + return Promise.reject( + new Error('Unexpected installations response shape'), + ) + } + + // Preserve original data shape while ensuring minimal structure exists + return { + installations: data.installations, + total_count: data.total_count, + } satisfies InstallationsResponse + })(), + (e) => (e instanceof Error ? e : new Error(String(e))), + ) +} diff --git a/frontend/internal-packages/github/src/index.ts b/frontend/internal-packages/github/src/index.ts index aa3f4c3aeb..b04f68bbb3 100644 --- a/frontend/internal-packages/github/src/index.ts +++ b/frontend/internal-packages/github/src/index.ts @@ -1,4 +1,4 @@ -export * from './api.browser' +export * from './api.oauth' export * from './api.server' export * from './config' export * from './types' diff --git a/frontend/internal-packages/neverthrow/src/index.ts b/frontend/internal-packages/neverthrow/src/index.ts index 88da1edd4f..8ee808ae0f 100644 --- a/frontend/internal-packages/neverthrow/src/index.ts +++ b/frontend/internal-packages/neverthrow/src/index.ts @@ -1,4 +1,7 @@ import { Result } from 'neverthrow' + +export { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' + import { defaultErrorFn } from './defaultErrorFn' export function fromThrowable( diff --git a/frontend/packages/security/README.md b/frontend/packages/security/README.md new file mode 100644 index 0000000000..e638336161 --- /dev/null +++ b/frontend/packages/security/README.md @@ -0,0 +1,18 @@ +# @liam-hq/security + +Security utilities for application-layer token handling. + +- Third-party tokens only: AES-256-GCM encryption with per-row IV and `key_id` for rotation. Encrypt/decrypt return `Result`. + +Env variables (server-only): + +- `TOKEN_PEPPER`: secret pepper used in hashing (store in Vault/KMS). +- `KEYRING`: comma-separated `kid:base64key` entries; first is current key. + +Exports: + +- `cryptoBox`: `encryptAesGcm` (Result), `decryptAesGcm` (Result), `currentKey` (Result), `setKeyring` + +See also: +- `docs/security/token-storage.md` +- `frontend/packages/security/KEY_ROTATION.md` diff --git a/frontend/packages/security/eslint.config.mjs b/frontend/packages/security/eslint.config.mjs new file mode 100644 index 0000000000..3a2defe886 --- /dev/null +++ b/frontend/packages/security/eslint.config.mjs @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url' +import { createBaseConfig } from '../../internal-packages/configs/eslint/index.js' + +const gitignorePath = fileURLToPath( + new URL('../../../.gitignore', import.meta.url), +) + +const baseConfig = createBaseConfig({ + tsconfigPath: './tsconfig.json', + gitignorePath, +}) + +export default [...baseConfig] diff --git a/frontend/packages/security/package.json b/frontend/packages/security/package.json new file mode 100644 index 0000000000..2e615ecdc0 --- /dev/null +++ b/frontend/packages/security/package.json @@ -0,0 +1,48 @@ +{ + "name": "@liam-hq/security", + "description": "Security utilities for token hashing and encryption at the application layer.", + "repository": { + "type": "git", + "url": "https://github.com/liam-hq/liam.git" + }, + "license": "Apache-2.0", + "private": false, + "version": "0.1.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./dist/index.js", + "./cryptoBox": "./dist/cryptoBox.js" + }, + "dependencies": { + "@liam-hq/neverthrow": "workspace:*" + }, + "devDependencies": { + "@biomejs/biome": "2.2.4", + "@liam-hq/configs": "workspace:*", + "@types/node": "22.18.6", + "eslint": "9.36.0", + "typescript": "5.9.2", + "vitest": "3.2.4" + }, + "scripts": { + "build": "tsc", + "fmt": "concurrently \"pnpm:fmt:*\"", + "fmt:biome": "biome check --write --unsafe .", + "fmt:eslint": "eslint --fix .", + "lint": "concurrently \"pnpm:lint:*\"", + "lint:biome": "biome check .", + "lint:eslint": "eslint .", + "lint:tsc": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "files": [ + "dist/**/*", + "README.md", + "package.json", + "LICENSE" + ], + "types": "dist/index.d.ts" +} diff --git a/frontend/packages/security/src/cryptoBox.ts b/frontend/packages/security/src/cryptoBox.ts new file mode 100644 index 0000000000..cd3f9d3acc --- /dev/null +++ b/frontend/packages/security/src/cryptoBox.ts @@ -0,0 +1,91 @@ +import crypto from 'node:crypto' +import { err, fromThrowable, ok, type Result } from '@liam-hq/neverthrow' + +export type Key = { id: string; key: Buffer } + +/** + * Parses KEYRING env into an in-memory keyring. + * Format: "kid1:base64key,kid0:base64key" (first entry is current key) + */ +function parseKeyring(envValue: string | undefined): Key[] { + const keys: Key[] = [] + const parts = (envValue ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + for (const entry of parts) { + const [id, b64] = entry.split(':') + if (!id || !b64) continue + const keyBuf = Buffer.from(b64, 'base64') + if (keyBuf.length !== 32) continue + keys.push({ id, key: keyBuf }) + } + return keys +} + +let RING: Key[] = parseKeyring(process.env['KEYRING']) + +export function setKeyring(keys: Key[]): void { + RING = keys +} + +export function currentKey(): Result { + if (!RING.length) return err(new Error('No keys configured')) + const k = RING[0] + if (!k) return err(new Error('No keys configured')) + return ok(k) +} + +export type CipherBundle = { + keyId: string + ciphertext: Buffer + initializationVector: Buffer // 12 bytes + authenticationTag: Buffer // 16 bytes +} + +/** + * Encrypts plaintext using AES-256-GCM with a random IV. + */ +export function encryptAesGcm(plaintext: string): Result { + const keyRes = currentKey() + if (keyRes.isErr()) return err(keyRes.error) + const { id, key } = keyRes.value + return fromThrowable(() => { + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const ciphertext = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]) + const authenticationTag = cipher.getAuthTag() + return { + keyId: id, + ciphertext, + initializationVector: iv, + authenticationTag, + } + })() +} + +/** + * Decrypts ciphertext using the key identified by keyId. + */ +export function decryptAesGcm( + keyId: string, + ciphertext: Buffer, + initializationVector: Buffer, + authenticationTag: Buffer, +): Result { + const entry = RING.find((k) => k.id === keyId) + if (!entry) return err(new Error('Unknown key id')) + return fromThrowable(() => { + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + entry.key, + initializationVector, + ) + decipher.setAuthTag(authenticationTag) + const pt = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return pt.toString('utf8') + })() +} diff --git a/frontend/packages/security/src/index.ts b/frontend/packages/security/src/index.ts new file mode 100644 index 0000000000..ed15d7d5f4 --- /dev/null +++ b/frontend/packages/security/src/index.ts @@ -0,0 +1 @@ +export * from './cryptoBox.js' diff --git a/frontend/packages/security/tsconfig.json b/frontend/packages/security/tsconfig.json new file mode 100644 index 0000000000..56ea5599be --- /dev/null +++ b/frontend/packages/security/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../internal-packages/configs/tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], + "resolveJsonModule": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} diff --git a/frontend/packages/security/vitest.config.ts b/frontend/packages/security/vitest.config.ts new file mode 100644 index 0000000000..7cba2b7eff --- /dev/null +++ b/frontend/packages/security/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + globals: true, + environment: 'node', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ceaef904e0..31151eeb03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@liam-hq/schema': specifier: workspace:* version: link:../../packages/schema + '@liam-hq/security': + specifier: workspace:* + version: link:../../packages/security '@liam-hq/ui': specifier: workspace:* version: link:../../packages/ui @@ -563,6 +566,9 @@ importers: neverthrow: specifier: 8.2.0 version: 8.2.0 + valibot: + specifier: 1.1.0 + version: 1.1.0(typescript@5.9.2) devDependencies: '@biomejs/biome': specifier: 2.2.4 @@ -1002,6 +1008,31 @@ importers: specifier: 3.2.4 version: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.18.6)(happy-dom@20.0.0)(jiti@2.6.1)(lightningcss@1.30.1)(msw@2.11.3(@types/node@22.18.6)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + frontend/packages/security: + dependencies: + '@liam-hq/neverthrow': + specifier: workspace:* + version: link:../../internal-packages/neverthrow + devDependencies: + '@biomejs/biome': + specifier: 2.2.4 + version: 2.2.4 + '@liam-hq/configs': + specifier: workspace:* + version: link:../../internal-packages/configs + '@types/node': + specifier: 22.18.6 + version: 22.18.6 + eslint: + specifier: 9.36.0 + version: 9.36.0(jiti@2.6.1) + typescript: + specifier: 5.9.2 + version: 5.9.2 + vitest: + specifier: 3.2.4 + version: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.18.6)(happy-dom@20.0.0)(jiti@2.6.1)(lightningcss@1.30.1)(msw@2.11.3(@types/node@22.18.6)(typescript@5.9.2))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + frontend/packages/ui: dependencies: '@radix-ui/react-collapsible': From f4331342630d6a6a83375918f1cca6a6caaf3201 Mon Sep 17 00:00:00 2001 From: IkedaNoritaka <50833174+NoritakaIkeda@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:28:51 +0900 Subject: [PATCH 02/34] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20to=20use?= =?UTF-8?q?=20Result=20monad=20patterns=20consistently?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored error handling to use neverthrow's Result monad patterns more consistently throughout the OAuth token management code. Key improvements: - Use fromThrowable for synchronous operations that may throw - Use andThen for chaining Result operations - Simplified currentKey() to use fromThrowable pattern - Improved cookie packing/unpacking with Result chaining - Better error handling in GitHub token refresh flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/app/api/github/token/refresh/route.ts | 58 ++++++++----------- frontend/apps/app/app/projects/new/page.tsx | 2 +- frontend/apps/app/eslint-suppressions.json | 5 -- frontend/apps/app/libs/github/cookie.ts | 58 ++++++++++--------- frontend/apps/app/libs/github/token.ts | 15 ++--- .../internal-packages/github/package.json | 1 - .../internal-packages/neverthrow/src/index.ts | 3 +- frontend/packages/security/src/cryptoBox.ts | 33 +++++++---- 8 files changed, 86 insertions(+), 89 deletions(-) diff --git a/frontend/apps/app/app/api/github/token/refresh/route.ts b/frontend/apps/app/app/api/github/token/refresh/route.ts index 3a8b9b40ea..903ec33af3 100644 --- a/frontend/apps/app/app/api/github/token/refresh/route.ts +++ b/frontend/apps/app/app/api/github/token/refresh/route.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result, ResultAsync } from '@liam-hq/neverthrow' +import { fromPromise, fromThrowable } from '@liam-hq/neverthrow' import * as Sentry from '@sentry/nextjs' import { NextResponse } from 'next/server' import * as v from 'valibot' @@ -16,6 +16,8 @@ const GitHubTokenSuccessSchema = v.object({ refresh_token: v.string(), token_type: v.string(), expires_in: v.number(), // seconds + // Align cookie TTL with GitHub if provided + refresh_token_expires_in: v.optional(v.number()), // seconds }) const GitHubTokenErrorSchema = v.object({ @@ -24,48 +26,37 @@ const GitHubTokenErrorSchema = v.object({ error_uri: v.optional(v.string()), }) -function getClientCredentials(): Result< - { clientId: string; clientSecret: string }, - Error -> { +const getClientCredentials = fromThrowable(() => { const clientId = process.env.GITHUB_CLIENT_ID const clientSecret = process.env.GITHUB_CLIENT_SECRET if (!clientId || !clientSecret) { - return err(new Error('Missing GitHub OAuth client credentials')) + // eslint-disable-next-line no-throw-error/no-throw-error -- Throw to feed fromThrowable wrapper + throw new Error('Missing GitHub OAuth client credentials') } - return ok({ clientId, clientSecret }) -} + return { clientId, clientSecret } +}) -function parseGitHubRefreshResponse( - json: unknown, -): Result, Error> { +function parseGitHubRefreshResponse(json: unknown) { const success = v.safeParse(GitHubTokenSuccessSchema, json) - if (success.success) return ok(success.output) + if (success.success) return success.output const ghError = v.safeParse(GitHubTokenErrorSchema, json) if (ghError.success) { const { error: code, error_description } = ghError.output - return err( - new Error( - `GitHub token refresh failed: ${code}${error_description ? ` - ${error_description}` : ''}`, - ), + // eslint-disable-next-line no-throw-error/no-throw-error -- Propagate parse failure to caller + throw new Error( + `GitHub token refresh failed: ${code}${error_description ? ` - ${error_description}` : ''}`, ) } - return err(new Error('GitHub token refresh returned invalid response')) + // eslint-disable-next-line no-throw-error/no-throw-error -- Propagate parse failure to caller + throw new Error('GitHub token refresh returned invalid response') } function refreshAccessToken( refreshToken: string, clientId: string, clientSecret: string, -): ResultAsync< - { - access_token: string - refresh_token: string - newExpiresAt: string - }, - Error -> { - return ResultAsync.fromPromise( +) { + return fromPromise( (async () => { const body = new URLSearchParams({ grant_type: 'refresh_token', @@ -84,24 +75,21 @@ function refreshAccessToken( }) if (!res.ok) { const text = await res.text().catch(() => '') - return Promise.reject( - new Error(`GitHub token refresh failed: ${res.status} ${text}`), - ) + // eslint-disable-next-line no-throw-error/no-throw-error -- Map HTTP error into Result error + throw new Error(`GitHub token refresh failed: ${res.status} ${text}`) } const json = await res.json().catch(() => null) - const parsed = parseGitHubRefreshResponse(json) - if (parsed.isErr()) return Promise.reject(parsed.error) - const next = parsed.value + const next = parseGitHubRefreshResponse(json) const newExpiresAt = new Date( Date.now() + next.expires_in * 1000, ).toISOString() return { access_token: next.access_token, refresh_token: next.refresh_token, + refresh_token_expires_in: next.refresh_token_expires_in, newExpiresAt, } })(), - (e) => (e instanceof Error ? e : new Error(String(e))), ) } @@ -149,7 +137,9 @@ async function handler(): Promise { // 4) Persist into cookies (access + refresh) await writeAccessToken(refreshed.access_token, refreshed.newExpiresAt) - const refreshExpiresAt = new Date(Date.now() + THIRTY_DAYS_MS).toISOString() + const rtei = refreshed.refresh_token_expires_in ?? 0 + const refreshTtlMs = rtei > 0 ? rtei * 1000 : THIRTY_DAYS_MS + const refreshExpiresAt = new Date(Date.now() + refreshTtlMs).toISOString() await writeRefreshToken(refreshed.refresh_token, refreshExpiresAt) // refreshed successfully; cookies updated diff --git a/frontend/apps/app/app/projects/new/page.tsx b/frontend/apps/app/app/projects/new/page.tsx index 3684e51407..a8f9b1acc0 100644 --- a/frontend/apps/app/app/projects/new/page.tsx +++ b/frontend/apps/app/app/projects/new/page.tsx @@ -26,7 +26,7 @@ export default async function NewProjectPage() { redirect(urlgen('login')) } - const tokenResult = await getUserAccessToken(user.id) + const tokenResult = await getUserAccessToken() if (tokenResult.isErr()) { console.error('Failed to get user access token:', tokenResult.error) redirect(urlgen('login')) diff --git a/frontend/apps/app/eslint-suppressions.json b/frontend/apps/app/eslint-suppressions.json index 9c67622e89..c216d451e1 100644 --- a/frontend/apps/app/eslint-suppressions.json +++ b/frontend/apps/app/eslint-suppressions.json @@ -29,11 +29,6 @@ "count": 5 } }, - "components/ProjectNewPage/components/InstallationSelector/actions/getRepositories.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 3 - } - }, "components/ProjectsPage/ProjectsPage.module.css": { "css-modules-kit/no-unused-class-names": { "count": 9 diff --git a/frontend/apps/app/libs/github/cookie.ts b/frontend/apps/app/libs/github/cookie.ts index 0472ce1171..60834141fd 100644 --- a/frontend/apps/app/libs/github/cookie.ts +++ b/frontend/apps/app/libs/github/cookie.ts @@ -1,6 +1,6 @@ // Server-only utility for encrypted GitHub tokens stored in HttpOnly cookies -import { err, fromThrowable, ok, type Result } from '@liam-hq/neverthrow' +import { fromThrowable } from '@liam-hq/neverthrow' import { decryptAesGcm, encryptAesGcm } from '@liam-hq/security/cryptoBox' import { cookies } from 'next/headers' import * as v from 'valibot' @@ -36,36 +36,40 @@ function toMaxAgeSeconds(expiresAtIso: string): number { return Math.floor(diffMs / 1000) } -function packCookieValue(plaintext: string): Result { - const enc = encryptAesGcm(plaintext) - if (enc.isErr()) return err(enc.error) - const { keyId, ciphertext, initializationVector, authenticationTag } = - enc.value - const packed: Packed = { - key_id: keyId, - ciphertext: ciphertext.toString('base64'), - initialization_vector: initializationVector.toString('base64'), - authentication_tag: authenticationTag.toString('base64'), - } +function packCookieValue(plaintext: string) { const toJson = fromThrowable(JSON.stringify) - const json = toJson(packed) - return json.isOk() ? ok(json.value) : err(json.error) + return encryptAesGcm(plaintext).andThen( + ({ keyId, ciphertext, initializationVector, authenticationTag }) => { + const packed: Packed = { + key_id: keyId, + ciphertext: ciphertext.toString('base64'), + initialization_vector: initializationVector.toString('base64'), + authentication_tag: authenticationTag.toString('base64'), + } + return toJson(packed) + }, + ) } -function unpackCookieValue(value: string): Result { +function unpackCookieValue(value: string) { const parseJson = fromThrowable(JSON.parse) - const json = parseJson(value) - if (json.isErr()) return err(json.error) - const parsed = v.safeParse(PackedSchema, json.value) - if (!parsed.success) return err(new Error('Invalid packed cookie schema')) - const p = parsed.output - const dec = decryptAesGcm( - p.key_id, - Buffer.from(p.ciphertext, 'base64'), - Buffer.from(p.initialization_vector, 'base64'), - Buffer.from(p.authentication_tag, 'base64'), - ) - return dec.isOk() ? ok(dec.value) : err(dec.error) + const toPacked = fromThrowable((json: unknown): Packed => { + const parsed = v.safeParse(PackedSchema, json) + if (!parsed.success) + // eslint-disable-next-line no-throw-error/no-throw-error -- Throw to feed fromThrowable wrapper + throw new Error('Invalid packed cookie schema') + return parsed.output + }) + return parseJson(value) + .andThen(toPacked) + .andThen((p) => + decryptAesGcm( + p.key_id, + Buffer.from(p.ciphertext, 'base64'), + Buffer.from(p.initialization_vector, 'base64'), + Buffer.from(p.authentication_tag, 'base64'), + ), + ) } async function readTokenFrom(name: string): Promise { diff --git a/frontend/apps/app/libs/github/token.ts b/frontend/apps/app/libs/github/token.ts index e970a6bbb0..37864b1e62 100644 --- a/frontend/apps/app/libs/github/token.ts +++ b/frontend/apps/app/libs/github/token.ts @@ -1,6 +1,6 @@ // Server-only GitHub token utilities: read access token from Cookie (no refresh in RSC) -import { ResultAsync } from '@liam-hq/neverthrow' +import { fromPromise } from '@liam-hq/neverthrow' import { readAccessToken } from './cookie' const SAFETY_MS = 3 * 60 * 1000 // 3 minutes safety window @@ -8,18 +8,15 @@ const SAFETY_MS = 3 * 60 * 1000 // 3 minutes safety window function isExpiringSoon(expiresAtIso: string | null | undefined): boolean { if (!expiresAtIso) return true - const expiresAt = new Date(expiresAtIso).getTime() - return Date.now() + SAFETY_MS >= expiresAt + const t = new Date(expiresAtIso).getTime() + if (Number.isNaN(t)) return true + return Date.now() + SAFETY_MS >= t } // No refresh here; Route Handler performs refresh and cookie writes -export function getUserAccessToken( - _userId: string, -): ResultAsync { - return ResultAsync.fromPromise(readAccessToken(), (e) => - e instanceof Error ? e : new Error(String(e)), - ).map((access) => { +export function getUserAccessToken() { + return fromPromise(readAccessToken()).map((access) => { if (access && !isExpiringSoon(access.expiresAt)) { return access.token } diff --git a/frontend/internal-packages/github/package.json b/frontend/internal-packages/github/package.json index d8ff8ec6af..4490eb93c2 100644 --- a/frontend/internal-packages/github/package.json +++ b/frontend/internal-packages/github/package.json @@ -8,7 +8,6 @@ "@octokit/auth-app": "7.2.2", "@octokit/openapi-types": "25.1.0", "@octokit/rest": "21.1.1", - "@supabase/supabase-js": "2.49.8", "neverthrow": "8.2.0", "valibot": "1.1.0" }, diff --git a/frontend/internal-packages/neverthrow/src/index.ts b/frontend/internal-packages/neverthrow/src/index.ts index 8ee808ae0f..5327b1f024 100644 --- a/frontend/internal-packages/neverthrow/src/index.ts +++ b/frontend/internal-packages/neverthrow/src/index.ts @@ -1,7 +1,5 @@ import { Result } from 'neverthrow' -export { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' - import { defaultErrorFn } from './defaultErrorFn' export function fromThrowable( @@ -18,6 +16,7 @@ export function fromThrowable( return Result.fromThrowable(fn, errorFn ?? defaultErrorFn) } +export type { Result, ResultAsync } from 'neverthrow' export { fromAsyncThrowable } from './fromAsyncThrowable' export { fromPromise } from './fromPromise' export { fromValibotSafeParse } from './fromValibotSafeParse' diff --git a/frontend/packages/security/src/cryptoBox.ts b/frontend/packages/security/src/cryptoBox.ts index cd3f9d3acc..330e8f81ef 100644 --- a/frontend/packages/security/src/cryptoBox.ts +++ b/frontend/packages/security/src/cryptoBox.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto' -import { err, fromThrowable, ok, type Result } from '@liam-hq/neverthrow' +import type { Result } from '@liam-hq/neverthrow' +import { fromThrowable } from '@liam-hq/neverthrow' export type Key = { id: string; key: Buffer } @@ -30,10 +31,18 @@ export function setKeyring(keys: Key[]): void { } export function currentKey(): Result { - if (!RING.length) return err(new Error('No keys configured')) - const k = RING[0] - if (!k) return err(new Error('No keys configured')) - return ok(k) + return fromThrowable(() => { + if (!RING.length) { + // eslint-disable-next-line no-throw-error/no-throw-error -- Throw to feed fromThrowable + throw new Error('No keys configured') + } + const k = RING[0] + if (!k) { + // eslint-disable-next-line no-throw-error/no-throw-error -- Throw to feed fromThrowable + throw new Error('No keys configured') + } + return k + })() } export type CipherBundle = { @@ -47,10 +56,11 @@ export type CipherBundle = { * Encrypts plaintext using AES-256-GCM with a random IV. */ export function encryptAesGcm(plaintext: string): Result { - const keyRes = currentKey() - if (keyRes.isErr()) return err(keyRes.error) - const { id, key } = keyRes.value return fromThrowable(() => { + const keyRes = currentKey() + if (keyRes.isErr()) throw keyRes.error + const { id, key } = keyRes.value + const iv = crypto.randomBytes(12) const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) const ciphertext = Buffer.concat([ @@ -76,9 +86,12 @@ export function decryptAesGcm( initializationVector: Buffer, authenticationTag: Buffer, ): Result { - const entry = RING.find((k) => k.id === keyId) - if (!entry) return err(new Error('Unknown key id')) return fromThrowable(() => { + const entry = RING.find((k) => k.id === keyId) + if (!entry) + // eslint-disable-next-line no-throw-error/no-throw-error -- Throw to feed fromThrowable + throw new Error('Unknown key id') + const decipher = crypto.createDecipheriv( 'aes-256-gcm', entry.key, From 162a684feab89994294545b2ca39d5de231b865d Mon Sep 17 00:00:00 2001 From: IkedaNoritaka <50833174+NoritakaIkeda@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:49:09 +0900 Subject: [PATCH 03/34] =?UTF-8?q?=E2=99=BB=EF=B8=8F(app):=20Remove=20login?= =?UTF-8?q?=20redirect=20on=20token=20failure=20in=20project=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed project creation page to handle token failures gracefully without redirecting to login. When getUserAccessToken fails or returns null, the page now stays on /projects/new with needsRefresh=true, allowing TokenRefreshKick to handle the refresh flow. InstallationSelector is disabled during refresh. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/apps/app/app/projects/new/page.tsx | 40 +++++++++++-------- .../ProjectNewPage/ProjectNewPage.tsx | 1 + .../InstallationSelector.tsx | 8 ++-- pnpm-lock.yaml | 3 -- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/frontend/apps/app/app/projects/new/page.tsx b/frontend/apps/app/app/projects/new/page.tsx index a8f9b1acc0..88e7731f87 100644 --- a/frontend/apps/app/app/projects/new/page.tsx +++ b/frontend/apps/app/app/projects/new/page.tsx @@ -1,4 +1,5 @@ import { getInstallations } from '@liam-hq/github' +import { fromPromise } from '@liam-hq/neverthrow' import { redirect } from 'next/navigation' import { ProjectNewPage } from '../../../components/ProjectNewPage' import { getOrganizationId } from '../../../features/organizations/services/getOrganizationId' @@ -27,24 +28,29 @@ export default async function NewProjectPage() { } const tokenResult = await getUserAccessToken() - if (tokenResult.isErr()) { - console.error('Failed to get user access token:', tokenResult.error) - redirect(urlgen('login')) - } - const token = tokenResult.value - if (!token) { - redirect(urlgen('login')) - } - const installationsResult = await getInstallations(token) - const { installations } = await installationsResult.match( - (v) => v, - (e) => { - console.error('Failed to fetch installations:', e) - return { installations: [] } - }, - ) - const needsRefresh = !token || installations.length === 0 + const { installations, needsRefresh } = await tokenResult + .asyncAndThen((token) => { + if (!token) { + return fromPromise( + Promise.resolve({ + installations: [], + needsRefresh: true, + }), + ) + } + return getInstallations(token).map((result) => ({ + installations: result.installations, + needsRefresh: false, + })) + }) + .match( + (v) => v, + (e) => { + console.error('Failed to get token or installations:', e) + return { installations: [], needsRefresh: true } + }, + ) return ( = ({
) diff --git a/frontend/apps/app/components/ProjectNewPage/components/InstallationSelector/InstallationSelector.tsx b/frontend/apps/app/components/ProjectNewPage/components/InstallationSelector/InstallationSelector.tsx index aa204369f1..4059decda4 100644 --- a/frontend/apps/app/components/ProjectNewPage/components/InstallationSelector/InstallationSelector.tsx +++ b/frontend/apps/app/components/ProjectNewPage/components/InstallationSelector/InstallationSelector.tsx @@ -24,11 +24,13 @@ import styles from './InstallationSelector.module.css' type Props = { installations: Installation[] organizationId: string + disabled?: boolean } export const InstallationSelector: FC = ({ installations, organizationId, + disabled = false, }) => { const [selectedInstallation, setSelectedInstallation] = useState(null) @@ -81,8 +83,8 @@ export const InstallationSelector: FC = ({
- -