diff --git a/.gitignore b/.gitignore
index 8f0f1cc..215efbe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,6 @@
*.iml
backend/target/
+**/node_modules/
+frontend/dist/
build/
diff --git a/README.md b/README.md
index ac1a49e..d8a7f29 100644
--- a/README.md
+++ b/README.md
@@ -96,3 +96,17 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
+
+## AP1: SPA Direct + Resource Server
+
+`develop-keycloak-pattern1`은 vanilla JavaScript SPA가 `spa-public` client로
+Authorization Code + PKCE S256 로그인을 수행하는 패턴입니다. access/refresh
+token은 명시적인 in-memory store에만 보관되므로 새로고침하면 사라집니다.
+
+```bash
+./scripts/verify-pattern1.sh
+```
+
+브라우저에서 `http://localhost:8088`을 열어 로그인한 뒤 보호 API를 호출할 수
+있습니다. SPA는 `http://localhost:8081/api/me`를 직접 호출하며 Spring
+Resource Server가 Bearer JWT를 검증합니다.
diff --git a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java
index d4c2129..41b68af 100644
--- a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java
+++ b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java
@@ -1,11 +1,16 @@
package com.example.keycloakpattern;
+import java.util.List;
+
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class SecurityConfig {
@@ -13,6 +18,7 @@ public class SecurityConfig {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
+ .cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
@@ -24,4 +30,19 @@ public class SecurityConfig {
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
+
+ @Bean
+ CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOrigins(List.of(
+ "http://localhost:8088",
+ "http://127.0.0.1:8088"
+ ));
+ configuration.setAllowedMethods(List.of("GET", "OPTIONS"));
+ configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/api/**", configuration);
+ return source;
+ }
}
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
new file mode 100644
index 0000000..78d42d6
--- /dev/null
+++ b/e2e/package-lock.json
@@ -0,0 +1,28 @@
+{
+ "name": "keycloak-pattern-e2e",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "keycloak-pattern-e2e",
+ "version": "1.0.0",
+ "devDependencies": {
+ "playwright-core": "1.62.0"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
+ "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/e2e/package.json b/e2e/package.json
new file mode 100644
index 0000000..06a4dd4
--- /dev/null
+++ b/e2e/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "keycloak-pattern-e2e",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "test:pattern1": "node pattern1.mjs"
+ },
+ "devDependencies": {
+ "playwright-core": "1.62.0"
+ }
+}
diff --git a/e2e/pattern1.mjs b/e2e/pattern1.mjs
new file mode 100644
index 0000000..0028c89
--- /dev/null
+++ b/e2e/pattern1.mjs
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import { chromium } from "playwright-core";
+
+const username = process.env.E2E_USERNAME ?? "regular-user";
+const password = process.env.E2E_PASSWORD;
+
+assert.ok(password, "E2E_PASSWORD must be set");
+
+const browser = await chromium.launch({
+ executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
+ headless: true,
+ args: ["--no-sandbox"],
+});
+
+try {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ let authorizationUrl;
+
+ page.on("request", (request) => {
+ if (request.url().includes("/protocol/openid-connect/auth")) {
+ authorizationUrl = new URL(request.url());
+ }
+ });
+
+ await page.goto("http://localhost:8088");
+ await page.locator("#login").click();
+ await page.waitForURL(/localhost:8080/u);
+ await page.locator("#username").fill(username);
+ await page.locator("#password").fill(password);
+ await page.locator("#kc-login").click();
+ await page.waitForURL("http://localhost:8088/");
+ await page.locator('[data-authenticated="true"]').waitFor();
+
+ assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
+ assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
+ assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
+
+ const accessToken = await page.evaluate(() => window.__pattern1.getAccessToken());
+ assert.ok(accessToken, "access token must exist in browser memory");
+
+ const storageSnapshot = await page.evaluate(() => ({
+ localStorage: Object.values(localStorage),
+ sessionStorage: Object.values(sessionStorage),
+ }));
+ assert.equal(
+ JSON.stringify(storageSnapshot).includes(accessToken),
+ false,
+ "access token must not be persisted in Web Storage",
+ );
+
+ await page.locator("#call-api").click();
+ await page.waitForFunction(() => {
+ const text = document.querySelector("#result")?.textContent ?? "";
+ return text.includes('"httpStatus": 200');
+ });
+
+ await page.reload();
+ await page.locator('[data-authenticated="false"]').waitFor();
+ assert.equal(
+ await page.evaluate(() => window.__pattern1.getAccessToken()),
+ null,
+ "reload must clear the memory-only token",
+ );
+
+ console.log(
+ "pattern1 browser verified: code+PKCE S256, protected API 200, Web Storage token 0, reload clears token",
+ );
+} finally {
+ await browser.close();
+}
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index de7eb7e..652cbdf 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -1,4 +1,16 @@
+FROM node:24-alpine AS build
+
+WORKDIR /workspace
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY src ./src
+COPY test ./test
+RUN npm test && npm run build
+
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
+COPY --from=build /workspace/dist/app.js /usr/share/nginx/html/app.js
diff --git a/frontend/index.html b/frontend/index.html
index 8b1109a..1b8c8ff 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,31 +3,66 @@
- Keycloak Authentication Patterns
+
+ AP1 · SPA Direct + Resource Server
- Keycloak Authentication Patterns
- 공통 Docker Compose baseline이 실행 중입니다.
-
- 공개 API는 /api/public, 보호 API는
- /api/me에서 확인할 수 있습니다.
-
+
+ AP1 · SPA Direct + Resource Server
+
+ 바닐라 JavaScript SPA가 spa-public client로 Authorization
+ Code + PKCE를 수행하고, access token을 직접 Spring Resource Server에
+ 전달합니다.
+
+
+ access/refresh token은 메모리에만 존재합니다. 새로고침하면 사라지는 것이
+ 이 패턴의 의도된 보안 경계입니다.
+
+
+
+
+
+
+
+
+
+ 세션 확인 중…
+
+
+