fix: support PKCE public client without client secret
Deploy EG-BIM QA Gateway / deploy (push) Successful in 48s

This commit is contained in:
root
2026-09-18 19:59:06 +09:00
commit a420407c18
17 changed files with 1310 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === '/auth/login') return startLogin(request, env);
if (url.pathname === '/auth/callback') return finishLogin(request, env);
if (url.pathname === '/auth/logout') return logout(request, env);
if (url.pathname === '/auth/session') return sessionResponse(request, env);
if (request.method !== 'GET' && request.method !== 'HEAD') {
return json({ error: 'method_not_allowed' }, 405);
}
if (matchesConfiguredPath(url.pathname, env.INTERNAL_ONLY_PREFIXES) && !isInternalRequest(request, env)) {
return json({ error: 'not_found' }, 404);
}
if (isProtectedPath(url.pathname, env) && !(await getSession(request, env))) {
const returnUrl = url.pathname + url.search;
return Response.redirect(new URL('/auth/login?return_url=' + encodeURIComponent(returnUrl), url), 302);
}
if (url.pathname === '/egbim' || url.pathname === '/tova' || url.pathname === '/gaia') {
return Response.redirect(new URL(url.pathname + '/', url), 301);
}
const objectKey = objectKeyForPath(url.pathname);
let object = await env.QA_BUCKET.get(objectKey);
if (!object && objectKey.endsWith('/index.html')) {
object = await env.QA_BUCKET.get(objectKey.replace(/\/index\.html$/, 'index.html'));
}
if (!object) return new Response('Not found', { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('x-content-type-options', 'nosniff');
headers.set('referrer-policy', 'strict-origin-when-cross-origin');
headers.set('cache-control', url.pathname.endsWith('.html') ? 'no-cache' : 'public, max-age=3600');
return new Response(request.method === 'HEAD' ? null : object.body, { headers });
} catch (error) {
console.error(error);
return json({ error: 'internal_error' }, 500);
}
}
};
function csv(value) {
return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
}
function pathMatches(pathname, values) {
return csv(values).some((value) => value.endsWith('/') ? pathname.startsWith(value) : pathname === value);
}
function matchesConfiguredPath(pathname, value) {
return pathMatches(pathname, value);
}
function isProtectedPath(pathname, env) {
return pathMatches(pathname, env.PROTECTED_EXACT_PATHS) || pathMatches(pathname, env.PROTECTED_PREFIXES);
}
function isInternalRequest(request, env) {
const expected = env.INTERNAL_SHARED_SECRET;
return Boolean(expected && request.headers.get('x-internal-secret') === expected);
}
function objectKeyForPath(pathname) {
const normalized = pathname.replace(/^\/+/, '');
if (normalized === '') return 'index.html';
if (normalized.endsWith('/')) return normalized + 'index.html';
return normalized;
}
function json(payload, status = 200, extraHeaders = {}) {
const headers = new Headers({ 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extraHeaders });
return new Response(JSON.stringify(payload), { status, headers });
}
function redirect(url, headers = {}) {
const responseHeaders = new Headers();
Object.entries(headers).forEach(([name, value]) => {
if (Array.isArray(value)) value.forEach((item) => responseHeaders.append(name, item));
else responseHeaders.set(name, value);
});
responseHeaders.set('location', url);
return new Response(null, { status: 302, headers: responseHeaders });
}
function base64url(value) {
const bytes = value instanceof Uint8Array ? value : encoder.encode(value);
let binary = '';
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function fromBase64url(value) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
async function sha256(value) {
return crypto.subtle.digest('SHA-256', typeof value === 'string' ? encoder.encode(value) : value);
}
async function hmac(value, secret) {
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
return crypto.subtle.sign('HMAC', key, encoder.encode(value));
}
async function signValue(value, secret) {
return value + '.' + base64url(new Uint8Array(await hmac(value, secret)));
}
async function verifyValue(signed, secret) {
if (!signed || !secret) return null;
const separator = signed.lastIndexOf('.');
if (separator < 1) return null;
const value = signed.slice(0, separator);
const signature = signed.slice(separator + 1);
const expected = base64url(new Uint8Array(await hmac(value, secret)));
if (signature.length !== expected.length) return null;
let difference = 0;
for (let index = 0; index < signature.length; index += 1) difference |= signature.charCodeAt(index) ^ expected.charCodeAt(index);
return difference === 0 ? value : null;
}
function randomBytes(size = 32) {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return bytes;
}
function randomString(size = 32) {
return base64url(randomBytes(size));
}
function getCookie(request, name) {
const header = request.headers.get('cookie') || '';
const match = header.split(';').map((part) => part.trim()).find((part) => part.startsWith(name + '='));
return match ? match.slice(name.length + 1) : '';
}
function cookie(name, value, maxAge, options = {}) {
const parts = [`${name}=${value}`, `Max-Age=${maxAge}`, 'Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax'];
if (options.delete) parts.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT');
return parts.join('; ');
}
function parseJwt(token) {
if (!token || token.split('.').length < 2) return {};
try { return JSON.parse(decoder.decode(fromBase64url(token.split('.')[1]))); } catch (error) { return {}; }
}
function requiredAuthConfig(env) {
const keys = ['AUTH_CLIENT_ID', 'AUTH_AUTHORIZE_URL', 'AUTH_TOKEN_URL', 'AUTH_REDIRECT_URI', 'SESSION_SECRET'];
return keys.filter((key) => !env[key]);
}
function normalizeClaims(claims) {
const profile = claims.profile && typeof claims.profile === 'object' ? claims.profile : {};
const custom = claims.customAttributes || claims.custom_attributes || {};
const tenants = claims.tenants || claims.tenantIds || custom.tenants || [];
const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map((key) => ({ id: key, ...(tenants[key] || {}) }));
const tenantId = claims.tenant_id || claims.tenantId || custom.tenant_id || custom.tenantId || tenantList[0]?.id || tenantList[0]?.tenantId || '';
const tenantIds = tenantList.map((tenant) => typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || '')).filter(Boolean);
if (tenantId && !tenantIds.includes(tenantId)) tenantIds.unshift(tenantId);
const subject = claims.sub || claims.subject || claims.userId || claims.user_id || '';
return {
userUuid: claims.userId || claims.user_id || subject,
ssoSubject: subject,
requesterId: subject,
tenantId,
requesterTenantId: tenantId,
tenantIds,
scope: claims.scope || '',
roles: claims.roles || claims.roleNames || [],
loginId: claims.email || claims.loginId || claims.preferred_username || profile.email || '',
email: claims.email || profile.email || '',
name: claims.name || profile.name || claims.display_name || '',
phone: claims.phone || claims.phone_number || '',
company: custom.company || claims.company || claims.organization || '',
familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || '',
department: custom.team || custom.department || claims.department || ''
};
}
function safeReturnUrl(value, origin) {
try {
const target = new URL(value || '/', origin);
return target.origin === origin ? target.pathname + target.search : '/';
} catch (error) { return '/'; }
}
async function startLogin(request, env) {
const missing = requiredAuthConfig(env);
if (missing.length) return json({ error: 'auth_not_configured', missing }, 503);
const url = new URL(request.url);
const returnUrl = safeReturnUrl(url.searchParams.get('return_url'), url.origin);
const state = randomString(24);
const verifier = randomString(48);
const nonce = randomString(24);
const statePayload = base64url(JSON.stringify({ state, verifier, nonce, returnUrl, createdAt: Date.now() }));
const signedState = await signValue(statePayload, env.SESSION_SECRET);
const challenge = base64url(new Uint8Array(await sha256(verifier)));
const authorize = new URL(env.AUTH_AUTHORIZE_URL);
authorize.searchParams.set('client_id', env.AUTH_CLIENT_ID);
authorize.searchParams.set('response_type', 'code');
authorize.searchParams.set('redirect_uri', env.AUTH_REDIRECT_URI);
authorize.searchParams.set('scope', env.AUTH_SCOPE || 'openid profile email');
authorize.searchParams.set('state', state);
authorize.searchParams.set('nonce', nonce);
authorize.searchParams.set('code_challenge', challenge);
authorize.searchParams.set('code_challenge_method', 'S256');
return redirect(authorize.toString(), { 'set-cookie': cookie(env.OAUTH_COOKIE_NAME, signedState, 600) });
}
async function finishLogin(request, env) {
const missing = requiredAuthConfig(env);
if (missing.length) return json({ error: 'auth_not_configured', missing }, 503);
const url = new URL(request.url);
const signedState = getCookie(request, env.OAUTH_COOKIE_NAME);
const statePayload = await verifyValue(signedState, env.SESSION_SECRET);
if (!statePayload) return json({ error: 'invalid_oauth_state' }, 400);
let state;
try { state = JSON.parse(decoder.decode(fromBase64url(statePayload))); } catch (error) { return json({ error: 'invalid_oauth_state' }, 400); }
if (url.searchParams.get('state') !== state.state || Date.now() - state.createdAt > 10 * 60 * 1000) return json({ error: 'invalid_oauth_state' }, 400);
const code = url.searchParams.get('code');
if (!code) return json({ error: 'oauth_code_missing', detail: url.searchParams.get('error_description') || url.searchParams.get('error') || '' }, 400);
const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', client_id: env.AUTH_CLIENT_ID, redirect_uri: env.AUTH_REDIRECT_URI, code, code_verifier: state.verifier });
const tokenResponse = await fetch(env.AUTH_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: tokenParams });
const tokens = await tokenResponse.json().catch(() => ({}));
if (!tokenResponse.ok) return json({ error: 'oauth_token_exchange_failed', detail: tokens.error_description || tokens.error || '' }, 502);
let claims = parseJwt(tokens.id_token || tokens.access_token);
if (state.nonce && claims.nonce && state.nonce !== claims.nonce) return json({ error: 'invalid_oauth_nonce' }, 400);
if (env.AUTH_USERINFO_URL && tokens.access_token) {
const userResponse = await fetch(env.AUTH_USERINFO_URL, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: 'application/json' } });
const userInfo = await userResponse.json().catch(() => ({}));
if (userResponse.ok) claims = { ...claims, ...userInfo };
}
const user = normalizeClaims(claims);
if (!user.ssoSubject) return json({ error: 'sso_subject_missing' }, 502);
const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 }));
const session = await signValue(sessionPayload, env.SESSION_SECRET);
return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, Number(env.SESSION_TTL_SECONDS || 3600)), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] });
}
async function getSession(request, env) {
const signedSession = getCookie(request, env.SESSION_COOKIE_NAME);
const value = await verifyValue(signedSession, env.SESSION_SECRET);
if (!value) return null;
try {
const payload = JSON.parse(decoder.decode(fromBase64url(value)));
return payload.expiresAt > Date.now() ? payload.user : null;
} catch (error) { return null; }
}
async function sessionResponse(request, env) {
const user = await getSession(request, env);
return json({ authenticated: Boolean(user), user }, 200, { 'access-control-allow-credentials': 'true' });
}
function logout(request, env) {
const url = new URL(request.url);
const target = safeReturnUrl(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin);
return redirect(new URL(target, url.origin).toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) });
}