如何集成 GitHub 登录

从 GitHub OAuth Web application flow 出发,说明如何用服务端完成授权码换 token、校验 state,并创建本站登录态。

很多网站不再自建完整的用户名密码系统,而是接入第三方登录。对开发者工具、技术社区、开源项目后台这类产品来说,GitHub 登录是很常见的选择。

不过“接入 GitHub 登录”容易被误解成前端拿到 GitHub access token 后存在浏览器里。这个做法能跑通 demo,但不适合作为默认实践。

更稳妥的结构是:浏览器只负责跳转和接收回调,服务端负责校验 state、用授权码换取 token、向 GitHub 拉取用户身份,然后创建自己系统的登录态。前端拿到的是本站 session,而不是 GitHub token。

English version: How to Implement Login with GitHub Safely

OAuth 流程怎么分工

GitHub OAuth App 的 Web application flow 大致是三步:

  1. 用户从你的站点跳转到 GitHub 授权页。
  2. GitHub 授权后带着临时 codestate 跳回你的回调地址。
  3. 服务端用 code 换取 access token,再用 token 请求 GitHub API 获取用户身份。

这里最关键的是两点:

  • client_secret 只能放在服务端。
  • state 必须校验,用来防止 CSRF 和错误会话串联。

如果只是做“用 GitHub 身份登录本站”,OAuth App 已经够用。如果需要更细粒度的仓库权限、安装到组织、以应用身份执行自动化任务,就应该优先评估 GitHub App。

创建 GitHub OAuth App

在 GitHub 里进入:

Settings -> Developer settings -> OAuth Apps -> New OAuth App

需要填写几个关键字段:

  • Application name:应用名称。
  • Homepage URL:站点首页地址。
  • Authorization callback URL:授权回调地址,比如 https://example.com/auth/github/callback

创建完成后会得到:

  • Client ID:可以出现在授权 URL 里。
  • Client Secret:必须只保存在服务端,通常放在环境变量或密钥管理系统里。

本地开发时可以把 callback 配成:

http://localhost:3000/auth/github/callback

线上环境要使用 HTTPS。

推荐架构

一个比较清晰的登录链路是:

浏览器点击 GitHub 登录
  -> 服务端生成 state 和 code_verifier
  -> 服务端把 state/code_verifier 写入 HttpOnly 临时 cookie 或 session
  -> 服务端重定向到 GitHub 授权页
  -> GitHub 回调 /auth/github/callback?code=...&state=...
  -> 服务端校验 state
  -> 服务端用 code 换 access token
  -> 服务端请求 GitHub /user 和 /user/emails
  -> 服务端创建或更新本地用户
  -> 服务端写入本站 session cookie
  -> 浏览器回到业务页面

这里也可以使用 PKCE。GitHub 文档已经把 code_challengecode_verifier 标为强烈推荐。即使服务端应用已经有 client_secret,PKCE 仍然能降低授权码被截获后的风险。

发起授权请求

下面用 Express 写一个示例。真实项目里可以把临时数据放进 Redis、数据库 session 或加密 cookie。

import crypto from 'node:crypto';
import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();
app.use(cookieParser());

const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
const redirectUri = 'http://localhost:3000/auth/github/callback';
const isProduction = process.env.NODE_ENV === 'production';

