这篇文章会用一个真实项目里的实现,带你把 Web 端接入 GitHub 授权登录的过程完整梳理一遍。
我们要完成的效果是:用户在登录页点击 使用 GitHub 登录 后,浏览器跳转到 GitHub 授权页;GitHub 授权成功后回调 API 子站;API 子站用 code 换 GitHub access token,再读取 GitHub 用户资料和邮箱,接着创建或绑定本系统用户,并生成一个短时一次性 ticket;Web 子站拿到 ticket 后,再换成本系统自己的登录态,最后把 accessToken、refreshToken 和 session 保存下来。
重点是:GitHub access token 不会交给浏览器长期保存,本系统 token 也不会直接塞进 URL。
在写代码之前,我们先把 GitHub 授权登录这件事本身讲清楚。
GitHub 登录不是让 GitHub 直接替我们的系统发登录态,而是让 GitHub 证明:当前浏览器里的这个用户,确实控制着某个 GitHub 账号。GitHub 能证明的是外部身份,本系统真正要保存和校验的,仍然是自己的 user、session、accessToken 和 refreshToken。
所以整个流程里会有两次身份交换。第一次发生在 API 和 GitHub 之间:GitHub callback 带回 code,API 拿着 code、client_id、client_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 后直接把 accessToken 和 refreshToken 拼到 Web URL 里,风险会明显放大。短时一次性 ticket 就安全很多:它只活几分钟,用过之后立即失效,即使被复制也很难再次利用。
授权登录最终落到系统内部时,仍然要回到熟悉的登录模型:找到或创建用户,绑定 GitHub 账号,分配角色,创建 session,签发系统自己的 access token 和 refresh token。这样后续业务代码不需要关心用户是邮箱密码登录还是 GitHub 登录,只要按统一的系统 session 做鉴权就可以。
GitHub OAuth 登录需要用到 client_secret 去换 access token。
client_secret 是敏感信息,不能放在浏览器里。因为前端代码会被用户下载、查看、调试,一旦把 secret 写到前端,就等于公开了。
所以合理分工是:
1Web 前端:负责跳转、接收 ticket、保存本系统登录态2API 后端:负责 GitHub code 换 token、读取用户信息、签发本系统 token3GitHub:负责确认用户身份并返回授权 code
当前项目采用的是 API callback + 一次性 ticket 的方案。
011. Web 登录页02GET /auth/web/github/authorize03042. API 返回05{06url: "https://github.com/login/oauth/authorize?...",07state: "..."08}09103. Web 把 state 存到 sessionStorage,然后跳转 GitHub11124. GitHub 授权成功后回调 API13GET /auth/web/github/callback?code=xxx&state=xxx14155. API 校验 state,用 code 换 GitHub access token16176. API 请求 GitHub 用户资料和邮箱18GET https://api.github.com/user19GET https://api.github.com/user/emails20217. API 创建或绑定本系统用户,并生成短时 ticket22238. API 重定向回 Web24/login/github/callback?ticket=xxx&state=xxx25269. Web 校验 state,用 ticket 换本系统登录态27POST /auth/web/github/ticket/login282910. Web 保存本系统 session,跳转首页
这里最容易误解的是 callback URL。
GitHub OAuth App 的 callback URL 应该填 API 地址,不是 Web 地址:
1http://127.0.0.1:8787/auth/web/github/callback
线上也是一样:
1https://api.ai-agent.workers.dev/auth/web/github/callback
API 子站需要配置 GitHub OAuth 信息。
本地 apps/api/.dev.vars:
1GITHUB_OAUTH_CLIENT_ID=你的 GitHub OAuth Client ID2GITHUB_OAUTH_CLIENT_SECRET=你的 GitHub OAuth Client Secret3GITHUB_OAUTH_CALLBACK_URL=http://127.0.0.1:8787/auth/web/github/callback
生产环境建议把 GITHUB_OAUTH_CLIENT_SECRET 配成 Cloudflare secret,不要写进仓库文件:
1cd apps/api2pnpm wrangler secret put GITHUB_OAUTH_CLIENT_SECRET --env production
可提交的 production vars 里只放非敏感配置:
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 地址:
1API_BASE_URL=https://api.ai-agent.workers.dev2NEXT_PUBLIC_API_BASE_URL=https://api.ai-agent.workers.dev
GitHub 登录不是简单地把邮箱密码换成 GitHub。
系统里需要多记录两类信息:一类是 GitHub 账号和本系统用户的绑定关系,另一类是 OAuth callback 后临时生成的一次性登录 ticket。
迁移文件:apps/api/migrations/0008_web_github_oauth.sql
01INSERT OR IGNORE INTO application_auth_methods (id, application_id, provider, enabled, created_at_ms, updated_at_ms)02SELECT '019e0d00-85c9-7c13-a83c-2e3000000002', applications.id, 'github', 1, 1746816000000, 174681600000003FROM applications04WHERE applications.code = 'web';0506UPDATE application_auth_methods07SET enabled = 1, updated_at_ms = 174681600000008WHERE provider = 'github'09AND application_id IN (10SELECT id11FROM applications12WHERE code = 'web'13);
这段是登录方式开关。
如果没有 web + github + enabled = 1,API 会直接返回:
1GitHub login is disabled
绑定表:
01CREATE TABLE IF NOT EXISTS oauth_accounts (02id TEXT PRIMARY KEY,03user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,04provider TEXT NOT NULL CHECK (provider IN ('github', 'google')),05provider_user_id TEXT NOT NULL,06provider_login TEXT,07email_id TEXT REFERENCES user_emails(id) ON DELETE SET NULL,08created_at_ms INTEGER NOT NULL,09updated_at_ms INTEGER NOT NULL10);1112CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_provider_user_unique13ON oauth_accounts(provider, provider_user_id);
provider_user_id 是 GitHub 返回的用户 ID。它比 GitHub 用户名更适合做绑定,因为用户名可能改,用户 ID 不会变。
一次性 ticket 表:
01CREATE TABLE IF NOT EXISTS oauth_login_tickets (02id TEXT PRIMARY KEY,03ticket_hash TEXT NOT NULL,04user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,05application_id TEXT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,06provider TEXT NOT NULL CHECK (provider IN ('github', 'google')),07created_at_ms INTEGER NOT NULL,08expires_at_ms INTEGER NOT NULL,09used_at_ms INTEGER10);
这里存的是 ticket_hash,不是 ticket 原文。即使数据库泄露,也不能直接拿 ticket 去登录。
合约文件:packages/contracts/src/auth/web-github-login.contract.ts
01import { z } from 'zod'02import { WebPasswordLoginResponseSchema } from './web-password-login.contract'0304export const WebGithubAuthUrlResponseSchema = z.object({05url: z.string().url(),06state: z.string().min(1),07})0809export type WebGithubAuthUrlResponse = z.infer<10typeof WebGithubAuthUrlResponseSchema11>1213export const WebGithubTicketLoginRequestSchema = z.object({14ticket: z.string().min(1),15})1617export type WebGithubTicketLoginRequest = z.infer<18typeof WebGithubTicketLoginRequestSchema19>2021export const WebGithubTicketLoginResponseSchema = WebPasswordLoginResponseSchema
这里我们可以看到两个关键点。authorize 接口返回的是 url 和 state;ticket 换登录态时,响应结构直接复用邮箱密码登录的响应。
也就是说,无论用户用邮箱密码登录,还是 GitHub 登录,前端最终拿到的都是同一套本系统登录态:
1accessToken2refreshToken3tokenType4expiresInSec5refreshExpiresInSec6session
路由文件:apps/api/src/routes/auth/web.route.ts
01webAuthRoute.get('/github/authorize', async (c) => {02const res = await buildWebGithubAuthUrl(c)0304return c.json(buildSuccess(res, createApiMeta()))05})0607webAuthRoute.get('/github/callback', async (c) => {08return handleWebGithubCallback(c)09})1011webAuthRoute.post(12'/github/ticket/login',13zValidator(14'json',15WebGithubTicketLoginRequestSchema,16buildValidationErrorHandler('Invalid GitHub login payload'),17),18async (c) => {19const payload = c.req.valid('json')20const res = await handleWebGithubTicketLogin({21c,22ticket: payload.ticket,23})2425return c.json(buildSuccess(res, createApiMeta()))26},27)
我们可以把这三个接口按职责理解成这样:
1/github/authorize 生成 GitHub 授权 URL2/github/callback 接 GitHub 回调,处理 code3/github/ticket/login Web 用 ticket 换本系统 token
核心代码在 buildWebGithubAuthUrl。
01export async function buildWebGithubAuthUrl(c: Context<{ Bindings: ApiBindings }>) {02const db = getDb(c.env.DB)0304if (!(await isGithubLoginEnabledForWeb(db))) {05throw authMethodDisabledError('GitHub login is disabled')06}0708const { env, clientId, callbackUrl } = getGithubOAuthConfig(c)09const state = await createOAuthState(env.JWT_REFRESH_SECRET)10const url = new URL('https://github.com/login/oauth/authorize')1112url.searchParams.set('client_id', clientId)13url.searchParams.set('redirect_uri', callbackUrl)14url.searchParams.set('scope', 'read:user user:email')15url.searchParams.set('state', state)16url.searchParams.set('allow_signup', 'true')1718return WebGithubAuthUrlResponseSchema.parse({19url: url.toString(),20state,21})22}
这里先检查数据库开关。
如果你本地点击 GitHub 登录时报:
1GitHub login is disabled
通常就是迁移没执行:
1cd apps/api2pnpm db:migrate:local
scope 里使用:
1read:user user:email
这里之所以要带上 user:email,是因为 GitHub 用户不一定公开邮箱。我们需要通过 /user/emails 再取一次已验证邮箱,后面才能和系统用户建立稳定关联。
OAuth 里的 state 用来防止登录 CSRF。
简单理解:
1发起登录时生成一个随机 state2GitHub 回调时必须带回同一个 state3前后不一致,就拒绝登录
当前实现里,API 会生成一个带签名的 state:
1async function createOAuthState(secret: string) {2const nonce = uuidv7()3const issuedAtMs = Date.now()4const payload = `${nonce}.${issuedAtMs}`5const signature = await signStatePayload(payload, secret)67return `${payload}.${signature}`8}
签名使用 HMAC:
01async function signStatePayload(payload: string, secret: string) {02const key = await crypto.subtle.importKey(03'raw',04new TextEncoder().encode(secret),05{ name: 'HMAC', hash: 'SHA-256' },06false,07['sign'],08)09const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))1011return base64UrlEncode(new Uint8Array(signature))12}
回调时验证:
01async function verifyOAuthState(state: string, secret: string) {02const parts = state.split('.')0304if (parts.length !== 3) {05throw authUnauthorizedError('GitHub state is invalid')06}0708const nonce = parts[0]09const issuedAtValue = parts[1]10const signature = parts[2]11const issuedAtMs = Number(issuedAtValue)1213if (!nonce || !signature || !Number.isFinite(issuedAtMs) || Date.now() - issuedAtMs > 10 * 60 * 1000) {14throw authUnauthorizedError('GitHub state is expired')15}1617const expectedSignature = await signStatePayload(`${nonce}.${issuedAtMs}`, secret)1819if (signature !== expectedSignature) {20throw authUnauthorizedError('GitHub state is invalid')21}22}
此外,Web 端也会把 state 存进 sessionStorage,callback 回来后再比对一次。这一步可以把 这次浏览器发起的登录 与 这次回调 绑定起来。
GitHub 授权成功后,会请求:
1/auth/web/github/callback?code=xxx&state=xxx
API 主要做几件事:
01export async function handleWebGithubCallback(c: Context<{ Bindings: ApiBindings }>) {02const code = c.req.query('code')03const state = c.req.query('state')04const error = c.req.query('error')05const errorDescription = c.req.query('error_description')06const { env, clientId, clientSecret, callbackUrl } = getGithubOAuthConfig(c)07const callbackResultUrl = new URL('/login/github/callback', env.WEB_ORIGIN)0809if (error) {10callbackResultUrl.searchParams.set('error', errorDescription ?? error)11return c.redirect(callbackResultUrl.toString())12}1314if (!code || !state) {15callbackResultUrl.searchParams.set('error', 'GitHub callback payload is invalid')16return c.redirect(callbackResultUrl.toString())17}1819try {20await verifyOAuthState(state, env.JWT_REFRESH_SECRET)2122const accessToken = await fetchGithubAccessToken({23code,24clientId,25clientSecret,26redirectUri: callbackUrl,27})2829const [githubUser, githubEmails] = await Promise.all([30fetchGithubJson<GithubUser>(githubUserUrl, accessToken),31fetchGithubJson<GithubEmail[]>(githubUserEmailsUrl, accessToken),32])3334const email = pickVerifiedGithubEmail(githubUser, githubEmails)35const userId = await resolveGithubWebUser({ c, githubUser, email })36const ticket = uuidv7()3738await insertOauthLoginTicket({39db,40id: uuidv7(),41ticketHash: await hashTokenJti(ticket),42userId,43applicationId,44provider: 'github',45createdAtMs: nowMs,46expiresAtMs: nowMs + oauthTicketTtlMs,47})4849callbackResultUrl.searchParams.set('ticket', ticket)50callbackResultUrl.searchParams.set('state', state)51} catch (oauthError) {52callbackResultUrl.searchParams.set(53'error',54oauthError instanceof Error ? oauthError.message : 'GitHub login failed',55)56}5758return c.redirect(callbackResultUrl.toString())59}
这段代码故意没有把系统 token 放进 URL。
它只把短时 ticket 放进 URL:
1/login/github/callback?ticket=xxx&state=xxx
ticket 有两个特点:
1有效期很短:2 分钟2只能使用一次:用过后 used_at_ms 会被写入
GitHub callback 给的是 code,不是 access token。
API 需要把 code 发给 GitHub:
01async function fetchGithubAccessToken(params: {02code: string03clientId: string04clientSecret: string05redirectUri: string06}) {07const response = await fetch('https://github.com/login/oauth/access_token', {08method: 'POST',09headers: {10accept: 'application/json',11'content-type': 'application/x-www-form-urlencoded',12},13body: new URLSearchParams({14client_id: params.clientId,15client_secret: params.clientSecret,16code: params.code,17redirect_uri: params.redirectUri,18}),19})2021const payload = await response.json() as {22access_token?: string23error?: string24error_description?: string25}2627if (!response.ok || !payload.access_token) {28throw new AppError(29BizCode.AUTH_UNAUTHORIZED,30payload.error_description ?? 'GitHub authorization failed',31401,32)33}3435return payload.access_token36}
这里使用 application/x-www-form-urlencoded,兼容性更稳。
拿到 GitHub access token 后,请求两个接口:
1const [githubUser, githubEmails] = await Promise.all([2fetchGithubJson<GithubUser>('https://api.github.com/user', accessToken),3fetchGithubJson<GithubEmail[]>('https://api.github.com/user/emails', accessToken),4])
请求 GitHub API 时带上:
01async function fetchGithubJson<T>(url: string, accessToken: string): Promise<T> {02const response = await fetch(url, {03headers: {04accept: 'application/vnd.github+json',05authorization: `Bearer ${accessToken}`,06'user-agent': 'ai-agent-web',07'x-github-api-version': '2022-11-28',08},09})1011if (!response.ok) {12throw new AppError(BizCode.AUTH_UNAUTHORIZED, 'Unable to read GitHub profile', 401)13}1415return await response.json() as T16}
邮箱选择逻辑:
01function pickVerifiedGithubEmail(user: GithubUser, emails: GithubEmail[]) {02const primaryEmail = emails.find((item) => item.primary && item.verified)03const firstVerifiedEmail = emails.find((item) => item.verified)04const fallbackEmail = user.email ? { email: user.email, verified: true } : null05const selectedEmail = primaryEmail ?? firstVerifiedEmail ?? fallbackEmail0607if (!selectedEmail?.email) {08throw new AppError(BizCode.AUTH_UNAUTHORIZED, 'GitHub account has no verified email', 401)09}1011return selectedEmail.email12}
邮箱选择的顺序是:
1已验证的主邮箱2任意已验证邮箱3GitHub user.email
如果没有可用邮箱,就不能继续登录。系统需要用邮箱建立用户身份,这个环节不能跳过去。
GitHub 登录后,有三种情况:
11. GitHub 账号已经绑定过本系统用户22. GitHub 邮箱对应的本系统用户已经存在,但还没绑定 GitHub33. 全新 GitHub 用户,需要创建本系统用户
核心逻辑:
01const existingGithubUser = await findWebUserByGithubAccount(db, providerUserId)0203if (existingGithubUser) {04return existingGithubUser.userId05}0607const existingEmailUser = await findUserByNormalizedEmail(db, normalizedEmail)0809if (existingEmailUser) {10await linkGithubAccountToUser({11db,12oauthAccountId: uuidv7(),13userId: existingEmailUser.userId,14emailId: existingEmailUser.emailId,15providerUserId,16providerLogin,17nowMs,18})1920await ensureUserHasRole({21db,22bindingId: uuidv7(),23userId: existingEmailUser.userId,24roleId: webRoleId,25nowMs,26})2728return existingEmailUser.userId29}3031await createGithubWebUser({32db,33userId,34emailId,35oauthAccountId: uuidv7(),36roleBindingId: uuidv7(),37webRoleId,38email,39normalizedEmail,40displayName,41providerUserId,42providerLogin,43nowMs,44})
这样设计之后,老用户可以用同邮箱 GitHub 登录,新用户也可以自动创建账号。GitHub ID 会被绑定起来,后续登录就不需要再依赖邮箱匹配。
API callback 完成后有两种常见做法:
1方案 A:直接把 accessToken / refreshToken 放到 URL2方案 B:生成一次性 ticket,让 Web 再换系统 token
当前实现选择方案 B。我们可以把原因再说清楚一点:URL 不适合放长期 token。
1URL 可能进入浏览器历史记录2URL 可能被日志系统记录3URL 可能通过 Referer 泄漏
所以 callback URL 里只放短时 ticket:
1/login/github/callback?ticket=xxx&state=xxx
Web 再请求:
1POST /auth/web/github/ticket/login
请求体:
1{2"ticket": "xxx"3}
API 消费 ticket:
01const ticket = await consumeOauthLoginTicket({02db,03ticketHash: await hashTokenJti(params.ticket),04provider: 'github',05nowMs: Date.now(),06})0708if (!ticket) {09throw authUnauthorizedError('GitHub login ticket is invalid')10}
consumeOauthLoginTicket 会同时做三件事:
1ticket_hash 必须匹配2used_at_ms 必须为空3expires_at_ms 必须大于当前时间
匹配成功后立刻写入 used_at_ms,避免重复使用。
GitHub 只负责证明 这个用户是某个 GitHub 用户。
真正用于系统鉴权的,仍然是我们自己的 token。
01const session = await createWebSession({02db,03userId,04applicationId,05userAgent: getUserAgent(c),06ip: getIp(c),07nowMs,08expiresAtMs: refreshExpiresAtMs,09roles: nextRoles,10})1112const tokenPair = await issueTokenPair({13session,14accessSecret: env.JWT_ACCESS_SECRET,15refreshSecret: env.JWT_REFRESH_SECRET,16accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,17refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,18})1920await insertRefreshToken({21db,22tokenId: tokenPair.refreshJti,23sessionId: session.sessionId,24jtiHash: await hashTokenJti(tokenPair.refreshJti),25parentTokenId: null,26issuedAtMs: nowMs,27expiresAtMs: refreshExpiresAtMs,28})
这样做的好处是登录方式可以扩展,但系统内部鉴权保持一致:
1邮箱密码登录 -> 本系统 session2GitHub 登录 -> 本系统 session3未来 Google -> 本系统 session
前端登录页里,GitHub 按钮不直接拼 URL,而是先请求 API:
1export async function redirectToGithubLogin() {2const response = await getWebGithubAuthUrl()34window.sessionStorage.setItem(githubOAuthStateStorageKey, response.state)5window.location.assign(response.url)6}
这样处理之后,GitHub client id、callback URL 和 scope 都由 API 统一生成,state 也可以先由 API 签名,再交给 Web 存储和校验。
登录按钮:
1<Button2variant="outline"3type="button"4disabled={isSubmitting || isGithubSubmitting}5onClick={loginWithGithub}6>7使用 GitHub 登录8</Button>
GitHub 最终会回到 Web:
1/login/github/callback?ticket=xxx&state=xxx
Web callback 页面要把逻辑再收回来:先读取 ticket 和 state,再校验 state 是否等于 sessionStorage 里保存的值,校验通过后再用 ticket 换系统 session。
核心代码:
01useEffect(() => {02const ticket = searchParams.get("ticket")03const state = searchParams.get("state")04const callbackError = searchParams.get("error")0506if (callbackError) {07setError(callbackError)08return09}1011if (!ticket) {12setError("GitHub 登录结果缺少 ticket")13return14}1516const loginTicket = ticket1718if (exchangedTicketRef.current === loginTicket) {19return20}2122const expectedState = consumeStoredGithubOAuthState()2324if (!state || !expectedState || state !== expectedState) {25setError("GitHub 登录状态校验失败")26return27}2829exchangedTicketRef.current = loginTicket3031async function exchangeTicket() {32try {33await loginByGithubTicket({ ticket: loginTicket })34router.replace("/")35} catch (ticketError) {36if (readClientSession()) {37router.replace("/")38return39}4041setError(ticketError instanceof Error ? ticketError.message : "GitHub 登录失败")42}43}4445void exchangeTicket()46}, [router, searchParams])
这里有一个细节:exchangedTicketRef 是为了避免 React 开发模式下 effect 执行两次,导致同一个 ticket 被重复消费。
ticket 换回系统 session 后,前端和邮箱密码登录一样保存:
1export async function loginByGithubTicket(input: WebGithubTicketLoginRequest) {2const response = await loginWithWebGithubTicket(input)3saveClientSession(response)4}
saveClientSession 会把 token 写入浏览器会话层,并通知其他模块登录态变化。
后续访问受保护页面时,仍然走已有的 dashboard guard:
1读取本地 session2请求 /rpc/user/profile3access token 过期则自动 refresh4refresh 成功后继续进入页面
接入 GitHub 授权登录之前,我们需要先在 GitHub 上创建一个 OAuth App。这里说的不是创建一个新的 GitHub 用户账号,而是在当前 GitHub 账号或组织下面注册一个应用,拿到后端换 token 时要用的 client_id 和 client_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 子站地址。本地调试时可以填:
1http://localhost:3005
线上环境则填真实的 Web 访问地址:
1https://ai-agent-web.pages.dev
Application description 可以不填,也可以写一句用户能看懂的说明。这个信息可能会展示给授权用户,所以不要写内部密钥、内网地址或其它敏感内容。
最关键的是 Authorization callback URL。这项一定要填 API 子站的 callback 地址,而不是 Web 登录页地址。本地调试时填:
1http://127.0.0.1:8787/auth/web/github/callback
线上环境填:
1https://api.ai-agent.workers.dev/auth/web/github/callback
GitHub OAuth App 只能配置一个 callback URL,所以不要试图让同一个 OAuth App 同时服务本地和生产。更稳妥的做法是创建两个 OAuth App:一个专门用于本地开发,一个专门用于线上环境。这样本地的 client_id、client_secret、callback URL 和生产环境完全隔离,排查问题也会清晰很多。
注册完成后,GitHub 会给出 Client ID。然后在应用详情页里生成一个 Client secret。这个 secret 只会在创建时完整展示,复制后要放进本地 .dev.vars 或线上 secret 管理里,不要写进仓库,也不要放到前端环境变量里。
本地调试时,我们先在 GitHub 创建 OAuth App。
本地 callback URL:
1http://127.0.0.1:8787/auth/web/github/callback
创建完成后,把 GitHub 给的 Client ID 和 Client secret 配到 apps/api/.dev.vars。本地建议把 WEB_ORIGIN 也写清楚,因为 API callback 处理完成后要重定向回 Web 子站:
1GITHUB_OAUTH_CLIENT_ID=你的 client id2GITHUB_OAUTH_CLIENT_SECRET=你的 client secret3GITHUB_OAUTH_CALLBACK_URL=http://127.0.0.1:8787/auth/web/github/callback4WEB_ORIGIN=http://localhost:3005
这三个 GitHub 相关配置必须和刚才创建的本地 OAuth App 保持一致。尤其是 GITHUB_OAUTH_CALLBACK_URL,要和 GitHub 后台的 Authorization callback URL 完全一致,localhost 和 127.0.0.1 在这里不能混用。
Web 子站本地只需要知道 API 地址。可以在 Web 子站的本地环境变量里配置:
1API_BASE_URL=http://127.0.0.1:87872NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8787
接着执行本地 D1 迁移:
1cd apps/api2pnpm db:migrate:local
迁移完成后,启动 API 和 Web:
1pnpm dev:api2pnpm dev:web
最后打开登录页:
1http://localhost:3005/login
点击 使用 GitHub 登录。
线上环境也建议单独创建一个 GitHub OAuth App,不要复用本地开发的那个。生产 OAuth App 的 Homepage URL 填 Web 线上地址:
1https://ai-agent-web.pages.dev
生产环境的 Authorization callback URL 填 API 线上 callback 地址:
1https://api.ai-agent.workers.dev/auth/web/github/callback
GitHub 后台创建完成后,把线上 OAuth App 的 Client ID 放进 API production vars:
1WEB_ORIGIN=https://ai-agent-web.pages.dev2ADMIN_ORIGIN=https://ai-agent-admin.pages.dev3GITHUB_OAUTH_CLIENT_ID=你的 client id4GITHUB_OAUTH_CALLBACK_URL=https://api.ai-agent.workers.dev/auth/web/github/callback
GITHUB_OAUTH_CLIENT_SECRET 是敏感信息,线上不要放进可提交配置文件,而是写入 Cloudflare secret:
1cd apps/api2pnpm wrangler secret put GITHUB_OAUTH_CLIENT_SECRET --env production
Web production env 只配置 API 访问地址:
1API_BASE_URL=https://api.ai-agent.workers.dev2NEXT_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 也要执行迁移:
1cd apps/api2pnpm wrangler d1 migrations apply ai-agent-production-auth --env production --remote
看到这个提示时,通常是数据库里还没有启用 web + github 登录方式。本地可以重新执行迁移:
1cd apps/api2pnpm db:migrate:local
线上环境则执行远程迁移。
这个错误一般说明 API 环境变量没有配完整,尤其要检查这两个值:
1GITHUB_OAUTH_CLIENT_ID2GITHUB_OAUTH_CLIENT_SECRET
GitHub OAuth App 里配置的 callback URL,必须和 API 实际传给 GitHub 的 redirect_uri 完全一致。
本地常见错误是一个写 localhost,另一个写 127.0.0.1。
这两个对 GitHub 来说不是同一个地址。
这说明 GitHub 没有返回可用的已验证邮箱。可以让用户到 GitHub 账号设置里确认邮箱已经完成验证。
这个问题通常是 Web callback 收到的 state 和登录发起时存入 sessionStorage 的 state 不一致。比较常见的触发方式有这些:
1直接打开 callback 地址2跨浏览器完成授权3sessionStorage 被清空4重复使用旧 callback URL
GitHub 授权登录可以拆成三层:
1GitHub OAuth 层:2负责证明 GitHub 用户身份34系统用户层:5负责创建用户、绑定 GitHub 账号、分配 web_user 角色67系统会话层:8负责签发 access token、refresh token、session
当前实现最关键的设计是:
1client_secret 只在 API 保存2callback 先进 API,不直接进 Web3URL 里只传短时一次性 ticket4Web 用 ticket 换本系统 session5邮箱密码登录和 GitHub 登录最终复用同一套 token 机制
这样一来,登录方式可以继续扩展,但业务侧不需要关心用户到底是邮箱密码登录,还是 GitHub 登录。