Compare commits

..
57 changed files with 1342 additions and 1661 deletions
-7
View File
@@ -10,15 +10,8 @@ POSTGRES_PASSWORD=change-me-postgres-password
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
MOCK_GOOGLE_BROKER_CLIENT_SECRET=change-me-mock-google-broker-client-secret
ADMIN_USER_PASSWORD=change-me-admin-user-password
REGULAR_USER_PASSWORD=change-me-regular-user-password
MOCK_GOOGLE_USER_PASSWORD=change-me-mock-google-user-password
# Optional real-Google profile. These are consumed only by
# scripts/configure-google-idp.sh and must never be committed with real values.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Port 80 is the single-EC2 target. 8088 avoids common local port conflicts.
NGINX_PORT=8088
+2 -4
View File
@@ -4,8 +4,6 @@
*.iml
backend/target/
build/
e2e/node_modules/
google-e2e/node_modules/
frontend/node_modules/
**/node_modules/
frontend/dist/
build/
+36 -7
View File
@@ -1,12 +1,5 @@
# Keycloak Authentication Patterns
The 39-branch implementation registry is documented in
[`docs/keycloak-branch-index.md`](docs/keycloak-branch-index.md).
Google brokering has a credential-free local OIDC harness and an opt-in
real-Google profile described in
[`docs/google-idp-brokering.md`](docs/google-idp-brokering.md).
Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은 로컬
인프라에서 비교하는 학습 프로젝트입니다.
@@ -103,3 +96,39 @@ 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를 검증합니다.
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 동작을 검증합니다.
@@ -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;
}
}
@@ -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;
}
}
@@ -11,6 +11,9 @@ spring:
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:
@@ -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)
);
}
}
@@ -1,6 +0,0 @@
# Keycloak receives HTTP only from the trusted reverse proxy.
KC_HTTP_ENABLED=true
KC_PROXY_HEADERS=xforwarded
KC_HOSTNAME=https://auth.example.test
KC_HOSTNAME_STRICT=true
-14
View File
@@ -1,14 +0,0 @@
server {
listen 8080;
server_name auth.example.test;
location / {
proxy_pass http://keycloak:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
-10
View File
@@ -1,10 +0,0 @@
auth.example.test {
tls /etc/tls/tls.crt /etc/tls/tls.key
reverse_proxy keycloak:8080 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Port 443
header_up X-Forwarded-Proto https
}
}
-21
View File
@@ -1,21 +0,0 @@
events {}
http {
server {
listen 443 ssl;
server_name auth.example.test;
ssl_certificate /etc/tls/tls.crt;
ssl_certificate_key /etc/tls/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
+52 -2
View File
@@ -38,10 +38,8 @@ services:
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}
MOCK_GOOGLE_BROKER_CLIENT_SECRET: ${MOCK_GOOGLE_BROKER_CLIENT_SECRET:?set MOCK_GOOGLE_BROKER_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}
MOCK_GOOGLE_USER_PASSWORD: ${MOCK_GOOGLE_USER_PASSWORD:?set MOCK_GOOGLE_USER_PASSWORD in .env}
ports:
- "127.0.0.1:8080:8080"
volumes:
@@ -88,6 +86,58 @@ services:
- 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
-18
View File
@@ -1,18 +0,0 @@
# Federated account key: `sub`, not email
외부 IdP의 email은 표시·연락 속성이지 계정 식별자나 자동 연결 증명이 아니다.
Keycloak의 federated identity는 provider alias와 provider user ID(`sub`)를
로컬 사용자에 연결한다.
정책:
- 신규 identity의 email이 기존 로컬 계정과 충돌하면 기존 계정의 인증을 다시
요구하는 기본 First Broker Login flow를 사용한다.
- `Automatically Set Existing User`를 production flow에 넣지 않는다.
- upstream email 변경은 같은 `sub`의 계정 귀속을 바꾸지 않는다.
- 마지막 로그인 수단을 unlink하는 UI에서는 먼저 다른 인증 수단을 등록하도록
안내한다.
`verify-account-linking-sub-vs-email.sh`는 mock IdP 사용자의 email을 실제로
변경하고 다시 로그인한다. 로컬 사용자 ID가 유지되고 federated `userId`
upstream `sub`와 같은지 확인한 후 원래 email을 복구한다.
+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에는 여전히 노출”이다.
-27
View File
@@ -1,27 +0,0 @@
# First Broker Login security
Keycloak 26.7.0's built-in `first broker login` flow does **not** silently
auto-link by email. It contains:
- `Create User If Unique`
- `Handle Existing Account`
- `Confirm link existing account`
- email verification or re-authentication ownership proof
`Automatically set existing user` is an explicit, dangerous opt-in. The local
acceptance harness copies the built-in flow, enables AutoLink, disables the
ownership-proof branch, and signs in through a controllable OIDC account whose
email collides with `regular-user`. It verifies that the external identity is
attached without proof. The harness then assigns the original built-in flow,
repeats the login, observes the existing-account confirmation page, and verifies
that no federated identity was attached.
Run after the stack is healthy:
```bash
./scripts/verify-first-broker-login.sh
```
The vulnerable flow remains only as a disabled learning artifact. The
`mock-google` provider is always returned to the secure built-in flow at the end
of the verification.
-23
View File
@@ -1,23 +0,0 @@
# Google claim and identity mapping
The broker uses the upstream OIDC `sub` as the stable federated identity key.
Email is a mutable profile attribute and is never the external identity key.
The default mapping policy is:
| Upstream claim | Keycloak target |
|---|---|
| `sub` | stable username `${ALIAS}.${CLAIM.sub}` and federated identity ID |
| `email` | email |
| `given_name` | first name |
| `family_name` | last name |
| `picture` | custom `picture` attribute |
| `hd` | custom `hd` attribute |
The Identity Provider uses `syncMode=IMPORT`: profile values are imported on
first login and later local edits are not overwritten on every login. `FORCE`
is an explicit alternative when upstream freshness is more important.
`./scripts/verify-google-claim-mapping.sh` signs in through the controllable
OIDC realm and verifies the resulting Keycloak user, custom attributes, stable
subject-derived username, and federated identity record.
-18
View File
@@ -1,18 +0,0 @@
# Google claim-to-role mapping
`hd=example.test`인 upstream OIDC identity에는 Keycloak realm role
`employee-role`을 부여한다. 매핑 키는 email이 아니라 Google subject이며,
role 조건에 쓰는 `hd` claim은 mock provider와 실제 Google provider에서 같은
계약을 사용한다.
Realm import는 `oidc-role-idp-mapper`를 선언한다. 실제 Google 설정 스크립트도
같은 mapper를 upsert한다. 따라서 재실행해도 mapper가 중복되지 않는다.
검증:
```sh
./scripts/verify-google-claim-to-role.sh
```
검증기는 mock Google 로그인, Authorization Code + PKCE 교환, 최종 Keycloak
access token의 `realm_access.roles`를 차례로 확인한다.
-28
View File
@@ -1,28 +0,0 @@
# Google IdP brokering
Keycloak is the only issuer trusted by AP1AP4. Google is an upstream Identity
Provider; applications do not receive or validate a Google token.
## Two verification profiles
The default local profile imports a second Keycloak realm named `mock-google`.
It acts as a controllable OIDC provider and allows tests to choose claims such
as a duplicate email, `email_verified=false`, `hd`, and `picture`. This is the
safe way to reproduce an unsafe email auto-link without impersonating a real
Google account.
The real-Google profile is configured explicitly:
1. Create a Google OAuth **Web application**.
2. Register the exact redirect URI printed by
`./scripts/configure-google-idp.sh`.
3. Put `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in ignored `.env`.
4. Start the stack and run the configuration script.
The script writes `providerId=google`, `trustEmail=false`, minimal
`openid profile email` scopes, and `syncMode=IMPORT` through the Keycloak Admin
API. Credentials are never written to the realm export or repository.
Google requires a public HTTPS redirect for non-local deployments. Local mock
verification proves the Keycloak brokering boundary; a real Google login is a
separate credentialed acceptance profile.
-24
View File
@@ -1,24 +0,0 @@
# Google redirect URI policy
Google에 등록하는 redirect URI는 애플리케이션 SPA callback이 아니라 Keycloak
broker endpoint다.
```text
https://auth.example.test/realms/keycloak-patterns/broker/google/endpoint
```
규칙:
- production URI는 HTTPS와 고정된 public Keycloak origin을 사용한다.
- wildcard, path prefix, 임시 tunnel hostname을 production OAuth client에
등록하지 않는다.
- 개발·스테이징·운영은 Google OAuth client를 분리한다.
- reverse proxy가 있더라도 Google이 보는 URI와 Keycloak이 생성하는 URI가
byte-for-byte 같아야 한다.
- `configure-google-idp.sh`가 출력하는 URI를 Google Console의 Authorized
redirect URI와 대조한다.
```sh
PUBLIC_KEYCLOAK_URL=https://auth.example.test \
./scripts/verify-google-redirect-uri-policy.sh
```
-20
View File
@@ -1,20 +0,0 @@
# HTTPS termination: nginx or Caddy
두 예제 모두 public `443`에서 TLS를 종료하고 private Docker network의
`keycloak:8080`으로 전달한다. Keycloak 쪽 설정은
`deploy/reverse-proxy/keycloak.env.example`의 hostname/proxy contract를
같이 사용한다.
- nginx: 인증서 배포·갱신을 운영자가 담당할 때 적합하다.
- Caddy: ACME를 통한 인증서 수명주기를 proxy가 담당하게 할 때 간단하다.
- 둘을 동시에 production entry point로 띄우지 않는다.
- 인증서와 private key는 repository 또는 image에 포함하지 않는다.
- HTTP challenge/redirect 및 방화벽의 80/443 허용은 배포 환경에서 별도로
결정한다.
검증 스크립트는 임시 자체 서명 인증서를 만들고 두 vendor image에서 설정을
각각 validate한 뒤 임시 파일을 제거한다.
```sh
./scripts/verify-https-termination-config.sh
```
-29
View File
@@ -1,29 +0,0 @@
# Keycloak branch implementation index
The source inventory contains 39 `feature-keycloak-*.md` branch notes. This
repository preserves one local Git feature branch for every note and merges it
with `--no-ff` into either the common `develop` baseline or one of the four
authentication-pattern branches.
| Target | Meaning |
|---|---|
| `common` | Shared realm, federation, deployment, or governance contract. Merge into `develop`, then propagate to AP1AP4. |
| `ap1` | Browser-based OAuth client: vanilla SPA, Authorization Code + PKCE, Resource Server. |
| `ap2` | Token-mediating confidential backend: browser receives access token only. |
| `ap3` | BFF: backend owns every OAuth token and browser owns only a session cookie. |
| `ap4` | Edge forward-auth: oauth2-proxy/Nginx owns login and backend trusts an isolated identity header. |
The machine-readable registry is
[`keycloak-branch-manifest.tsv`](keycloak-branch-manifest.tsv). Run:
```bash
./scripts/audit-keycloak-branches.sh
```
The audit succeeds only when all 39 note names have matching local feature
branches and each feature tip is reachable from its declared target branch.
Google credentials are never committed. The default local acceptance harness
uses a second Keycloak realm as a controllable OIDC provider so claim mapping
and unsafe-linking failure paths can be reproduced. A real Google login remains
an explicit credentialed/public-HTTPS verification profile.
-40
View File
@@ -1,40 +0,0 @@
branch target delivery
feature/keycloak-account-linking-spa-ux ap1 documented-and-contract-tested
feature/keycloak-account-linking-sub-vs-email common documented-and-contract-tested
feature/keycloak-bff-csrf-samesite-defense ap3 locally-verified
feature/keycloak-bff-oauth2login-session ap3 locally-verified
feature/keycloak-bff-vs-spa-direct ap3 documented
feature/keycloak-docker-compose-stack common locally-verified
feature/keycloak-edge-forwardauth-google-federation ap4 documented-and-config-tested
feature/keycloak-edge-forwardauth-no-google ap4 documented-and-config-tested
feature/keycloak-federation-spa-zero-change ap1 contract-tested
feature/keycloak-first-broker-login-flow common locally-verified-with-mock-idp
feature/keycloak-four-pattern-tradeoff-matrix common documented-and-evidence-linked
feature/keycloak-google-claim-attribute-mapping common locally-verified-with-mock-idp
feature/keycloak-google-redirect-uri-policy common config-tested
feature/keycloak-header-spoofing-defense ap4 locally-verified
feature/keycloak-https-termination-caddy-nginx common config-tested
feature/keycloak-idp-brokering-google-client common locally-verified-with-mock-idp
feature/keycloak-idp-mappers-claim-to-role common locally-verified-with-mock-idp
feature/keycloak-internal-spa-direct-google-federation ap1 documented-and-contract-tested
feature/keycloak-internal-spa-direct-no-google ap1 documented-and-contract-tested
feature/keycloak-iss-claim-hostname-mismatch ap1 locally-verified
feature/keycloak-nginx-auth-request-integration ap4 locally-verified
feature/keycloak-oauth2-proxy-oidc-flow ap4 locally-verified
feature/keycloak-patterns common governance
feature/keycloak-pkce-flow-stages ap1 contract-tested
feature/keycloak-public-domain-tunneling common config-tested
feature/keycloak-realm-client-export common locally-verified
feature/keycloak-refresh-rotation-and-logout ap1 locally-verified
feature/keycloak-refresh-token-rotation ap1 contract-tested
feature/keycloak-reverse-proxy-headers common config-tested
feature/keycloak-single-ec2-google-federation ap1 documented-and-config-tested
feature/keycloak-single-ec2-no-google ap1 documented-and-contract-tested
feature/keycloak-spa-token-storage-tradeoff ap1 locally-verified
feature/keycloak-spring-rs-audience-validator ap1 locally-verified
feature/keycloak-spring-rs-role-mapping ap1 locally-verified
feature/keycloak-three-leg-trust-chain ap1 documented-and-contract-tested
feature/keycloak-token-mediating-access-handoff ap2 locally-verified
feature/keycloak-token-mediating-confidential-client ap2 locally-verified
feature/keycloak-traefik-forwardauth-alternative ap4 config-tested
feature/keycloak-vanilla-js-spa-pkce ap1 locally-verified
1 branch target delivery
2 feature/keycloak-account-linking-spa-ux ap1 documented-and-contract-tested
3 feature/keycloak-account-linking-sub-vs-email common documented-and-contract-tested
4 feature/keycloak-bff-csrf-samesite-defense ap3 locally-verified
5 feature/keycloak-bff-oauth2login-session ap3 locally-verified
6 feature/keycloak-bff-vs-spa-direct ap3 documented
7 feature/keycloak-docker-compose-stack common locally-verified
8 feature/keycloak-edge-forwardauth-google-federation ap4 documented-and-config-tested
9 feature/keycloak-edge-forwardauth-no-google ap4 documented-and-config-tested
10 feature/keycloak-federation-spa-zero-change ap1 contract-tested
11 feature/keycloak-first-broker-login-flow common locally-verified-with-mock-idp
12 feature/keycloak-four-pattern-tradeoff-matrix common documented-and-evidence-linked
13 feature/keycloak-google-claim-attribute-mapping common locally-verified-with-mock-idp
14 feature/keycloak-google-redirect-uri-policy common config-tested
15 feature/keycloak-header-spoofing-defense ap4 locally-verified
16 feature/keycloak-https-termination-caddy-nginx common config-tested
17 feature/keycloak-idp-brokering-google-client common locally-verified-with-mock-idp
18 feature/keycloak-idp-mappers-claim-to-role common locally-verified-with-mock-idp
19 feature/keycloak-internal-spa-direct-google-federation ap1 documented-and-contract-tested
20 feature/keycloak-internal-spa-direct-no-google ap1 documented-and-contract-tested
21 feature/keycloak-iss-claim-hostname-mismatch ap1 locally-verified
22 feature/keycloak-nginx-auth-request-integration ap4 locally-verified
23 feature/keycloak-oauth2-proxy-oidc-flow ap4 locally-verified
24 feature/keycloak-patterns common governance
25 feature/keycloak-pkce-flow-stages ap1 contract-tested
26 feature/keycloak-public-domain-tunneling common config-tested
27 feature/keycloak-realm-client-export common locally-verified
28 feature/keycloak-refresh-rotation-and-logout ap1 locally-verified
29 feature/keycloak-refresh-token-rotation ap1 contract-tested
30 feature/keycloak-reverse-proxy-headers common config-tested
31 feature/keycloak-single-ec2-google-federation ap1 documented-and-config-tested
32 feature/keycloak-single-ec2-no-google ap1 documented-and-contract-tested
33 feature/keycloak-spa-token-storage-tradeoff ap1 locally-verified
34 feature/keycloak-spring-rs-audience-validator ap1 locally-verified
35 feature/keycloak-spring-rs-role-mapping ap1 locally-verified
36 feature/keycloak-three-leg-trust-chain ap1 documented-and-contract-tested
37 feature/keycloak-token-mediating-access-handoff ap2 locally-verified
38 feature/keycloak-token-mediating-confidential-client ap2 locally-verified
39 feature/keycloak-traefik-forwardauth-alternative ap4 config-tested
40 feature/keycloak-vanilla-js-spa-pkce ap1 locally-verified
-15
View File
@@ -1,15 +0,0 @@
# Reverse proxy headers
TLS를 reverse proxy에서 종료하면 Keycloak은 브라우저가 사용한 외부 origin을
정확히 알아야 한다. 배포 예제는 다음 계약을 함께 적용한다.
- nginx는 `Host`, `X-Forwarded-Host`, `X-Forwarded-Port`,
`X-Forwarded-Proto`, `X-Forwarded-For`를 덮어쓴다.
- Keycloak은 `KC_PROXY_HEADERS=xforwarded`로 그 헤더 형식을 명시한다.
- `KC_HOSTNAME`은 외부 HTTPS URL로 고정하고 strict hostname 검증을 켠다.
- Keycloak의 8080 포트는 public으로 publish하지 않고 proxy network에서만
접근시킨다. 신뢰되지 않은 클라이언트가 forwarded header를 직접 넣을 수
있으면 안 된다.
`scripts/verify-reverse-proxy-headers.sh`는 양쪽 설정의 짝과 nginx 구문을
검증한다.
+9 -8
View File
@@ -1,26 +1,27 @@
{
"name": "keycloak-google-broker-e2e",
"name": "keycloak-pattern-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "keycloak-google-broker-e2e",
"name": "keycloak-pattern-e2e",
"version": "1.0.0",
"dependencies": {
"playwright-core": "1.55.1"
"devDependencies": {
"playwright-core": "1.62.0"
}
},
"node_modules/playwright-core": {
"version": "1.55.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
"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": ">=18"
"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();
}
+12
View File
@@ -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
+46 -11
View File
@@ -3,31 +3,66 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keycloak Authentication Patterns</title>
<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: 48rem;
margin: 8vh auto;
max-width: 56rem;
margin: 6vh auto;
padding: 0 1.5rem;
line-height: 1.6;
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.15rem 0.35rem;
border-radius: 0.25rem;
background: color-mix(in srgb, CanvasText 10%, Canvas);
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>
<h1>Keycloak Authentication Patterns</h1>
<p>공통 Docker Compose baseline이 실행 중입니다.</p>
<main>
<h1>AP1 · SPA Direct + Resource Server</h1>
<p>
공개 API는 <code>/api/public</code>, 보호 API는
<code>/api/me</code>에서 확인할 수 있습니다.
바닐라 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>
+1
View File
@@ -21,6 +21,7 @@ server {
}
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);
});
-123
View File
@@ -1,123 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const keycloakUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
const adminUsername = process.env.KC_BOOTSTRAP_ADMIN_USERNAME;
const adminPassword = process.env.KC_BOOTSTRAP_ADMIN_PASSWORD;
const mockPassword = process.env.MOCK_GOOGLE_USER_PASSWORD;
assert.ok(adminUsername && adminPassword && mockPassword);
async function adminToken() {
const response = await fetch(
`${keycloakUrl}/realms/master/protocol/openid-connect/token`,
{
method: "POST",
body: new URLSearchParams({
client_id: "admin-cli",
grant_type: "password",
username: adminUsername,
password: adminPassword,
}),
},
);
assert.equal(response.status, 200);
return (await response.json()).access_token;
}
async function usersByEmail(token) {
const response = await fetch(
`${keycloakUrl}/admin/realms/keycloak-patterns/users?email=${encodeURIComponent(
"broker-new-user@example.test",
)}&exact=true`,
{ headers: { Authorization: `Bearer ${token}` } },
);
assert.equal(response.status, 200);
return response.json();
}
async function userById(token, userId) {
const response = await fetch(
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${userId}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
assert.equal(response.status, 200);
return response.json();
}
async function removePreviousUser(token) {
for (const user of await usersByEmail(token)) {
const response = await fetch(
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${user.id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
assert.equal(response.status, 204);
}
}
async function brokerLogin(page) {
const url = new URL(
`${keycloakUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
);
url.search = new URLSearchParams({
client_id: "spa-public",
redirect_uri: "http://localhost:8088/",
response_type: "code",
scope: "openid profile email",
state: crypto.randomUUID(),
nonce: crypto.randomUUID(),
code_challenge: "K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI",
code_challenge_method: "S256",
kc_idp_hint: "mock-google",
prompt: "login",
});
await page.goto(url.toString());
await page.waitForURL(/\/realms\/mock-google\//u);
await page.locator("#username").fill("mock-new-user");
await page.locator("#password").fill(mockPassword);
await page.locator("#kc-login").click();
await page.waitForURL(/localhost:8088\/\?.*code=/u);
}
const token = await adminToken();
await removePreviousUser(token);
const browser = await chromium.launch({
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
headless: true,
args: ["--no-sandbox"],
});
try {
const page = await browser.newPage();
await brokerLogin(page);
const users = await usersByEmail(token);
assert.equal(users.length, 1);
const user = await userById(token, users[0].id);
assert.match(user.username, /^mock-google\.[0-9a-f-]+$/u);
assert.equal(user.firstName, "Broker");
assert.equal(user.lastName, "New");
assert.deepEqual(user.attributes.picture, [
"https://images.example.test/mock-user.png",
]);
assert.deepEqual(user.attributes.hd, ["example.test"]);
const identitiesResponse = await fetch(
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${user.id}/federated-identity`,
{ headers: { Authorization: `Bearer ${token}` } },
);
assert.equal(identitiesResponse.status, 200);
const identities = await identitiesResponse.json();
assert.equal(identities.length, 1);
assert.equal(identities[0].identityProvider, "mock-google");
assert.ok(identities[0].userId);
console.log(
"Google claim mapping verified: stable sub username, profile attributes, federated identity",
);
} finally {
await browser.close();
}
-68
View File
@@ -1,68 +0,0 @@
import assert from "node:assert/strict";
import { createHash, randomBytes } from "node:crypto";
import { chromium } from "playwright-core";
const keycloakUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
const password = process.env.MOCK_GOOGLE_USER_PASSWORD;
assert.ok(password);
const verifier = randomBytes(48).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const redirectUri = "http://localhost:8088/";
const browser = await chromium.launch({
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
headless: true,
args: ["--no-sandbox"],
});
try {
const page = await browser.newPage();
const authorizationUrl = new URL(
`${keycloakUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
);
authorizationUrl.search = new URLSearchParams({
client_id: "spa-public",
redirect_uri: redirectUri,
response_type: "code",
scope: "openid profile email",
state: crypto.randomUUID(),
nonce: crypto.randomUUID(),
code_challenge: challenge,
code_challenge_method: "S256",
kc_idp_hint: "mock-google",
prompt: "login",
});
await page.goto(authorizationUrl.toString());
await page.waitForURL(/\/realms\/mock-google\//u);
await page.locator("#username").fill("mock-new-user");
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(/localhost:8088\/\?.*code=/u);
const code = new URL(page.url()).searchParams.get("code");
assert.ok(code);
const response = await fetch(
`${keycloakUrl}/realms/keycloak-patterns/protocol/openid-connect/token`,
{
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: "spa-public",
redirect_uri: redirectUri,
code,
code_verifier: verifier,
}),
},
);
assert.equal(response.status, 200);
const tokens = await response.json();
const payload = JSON.parse(
Buffer.from(tokens.access_token.split(".")[1], "base64url").toString(),
);
assert.ok(payload.realm_access.roles.includes("employee-role"));
console.log("Claim-to-role verified: hd=example.test -> employee-role");
} finally {
await browser.close();
}
-124
View File
@@ -1,124 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const expectation = process.env.FIRST_BROKER_EXPECTATION;
assert.ok(
expectation === "vulnerable" || expectation === "secure",
"FIRST_BROKER_EXPECTATION must be vulnerable or secure",
);
const keycloakUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
const adminUsername = process.env.KC_BOOTSTRAP_ADMIN_USERNAME;
const adminPassword = process.env.KC_BOOTSTRAP_ADMIN_PASSWORD;
const mockPassword = process.env.MOCK_GOOGLE_USER_PASSWORD;
assert.ok(adminUsername && adminPassword && mockPassword);
async function adminToken() {
const body = new URLSearchParams({
client_id: "admin-cli",
grant_type: "password",
username: adminUsername,
password: adminPassword,
});
const response = await fetch(
`${keycloakUrl}/realms/master/protocol/openid-connect/token`,
{ method: "POST", body },
);
assert.equal(response.status, 200);
return (await response.json()).access_token;
}
async function regularUser(token) {
const response = await fetch(
`${keycloakUrl}/admin/realms/keycloak-patterns/users?username=regular-user&exact=true`,
{ headers: { Authorization: `Bearer ${token}` } },
);
assert.equal(response.status, 200);
const users = await response.json();
assert.equal(users.length, 1);
return users[0];
}
async function federatedIdentities(token, userId) {
const response = await fetch(
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${userId}/federated-identity`,
{ headers: { Authorization: `Bearer ${token}` } },
);
assert.equal(response.status, 200);
return response.json();
}
async function removeMockLink(token, userId) {
const identities = await federatedIdentities(token, userId);
if (identities.some(({ identityProvider }) => identityProvider === "mock-google")) {
const response = await fetch(
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${userId}/federated-identity/mock-google`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
assert.equal(response.status, 204);
}
}
const token = await adminToken();
const user = await regularUser(token);
await removeMockLink(token, user.id);
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();
const authorizationUrl = new URL(
`${keycloakUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
);
authorizationUrl.search = new URLSearchParams({
client_id: "spa-public",
redirect_uri: "http://localhost:8088/",
response_type: "code",
scope: "openid profile email",
state: `first-broker-${expectation}`,
nonce: `nonce-${expectation}`,
code_challenge: "K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI",
code_challenge_method: "S256",
kc_idp_hint: "mock-google",
});
await page.goto(authorizationUrl.toString());
await page.waitForURL(/\/realms\/mock-google\//u);
await page.locator("#username").fill("mock-collision-user");
await page.locator("#password").fill(mockPassword);
await page.locator("#kc-login").click();
await page.waitForLoadState("domcontentloaded");
if (expectation === "vulnerable") {
await page.waitForURL(/localhost:8088\/\?.*code=/u);
const identities = await federatedIdentities(token, user.id);
assert.equal(
identities.some(({ identityProvider }) => identityProvider === "mock-google"),
true,
"unsafe AutoLink should attach the attacker-controlled identity",
);
await removeMockLink(token, user.id);
} else {
assert.match(page.url(), /\/realms\/keycloak-patterns\//u);
const body = (await page.locator("body").innerText()).toLowerCase();
assert.match(body, /account already exists|link existing account|existing account/u);
const identities = await federatedIdentities(token, user.id);
assert.equal(
identities.some(({ identityProvider }) => identityProvider === "mock-google"),
false,
"Confirm Link must not attach the identity without ownership proof",
);
}
console.log(`first broker login ${expectation} case verified`);
} finally {
await browser.close();
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "keycloak-google-broker-e2e",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test:first-broker": "node first-broker-login.mjs",
"test:claim-mapping": "node claim-mapping.mjs",
"test:claim-to-role": "node claim-to-role.mjs",
"test:sub-vs-email": "node sub-vs-email.mjs"
},
"dependencies": {
"playwright-core": "1.55.1"
}
}
-132
View File
@@ -1,132 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const baseUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
const adminUsername = process.env.KC_BOOTSTRAP_ADMIN_USERNAME;
const adminPassword = process.env.KC_BOOTSTRAP_ADMIN_PASSWORD;
const mockPassword = process.env.MOCK_GOOGLE_USER_PASSWORD;
assert.ok(adminUsername && adminPassword && mockPassword);
async function adminToken() {
const response = await fetch(
`${baseUrl}/realms/master/protocol/openid-connect/token`,
{
method: "POST",
body: new URLSearchParams({
client_id: "admin-cli",
grant_type: "password",
username: adminUsername,
password: adminPassword,
}),
},
);
assert.equal(response.status, 200);
return (await response.json()).access_token;
}
async function adminJson(token, path, init = {}) {
const response = await fetch(`${baseUrl}/admin/realms/${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init.body ? { "Content-Type": "application/json" } : {}),
},
});
assert.ok(response.ok, `${init.method ?? "GET"} ${path}: ${response.status}`);
return response.status === 204 ? undefined : response.json();
}
async function users(token, realm, query) {
return adminJson(token, `${realm}/users?${new URLSearchParams(query)}`);
}
async function brokerLogin(browser) {
const page = await browser.newPage();
const url = new URL(
`${baseUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
);
url.search = new URLSearchParams({
client_id: "spa-public",
redirect_uri: "http://localhost:8088/",
response_type: "code",
scope: "openid profile email",
state: crypto.randomUUID(),
nonce: crypto.randomUUID(),
code_challenge: "K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI",
code_challenge_method: "S256",
kc_idp_hint: "mock-google",
prompt: "login",
});
await page.goto(url.toString());
await page.waitForURL(/\/realms\/mock-google\//u);
await page.locator("#username").fill("mock-new-user");
await page.locator("#password").fill(mockPassword);
await page.locator("#kc-login").click();
await page.waitForURL(/localhost:8088\/\?.*code=/u);
await page.close();
}
const token = await adminToken();
const mockUsers = await users(token, "mock-google", {
username: "mock-new-user",
exact: "true",
});
assert.equal(mockUsers.length, 1);
const mockUser = await adminJson(
token,
`mock-google/users/${mockUsers[0].id}`,
);
const originalEmail = mockUser.email;
const changedEmail = "broker-renamed-user@example.test";
for (const existing of await users(token, "keycloak-patterns", {
username: `mock-google.${mockUser.id}`,
exact: "true",
})) {
await adminJson(token, `keycloak-patterns/users/${existing.id}`, {
method: "DELETE",
});
}
const browser = await chromium.launch({
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
headless: true,
args: ["--no-sandbox"],
});
try {
await brokerLogin(browser);
const before = await users(token, "keycloak-patterns", {
username: `mock-google.${mockUser.id}`,
exact: "true",
});
assert.equal(before.length, 1);
const localUserId = before[0].id;
const identities = await adminJson(
token,
`keycloak-patterns/users/${localUserId}/federated-identity`,
);
assert.equal(identities[0].userId, mockUser.id);
await adminJson(token, `mock-google/users/${mockUser.id}`, {
method: "PUT",
body: JSON.stringify({ ...mockUser, email: changedEmail }),
});
await brokerLogin(browser);
const after = await users(token, "keycloak-patterns", {
username: `mock-google.${mockUser.id}`,
exact: "true",
});
assert.equal(after.length, 1);
assert.equal(after[0].id, localUserId);
console.log(
"Federated identity verified: provider sub stayed linked while upstream email changed",
);
} finally {
await adminJson(token, `mock-google/users/${mockUser.id}`, {
method: "PUT",
body: JSON.stringify({ ...mockUser, email: originalEmail }),
});
await browser.close();
}
+15 -106
View File
@@ -24,10 +24,6 @@
{
"name": "user-role",
"description": "Regular authenticated user role"
},
{
"name": "employee-role",
"description": "Assigned to brokered identities whose hosted-domain claim is example.test"
}
]
},
@@ -55,7 +51,22 @@
"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",
@@ -128,108 +139,6 @@
}
}
],
"identityProviders": [
{
"alias": "mock-google",
"displayName": "Mock Google (local verification)",
"providerId": "oidc",
"enabled": true,
"updateProfileFirstLoginMode": "off",
"trustEmail": false,
"storeToken": false,
"addReadTokenRoleOnCreate": false,
"authenticateByDefault": false,
"linkOnly": false,
"firstBrokerLoginFlowAlias": "first broker login",
"config": {
"clientId": "mock-google-broker",
"clientSecret": "${MOCK_GOOGLE_BROKER_CLIENT_SECRET}",
"authorizationUrl": "http://localhost:8080/realms/mock-google/protocol/openid-connect/auth",
"tokenUrl": "http://keycloak:8080/realms/mock-google/protocol/openid-connect/token",
"userInfoUrl": "http://keycloak:8080/realms/mock-google/protocol/openid-connect/userinfo",
"issuer": "http://localhost:8080/realms/mock-google",
"jwksUrl": "http://keycloak:8080/realms/mock-google/protocol/openid-connect/certs",
"useJwksUrl": "true",
"validateSignature": "true",
"defaultScope": "openid profile email",
"syncMode": "IMPORT",
"pkceEnabled": "true",
"pkceMethod": "S256"
}
}
],
"identityProviderMappers": [
{
"name": "mock-google-stable-username",
"identityProviderAlias": "mock-google",
"identityProviderMapper": "oidc-username-idp-mapper",
"config": {
"template": "${ALIAS}.${CLAIM.sub}",
"target": "LOCAL"
}
},
{
"name": "mock-google-email",
"identityProviderAlias": "mock-google",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"syncMode": "INHERIT",
"claim": "email",
"user.attribute": "email"
}
},
{
"name": "mock-google-given-name",
"identityProviderAlias": "mock-google",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"syncMode": "INHERIT",
"claim": "given_name",
"user.attribute": "firstName"
}
},
{
"name": "mock-google-family-name",
"identityProviderAlias": "mock-google",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"syncMode": "INHERIT",
"claim": "family_name",
"user.attribute": "lastName"
}
},
{
"name": "mock-google-picture",
"identityProviderAlias": "mock-google",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"syncMode": "INHERIT",
"claim": "picture",
"user.attribute": "picture"
}
},
{
"name": "mock-google-hosted-domain",
"identityProviderAlias": "mock-google",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"syncMode": "INHERIT",
"claim": "hd",
"user.attribute": "hd"
}
},
{
"name": "mock-google-example-domain-role",
"identityProviderAlias": "mock-google",
"identityProviderMapper": "oidc-role-idp-mapper",
"config": {
"syncMode": "INHERIT",
"claim": "hd",
"claim.value": "example.test",
"role": "employee-role"
}
}
],
"users": [
{
"username": "admin-user",
-93
View File
@@ -1,93 +0,0 @@
{
"realm": "mock-google",
"displayName": "Controllable Google OIDC Test Provider",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"bruteForceProtected": true,
"clients": [
{
"clientId": "mock-google-broker",
"name": "Main Realm Identity Broker",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"secret": "${MOCK_GOOGLE_BROKER_CLIENT_SECRET}",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"redirectUris": [
"http://localhost:8080/realms/keycloak-patterns/broker/mock-google/endpoint"
],
"webOrigins": [],
"protocolMappers": [
{
"name": "hosted-domain",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"claim.name": "hd",
"claim.value": "example.test",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "picture",
"protocol": "openid-connect",
"protocolMapper": "oidc-hardcoded-claim-mapper",
"consentRequired": false,
"config": {
"claim.name": "picture",
"claim.value": "https://images.example.test/mock-user.png",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "mock-new-user",
"enabled": true,
"email": "broker-new-user@example.test",
"emailVerified": true,
"firstName": "Broker",
"lastName": "New",
"credentials": [
{
"type": "password",
"value": "${MOCK_GOOGLE_USER_PASSWORD}",
"temporary": false
}
]
},
{
"username": "mock-collision-user",
"enabled": true,
"email": "regular-user@example.test",
"emailVerified": false,
"firstName": "Broker",
"lastName": "Collision",
"credentials": [
{
"type": "password",
"value": "${MOCK_GOOGLE_USER_PASSWORD}",
"temporary": false
}
]
}
]
}
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env sh
set -eu
manifest="${1:-docs/keycloak-branch-manifest.tsv}"
notes_dir="${KEYCLOAK_BRANCH_NOTES_DIR:-/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes}"
expected_count="$(awk 'NR > 1 { count += 1 } END { print count + 0 }' "$manifest")"
if [ "$expected_count" -ne 39 ]; then
echo "manifest must contain exactly 39 Keycloak branches; found $expected_count" >&2
exit 1
fi
note_count="$(find "$notes_dir" -maxdepth 1 -type f -name 'feature-keycloak-*.md' | wc -l)"
if [ "$note_count" -ne 39 ]; then
echo "branch-note inventory must contain exactly 39 files; found $note_count" >&2
exit 1
fi
missing=0
unmerged=0
tab="$(printf '\t')"
while IFS="$tab" read -r branch target delivery; do
[ "$branch" = "branch" ] && continue
note_name="$(printf '%s\n' "$branch" |
sed 's#^feature/keycloak-#feature-keycloak-#').md"
if [ ! -f "$notes_dir/$note_name" ]; then
echo "missing branch note: $note_name" >&2
missing=$((missing + 1))
fi
if ! git show-ref --verify --quiet "refs/heads/$branch"; then
echo "missing local branch: $branch" >&2
missing=$((missing + 1))
continue
fi
case "$target" in
common) target_branch="develop" ;;
ap1) target_branch="develop-keycloak-pattern1" ;;
ap2) target_branch="develop-keycloak-pattern2" ;;
ap3) target_branch="develop-keycloak-pattern3" ;;
ap4) target_branch="develop-keycloak-pattern4" ;;
*)
echo "unknown target '$target' for $branch ($delivery)" >&2
exit 1
;;
esac
if ! git merge-base --is-ancestor "$branch" "$target_branch"; then
echo "feature tip is not merged: $branch -> $target_branch" >&2
unmerged=$((unmerged + 1))
fi
done < "$manifest"
if [ "$missing" -ne 0 ] || [ "$unmerged" -ne 0 ]; then
echo "Keycloak branch audit failed: missing=$missing unmerged=$unmerged" >&2
exit 1
fi
echo "Keycloak branch audit passed: 39/39 branches exist and are merged"
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env" >&2
exit 1
fi
set -a
. ./.env
set +a
keycloak_url="${KEYCLOAK_URL:-http://localhost:8080}"
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
profile_url="$keycloak_url/admin/realms/$realm/users/profile"
admin_token="$(
curl -fsS \
-d client_id=admin-cli \
-d grant_type=password \
-d "username=$KC_BOOTSTRAP_ADMIN_USERNAME" \
-d "password=$KC_BOOTSTRAP_ADMIN_PASSWORD" \
"$keycloak_url/realms/master/protocol/openid-connect/token" |
jq -er .access_token
)"
profile="$(curl -fsS -H "Authorization: Bearer $admin_token" "$profile_url")"
updated_profile="$(
printf '%s' "$profile" |
jq '
def broker_attribute($name; $label): {
name: $name,
displayName: $label,
validations: {length: {max: 2048}},
permissions: {
view: ["admin", "user"],
edit: ["admin"]
},
multivalued: false,
group: "user-metadata"
};
if any(.attributes[]; .name == "picture") then .
else .attributes += [broker_attribute("picture"; "Profile picture URL")]
end |
if any(.attributes[]; .name == "hd") then .
else .attributes += [broker_attribute("hd"; "Hosted domain")]
end
'
)"
printf '%s' "$updated_profile" |
curl -fsS -X PUT \
-H "Authorization: Bearer $admin_token" \
-H "Content-Type: application/json" \
--data @- \
"$profile_url"
echo "Broker user-profile attributes configured for realm '$realm'"
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env" >&2
exit 1
fi
set -a
. ./.env
set +a
: "${KC_BOOTSTRAP_ADMIN_USERNAME:?set KC_BOOTSTRAP_ADMIN_USERNAME in .env}"
: "${KC_BOOTSTRAP_ADMIN_PASSWORD:?set KC_BOOTSTRAP_ADMIN_PASSWORD in .env}"
: "${GOOGLE_CLIENT_ID:?set GOOGLE_CLIENT_ID in .env}"
: "${GOOGLE_CLIENT_SECRET:?set GOOGLE_CLIENT_SECRET in .env}"
./scripts/configure-broker-user-profile.sh
keycloak_url="${KEYCLOAK_URL:-http://localhost:8080}"
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
admin_token="$(
curl -fsS \
-d client_id=admin-cli \
-d grant_type=password \
-d "username=$KC_BOOTSTRAP_ADMIN_USERNAME" \
-d "password=$KC_BOOTSTRAP_ADMIN_PASSWORD" \
"$keycloak_url/realms/master/protocol/openid-connect/token" |
jq -er .access_token
)"
payload="$(
jq -n \
--arg client_id "$GOOGLE_CLIENT_ID" \
--arg client_secret "$GOOGLE_CLIENT_SECRET" \
'{
alias: "google",
displayName: "Sign in with Google",
providerId: "google",
enabled: true,
updateProfileFirstLoginMode: "off",
trustEmail: false,
storeToken: false,
addReadTokenRoleOnCreate: false,
authenticateByDefault: false,
linkOnly: false,
firstBrokerLoginFlowAlias: "first broker login",
config: {
clientId: $client_id,
clientSecret: $client_secret,
defaultScope: "openid profile email",
syncMode: "IMPORT"
}
}'
)"
endpoint="$keycloak_url/admin/realms/$realm/identity-provider/instances"
status="$(
curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $admin_token" \
"$endpoint/google"
)"
if [ "$status" = "200" ]; then
curl -fsS -X PUT \
-H "Authorization: Bearer $admin_token" \
-H "Content-Type: application/json" \
--data "$payload" \
"$endpoint/google"
action="updated"
else
curl -fsS -X POST \
-H "Authorization: Bearer $admin_token" \
-H "Content-Type: application/json" \
--data "$payload" \
"$endpoint"
action="created"
fi
mapper_endpoint="$endpoint/google/mappers"
upsert_mapper() {
mapper_name="$1"
mapper_type="$2"
mapper_config="$3"
mapper_id="$(
curl -fsS \
-H "Authorization: Bearer $admin_token" \
"$mapper_endpoint" |
jq -r --arg name "$mapper_name" '
.[] | select(.name == $name) | .id
' |
head -1
)"
mapper_payload="$(
jq -n \
--arg name "$mapper_name" \
--arg alias "google" \
--arg mapper "$mapper_type" \
--argjson config "$mapper_config" \
'{
name: $name,
identityProviderAlias: $alias,
identityProviderMapper: $mapper,
config: $config
}'
)"
if [ -n "$mapper_id" ]; then
curl -fsS -X PUT \
-H "Authorization: Bearer $admin_token" \
-H "Content-Type: application/json" \
--data "$mapper_payload" \
"$mapper_endpoint/$mapper_id"
else
curl -fsS -X POST \
-H "Authorization: Bearer $admin_token" \
-H "Content-Type: application/json" \
--data "$mapper_payload" \
"$mapper_endpoint"
fi
}
upsert_mapper \
"google-stable-username" \
"oidc-username-idp-mapper" \
'{"template":"${ALIAS}.${CLAIM.sub}","target":"LOCAL"}'
upsert_mapper \
"google-email" \
"oidc-user-attribute-idp-mapper" \
'{"syncMode":"INHERIT","claim":"email","user.attribute":"email"}'
upsert_mapper \
"google-given-name" \
"oidc-user-attribute-idp-mapper" \
'{"syncMode":"INHERIT","claim":"given_name","user.attribute":"firstName"}'
upsert_mapper \
"google-family-name" \
"oidc-user-attribute-idp-mapper" \
'{"syncMode":"INHERIT","claim":"family_name","user.attribute":"lastName"}'
upsert_mapper \
"google-picture" \
"oidc-user-attribute-idp-mapper" \
'{"syncMode":"INHERIT","claim":"picture","user.attribute":"picture"}'
upsert_mapper \
"google-hosted-domain" \
"oidc-user-attribute-idp-mapper" \
'{"syncMode":"INHERIT","claim":"hd","user.attribute":"hd"}'
upsert_mapper \
"google-example-domain-role" \
"oidc-role-idp-mapper" \
'{"syncMode":"INHERIT","claim":"hd","claim.value":"example.test","role":"employee-role"}'
echo "Google Identity Provider $action for realm '$realm'"
echo "Register this exact Google redirect URI:"
echo "$keycloak_url/realms/$realm/broker/google/endpoint"
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env sh
set -eu
mode="${1:-}"
case "$mode" in
vulnerable|secure) ;;
*)
echo "usage: $0 vulnerable|secure" >&2
exit 1
;;
esac
if [ ! -f .env ]; then
echo "missing .env" >&2
exit 1
fi
set -a
. ./.env
set +a
keycloak_url="${KEYCLOAK_URL:-http://localhost:8080}"
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
admin_base="$keycloak_url/admin/realms/$realm"
vulnerable_flow="vulnerable first broker login"
idp_url="$admin_base/identity-provider/instances/mock-google"
admin_token="$(
curl -fsS \
-d client_id=admin-cli \
-d grant_type=password \
-d "username=$KC_BOOTSTRAP_ADMIN_USERNAME" \
-d "password=$KC_BOOTSTRAP_ADMIN_PASSWORD" \
"$keycloak_url/realms/master/protocol/openid-connect/token" |
jq -er .access_token
)"
auth_header="Authorization: Bearer $admin_token"
encode() {
jq -rn --arg value "$1" '$value | @uri'
}
flows="$(curl -fsS -H "$auth_header" "$admin_base/authentication/flows")"
flow_id="$(
printf '%s' "$flows" |
jq -r --arg alias "$vulnerable_flow" '
.[] | select(.alias == $alias) | .id
' |
head -1
)"
if [ -n "$flow_id" ]; then
existing_executions="$(
curl -fsS -H "$auth_header" \
"$admin_base/authentication/flows/$(encode "$vulnerable_flow")/executions"
)"
if printf '%s' "$existing_executions" | jq -e '
any(.[]; .authenticationFlow == true)
' >/dev/null; then
idp_before_delete="$(curl -fsS -H "$auth_header" "$idp_url")"
printf '%s' "$idp_before_delete" |
jq '.firstBrokerLoginFlowAlias = "first broker login"' |
curl -fsS -X PUT \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data @- \
"$idp_url"
curl -fsS -X DELETE \
-H "$auth_header" \
"$admin_base/authentication/flows/$flow_id"
flow_id=""
fi
fi
if [ -z "$flow_id" ]; then
curl -fsS -X POST \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data "$(
jq -n --arg alias "$vulnerable_flow" '{
alias: $alias,
description: "INSECURE LEARNING FLOW - automatic email linking",
providerId: "basic-flow",
topLevel: true,
builtIn: false
}'
)" \
"$admin_base/authentication/flows"
fi
executions_url="$admin_base/authentication/flows/$(encode "$vulnerable_flow")/executions"
executions="$(curl -fsS -H "$auth_header" "$executions_url")"
create_user_id="$(
printf '%s' "$executions" |
jq -r '.[] | select(.providerId == "idp-create-user-if-unique") | .id' |
head -1
)"
if [ -z "$create_user_id" ]; then
curl -fsS -X POST \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data '{"provider":"idp-create-user-if-unique"}' \
"$admin_base/authentication/flows/$(encode "$vulnerable_flow")/executions/execution"
executions="$(curl -fsS -H "$auth_header" "$executions_url")"
create_user_id="$(
printf '%s' "$executions" |
jq -r '.[] | select(.providerId == "idp-create-user-if-unique") | .id' |
head -1
)"
fi
auto_link_id="$(
printf '%s' "$executions" |
jq -r '.[] | select(.providerId == "idp-auto-link") | .id' |
head -1
)"
if [ -z "$auto_link_id" ]; then
curl -fsS -X POST \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data '{"provider":"idp-auto-link"}' \
"$admin_base/authentication/flows/$(encode "$vulnerable_flow")/executions/execution"
executions="$(curl -fsS -H "$auth_header" "$executions_url")"
auto_link_id="$(
printf '%s' "$executions" |
jq -r '.[] | select(.providerId == "idp-auto-link") | .id' |
head -1
)"
fi
if [ "$mode" = "vulnerable" ]; then
curl -fsS -X PUT \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data "$(jq -n --arg id "$create_user_id" '{id: $id, requirement: "ALTERNATIVE"}')" \
"$executions_url"
curl -fsS -X PUT \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data "$(jq -n --arg id "$auto_link_id" '{id: $id, requirement: "ALTERNATIVE"}')" \
"$executions_url"
selected_flow="$vulnerable_flow"
else
selected_flow="first broker login"
fi
idp="$(curl -fsS -H "$auth_header" "$idp_url")"
printf '%s' "$idp" |
jq --arg flow "$selected_flow" '.firstBrokerLoginFlowAlias = $flow' |
curl -fsS -X PUT \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data @- \
"$idp_url"
assigned="$(
curl -fsS -H "$auth_header" "$idp_url" |
jq -r .firstBrokerLoginFlowAlias
)"
test "$assigned" = "$selected_flow"
if [ "$mode" = "secure" ]; then
secure_executions="$(
curl -fsS -H "$auth_header" \
"$admin_base/authentication/flows/$(encode "first broker login")/executions"
)"
printf '%s' "$secure_executions" | jq -e '
any(.[];
.providerId == "idp-confirm-link" and .requirement == "REQUIRED"
) and
(any(.[];
.providerId == "idp-auto-link" and .requirement != "DISABLED"
) | not)
' >/dev/null
fi
echo "mock-google First Broker Login mode: $mode ($selected_flow)"
+12
View File
@@ -70,6 +70,18 @@ def validate(path: Path, runtime: bool) -> None:
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]
@@ -1,11 +0,0 @@
#!/usr/bin/env sh
set -eu
set -a
. ./.env
set +a
./scripts/set-first-broker-login-mode.sh secure
cd google-e2e
npm install --ignore-scripts
npm run test:sub-vs-email
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env" >&2
exit 1
fi
set -a
. ./.env
set +a
restore_secure_flow() {
./scripts/set-first-broker-login-mode.sh secure >/dev/null 2>&1 || true
}
trap restore_secure_flow 0 1 2 15
npm --prefix google-e2e ci
./scripts/set-first-broker-login-mode.sh vulnerable
FIRST_BROKER_EXPECTATION=vulnerable \
npm --prefix google-e2e run test:first-broker
./scripts/set-first-broker-login-mode.sh secure
FIRST_BROKER_EXPECTATION=secure \
npm --prefix google-e2e run test:first-broker
trap - 0 1 2 15
echo "First Broker Login verified: unsafe AutoLink reproduced, Confirm Link restored"
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env" >&2
exit 1
fi
set -a
. ./.env
set +a
keycloak_url="${KEYCLOAK_URL:-http://localhost:8080}"
admin_token="$(
curl -fsS \
-d client_id=admin-cli \
-d grant_type=password \
-d "username=$KC_BOOTSTRAP_ADMIN_USERNAME" \
-d "password=$KC_BOOTSTRAP_ADMIN_PASSWORD" \
"$keycloak_url/realms/master/protocol/openid-connect/token" |
jq -er .access_token
)"
idp="$(
curl -fsS \
-H "Authorization: Bearer $admin_token" \
"$keycloak_url/admin/realms/keycloak-patterns/identity-provider/instances/mock-google"
)"
printf '%s' "$idp" | jq -e '
.providerId == "oidc" and
.enabled == true and
.trustEmail == false and
.config.clientId == "mock-google-broker" and
.config.defaultScope == "openid profile email" and
.config.syncMode == "IMPORT" and
.config.validateSignature == "true"
' >/dev/null
client="$(
curl -fsS \
-H "Authorization: Bearer $admin_token" \
"$keycloak_url/admin/realms/mock-google/clients?clientId=mock-google-broker"
)"
printf '%s' "$client" | jq -e '
length == 1 and
.[0].publicClient == false and
(.[0].redirectUris | index(
"http://localhost:8080/realms/keycloak-patterns/broker/mock-google/endpoint"
)) != null
' >/dev/null
location="$(
curl -sS -D - -o /dev/null \
"$keycloak_url/realms/keycloak-patterns/protocol/openid-connect/auth?client_id=spa-public&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2F&response_type=code&scope=openid&code_challenge=K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI&code_challenge_method=S256&kc_idp_hint=mock-google" |
awk 'BEGIN { IGNORECASE=1 } /^Location:/ { print $2 }' |
tr -d '\r'
)"
case "$location" in
"$keycloak_url/realms/keycloak-patterns/broker/mock-google/login"*) ;;
*)
echo "broker did not redirect to the controllable OIDC provider: $location" >&2
exit 1
;;
esac
echo "Google broker contract verified with the local mock OIDC realm"
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env" >&2
exit 1
fi
set -a
. ./.env
set +a
./scripts/configure-broker-user-profile.sh
./scripts/set-first-broker-login-mode.sh secure
npm --prefix google-e2e ci
npm --prefix google-e2e run test:claim-mapping
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env sh
set -eu
set -a
. ./.env
set +a
./scripts/configure-broker-user-profile.sh >/dev/null
./scripts/set-first-broker-login-mode.sh secure
docker compose exec -T keycloak \
/opt/keycloak/bin/kcadm.sh create roles -r keycloak-patterns \
-s name=employee-role \
-s 'description=Assigned from the upstream hd claim' >/dev/null 2>&1 || true
cd google-e2e
npm install --ignore-scripts
npm run test:claim-to-role
@@ -1,24 +0,0 @@
#!/usr/bin/env sh
set -eu
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
public_keycloak_url="${PUBLIC_KEYCLOAK_URL:-https://auth.example.test}"
expected="$public_keycloak_url/realms/$realm/broker/google/endpoint"
case "$public_keycloak_url" in
https://*) ;;
*)
echo "PUBLIC_KEYCLOAK_URL must use https outside the local mock environment" >&2
exit 1
;;
esac
case "$public_keycloak_url" in
*\** | */)
echo "PUBLIC_KEYCLOAK_URL must be an exact origin without wildcard/trailing slash" >&2
exit 1
;;
esac
test "$expected" = "https://auth.example.test/realms/keycloak-patterns/broker/google/endpoint"
echo "Google redirect URI policy verified: $expected"
@@ -1,27 +0,0 @@
#!/usr/bin/env sh
set -eu
test_dir="$(mktemp -d)"
cleanup() {
rm -rf "$test_dir"
}
trap cleanup EXIT
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj "/CN=auth.example.test" \
-keyout "$test_dir/tls.key" \
-out "$test_dir/tls.crt" >/dev/null 2>&1
docker run --rm \
--add-host keycloak:127.0.0.1 \
-v "$PWD/deploy/tls/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$test_dir:/etc/tls:ro" \
nginx:1.29-alpine nginx -t
docker run --rm \
--add-host keycloak:127.0.0.1 \
-v "$PWD/deploy/tls/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "$test_dir:/etc/tls:ro" \
caddy:2.10.2-alpine caddy validate --config /etc/caddy/Caddyfile
echo "nginx and Caddy HTTPS termination configurations verified"
+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"
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env sh
set -eu
config=deploy/reverse-proxy/nginx-keycloak.conf
env_file=deploy/reverse-proxy/keycloak.env.example
grep -q 'proxy_set_header X-Forwarded-Host' "$config"
grep -q 'proxy_set_header X-Forwarded-Port 443' "$config"
grep -q 'proxy_set_header X-Forwarded-Proto https' "$config"
grep -q '^KC_PROXY_HEADERS=xforwarded$' "$env_file"
grep -q '^KC_HOSTNAME=https://' "$env_file"
docker run --rm \
--add-host keycloak:127.0.0.1 \
-v "$PWD/$config:/etc/nginx/conf.d/default.conf:ro" \
nginx:1.29-alpine nginx -t
echo "Reverse-proxy header and Keycloak hostname contracts verified"