function base64url(buffer) {
  return buffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function createCodeChallenge(verifier) {
  return base64url(crypto.createHash('sha256').update(verifier).digest());
}

app.get('/auth/github/start', (req, res) => {
  const state = base64url(crypto.randomBytes(32));
  const codeVerifier = base64url(crypto.randomBytes(32));
  const codeChallenge = createCodeChallenge(codeVerifier);

  res.cookie('github_oauth_state', state, {
    httpOnly: true,
    secure: isProduction,
    sameSite: 'lax',
    maxAge: 10 * 60 * 1000,
  });

  res.cookie('github_oauth_code_verifier', codeVerifier, {
    httpOnly: true,
    secure: isProduction,
    sameSite: 'lax',
    maxAge: 10 * 60 * 1000,
  });

  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: 'read:user user:email',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

scope 不要贪多。只需要登录身份时,常见选择是:

  • read:user:读取基础用户资料。
  • user:email:读取用户邮箱,尤其是主资料里的 email 为空时。

不要为了登录直接申请 repo 这类高权限 scope。权限越大,用户越警惕,token 泄露后的风险也越大。

处理 GitHub 回调

GitHub 回调时会带上 codestate。服务端必须先检查 state 是否和自己之前保存的一致,不一致就终止流程。

app.get('/auth/github/callback', async (req, res) => {
  const { code, state } = req.query;

  if (!code || !state) {
    return res.status(400).send('Missing OAuth code or state');
  }

  if (state !== req.cookies.github_oauth_state) {
    return res.status(400).send('Invalid OAuth state');
  }

  const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: clientId,
      client_secret: clientSecret,
      code: String(code),
      redirect_uri: redirectUri,
      code_verifier: req.cookies.github_oauth_code_verifier,
    }),
  });

  const tokenData = await tokenResponse.json();

  if (!tokenResponse.ok || tokenData.error) {
    return res.status(401).json({
      message: 'GitHub authorization failed',
      error: tokenData.error,
    });
  }

  const accessToken = tokenData.access_token;

  const githubUser = await fetchGitHubUser(accessToken);
  const emails = await fetchGitHubEmails(accessToken);

  const primaryEmail =
    emails.find((email) => email.primary && email.verified)?.email ??
    githubUser.email;

  const user = await upsertUserFromGitHub({
    githubId: githubUser.id,
    login: githubUser.login,
    name: githubUser.name,
    avatarUrl: githubUser.avatar_url,
    email: primaryEmail,
  });

  const sessionId = await createSession(user.id);

  res.clearCookie('github_oauth_state');
  res.clearCookie('github_oauth_code_verifier');
  res.cookie('session_id', sessionId, {
    httpOnly: true,
    secure: isProduction,
    sameSite: 'lax',
  });

  res.redirect('/dashboard');
});

请求 GitHub API 时使用 Authorization: Bearer

async function fetchGitHubUser(accessToken) {
  const response = await fetch('https://api.github.com/user', {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: 'application/vnd.github+json',
    },
  });

  if (!response.ok) {
    throw new Error('Failed to fetch GitHub user');
  }

  return response.json();
}

async function fetchGitHubEmails(accessToken) {
  const response = await fetch('https://api.github.com/user/emails', {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: 'application/vnd.github+json',
    },
  });

  if (!response.ok) {
    return [];
  }

  return response.json();
}

upsertUserFromGitHub()createSession() 取决于自己的业务系统。常见做法是:

  • 用 GitHub user id 绑定本地用户,而不是只用 login。login 可能改名,id 更稳定。
  • 保存头像、昵称、邮箱等展示字段。
  • 用自己的 session 或 JWT 管理本站登录态。
  • GitHub token 如果后续不需要调用 GitHub API,就不要长期保存。

前端应该做什么

前端只需要把用户带到服务端的登录入口:

<a href="/auth/github/start">Continue with GitHub</a>

或者按钮点击后跳转:

document.querySelector('#github-login').addEventListener('click', () => {
  window.location.href = '/auth/github/start';
});

前端不需要知道 client_secret,也不应该把 GitHub access token 存进 localStorage。浏览器侧只持有本站自己的登录态 cookie。

常见坑

没有校验 state

state 是 OAuth 登录里最容易被省略、也最不该省略的字段。它应该是不可猜测的随机字符串,并且和当前登录发起方绑定。回调时如果不一致,流程必须终止。

把 token 返回给前端

GitHub access token 代表用户授权。把它返回给前端并存入 localStorage,会扩大 XSS 后的损失。除非是纯前端应用且做了专门设计,否则更推荐服务端持有 token,并给浏览器发本站 session。

用 login 当唯一身份

GitHub 用户名可以修改。数据库绑定用户时应该优先使用 GitHub user id。

scope 申请过大

登录通常不需要仓库权限。权限申请越大,授权页面越吓人,也越难通过用户信任。

忽略邮箱为空

GitHub 用户资料里的 email 可能为空。需要邮箱时,要通过 user:email scope 调 /user/emails,并优先选择已验证的主邮箱。

总结

GitHub 登录的核心不是在前端拼一个授权 URL,而是把 OAuth 的安全边界放对:

  • 前端负责跳转。
  • 服务端保存 client_secret
  • state 用来绑定登录请求和回调。
  • 授权码在服务端换 token。
  • token 用来向 GitHub 确认用户身份。
  • 本站登录态由自己的 session 系统管理。

这样接入后,GitHub 只是身份提供方,真正的账号体系仍然掌握在自己的应用里。

扩展阅读