创建时间: 2026-06-06最后更新: 2026-06-06

这篇文章会用一个真实项目里的实现,带你把 Web 端接入 GitHub 授权登录的过程完整梳理一遍。

我们要完成的效果是:用户在登录页点击 使用 GitHub 登录 后,浏览器跳转到 GitHub 授权页;GitHub 授权成功后回调 API 子站;API 子站用 code 换 GitHub access token,再读取 GitHub 用户资料和邮箱,接着创建或绑定本系统用户,并生成一个短时一次性 ticket;Web 子站拿到 ticket 后,再换成本系统自己的登录态,最后把 accessTokenrefreshTokensession 保存下来。

重点是:GitHub access token 不会交给浏览器长期保存,本系统 token 也不会直接塞进 URL。

1. 授权登录原理

在写代码之前,我们先把 GitHub 授权登录这件事本身讲清楚。

GitHub 登录不是让 GitHub 直接替我们的系统发登录态,而是让 GitHub 证明:当前浏览器里的这个用户,确实控制着某个 GitHub 账号。GitHub 能证明的是外部身份,本系统真正要保存和校验的,仍然是自己的 usersessionaccessTokenrefreshToken

所以整个流程里会有两次身份交换。第一次发生在 API 和 GitHub 之间:GitHub callback 带回 code,API 拿着 codeclient_idclient_secret 去 GitHub 换 access token,再用这个 access token 读取 GitHub 用户资料。第二次发生在 Web 和 API 之间:API 确认 GitHub 身份以后,不直接把系统 token 塞进 URL,而是生成一个短时一次性 ticket,让 Web 再用这个 ticket 换成本系统登录态。

这里有几个角色要分清楚。

client_secret 只能放在 API,因为它是 GitHub OAuth App 的敏感凭证。浏览器代码会被下载和调试,一旦把 client_secret 放到 Web 里,就等于把它公开了。code 是 GitHub callback 给 API 的临时凭证,它只能用一次,而且必须配合 client_secret 才能换 access token。state 用来防止登录 CSRF,发起登录和回调回来必须是同一个 state。ticket 则是我们系统自己生成的临时登录凭证,它的职责是把 API callback 处理结果安全地交给 Web。

为什么中间要多一个 ticket?因为 URL 不适合承载长期 token。URL 会进入浏览器历史记录,也可能被日志系统记录,还可能通过 Referer 泄漏。如果 callback 后直接把 accessTokenrefreshToken 拼到 Web URL 里,风险会明显放大。短时一次性 ticket 就安全很多:它只活几分钟,用过之后立即失效,即使被复制也很难再次利用。

授权登录最终落到系统内部时,仍然要回到熟悉的登录模型:找到或创建用户,绑定 GitHub 账号,分配角色,创建 session,签发系统自己的 access token 和 refresh token。这样后续业务代码不需要关心用户是邮箱密码登录还是 GitHub 登录,只要按统一的系统 session 做鉴权就可以。

2. 不能只靠前端

GitHub OAuth 登录需要用到 client_secret 去换 access token。

client_secret 是敏感信息,不能放在浏览器里。因为前端代码会被用户下载、查看、调试,一旦把 secret 写到前端,就等于公开了。

所以合理分工是:

index.txt
1
Web 前端:负责跳转、接收 ticket、保存本系统登录态
2
API 后端:负责 GitHub code 换 token、读取用户信息、签发本系统 token
3
GitHub:负责确认用户身份并返回授权 code

3. 整体登录流程

当前项目采用的是 API callback + 一次性 ticket 的方案。

index.txt
01
1. Web 登录页
02
GET /auth/web/github/authorize
03
04
2. API 返回
05
{
06
url: "https://github.com/login/oauth/authorize?...",
07
state: "..."
08
}
09
10
3. Web 把 state 存到 sessionStorage,然后跳转 GitHub
11
12
4. GitHub 授权成功后回调 API
13
GET /auth/web/github/callback?code=xxx&state=xxx
14
15
5. API 校验 state,用 code 换 GitHub access token
16
17
6. API 请求 GitHub 用户资料和邮箱
18
GET https://api.github.com/user
19
GET https://api.github.com/user/emails
20
21
7. API 创建或绑定本系统用户,并生成短时 ticket
22
23
8. API 重定向回 Web
24
/login/github/callback?ticket=xxx&state=xxx
25
26
9. Web 校验 state,用 ticket 换本系统登录态
27
POST /auth/web/github/ticket/login
28
29
10. Web 保存本系统 session,跳转首页

