feat(giffgaff): 支持 PKCE 公有客户端和机密客户端的 OAuth 令牌交换

- 新增 giffgaff-token-exchange.js 函数处理 OAuth 令牌交换
- 增加对 PKCE 公有客户端和机密客户端的支持
- 优化令牌交换流程,支持可选的 Basic 认证
- 更新前端和后端的 OAuth 配置
This commit is contained in:
Abner
2025-08-10 18:08:48 +08:00
parent f7d7c5375d
commit ee034d3cb9
4 changed files with 40 additions and 14 deletions

View File

@@ -7,9 +7,8 @@
const axios = require('axios');
exports.handler = async (event) => {
const allowedOrigin = 'https://esim.cosr.eu.org';
const headers = {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Content-Type': 'application/json'
@@ -19,11 +18,6 @@ exports.handler = async (event) => {
return { statusCode: 200, headers, body: '' };
}
const reqOrigin = (event.headers && (event.headers.origin || event.headers.Origin)) || '';
if (reqOrigin && reqOrigin !== allowedOrigin) {
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
}
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
@@ -88,6 +82,4 @@ exports.handler = async (event) => {
body: JSON.stringify({ error: 'Token Exchange Failed', details: data })
};
}
};
};

View File

@@ -39,6 +39,7 @@ app.use(express.static('.'));
const giffgaffMfaChallenge = require('./netlify/functions/giffgaff-mfa-challenge');
const giffgaffMfaValidation = require('./netlify/functions/giffgaff-mfa-validation');
const giffgaffGraphql = require('./netlify/functions/giffgaff-graphql');
const giffgaffTokenExchange = require('./netlify/functions/giffgaff-token-exchange');
const verifyCookie = require('./netlify/functions/verify-cookie');
// 包装Netlify Functions为Express路由
@@ -83,6 +84,7 @@ function wrapNetlifyFunction(handler) {
app.use('/.netlify/functions/giffgaff-mfa-challenge', wrapNetlifyFunction(giffgaffMfaChallenge));
app.use('/.netlify/functions/giffgaff-mfa-validation', wrapNetlifyFunction(giffgaffMfaValidation));
app.use('/.netlify/functions/giffgaff-graphql', wrapNetlifyFunction(giffgaffGraphql));
app.use('/.netlify/functions/giffgaff-token-exchange', wrapNetlifyFunction(giffgaffTokenExchange));
app.use('/.netlify/functions/verify-cookie', wrapNetlifyFunction(verifyCookie));
// Simyo API代理路由

View File

@@ -1243,10 +1243,12 @@
const oauthConfig = {
clientId: "4a05bf219b3985647d9b9a3ba610a9ce",
redirectUri: "giffgaff://auth/callback/",
authUrl: "https://id.giffgaff.com/auth/oauth/authorize",
authUrl: "https://id.giffgaff.com/oauth/authorize",
// 前端不再保存/使用 clientSecret令牌交换改由后端函数完成
tokenUrl: "/.netlify/functions/giffgaff-token-exchange",
scope: "read"
scope: "read",
// 可选:显式要求后端使用 Basic仅在你使用机密客户端时打开
// useBasic: true
};
// Giffgaff API端点
@@ -1885,6 +1887,13 @@
appState.codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(appState.codeVerifier);
const state = generateState();
// 临时持久化(使用 state->verifier 映射,避免多次点击导致覆盖)
try {
const pkceMap = JSON.parse(sessionStorage.getItem('gg_pkce_map') || '{}');
pkceMap[state] = appState.codeVerifier;
sessionStorage.setItem('gg_pkce_map', JSON.stringify(pkceMap));
sessionStorage.setItem('gg_oauth_last_state', state);
} catch (e) {}
// 构建授权URL
const authParams = new URLSearchParams({
@@ -1953,6 +1962,22 @@
console.log('解析到的授权码:', code);
console.log('解析到的状态:', state);
// 如果内存中的 codeVerifier 丢失,优先通过 state 在映射中恢复
if (!appState.codeVerifier) {
try {
const pkceMap = JSON.parse(sessionStorage.getItem('gg_pkce_map') || '{}');
const byState = state && pkceMap[state];
if (byState && byState.length >= 43) appState.codeVerifier = byState;
if (!appState.codeVerifier) {
const savedVerifier = sessionStorage.getItem('gg_code_verifier');
if (savedVerifier && savedVerifier.length >= 43) appState.codeVerifier = savedVerifier;
}
} catch (e) {}
}
if (!appState.codeVerifier) {
throw new Error('会话已重置或过期:缺少 code_verifier请重新点击“开始OAuth登录”');
}
// 交换访问令牌
const tokenResponse = await fetch(oauthConfig.tokenUrl, {
method: 'POST',
@@ -1960,7 +1985,9 @@
body: JSON.stringify({
code: code,
code_verifier: appState.codeVerifier,
redirect_uri: oauthConfig.redirectUri
redirect_uri: oauthConfig.redirectUri,
client_id: oauthConfig.clientId,
...(typeof oauthConfig.useBasic !== 'undefined' ? { use_basic: oauthConfig.useBasic } : {})
})
});
@@ -1970,6 +1997,11 @@
const tokenData = await tokenResponse.json();
appState.accessToken = tokenData.access_token;
try {
const pkceMap = JSON.parse(sessionStorage.getItem('gg_pkce_map') || '{}');
if (state && pkceMap[state]) { delete pkceMap[state]; }
sessionStorage.setItem('gg_pkce_map', JSON.stringify(pkceMap));
} catch (e) {}
showStatus(elements.callbackStatus, "OAuth登录成功", "success");
updateStatus(); // 更新状态显示

View File

@@ -61,7 +61,7 @@ describe('Giffgaff OAuthManager', () => {
const verifier = 'test-verifier';
const url = await OAuthManager.buildAuthorizationUrl(verifier);
expect(url).toContain('https://id.giffgaff.com/auth/oauth/authorize');
expect(url).toContain('https://id.giffgaff.com/oauth/authorize');
expect(url).toContain('response_type=code');
expect(url).toContain('client_id=');
expect(url).toContain('redirect_uri=');