Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ data class SocialLoginRequest private constructor(
val oauthToken: String? = null,

@field:Schema(
description = "Authorization code used to issue Apple access/refresh tokens (required only for Apple login)",
description = "Authorization code used to issue access/refresh tokens (required for Apple and Google login)",
example = "c322a426...",
required = false
)
Expand Down Expand Up @@ -63,7 +63,14 @@ data class SocialLoginRequest private constructor(
AppleAuthCredentials(request.validOauthToken(), authCode)
}

ProviderType.GOOGLE -> GoogleAuthCredentials(request.validOauthToken())
ProviderType.GOOGLE -> {
val authCode = request.authorizationCode
?: throw AuthException(
AuthErrorCode.INVALID_REQUEST,
"Google login requires an authorization code."
)
GoogleAuthCredentials(request.validOauthToken(), authCode)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import org.yapp.apis.auth.exception.AuthErrorCode
import org.yapp.apis.auth.exception.AuthException
import org.yapp.apis.config.GoogleOauthProperties
import org.yapp.infra.external.oauth.google.GoogleApi
import org.yapp.infra.external.oauth.google.response.GoogleUserInfo // Changed to GoogleUserInfo
import org.yapp.infra.external.oauth.google.response.GoogleTokenResponse
import org.yapp.infra.external.oauth.google.response.GoogleUserInfo

@Component
class GoogleApiManager(
Expand All @@ -16,7 +17,57 @@ class GoogleApiManager(
) {
private val log = KotlinLogging.logger {}

fun getUserInfo(accessToken: String): GoogleUserInfo { // Changed to GoogleUserInfo
// Note: ID tokens cannot be exchanged for access tokens with Google's token endpoint.
// The ID token should be validated directly using GoogleIdTokenProcessor.
// If an access token is needed, use the authorization code flow instead.

fun exchangeAuthorizationCode(authorizationCode: String): GoogleTokenResponse {
val tokenUri = googleOauthProperties.url.tokenUri
?: throw AuthException(
AuthErrorCode.OAUTH_SERVER_ERROR,
"Google token URI is not configured."
)

val redirectUri = googleOauthProperties.redirectUri
?: throw AuthException(
AuthErrorCode.OAUTH_SERVER_ERROR,
"Google redirect URI is not configured."
)

val clientSecret = googleOauthProperties.clientSecret
?: throw AuthException(
AuthErrorCode.OAUTH_SERVER_ERROR,
"Google client secret is not configured."
)

return googleApi.exchangeAuthorizationCode(
code = authorizationCode,
clientId = googleOauthProperties.clientId,
clientSecret = clientSecret,
redirectUri = redirectUri,
tokenExchangeUrl = tokenUri
)
.onSuccess { tokenResponse ->
log.info { "Successfully exchanged Google authorization code for tokens" }
}
Comment on lines +50 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | πŸ”΅ Trivial

onSuccess μ½œλ°±μ—μ„œ μ‚¬μš©ν•˜μ§€ μ•ŠλŠ” νŒŒλΌλ―Έν„°λ₯Ό _둜 λ³€κ²½ν•˜μ„Έμš”.

tokenResponse νŒŒλΌλ―Έν„°κ°€ μ‚¬μš©λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

♻️ μˆ˜μ • μ œμ•ˆ
-            .onSuccess { tokenResponse ->
+            .onSuccess { _ ->
                 log.info { "Successfully exchanged Google authorization code for tokens" }
             }

λ˜λŠ” νŒŒλΌλ―Έν„°λ₯Ό μ™„μ „νžˆ μƒλž΅:

-            .onSuccess { tokenResponse ->
+            .onSuccess {
                 log.info { "Successfully exchanged Google authorization code for tokens" }
             }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.onSuccess { tokenResponse ->
log.info { "Successfully exchanged Google authorization code for tokens" }
}
.onSuccess { _ ->
log.info { "Successfully exchanged Google authorization code for tokens" }
}
πŸ€– Prompt for AI Agents
In `@apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt` around
lines 50 - 52, onSuccess μ½œλ°±μ—μ„œ 전달받은 tokenResponse νŒŒλΌλ―Έν„°κ°€ μ‚¬μš©λ˜κ³  μžˆμ§€ μ•ŠμŠ΅λ‹ˆλ‹€;
GoogleApiManager.kt λ‚΄λΆ€μ˜ .onSuccess { tokenResponse -> ... }λ₯Ό μ°Ύμ•„ νŒŒλΌλ―Έν„° 이름을 μ‚¬μš©ν•˜μ§€
μ•ŠμŒμ„ λͺ…μ‹œν•˜κΈ° μœ„ν•΄ μ–Έλ”μŠ€μ½”μ–΄λ‘œ λ°”κΎΈκ±°λ‚˜ νŒŒλΌλ―Έν„°λ₯Ό μ™„μ „νžˆ μƒλž΅ν•˜μ—¬ .onSuccess { _ -> ... } λ˜λŠ” .onSuccess {
... } ν˜•μ‹μœΌλ‘œ μˆ˜μ •ν•˜μ„Έμš”.

.getOrElse { exception ->
log.error(exception) { "Failed to exchange Google authorization code" }

when (exception) {
is HttpClientErrorException -> throw AuthException(
AuthErrorCode.INVALID_OAUTH_TOKEN,
"Invalid Google Authorization Code.",
)

else -> throw AuthException(
AuthErrorCode.OAUTH_SERVER_ERROR,
"Failed to communicate with Google server.",
)
}
}
}

fun getUserInfo(accessToken: String): GoogleUserInfo {
return googleApi.fetchUserInfo(accessToken, googleOauthProperties.url.userInfo)
.onSuccess { userInfo ->
log.info { "Successfully fetched Google user info for userId: ${userInfo.id}" }
Expand All @@ -42,4 +93,4 @@ class GoogleApiManager(
googleApi.revokeGoogleToken(token)
.getOrThrow()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.yapp.apis.user.dto.request.FindOrCreateUserRequest
import org.yapp.apis.user.dto.request.SaveAppleRefreshTokenRequest
import org.yapp.apis.user.dto.request.SaveGoogleRefreshTokenRequest
import org.yapp.apis.user.dto.response.CreateUserResponse
import org.yapp.apis.user.service.UserAccountService
import org.yapp.globalutils.annotation.ApplicationService
Expand All @@ -15,17 +16,32 @@ class UserSignInService(
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun processSignIn(
@Valid request: FindOrCreateUserRequest, appleRefreshToken: String?
@Valid request: FindOrCreateUserRequest,
appleRefreshToken: String?,
googleRefreshToken: String?
): CreateUserResponse {
val initialUserResponse = userAccountService.findOrCreateUser(request)

return appleRefreshToken.takeIf { !it.isNullOrBlank() }
?.let { token ->
userAccountService.updateAppleRefreshToken(
SaveAppleRefreshTokenRequest.of(
initialUserResponse, token
)
var userResponse = initialUserResponse

// Update Apple refresh token if provided
if (!appleRefreshToken.isNullOrBlank()) {
userResponse = userAccountService.updateAppleRefreshToken(
SaveAppleRefreshTokenRequest.of(
userResponse, appleRefreshToken
)
)
}

// Update Google refresh token if provided
if (!googleRefreshToken.isNullOrBlank()) {
userResponse = userAccountService.updateGoogleRefreshToken(
SaveGoogleRefreshTokenRequest.of(
userResponse, googleRefreshToken
)
} ?: initialUserResponse
)
}
Comment on lines +25 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

var μž¬ν• λ‹Ή 체인이 μ •ν™•ν•˜κ²Œ λ™μž‘ν•˜λŠ”μ§€ 확인 ν•„μš”

ν˜„μž¬ userResponseκ°€ Apple 토큰 μ—…λ°μ΄νŠΈ κ²°κ³Όλ₯Ό λ°›κ³ , μ΄μ–΄μ„œ Google 토큰 μ—…λ°μ΄νŠΈμ—λ„ λ™μΌν•œ userResponseλ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€. μ‹€μ œ 둜그인 ν”Œλ‘œμš°μ—μ„œλŠ” Apple λ˜λŠ” Google 쀑 ν•˜λ‚˜λ§Œ non-nullμ΄λ―€λ‘œ 문제 μ—†μ§€λ§Œ, λ§Œμ•½ 두 토큰이 λ™μ‹œμ— μ „λ‹¬λ˜λ©΄ 두 번의 DB μ—…λ°μ΄νŠΈκ°€ 순차 μ‹€ν–‰λ©λ‹ˆλ‹€.

이 λ™μž‘μ΄ μ˜λ„λœ 것인지 ν™•μΈν•˜κ±°λ‚˜, λ°©μ–΄μ μœΌλ‘œ λ‘˜ λ‹€ μ „λ‹¬λœ 경우λ₯Ό μ°¨λ‹¨ν•˜λŠ” 것을 κ³ λ €ν•΄ μ£Όμ„Έμš”.

λ°©μ–΄ μ½”λ“œ μ˜ˆμ‹œ
 fun processSignIn(
     `@Valid` request: FindOrCreateUserRequest,
     appleRefreshToken: String?,
     googleRefreshToken: String?
 ): CreateUserResponse {
+    require(!(appleRefreshToken != null && googleRefreshToken != null)) {
+        "Appleκ³Ό Google refresh token이 λ™μ‹œμ— 전달될 수 μ—†μŠ΅λ‹ˆλ‹€."
+    }
     val initialUserResponse = userAccountService.findOrCreateUser(request)
πŸ€– Prompt for AI Agents
In `@apis/src/main/kotlin/org/yapp/apis/auth/service/UserSignInService.kt` around
lines 25 - 43, The code currently reassigns userResponse from
initialUserResponse after the Apple update and then uses that potentially
modified value for the Google update (symbols: userResponse,
initialUserResponse, appleRefreshToken, googleRefreshToken,
userAccountService.updateAppleRefreshToken, updateGoogleRefreshToken), which
causes two sequential DB updates if both tokens are provided; change this by
adding a defensive check that disallows both appleRefreshToken and
googleRefreshToken being non-blank (throw a clear IllegalArgumentException or
custom BadRequestException) before performing any updates, or explicitly
document and retain the dual-update behaviorβ€”implement the guard near the start
of the method to validate tokens and return/throw before calling
updateAppleRefreshToken/updateGoogleRefreshToken.


return userResponse
Comment on lines 18 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | πŸ”΅ Trivial

ν™•μž₯성을 κ³ λ €ν•œ λ¦¬νŒ©ν† λ§ μ œμ•ˆ: μ†Œμ…œ ν”„λ‘œλ°”μ΄λ” μΆ”κ°€ μ‹œ νŒŒλΌλ―Έν„° 증가 문제

ν˜„μž¬ Apple/Google 두 개 ν”„λ‘œλ°”μ΄λ”μ— λŒ€ν•΄ 각각 nullable νŒŒλΌλ―Έν„°λ₯Ό μΆ”κ°€ν•˜λŠ” 방식은 λͺ…ν™•ν•˜κ³  λ™μž‘μ— λ¬Έμ œκ°€ μ—†μŠ΅λ‹ˆλ‹€. λ‹€λ§Œ, ν–₯ν›„ ν”„λ‘œλ°”μ΄λ”κ°€ 좔가될 λ•Œλ§ˆλ‹€ processSignIn의 μ‹œκ·Έλ‹ˆμ²˜κ°€ 계속 λŠ˜μ–΄λ‚˜κ²Œ λ©λ‹ˆλ‹€.

ν”„λ‘œλ°”μ΄λ”λ³„ refresh token을 ν•˜λ‚˜μ˜ sealed class λ˜λŠ” map 기반 νŒŒλΌλ―Έν„°λ‘œ ν†΅ν•©ν•˜λ©΄ ν™•μž₯ μ‹œ μ‹œκ·Έλ‹ˆμ²˜ λ³€κ²½ 없이 λŒ€μ‘ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

μ˜ˆμ‹œ: sealed class ν™œμš©
sealed class SocialRefreshToken {
    data class Apple(val refreshToken: String) : SocialRefreshToken()
    data class Google(val refreshToken: String) : SocialRefreshToken()
}

fun processSignIn(
    `@Valid` request: FindOrCreateUserRequest,
    socialRefreshToken: SocialRefreshToken?
): CreateUserResponse {
    val initialUserResponse = userAccountService.findOrCreateUser(request)
    return when (socialRefreshToken) {
        is SocialRefreshToken.Apple -> userAccountService.updateAppleRefreshToken(
            SaveAppleRefreshTokenRequest.of(initialUserResponse, socialRefreshToken.refreshToken)
        )
        is SocialRefreshToken.Google -> userAccountService.updateGoogleRefreshToken(
            SaveGoogleRefreshTokenRequest.of(initialUserResponse, socialRefreshToken.refreshToken)
        )
        null -> initialUserResponse
    }
}
πŸ€– Prompt for AI Agents
In `@apis/src/main/kotlin/org/yapp/apis/auth/service/UserSignInService.kt` around
lines 18 - 45, ν˜„μž¬ processSignIn의 μ‹œκ·Έλ‹ˆμ²˜κ°€ appleRefreshToken: String? ,
googleRefreshToken: String? 처럼 ν”„λ‘œλ°”μ΄λ”λ³„ nullable νŒŒλΌλ―Έν„°λ‘œ λ˜μ–΄ μžˆμ–΄ ν”„λ‘œλ°”μ΄λ”κ°€ λŠ˜μ–΄λ‚˜λ©΄ μ‹œκ·Έλ‹ˆμ²˜κ°€ 계속
μ»€μ§‘λ‹ˆλ‹€; 이λ₯Ό 고치렀면 FindOrCreateUserRequestλ₯Ό κ·ΈλŒ€λ‘œ λ°›κ³  ν”„λ‘œλ°”μ΄λ”λ³„ refresh token을 ν•˜λ‚˜μ˜ 인자(예:
sealed class SocialRefreshToken λ˜λŠ” Map<String,String> socialRefreshTokens)둜 ν†΅ν•©ν•œ
λ’€ processSignIn λ‚΄λΆ€μ—μ„œ initialUserResponseλ₯Ό 받은 ν›„ socialRefreshTokensλ₯Ό κ²€μ‚¬ν•˜μ—¬ 각
ν”„λ‘œλ°”μ΄λ”μ— λ§žλŠ” update λ©”μ„œλ“œ(userAccountService.updateAppleRefreshToken,
updateGoogleRefreshToken λ“±)λ₯Ό ν˜ΈμΆœν•˜λ„λ‘ λΆ„κΈ° λ˜λŠ” 반볡 처리둜 λ³€κ²½ν•˜μ„Έμš”; μƒˆλ‘œμš΄ ν”„λ‘œλ°”μ΄λ”κ°€ μΆ”κ°€λ˜λ©΄ sealed
class에 μΌ€μ΄μŠ€λ₯Ό μΆ”κ°€ν•˜κ±°λ‚˜ λ§΅ ν‚€λ§Œ μΆ”κ°€ν•˜λ©΄ λ˜λ„λ‘ κ΅¬ν˜„μ„ λ³€κ²½ν•˜λ©΄ λ©λ‹ˆλ‹€.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ data class AppleAuthCredentials(

data class GoogleAuthCredentials(
val idToken: String,
val authorizationCode: String,
) : SignInCredentials() {
override fun getProviderType(): ProviderType {
return ProviderType.GOOGLE
}
}
Comment on lines 22 to 29
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | πŸ”΅ Trivial

getProviderType() μŠ€νƒ€μΌμ΄ λ‹€λ₯Έ credentials ν΄λž˜μŠ€μ™€ μΌκ΄€λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

KakaoAuthCredentials와 AppleAuthCredentialsλŠ” expression body(= ProviderType.X)λ₯Ό μ‚¬μš©ν•˜μ§€λ§Œ, GoogleAuthCredentials만 block bodyλ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.

♻️ 일관성을 μœ„ν•œ μˆ˜μ • μ œμ•ˆ
 data class GoogleAuthCredentials(
     val idToken: String,
     val authorizationCode: String,
 ) : SignInCredentials() {
-    override fun getProviderType(): ProviderType {
-        return ProviderType.GOOGLE
-    }
+    override fun getProviderType(): ProviderType = ProviderType.GOOGLE
 }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
data class GoogleAuthCredentials(
val idToken: String,
val authorizationCode: String,
) : SignInCredentials() {
override fun getProviderType(): ProviderType {
return ProviderType.GOOGLE
}
}
data class GoogleAuthCredentials(
val idToken: String,
val authorizationCode: String,
) : SignInCredentials() {
override fun getProviderType(): ProviderType = ProviderType.GOOGLE
}
πŸ€– Prompt for AI Agents
In `@apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt`
around lines 22 - 29, GoogleAuthCredentials uses a block-bodied override for
getProviderType(), which is inconsistent with KakaoAuthCredentials and
AppleAuthCredentials that use expression-body overrides; change
GoogleAuthCredentials.getProviderType() to an expression body returning
ProviderType.GOOGLE so the style matches (update the override in class
GoogleAuthCredentials to use = ProviderType.GOOGLE).


18 changes: 16 additions & 2 deletions apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import org.yapp.apis.auth.dto.request.*
import org.yapp.apis.auth.dto.response.TokenPairResponse
import org.yapp.apis.auth.service.*
import org.yapp.apis.auth.strategy.signin.AppleAuthCredentials
import org.yapp.apis.auth.strategy.signin.GoogleAuthCredentials
import org.yapp.apis.auth.strategy.signin.SignInCredentials
import org.yapp.apis.auth.strategy.signin.SignInStrategyResolver
import org.yapp.apis.auth.strategy.withdraw.WithdrawStrategyResolver
import org.yapp.apis.user.dto.request.FindOrCreateUserRequest
import org.yapp.apis.user.dto.request.FindUserIdentityRequest
import org.yapp.apis.user.service.UserAccountService
import org.yapp.apis.user.service.UserService
import org.yapp.apis.auth.manager.GoogleApiManager
import org.yapp.globalutils.annotation.UseCase
import java.util.*

Expand All @@ -25,7 +27,8 @@ class AuthUseCase(
private val userWithdrawalService: UserWithdrawalService,
private val refreshTokenService: RefreshTokenService,
private val authTokenService: AuthTokenService,
private val appleAuthService: AppleAuthService
private val appleAuthService: AppleAuthService,
private val googleApiManager: GoogleApiManager
) {
// μΆ”ν›„ Redis μ €μž₯을 λΉ„λ™κΈ°λ‘œ μ²˜λ¦¬ν•˜κ³  μ‹€νŒ¨ μ‹œ μž¬μ‹œλ„ 둜직 λ„μž…
fun signIn(socialLoginRequest: SocialLoginRequest): TokenPairResponse {
Expand All @@ -34,10 +37,12 @@ class AuthUseCase(
val userCreateInfoResponse = strategy.authenticate(credentials)

val appleRefreshToken = fetchAppleRefreshTokenIfNeeded(credentials)
val googleRefreshToken = fetchGoogleRefreshTokenIfNeeded(credentials)

val createUserResponse = userSignInService.processSignIn(
FindOrCreateUserRequest.from(userCreateInfoResponse),
appleRefreshToken
appleRefreshToken,
googleRefreshToken
)

return authTokenService.generateTokenPair(GenerateTokenPairRequest.from(createUserResponse))
Expand Down Expand Up @@ -77,4 +82,13 @@ class AuthUseCase(

return null
}

private fun fetchGoogleRefreshTokenIfNeeded(credentials: SignInCredentials): String? {
if (credentials is GoogleAuthCredentials) {
val tokenResponse = googleApiManager.exchangeAuthorizationCode(credentials.authorizationCode)
return tokenResponse.refreshToken
}

return null
}
Comment on lines +86 to +93
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for GoogleApiManager implementation
echo "=== Searching for GoogleApiManager.kt ==="
fd "GoogleApiManager.kt" -type f

echo ""
echo "=== Searching for GoogleRestClient.kt ==="
fd "GoogleRestClient.kt" -type f

echo ""
echo "=== Searching for GoogleTokenResponse.kt ==="
fd "GoogleTokenResponse.kt" -type f

echo ""
echo "=== Searching for related API manager files ==="
fd -e ".kt" | grep -i "google.*api\|api.*manager" | head -20

Repository: YAPP-Github/Reed-Server

Length of output: 1050


🌐 Web query:

Google OAuth2 refresh_token response requirements access_type offline prompt consent

πŸ’‘ Result:

To receive a refresh_token from Google’s OAuth 2.0 token response (when exchanging an authorization code):

  • Request offline access in the authorization request by setting access_type=offline. This instructs Google to return a refresh token on the first code exchange for that user+client. [1][2]
  • Expect refresh_token only on the first authorization for a given user ↔ OAuth client pairing; subsequent authorizations are often auto-approved and won’t include a new refresh token unless the user revoked access. [1][3]
  • If you must obtain a new refresh token (e.g., you lost it), force a new consent screen with prompt=consent in the authorization request. (prompt=select_account alone is not sufficient.) [1][4]

Notes:

  • include_granted_scopes=true is for incremental authorization; it’s commonly recommended but isn’t the key requirement for getting a refresh token. [1]

🏁 Script executed:

#!/bin/bash
echo "=== GoogleApiManager.kt ==="
cat "apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt"

echo ""
echo "=== GoogleApi.kt ==="
cat "infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt"

echo ""
echo "=== Searching for GoogleTokenResponse ==="
fd -e "kt" -path "*/google/*" | xargs grep -l "GoogleTokenResponse\|class.*Token.*Response" 2>/dev/null | head -10

Repository: YAPP-Github/Reed-Server

Length of output: 5652


🏁 Script executed:

#!/bin/bash
# Search for GoogleTokenResponse definition
echo "=== Searching for token response classes ==="
rg "data class|class.*TokenResponse" -A 5 -i --type kt | grep -i google -A 5 | head -40

Repository: YAPP-Github/Reed-Server

Length of output: 155


🏁 Script executed:

#!/bin/bash
echo "=== Searching for GoogleTokenResponse definition ==="
find . -name "*.kt" -path "*/google/*" -exec grep -l "GoogleTokenResponse" {} \;

echo ""
echo "=== Searching for GoogleRestClient ==="
find . -name "*.kt" | xargs grep -l "GoogleRestClient" | head -5

Repository: YAPP-Github/Reed-Server

Length of output: 562


🏁 Script executed:

#!/bin/bash
echo "=== Find all files in google folder ==="
find . -path "*/google/*" -name "*.kt" | head -20

echo ""
echo "=== Search for response classes in google oauth folder ==="
find . -path "*/google/*" -name "*.kt" -exec grep -H "data class\|class.*Response" {} \;

Repository: YAPP-Github/Reed-Server

Length of output: 824


🏁 Script executed:

#!/bin/bash
echo "=== GoogleTokenResponse.kt ==="
cat "./infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleTokenResponse.kt"

echo ""
echo "=== GoogleRestClient.kt ==="
cat "./infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt"

Repository: YAPP-Github/Reed-Server

Length of output: 2745


🏁 Script executed:

#!/bin/bash
echo "=== Searching for GoogleOauthProperties ==="
find . -name "*.kt" | xargs grep -l "GoogleOauthProperties"

echo ""
echo "=== Searching for authorization request construction ==="
find . -name "*.kt" | xargs grep -B 5 -A 5 "access_type\|prompt.*consent" | head -30

Repository: YAPP-Github/Reed-Server

Length of output: 453


🏁 Script executed:

#!/bin/bash
echo "=== GoogleOauthProperties.kt ==="
cat "./apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt"

echo ""
echo "=== Searching for authorization request/login flow ==="
find . -name "*.kt" | xargs grep -l "authorize\|authorization.*url\|oauth.*login" | head -10

Repository: YAPP-Github/Reed-Server

Length of output: 647


🏁 Script executed:

#!/bin/bash
echo "=== Search for Google OAuth authorize endpoint construction ==="
rg -i "authorize|/o/oauth" --max-count 20

echo ""
echo "=== Search for OAuth login controller ==="
find . -name "*Controller.kt" | xargs grep -l "google\|oauth" | head -5

Repository: YAPP-Github/Reed-Server

Length of output: 1772


🏁 Script executed:

#!/bin/bash
echo "=== Search for authentication controller ==="
find . -path "*/auth/*" -name "*Controller.kt" -o -path "*/auth/*" -name "*Endpoint.kt"

echo ""
echo "=== Search for Google authorization URL construction ==="
rg -i "google.*authorize|createAuthorizationRequestUri|getAuthorizationUri" --max-count 20

echo ""
echo "=== Check AuthController ==="
find . -name "*AuthController.kt" -o -name "*LoginController.kt"

Repository: YAPP-Github/Reed-Server

Length of output: 341


🏁 Script executed:

#!/bin/bash
echo "=== Look at auth usecase and controller ==="
find ./apis/src/main/kotlin/org/yapp/apis/auth -name "*.kt" | head -20

echo ""
echo "=== Search for Google OAuth login/authorize endpoint ==="
rg -B 3 -A 10 "fun.*google.*login|fun.*sign.*google" -i --max-count 15

Repository: YAPP-Github/Reed-Server

Length of output: 4513


🏁 Script executed:

#!/bin/bash
echo "=== GoogleSignInStrategy.kt ==="
cat "./apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt"

echo ""
echo "=== AuthController.kt ==="
cat "./apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt"

Repository: YAPP-Github/Reed-Server

Length of output: 3874


🏁 Script executed:

#!/bin/bash
echo "=== Check SocialLoginRequest to understand credentials ==="
cat "./apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt"

echo ""
echo "=== Search for GoogleAuthCredentials ==="
find . -name "*.kt" | xargs grep -l "GoogleAuthCredentials" | head -5

Repository: YAPP-Github/Reed-Server

Length of output: 3400


🏁 Script executed:

#!/bin/bash
echo "=== SignInCredentials.kt and related ==="
cat "./apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt"

echo ""
echo "=== Full AuthUseCase.kt to understand context ==="
cat "./apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt"

Repository: YAPP-Github/Reed-Server

Length of output: 5116


Google OAuth 토큰 κ΅ν™˜ μ‹œ μ—λŸ¬ 핸듀링과 refresh_token κ°€λŠ₯μ„± κ²€ν†  ν•„μš”

ν˜„μž¬ μ½”λ“œλŠ” googleApiManager.exchangeAuthorizationCodeμ—μ„œ AuthException으둜 μ—λŸ¬λ₯Ό λ³€ν™˜ν•˜κ³  μžˆμ–΄ κΈ°λ³Έ μ—λŸ¬ μ²˜λ¦¬λŠ” κ΅¬ν˜„λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. λ‹€λ§Œ 두 κ°€μ§€ 확인이 ν•„μš”ν•©λ‹ˆλ‹€:

  1. ν΄λΌμ΄μ–ΈνŠΈ 인증 μš”μ²­ 확인 - Google OAuthμ—μ„œ refresh_token을 λ°›μœΌλ €λ©΄ 초기 인증 μš”μ²­(authorization request)에 access_type=offline&prompt=consent이 ν¬ν•¨λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. ν˜„μž¬ HTML의 Google Sign-In λΌμ΄λΈŒλŸ¬λ¦¬κ°€ 이 νŒŒλΌλ―Έν„°λ₯Ό μ„€μ •ν•˜κ³  μžˆλŠ”μ§€ ν™•μΈν•˜μ„Έμš”.

  2. null refresh_token 처리 - GoogleTokenResponse.refreshToken은 nullableμž…λ‹ˆλ‹€. ν΄λΌμ΄μ–ΈνŠΈκ°€ μ˜€ν”„λΌμΈ 접근을 μš”μ²­ν•˜μ§€ μ•ŠμœΌλ©΄ null이 λ°˜ν™˜λ  수 있으며, ν˜„μž¬ μ½”λ“œλŠ” 이λ₯Ό κ·ΈλŒ€λ‘œ μ „λ‹¬ν•©λ‹ˆλ‹€. 이것이 μ˜λ„λœ λ™μž‘μΈμ§€, μ•„λ‹ˆλ©΄ null인 경우 둜그 κ²½κ³ λ₯Ό μΆ”κ°€ν•΄μ•Ό ν•˜λŠ”μ§€ κ²€ν†  λΆ€νƒλ“œλ¦½λ‹ˆλ‹€.

πŸ€– Prompt for AI Agents
In `@apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt` around lines
86 - 93, Verify the frontend's Google Sign-In flow includes access_type=offline
and prompt=consent so a refresh_token can be issued, and then update
fetchGoogleRefreshTokenIfNeeded to handle nullable
GoogleTokenResponse.refreshToken from
googleApiManager.exchangeAuthorizationCode: when credentials is
GoogleAuthCredentials call exchangeAuthorizationCode as now, but if
tokenResponse.refreshToken is null emit a warning via the existing logger
(include identifying info like user id or authorizationCode) and explicitly
return null (or throw a documented AuthException if your flow requires a refresh
token); reference fetchGoogleRefreshTokenIfNeeded, GoogleAuthCredentials,
googleApiManager.exchangeAuthorizationCode, and GoogleTokenResponse.refreshToken
when making the change.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.yapp.apis.user.dto.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import org.yapp.apis.user.dto.response.CreateUserResponse
import java.util.*

@Schema(
name = "SaveGoogleRefreshTokenRequest",
description = "Request DTO for saving Google refresh token with user ID and authorization code"
)
Comment on lines +9 to +12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | πŸ”΅ Trivial

λ‚΄λΆ€ μ „μš© DTO에 @Schema μ–΄λ…Έν…Œμ΄μ…˜μ΄ λΆˆν•„μš”ν•΄ λ³΄μž…λ‹ˆλ‹€.

이 ν΄λž˜μŠ€λŠ” API μš”μ²­ λ°”λ””λ‘œ 직접 λ…ΈμΆœλ˜μ§€ μ•Šκ³ , of() νŒ©ν† λ¦¬λ₯Ό 톡해 λ‚΄λΆ€μ μœΌλ‘œλ§Œ μƒμ„±λ©λ‹ˆλ‹€. Swagger λ¬Έμ„œμ— λ…ΈμΆœν•  ν•„μš”κ°€ μ—†λ‹€λ©΄ @Schema μ–΄λ…Έν…Œμ΄μ…˜μ„ μ œκ±°ν•˜λŠ” 것이 μ’‹μŠ΅λ‹ˆλ‹€.

πŸ€– Prompt for AI Agents
In
`@apis/src/main/kotlin/org/yapp/apis/user/dto/request/SaveGoogleRefreshTokenRequest.kt`
around lines 9 - 12, Remove the unnecessary Swagger annotation from the internal
DTO by deleting the `@Schema` annotation on the SaveGoogleRefreshTokenRequest
class; this DTO is constructed via its of() factory and not exposed as an API
request body, so simply remove the `@Schema`(...) declaration (leave class,
properties, and the of() factory intact).

data class SaveGoogleRefreshTokenRequest private constructor(
@field:Schema(
description = "Unique identifier of the user",
example = "a1b2c3d4-e5f6-7890-1234-56789abcdef0"
)
@field:NotNull(message = "userId must not be null")
val userId: UUID? = null,

@field:Schema(
description = "Google refresh token, nullable if not issued yet",
example = "1//0g_xxxxxxxxxxxxxxxxxxxxxx"
)
@field:NotBlank(message = "googleRefreshToken must not be blank")
val googleRefreshToken: String? = null
Comment on lines +21 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

@Schema descriptionκ³Ό @field:NotBlank 검증이 λͺ¨μˆœλ©λ‹ˆλ‹€.

Line 22의 descriptionμ—λŠ” "nullable if not issued yet"이라고 λ˜μ–΄ μžˆμ§€λ§Œ, Line 25의 @field:NotBlankλŠ” blank/null을 ν—ˆμš©ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. 이 DTOλŠ” of() νŒ©ν† λ¦¬ λ©”μ„œλ“œλ₯Ό 톡해 non-null κ°’μœΌλ‘œλ§Œ μƒμ„±λ˜λ―€λ‘œ, description을 μˆ˜μ •ν•˜λŠ” 것이 λ§žμŠ΅λ‹ˆλ‹€.

πŸ“ description μˆ˜μ • μ œμ•ˆ
     `@field`:Schema(
-        description = "Google refresh token, nullable if not issued yet",
+        description = "Google refresh token",
         example = "1//0g_xxxxxxxxxxxxxxxxxxxxxx"
     )
πŸ€– Prompt for AI Agents
In
`@apis/src/main/kotlin/org/yapp/apis/user/dto/request/SaveGoogleRefreshTokenRequest.kt`
around lines 21 - 26, The `@Schema` description for SaveGoogleRefreshTokenRequest
is inconsistent with the `@field`:NotBlank validation on googleRefreshToken and
the of() factory that creates non-null values; update the `@Schema` on
googleRefreshToken to indicate it is required/non-blank (remove "nullable if not
issued yet") and reflect that it contains a valid Google refresh token, so the
documentation matches the `@field`:NotBlank constraint and the of() factory
behavior.

) {
fun validUserId(): UUID = userId!!
fun validGoogleRefreshToken(): String = googleRefreshToken!!

companion object {
fun of(
userResponse: CreateUserResponse,
googleRefreshToken: String
): SaveGoogleRefreshTokenRequest {
return SaveGoogleRefreshTokenRequest(
userId = userResponse.id,
googleRefreshToken = googleRefreshToken
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.yapp.apis.user.service

import jakarta.validation.Valid
import org.yapp.apis.user.dto.request.SaveAppleRefreshTokenRequest
import org.yapp.apis.user.dto.request.SaveGoogleRefreshTokenRequest
import org.yapp.apis.auth.exception.AuthErrorCode
import org.yapp.apis.auth.exception.AuthException
import org.yapp.apis.user.dto.request.FindOrCreateUserRequest
Expand Down Expand Up @@ -54,6 +55,15 @@ class UserAccountService(
return CreateUserResponse.from(userAuthVO)
}

fun updateGoogleRefreshToken(@Valid saveGoogleRefreshTokenRequest: SaveGoogleRefreshTokenRequest): CreateUserResponse {
val userAuthVO = userDomainService.updateGoogleRefreshToken(
saveGoogleRefreshTokenRequest.validUserId(),
saveGoogleRefreshTokenRequest.validGoogleRefreshToken()
)

return CreateUserResponse.from(userAuthVO)
}

private fun createNewUser(@Valid findOrCreateUserRequest: FindOrCreateUserRequest): UserAuthVO {
val email = findOrCreateUserRequest.getOrDefaultEmail()
val nickname = findOrCreateUserRequest.getOrDefaultNickname()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package org.yapp.infra.external.oauth.google

import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.yapp.infra.external.oauth.google.response.GoogleUserInfo // Changed to GoogleUserInfo
import org.yapp.infra.external.oauth.google.response.GoogleTokenResponse
import org.yapp.infra.external.oauth.google.response.GoogleUserInfo

@Component
class GoogleApi(
Expand All @@ -16,12 +17,27 @@ class GoogleApi(
fun fetchUserInfo(
accessToken: String,
userInfoUrl: String,
): Result<GoogleUserInfo> { // Changed to GoogleUserInfo
): Result<GoogleUserInfo> {
return runCatching {
googleRestClient.getUserInfo(BEARER_PREFIX + accessToken, userInfoUrl)
}
}

fun exchangeAuthorizationCode(
code: String,
clientId: String,
clientSecret: String,
redirectUri: String,
tokenExchangeUrl: String,
): Result<GoogleTokenResponse> {
return runCatching {
googleRestClient.exchangeAuthorizationCode(code, clientId, clientSecret, redirectUri, tokenExchangeUrl)
}
}

// Note: ID tokens cannot be exchanged for access tokens with Google's token endpoint.
// The ID token should be validated directly or use the authorization code flow instead.

fun revokeGoogleToken(
token: String
): Result<Unit> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.yapp.infra.external.oauth.google

import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.util.LinkedMultiValueMap
import org.yapp.infra.external.oauth.google.response.GoogleTokenResponse
import org.yapp.infra.external.oauth.google.response.GoogleUserInfo

@Component
Expand All @@ -23,6 +25,33 @@ class GoogleRestClient(
?: throw IllegalStateException("Google API 응닡이 null μž…λ‹ˆλ‹€.")
}

fun exchangeAuthorizationCode(
code: String,
clientId: String,
clientSecret: String,
redirectUri: String,
url: String,
): GoogleTokenResponse {
val params = LinkedMultiValueMap<String, String>().apply {
add("code", code)
add("client_id", clientId)
add("client_secret", clientSecret)
add("redirect_uri", redirectUri)
add("grant_type", "authorization_code")
}

return client.post()
.uri(url)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(params)
.retrieve()
.body(GoogleTokenResponse::class.java)
?: throw IllegalStateException("Google Token API 응닡이 null μž…λ‹ˆλ‹€.")
}

// Note: ID tokens cannot be exchanged for access tokens with Google's token endpoint.
// The ID token should be validated directly or use the authorization code flow instead.

fun revoke(requestBody: LinkedMultiValueMap<String, String>) {
client.post()
.uri("https://oauth2.googleapis.com/revoke")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.yapp.infra.external.oauth.google.response

import com.fasterxml.jackson.annotation.JsonProperty

data class GoogleTokenResponse(
@JsonProperty("access_token")
val accessToken: String,

@JsonProperty("refresh_token")
val refreshToken: String?,

@JsonProperty("expires_in")
val expiresIn: Int,

@JsonProperty("token_type")
val tokenType: String,

@JsonProperty("scope")
val scope: String?,

@JsonProperty("id_token")
val idToken: String?
)