这里最容易误解的是 callback URL。

GitHub OAuth App 的 callback URL 应该填 API 地址,不是 Web 地址:

callback-url.txt
1
http://127.0.0.1:8787/auth/web/github/callback

线上也是一样:

callback-url.txt
1
https://api.ai-agent.workers.dev/auth/web/github/callback

4. 环境变量

API 子站需要配置 GitHub OAuth 信息。

本地 apps/api/.dev.vars

apps/api/.dev.vars
1
GITHUB_OAUTH_CLIENT_ID=你的 GitHub OAuth Client ID
2
GITHUB_OAUTH_CLIENT_SECRET=你的 GitHub OAuth Client Secret
3
GITHUB_OAUTH_CALLBACK_URL=http://127.0.0.1:8787/auth/web/github/callback

生产环境建议把 GITHUB_OAUTH_CLIENT_SECRET 配成 Cloudflare secret,不要写进仓库文件:

index.bash
1
cd apps/api
2
pnpm wrangler secret put GITHUB_OAUTH_CLIENT_SECRET --env production

可提交的 production vars 里只放非敏感配置:

wrangler.jsonc
1
{
2
"vars": {
3
"APP_ENV": "production",
4
"WEB_ORIGIN": "https://ai-agent-web.pages.dev",
5
"ADMIN_ORIGIN": "https://ai-agent-admin.pages.dev",
6
"GITHUB_OAUTH_CLIENT_ID": "你的 GitHub OAuth Client ID",
7
"GITHUB_OAUTH_CALLBACK_URL": "https://api.ai-agent.workers.dev/auth/web/github/callback"
8
}
9
}

Web 子站只需要知道 API 地址:

apps/web/.env
1
API_BASE_URL=https://api.ai-agent.workers.dev
2
NEXT_PUBLIC_API_BASE_URL=https://api.ai-agent.workers.dev

5. 数据库设计

GitHub 登录不是简单地把邮箱密码换成 GitHub。

系统里需要多记录两类信息:一类是 GitHub 账号和本系统用户的绑定关系,另一类是 OAuth callback 后临时生成的一次性登录 ticket。

迁移文件:apps/api/migrations/0008_web_github_oauth.sql

0008_web_github_oauth.sql
01
INSERT OR IGNORE INTO application_auth_methods (id, application_id, provider, enabled, created_at_ms, updated_at_ms)
02
SELECT '019e0d00-85c9-7c13-a83c-2e3000000002', applications.id, 'github', 1, 1746816000000, 1746816000000
03
FROM applications
04
WHERE applications.code = 'web';
05
06
UPDATE application_auth_methods
07
SET enabled = 1, updated_at_ms = 1746816000000
08
WHERE provider = 'github'
09
AND application_id IN (
10
SELECT id
11
FROM applications
12
WHERE code = 'web'
13
);

这段是登录方式开关。

如果没有 web + github + enabled = 1,API 会直接返回:

error.txt
1
GitHub login is disabled

绑定表:

0008_web_github_oauth.sql
01
CREATE TABLE IF NOT EXISTS oauth_accounts (
02
id TEXT PRIMARY KEY,
03
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
04
provider TEXT NOT NULL CHECK (provider IN ('github', 'google')),
05
provider_user_id TEXT NOT NULL,
06
provider_login TEXT,
07
email_id TEXT REFERENCES user_emails(id) ON DELETE SET NULL,
08
created_at_ms INTEGER NOT NULL,
09
updated_at_ms INTEGER NOT NULL
10
);
11
12
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_provider_user_unique
13
ON oauth_accounts(provider, provider_user_id);

provider_user_id 是 GitHub 返回的用户 ID。它比 GitHub 用户名更适合做绑定,因为用户名可能改,用户 ID 不会变。

一次性 ticket 表:

0008_web_github_oauth.sql
01
CREATE TABLE IF NOT EXISTS oauth_login_tickets (
02
id TEXT PRIMARY KEY,
03
ticket_hash TEXT NOT NULL,
04
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
05
application_id TEXT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
06
provider TEXT NOT NULL CHECK (provider IN ('github', 'google')),
07
created_at_ms INTEGER NOT NULL,
08
expires_at_ms INTEGER NOT NULL,
09
used_at_ms INTEGER
10
);

这里存的是 ticket_hash,不是 ticket 原文。即使数据库泄露,也不能直接拿 ticket 去登录。

6. 接口合约

合约文件:packages/contracts/src/auth/web-github-login.contract.ts

