Skip to content

Commit bdda7dc

Browse files
committed
fix: working on githb token expiring
1 parent 2056880 commit bdda7dc

19 files changed

Lines changed: 1455 additions & 101 deletions

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,13 @@ DATABASE_URL=postgres://user:pass@localhost:5432/clabot
8080
SESSION_SECRET=your-secret-key
8181
ENCRYPTION_KEY=your-encryption-key
8282

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

87-
# GitHub App
89+
# GitHub App — installation/identification credentials.
8890
GITHUB_APP_SLUG=...
8991
GITHUB_APP_ID=...
9092
GITHUB_PRIVATE_KEY=...
@@ -185,8 +187,8 @@ tests/
185187

186188
| Variable | Description |
187189
| --- | --- |
188-
| `GITHUB_CLIENT_ID` | OAuth app client ID |
189-
| `GITHUB_CLIENT_SECRET` | OAuth app client secret |
190+
| `GITHUB_CLIENT_ID` | GitHub App user-authorization client ID (the App's own client_id) |
191+
| `GITHUB_CLIENT_SECRET` | GitHub App user-authorization client secret |
190192
| `GITHUB_APP_SLUG` | GitHub App slug |
191193
| `GITHUB_APP_ID` | GitHub App ID |
192194
| `GITHUB_PRIVATE_KEY` | GitHub App private key |

app/admin/page.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { InstallSyncPoller } from "@/components/admin/install-sync-poller"
77
import { Badge } from "@/components/ui/badge"
88
import { Button } from "@/components/ui/button"
99
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
10-
import { Github, Plus, Building2, ArrowRight } from "lucide-react"
10+
import { AlertTriangle, ArrowRight, Building2, Github, Plus, RefreshCw } from "lucide-react"
1111
import { getSessionUser } from "@/lib/auth"
1212
import { getOrganizations } from "@/lib/db/queries"
1313
import { filterInstalledOrganizationsForAdmin } from "@/lib/github/admin-authorization"
@@ -90,6 +90,29 @@ export default async function AdminPage({
9090
</a>
9191
</div>
9292

93+
{user.githubTokenKind !== "refreshable" && (
94+
<div
95+
className="mb-6 flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4"
96+
data-testid="reauth-banner"
97+
>
98+
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-500" />
99+
<div className="flex-1">
100+
<p className="text-sm font-medium text-foreground">
101+
Re-authenticate to refresh GitHub access
102+
</p>
103+
<p className="text-xs text-muted-foreground">
104+
Some organizations may be hidden until you sign in again so we can refresh your
105+
GitHub permissions.
106+
</p>
107+
</div>
108+
<a href="/api/auth/github?returnTo=%2Fadmin">
109+
<Button size="sm" variant="outline" className="gap-2">
110+
<RefreshCw className="h-3 w-3" />
111+
Re-authenticate
112+
</Button>
113+
</a>
114+
</div>
115+
)}
93116
{hasApiError ? (
94117
<Card>
95118
<CardContent className="py-12 text-center">

app/api/admin/orgs/[orgSlug]/bypass/suggest/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { type NextRequest, NextResponse } from "next/server"
22
import { getBypassAccountsByOrg } from "@/lib/db/queries"
33
import { searchGitHubUsersWithOAuth } from "@/lib/github/oauth-user-search"
4+
import { getValidUserAccessToken } from "@/lib/github/user-token"
45
import { authorizeOrgAccess } from "@/lib/server/org-access"
5-
import { decryptSecret } from "@/lib/security/encryption"
66
import {
77
formatBypassActorLogin,
88
isLikelyAppBotActor,
@@ -52,16 +52,18 @@ export async function GET(
5252
}
5353
}
5454

55-
const encryptedToken = access.user.githubAccessTokenEncrypted ?? null
56-
const accessToken = encryptedToken ? decryptSecret(encryptedToken) : null
55+
const accessToken = await getValidUserAccessToken(access.user.id)
5756
if (!accessToken) {
5857
if (bypassKind === "app_bot") {
5958
const manualSuggestion = toManualAppBotSuggestion()
6059
return NextResponse.json({ suggestions: manualSuggestion ? [manualSuggestion] : [] })
6160
}
6261

6362
return NextResponse.json(
64-
{ error: "Missing GitHub OAuth token. Sign out and sign back in to enable autocomplete." },
63+
{
64+
error:
65+
"GitHub authorization is no longer valid. Sign out and sign back in to enable autocomplete.",
66+
},
6567
{ status: 400 }
6668
)
6769
}

app/api/auth/github/route.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server"
2-
import { upsertUser, updateUserGithubAuth } from "@/lib/db/queries"
2+
import { upsertUser, setUserGithubTokens } from "@/lib/db/queries"
33
import { createSessionToken, getSessionCookieOptions } from "@/lib/auth"
44
import { encryptSecret } from "@/lib/security/encryption"
55

@@ -100,11 +100,31 @@ export async function GET(request: NextRequest) {
100100
return makeAuthErrorRedirect("github_token")
101101
}
102102

103-
const accessToken = tokenData.access_token
103+
const accessToken: string | undefined = tokenData.access_token
104+
const refreshToken: string | undefined = tokenData.refresh_token
105+
const expiresIn: number | undefined =
106+
typeof tokenData.expires_in === "number" ? tokenData.expires_in : undefined
107+
const refreshTokenExpiresIn: number | undefined =
108+
typeof tokenData.refresh_token_expires_in === "number"
109+
? tokenData.refresh_token_expires_in
110+
: undefined
111+
104112
if (!accessToken) {
105113
console.error("GitHub OAuth token error: missing access token", tokenData)
106114
return makeAuthErrorRedirect("github_token")
107115
}
116+
if (!refreshToken || !expiresIn || !refreshTokenExpiresIn) {
117+
console.error(
118+
"GitHub OAuth token error: response missing refresh_token / expires_in fields. " +
119+
"Confirm that 'Expire user authorization tokens' is enabled on the GitHub App.",
120+
{
121+
hasRefreshToken: Boolean(refreshToken),
122+
hasExpiresIn: Boolean(expiresIn),
123+
hasRefreshTokenExpiresIn: Boolean(refreshTokenExpiresIn),
124+
}
125+
)
126+
return makeAuthErrorRedirect("github_token")
127+
}
108128

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

136156
const encryptedAccessToken = encryptSecret(accessToken)
137-
if (!encryptedAccessToken) {
157+
const encryptedRefreshToken = encryptSecret(refreshToken)
158+
if (!encryptedAccessToken || !encryptedRefreshToken) {
138159
console.error(
139-
"Failed to encrypt GitHub OAuth token: ENCRYPTION_KEY or SESSION_SECRET is missing"
160+
"Failed to encrypt GitHub OAuth tokens: ENCRYPTION_KEY or SESSION_SECRET is missing"
140161
)
141162
return makeAuthErrorRedirect("server_config")
142163
}
143164

144-
await updateUserGithubAuth(user.id, {
165+
const now = Date.now()
166+
await setUserGithubTokens(user.id, {
145167
accessTokenEncrypted: encryptedAccessToken,
168+
accessTokenExpiresAt: new Date(now + expiresIn * 1000).toISOString(),
169+
refreshTokenEncrypted: encryptedRefreshToken,
170+
refreshTokenExpiresAt: new Date(now + refreshTokenExpiresIn * 1000).toISOString(),
146171
tokenScopes: tokenData.scope ?? "",
147172
})
148173

app/api/auth/logout/route.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,39 @@
11
import { NextRequest, NextResponse } from "next/server"
2-
import { getSessionCookieOptions } from "@/lib/auth"
2+
import { COOKIE_NAME, getSessionCookieOptions, verifySessionToken } from "@/lib/auth"
3+
import { revokeUserGithubTokens } from "@/lib/github/user-token"
4+
import { createAuditEvent } from "@/lib/db/queries"
35

46
/**
57
* POST /api/auth/logout
6-
* Clears the session cookie and redirects to the home page.
8+
* Revokes the GitHub user access token (best-effort), clears the encrypted
9+
* token columns, then clears the session cookie and redirects home.
710
*/
811
export async function POST(request: NextRequest) {
12+
const token = request.cookies.get(COOKIE_NAME)?.value
13+
const payload = token ? await verifySessionToken(token) : null
14+
15+
if (payload?.userId) {
16+
// Fire-and-forget upstream revoke + DB clear. Don't block logout on it.
17+
void revokeUserGithubTokens(payload.userId).catch((error) => {
18+
console.warn("[logout] Token revocation failed", error)
19+
})
20+
void createAuditEvent({
21+
eventType: "user.signed_out",
22+
userId: payload.userId,
23+
actorGithubUsername: payload.githubUsername ?? null,
24+
}).catch((error) => {
25+
console.warn("[logout] Failed to write audit event", error)
26+
})
27+
}
28+
929
const response = NextResponse.redirect(new URL("/", request.url))
1030
const cookieOpts = getSessionCookieOptions()
1131
response.cookies.set(cookieOpts.name, "", {
1232
httpOnly: cookieOpts.httpOnly,
1333
secure: cookieOpts.secure,
1434
sameSite: cookieOpts.sameSite,
1535
path: cookieOpts.path,
16-
maxAge: 0, // expire immediately
36+
maxAge: 0,
1737
})
1838
return response
1939
}

app/contributor/page.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SiteHeader } from "@/components/site-header"
33
import { Badge } from "@/components/ui/badge"
44
import { Button } from "@/components/ui/button"
55
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
6-
import { AlertTriangle, Download, ExternalLink, FileCheck2, Github } from "lucide-react"
6+
import { AlertTriangle, Download, ExternalLink, FileCheck2, Github, RefreshCw } from "lucide-react"
77
import { getSessionUser } from "@/lib/auth"
88
import { getOrganizations, getSignaturesByUser } from "@/lib/db/queries"
99

@@ -126,6 +126,29 @@ export default async function ContributorPage() {
126126
</CardContent>
127127
</Card>
128128

129+
{user.githubTokenKind !== "refreshable" && (
130+
<div
131+
className="mb-6 flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4"
132+
data-testid="reauth-banner"
133+
>
134+
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-500" />
135+
<div className="flex-1">
136+
<p className="text-sm font-medium text-foreground">
137+
Re-authenticate to refresh GitHub access
138+
</p>
139+
<p className="text-xs text-muted-foreground">
140+
Sign in again so we can refresh your GitHub permissions.
141+
</p>
142+
</div>
143+
<a href="/api/auth/github?returnTo=%2Fcontributor">
144+
<Button size="sm" variant="outline" className="gap-2">
145+
<RefreshCw className="h-3 w-3" />
146+
Re-authenticate
147+
</Button>
148+
</a>
149+
</div>
150+
)}
151+
129152
{outdatedCount > 0 && (
130153
<div
131154
className="mb-6 flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4"

docs/operator/deploy-on-my-infrastructure.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Run CLA Bot in your own infrastructure with production-ready configuration.
77
- Node.js >= 20
88
- pnpm
99
- PostgreSQL
10-
- GitHub OAuth app + GitHub App credentials
10+
- A GitHub App with user authorization enabled and "Expire user authorization tokens" turned on (provides both the user-OAuth and installation credentials)
1111

1212
## Required environment variables
1313
- `DATABASE_URL`
@@ -40,7 +40,7 @@ Use Drizzle migrations in `drizzle/`. Do not apply ad hoc manual schema edits ou
4040
| Symptom | Likely cause | Action |
4141
| --- | --- | --- |
4242
| Webhook requests rejected | Signature mismatch or wrong secret | Verify `GITHUB_WEBHOOK_SECRET` and GitHub webhook settings |
43-
| OAuth sign-in fails | Invalid GitHub OAuth credentials | Re-check `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` |
43+
| 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 |
4444
| PR checks stale after signing | Async sync backlog/transient failure | Retry after short wait; inspect webhook and app logs |
4545
| App boot fails on deploy | DB unreachable or migrations pending | Validate `DATABASE_URL`, run `pnpm db:migrate`, retry |
4646

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE "users" ADD COLUMN "github_access_token_expires_at" text;--> statement-breakpoint
2+
ALTER TABLE "users" ADD COLUMN "github_refresh_token_encrypted" text;--> statement-breakpoint
3+
ALTER TABLE "users" ADD COLUMN "github_refresh_token_expires_at" text;--> statement-breakpoint
4+
ALTER TABLE "users" ADD COLUMN "github_token_kind" text DEFAULT 'legacy_user' NOT NULL;

0 commit comments

Comments
 (0)