-
Notifications
You must be signed in to change notification settings - Fork 0
[FIX/#117] 애플 로그인 revoke 시 authorization code 만료 이슈 대응 #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9044377
2828d8d
28f8c0d
e089f6d
bbad668
c0aacc6
ca1eadb
8aefd7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
|
|
||
| public record LoginCommand( | ||
| OAuthProvider provider, | ||
| String idToken | ||
| String idToken, | ||
| String authorizationCode | ||
| ) { | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package org.sopt.routee.external.api.port; | ||
|
|
||
| import org.sopt.routee.external.api.type.OAuthProvider; | ||
|
|
||
| public interface OAuthRefreshTokenExchangePort { | ||
|
|
||
| String exchangeForRefreshToken(OAuthProvider provider, String authorizationCode); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,5 +2,5 @@ | |
|
|
||
| public interface OAuthRevokePort { | ||
|
|
||
| void revoke(String authorizationCode); | ||
| void revoke(String refreshToken); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package org.sopt.routee.external.internal.oauth.adapter; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonProperty; | ||
|
|
||
| record AppleOAuthErrorResponse( | ||
| @JsonProperty("error") String error | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package org.sopt.routee.external.internal.oauth.adapter; | ||
|
|
||
| import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.util.LinkedMultiValueMap; | ||
| import org.springframework.util.MultiValueMap; | ||
| import org.springframework.web.client.RestClient; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| class AppleOAuthFormClient { | ||
|
|
||
| private final RestClient restClient; | ||
| private final OAuthRevokeProperty property; | ||
| private final AppleClientSecretGenerator clientSecretGenerator; | ||
|
|
||
| MultiValueMap<String, String> credentialForm() { | ||
| MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); | ||
|
|
||
| form.add("client_id", property.clientId()); | ||
| form.add("client_secret", clientSecretGenerator.generate()); | ||
|
|
||
| return form; | ||
| } | ||
|
|
||
| <T> T post(String uri, MultiValueMap<String, String> form, Class<T> responseType) { | ||
| return restClient.post() | ||
| .uri(uri) | ||
| .contentType(MediaType.APPLICATION_FORM_URLENCODED) | ||
| .body(form) | ||
| .retrieve() | ||
| .body(responseType); | ||
| } | ||
|
|
||
| String tokenUri() { | ||
| return property.tokenUri(); | ||
| } | ||
|
|
||
| String revokeUri() { | ||
| return property.revokeUri(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package org.sopt.routee.external.internal.oauth.adapter; | ||
|
|
||
| import org.sopt.routee.external.api.port.OAuthRefreshTokenExchangePort; | ||
| import org.sopt.routee.external.api.type.OAuthProvider; | ||
| import org.sopt.routee.external.internal.oauth.exception.OAuthAuthorizationCodeExpiredException; | ||
| import org.sopt.routee.external.internal.oauth.exception.OAuthRefreshTokenExchangeException; | ||
| import org.sopt.routee.external.internal.oidc.exception.UnsupportedOidcProviderException; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.util.MultiValueMap; | ||
| import org.springframework.util.StringUtils; | ||
| import org.springframework.web.client.HttpClientErrorException; | ||
| import org.springframework.web.client.RestClientException; | ||
|
|
||
| import tools.jackson.core.JacksonException; | ||
| import tools.jackson.databind.ObjectMapper; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| class AppleOAuthRefreshTokenExchangeAdapter implements OAuthRefreshTokenExchangePort { | ||
|
|
||
| private static final String INVALID_GRANT = "invalid_grant"; | ||
|
|
||
| private final AppleOAuthFormClient client; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @Override | ||
| public String exchangeForRefreshToken(OAuthProvider provider, String authorizationCode) { | ||
| if (provider != OAuthProvider.APPLE) { | ||
| throw new UnsupportedOidcProviderException(); | ||
| } | ||
|
|
||
| MultiValueMap<String, String> form = client.credentialForm(); | ||
|
|
||
| form.add("grant_type", "authorization_code"); | ||
| form.add("code", authorizationCode); | ||
|
|
||
| try { | ||
| OAuthTokenResponse response = client.post(client.tokenUri(), form, OAuthTokenResponse.class); | ||
|
|
||
| if (response == null || !StringUtils.hasText(response.refreshToken())) { | ||
| throw new OAuthRefreshTokenExchangeException(); | ||
| } | ||
|
|
||
| return response.refreshToken(); | ||
| } catch (HttpClientErrorException e) { | ||
| if (isInvalidGrant(e)) { | ||
| throw new OAuthAuthorizationCodeExpiredException(e); | ||
| } | ||
| throw new OAuthRefreshTokenExchangeException(e); | ||
| } catch (RestClientException e) { | ||
| throw new OAuthRefreshTokenExchangeException(e); | ||
| } | ||
| } | ||
|
|
||
| private boolean isInvalidGrant(HttpClientErrorException e) { | ||
| try { | ||
| AppleOAuthErrorResponse errorResponse = | ||
| objectMapper.readValue(e.getResponseBodyAsString(), AppleOAuthErrorResponse.class); | ||
| return INVALID_GRANT.equals(errorResponse.error()); | ||
| } catch (JacksonException parseException) { | ||
| return false; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,81 +1,58 @@ | ||
| package org.sopt.routee.external.internal.oauth.adapter; | ||
|
|
||
| import java.util.Set; | ||
|
|
||
| import org.sopt.routee.external.api.port.OAuthRevokePort; | ||
| import org.sopt.routee.external.internal.oauth.config.OAuthRevokeProperty; | ||
| import org.sopt.routee.external.internal.oauth.exception.OAuthRevokeException; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.util.LinkedMultiValueMap; | ||
| import org.springframework.util.MultiValueMap; | ||
| import org.springframework.util.StringUtils; | ||
| import org.springframework.web.client.RestClient; | ||
| import org.springframework.web.client.HttpClientErrorException; | ||
| import org.springframework.web.client.RestClientException; | ||
|
|
||
| import tools.jackson.core.JacksonException; | ||
| import tools.jackson.databind.ObjectMapper; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| class AppleOAuthRevokeAdapter implements OAuthRevokePort { | ||
|
|
||
| private static final String REFRESH_TOKEN_HINT = "refresh_token"; | ||
| private static final Set<String> ALREADY_INVALID_ERRORS = Set.of("invalid_token", "invalid_grant"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,90p' routee-external/src/main/java/org/sopt/routee/external/internal/oauth/adapter/AppleOAuthRevokeAdapter.java
sed -n '135,195p' routee-member/src/main/java/org/sopt/routee/member/internal/service/MemberService.java
rg -n 'invalid_grant|invalid_token|ALREADY_INVALID_ERRORS|revokeOAuthConnection' routee-* --glob '*.java'Repository: Team-Routee/Routee-Server Length of output: 4663 🌐 Web query:
💡 Result: <search_synthesis> <source_evidence> Citations:
🏁 Script executed: #!/bin/bash
set -o pipefail
printf '%s\n' '--- candidate files ---'
git ls-files | rg 'AppleOAuth(Revoke|Form|).*|OAuthRevoke|MemberService|oauth|apple' | head -120
printf '%s\n' '--- adapter callers and tests ---'
rg -n -C 3 'AppleOAuthRevokeAdapter|AppleOAuthFormClient|OAuthRevokePort|credentialForm|invalid_grant|invalid_token|withdraw\\(' routee-external routee-member --glob '*.java' --glob '*.md' --glob '*.adoc' --glob '*.yml' --glob '*.yaml'
printf '%s\n' '--- adapter/client outlines ---'
for f in $(git ls-files | rg 'AppleOAuth(RevokeAdapter|FormClient)|OAuthRevoke' | head -20); do
echo "FILE: $f"
wc -l "$f"
sed -n '1,220p' "$f"
done
printf '%s\n' '--- focused tests/docs ---'
git ls-files | rg -i 'test|readme|doc|apple|oauth' | while read -r f; do
rg -n -H 'invalid_grant|invalid_token|revoke|Apple' "$f" 2>/dev/null
done | head -240Repository: Team-Routee/Routee-Server Length of output: 14979 🌐 Web query:
💡 Result: <search_synthesis> <source_evidence> Citations:
수정 예시- private static final Set<String> ALREADY_INVALID_ERRORS = Set.of("invalid_token", "invalid_grant");
-
...
- if (isAlreadyInvalid(e)) {
- log.info("Apple OAuth token already invalid/revoked. Treating as success.");
- return;
- }
throw new OAuthRevokeException(e);🤖 Prompt for AI Agents |
||
|
|
||
| private final RestClient restClient; | ||
| private final OAuthRevokeProperty property; | ||
| private final AppleClientSecretGenerator clientSecretGenerator; | ||
| private final AppleOAuthFormClient client; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @Override | ||
| public void revoke(String authorizationCode) { | ||
| OAuthTokenResponse token = exchangeAuthorizationCode(authorizationCode); | ||
|
|
||
| if (!StringUtils.hasText(token.refreshToken())) { | ||
| throw new OAuthRevokeException(); | ||
| } | ||
|
|
||
| requestRevoke(token.refreshToken(), REFRESH_TOKEN_HINT); | ||
| } | ||
|
|
||
| private OAuthTokenResponse exchangeAuthorizationCode(String authorizationCode) { | ||
| MultiValueMap<String, String> form = credentialForm(); | ||
|
|
||
| form.add("grant_type", "authorization_code"); | ||
| form.add("code", authorizationCode); | ||
|
|
||
| OAuthTokenResponse response = post(property.tokenUri(), form, OAuthTokenResponse.class); | ||
|
|
||
| if (response == null) { | ||
| throw new OAuthRevokeException(); | ||
| } | ||
|
|
||
| return response; | ||
| } | ||
| public void revoke(String refreshToken) { | ||
| MultiValueMap<String, String> form = client.credentialForm(); | ||
|
|
||
| private void requestRevoke(String token, String tokenTypeHint) { | ||
| MultiValueMap<String, String> form = credentialForm(); | ||
| form.add("token", token); | ||
| form.add("token_type_hint", tokenTypeHint); | ||
|
|
||
| post(property.revokeUri(), form, Void.class); | ||
| } | ||
| form.add("token", refreshToken); | ||
| form.add("token_type_hint", REFRESH_TOKEN_HINT); | ||
|
|
||
| private <T> T post(String uri, MultiValueMap<String, String> form, Class<T> responseType) { | ||
| try { | ||
| return restClient.post() | ||
| .uri(uri) | ||
| .contentType(MediaType.APPLICATION_FORM_URLENCODED) | ||
| .body(form) | ||
| .retrieve() | ||
| .body(responseType); | ||
| client.post(client.revokeUri(), form, Void.class); | ||
| } catch (HttpClientErrorException e) { | ||
| if (isAlreadyInvalid(e)) { | ||
| log.info("Apple OAuth token already invalid/revoked. Treating as success."); | ||
| return; | ||
| } | ||
| throw new OAuthRevokeException(e); | ||
| } catch (RestClientException e) { | ||
| throw new OAuthRevokeException(e); | ||
| } | ||
| } | ||
|
|
||
| private MultiValueMap<String, String> credentialForm() { | ||
| MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); | ||
|
|
||
| form.add("client_id", property.clientId()); | ||
| form.add("client_secret", clientSecretGenerator.generate()); | ||
|
|
||
| return form; | ||
| private boolean isAlreadyInvalid(HttpClientErrorException e) { | ||
| try { | ||
| AppleOAuthErrorResponse errorResponse = | ||
| objectMapper.readValue(e.getResponseBodyAsString(), AppleOAuthErrorResponse.class); | ||
| return ALREADY_INVALID_ERRORS.contains(errorResponse.error()); | ||
| } catch (JacksonException parseException) { | ||
| return false; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package org.sopt.routee.external.internal.oauth.exception; | ||
|
|
||
| import org.sopt.routee.exception.BaseException; | ||
| import org.sopt.routee.external.internal.oauth.code.ErrorCode; | ||
|
|
||
| public final class OAuthAuthorizationCodeExpiredException extends BaseException { | ||
|
|
||
| public OAuthAuthorizationCodeExpiredException(Throwable cause) { | ||
| super(ErrorCode.AUTHORIZATION_CODE_EXPIRED, cause); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
기존 revoke 테스트를 refresh token 계약에 맞게 수정하십시오.
MemberServiceTest는 아직revoke(AUTHORIZATION_CODE)를 검증합니다. 인자 타입이 모두String이므로 이 오래된 계약은 컴파일 단계에서 검출되지 않습니다.Credential 저장소에 refresh token을 설정하십시오. 검증과 예외 테스트도
REFRESH_TOKEN을 사용하도록 수정하십시오.🤖 Prompt for AI Agents