web-github-login.contract.ts
01
import { z } from 'zod'
02
import { WebPasswordLoginResponseSchema } from './web-password-login.contract'
03
04
export const WebGithubAuthUrlResponseSchema = z.object({
05
url: z.string().url(),
06
state: z.string().min(1),
07
})
08
09
export type WebGithubAuthUrlResponse = z.infer<
10
typeof WebGithubAuthUrlResponseSchema
11
>
12
13
export const WebGithubTicketLoginRequestSchema = z.object({
14
ticket: z.string().min(1),
15
})
16
17
export type WebGithubTicketLoginRequest = z.infer<
18
typeof WebGithubTicketLoginRequestSchema
19
>
20
21
export const WebGithubTicketLoginResponseSchema = WebPasswordLoginResponseSchema

这里我们可以看到两个关键点。authorize 接口返回的是 urlstate;ticket 换登录态时,响应结构直接复用邮箱密码登录的响应。

也就是说,无论用户用邮箱密码登录,还是 GitHub 登录,前端最终拿到的都是同一套本系统登录态:

index.txt
1
accessToken
2
refreshToken
3
tokenType
4
expiresInSec
5
refreshExpiresInSec
6
session

7. API 路由

路由文件:apps/api/src/routes/auth/web.route.ts

web.route.ts
01
webAuthRoute.get('/github/authorize', async (c) => {
02
const res = await buildWebGithubAuthUrl(c)
03
04
return c.json(buildSuccess(res, createApiMeta()))
05
})
06
07
webAuthRoute.get('/github/callback', async (c) => {
08
return handleWebGithubCallback(c)
09
})
10
11
webAuthRoute.post(
12
'/github/ticket/login',
13
zValidator(
14
'json',
15
WebGithubTicketLoginRequestSchema,
16
buildValidationErrorHandler('Invalid GitHub login payload'),
17
),
18
async (c) => {
19
const payload = c.req.valid('json')
20
const res = await handleWebGithubTicketLogin({
21
c,
22
ticket: payload.ticket,
23
})
24
25
return c.json(buildSuccess(res, createApiMeta()))
26
},
27
)

我们可以把这三个接口按职责理解成这样:

index.txt
1
/github/authorize 生成 GitHub 授权 URL
2
/github/callback 接 GitHub 回调,处理 code
3
/github/ticket/login Web 用 ticket 换本系统 token

8. 生成授权 URL

核心代码在 buildWebGithubAuthUrl

github-oauth.service.ts
01
export async function buildWebGithubAuthUrl(c: Context<{ Bindings: ApiBindings }>) {
02
const db = getDb(c.env.DB)
03
04
if (!(await isGithubLoginEnabledForWeb(db))) {
05
throw authMethodDisabledError('GitHub login is disabled')
06
}
07
08
const { env, clientId, callbackUrl } = getGithubOAuthConfig(c)
09
const state = await createOAuthState(env.JWT_REFRESH_SECRET)
10
const url = new URL('https://github.com/login/oauth/authorize')
11
12
url.searchParams.set('client_id', clientId)
13
url.searchParams.set('redirect_uri', callbackUrl)
14
url.searchParams.set('scope', 'read:user user:email')
15
url.searchParams.set('state', state)
16
url.searchParams.set('allow_signup', 'true')
17
18
return WebGithubAuthUrlResponseSchema.parse({
19
url: url.toString(),
20
state,
21
})
22
}

这里先检查数据库开关。

如果你本地点击 GitHub 登录时报:

error.txt
1
GitHub login is disabled

通常就是迁移没执行:

index.bash
1
cd apps/api
2
pnpm db:migrate:local

scope 里使用:

index.txt
1
read:user user:email

这里之所以要带上 user:email,是因为 GitHub 用户不一定公开邮箱。我们需要通过 /user/emails 再取一次已验证邮箱,后面才能和系统用户建立稳定关联。

9. state 的作用

OAuth 里的 state 用来防止登录 CSRF。

简单理解:

index.txt
1
发起登录时生成一个随机 state
2
GitHub 回调时必须带回同一个 state
3
前后不一致,就拒绝登录

当前实现里,API 会生成一个带签名的 state:

github-oauth.service.ts
1
async function createOAuthState(secret: string) {
2
const nonce = uuidv7()
3
const issuedAtMs = Date.now()
4
const payload = `${nonce}.${issuedAtMs}`
5
const signature = await signStatePayload(payload, secret)
6
7
return `${payload}.${signature}`
8
}

签名使用 HMAC:

