feat(ap1): add vanilla SPA PKCE login
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
InMemoryWebStorage,
|
||||
UserManager,
|
||||
WebStorageStateStore,
|
||||
} from "oidc-client-ts";
|
||||
|
||||
import { createPkcePair } from "./pkce.js";
|
||||
|
||||
const authority = "http://localhost:8080/realms/keycloak-patterns";
|
||||
const backendBaseUrl = "http://localhost:8081";
|
||||
|
||||
const userManager = new UserManager({
|
||||
authority,
|
||||
client_id: "spa-public",
|
||||
redirect_uri: "http://localhost:8088/callback.html",
|
||||
post_logout_redirect_uri: "http://localhost:8088/",
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
automaticSilentRenew: true,
|
||||
monitorSession: false,
|
||||
loadUserInfo: false,
|
||||
userStore: new WebStorageStateStore({
|
||||
store: new InMemoryWebStorage(),
|
||||
}),
|
||||
stateStore: new WebStorageStateStore({
|
||||
store: window.sessionStorage,
|
||||
}),
|
||||
});
|
||||
|
||||
const loginButton = document.querySelector("#login");
|
||||
const logoutButton = document.querySelector("#logout");
|
||||
const apiButton = document.querySelector("#call-api");
|
||||
const pkceButton = document.querySelector("#pkce-demo");
|
||||
const sessionState = document.querySelector("#session-state");
|
||||
const result = document.querySelector("#result");
|
||||
|
||||
let currentUser = null;
|
||||
|
||||
function renderJson(value) {
|
||||
result.textContent = JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function tokenMetadata(user) {
|
||||
return {
|
||||
subject: user.profile.sub,
|
||||
username: user.profile.preferred_username,
|
||||
expiresAt: new Date(user.expires_at * 1000).toISOString(),
|
||||
accessTokenHeldBy: "browser memory",
|
||||
refreshTokenHeldBy: user.refresh_token ? "browser memory" : "not issued",
|
||||
};
|
||||
}
|
||||
|
||||
function renderSession(user) {
|
||||
currentUser = user;
|
||||
const authenticated = Boolean(user && !user.expired);
|
||||
sessionState.dataset.authenticated = String(authenticated);
|
||||
sessionState.textContent = authenticated
|
||||
? `${user.profile.preferred_username} 로그인됨 · token은 메모리에만 보관`
|
||||
: "로그인되지 않음 · 브라우저 저장소에 token 없음";
|
||||
loginButton.disabled = authenticated;
|
||||
logoutButton.disabled = !authenticated;
|
||||
apiButton.disabled = !authenticated;
|
||||
|
||||
if (authenticated) {
|
||||
renderJson(tokenMetadata(user));
|
||||
}
|
||||
}
|
||||
|
||||
async function finishSigninCallback() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const isCallback = window.location.pathname === "/callback.html"
|
||||
&& (params.has("code") || params.has("error"));
|
||||
|
||||
if (!isCallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await userManager.signinRedirectCallback();
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
return user;
|
||||
}
|
||||
|
||||
async function callProtectedApi() {
|
||||
if (!currentUser || currentUser.expired) {
|
||||
throw new Error("로그인이 필요합니다.");
|
||||
}
|
||||
|
||||
const response = await fetch(`${backendBaseUrl}/api/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentUser.access_token}`,
|
||||
},
|
||||
});
|
||||
const body = await response.json();
|
||||
renderJson({
|
||||
httpStatus: response.status,
|
||||
resourceServerResponse: body,
|
||||
tokenBoundary: tokenMetadata(currentUser),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`보호 API가 ${response.status}를 반환했습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
loginButton.addEventListener("click", () => userManager.signinRedirect());
|
||||
logoutButton.addEventListener("click", () => userManager.signoutRedirect());
|
||||
apiButton.addEventListener("click", () => {
|
||||
callProtectedApi().catch((error) => renderJson({ error: error.message }));
|
||||
});
|
||||
pkceButton.addEventListener("click", () => {
|
||||
createPkcePair()
|
||||
.then(({ verifier, challenge, method }) => renderJson({
|
||||
method,
|
||||
verifierLength: verifier.length,
|
||||
challengeLength: challenge.length,
|
||||
note: "실제 로그인에서는 oidc-client-ts가 같은 S256 단계를 수행합니다.",
|
||||
}))
|
||||
.catch((error) => renderJson({ error: error.message }));
|
||||
});
|
||||
|
||||
userManager.events.addUserLoaded(renderSession);
|
||||
userManager.events.addUserUnloaded(() => renderSession(null));
|
||||
userManager.events.addAccessTokenExpired(() => renderSession(null));
|
||||
|
||||
window.__pattern1 = {
|
||||
getAccessToken: () => currentUser?.access_token ?? null,
|
||||
getRefreshToken: () => currentUser?.refresh_token ?? null,
|
||||
getIdToken: () => currentUser?.id_token ?? null,
|
||||
callProtectedApi,
|
||||
};
|
||||
|
||||
try {
|
||||
const callbackUser = await finishSigninCallback();
|
||||
renderSession(callbackUser ?? await userManager.getUser());
|
||||
} catch (error) {
|
||||
renderSession(null);
|
||||
renderJson({ error: error.message });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
function base64Url(bytes) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/u, "");
|
||||
}
|
||||
|
||||
export async function createPkcePair(cryptoApi = globalThis.crypto) {
|
||||
const verifierBytes = new Uint8Array(32);
|
||||
cryptoApi.getRandomValues(verifierBytes);
|
||||
const verifier = base64Url(verifierBytes);
|
||||
const digest = await cryptoApi.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(verifier),
|
||||
);
|
||||
|
||||
return {
|
||||
verifier,
|
||||
challenge: base64Url(new Uint8Array(digest)),
|
||||
method: "S256",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user