Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,13 @@ DATABASE_URL=postgres://user:pass@localhost:5432/clabot
SESSION_SECRET=your-secret-key
ENCRYPTION_KEY=your-encryption-key

# GitHub OAuth
# GitHub App — user authorization (sign-in) credentials.
# Found under the App's "Client ID" + "Client secrets".
# The App must have "Expire user authorization tokens" enabled.
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...

# GitHub App
# GitHub App — installation/identification credentials.
GITHUB_APP_SLUG=...
GITHUB_APP_ID=...
GITHUB_PRIVATE_KEY=...
Expand Down Expand Up @@ -185,8 +187,8 @@ tests/

| Variable | Description |
| --- | --- |
| `GITHUB_CLIENT_ID` | OAuth app client ID |
| `GITHUB_CLIENT_SECRET` | OAuth app client secret |
| `GITHUB_CLIENT_ID` | GitHub App user-authorization client ID (the App's own client_id) |
| `GITHUB_CLIENT_SECRET` | GitHub App user-authorization client secret |
| `GITHUB_APP_SLUG` | GitHub App slug |
| `GITHUB_APP_ID` | GitHub App ID |
| `GITHUB_PRIVATE_KEY` | GitHub App private key |
Expand Down
25 changes: 24 additions & 1 deletion app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { InstallSyncPoller } from "@/components/admin/install-sync-poller"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Github, Plus, Building2, ArrowRight } from "lucide-react"
import { AlertTriangle, ArrowRight, Building2, Github, Plus, RefreshCw } from "lucide-react"
import { getSessionUser } from "@/lib/auth"
import { getOrganizations } from "@/lib/db/queries"
import { filterInstalledOrganizationsForAdmin } from "@/lib/github/admin-authorization"
Expand Down Expand Up @@ -90,6 +90,29 @@ export default async function AdminPage({
</a>
</div>

{user.githubTokenKind !== "refreshable" && (
<div
className="mb-6 flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4"
data-testid="reauth-banner"
>
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-500" />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
Re-authenticate to refresh GitHub access
</p>
<p className="text-xs text-muted-foreground">
Some organizations may be hidden until you sign in again so we can refresh your
GitHub permissions.
</p>
</div>
<a href="/api/auth/github?returnTo=%2Fadmin">
<Button size="sm" variant="outline" className="gap-2">
<RefreshCw className="h-3 w-3" />
Re-authenticate
</Button>
</a>
</div>
)}
{hasApiError ? (
<Card>
<CardContent className="py-12 text-center">
Expand Down
10 changes: 6 additions & 4 deletions app/api/admin/orgs/[orgSlug]/bypass/suggest/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { type NextRequest, NextResponse } from "next/server"
import { getBypassAccountsByOrg } from "@/lib/db/queries"
import { searchGitHubUsersWithOAuth } from "@/lib/github/oauth-user-search"
import { getValidUserAccessToken } from "@/lib/github/user-token"
import { authorizeOrgAccess } from "@/lib/server/org-access"
import { decryptSecret } from "@/lib/security/encryption"
import {
formatBypassActorLogin,
isLikelyAppBotActor,
Expand Down Expand Up @@ -52,16 +52,18 @@ export async function GET(
}
}

const encryptedToken = access.user.githubAccessTokenEncrypted ?? null
const accessToken = encryptedToken ? decryptSecret(encryptedToken) : null
const accessToken = await getValidUserAccessToken(access.user.id)
if (!accessToken) {
if (bypassKind === "app_bot") {
const manualSuggestion = toManualAppBotSuggestion()
return NextResponse.json({ suggestions: manualSuggestion ? [manualSuggestion] : [] })
}

return NextResponse.json(
{ error: "Missing GitHub OAuth token. Sign out and sign back in to enable autocomplete." },
{
error:
"GitHub authorization is no longer valid. Sign out and sign back in to enable autocomplete.",
},
{ status: 400 }
)
}
Expand Down
35 changes: 30 additions & 5 deletions app/api/auth/github/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"
import { upsertUser, updateUserGithubAuth } from "@/lib/db/queries"
import { upsertUser, setUserGithubTokens } from "@/lib/db/queries"
import { createSessionToken, getSessionCookieOptions } from "@/lib/auth"
import { encryptSecret } from "@/lib/security/encryption"

Expand Down Expand Up @@ -100,11 +100,31 @@ export async function GET(request: NextRequest) {
return makeAuthErrorRedirect("github_token")
}

const accessToken = tokenData.access_token
const accessToken: string | undefined = tokenData.access_token
const refreshToken: string | undefined = tokenData.refresh_token
const expiresIn: number | undefined =
typeof tokenData.expires_in === "number" ? tokenData.expires_in : undefined
const refreshTokenExpiresIn: number | undefined =
typeof tokenData.refresh_token_expires_in === "number"
? tokenData.refresh_token_expires_in
: undefined

if (!accessToken) {
console.error("GitHub OAuth token error: missing access token", tokenData)
return makeAuthErrorRedirect("github_token")
}
if (!refreshToken || !expiresIn || !refreshTokenExpiresIn) {
console.error(
"GitHub OAuth token error: response missing refresh_token / expires_in fields. " +
"Confirm that 'Expire user authorization tokens' is enabled on the GitHub App.",
{
hasRefreshToken: Boolean(refreshToken),
hasExpiresIn: Boolean(expiresIn),
hasRefreshTokenExpiresIn: Boolean(refreshTokenExpiresIn),
}
)
return makeAuthErrorRedirect("github_token")
}

// Step 3: Fetch user profile from GitHub API
const userRes = await fetch("https://api.github.com/user", {
Expand Down Expand Up @@ -134,15 +154,20 @@ export async function GET(request: NextRequest) {
})

const encryptedAccessToken = encryptSecret(accessToken)
if (!encryptedAccessToken) {
const encryptedRefreshToken = encryptSecret(refreshToken)
if (!encryptedAccessToken || !encryptedRefreshToken) {
console.error(
"Failed to encrypt GitHub OAuth token: ENCRYPTION_KEY or SESSION_SECRET is missing"
"Failed to encrypt GitHub OAuth tokens: ENCRYPTION_KEY or SESSION_SECRET is missing"
)
return makeAuthErrorRedirect("server_config")
}

await updateUserGithubAuth(user.id, {
const now = Date.now()
await setUserGithubTokens(user.id, {
accessTokenEncrypted: encryptedAccessToken,
accessTokenExpiresAt: new Date(now + expiresIn * 1000).toISOString(),
refreshTokenEncrypted: encryptedRefreshToken,
refreshTokenExpiresAt: new Date(now + refreshTokenExpiresIn * 1000).toISOString(),
tokenScopes: tokenData.scope ?? "",
})

Expand Down
26 changes: 23 additions & 3 deletions app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionCookieOptions } from "@/lib/auth"
import { COOKIE_NAME, getSessionCookieOptions, verifySessionToken } from "@/lib/auth"
import { revokeUserGithubTokens } from "@/lib/github/user-token"
import { createAuditEvent } from "@/lib/db/queries"

/**
* POST /api/auth/logout
* Clears the session cookie and redirects to the home page.
* Revokes the GitHub user access token (best-effort), clears the encrypted
* token columns, then clears the session cookie and redirects home.
*/
export async function POST(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAME)?.value
const payload = token ? await verifySessionToken(token) : null

if (payload?.userId) {
// Fire-and-forget upstream revoke + DB clear. Don't block logout on it.
void revokeUserGithubTokens(payload.userId).catch((error) => {
console.warn("[logout] Token revocation failed", error)
})
void createAuditEvent({
eventType: "user.signed_out",
userId: payload.userId,
actorGithubUsername: payload.githubUsername ?? null,
}).catch((error) => {
console.warn("[logout] Failed to write audit event", error)
})
}

const response = NextResponse.redirect(new URL("/", request.url))
const cookieOpts = getSessionCookieOptions()
response.cookies.set(cookieOpts.name, "", {
httpOnly: cookieOpts.httpOnly,
secure: cookieOpts.secure,
sameSite: cookieOpts.sameSite,
path: cookieOpts.path,
maxAge: 0, // expire immediately
maxAge: 0,
})
return response
}
25 changes: 24 additions & 1 deletion app/contributor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SiteHeader } from "@/components/site-header"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertTriangle, Download, ExternalLink, FileCheck2, Github } from "lucide-react"
import { AlertTriangle, Download, ExternalLink, FileCheck2, Github, RefreshCw } from "lucide-react"
import { getSessionUser } from "@/lib/auth"
import { getOrganizations, getSignaturesByUser } from "@/lib/db/queries"

Expand Down Expand Up @@ -126,6 +126,29 @@ export default async function ContributorPage() {
</CardContent>
</Card>

{user.githubTokenKind !== "refreshable" && (
<div
className="mb-6 flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4"
data-testid="reauth-banner"
>
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-500" />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
Re-authenticate to refresh GitHub access
</p>
<p className="text-xs text-muted-foreground">
Sign in again so we can refresh your GitHub permissions.
</p>
</div>
<a href="/api/auth/github?returnTo=%2Fcontributor">
<Button size="sm" variant="outline" className="gap-2">
<RefreshCw className="h-3 w-3" />
Re-authenticate
</Button>
</a>
</div>
)}

{outdatedCount > 0 && (
<div
className="mb-6 flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4"
Expand Down
4 changes: 2 additions & 2 deletions docs/operator/deploy-on-my-infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Run CLA Bot in your own infrastructure with production-ready configuration.
- Node.js >= 20
- pnpm
- PostgreSQL
- GitHub OAuth app + GitHub App credentials
- A GitHub App with user authorization enabled and "Expire user authorization tokens" turned on (provides both the user-OAuth and installation credentials)

## Required environment variables
- `DATABASE_URL`
Expand Down Expand Up @@ -40,7 +40,7 @@ Use Drizzle migrations in `drizzle/`. Do not apply ad hoc manual schema edits ou
| Symptom | Likely cause | Action |
| --- | --- | --- |
| Webhook requests rejected | Signature mismatch or wrong secret | Verify `GITHUB_WEBHOOK_SECRET` and GitHub webhook settings |
| OAuth sign-in fails | Invalid GitHub OAuth credentials | Re-check `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` |
| OAuth sign-in fails | Invalid GitHub App user-OAuth credentials, or "Expire user authorization tokens" disabled | Re-check `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` and confirm the App's token-expiry setting |
| PR checks stale after signing | Async sync backlog/transient failure | Retry after short wait; inspect webhook and app logs |
| App boot fails on deploy | DB unreachable or migrations pending | Validate `DATABASE_URL`, run `pnpm db:migrate`, retry |

Expand Down
4 changes: 4 additions & 0 deletions drizzle/0004_youthful_wild_pack.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE "users" ADD COLUMN "github_access_token_expires_at" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "github_refresh_token_encrypted" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "github_refresh_token_expires_at" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "github_token_kind" text DEFAULT 'legacy_user' NOT NULL;
Loading
Loading