github-oauth.service.ts
01
async function signStatePayload(payload: string, secret: string) {
02
const key = await crypto.subtle.importKey(
03
'raw',
04
new TextEncoder().encode(secret),
05
{ name: 'HMAC', hash: 'SHA-256' },
06
false,
07
['sign'],
08
)
09
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
10
11
return base64UrlEncode(new Uint8Array(signature))
12
}

回调时验证:

github-oauth.service.ts
01
async function verifyOAuthState(state: string, secret: string) {
02
const parts = state.split('.')
03
04
if (parts.length !== 3) {
05
throw authUnauthorizedError('GitHub state is invalid')
06
}
07
08
const nonce = parts[0]
09
const issuedAtValue = parts[1]
10
const signature = parts[2]
11
const issuedAtMs = Number(issuedAtValue)
12
13
if (!nonce || !signature || !Number.isFinite(issuedAtMs) || Date.now() - issuedAtMs > 10 * 60 * 1000) {
14
throw authUnauthorizedError('GitHub state is expired')
15
}
16
17
const expectedSignature = await signStatePayload(`${nonce}.${issuedAtMs}`, secret)
18
19
if (signature !== expectedSignature) {
20
throw authUnauthorizedError('GitHub state is invalid')
21
}
22
}

此外,Web 端也会把 state 存进 sessionStorage,callback 回来后再比对一次。这一步可以把 这次浏览器发起的登录这次回调 绑定起来。

10. 处理 GitHub callback

GitHub 授权成功后,会请求:

callback-url.txt
1
/auth/web/github/callback?code=xxx&state=xxx

API 主要做几件事:

github-oauth.service.ts
01
export async function handleWebGithubCallback(c: Context<{ Bindings: ApiBindings }>) {
02
const code = c.req.query('code')
03
const state = c.req.query('state')
04
const error = c.req.query('error')
05
const errorDescription = c.req.query('error_description')
06
const { env, clientId, clientSecret, callbackUrl } = getGithubOAuthConfig(c)
07
const callbackResultUrl = new URL('/login/github/callback', env.WEB_ORIGIN)
08
09
if (error) {
10
callbackResultUrl.searchParams.set('error', errorDescription ?? error)
11
return c.redirect(callbackResultUrl.toString())
12
}
13
14
if (!code || !state) {
15
callbackResultUrl.searchParams.set('error', 'GitHub callback payload is invalid')
16
return c.redirect(callbackResultUrl.toString())
17
}
18
19
try {
20
await verifyOAuthState(state, env.JWT_REFRESH_SECRET)
21
22
const accessToken = await fetchGithubAccessToken({
23
code,
24
clientId,
25
clientSecret,
26
redirectUri: callbackUrl,
27
})
28
29
const [githubUser, githubEmails] = await Promise.all([
30
fetchGithubJson<GithubUser>(githubUserUrl, accessToken),
31
fetchGithubJson<GithubEmail[]>(githubUserEmailsUrl, accessToken),
32
])
33
34
const email = pickVerifiedGithubEmail(githubUser, githubEmails)
35
const userId = await resolveGithubWebUser({ c, githubUser, email })
36
const ticket = uuidv7()
37
38
await insertOauthLoginTicket({
39
db,
40
id: uuidv7(),
41
ticketHash: await hashTokenJti(ticket),
42
userId,
43
applicationId,
44
provider: 'github',
45
createdAtMs: nowMs,
46
expiresAtMs: nowMs + oauthTicketTtlMs,
47
})
48
49
callbackResultUrl.searchParams.set('ticket', ticket)
50
callbackResultUrl.searchParams.set('state', state)
51
} catch (oauthError) {
52
callbackResultUrl.searchParams.set(
53
'error',
54
oauthError instanceof Error ? oauthError.message : 'GitHub login failed',
55
)
56
}
57
58
return c.redirect(callbackResultUrl.toString())
59
}

这段代码故意没有把系统 token 放进 URL。

它只把短时 ticket 放进 URL:

callback-url.txt
1
/login/github/callback?ticket=xxx&state=xxx

ticket 有两个特点:

index.txt
1
有效期很短:2 分钟
2
只能使用一次:用过后 used_at_ms 会被写入

11. code 换 access token

GitHub callback 给的是 code,不是 access token。

API 需要把 code 发给 GitHub:

