fix: support PKCE public client without client secret
Deploy EG-BIM QA Gateway / deploy (push) Successful in 48s
Deploy EG-BIM QA Gateway / deploy (push) Successful in 48s
This commit is contained in:
+274
@@ -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 }) });
|
||||
}
|
||||
Reference in New Issue
Block a user