Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e3dcb15
♻️ Migrate GitHub OAuth token management to encrypted HttpOnly cookies
NoritakaIkeda Oct 17, 2025
f433134
♻️ Refactor to use Result monad patterns consistently
NoritakaIkeda Oct 17, 2025
162a684
♻️(app): Remove login redirect on token failure in project creation
NoritakaIkeda Oct 17, 2025
437fb3c
📝(config): Improve KEYRING documentation in env template
NoritakaIkeda Oct 17, 2025
8f4ded6
♻️(security): Migrate to LIAM_GITHUB_OAUTH_KEYRING environment variable
NoritakaIkeda Oct 20, 2025
b3aedd9
♻️(security): Remove legacy keyring fallback variables
NoritakaIkeda Oct 20, 2025
183f7d5
♻️(security): Move security package to internal-packages
NoritakaIkeda Oct 20, 2025
9b7b704
🔧(security): Add missing config files to security package
NoritakaIkeda Oct 20, 2025
b9d4801
♻️(auth): Refactor token refresh to use Result monad patterns
NoritakaIkeda Oct 20, 2025
896dbbf
♻️(middleware): Improve API authentication handling
NoritakaIkeda Oct 20, 2025
13f2557
🐛(auth): Ensure cookie writes complete and propagate failures
NoritakaIkeda Oct 20, 2025
f9d22ce
Merge branch 'main' into feat/github-oauth-token-management
NoritakaIkeda Oct 20, 2025
0cf8ebe
♻️(middleware): Simplify authentication logic
NoritakaIkeda Oct 20, 2025
5b08f37
➕(deps): Add @octokit/oauth-app and remove @supabase/supabase-js
NoritakaIkeda Oct 20, 2025
cc1a9a2
♻️(auth): Refactor token refresh to use @octokit/oauth-app
NoritakaIkeda Oct 20, 2025
e4bc3e1
🐛(middleware): Use correct logout API path
NoritakaIkeda Oct 20, 2025
498d126
🎨(auth): Format token refresh route imports
NoritakaIkeda Oct 20, 2025
370ba03
🎨(deps): Reorder dependencies alphabetically in package.json
NoritakaIkeda Oct 20, 2025
07e4cd0
🎨(lint): Remove trailing newline from eslint-suppressions.json
NoritakaIkeda Oct 20, 2025
7fb689b
♻️(auth): Revert to manual GitHub OAuth token refresh
NoritakaIkeda Oct 20, 2025
0aeeaa0
✨(auth): Make refresh_token_expires_in required in token refresh
NoritakaIkeda Oct 21, 2025
61e3146
✨(organizations): Add clearOrganizationIdCookie server action
NoritakaIkeda Oct 21, 2025
aece9bd
✨(auth): Add comprehensive logout API endpoint
NoritakaIkeda Oct 21, 2025
164db73
♻️(ui): Refactor UserDropdown to use new logout API
NoritakaIkeda Oct 21, 2025
d80f93e
♻️(auth): Move logout endpoint from /api/auth/logout to /api/logout
NoritakaIkeda Oct 21, 2025
e804aab
➖(deps): Remove unused @octokit/oauth-app dependency
NoritakaIkeda Oct 21, 2025
69c832f
🐛(ui): Fix installation selector state update timing
NoritakaIkeda Oct 21, 2025
85635de
🎨(organizations): Remove trailing newline from clearOrganizationIdCookie
NoritakaIkeda Oct 21, 2025
dce67e7
➕(security): Add neverthrow dependency
NoritakaIkeda Oct 21, 2025
eb68438
♻️(security): Refactor cryptoBox to use Result monad patterns
NoritakaIkeda Oct 21, 2025
8541e7f
♻️(auth): Refactor token refresh to use neverthrow Result patterns
NoritakaIkeda Oct 21, 2025
4af40bd
🔧(app): Remove unused path aliases from tsconfig.json
NoritakaIkeda Oct 21, 2025
d3e80e7
📝(middleware): Remove outdated API routes comment
NoritakaIkeda Oct 21, 2025
972b2c9
♻️(projects): Simplify okAsync usage in new project page
NoritakaIkeda Oct 21, 2025
8793232
Merge remote-tracking branch 'origin/main' into feat/github-oauth-tok…
NoritakaIkeda Oct 21, 2025
663d433
🔧: Fix lockfile and sync package versions after merge
NoritakaIkeda Oct 21, 2025
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
12 changes: 12 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ SENTRY_ORG=""
SENTRY_PROJECT=""
SUPABASE_SERVICE_ROLE_KEY=""
NEXT_PUBLIC_ALLOWED_DOMAINS=""