github-oauth.service.ts
01
async function fetchGithubAccessToken(params: {
02
code: string
03
clientId: string
04
clientSecret: string
05
redirectUri: string
06
}) {
07
const response = await fetch('https://github.com/login/oauth/access_token', {
08
method: 'POST',
09
headers: {
10
accept: 'application/json',
11
'content-type': 'application/x-www-form-urlencoded',
12
},
13
body: new URLSearchParams({
14
client_id: params.clientId,
15
client_secret: params.clientSecret,
16
code: params.code,
17
redirect_uri: params.redirectUri,
18
}),
19
})
20
21
const payload = await response.json() as {
22
access_token?: string
23
error?: string
24
error_description?: string
25
}
26
27
if (!response.ok || !payload.access_token) {
28
throw new AppError(
29
BizCode.AUTH_UNAUTHORIZED,
30
payload.error_description ?? 'GitHub authorization failed',
31
401,
32
)
33
}
34
35
return payload.access_token
36
}

这里使用 application/x-www-form-urlencoded,兼容性更稳。

12. 读取用户和邮箱

拿到 GitHub access token 后,请求两个接口:

github-oauth.service.ts
1
const [githubUser, githubEmails] = await Promise.all([
2
fetchGithubJson<GithubUser>('https://api.github.com/user', accessToken),
3
fetchGithubJson<GithubEmail[]>('https://api.github.com/user/emails', accessToken),
4
])

请求 GitHub API 时带上:

github-oauth.service.ts
01
async function fetchGithubJson<T>(url: string, accessToken: string): Promise<T> {
02
const response = await fetch(url, {
03
headers: {
04
accept: 'application/vnd.github+json',
05
authorization: `Bearer ${accessToken}`,
06
'user-agent': 'ai-agent-web',
07
'x-github-api-version': '2022-11-28',
08
},
09
})
10
11
if (!response.ok) {
12
throw new AppError(BizCode.AUTH_UNAUTHORIZED, 'Unable to read GitHub profile', 401)
13
}
14
15
return await response.json() as T
16
}

邮箱选择逻辑:

github-oauth.service.ts
01
function pickVerifiedGithubEmail(user: GithubUser, emails: GithubEmail[]) {
02
const primaryEmail = emails.find((item) => item.primary && item.verified)
03
const firstVerifiedEmail = emails.find((item) => item.verified)
04
const fallbackEmail = user.email ? { email: user.email, verified: true } : null
05
const selectedEmail = primaryEmail ?? firstVerifiedEmail ?? fallbackEmail
06
07
if (!selectedEmail?.email) {
08
throw new AppError(BizCode.AUTH_UNAUTHORIZED, 'GitHub account has no verified email', 401)
09
}
10
11
return selectedEmail.email
12
}

邮箱选择的顺序是:

index.txt
1
已验证的主邮箱
2
任意已验证邮箱
3
GitHub user.email

如果没有可用邮箱,就不能继续登录。系统需要用邮箱建立用户身份,这个环节不能跳过去。

13. 创建或绑定用户

GitHub 登录后,有三种情况:

index.txt
1
1. GitHub 账号已经绑定过本系统用户
2
2. GitHub 邮箱对应的本系统用户已经存在,但还没绑定 GitHub
3
3. 全新 GitHub 用户,需要创建本系统用户

核心逻辑:

github-oauth.service.ts
01
const existingGithubUser = await findWebUserByGithubAccount(db, providerUserId)
02
03
if (existingGithubUser) {
04
return existingGithubUser.userId
05
}
06
07
const existingEmailUser = await findUserByNormalizedEmail(db, normalizedEmail)
08
09
if (existingEmailUser) {
10
await linkGithubAccountToUser({
11
db,
12
oauthAccountId: uuidv7(),
13
userId: existingEmailUser.userId,
14
emailId: existingEmailUser.emailId,
15
providerUserId,
16
providerLogin,
17
nowMs,
18
})
19
20
await ensureUserHasRole({
21
db,
22
bindingId: uuidv7(),
23
userId: existingEmailUser.userId,
24
roleId: webRoleId,
25
nowMs,
26
})
27
28
return existingEmailUser.userId
29
}
30
31
await createGithubWebUser({
32
db,
33
userId,
34
emailId,
35
oauthAccountId: uuidv7(),
36
roleBindingId: uuidv7(),
37
webRoleId,
38
email,
39
normalizedEmail,
40
displayName,
41
providerUserId,
42
providerLogin,
43
nowMs,
44
})

这样设计之后,老用户可以用同邮箱 GitHub 登录,新用户也可以自动创建账号。GitHub ID 会被绑定起来,后续登录就不需要再依赖邮箱匹配。

