이 레슨의 클라이언트는 비밀 없이 PKCE 만 쓰는 공개 클라이언트입니다. 서버 사이드 앱은 인가 서버에 등록한 client_secret 을 뒷 채널에서만 제시합니다. 규격은 폼 필드보다 HTTP Basic 을 권장합니다.
// ClientApp.post 에 추가
String basic = Base64.getEncoder().encodeToString((CLIENT_ID + ":" + CLIENT_SECRET).getBytes(StandardCharsets.UTF_8));
builder.header("Authorization", "Basic " + basic);
// AuthorizationServer.token 에 추가
String auth = ex.getRequestHeaders().getFirst("Authorization");
String[] idSecret = new String(Base64.getDecoder().decode(auth.substring(6)), StandardCharsets.UTF_8).split(":", 2);
if (!MessageDigest.isEqual(registeredSecret(idSecret[0]).getBytes(), idSecret[1].getBytes())) { Http.json(ex, 401, Map.of("error", "invalid_client")); return; }기밀 클라이언트라도 PKCE 는 유지합니다. 비밀은 "이 요청이 우리 앱 서버에서 왔다" 를 증명하고, PKCE 는 "이 코드를 받은 그 세션이다" 를 증명합니다. 서로 다른 것을 막습니다. client_secret 은 브라우저 JS 번들이나 모바일 앱 바이너리에 절대 넣지 않습니다. 디컴파일 한 번이면 끝입니다.
인가 요청의 scope=profile orders:read 가 토큰에 실려 리소스 서버에 도착합니다. 리소스 서버는 엔드포인트마다 필요한 scope 를 검사합니다. 사용자의 역할(ROLE_ADMIN)과 클라이언트 앱에 허락한 범위(orders:read)는 다른 축입니다. 관리자가 로그인했어도 "프로필만 읽기" 로 동의한 앱은 주문을 못 봅니다.
// TokenService.issue 에 scope 를 클레임으로 추가한 뒤, ResourceServer 에서
private void protectedApi(HttpExchange ex, String requiredScope, Api api) throws IOException {
Map<String, Object> c = tokens.verifyAccess(bearer, AUDIENCE);
Set<String> scopes = Set.of(String.valueOf(c.getOrDefault("scope", "")).split(" "));
if (requiredScope != null && !scopes.contains(requiredScope)) {
ex.getResponseHeaders().add("WWW-Authenticate", "Bearer error=\"insufficient_scope\", scope=\"" + requiredScope + "\"");
Http.json(ex, 403, Map.of("error", "scope 부족: " + requiredScope)); return; // 인증은 됐으니 401 이 아니라 403
}
...
}Spring 에서는 .requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders:read") 한 줄입니다. Resource Server 가 scope 클레임을 SCOPE_ 접두어 권한으로 자동 변환합니다.
구글 인가 서버에서 토큰을 받았다고 로그인이 끝난 게 아닙니다. 그 토큰은 구글 API 용이지 우리 API 용이 아닙니다.
// ClientApp.callback 의 토큰 교환 직후
Map<String, Object> me = MiniJson.read(get(providerUserInfoUrl, providerAccessToken).body()); // {"sub":"1093...","email":"[email protected]","email_verified":true}
if (!Boolean.TRUE.equals(me.get("email_verified"))) { Http.text(ex, 403, "이메일 미검증 계정"); return; }
String localUser = accounts.findByProvider("google", (String) me.get("sub")) // provider + provider sub 로 연결. 이메일로 연결하면 이메일 재사용 시 계정 탈취
.orElseGet(() -> accounts.createFrom("google", me));
TokenService.Tokens ours = ourTokens.issue(localUser, roles(localUser), "resource-api"); // 우리 토큰 발급 → 이후는 자사 로그인과 동일연결 키는 이메일이 아니라 provider 이름 + provider 의 sub 입니다. 이메일은 바뀌고 재사용되지만 sub 는 그 제공자 안에서 영구 고유값입니다.
OIDC(OpenID Connect) 를 쓰면 이 사용자 정보가 id_token(JWT) 으로 토큰 응답에 같이 옵니다. 그 경우 id_token 의 서명을 제공자 공개키(JWKS)로 검증하고 aud 가 우리 client_id 인지, nonce 가 우리가 보낸 값인지 확인합니다.
refreshStore 는 Map 입니다. 서버가 둘 이상이거나 재시작되면 쓸 수 없습니다. 실무는 DB 나 Redis 입니다.
CREATE TABLE refresh_token (
token_hash VARCHAR2(64) PRIMARY KEY, -- SHA-256(token). 원본은 저장하지 않는다. DB 가 털려도 토큰은 못 쓴다
family_id VARCHAR2(43) NOT NULL,
username VARCHAR2(50) NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP, -- NULL 이면 미사용
created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE INDEX ix_refresh_family ON refresh_token(family_id);
-- 회전: UPDATE ... SET used_at = SYSTIMESTAMP WHERE token_hash = ? AND used_at IS NULL → 갱신 건수 0 이면 재사용 → family 전체 폐기
-- 청소: 배치(@Scheduled) 가 DELETE WHERE expires_at < SYSDATE - 1해시해서 저장하는 이유는 비밀번호와 같습니다. 저장소 유출이 곧 세션 탈취가 되지 않게 합니다. 조회는 SHA-256(받은 토큰) 으로 하면 되니 성능 부담도 없습니다.
회전을 UPDATE ... WHERE used_at IS NULL 하나로 처리하면 두 요청이 동시에 와도 한쪽만 성공하는 원자성이 DB 에서 보장됩니다. Redis 라면 키에 TTL 을 걸어 청소 배치가 필요 없습니다. jti 거부 목록도 Redis SET jti 1 EX 남은초 가 정석입니다.