hf-oauth-demo / server.js
其天
Refactor to HF-only OAuth demo aligned with ms-oauth-studio structure
03be224
Raw
History Blame Contribute Delete
6.53 kB
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const path = require('path');
const crypto = require('crypto');
const apiEndpoints = require('./api_endpoints');
const oauthEndpoints = require('./oauth_endpoints');
const app = express();
const port = process.env.PORT || 7860;
// --- 静态文件和通用配置 ---
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
const stateStore = new Map(); // 用于存储 state 的临时存储
// --- OAuth 配置 ---
const HUGGINGFACE_AUTH_URL = 'https://huggingface.co/oauth/authorize';
const HUGGINGFACE_TOKEN_URL = 'https://huggingface.co/oauth/token';
const CLIENT_ID = process.env.OAUTH_CLIENT_ID || process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || process.env.CLIENT_SECRET;
const OAUTH_SCOPES = process.env.OAUTH_SCOPES || 'openid profile inference-api';
// 在 Hugging Face Spaces 中使用 SPACE_HOST 构建回调地址
// 本地开发时使用 .env 中的 REDIRECT_URI
const REDIRECT_URI = process.env.SPACE_HOST
? `https://${process.env.SPACE_HOST}/callback`
: (process.env.REDIRECT_URI || `http://127.0.0.1:${port}/callback`);
/**
* 生成一个随机的 state 字符串
* @returns {string} 16 位的随机十六进制字符串
*/
const generateRandomState = () => crypto.randomBytes(8).toString('hex');
/**
* 验证回调中返回的 state 是否有效
* @param {string} state - 从回调请求中获取的 state
* @returns {boolean} 如果有效则返回 true,否则返回 false
*/
const isValidState = (state) => {
if (!state || !stateStore.has(state)) return false;
const stateData = stateStore.get(state);
if (Date.now() > stateData.expiresAt) {
stateStore.delete(state);
console.log('State 验证失败: 已过期');
return false;
}
console.log('State 验证成功');
return true;
};
// --- 路由 ---
// 1. 主页
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// 2. 引导用户到 Hugging Face 的授权页面(传统模式 - 直接重定向)
app.get('/login', (req, res) => {
if (!CLIENT_ID) {
return res.status(500).send('<h1>配置错误</h1><p>未设置 OAUTH_CLIENT_ID 或 CLIENT_ID 环境变量</p>');
}
const state = generateRandomState();
const expiresAt = Date.now() + 10 * 60 * 1000;
stateStore.set(state, { expiresAt, createdAt: Date.now() });
const authUrl = new URL(HUGGINGFACE_AUTH_URL);
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', OAUTH_SCOPES);
authUrl.searchParams.set('state', state);
console.log('设置 state:', state);
res.redirect(authUrl.href);
});
/**
* API: 启动 OAuth 流程(测试模式 - 返回 JSON,不自动重定向)
* 每次调用生成独立的 state,通过 state 关联后续 callback
*/
app.get('/api/oauth/start', (req, res) => {
if (!CLIENT_ID) {
return res.status(500).json({ error: '未设置 OAUTH_CLIENT_ID 或 CLIENT_ID 环境变量' });
}
// scope 由前端传入,允许用户自定义请求的权限范围
const scope = req.query.scope || OAUTH_SCOPES;
const state = generateRandomState();
const expiresAt = Date.now() + 10 * 60 * 1000;
stateStore.set(state, { expiresAt, createdAt: Date.now(), code: null });
const authUrl = new URL(HUGGINGFACE_AUTH_URL);
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', scope);
authUrl.searchParams.set('state', state);
res.json({
step: 1,
description: '已生成 state 并构建授权 URL',
state: state,
authorize_url: authUrl.href,
params: {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: scope,
state: state
},
next_step: '请在浏览器中打开 authorize_url,完成授权后会回调到本服务的 /callback'
});
});
/**
* API: 查询指定 state 关联的授权码
* 只能查询自己发起的 state 对应的 code(通过 state 隔离用户)
*/
app.get('/api/oauth/code/:state', (req, res) => {
const { state } = req.params;
if (!state || !stateStore.has(state)) {
return res.status(404).json({ error: 'State 不存在或已过期' });
}
const stateData = stateStore.get(state);
if (!stateData.code) {
return res.json({
state: state,
code: null,
message: '授权尚未完成,code 还未生成。请先完成授权流程。'
});
}
res.json({
state: state,
code: stateData.code,
received_at: stateData.codeReceivedAt
});
});
// 3. 接收授权码 - 保存 code 到对应的 state 记录,不自动换取 token
app.get('/callback', async (req, res) => {
const { code, state, error, error_description } = req.query;
console.log('\n--- 新的回调请求 ---');
console.log('接收授权码 code:', code);
console.log('接收 state:', state);
// 错误处理
if (error) {
const errMsg = `授权失败: ${error} - ${error_description}`;
console.error(errMsg);
return res.status(400).send(`<h1>授权失败</h1><p>${errMsg}</p><a href="/test_interface.html">返回测试页面</a>`);
}
if (!code) {
return res.status(400).send(`<h1>授权失败</h1><p>未收到授权码</p><a href="/test_interface.html">返回测试页面</a>`);
}
// 验证 state(不删除,保留供后续查询)
if (!isValidState(state)) {
return res.status(403).send(`<h1>授权失败</h1><p>State 验证失败</p><a href="/test_interface.html">返回测试页面</a>`);
}
// 将 code 保存到对应的 state 记录中(按 state 隔离)
const stateData = stateStore.get(state);
stateData.code = code;
stateData.codeReceivedAt = new Date().toISOString();
console.log(`授权码已保存到 state=${state},未消耗`);
// 重定向到测试页面,仅携带 state(不在 URL 暴露 code)
res.redirect(`/test_interface.html?tab=oauth&state=${encodeURIComponent(state)}`);
});
// 挂载 API 路由
app.use('/api', apiEndpoints);
app.use('/api/oauth', oauthEndpoints);
// 启动服务器
app.listen(port, () => {
console.log(`Hugging Face OAuth 测试服务器运行在 http://127.0.0.1:${port}`);
console.log('请在浏览器中打开上述地址以开始登录流程。');
});