14. ticket 换系统 token

API callback 完成后有两种常见做法:

index.txt
1
方案 A:直接把 accessToken / refreshToken 放到 URL
2
方案 B:生成一次性 ticket,让 Web 再换系统 token

当前实现选择方案 B。我们可以把原因再说清楚一点:URL 不适合放长期 token。

index.txt
1
URL 可能进入浏览器历史记录
2
URL 可能被日志系统记录
3
URL 可能通过 Referer 泄漏

所以 callback URL 里只放短时 ticket:

callback-url.txt
1
/login/github/callback?ticket=xxx&state=xxx

Web 再请求:

index.txt
1
POST /auth/web/github/ticket/login

请求体:

index.json
1
{
2
"ticket": "xxx"
3
}

API 消费 ticket:

github-oauth.service.ts
01
const ticket = await consumeOauthLoginTicket({
02
db,
03
ticketHash: await hashTokenJti(params.ticket),
04
provider: 'github',
05
nowMs: Date.now(),
06
})
07
08
if (!ticket) {
09
throw authUnauthorizedError('GitHub login ticket is invalid')
10
}

consumeOauthLoginTicket 会同时做三件事:

index.txt
1
ticket_hash 必须匹配
2
used_at_ms 必须为空
3
expires_at_ms 必须大于当前时间

匹配成功后立刻写入 used_at_ms,避免重复使用。

15. 签发系统登录态

GitHub 只负责证明 这个用户是某个 GitHub 用户

真正用于系统鉴权的,仍然是我们自己的 token。

github-oauth.service.ts
01
const session = await createWebSession({
02
db,
03
userId,
04
applicationId,
05
userAgent: getUserAgent(c),
06
ip: getIp(c),
07
nowMs,
08
expiresAtMs: refreshExpiresAtMs,
09
roles: nextRoles,
10
})
11
12
const tokenPair = await issueTokenPair({
13
session,
14
accessSecret: env.JWT_ACCESS_SECRET,
15
refreshSecret: env.JWT_REFRESH_SECRET,
16
accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,
17
refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,
18
})
19
20
await insertRefreshToken({
21
db,
22
tokenId: tokenPair.refreshJti,
23
sessionId: session.sessionId,
24
jtiHash: await hashTokenJti(tokenPair.refreshJti),
25
parentTokenId: null,
26
issuedAtMs: nowMs,
27
expiresAtMs: refreshExpiresAtMs,
28
})

这样做的好处是登录方式可以扩展,但系统内部鉴权保持一致:

index.txt
1
邮箱密码登录 -> 本系统 session
2
GitHub 登录 -> 本系统 session
3
未来 Google -> 本系统 session

16. Web 登录按钮

前端登录页里,GitHub 按钮不直接拼 URL,而是先请求 API:

github-login.ts
1
export async function redirectToGithubLogin() {
2
const response = await getWebGithubAuthUrl()
3
4
window.sessionStorage.setItem(githubOAuthStateStorageKey, response.state)
5
window.location.assign(response.url)
6
}

这样处理之后,GitHub client id、callback URL 和 scope 都由 API 统一生成,state 也可以先由 API 签名,再交给 Web 存储和校验。

登录按钮:

login-form.tsx
1
<Button
2
variant="outline"
3
type="button"
4
disabled={isSubmitting || isGithubSubmitting}
5
onClick={loginWithGithub}
6
>
7
使用 GitHub 登录
8
</Button>

17. Web callback 页面

GitHub 最终会回到 Web:

callback-url.txt
1
/login/github/callback?ticket=xxx&state=xxx

Web callback 页面要把逻辑再收回来:先读取 ticketstate,再校验 state 是否等于 sessionStorage 里保存的值,校验通过后再用 ticket 换系统 session。

核心代码:

page.tsx
01
useEffect(() => {
02
const ticket = searchParams.get("ticket")
03
const state = searchParams.get("state")
04
const callbackError = searchParams.get("error")
05
06
if (callbackError) {
07
setError(callbackError)
08
return
09
}
10
11
if (!ticket) {
12
setError("GitHub 登录结果缺少 ticket")
13
return
14
}
15
16
const loginTicket = ticket
17
18
if (exchangedTicketRef.current === loginTicket) {
19
return
20
}
21
22
const expectedState = consumeStoredGithubOAuthState()
23
24
if (!state || !expectedState || state !== expectedState) {
25
setError("GitHub 登录状态校验失败")
26
return
27
}
28
29
exchangedTicketRef.current = loginTicket
30
31
async function exchangeTicket() {
32
try {
33
await loginByGithubTicket({ ticket: loginTicket })
34
router.replace("/")
35
} catch (ticketError) {
36
if (readClientSession()) {
37
router.replace("/")
38
return
39
}
40
41
setError(ticketError instanceof Error ? ticketError.message : "GitHub 登录失败")
42
}
43
}
44
45
void exchangeTicket()
46
}, [router, searchParams])

