ResourceServer 의 /api/orders 가 scope orders:read 를 요구하게 만드세요.
TokenService.issue 에 scope 문자열을 받는 오버로드를 추가해 /token 응답의 scope 가 Access 토큰 클레임에도 들어가게 합니다.WWW-Authenticate: Bearer error="insufficient_scope" 를 돌려줍니다.ClientApp 의 인가 요청 scope 를 profile 로만 바꿔 /api/orders 가 403 이 되는 것을 확인합니다.// TokenService
public Tokens issue(String subject, List<String> roles, String audience, String scope) {
return issue(subject, roles, audience, randomToken(), scope);
}
private Tokens issue(String subject, List<String> roles, String audience, String family, String scope) {
Map<String, Object> extra = new LinkedHashMap<>(Map.of("aud", audience, "roles", roles, "jti", randomToken()));
if (scope != null && !scope.isBlank()) extra.put("scope", scope);
String access = Jwt.hs256(Jwt.claims(ISSUER, subject, accessTtl, extra), secret);
...
}
// rotate 도 RefreshRecord 에 scope 를 보관했다가 그대로 넘긴다 (회전으로 scope 가 넓어지면 안 된다)
// ResourceServer
server.createContext("/api/orders", ex -> protectedApi(ex, "orders:read", c -> Map.of("owner", c.get("sub"), "orders", List.of("ORD-1001", "ORD-1002"))));
private void protectedApi(HttpExchange ex, String requiredScope, Api api) throws IOException {
...
Map<String, Object> c = tokens.verifyAccess(bearer, AuthorizationServer.AUDIENCE);
if (requiredScope != null) {
Set<String> scopes = Set.of(String.valueOf(c.getOrDefault("scope", "")).split(" "));
if (!scopes.contains(requiredScope)) {
ex.getResponseHeaders().add("WWW-Authenticate", "Bearer error=\"insufficient_scope\", scope=\"" + requiredScope + "\"");
Http.json(ex, 403, Map.of("error", "scope 부족: " + requiredScope)); return;
}
}
Http.json(ex, 200, api.handle(c));
}401 과 403 의 구분이 여기서도 유지됩니다. 토큰은 유효하니(인증 성공) 401 이 아니고, 허락 범위 밖이니(인가 실패) 403 입니다. 회전 시 scope 를 Refresh 레코드에서 그대로 복사해야 합니다. 회전 요청에 새 scope 를 받아 주면 사용자가 동의한 적 없는 범위가 열립니다. 규격도 회전 시 scope 는 원래보다 좁게만 바꿀 수 있다고 정합니다.
Refresh 쿠키의 Path=/refresh 를 Path=/ 로 바꾸면 무엇이 달라지는지 Main.cookieFlow 에서 확인하세요.
그런 다음 AuthorizationServer 에 /logout-all 을 추가해, 현재 사용자의 모든 family(다른 기기의 세션 포함)를 폐기하도록 만드세요. TokenService 에 사용자 → family 집합 색인이 필요합니다. 두 번 로그인(두 기기)한 뒤 한쪽에서 /logout-all 을 호출하면 다른 쪽 /refresh 가 401 이 되어야 합니다.
Path=/ 로 바꾸면 Browser 흉내는 Path 를 무시하므로 출력이 같습니다. 실제 브라우저에서는 모든 요청에 refresh 쿠키가 실립니다. /api/profile 같은 리소스 요청에도 딸려 가서 로그·프록시·다른 서버에 노출됩니다.
Path 는 "이 쿠키가 필요한 유일한 경로" 로 좁혀야 합니다. 이 레슨의 Browser 가 Path 를 무시하는 것이 데모의 한계이며 소스에 "데모 한계:" 주석으로 표시해 두었습니다.
// TokenService
private final Map<String, Set<String>> familiesByUser = new ConcurrentHashMap<>();
private Tokens issue(String subject, List<String> roles, String audience, String family) {
familiesByUser.computeIfAbsent(subject, k -> ConcurrentHashMap.newKeySet()).add(family);
...
}
public int revokeAll(String subject) {
Set<String> fams = familiesByUser.getOrDefault(subject, Set.of());
revokedFamilies.addAll(fams);
refreshStore.values().removeIf(r -> fams.contains(r.family()));
return fams.size();
}
// AuthorizationServer
server.createContext("/logout-all", ex -> {
String access = Http.bearer(ex);
Map<String, Object> c;
try { c = tokens.verifyAccess(access, AUDIENCE); } catch (Jwt.JwtException e) { Http.json(ex, 401, Map.of("error", e.getMessage())); return; }
int n = tokens.revokeAll(String.valueOf(c.get("sub")));
ex.getResponseHeaders().add("Set-Cookie", Http.refreshCookie("refresh_token", "", "/refresh", 0));
Http.json(ex, 200, Map.of("revokedSessions", n));
});/logout 은 쿠키만 있으면 되지만 /logout-all 은 유효한 Access 를 요구해야 합니다. 그렇지 않으면 누구나 남의 사용자 이름으로 모든 세션을 끊는 서비스 거부가 됩니다.
이 기능은 "비밀번호 변경 시 모든 기기 로그아웃", "이 계정에서 의심스러운 활동" 대응의 기본 재료입니다. DB 라면 UPDATE refresh_token SET used_at = SYSTIMESTAMP WHERE username = ? 와 family 폐기 테이블 insert 로 끝납니다.
AuthorizationServer.token 이 인가 코드 교환 응답에 OIDC 식 id_token 을 함께 돌려주게 하세요. id_token 은 iss, sub, aud(= client_id), exp, nonce(인가 요청에서 받은 값) 를 담은 HS256 JWT 입니다.
/authorize 가 nonce 파라미터를 받아 코드와 함께 저장해야 합니다. ClientApp 은 로그인 시 nonce 를 세션에 저장하고, 콜백에서 id_token 을 검증해 aud 와 nonce 가 맞을 때만 로그인 완료로 처리하세요.
// AuthorizationServer
record AuthCode(String clientId, String redirectUri, String user, String scope, String codeChallenge, String nonce, long expiresAt, boolean used) {}
// authorize: codes.put(code, new AuthCode(..., q.get("nonce"), ...));
// token (authorization_code 분기):
String idToken = Jwt.hs256(Jwt.claims(TokenService.ISSUER, c.user(), 300,
Map.of("aud", c.clientId(), "nonce", String.valueOf(c.nonce()), "email", c.user() + "@example.com")), secret);
Http.json(ex, 200, Map.of("access_token", t.accessToken(), "refresh_token", t.refreshToken(), "id_token", idToken, "token_type", "Bearer", "expires_in", t.expiresIn(), "scope", c.scope()));
// ClientApp.login: s.nonce = random(); p.put("nonce", s.nonce);
// ClientApp.callback (토큰 교환 성공 후):
Map<String, Object> id = Jwt.verifyHs256((String) t.get("id_token"), sharedSecret); // 실제 OIDC 는 제공자 JWKS 의 공개키로 RS256 검증
if (!CLIENT_ID.equals(id.get("aud"))) { Http.text(ex, 400, "id_token aud 가 우리 앱이 아님"); return; }
if (s.nonce == null || !s.nonce.equals(id.get("nonce"))) { Http.text(ex, 400, "nonce 불일치 → 재전송된 id_token"); return; }
s.user = (String) id.get("sub"); s.nonce = null;id_token 은 "누가 로그인했는가" 를 클라이언트에게 알려 주는 토큰이고, access_token 은 "API 를 부를 권한" 입니다. OAuth2 는 후자만 정의하고 OIDC 가 전자를 얹었습니다.
nonce 가 state 와 다른 점은 묶이는 위치입니다. state 는 콜백 URL(앞 채널)에, nonce 는 id_token(뒷 채널 응답) 안에 묶입니다. 가로챈 id_token 을 다른 세션에 재전송하는 것을 nonce 가 막습니다.
aud 검사는 다른 앱용으로 발급된 id_token 을 우리 앱 로그인에 쓰는 것을 막습니다. 두 검사 모두 Spring 의 OidcIdTokenValidator 가 자동으로 합니다.