Compare commits

...
35 changed files with 2276 additions and 1 deletions
+17
View File
@@ -0,0 +1,17 @@
# Copy this file to .env and replace every change-me value.
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=change-me-postgres-password
# Keycloak resolves these placeholders while importing the realm.
TOKEN_MEDIATING_CLIENT_SECRET=change-me-token-mediating-client-secret
BFF_CLIENT_SECRET=change-me-bff-client-secret
EDGE_PROXY_CLIENT_SECRET=change-me-edge-proxy-client-secret
ADMIN_USER_PASSWORD=change-me-admin-user-password
REGULAR_USER_PASSWORD=change-me-regular-user-password
# Port 80 is the single-EC2 target. 8088 avoids common local port conflicts.
NGINX_PORT=8088
+9
View File
@@ -0,0 +1,9 @@
.env
.idea/
.vscode/
*.iml
backend/target/
**/node_modules/
frontend/dist/
build/
+133 -1
View File
@@ -1,2 +1,134 @@
# keycloak-pattern
# Keycloak Authentication Patterns
Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은 로컬
인프라에서 비교하는 학습 프로젝트입니다.
- AP1: Browser-based OAuth Client (SPA direct + Resource Server)
- AP2: Token-Mediating Backend
- AP3: Backend-for-Frontend (BFF)
- AP4: Edge forward-auth
현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API,
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
위에서 별도 브랜치로 진행합니다.
## 요구 사항
- Docker Engine
- Docker Compose
- `curl`
로컬 Java나 Maven은 필요하지 않습니다. Spring Boot 빌드와 테스트는 Maven
컨테이너에서 수행합니다.
## 시작
```bash
cp .env.example .env
docker compose up --build -d
./scripts/verify-stack.sh
```
기본 주소는 다음과 같습니다.
| 구성 요소 | 주소 |
|---|---|
| Keycloak | <http://localhost:8080> |
| Spring Boot API | <http://localhost:8081> |
| nginx | <http://localhost:8088> |
host의 80번 포트를 쓸 수 있는 single-EC2 환경에서는 `.env`
`NGINX_PORT=80`으로 변경할 수 있습니다.
## 상태 확인
```bash
docker compose ps
docker compose logs -f keycloak
curl http://localhost:8088/api/public
curl -i http://localhost:8088/api/me
```
`/api/public``200`, 인증 정보가 없는 `/api/me``401`이 정상입니다.
## 환경 초기화
PostgreSQL과 Keycloak data volume을 제거하고 realm import부터 다시
검증하려면 다음 한 줄을 사용합니다.
```bash
docker compose down -v && docker compose up --build -d
```
`start-dev`와 로컬 HTTP 설정은 학습 전용입니다. 운영 환경에서는
optimized Keycloak image, HTTPS, 엄격한 hostname 및 외부 secret store를
사용해야 합니다.
## Realm baseline
`keycloak/import/keycloak-patterns-realm.json`은 시작 시 자동 import됩니다.
하나의 `keycloak-patterns` realm 안에서 패턴마다 client를 분리합니다.
| Client | 유형 | 패턴 |
|---|---|---|
| `spa-public` | public + PKCE S256 | AP1 |
| `token-mediating-confidential` | confidential | AP2 |
| `bff-confidential` | confidential | AP3 |
| `edge-proxy` | confidential | AP4 |
confidential client secret과 테스트 사용자 password는 JSON에 평문으로
저장하지 않습니다. JSON에는 `${ENVIRONMENT_VARIABLE}` placeholder만
커밋하고, Keycloak 26.7.0이 import 시 `.env` 값을 주입합니다.
```bash
python3 scripts/validate-realm.py
docker compose down -v
docker compose up -d --wait
./scripts/verify-realm.sh
```
실행 중인 DB에서 CLI realm export를 재현하려면 다음 명령을 사용합니다.
Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
```bash
./scripts/export-realm.sh
```
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를 검증합니다.
Keycloak의 dedicated audience mapper는 `spa-public` access token에
`keycloak-pattern-api`를 추가합니다. Spring은 signature, `iss`, `exp`
아니라 이 `aud`도 검사합니다. `verify-pattern1.sh`는 같은 정상 토큰을
`deliberately-wrong-audience`를 기대하는 진단 인스턴스에도 제출해 `401`
확인합니다.
Keycloak은 `KC_HOSTNAME=http://localhost:8080`을 기준으로 token의 `iss`
발급합니다. 정상 Resource Server는 이 외부 issuer 문자열을 검증하되 JWKS는
Docker 내부의 `http://keycloak:8080`에서 가져옵니다. 진단 인스턴스는 일부러
`http://wrong-issuer.invalid`를 기대하도록 구성되어, 서명과 audience가
정상이더라도 issuer mismatch로 `401`을 반환합니다.
token 저장 위치와 XSS 범위는
[`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다.
E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer
header를 관찰할 수 있음을 재현합니다.
refresh rotation, 소비된 refresh token 재사용, RP-Initiated Logout,
revocation과 stateless JWT의 차이는
[`docs/ap1-refresh-logout.md`](docs/ap1-refresh-logout.md)에 정리했으며 같은
E2E에서 실제 Keycloak 26.7.0 동작을 검증합니다.
+1
View File
@@ -0,0 +1 @@
target/
+19
View File
@@ -0,0 +1,19 @@
FROM maven:3.9.11-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn --batch-mode dependency:go-offline
COPY src src
RUN mvn --batch-mode verify
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
WORKDIR /app
COPY --from=build /workspace/target/keycloak-pattern-api.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>keycloak-pattern-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>keycloak-pattern-api</name>
<description>Shared resource API for the Keycloak authentication patterns</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>keycloak-pattern-api</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,30 @@
package com.example.keycloakpattern;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class ApiController {
@GetMapping("/public")
public Map<String, String> publicEndpoint() {
return Map.of("status", "ok", "service", "keycloak-pattern-api");
}
@GetMapping("/me")
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("subject", jwt.getSubject());
response.put("username", jwt.getClaimAsString("preferred_username"));
response.put("issuer", jwt.getIssuer());
response.put("audience", jwt.getAudience());
return response;
}
}
@@ -0,0 +1,29 @@
package com.example.keycloakpattern;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error MISSING_AUDIENCE = new OAuth2Error(
"invalid_token",
"The required resource audience is missing",
null
);
private final String expectedAudience;
AudienceValidator(String expectedAudience) {
this.expectedAudience = expectedAudience;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains(expectedAudience)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(MISSING_AUDIENCE);
}
}
@@ -0,0 +1,31 @@
package com.example.keycloakpattern;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
@Configuration
public class JwtDecoderConfig {
@Bean
JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
@Value("${security.expected-audience}") String expectedAudience
) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
OAuth2TokenValidator<Jwt> issuerAndTimestamp =
JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> audience = new AudienceValidator(expectedAudience);
decoder.setJwtValidator(
new DelegatingOAuth2TokenValidator<>(issuerAndTimestamp, audience)
);
return decoder;
}
}
@@ -0,0 +1,12 @@
package com.example.keycloakpattern;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class KeycloakPatternApplication {
public static void main(String[] args) {
SpringApplication.run(KeycloakPatternApplication.class, args);
}
}
@@ -0,0 +1,48 @@
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 {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
.permitAll()
.anyRequest()
.authenticated())
.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;
}
}
@@ -0,0 +1,25 @@
server:
port: ${SERVER_PORT:8081}
spring:
application:
name: keycloak-pattern-api
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8080/realms/keycloak-patterns}
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/certs}
security:
expected-audience: ${SECURITY_EXPECTED_AUDIENCE:keycloak-pattern-api}
management:
endpoint:
health:
probes:
enabled: true
endpoints:
web:
exposure:
include: health,info
@@ -0,0 +1,43 @@
package com.example.keycloakpattern;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class ApiSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void publicEndpointDoesNotRequireAuthentication() throws Exception {
mockMvc.perform(get("/api/public"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("ok"));
}
@Test
void protectedEndpointRejectsAnonymousRequests() throws Exception {
mockMvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized());
}
@Test
void protectedEndpointAcceptsJwtAuthentication() throws Exception {
mockMvc.perform(get("/api/me").with(jwt().jwt(token -> token
.subject("test-subject")
.claim("preferred_username", "regular-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subject").value("test-subject"))
.andExpect(jsonPath("$.username").value("regular-user"));
}
}
@@ -0,0 +1,49 @@
package com.example.keycloakpattern;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
class AudienceValidatorTest {
private final AudienceValidator validator =
new AudienceValidator("keycloak-pattern-api");
@Test
void acceptsRequiredAudience() {
OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience(
List.of("account", "keycloak-pattern-api")
));
assertThat(result.hasErrors()).isFalse();
}
@Test
void rejectsForeignAudience() {
OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience(
List.of("another-resource")
));
assertThat(result.hasErrors()).isTrue();
assertThat(result.getErrors())
.extracting(error -> error.getErrorCode())
.containsExactly("invalid_token");
}
private Jwt jwtWithAudience(List<String> audience) {
Instant now = Instant.now();
return new Jwt(
"test-token",
now,
now.plusSeconds(300),
Map.of("alg", "none"),
Map.of("sub", "test-subject", "aud", audience)
);
}
}
+166
View File
@@ -0,0 +1,166 @@
name: keycloak-patterns
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-keycloak}
POSTGRES_USER: ${POSTGRES_USER:-keycloak}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?copy .env.example to .env and set POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test:
- CMD-SHELL
- pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"
interval: 5s
timeout: 5s
retries: 12
networks:
- keycloak-net
restart: unless-stopped
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
command:
- start-dev
- --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-keycloak}
KC_DB_USERNAME: ${POSTGRES_USER:-keycloak}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD:?copy .env.example to .env and set POSTGRES_PASSWORD}
KC_HOSTNAME: http://localhost:8080
KC_HTTP_ENABLED: "true"
KC_HEALTH_ENABLED: "true"
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?set KC_BOOTSTRAP_ADMIN_USERNAME in .env}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?set KC_BOOTSTRAP_ADMIN_PASSWORD in .env}
TOKEN_MEDIATING_CLIENT_SECRET: ${TOKEN_MEDIATING_CLIENT_SECRET:?set TOKEN_MEDIATING_CLIENT_SECRET in .env}
BFF_CLIENT_SECRET: ${BFF_CLIENT_SECRET:?set BFF_CLIENT_SECRET in .env}
EDGE_PROXY_CLIENT_SECRET: ${EDGE_PROXY_CLIENT_SECRET:?set EDGE_PROXY_CLIENT_SECRET in .env}
ADMIN_USER_PASSWORD: ${ADMIN_USER_PASSWORD:?set ADMIN_USER_PASSWORD in .env}
REGULAR_USER_PASSWORD: ${REGULAR_USER_PASSWORD:?set REGULAR_USER_PASSWORD in .env}
ports:
- "127.0.0.1:8080:8080"
volumes:
- keycloak_data:/opt/keycloak/data
- ./keycloak/import:/opt/keycloak/data/import:ro
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
- CMD
- bash
- -c
- "{ printf 'HEAD /health/ready HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000"
interval: 10s
timeout: 5s
retries: 18
start_period: 60s
networks:
- keycloak-net
restart: unless-stopped
app:
build:
context: ./backend
environment:
SERVER_PORT: "8081"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
ports:
- "127.0.0.1:8081:8081"
depends_on:
keycloak:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
networks:
- keycloak-net
restart: unless-stopped
app-wrong-audience:
profiles:
- diagnostics
build:
context: ./backend
environment:
SERVER_PORT: "8081"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
SECURITY_EXPECTED_AUDIENCE: deliberately-wrong-audience
ports:
- "127.0.0.1:18081:8081"
depends_on:
keycloak:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
networks:
- keycloak-net
app-wrong-issuer:
profiles:
- diagnostics
build:
context: ./backend
environment:
SERVER_PORT: "8081"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://wrong-issuer.invalid/realms/keycloak-patterns
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
SECURITY_EXPECTED_AUDIENCE: keycloak-pattern-api
ports:
- "127.0.0.1:18082:8081"
depends_on:
keycloak:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
networks:
- keycloak-net
nginx:
build:
context: ./frontend
ports:
- "127.0.0.1:${NGINX_PORT:-8088}:80"
depends_on:
app:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- wget -q -O - http://127.0.0.1/health | grep -q '^ok$'
interval: 10s
timeout: 5s
retries: 12
networks:
- keycloak-net
restart: unless-stopped
volumes:
keycloak_data:
postgres_data:
networks:
keycloak-net:
driver: bridge
+30
View File
@@ -0,0 +1,30 @@
# AP1 refresh rotation and logout
Realm 실행 profile:
- Access Token Lifespan: 300초
- Revoke Refresh Token: 활성화
- Refresh Token Max Reuse: 0
`e2e/pattern1.mjs`는 token 원문을 출력하지 않고 다음 순서를 실행한다.
1. browser Authorization Code + PKCE 로그인으로 AT₁/RT₁/ID Token을 받는다.
2. `signoutRedirect()``id_token_hint`를 포함한 Keycloak logout endpoint를
호출하는지 확인한다.
3. logout 이후 새 authorization 요청에서 로그인 화면이 다시 필요한지
확인한다.
4. 새 RT₁으로 refresh하여 AT₂/RT₂를 받고 RT₂가 RT₁과 다른지 확인한다.
5. 이미 소비된 RT₁을 재사용해 성공하지 않는지 확인한다.
6. RT₁ 재사용 뒤 RT₂와 realm session 상태가 어떤 결과를 내는지 status로
기록한다. 이 결과를 사전에 family invalidation이라고 단정하지 않는다.
7. refresh token을 revoke한 뒤 같은 refresh token의 재사용은 실패하지만,
이미 발급된 self-contained access JWT는 `exp` 전까지 Resource Server에서
계속 `200`인 stateless 함정을 확인한다.
logout은 브라우저 SSO session을 종료하는 흐름이고 token revocation은 특정
token grant를 폐기하는 흐름이다. 둘은 목적과 endpoint가 다르다.
즉시 access 차단이 필요한 시스템이라면 짧은 access token TTL 외에
introspection, reference token 또는 별도 deny-list 같은 stateful 검증을
검토해야 한다. 이 AP1 구현은 JWT의 stateless 검증 특성을 의도적으로
유지한다.
+29
View File
@@ -0,0 +1,29 @@
# AP1 token storage trade-off
AP1에서는 `access_token`, `refresh_token`, `id_token`
`oidc-client-ts`의 명시적인 `InMemoryWebStorage`에만 보관한다.
`localStorage``sessionStorage`에는 OAuth token을 저장하지 않는다.
full-page authorization redirect를 생존해야 하는 일회성 transaction
state와 PKCE verifier만 `sessionStorage`를 사용한다. callback 성공 후
라이브러리가 해당 transaction state를 제거한다.
| 저장 위치 | reload 생존 | JavaScript 접근 | AP1 선택 |
|---|---:|---:|---:|
| 메모리 | 아니요 | 실행 중 가능 | 사용 |
| `sessionStorage` | 같은 탭에서 가능 | 가능 | token 저장 금지 |
| `localStorage` | 예 | 가능 | token 저장 금지 |
| HttpOnly cookie | 가능 | raw token 접근 불가 | AP2/AP3의 서버 소유 경계 |
메모리 저장은 XSS를 제거하지 않는다. 악성 스크립트가 실행 중 `fetch`
후킹하면 SPA가 붙이는 `Authorization: Bearer ...` 헤더를 관찰할 수 있다.
다만 persistent storage를 사용하지 않으므로 reload 이후 탈취 가능한 token
복사본이 남지 않는다.
`e2e/pattern1.mjs`는 다음 두 조건을 동시에 검증한다.
1. access token이 Web Storage 어디에도 존재하지 않는다.
2. 실행 중 fetch hook은 Bearer token을 관찰할 수 있다.
따라서 결론은 “메모리면 XSS에 안전”이 아니라 “영속 탈취 범위를 줄이지만
실행 중 XSS에는 여전히 노출”이다.
+28
View File
@@ -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"
}
}
}
}
+12
View File
@@ -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"
}
}
+210
View File
@@ -0,0 +1,210 @@
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;
const frontendUrl = "http://localhost:8088/";
const tokenEndpoint =
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token";
const revokeEndpoint =
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/revoke";
assert.ok(password, "E2E_PASSWORD must be set");
async function login(page) {
await page.locator("#login").click();
await page.waitForURL(/localhost:8080/u);
await page.locator("#username").waitFor();
const tokenResponsePromise = page.waitForResponse((response) =>
response.url() === tokenEndpoint
&& response.request().postData()?.includes("grant_type=authorization_code"),
);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
const tokenResponse = await tokenResponsePromise;
assert.equal(tokenResponse.status(), 200);
const tokenSet = await tokenResponse.json();
assert.ok(tokenSet.access_token);
assert.ok(tokenSet.refresh_token);
assert.ok(tokenSet.id_token);
await page.waitForURL(frontendUrl);
await page.locator('[data-authenticated="true"]').waitFor();
return tokenSet;
}
async function postForm(url, values) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(values),
});
}
async function refresh(refreshToken) {
return postForm(tokenEndpoint, {
grant_type: "refresh_token",
client_id: "spa-public",
refresh_token: refreshToken,
});
}
async function callResource(accessToken, url = "http://localhost:8081/api/me") {
return fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
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(frontendUrl);
const firstTokenSet = await login(page);
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
await page.evaluate(() => {
const originalFetch = window.fetch.bind(window);
window.__xssProbe = { authorization: null };
window.fetch = (input, init = {}) => {
const headers = new Headers(
init.headers ?? (input instanceof Request ? input.headers : undefined),
);
const authorization = headers.get("Authorization");
if (authorization) {
window.__xssProbe.authorization = authorization;
}
return originalFetch(input, init);
};
});
await page.locator("#call-api").click();
await page.waitForFunction(() => {
const text = document.querySelector("#result")?.textContent ?? "";
return text.includes('"httpStatus": 200');
});
const capturedAuthorization = await page.evaluate(
() => window.__xssProbe.authorization,
);
assert.match(capturedAuthorization, /^Bearer /u);
const accessToken = capturedAuthorization.slice("Bearer ".length);
assert.equal(accessToken, firstTokenSet.access_token);
const payload = JSON.parse(
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
);
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
assert.ok(audiences.includes("keycloak-pattern-api"));
const storageSnapshot = await page.evaluate(() => ({
localStorage: Object.values(localStorage),
sessionStorage: Object.values(sessionStorage),
}));
assert.equal(JSON.stringify(storageSnapshot).includes(accessToken), false);
assert.ok(capturedAuthorization);
if (process.env.WRONG_AUDIENCE_URL) {
assert.equal(
(await callResource(accessToken, process.env.WRONG_AUDIENCE_URL)).status,
401,
);
}
if (process.env.WRONG_ISSUER_URL) {
assert.equal(
(await callResource(accessToken, process.env.WRONG_ISSUER_URL)).status,
401,
);
}
const logoutRequestPromise = page.waitForRequest((request) =>
request.url().includes("/protocol/openid-connect/logout"),
);
await page.locator("#logout").click();
const logoutRequest = await logoutRequestPromise;
assert.ok(new URL(logoutRequest.url()).searchParams.get("id_token_hint"));
await page.waitForURL(frontendUrl);
await page.locator('[data-authenticated="false"]').waitFor();
const secondTokenSet = await login(page);
const rotatedResponse = await refresh(secondTokenSet.refresh_token);
assert.equal(rotatedResponse.status, 200);
const rotated = await rotatedResponse.json();
assert.ok(rotated.refresh_token);
assert.notEqual(rotated.refresh_token, secondTokenSet.refresh_token);
const reusedOldResponse = await refresh(secondTokenSet.refresh_token);
assert.notEqual(
reusedOldResponse.status,
200,
"a consumed refresh token must not be accepted again",
);
const rotatedAfterReuseResponse = await refresh(rotated.refresh_token);
const rotatedAfterReuseStatus = rotatedAfterReuseResponse.status;
assert.ok([200, 400, 401].includes(rotatedAfterReuseStatus));
assert.equal(
(await callResource(rotated.access_token)).status,
200,
"a locally validated access JWT remains usable until exp",
);
await context.clearCookies();
await page.reload();
await page.locator('[data-authenticated="false"]').waitFor();
const thirdTokenSet = await login(page);
const revokeResponse = await postForm(revokeEndpoint, {
token: thirdTokenSet.refresh_token,
token_type_hint: "refresh_token",
client_id: "spa-public",
});
assert.equal(revokeResponse.status, 200);
assert.notEqual((await refresh(thirdTokenSet.refresh_token)).status, 200);
assert.equal(
(await callResource(thirdTokenSet.access_token)).status,
200,
"refresh revoke is not an immediate deny-list for a stateless access JWT",
);
await page.reload();
await page.locator('[data-authenticated="false"]').waitFor();
assert.equal(
await page.evaluate(
(token) => JSON.stringify({
localStorage: Object.values(localStorage),
sessionStorage: Object.values(sessionStorage),
}).includes(token),
thirdTokenSet.access_token,
),
false,
);
console.log(
"pattern1 verified: PKCE, aud/iss negatives, memory/XSS boundary, logout, RT rotation/reuse, revoke-vs-stateless JWT"
+ ` (RT2 after RT1 reuse: ${rotatedAfterReuseStatus})`,
);
} finally {
await browser.close();
}
+16
View File
@@ -0,0 +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
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>AP1 · SPA Direct + Resource Server</title>
<style>
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
body {
max-width: 56rem;
margin: 6vh auto;
padding: 0 1.5rem;
line-height: 1.55;
}
button {
margin: 0 0.5rem 0.5rem 0;
padding: 0.6rem 0.9rem;
cursor: pointer;
}
code, pre {
border-radius: 0.35rem;
background: color-mix(in srgb, CanvasText 9%, Canvas);
}
code {
padding: 0.1rem 0.3rem;
}
pre {
min-height: 8rem;
padding: 1rem;
overflow: auto;
white-space: pre-wrap;
}
.notice {
border-left: 0.3rem solid #e09f3e;
padding-left: 1rem;
}
</style>
</head>
<body>
<main>
<h1>AP1 · SPA Direct + Resource Server</h1>
<p>
바닐라 JavaScript SPA가 <code>spa-public</code> client로 Authorization
Code + PKCE를 수행하고, access token을 직접 Spring Resource Server에
전달합니다.
</p>
<p class="notice">
access/refresh token은 메모리에만 존재합니다. 새로고침하면 사라지는 것이
이 패턴의 의도된 보안 경계입니다.
</p>
<section>
<button id="login" type="button">Keycloak 로그인</button>
<button id="call-api" type="button" disabled>보호 API 호출</button>
<button id="pkce-demo" type="button">수동 PKCE 생성</button>
<button id="logout" type="button" disabled>로그아웃</button>
</section>
<p id="session-state" data-authenticated="false">세션 확인 중…</p>
<pre id="result" aria-live="polite"></pre>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://app:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
add_header Cache-Control "no-store";
try_files $uri $uri/ /index.html;
}
}
+523
View File
@@ -0,0 +1,523 @@
{
"name": "keycloak-pattern1-spa",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "keycloak-pattern1-spa",
"version": "1.0.0",
"dependencies": {
"oidc-client-ts": "3.5.0"
},
"devDependencies": {
"esbuild": "0.28.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/oidc-client-ts": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz",
"integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "keycloak-pattern1-spa",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "esbuild src/app.js --bundle --format=esm --outfile=dist/app.js --minify --sourcemap",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"oidc-client-ts": "3.5.0"
},
"devDependencies": {
"esbuild": "0.28.1"
}
}
+131
View File
@@ -0,0 +1,131 @@
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));
try {
const callbackUser = await finishSigninCallback();
renderSession(callbackUser ?? await userManager.getUser());
} catch (error) {
renderSession(null);
renderJson({ error: error.message });
}
+26
View File
@@ -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",
};
}
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { webcrypto } from "node:crypto";
import test from "node:test";
globalThis.btoa = (value) => Buffer.from(value, "binary").toString("base64");
const { createPkcePair } = await import("../src/pkce.js");
test("manual PKCE helper creates an RFC 7636 S256 pair", async () => {
const pair = await createPkcePair(webcrypto);
assert.equal(pair.method, "S256");
assert.equal(pair.verifier.length, 43);
assert.equal(pair.challenge.length, 43);
assert.match(pair.verifier, /^[A-Za-z0-9_-]+$/u);
assert.match(pair.challenge, /^[A-Za-z0-9_-]+$/u);
assert.notEqual(pair.verifier, pair.challenge);
});
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,180 @@
{
"realm": "keycloak-patterns",
"displayName": "Keycloak Authentication Patterns",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"bruteForceProtected": true,
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 2592000,
"revokeRefreshToken": true,
"refreshTokenMaxReuse": 0,
"roles": {
"realm": [
{
"name": "admin-role",
"description": "Administrative role used by authorization examples"
},
{
"name": "user-role",
"description": "Regular authenticated user role"
}
]
},
"clients": [
{
"clientId": "spa-public",
"name": "AP1 SPA Public Client",
"description": "Browser-based OAuth client using Authorization Code and PKCE",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"redirectUris": [
"http://localhost:8088/*",
"http://127.0.0.1:8088/*"
],
"webOrigins": [
"http://localhost:8088",
"http://127.0.0.1:8088"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8088/*##http://127.0.0.1:8088/*"
},
"protocolMappers": [
{
"name": "keycloak-pattern-api-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "keycloak-pattern-api",
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false",
"introspection.token.claim": "true"
}
}
]
},
{
"clientId": "token-mediating-confidential",
"name": "AP2 Token-Mediating Backend",
"description": "Confidential backend that keeps refresh tokens server-side",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"secret": "${TOKEN_MEDIATING_CLIENT_SECRET}",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"redirectUris": [
"http://localhost:8082/login/oauth2/code/keycloak"
],
"webOrigins": [
"http://localhost:8082"
],
"attributes": {
"post.logout.redirect.uris": "http://localhost:8082/*"
}
},
{
"clientId": "bff-confidential",
"name": "AP3 Backend-for-Frontend",
"description": "Confidential BFF that keeps all OAuth tokens server-side",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"secret": "${BFF_CLIENT_SECRET}",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"redirectUris": [
"http://localhost:8083/login/oauth2/code/keycloak"
],
"webOrigins": [
"http://localhost:8083"
],
"attributes": {
"post.logout.redirect.uris": "http://localhost:8083/*"
}
},
{
"clientId": "edge-proxy",
"name": "AP4 Edge Forward Auth",
"description": "Confidential oauth2-proxy OIDC client",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"secret": "${EDGE_PROXY_CLIENT_SECRET}",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"redirectUris": [
"http://localhost:4180/oauth2/callback"
],
"webOrigins": [],
"attributes": {
"post.logout.redirect.uris": "http://localhost:8088/*"
}
}
],
"users": [
{
"username": "admin-user",
"enabled": true,
"email": "admin-user@example.test",
"emailVerified": true,
"firstName": "Admin",
"lastName": "User",
"realmRoles": [
"admin-role"
],
"credentials": [
{
"type": "password",
"value": "${ADMIN_USER_PASSWORD}",
"temporary": false
}
]
},
{
"username": "regular-user",
"enabled": true,
"email": "regular-user@example.test",
"emailVerified": true,
"firstName": "Regular",
"lastName": "User",
"realmRoles": [
"user-role"
],
"credentials": [
{
"type": "password",
"value": "${REGULAR_USER_PASSWORD}",
"temporary": false
}
]
}
]
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env sh
set -eu
compose_file=${COMPOSE_FILE:-docker-compose.yml}
output_dir=${REALM_EXPORT_DIR:-"$(pwd)/build/keycloak-export"}
output_file="$output_dir/keycloak-patterns-realm.json"
mkdir -p "$output_dir"
restart_keycloak() {
trap - EXIT INT TERM
docker compose -f "$compose_file" up -d --wait >/dev/null
}
trap restart_keycloak EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
docker compose -f "$compose_file" stop keycloak >/dev/null
docker compose -f "$compose_file" run --rm --no-deps \
--volume "$output_dir:/opt/keycloak/data/export" \
keycloak \
export \
--realm keycloak-patterns \
--file /opt/keycloak/data/export/keycloak-patterns-realm.json \
--users same_file
chmod 600 "$output_file"
python3 scripts/validate-realm.py --runtime "$output_file"
echo "runtime realm export written to $output_file"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Validate the committed realm template or a runtime Keycloak CLI export."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
REALM_NAME = "keycloak-patterns"
PUBLIC_CLIENT = "spa-public"
CONFIDENTIAL_CLIENTS = {
"token-mediating-confidential": "${TOKEN_MEDIATING_CLIENT_SECRET}",
"bff-confidential": "${BFF_CLIENT_SECRET}",
"edge-proxy": "${EDGE_PROXY_CLIENT_SECRET}",
}
EXPECTED_ROLES = {"admin-role", "user-role"}
EXPECTED_USERS = {"admin-user", "regular-user"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument(
"realm_file",
nargs="?",
type=Path,
default=Path("keycloak/import/keycloak-patterns-realm.json"),
)
parser.add_argument(
"--runtime",
action="store_true",
help="validate an expanded CLI export instead of the committed template",
)
return parser.parse_args()
def require(condition: bool, message: str) -> None:
if not condition:
raise SystemExit(f"realm validation failed: {message}")
def indexed(items: list[dict[str, Any]], key: str) -> dict[str, dict[str, Any]]:
return {str(item[key]): item for item in items}
def validate(path: Path, runtime: bool) -> None:
document = json.loads(path.read_text(encoding="utf-8"))
require(document.get("realm") == REALM_NAME, f"realm must be {REALM_NAME}")
require(document.get("enabled") is True, "realm must be enabled")
require(document.get("accessTokenLifespan") == 300, "access token TTL must be 300s")
require(document.get("revokeRefreshToken") is True, "refresh token rotation must be enabled")
require(document.get("refreshTokenMaxReuse") == 0, "refresh token max reuse must be 0")
clients = indexed(document.get("clients", []), "clientId")
expected_client_ids = {PUBLIC_CLIENT, *CONFIDENTIAL_CLIENTS}
require(expected_client_ids <= clients.keys(), "all four pattern clients must exist")
if not runtime:
require(clients.keys() == expected_client_ids, "template must declare exactly four clients")
spa = clients[PUBLIC_CLIENT]
require(spa.get("publicClient") is True, "spa-public must be a public client")
require("secret" not in spa, "spa-public must not have a client secret")
require(spa.get("standardFlowEnabled") is True, "spa-public standard flow must be enabled")
require(
spa.get("directAccessGrantsEnabled") is False,
"spa-public direct access grants must be disabled",
)
require(
spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256",
"spa-public must enforce PKCE S256",
)
audience_mappers = [
mapper
for mapper in spa.get("protocolMappers", [])
if mapper.get("protocolMapper") == "oidc-audience-mapper"
]
if not runtime:
require(len(audience_mappers) == 1, "spa-public must declare one audience mapper")
require(
audience_mappers[0].get("config", {}).get("included.custom.audience")
== "keycloak-pattern-api",
"spa-public access token must target keycloak-pattern-api",
)
for client_id, placeholder in CONFIDENTIAL_CLIENTS.items():
client = clients[client_id]
require(client.get("publicClient") is False, f"{client_id} must be confidential")
require(
client.get("clientAuthenticatorType") == "client-secret",
f"{client_id} must use client-secret authentication",
)
secret = client.get("secret")
if runtime:
require(isinstance(secret, str) and len(secret) >= 16, f"{client_id} secret missing")
require(not secret.startswith("${"), f"{client_id} placeholder was not resolved")
else:
require(secret == placeholder, f"{client_id} must use an env placeholder")
roles = {
role["name"]
for role in document.get("roles", {}).get("realm", [])
if "name" in role
}
require(EXPECTED_ROLES <= roles, "admin-role and user-role must exist")
users = indexed(document.get("users", []), "username")
require(EXPECTED_USERS <= users.keys(), "both baseline users must exist")
if not runtime:
expected_passwords = {
"admin-user": "${ADMIN_USER_PASSWORD}",
"regular-user": "${REGULAR_USER_PASSWORD}",
}
for username, placeholder in expected_passwords.items():
credentials = users[username].get("credentials", [])
require(len(credentials) == 1, f"{username} must have one initial credential")
require(
credentials[0].get("value") == placeholder,
f"{username} password must use an env placeholder",
)
print(
f"realm validated: {REALM_NAME}, four pattern clients, "
f"{len(EXPECTED_ROLES)} roles, {len(EXPECTED_USERS)} users"
)
if __name__ == "__main__":
arguments = parse_args()
validate(arguments.realm_file, arguments.runtime)
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env; copy .env.example and set local-only values" >&2
exit 1
fi
set -a
. ./.env
set +a
docker compose down --volumes --remove-orphans
docker compose up --build -d --wait
docker compose --profile diagnostics up -d --wait \
app-wrong-audience \
app-wrong-issuer
npm --prefix e2e ci
E2E_USERNAME=regular-user \
E2E_PASSWORD="$REGULAR_USER_PASSWORD" \
WRONG_AUDIENCE_URL=http://localhost:18081/api/me \
WRONG_ISSUER_URL=http://localhost:18082/api/me \
npm --prefix e2e run test:pattern1
echo "AP1 verified end to end"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env sh
set -eu
compose_file=${COMPOSE_FILE:-docker-compose.yml}
python3 scripts/validate-realm.py
./scripts/verify-stack.sh
curl --fail --silent --show-error --output /dev/null \
"http://localhost:8080/realms/keycloak-patterns/.well-known/openid-configuration"
docker compose -f "$compose_file" exec -T keycloak sh -ec '
kcadm=/opt/keycloak/bin/kcadm.sh
"$kcadm" config credentials \
--server http://localhost:8080 \
--realm master \
--user "$KC_BOOTSTRAP_ADMIN_USERNAME" \
--password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null
clients=$("$kcadm" get clients -r keycloak-patterns --fields clientId)
for client in \
spa-public \
token-mediating-confidential \
bff-confidential \
edge-proxy
do
printf "%s" "$clients" | grep -q "\"$client\""
done
users=$("$kcadm" get users -r keycloak-patterns --fields username)
printf "%s" "$users" | grep -q "\"admin-user\""
printf "%s" "$users" | grep -q "\"regular-user\""
roles=$("$kcadm" get roles -r keycloak-patterns --fields name)
printf "%s" "$roles" | grep -q "\"admin-role\""
printf "%s" "$roles" | grep -q "\"user-role\""
'
echo "realm verified: import, discovery, four clients, two roles, two users"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env sh
set -eu
compose_file=${COMPOSE_FILE:-docker-compose.yml}
nginx_port=${NGINX_PORT:-8088}
wait_for_url() {
name=$1
url=$2
attempts=30
while [ "$attempts" -gt 0 ]; do
if curl --fail --silent --show-error --output /dev/null "$url"; then
return 0
fi
attempts=$((attempts - 1))
sleep 2
done
echo "$name did not become ready: $url" >&2
return 1
}
assert_healthy() {
service=$1
attempts=30
while [ "$attempts" -gt 0 ]; do
container_id=$(docker compose -f "$compose_file" ps --quiet "$service")
if [ -n "$container_id" ]; then
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")
if [ "$health" = "healthy" ]; then
return 0
fi
else
health="not-running"
fi
attempts=$((attempts - 1))
sleep 2
done
echo "$service is not healthy: $health" >&2
return 1
}
for service in postgres keycloak app nginx; do
assert_healthy "$service"
done
wait_for_url "Keycloak discovery" \
"http://localhost:8080/realms/master/.well-known/openid-configuration"
wait_for_url "Spring Boot health" "http://localhost:8081/actuator/health"
wait_for_url "nginx health" "http://localhost:${nginx_port}/health"
wait_for_url "public API" "http://localhost:${nginx_port}/api/public"
protected_status=$(curl --silent --output /dev/null --write-out '%{http_code}' \
"http://localhost:${nginx_port}/api/me")
if [ "$protected_status" != "401" ]; then
echo "expected unauthenticated /api/me to return 401, got $protected_status" >&2
exit 1
fi
echo "baseline stack verified: 4 healthy services, public 200, protected 401"