这里有一个细节:exchangedTicketRef 是为了避免 React 开发模式下 effect 执行两次,导致同一个 ticket 被重复消费。

18. 保存登录态

ticket 换回系统 session 后,前端和邮箱密码登录一样保存:

github-login.ts
1
export async function loginByGithubTicket(input: WebGithubTicketLoginRequest) {
2
const response = await loginWithWebGithubTicket(input)
3
saveClientSession(response)
4
}

saveClientSession 会把 token 写入浏览器会话层,并通知其他模块登录态变化。

后续访问受保护页面时,仍然走已有的 dashboard guard:

index.txt
1
读取本地 session
2
请求 /rpc/user/profile
3
access token 过期则自动 refresh
4
refresh 成功后继续进入页面

19. 创建 GitHub OAuth App

接入 GitHub 授权登录之前,我们需要先在 GitHub 上创建一个 OAuth App。这里说的不是创建一个新的 GitHub 用户账号,而是在当前 GitHub 账号或组织下面注册一个应用,拿到后端换 token 时要用的 client_idclient_secret

可以直接打开 settings/apps 进入创建入口,也可以从 GitHub 右上角头像菜单进入。进入 Settings 后,在左侧找到 Developer settings,再进入 OAuth apps,点击 New OAuth App。如果这个账号以前没有创建过 OAuth App,按钮文案可能会显示为 Register a new application

创建表单里有几个字段要认真填。

Application name 是用户授权时会看到的应用名称。建议本地和线上分开命名,比如本地叫 AI Agent Web Local,线上叫 AI Agent Web Production,这样授权页和后台列表都更容易区分。

Homepage URL 填 Web 子站地址。本地调试时可以填:

homepage-url.txt
1
http://localhost:3005

线上环境则填真实的 Web 访问地址:

homepage-url.txt
1
https://ai-agent-web.pages.dev

Application description 可以不填,也可以写一句用户能看懂的说明。这个信息可能会展示给授权用户,所以不要写内部密钥、内网地址或其它敏感内容。

最关键的是 Authorization callback URL。这项一定要填 API 子站的 callback 地址,而不是 Web 登录页地址。本地调试时填:

callback-url.txt
1
http://127.0.0.1:8787/auth/web/github/callback

线上环境填:

callback-url.txt
1
https://api.ai-agent.workers.dev/auth/web/github/callback

GitHub OAuth App 只能配置一个 callback URL,所以不要试图让同一个 OAuth App 同时服务本地和生产。更稳妥的做法是创建两个 OAuth App:一个专门用于本地开发,一个专门用于线上环境。这样本地的 client_idclient_secretcallback URL 和生产环境完全隔离,排查问题也会清晰很多。

注册完成后,GitHub 会给出 Client ID。然后在应用详情页里生成一个 Client secret。这个 secret 只会在创建时完整展示,复制后要放进本地 .dev.vars 或线上 secret 管理里,不要写进仓库,也不要放到前端环境变量里。

20. 本地调试

本地调试时,我们先在 GitHub 创建 OAuth App。

本地 callback URL:

callback-url.txt
1
http://127.0.0.1:8787/auth/web/github/callback

创建完成后,把 GitHub 给的 Client IDClient secret 配到 apps/api/.dev.vars。本地建议把 WEB_ORIGIN 也写清楚,因为 API callback 处理完成后要重定向回 Web 子站:

apps/api/.dev.vars
1
GITHUB_OAUTH_CLIENT_ID=你的 client id
2
GITHUB_OAUTH_CLIENT_SECRET=你的 client secret
3
GITHUB_OAUTH_CALLBACK_URL=http://127.0.0.1:8787/auth/web/github/callback
4
WEB_ORIGIN=http://localhost:3005

这三个 GitHub 相关配置必须和刚才创建的本地 OAuth App 保持一致。尤其是 GITHUB_OAUTH_CALLBACK_URL,要和 GitHub 后台的 Authorization callback URL 完全一致,localhost127.0.0.1 在这里不能混用。

Web 子站本地只需要知道 API 地址。可以在 Web 子站的本地环境变量里配置:

