Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff6d2bfda | ||
|
|
7f47478fb8 | ||
|
|
6f1ccdd978 | ||
|
|
8961671a1c | ||
|
|
5a8bc9b145 | ||
|
|
994bef0edd | ||
|
|
84a9ca3e8f | ||
|
|
c048d00994 |
@@ -110,3 +110,25 @@ token은 명시적인 in-memory store에만 보관되므로 새로고침하면
|
|||||||
브라우저에서 `http://localhost:8088`을 열어 로그인한 뒤 보호 API를 호출할 수
|
브라우저에서 `http://localhost:8088`을 열어 로그인한 뒤 보호 API를 호출할 수
|
||||||
있습니다. SPA는 `http://localhost:8081/api/me`를 직접 호출하며 Spring
|
있습니다. SPA는 `http://localhost:8081/api/me`를 직접 호출하며 Spring
|
||||||
Resource Server가 Bearer JWT를 검증합니다.
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ spring:
|
|||||||
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8080/realms/keycloak-patterns}
|
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}
|
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:
|
management:
|
||||||
endpoint:
|
endpoint:
|
||||||
health:
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,58 @@ services:
|
|||||||
- keycloak-net
|
- keycloak-net
|
||||||
restart: unless-stopped
|
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:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|||||||
@@ -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 검증 특성을 의도적으로
|
||||||
|
유지한다.
|
||||||
@@ -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에는 여전히 노출”이다.
|
||||||
+162
-23
@@ -3,9 +3,62 @@ import { chromium } from "playwright-core";
|
|||||||
|
|
||||||
const username = process.env.E2E_USERNAME ?? "regular-user";
|
const username = process.env.E2E_USERNAME ?? "regular-user";
|
||||||
const password = process.env.E2E_PASSWORD;
|
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");
|
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({
|
const browser = await chromium.launch({
|
||||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -23,31 +76,27 @@ try {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("http://localhost:8088");
|
await page.goto(frontendUrl);
|
||||||
await page.locator("#login").click();
|
const firstTokenSet = await login(page);
|
||||||
await page.waitForURL(/localhost:8080/u);
|
|
||||||
await page.locator("#username").fill(username);
|
|
||||||
await page.locator("#password").fill(password);
|
|
||||||
await page.locator("#kc-login").click();
|
|
||||||
await page.waitForURL("http://localhost:8088/");
|
|
||||||
await page.locator('[data-authenticated="true"]').waitFor();
|
|
||||||
|
|
||||||
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
|
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
|
||||||
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
||||||
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
|
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
|
||||||
|
|
||||||
const accessToken = await page.evaluate(() => window.__pattern1.getAccessToken());
|
await page.evaluate(() => {
|
||||||
assert.ok(accessToken, "access token must exist in browser memory");
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
window.__xssProbe = { authorization: null };
|
||||||
const storageSnapshot = await page.evaluate(() => ({
|
window.fetch = (input, init = {}) => {
|
||||||
localStorage: Object.values(localStorage),
|
const headers = new Headers(
|
||||||
sessionStorage: Object.values(sessionStorage),
|
init.headers ?? (input instanceof Request ? input.headers : undefined),
|
||||||
}));
|
|
||||||
assert.equal(
|
|
||||||
JSON.stringify(storageSnapshot).includes(accessToken),
|
|
||||||
false,
|
|
||||||
"access token must not be persisted in Web Storage",
|
|
||||||
);
|
);
|
||||||
|
const authorization = headers.get("Authorization");
|
||||||
|
if (authorization) {
|
||||||
|
window.__xssProbe.authorization = authorization;
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
await page.locator("#call-api").click();
|
await page.locator("#call-api").click();
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
@@ -55,16 +104,106 @@ try {
|
|||||||
return text.includes('"httpStatus": 200');
|
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.reload();
|
||||||
await page.locator('[data-authenticated="false"]').waitFor();
|
await page.locator('[data-authenticated="false"]').waitFor();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(() => window.__pattern1.getAccessToken()),
|
await page.evaluate(
|
||||||
null,
|
(token) => JSON.stringify({
|
||||||
"reload must clear the memory-only token",
|
localStorage: Object.values(localStorage),
|
||||||
|
sessionStorage: Object.values(sessionStorage),
|
||||||
|
}).includes(token),
|
||||||
|
thirdTokenSet.access_token,
|
||||||
|
),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"pattern1 browser verified: code+PKCE S256, protected API 200, Web Storage token 0, reload clears token",
|
"pattern1 verified: PKCE, aud/iss negatives, memory/XSS boundary, logout, RT rotation/reuse, revoke-vs-stateless JWT"
|
||||||
|
+ ` (RT2 after RT1 reuse: ${rotatedAfterReuseStatus})`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -122,13 +122,6 @@ userManager.events.addUserLoaded(renderSession);
|
|||||||
userManager.events.addUserUnloaded(() => renderSession(null));
|
userManager.events.addUserUnloaded(() => renderSession(null));
|
||||||
userManager.events.addAccessTokenExpired(() => renderSession(null));
|
userManager.events.addAccessTokenExpired(() => renderSession(null));
|
||||||
|
|
||||||
window.__pattern1 = {
|
|
||||||
getAccessToken: () => currentUser?.access_token ?? null,
|
|
||||||
getRefreshToken: () => currentUser?.refresh_token ?? null,
|
|
||||||
getIdToken: () => currentUser?.id_token ?? null,
|
|
||||||
callProtectedApi,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbackUser = await finishSigninCallback();
|
const callbackUser = await finishSigninCallback();
|
||||||
renderSession(callbackUser ?? await userManager.getUser());
|
renderSession(callbackUser ?? await userManager.getUser());
|
||||||
|
|||||||
@@ -51,7 +51,22 @@
|
|||||||
"attributes": {
|
"attributes": {
|
||||||
"pkce.code.challenge.method": "S256",
|
"pkce.code.challenge.method": "S256",
|
||||||
"post.logout.redirect.uris": "http://localhost:8088/*##http://127.0.0.1:8088/*"
|
"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",
|
"clientId": "token-mediating-confidential",
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ def validate(path: Path, runtime: bool) -> None:
|
|||||||
spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256",
|
spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256",
|
||||||
"spa-public must enforce PKCE 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():
|
for client_id, placeholder in CONFIDENTIAL_CLIENTS.items():
|
||||||
client = clients[client_id]
|
client = clients[client_id]
|
||||||
|
|||||||
@@ -12,10 +12,15 @@ set +a
|
|||||||
|
|
||||||
docker compose down --volumes --remove-orphans
|
docker compose down --volumes --remove-orphans
|
||||||
docker compose up --build -d --wait
|
docker compose up --build -d --wait
|
||||||
|
docker compose --profile diagnostics up -d --wait \
|
||||||
|
app-wrong-audience \
|
||||||
|
app-wrong-issuer
|
||||||
|
|
||||||
npm --prefix e2e ci
|
npm --prefix e2e ci
|
||||||
E2E_USERNAME=regular-user \
|
E2E_USERNAME=regular-user \
|
||||||
E2E_PASSWORD="$REGULAR_USER_PASSWORD" \
|
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
|
npm --prefix e2e run test:pattern1
|
||||||
|
|
||||||
echo "AP1 verified end to end"
|
echo "AP1 verified end to end"
|
||||||
|
|||||||
Reference in New Issue
Block a user