# GitHub OAuth token encryption keyring
# LIAM_GITHUB_OAUTH_KEYRING
# Usage: AES-256-GCM keyring for encrypting GitHub OAuth tokens in HttpOnly cookies.
# Format: comma-separated entries "<kid>:<base64key>" — first entry is the current key.
# - <kid>: recommended format is a readable version like "kYYYY-MM" (e.g., k2025-01)
# - <base64key>: 32-byte random key, base64-encoded
# Generate 32-byte key (Node.js):
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Example (current key first, with a rotated fallback):
# LIAM_GITHUB_OAUTH_KEYRING="k2025-01:BASE64_KEY_FOR_2025_01,k2024-12:BASE64_KEY_FOR_2024_12"
LIAM_GITHUB_OAUTH_KEYRING=""
143 changes: 143 additions & 0 deletions frontend/apps/app/app/api/github/token/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
fromPromise,
fromValibotSafeParse,
type Result,
} from '@liam-hq/neverthrow'
import * as Sentry from '@sentry/nextjs'
import type { ResultAsync as NeverthrowResultAsync } from 'neverthrow'
import { err, ok, ResultAsync } from 'neverthrow'
import { NextResponse } from 'next/server'
import * as v from 'valibot'
import type { TokenPayload } from '../../../../../libs/github/cookie'
import {
readRefreshToken,
writeAccessToken,
writeRefreshToken,
} from '../../../../../libs/github/cookie'

const 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 })
}

type RefreshAccessTokenResult = {
accessToken: string
refreshToken: string
refreshTokenExpiresIn: number
newExpiresAt: string
}

// GitHub OAuth token refresh response schema
const GitHubTokenRefreshResponseSchema = v.object({
access_token: v.string(),
token_type: v.string(),
scope: v.optional(v.string()),
expires_in: v.number(),
refresh_token: v.string(),
refresh_token_expires_in: v.number(),
})

function refreshAccessToken(
refreshToken: string,
clientId: string,
clientSecret: string,
): ResultAsync<RefreshAccessTokenResult, Error> {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}).toString()

return ResultAsync.fromPromise(
fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
}),
(e) => (e instanceof Error ? e : new Error(String(e))),
)
.andThen((res) => {
if (!res.ok) {
return err(new Error(`GitHub token refresh failed: ${res.status}`))
}
return ResultAsync.fromPromise<unknown, Error>(res.json(), (e) =>
e instanceof Error ? e : new Error(String(e)),
)
})
.andThen((data) =>
fromValibotSafeParse(GitHubTokenRefreshResponseSchema, data),
)
.map((data) => {
const now = Date.now()
const newExpiresAt = new Date(
now + Math.max(0, data.expires_in) * 1000,
).toISOString()
const refreshTokenExpiresIn = data.refresh_token_expires_in

return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
refreshTokenExpiresIn,
newExpiresAt,
}
})
}

async function handler(): Promise<NextResponse> {
// 1) 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

// 2) Read refresh token and refresh via GitHub
const refreshTokenResult: NeverthrowResultAsync<TokenPayload, Error> =
readRefreshToken()
const result = await refreshTokenResult
.andThen(({ token }: TokenPayload) =>
refreshAccessToken(token, clientId, clientSecret),
)
.andThen((refreshed: RefreshAccessTokenResult) => {
const rtei = refreshed.refreshTokenExpiresIn
const refreshTtlMs = rtei * 1000
const refreshExpiresAt = new Date(Date.now() + refreshTtlMs).toISOString()

// Ensure cookie writes complete and propagate failures
return fromPromise(
Promise.all([
writeAccessToken(refreshed.accessToken, refreshed.newExpiresAt),
writeRefreshToken(refreshed.refreshToken, refreshExpiresAt),
]),
).map(() => NextResponse.json({ ok: true }))
})

if (result.isErr()) {
Sentry.captureException(result.error, { tags: { area: 'refresh_api' } })
return NextResponse.json(
{ ok: false, error: 'refresh_failed' },
{ status: 401 },
)
}

return result.value
}

export async function POST() {
return handler()
}
44 changes: 44 additions & 0 deletions frontend/apps/app/app/api/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { fromAsyncThrowable } from '@liam-hq/neverthrow'
import * as Sentry from '@sentry/nextjs'
import { NextResponse } from 'next/server'
import { clearOrganizationIdCookie } from '../../../features/organizations/services/clearOrganizationIdCookie'
import { createClient } from '../../../libs/db/server'
import { clearTokens } from '../../../libs/github/cookie'

