diff --git a/script.js b/script.js index a06ae9b..94f6ace 100644 --- a/script.js +++ b/script.js @@ -11,6 +11,8 @@ const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1"; const AUTH_OVERRIDE_KEY = "links_home_auth_override_v1"; const AUTH_SETUP_SHOWN_KEY = "links_home_auth_setup_shown_v1"; + const AUTH_PKCE_KEY = "links_home_auth_pkce_v1"; + const AUTH_TOKEN_KEY = "links_home_auth_tokens_v1"; const el = { subtitle: document.getElementById("subtitle"), @@ -441,6 +443,185 @@ return u.toString(); } + function redirectUri() { + return location.origin === "null" ? location.href : location.origin + location.pathname; + } + + function b64urlFromBytes(bytes) { + let bin = ""; + const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]); + const b64 = btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + return b64; + } + + async function sha256Bytes(input) { + const data = new TextEncoder().encode(String(input)); + const hash = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(hash); + } + + function randomString(len = 43) { + const bytes = new Uint8Array(len); + crypto.getRandomValues(bytes); + return b64urlFromBytes(bytes); + } + + function decodeJwtPayload(token) { + try { + const parts = String(token || "").split("."); + if (parts.length < 2) return null; + const b64 = parts[1].replaceAll("-", "+").replaceAll("_", "/"); + const pad = "=".repeat((4 - (b64.length % 4)) % 4); + const json = atob(b64 + pad); + return safeJsonParse(json, null); + } catch { + return null; + } + } + + function loadTokens() { + const raw = localStorage.getItem(AUTH_TOKEN_KEY); + const data = raw ? safeJsonParse(raw, null) : null; + if (!data || typeof data !== "object") return null; + return { + id_token: typeof data.id_token === "string" ? data.id_token : "", + access_token: typeof data.access_token === "string" ? data.access_token : "", + received_at: typeof data.received_at === "string" ? data.received_at : "", + expires_in: typeof data.expires_in === "number" ? data.expires_in : 0, + }; + } + + function saveTokens(t) { + localStorage.setItem( + AUTH_TOKEN_KEY, + JSON.stringify({ + id_token: t.id_token || "", + access_token: t.access_token || "", + expires_in: t.expires_in || 0, + received_at: nowIso(), + }) + ); + } + + function clearTokens() { + localStorage.removeItem(AUTH_TOKEN_KEY); + } + + async function manualAuthorize(connection) { + const cfg = getAuthConfig(); + if (!cfg.auth0.domain || !cfg.auth0.clientId) { + openAuthModal(); + return; + } + if (!globalThis.crypto || !crypto.subtle) { + toast("이 브라우저는 보안 로그인(PKCE)을 지원하지 않습니다."); + return; + } + const verifier = randomString(64); + const challenge = b64urlFromBytes(await sha256Bytes(verifier)); + const state = randomString(24); + sessionStorage.setItem( + AUTH_PKCE_KEY, + JSON.stringify({ + verifier, + state, + redirect_uri: redirectUri(), + created_at: nowIso(), + }) + ); + + const u = new URL(`https://${cfg.auth0.domain}/authorize`); + u.searchParams.set("response_type", "code"); + u.searchParams.set("client_id", cfg.auth0.clientId); + u.searchParams.set("redirect_uri", redirectUri()); + u.searchParams.set("scope", "openid profile email"); + u.searchParams.set("state", state); + u.searchParams.set("code_challenge", challenge); + u.searchParams.set("code_challenge_method", "S256"); + if (connection) u.searchParams.set("connection", connection); + location.assign(u.toString()); + } + + async function manualHandleCallbackIfNeeded() { + const cfg = getAuthConfig(); + const u = new URL(location.href); + const code = u.searchParams.get("code") || ""; + const stateParam = u.searchParams.get("state") || ""; + if (!code || !stateParam) return null; + + const raw = sessionStorage.getItem(AUTH_PKCE_KEY); + const pkce = raw ? safeJsonParse(raw, null) : null; + if (!pkce || pkce.state !== stateParam) { + toast("로그인 상태값(state)이 일치하지 않습니다. 다시 시도하세요."); + return null; + } + + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("client_id", cfg.auth0.clientId); + body.set("code_verifier", pkce.verifier); + body.set("code", code); + body.set("redirect_uri", pkce.redirect_uri || redirectUri()); + + const tokenRes = await fetch(`https://${cfg.auth0.domain}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!tokenRes.ok) { + toast("토큰 발급에 실패했습니다. Auth0 Callback URL 설정을 확인하세요."); + return null; + } + const tokenJson = await tokenRes.json(); + const idToken = String(tokenJson.id_token || ""); + const accessToken = String(tokenJson.access_token || ""); + const expiresIn = Number(tokenJson.expires_in || 0) || 0; + if (!idToken) { + toast("ID 토큰이 없습니다. 로그인 설정을 확인하세요."); + return null; + } + + saveTokens({ id_token: idToken, access_token: accessToken, expires_in: expiresIn }); + sessionStorage.removeItem(AUTH_PKCE_KEY); + history.replaceState({}, document.title, currentUrlNoQuery()); + return { idToken, accessToken }; + } + + async function manualLoadUser() { + const t = loadTokens(); + if (!t || !t.id_token) return null; + const payload = decodeJwtPayload(t.id_token); + if (!payload) return null; + // 최소 표시/권한 확인용 user shape + return { + sub: payload.sub, + email: payload.email, + name: payload.name, + picture: payload.picture, + }; + } + + async function syncUserToServerWithIdToken(idToken) { + try { + const cfg = getAuthConfig(); + const r = await fetch("/api/auth/sync", { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, + "X-Auth0-ClientId": cfg.auth0.clientId, + }, + }); + if (!r.ok) return null; + const data = await r.json(); + if (data && data.ok) return Boolean(data.canManage); + return null; + } catch { + return null; + } + } + function isAllowedEmail(email) { const { allowedEmails } = getAuthConfig(); if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용 @@ -519,11 +700,22 @@ } if (!hasSdk) { - toastOnce("auth0sdk", "로그인 SDK 로드에 실패했습니다. 네트워크/차단 설정을 확인하세요."); + // SDK가 없어도 PKCE 수동 로그인으로 동작 가능 auth.client = null; - auth.user = null; - auth.authorized = false; - auth.mode = "sdk_missing"; + auth.mode = "enabled"; + await manualHandleCallbackIfNeeded().catch(() => {}); + auth.user = await manualLoadUser(); + const email = auth.user && auth.user.email ? String(auth.user.email) : ""; + auth.authorized = Boolean(auth.user) && isAllowedEmail(email); + auth.serverCanManage = null; + const t = loadTokens(); + if (auth.user && t && t.id_token) { + const can = await syncUserToServerWithIdToken(t.id_token); + if (typeof can === "boolean") { + auth.serverCanManage = can; + auth.authorized = can; + } + } updateAuthUi(); applyManageLock(); return; @@ -538,7 +730,7 @@ domain: cfg.auth0.domain, clientId: cfg.auth0.clientId, authorizationParams: { - redirect_uri: location.origin === "null" ? location.href : location.origin + location.pathname, + redirect_uri: redirectUri(), }, cacheLocation: "localstorage", useRefreshTokens: true, @@ -618,26 +810,44 @@ } async function loginWithConnection(connection) { - if (auth.mode !== "enabled" || !auth.client) { + if (auth.mode !== "enabled") { openAuthModal(); return; } - await auth.client.loginWithRedirect({ - authorizationParams: { connection }, - }); + if (auth.client) { + await auth.client.loginWithRedirect({ + authorizationParams: { connection }, + }); + return; + } + await manualAuthorize(connection); } async function logout() { - if (auth.mode !== "enabled" || !auth.client) return; + if (auth.mode !== "enabled") return; + // SDK가 있으면 SDK로, 없으면 수동 로그아웃 + if (auth.client) { + auth.user = null; + auth.authorized = false; + updateAuthUi(); + applyManageLock(); + await auth.client.logout({ + logoutParams: { + returnTo: redirectUri(), + }, + }); + return; + } + clearTokens(); auth.user = null; auth.authorized = false; updateAuthUi(); applyManageLock(); - await auth.client.logout({ - logoutParams: { - returnTo: location.origin === "null" ? location.href : location.origin + location.pathname, - }, - }); + const cfg = getAuthConfig(); + const u = new URL(`https://${cfg.auth0.domain}/v2/logout`); + u.searchParams.set("client_id", cfg.auth0.clientId); + u.searchParams.set("returnTo", redirectUri()); + location.assign(u.toString()); } function openAuthModal() {