본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
4장 : 인증과 권한 부여

JWT Resource Server와 scope 변환

직전 절의 필터 체인은 Bearer token이 유효하다고 판단할 기준이 필요합니다. Resource Server는 token을 발급하지 않고, 신뢰하는 issuer의 키로 서명과 claim을 검증합니다. 검증을 통과한 scope claim은 Spring Security에서 SCOPE_ 접두사가 붙은 authority로 바뀍니다.


API는 토큰을 발급하지 않고 검증한다

API가 자체적으로 ID·비밀번호를 받아 JWT를 만들면 자원 서버와 인증 서버의 책임이 섞입니다. 이 API는 issuer, audience, 만료 시간과 서명만 검사합니다. 키를 주기적으로 바꾸는 운영 환경에서는 JWK Set URL을 사용하고, 외부 issuer가 없는 로컬 학습에서만 공유 secret fixture를 쓹니다.


issuer 기반 JWT decoder를 연결한다

운영 설정은 issuer-uri로 발급자를 검증하고 jwk-set-uri로 서명 키를 얻습니다. 둘을 같이 주면 startup이 issuer discovery 성공에 매달리지 않으면서도 iss는 계속 검증합니다. audiences 제약은 다른 API를 위해 발급된 token이 여기서 재사용되는 것을 막습니다.

기존 src/main/resources/application.yaml의 spring 아래에 병합
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://id.example.test/realms/study-log
          jwk-set-uri: https://id.example.test/realms/study-log/protocol/openid-connect/certs
          audiences: study-log-api
src/main/java/com/andongmin/studylog/security/ScopeInspector.java
package com.andongmin.studylog.security;

import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Component
public class ScopeInspector {
    public Set<String> authorities(Authentication authentication) {
        return authentication.getAuthorities().stream()
                .map(authority -> authority.getAuthority())
                .filter(authority -> authority.startsWith("SCOPE_"))
                .collect(Collectors.toUnmodifiableSet());
    }
}

운영 profile은 위 issuer와 JWK를 검증합니다. 외부 인증 서버가 없는 로컬 학습에서는 같은 claim 검증을 유지한 채 공유 secret으로 서명한 fixture를 사용합니다. local profile에서만 다음 decoder가 활성화되므로 운영 설정을 우회하지 않습니다.

src/main/resources/application-local.yaml
study-log:
  local-jwt-secret: ${LOCAL_JWT_SECRET}
src/main/java/com/andongmin/studylog/security/LocalJwtConfiguration.java
package com.andongmin.studylog.security;

import java.nio.charset.StandardCharsets;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtAudienceValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

@Configuration(proxyBeanMethods = false)
@Profile("local")
public class LocalJwtConfiguration {
    private static final String ISSUER = "http://localhost:8080";

    @Bean
    JwtDecoder localJwtDecoder(
            @Value("${study-log.local-jwt-secret}") String secret) {
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        if (keyBytes.length < 32) {
            throw new IllegalArgumentException(
                    "LOCAL_JWT_SECRET must contain at least 32 UTF-8 bytes");
        }
        var key = new SecretKeySpec(keyBytes, "HmacSHA256");
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();
        var validator = new DelegatingOAuth2TokenValidator<Jwt>(
                JwtValidators.createDefaultWithIssuer(ISSUER),
                new JwtAudienceValidator("study-log-api"));
        decoder.setJwtValidator(validator);
        return decoder;
    }
}
dev/LocalToken.java
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class LocalToken {
    public static void main(String[] args) throws Exception {
        if (args.length != 2 || args[0].getBytes(StandardCharsets.UTF_8).length < 32) {
            throw new IllegalArgumentException(
                    "pass a secret of at least 32 UTF-8 bytes and one scope string");
        }
        String scope = args[1];
        if (!scope.matches("[a-z. ]+")) {
            throw new IllegalArgumentException(
                    "scope may contain lowercase letters, dots, and spaces");
        }
        long issuedAt = Instant.now().getEpochSecond();
        String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
        String payload = "{\"iss\":\"http://localhost:8080\"," +
                "\"aud\":[\"study-log-api\"]," +
                "\"sub\":\"local-learner\",\"scope\":\"" + scope + "\"," +
                "\"iat\":" + issuedAt + ",\"exp\":" + (issuedAt + 3600) + "}";
        String content = encode(header.getBytes(StandardCharsets.UTF_8)) + "." +
                encode(payload.getBytes(StandardCharsets.UTF_8));
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(
                args[0].getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        System.out.println(content + "." + encode(mac.doFinal(
                content.getBytes(StandardCharsets.US_ASCII))));
    }

    private static String encode(byte[] value) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
    }
}
src/main/java/com/andongmin/studylog/security/StudySessionSecurityProbeController.java
package com.andongmin.studylog.security;

import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/study-sessions")
public class StudySessionSecurityProbeController {
    @GetMapping
    Map<String, Object> read(Authentication authentication) {
        return Map.of("subject", authentication.getName(), "authenticated", true);
    }
}

study.read와 study.write claim을 확인한다

terminal A에서 local profile로 서버를 실행하면 auto-configuration은 사용자가 제공한 JwtDecoder bean을 사용하고 원격 JWK decoder는 물러납니다. terminal B에서 같은 secret으로 read와 write token을 만들어 같은 GET을 호출합니다. 서명과 issuer·audience는 둘 다 유효하므로 두 응답의 차이는 scope에서만 나와야 합니다.

terminal A — local 서버
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:run
terminal B — scope 요청
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
READ_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.read")"
WRITE_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.write")"
curl -i -H "Authorization: Bearer $READ_TOKEN" http://localhost:8080/api/study-sessions
curl -i -H "Authorization: Bearer $WRITE_TOKEN" http://localhost:8080/api/study-sessions
Authority 확인 결과
local decoder도 issuer, 서명, audience와 만료 시간을 검증합니다. study.read 토큰의 GET은 200이고 study.write만 있는 토큰의 같은 GET은 403이어야 합니다. 이후 장의 `READ_TOKEN`과 `WRITE_TOKEN`은 이 명령으로 다시 발급합니다.

URL matcher는 외부 HTTP 요청을 잘 막았습니다. 그러나 scheduler나 다른 bean이 service를 직접 호출하면 URL 규칙은 지나지 않습니다. 같은 scope 언어를 method 경계에도 배치할 이유입니다.