apps/web/.env
1
API_BASE_URL=http://127.0.0.1:8787
2
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8787

接着执行本地 D1 迁移:

index.bash
1
cd apps/api
2
pnpm db:migrate:local

迁移完成后,启动 API 和 Web:

index.bash
1
pnpm dev:api
2
pnpm dev:web

最后打开登录页:

login-url.txt
1
http://localhost:3005/login

点击 使用 GitHub 登录

21. 线上部署

线上环境也建议单独创建一个 GitHub OAuth App,不要复用本地开发的那个。生产 OAuth App 的 Homepage URL 填 Web 线上地址:

homepage-url.txt
1
https://ai-agent-web.pages.dev

生产环境的 Authorization callback URL 填 API 线上 callback 地址:

callback-url.txt
1
https://api.ai-agent.workers.dev/auth/web/github/callback

GitHub 后台创建完成后,把线上 OAuth App 的 Client ID 放进 API production vars:

wrangler.vars
1
WEB_ORIGIN=https://ai-agent-web.pages.dev
2
ADMIN_ORIGIN=https://ai-agent-admin.pages.dev
3
GITHUB_OAUTH_CLIENT_ID=你的 client id
4
GITHUB_OAUTH_CALLBACK_URL=https://api.ai-agent.workers.dev/auth/web/github/callback

GITHUB_OAUTH_CLIENT_SECRET 是敏感信息,线上不要放进可提交配置文件,而是写入 Cloudflare secret:

index.bash
1
cd apps/api
2
pnpm wrangler secret put GITHUB_OAUTH_CLIENT_SECRET --env production

Web production env 只配置 API 访问地址:

apps/web/.env.production
1
API_BASE_URL=https://api.ai-agent.workers.dev
2
NEXT_PUBLIC_API_BASE_URL=https://api.ai-agent.workers.dev

上线前可以按这个顺序再检查一遍:GitHub OAuth App 的 callback URL 是否是 API 地址;API 里的 GITHUB_OAUTH_CALLBACK_URL 是否和 GitHub 后台完全一致;API 的 WEB_ORIGIN 是否指向 Web 线上地址;Web 的 NEXT_PUBLIC_API_BASE_URL 是否指向 API 线上地址;生产 secret 是否已经写入成功。

远程 D1 也要执行迁移:

index.bash
1
cd apps/api
2
pnpm wrangler d1 migrations apply ai-agent-production-auth --env production --remote

22. 常见问题

1. GitHub login is disabled

看到这个提示时,通常是数据库里还没有启用 web + github 登录方式。本地可以重新执行迁移:

index.bash
1
cd apps/api
2
pnpm db:migrate:local

线上环境则执行远程迁移。

2. GitHub login is not configured

这个错误一般说明 API 环境变量没有配完整,尤其要检查这两个值:

env.txt
1
GITHUB_OAUTH_CLIENT_ID
2
GITHUB_OAUTH_CLIENT_SECRET

3. callback URL 不匹配

GitHub OAuth App 里配置的 callback URL,必须和 API 实际传给 GitHub 的 redirect_uri 完全一致。

本地常见错误是一个写 localhost,另一个写 127.0.0.1

这两个对 GitHub 来说不是同一个地址。

4. GitHub account has no verified email

这说明 GitHub 没有返回可用的已验证邮箱。可以让用户到 GitHub 账号设置里确认邮箱已经完成验证。

5. GitHub 登录状态校验失败

这个问题通常是 Web callback 收到的 state 和登录发起时存入 sessionStorage 的 state 不一致。比较常见的触发方式有这些:

index.txt
1
直接打开 callback 地址
2
跨浏览器完成授权
3
sessionStorage 被清空
4
重复使用旧 callback URL

总结

GitHub 授权登录可以拆成三层:

index.txt
1
GitHub OAuth 层:
2
负责证明 GitHub 用户身份
3
4
系统用户层:
5
负责创建用户、绑定 GitHub 账号、分配 web_user 角色
6
7
系统会话层:
8
负责签发 access token、refresh token、session

当前实现最关键的设计是:

index.txt
1
client_secret 只在 API 保存
2
callback 先进 API,不直接进 Web
3
URL 里只传短时一次性 ticket
4
Web 用 ticket 换本系统 session
5
邮箱密码登录和 GitHub 登录最终复用同一套 token 机制

这样一来,登录方式可以继续扩展,但业务侧不需要关心用户到底是邮箱密码登录,还是 GitHub 登录。