Compare commits

..
Author SHA1 Message Date
donghyeon-ka c611acf662 feat(ap3): add oauth2Login session BFF 2026-07-25 14:38:02 +09:00
40 changed files with 837 additions and 1183 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
+3 -4
View File
@@ -4,8 +4,7 @@
*.iml
backend/target/
build/
e2e/node_modules/
google-e2e/node_modules/
frontend/node_modules/
bff/target/
**/node_modules/
frontend/dist/
build/
+14 -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,17 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
## AP3: Backend-for-Frontend
`develop-keycloak-pattern3`의 Spring BFF는 `http://localhost:8083`에서
실행됩니다. 브라우저에는 HttpOnly session cookie만 두고 access/refresh
token은 BFF가 서버에 보관합니다. `/bff/api/me`는 BFF가 보유 access
token을 붙여 Resource Server로 proxy합니다.
```bash
./scripts/verify-pattern3.sh
```
자세한 경계와 session 저장소 trade-off는
[`docs/ap3-bff-boundary.md`](docs/ap3-bff-boundary.md)를 참고하세요.
@@ -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;
}
}
@@ -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
View File
@@ -0,0 +1 @@
target/
+14
View File
@@ -0,0 +1,14 @@
FROM maven:3.9.11-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn --batch-mode dependency:go-offline
COPY src src
RUN mvn --batch-mode verify
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=build /workspace/target/keycloak-bff.jar app.jar
USER spring:spring
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>keycloak-bff</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>keycloak-bff</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>keycloak-bff</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package com.example.keycloakpattern.bff;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BffApplication {
public static void main(String[] args) {
SpringApplication.run(BffApplication.class, args);
}
}
@@ -0,0 +1,112 @@
package com.example.keycloakpattern.bff;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
@RestController
public class BffController {
private final OAuth2AuthorizedClientService authorizedClientService;
private final OAuth2AuthorizedClientManager authorizedClientManager;
private final RestClient resourceApi;
private final AtomicReference<String> theme = new AtomicReference<>("system");
public BffController(
OAuth2AuthorizedClientService authorizedClientService,
OAuth2AuthorizedClientManager authorizedClientManager,
RestClient.Builder restClientBuilder,
@Value("${resource-api.base-url}") String resourceApiBaseUrl
) {
this.authorizedClientService = authorizedClientService;
this.authorizedClientManager = authorizedClientManager;
this.resourceApi = restClientBuilder.baseUrl(resourceApiBaseUrl).build();
}
@GetMapping("/bff/token-boundary")
ResponseEntity<Map<String, Object>> tokenBoundary(Authentication authentication) {
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(
"keycloak",
authentication.getName()
);
Map<String, Object> response = new LinkedHashMap<>();
response.put("pattern", "AP3-backend-for-frontend");
response.put("principal", authentication.getName());
response.put("accessTokenStoredOnServer", client != null
&& client.getAccessToken() != null);
response.put("refreshTokenStoredOnServer", client != null
&& client.getRefreshToken() != null);
response.put("browserTokenCount", 0);
response.put("csrfProtectionEnabled", false);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header("Pragma", "no-cache")
.body(response);
}
@GetMapping("/bff/api/me")
ResponseEntity<?> currentUser(Authentication authentication) {
OAuth2AuthorizedClient client = authorizedClient(authentication);
return resourceApi.get()
.uri("/api/me")
.header(
HttpHeaders.AUTHORIZATION,
"Bearer " + client.getAccessToken().getTokenValue()
)
.retrieve()
.toEntity(Map.class);
}
@PostMapping("/bff/api/preferences")
Map<String, Object> updatePreference(
Authentication authentication,
@RequestParam(defaultValue = "system") String theme
) {
this.theme.set(theme);
return Map.of(
"updated", true,
"theme", this.theme.get(),
"principal", authentication.getName()
);
}
@GetMapping("/bff/api/preferences")
Map<String, String> preference() {
return Map.of("theme", theme.get());
}
private OAuth2AuthorizedClient authorizedClient(Authentication authentication) {
OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(authentication)
.build();
OAuth2AuthorizedClient client = authorizedClientManager.authorize(request);
if (client == null || client.getAccessToken() == null) {
throw new ResponseStatusException(
UNAUTHORIZED,
"No authorized Keycloak client is available"
);
}
return client;
}
}
@@ -0,0 +1,73 @@
package com.example.keycloakpattern.bff;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain bffSecurity(
HttpSecurity http,
ClientRegistrationRepository clientRegistrationRepository
) throws Exception {
DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
authorizationRequestResolver.setAuthorizationRequestCustomizer(
OAuth2AuthorizationRequestCustomizers.withPkce()
);
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/",
"/index.html",
"/app.js",
"/favicon.ico",
"/actuator/health",
"/actuator/health/**"
)
.permitAll()
.anyRequest()
.authenticated())
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint
.authorizationRequestResolver(authorizationRequestResolver))
.defaultSuccessUrl("/", true))
.build();
}
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService
) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository,
authorizedClientService
);
manager.setAuthorizedClientProvider(authorizedClientProvider);
return manager;
}
}
+46
View File
@@ -0,0 +1,46 @@
server:
port: ${SERVER_PORT:8083}
servlet:
session:
cookie:
name: AP3_SESSION
http-only: true
spring:
application:
name: keycloak-bff
security:
oauth2:
client:
registration:
keycloak:
provider: keycloak
client-id: bff-confidential
client-secret: ${KEYCLOAK_CLIENT_SECRET}
client-authentication-method: client_secret_basic
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope:
- openid
- profile
- email
provider:
keycloak:
authorization-uri: http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
token-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/token
jwk-set-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
user-info-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/userinfo
user-name-attribute: preferred_username
resource-api:
base-url: ${RESOURCE_API_BASE_URL:http://localhost:8081}
management:
endpoint:
health:
probes:
enabled: true
endpoints:
web:
exposure:
include: health,info
+39
View File
@@ -0,0 +1,39 @@
const result = document.querySelector("#result");
function render(value) {
result.textContent = JSON.stringify(value, null, 2);
}
async function request(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { Accept: "application/json", ...options.headers },
});
if (response.redirected || response.status === 401) {
window.location.assign("/oauth2/authorization/keycloak");
return null;
}
const body = await response.json();
render({ status: response.status, ...body });
return { response, body };
}
document.querySelector("#login").addEventListener("click", () => {
window.location.assign("/oauth2/authorization/keycloak");
});
document.querySelector("#inspect").addEventListener("click", () => {
void request("/bff/token-boundary");
});
document.querySelector("#call-bff").addEventListener("click", () => {
void request("/bff/api/me");
});
document.querySelector("#change-without-csrf").addEventListener("click", () => {
void request("/bff/api/preferences", {
method: "POST",
body: new URLSearchParams({ theme: "dark" }),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
});
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AP3 · Backend-for-Frontend</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { max-width: 58rem; margin: 6vh auto; padding: 0 1.5rem; line-height: 1.6; }
button { margin: 0 0.5rem 0.5rem 0; padding: 0.6rem 0.9rem; cursor: pointer; }
pre { min-height: 9rem; padding: 1rem; border-radius: 0.4rem;
background: color-mix(in srgb, CanvasText 9%, Canvas); white-space: pre-wrap; }
</style>
</head>
<body>
<main>
<h1>AP3 · Backend-for-Frontend</h1>
<p>
브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session
cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource
Server 요청에 붙입니다.
</p>
<button id="login" type="button">Keycloak 로그인</button>
<button id="inspect" type="button">token 경계 확인</button>
<button id="call-bff" type="button">BFF 경유 API 호출</button>
<button id="change-without-csrf" type="button">CSRF token 없이 상태 변경</button>
<pre id="result" aria-live="polite"></pre>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
@@ -0,0 +1,69 @@
package com.example.keycloakpattern.bff;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(properties = {
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
"resource-api.base-url=http://127.0.0.1:9"
})
@AutoConfigureMockMvc
class BffControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OAuth2AuthorizedClientService authorizedClientService;
@MockitoBean
private OAuth2AuthorizedClientManager authorizedClientManager;
@Test
void reportsServerTokenCustodyWithoutReturningTokens() throws Exception {
OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class);
when(client.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class));
when(client.getRefreshToken()).thenReturn(mock(OAuth2RefreshToken.class));
when(authorizedClientService.loadAuthorizedClient("keycloak", "test-subject"))
.thenReturn(client);
mockMvc.perform(get("/bff/token-boundary").with(oidcLogin()
.idToken(token -> token.subject("test-subject"))))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(jsonPath("$.accessTokenStoredOnServer").value(true))
.andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true))
.andExpect(jsonPath("$.browserTokenCount").value(0))
.andExpect(jsonPath("$.csrfProtectionEnabled").value(false))
.andExpect(jsonPath("$.access_token").doesNotExist())
.andExpect(jsonPath("$.refresh_token").doesNotExist());
}
@Test
void demonstratesStateChangeWithoutCsrfProtection() throws Exception {
mockMvc.perform(post("/bff/api/preferences")
.param("theme", "attacker")
.with(oidcLogin().idToken(token -> token.subject("test-subject"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(true))
.andExpect(jsonPath("$.theme").value("attacker"));
}
}
+26 -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,32 @@ services:
- keycloak-net
restart: unless-stopped
bff:
build:
context: ./bff
environment:
SERVER_PORT: "8083"
KEYCLOAK_CLIENT_SECRET: ${BFF_CLIENT_SECRET:?set BFF_CLIENT_SECRET in .env}
RESOURCE_API_BASE_URL: http://app:8081
ports:
- "127.0.0.1:8083:8083"
depends_on:
keycloak:
condition: service_healthy
app:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- wget -q -O - http://127.0.0.1:8083/actuator/health | grep -q '"status":"UP"'
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
networks:
- keycloak-net
restart: unless-stopped
nginx:
build:
context: ./frontend
+28
View File
@@ -0,0 +1,28 @@
# AP3 · Backend-for-Frontend
## 요청과 token 경계
1. 브라우저는 BFF의 `/oauth2/authorization/keycloak`로 로그인을 시작합니다.
2. Spring `oauth2Login`은 PKCE S256 authorization code flow를 수행합니다.
3. BFF가 client secret으로 code를 교환하고 access/refresh token을 서버의
`OAuth2AuthorizedClientService`에 보관합니다.
4. 브라우저에는 OAuth token 대신 HttpOnly `AP3_SESSION` 식별자만 남습니다.
5. 브라우저가 `/bff/api/me`를 cookie로 호출하면 BFF가 access token을
`Authorization: Bearer`로 붙여 Resource Server에 fan-out합니다.
Resource Server는 `aud=keycloak-pattern-api`를 검증합니다. 브라우저에서는
8081로 직접 요청하거나 Keycloak token endpoint를 호출하지 않습니다.
학습용 구성은 단일 인스턴스 메모리에 session과 authorized client를
보관합니다. BFF를 재시작하면 세션이 사라집니다. 다중 인스턴스 운영에서는
Spring Session/Redis 같은 공유 저장소와 저장 token 암호화 정책이 필요합니다.
## 방어 전 CSRF 재현
이 feature 브랜치에서는 다음 CSRF 방어 feature와 비교하기 위해 CSRF를
의도적으로 끕니다. 다른 origin의 자동 제출 form이 브라우저 cookie를
자동으로 포함해 `/bff/api/preferences` 상태를 바꾸는 것을 E2E에서
재현합니다.
이 취약 상태는 `feature/keycloak-bff-csrf-samesite-defense`에서 Spring
CSRF token과 명시적 SameSite=Lax를 적용해 차단합니다.
-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.
-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.
-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
+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:pattern3": "node pattern3.mjs"
},
"devDependencies": {
"playwright-core": "1.62.0"
}
}
+140
View File
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const password = process.env.E2E_PASSWORD;
assert.ok(password, "E2E_PASSWORD must be set");
async function completeKeycloakLogin(page) {
for (let attempt = 1; attempt <= 2; attempt += 1) {
await page.locator("#username").fill(
process.env.E2E_USERNAME ?? "regular-user",
);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForLoadState("domcontentloaded");
if (page.url() === "http://localhost:8083/") {
return;
}
if (attempt === 1) {
await page.goto(
"http://localhost:8083/oauth2/authorization/keycloak",
);
await page.waitForURL(/localhost:8080/u);
}
}
throw new Error(`Keycloak login did not return to AP3: ${page.url()}`);
}
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 browserRequests = [];
page.on("request", (request) => browserRequests.push(request.url()));
await page.goto("http://localhost:8083");
const authorizationRequestPromise = page.waitForRequest((request) =>
request.url().includes(
"/protocol/openid-connect/auth?response_type=code",
),
);
await page.locator("#login").click();
const authorizationRequest = await authorizationRequestPromise;
const authorizationUrl = new URL(authorizationRequest.url());
assert.equal(authorizationUrl.searchParams.get("client_id"), "bff-confidential");
assert.equal(authorizationUrl.searchParams.get("code_challenge_method"), "S256");
assert.ok(authorizationUrl.searchParams.get("code_challenge"));
await page.waitForURL(/localhost:8080/u);
await completeKeycloakLogin(page);
const callbackRequest = browserRequests.find((url) =>
url.startsWith("http://localhost:8083/login/oauth2/code/keycloak?"),
);
assert.ok(callbackRequest, "authorization response must use the BFF callback");
const boundaryResponsePromise = page.waitForResponse((response) =>
response.url().endsWith("/bff/token-boundary"),
);
await page.locator("#inspect").click();
const boundaryResponse = await boundaryResponsePromise;
assert.equal(boundaryResponse.status(), 200);
const boundary = await boundaryResponse.json();
assert.equal(boundary.accessTokenStoredOnServer, true);
assert.equal(boundary.refreshTokenStoredOnServer, true);
assert.equal(boundary.browserTokenCount, 0);
assert.equal(boundary.csrfProtectionEnabled, false);
assert.equal(JSON.stringify(boundary).includes("access_token"), false);
assert.equal(JSON.stringify(boundary).includes("refresh_token"), false);
const proxyResponsePromise = page.waitForResponse((response) =>
response.url().endsWith("/bff/api/me"),
);
await page.locator("#call-bff").click();
const proxyResponse = await proxyResponsePromise;
assert.equal(proxyResponse.status(), 200);
const resource = await proxyResponse.json();
assert.equal(resource.username, "regular-user");
assert.ok(resource.audience.includes("keycloak-pattern-api"));
assert.equal(
browserRequests.some((url) => url.startsWith("http://localhost:8081/")),
false,
"the browser must not bypass the BFF",
);
assert.equal(
browserRequests.some((url) =>
url.includes("/protocol/openid-connect/token"),
),
false,
"the token exchange must be server-to-server",
);
const cookies = await context.cookies("http://localhost:8083/");
const sessionCookie = cookies.find((cookie) => cookie.name === "AP3_SESSION");
assert.ok(sessionCookie);
assert.equal(sessionCookie.httpOnly, true);
const storage = await page.evaluate(() => ({
localStorage: Object.values(localStorage),
sessionStorage: Object.values(sessionStorage),
readableCookies: document.cookie,
}));
assert.deepEqual(storage.localStorage, []);
assert.deepEqual(storage.sessionStorage, []);
assert.equal(storage.readableCookies.includes("AP3_SESSION"), false);
await page.goto("http://localhost:8088");
const forgedResponsePromise = page.waitForResponse(
(response) =>
response.url() === "http://localhost:8083/bff/api/preferences" &&
response.request().method() === "POST",
);
await page.evaluate(() => {
const form = document.createElement("form");
form.method = "POST";
form.action = "http://localhost:8083/bff/api/preferences";
const input = document.createElement("input");
input.name = "theme";
input.value = "attacker";
form.append(input);
document.body.append(form);
form.submit();
});
const forgedResponse = await forgedResponsePromise;
assert.equal(forgedResponse.status(), 200);
const forgedResult = await forgedResponse.json();
assert.equal(forgedResult.updated, true);
assert.equal(forgedResult.theme, "attacker");
console.log(
"pattern3 BFF verified: browser token 0, session-only proxy 200, pre-defense CSRF reproduced",
);
} finally {
await browser.close();
}
-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();
}
-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();
}
-13
View File
@@ -1,13 +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"
},
"dependencies": {
"playwright-core": "1.55.1"
}
}
+17 -92
View File
@@ -98,8 +98,24 @@
"http://localhost:8083"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8083/*"
}
},
"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": "edge-proxy",
@@ -124,97 +140,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"
}
}
],
"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'"
-150
View File
@@ -1,150 +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"}'
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)"
-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
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env; copy .env.example and set development values" >&2
exit 1
fi
set -a
. ./.env
set +a
docker compose down --volumes --remove-orphans
docker compose up --build -d --wait
npm --prefix e2e ci
E2E_USERNAME=regular-user \
E2E_PASSWORD="$REGULAR_USER_PASSWORD" \
npm --prefix e2e run test:pattern3
echo "AP3 BFF session and CSRF behavior verified"