Support Auth0 login without SPA SDK (PKCE)

If createAuth0Client is unavailable on static hosting, use manual OAuth2 PKCE flow for Google login, token storage, and logout, while keeping email allowlist and optional server sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-02-07 20:48:20 +09:00
parent 58750febcc
commit 21834aa728

240
script.js
View File

@@ -11,6 +11,8 @@
const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1"; const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1";
const AUTH_OVERRIDE_KEY = "links_home_auth_override_v1"; const AUTH_OVERRIDE_KEY = "links_home_auth_override_v1";
const AUTH_SETUP_SHOWN_KEY = "links_home_auth_setup_shown_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 = { const el = {
subtitle: document.getElementById("subtitle"), subtitle: document.getElementById("subtitle"),
@@ -441,6 +443,185 @@
return u.toString(); 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) { function isAllowedEmail(email) {
const { allowedEmails } = getAuthConfig(); const { allowedEmails } = getAuthConfig();
if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용 if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용
@@ -519,11 +700,22 @@
} }
if (!hasSdk) { if (!hasSdk) {
toastOnce("auth0sdk", "로그인 SDK 로드에 실패했습니다. 네트워크/차단 설정을 확인하세요."); // SDK가 없어도 PKCE 수동 로그인으로 동작 가능
auth.client = null; auth.client = null;
auth.user = null; auth.mode = "enabled";
auth.authorized = false; await manualHandleCallbackIfNeeded().catch(() => {});
auth.mode = "sdk_missing"; 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(); updateAuthUi();
applyManageLock(); applyManageLock();
return; return;
@@ -538,7 +730,7 @@
domain: cfg.auth0.domain, domain: cfg.auth0.domain,
clientId: cfg.auth0.clientId, clientId: cfg.auth0.clientId,
authorizationParams: { authorizationParams: {
redirect_uri: location.origin === "null" ? location.href : location.origin + location.pathname, redirect_uri: redirectUri(),
}, },
cacheLocation: "localstorage", cacheLocation: "localstorage",
useRefreshTokens: true, useRefreshTokens: true,
@@ -618,26 +810,44 @@
} }
async function loginWithConnection(connection) { async function loginWithConnection(connection) {
if (auth.mode !== "enabled" || !auth.client) { if (auth.mode !== "enabled") {
openAuthModal(); openAuthModal();
return; return;
} }
await auth.client.loginWithRedirect({ if (auth.client) {
authorizationParams: { connection }, await auth.client.loginWithRedirect({
}); authorizationParams: { connection },
});
return;
}
await manualAuthorize(connection);
} }
async function logout() { 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.user = null;
auth.authorized = false; auth.authorized = false;
updateAuthUi(); updateAuthUi();
applyManageLock(); applyManageLock();
await auth.client.logout({ const cfg = getAuthConfig();
logoutParams: { const u = new URL(`https://${cfg.auth0.domain}/v2/logout`);
returnTo: location.origin === "null" ? location.href : location.origin + location.pathname, u.searchParams.set("client_id", cfg.auth0.clientId);
}, u.searchParams.set("returnTo", redirectUri());
}); location.assign(u.toString());
} }
function openAuthModal() { function openAuthModal() {