export async function POST() {
const opClearOrg = await fromAsyncThrowable(clearOrganizationIdCookie)()
const opClearTokens = await fromAsyncThrowable(clearTokens)()
const opSupabaseSignout = await fromAsyncThrowable(async () => {
const supabase = await createClient()
await supabase.auth.signOut({ scope: 'global' })
})()

let hasError = false
if (opClearOrg.isErr()) {
hasError = true
Sentry.captureException(opClearOrg.error, {
tags: { area: 'logout_api', step: 'clear_org_cookie' },
})
}
if (opClearTokens.isErr()) {
hasError = true
Sentry.captureException(opClearTokens.error, {
tags: { area: 'logout_api', step: 'clear_tokens' },
})
}
if (opSupabaseSignout.isErr()) {
hasError = true
Sentry.captureException(opSupabaseSignout.error, {
tags: { area: 'logout_api', step: 'supabase_signout' },
})
}

if (hasError) {
return NextResponse.json(
{ ok: false, error: 'logout_failed' },
{ status: 500 },
)
}

return NextResponse.json({ ok: true })
}
43 changes: 42 additions & 1 deletion frontend/apps/app/app/auth/callback/[provider]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
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)
Expand All @@ -11,9 +17,44 @@ export async function GET(request: Request) {

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
// Access token expiration: 8 hours.
// GitHub's OAuth access tokens typically expire after 8 hours. See: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-3-github-redirects-back-to-your-site
const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000
Comment on lines +27 to +30

Copilot AI Oct 16, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These hardcoded expiration values should be documented explaining why 8 hours for access tokens and 30 days for refresh tokens were chosen, or ideally sourced from GitHub API documentation if these align with GitHub's actual token lifetimes.

Suggested change
// received provider tokens for cookie write
const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000
// received provider tokens for cookie write
// Access token expiration: 8 hours.
// GitHub's OAuth access tokens typically expire after 8 hours. See: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-3-github-redirects-back-to-your-site
const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000
// Refresh token expiration: 30 days.
// GitHub's OAuth refresh tokens typically expire after 30 days. See: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-3-github-redirects-back-to-your-site

Copilot uses AI. Check for mistakes.
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub refresh tokens are valid for 6 months (180 days), not 30 days. This hardcoded value causes refresh tokens to be considered expired after 30 days, even though they're still valid. Why?
https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this point earlier.
I intended to align with any standard HttpOnly cookie storage duration, but since web standards and common security guidelines don't specifically mention it, I'll match the lifetime of GitHub tokens instead. I'll address this in the next PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intended to align with any standard HttpOnly cookie storage duration.

I don't think there's a problem with that itself, but using magic numbers is not good.


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
},
Comment on lines +50 to +56

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Access tokens and refresh tokens must be obtained during login. Since this is a critical path, we want to monitor it with Sentry without stopping the app due to errors.

)

const forwardedHost = request.headers.get('x-forwarded-host')
const isLocalEnv = process.env.NODE_ENV === 'development'
Expand Down
34 changes: 27 additions & 7 deletions frontend/apps/app/app/projects/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
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'
import { createClient } from '../../../libs/db/server'
import { getUserAccessToken } from '../../../libs/github/token'
import { urlgen } from '../../../libs/routes'

export default async function NewProjectPage() {
Expand All @@ -25,18 +27,36 @@ export default async function NewProjectPage() {
redirect(urlgen('login'))
}

const { data } = await supabase.auth.getSession()

if (data.session === null) {
redirect(urlgen('login'))
}

const { installations } = await getInstallations(data.session)
const tokenResult = await getUserAccessToken()

const { installations, needsRefresh } = await tokenResult
.asyncAndThen((token) => {
if (!token) {
return fromPromise(
Promise.resolve({
installations: [],
needsRefresh: true,
}),
)
Comment thread
MH4GF marked this conversation as resolved.
Outdated
}
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 (
<ProjectNewPage
installations={installations}
organizationId={organizationId}
needsRefresh={needsRefresh}
/>
)
}
37 changes: 13 additions & 24 deletions frontend/apps/app/components/CommonLayout/AppBar/UserDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client'

import { fromAsyncThrowable } from '@liam-hq/neverthrow'
import {
Avatar,
AvatarWithImage,
Expand All @@ -13,7 +13,6 @@ import {
} from '@liam-hq/ui'
import { useRouter } from 'next/navigation'
import { type FC, useCallback } from 'react'
import { createClient } from '../../../libs/db/client'

function getUserInitial({
userName,
Expand All @@ -40,38 +39,28 @@ type Props = {
userEmail?: string | null
}

// Helper function to delete cookie
const deleteCookie = (name: string) => {
const expires = 'Thu, 01 Jan 1970 00:00:00 UTC'
const cookie = `${name}=; expires=${expires}; path=/;`
// biome-ignore lint/suspicious/noDocumentCookie: Required for cookie deletion
document.cookie = cookie
}

export const UserDropdown: FC<Props> = ({ avatarUrl, userName, userEmail }) => {
const toast = useToast()
const router = useRouter()

const userInitial = getUserInitial({ userName, userEmail })

const handleLogout = useCallback(async () => {
// Perform logout on client side
const supabase = createClient()
const { error } = await supabase.auth.signOut()
const result = await fromAsyncThrowable(async () =>
fetch('/api/logout', { method: 'POST', cache: 'no-store' }),
)()

if (!error) {
// Delete organizationId cookie
deleteCookie('organizationId')

// Redirect with success parameter
if (result.isOk() && result.value.ok) {
router.push('/login?logout=success')
} else {
toast({
title: 'Logout failed',
description: error.message || 'An error occurred during logout.',
status: 'error',
})
return
}

// If fetch failed or response not OK, show error
toast({
title: 'Logout failed',
description: 'Unable to complete logout. Please try again.',
status: 'error',
})
}, [toast, router])

return (
Expand Down
Loading
Loading