name: github-oauth-nango-integration description: 使用Nango实现GitHub OAuth + GitHub App身份验证 - 提供用户登录和仓库访问的双连接模式,包含Webhook处理
GitHub OAuth + Nango 集成
概述
实现双连接OAuth模式:一个用于用户身份(github集成),另一个用于仓库访问(github-app-oauth集成)。这种分离使得在通过GitHub App安装维护细粒度仓库权限的同时,实现安全登录。
何时使用
- 通过Nango设置GitHub OAuth登录
- 实现GitHub App安装Webhook
- 协调OAuth用户与GitHub App安装
- 构建需要用户认证和仓库访问的应用
- 处理GitHub数据的Nango同步Webhook
为何需要两个连接?
GitHub有两种不同的认证机制,服务于不同目的:
GitHub OAuth应用(github集成)
- 是什么:用于用户身份的传统OAuth
- 提供什么:用户资料(姓名、邮箱、头像、GitHub ID)
- 不提供什么:仓库访问权限
- 用途:登录,“使用GitHub登录”
GitHub应用(github-app-oauth集成)
- 是什么:具有细粒度仓库权限的可安装应用
- 提供什么:访问用户安装该应用的特定仓库
- 不提供什么:用户身份(它知道安装,但不知道谁在使用)
- 用途:读取PR、提交、文件;发布评论;Webhook
协调问题
仅OAuth应用: “用户john@example.com已登录” → 但他们可以访问哪些仓库?
仅GitHub应用: “安装#12345可以访问仓库X” → 但用户是谁?
解决方案:通过用户ID链接的两个独立OAuth流程:
- 登录流程 → 用户认证 → 存储用户身份 +
nangoConnectionId - 仓库流程 → 同一用户授权应用 → 存储仓库 + 通过
ownerId链接
这让你可以回答:“用户john@example.com可以访问仓库X, Y, Z”
快速参考
| 连接类型 | Nango集成 | 目的 | 存储位置 |
|---|---|---|---|
| 用户登录 | github |
认证、身份 | users.nangoConnectionId |
| 仓库访问 | github-app-oauth |
PR操作、文件访问 | repos.nangoConnectionId |
| 流程 | 端点 | Webhook类型 |
|---|---|---|
| 登录 | GET /auth/nango-session |
auth + github |
| 仓库连接 | GET /auth/github-app-session |
auth + github-app-oauth |
| 数据同步 | N/A(计划任务) | sync |
实现
1. 数据库模式
// users表 - 存储登录连接
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
githubId: text('github_id').unique().notNull(),
githubUsername: text('github_username').notNull(),
email: text('email'),
avatarUrl: text('avatar_url'),
nangoConnectionId: text('nango_connection_id'), // 永久登录连接
incomingConnectionId: text('incoming_connection_id'), // 临时轮询连接
pendingInstallationRequest: timestamp('pending_installation_request'), // 组织批准等待
});
// repos表 - 存储每个仓库的应用连接
export const repos = pgTable('repos', {
id: uuid('id').primaryKey().defaultRandom(),
githubRepoId: text('github_repo_id').unique().notNull(),
fullName: text('full_name').notNull(),
installationId: uuid('installation_id').references(() => githubInstallations.id),
ownerId: uuid('owner_id').references(() => users.id),
nangoConnectionId: text('nango_connection_id'), // 此仓库的应用连接
});
// github_installations - 跟踪应用安装
export const githubInstallations = pgTable('github_installations', {
id: uuid('id').primaryKey().defaultRandom(),
installationId: text('installation_id').unique().notNull(),
accountType: text('account_type'), // 'user' | 'organization'
accountLogin: text('account_login'),
installedById: uuid('installed_by_id').references(() => users.id),
});
2. 常量
// constants.ts
export const NANGO_INTEGRATION = {
GITHUB_USER: 'github', // 仅登录
GITHUB_APP_OAUTH: 'github-app-oauth' // 仓库访问
} as const;
3. 登录流程路由
// GET /auth/nango-session - 创建登录OAuth会话
app.get('/auth/nango-session', async (c) => {
const tempUserId = randomUUID();
const { sessionToken } = await nangoClient.createConnectSession({
end_user: { id: tempUserId },
allowed_integrations: [NANGO_INTEGRATION.GITHUB_USER],
});
return c.json({ sessionToken, tempUserId });
});
// GET /auth/nango/status/:connectionId - 轮询登录完成状态
app.get('/auth/nango/status/:connectionId', async (c) => {
const { connectionId } = c.req.param();
// 检查是否存在使用此传入连接的用户
const user = await userRepo.findByIncomingConnectionId(connectionId);
if (!user) {
return c.json({ ready: false });
}
// 签发JWT并返回
const token = authService.issueToken(user);
await userRepo.clearIncomingConnectionId(user.id);
return c.json({ ready: true, token, user });
});
4. 应用OAuth流程路由
// GET /auth/github-app-session - 创建应用OAuth会话(已认证)
app.get('/auth/github-app-session', authMiddleware, async (c) => {
const user = c.get('user');
const { sessionToken } = await nangoClient.createConnectSession({
end_user: { id: user.id, email: user.email },
allowed_integrations: [NANGO_INTEGRATION.GITHUB_APP_OAUTH],
});
return c.json({ sessionToken });
});
// GET /auth/github-app/status/:connectionId - 轮询仓库同步
app.get('/auth/github-app/status/:connectionId', authMiddleware, async (c) => {
const user = c.get('user');
// 检查待处理的组织批准
if (user.pendingInstallationRequest) {
return c.json({ ready: false, pendingApproval: true });
}
// 检查仓库是否已同步
const repos = await repoRepo.findByOwnerId(user.id);
return c.json({ ready: repos.length > 0, repos });
});
5. 认证Webhook处理器
// auth-webhook-service.ts
export async function handleAuthWebhook(payload: NangoAuthWebhook): Promise<boolean> {
const { connectionId, providerConfigKey, endUser } = payload;
if (providerConfigKey === NANGO_INTEGRATION.GITHUB_USER) {
return handleLoginWebhook(connectionId, endUser);
}
if (providerConfigKey === NANGO_INTEGRATION.GITHUB_APP_OAUTH) {
return handleAppOAuthWebhook(connectionId, endUser);
}
return false;
}
async function handleLoginWebhook(connectionId: string, endUser?: EndUser) {
// 通过Nango获取GitHub用户信息
const githubUser = await nangoService.getGitHubUser(connectionId);
// 检查用户是否存在
const existingUser = await userRepo.findByGitHubId(String(githubUser.id));
if (existingUser) {
// 返回用户 - 存储临时连接用于轮询
await userRepo.update(existingUser.id, {
incomingConnectionId: connectionId,
});
// 稍后删除重复连接
await nangoService.deleteConnection(connectionId);
} else {
// 新用户 - 创建记录
const user = await userRepo.create({
githubId: String(githubUser.id),
githubUsername: githubUser.login,
email: githubUser.email,
avatarUrl: githubUser.avatar_url,
nangoConnectionId: connectionId,
incomingConnectionId: connectionId,
});
// 使用真实用户ID更新连接
await nangoService.patchConnection(connectionId, {
end_user: { id: user.id, email: user.email },
});
}
return true;
}
async function handleAppOAuthWebhook(connectionId: string, endUser?: EndUser) {
const userId = endUser?.id;
if (!userId) throw new Error('应用OAuth Webhook中没有用户ID');
const user = await userRepo.findById(userId);
if (!user) throw new Error('用户未找到');
try {
// 获取用户有权访问的仓库
const repos = await githubService.getInstallationReposRaw(connectionId);
// 将仓库同步到数据库
for (const repo of repos) {
await repoRepo.upsert({
githubRepoId: String(repo.id),
fullName: repo.full_name,
ownerId: user.id,
nangoConnectionId: connectionId,
});
}
// 触发Nango同步
await nangoService.triggerSync(connectionId, ['pull-requests', 'commits']);
} catch (error) {
if (error.status === 403) {
// 组织批准待处理
await userRepo.update(user.id, {
pendingInstallationRequest: new Date(),
});
return true; // 优雅降级
}
throw error;
}
return true;
}
6. 带签名验证的Webhook路由
// webhooks.ts
app.post('/api/webhooks/nango', async (c) => {
const signature = c.req.header('X-Nango-Signature');
const body = await c.req.text();
// 验证签名
const expectedSignature = createHmac('sha256', NANGO_SECRET_KEY)
.update(body)
.digest('hex');
if (signature !== expectedSignature) {
return c.json({ error: '无效签名' }, 401);
}
const payload = JSON.parse(body);
if (payload.type === 'auth') {
const success = await handleAuthWebhook(payload);
return c.json({ success });
}
if (payload.type === 'sync') {
await processSyncWebhook(payload);
return c.json({ success: true });
}
return c.json({ success: false });
});
7. 前端集成
// 登录流程
async function handleLogin() {
const res = await fetch('/api/auth/nango-session');
const { sessionToken } = await res.json();
const nango = new Nango({ connectSessionToken: sessionToken });
nango.openConnectUI({
onEvent: async (event) => {
if (event.type === 'connect') {
// 轮询完成状态
const result = await pollForAuth(event.payload.connectionId);
if (result.ready) {
localStorage.setItem('token', result.token);
navigate('/dashboard');
}
}
},
});
}
// 仓库连接流程(登录后)
async function handleConnectRepos() {
const res = await fetch('/api/auth/github-app-session', {
headers: { Authorization: `Bearer ${token}` },
});
const { sessionToken } = await res.json();
const nango = new Nango({ connectSessionToken: sessionToken });
nango.openConnectUI({
onEvent: async (event) => {
if (event.type === 'connect') {
const result = await pollForRepos(event.payload.connectionId);
if (result.pendingApproval) {
showMessage('等待组织管理员批准...');
} else if (result.ready) {
setRepos(result.repos);
}
}
},
});
}
完整流程示意图
用户登录:
前端 → GET /auth/nango-session
→ Nango.openConnectUI(sessionToken)
→ 用户授权GitHub
→ Nango Webhook (类型: auth, providerConfigKey: github)
→ 后端创建/更新用户
→ 前端轮询 /auth/nango/status/:connectionId
→ 返回JWT令牌
仓库连接(已认证):
前端 → GET /auth/github-app-session (带JWT)
→ Nango.openConnectUI(sessionToken)
→ 用户授权GitHub应用
→ Nango Webhook (类型: auth, providerConfigKey: github-app-oauth)
→ 后端获取仓库,同步到数据库
→ 前端轮询 /auth/github-app/status/:connectionId
→ 返回仓库列表
数据同步(后台):
Nango → 计划任务每4小时同步
→ Webhook (类型: sync, 模型: GithubPullRequest)
→ 后端处理增量更新
常见错误
| 错误 | 修复方法 |
|---|---|
| 对登录和仓库访问使用相同连接 | 使用两个集成:github用于登录,github-app-oauth用于仓库 |
| 未处理组织批准待处理 | 检查403错误,设置pendingInstallationRequest标志 |
连接中缺少endUser.id |
始终在createConnectSession中设置,用户创建后更新 |
| 轮询错误的连接ID | 为返回用户单独存储incomingConnectionId |
| 未验证Webhook签名 | 始终使用HMAC-SHA256验证X-Nango-Signature |
| 保留重复连接 | 返回用户认证后删除临时连接 |
环境变量
# 必需
NANGO_SECRET_KEY=你的nango密钥
JWT_SECRET=你的jwt密钥-至少32字符
DATABASE_URL=postgres://...
# 在Nango仪表板中配置
# - github集成:OAuth应用凭据
# - github-app-oauth集成:GitHub应用凭据
Nango仪表板设置
-
创建
github集成(用于登录):- 类型:OAuth2
- 客户端ID/密钥:来自GitHub OAuth应用
- 范围:
read:user,user:email
-
创建
github-app-oauth集成(用于仓库):- 类型:GitHub应用
- 应用ID、私钥、客户端ID/密钥:来自GitHub应用
- 范围:
repo,pull_request等
-
配置Webhook URL:
https://你的域名/api/webhooks/nango -
启用同步:
pull-requests,commits,issues等