Skip to content

feature: 애플 로그인 엔드포인트 추가#131

Open
kjyyjk wants to merge 13 commits into
v2/developfrom
v2/feature/#130-apple-login
Open

feature: 애플 로그인 엔드포인트 추가#131
kjyyjk wants to merge 13 commits into
v2/developfrom
v2/feature/#130-apple-login

Conversation

@kjyyjk

@kjyyjk kjyyjk commented Jul 8, 2026

Copy link
Copy Markdown

연관된 이슈

변경 사항

  • POST /auth/login/apple/confirm Apple 로그인 완료 API를 추가했습니다.
  • Apple 로그인 성공 시 Forgather access token과 refresh token을 응답 바디와 HttpOnly 쿠키로 함께 반환합니다.
  • authorization code를 Apple token endpoint에서 교환하고, Apple 서버가 반환한 ID token을 인증 기준으로 사용하도록 구현했습니다.
  • Apple JWKS 공개키 조회와 ID token의 서명·claim·nonce 검증을 추가했습니다.
  • ES256 Apple client secret 생성과 Apple 전용 HTTP timeout 및 오류 변환을 추가했습니다.
  • Apple 계정 연동 정보와 refresh token을 저장하기 위한 host_apple 테이블 및 호스트 이메일 컬럼을 추가했습니다.
  • Apple token 교환, client secret, ID token 검증, 신규·기존 회원 로그인 흐름을 검증하는 테스트를 추가했습니다.

작업 내용

API

POST /auth/login/apple/confirm

Apple 네이티브 로그인에서 발급받은 authorization code를 서버가 Apple token endpoint에 교환하고, 검증된 Apple 사용자 정보로 로그인합니다.

로그인에 성공하면 Forgather access token과 refresh token을 응답 바디와 HttpOnly 쿠키로 함께 반환합니다.

클라이언트가 전달한 id_token은 요청 호환을 위해 받지만 인증에는 사용하지 않습니다. 실제 인증과 회원 식별에는 Apple code 교환 응답의 ID token을 사용합니다.

[요청]

위치 파라미터 타입 설명
Body id_token string 클라이언트가 받은 Apple ID token. 요청 호환용이며 인증에는 사용하지 않음
Body authorization_code string Apple token endpoint 교환에 사용하는 일회용 인가 코드
Body raw_nonce string Apple 로그인 요청에 사용한 nonce의 원본 문자열
Body full_name string 클라이언트가 로케일에 맞게 조합한 이름. 신규 회원에게만 필수

[요청 예시]

{
  "id_token": "client-issued-apple-id-token",
  "authorization_code": "client-issued-authorization-code",
  "raw_nonce": "client-generated-raw-nonce",
  "full_name": "홍길동"
}

[응답]

상태 코드 code 설명
200 SUCCESS Apple 로그인 성공
400 BAD_REQUEST authorization code 누락 또는 잘못된 token 요청, 신규 회원 이름 검증 실패
401 UNAUTHORIZED 또는 JWT_INVALID 만료·재사용된 authorization code, ID token 서명 또는 claim 검증 실패
502 INTERNAL_ERROR Apple token 서버 연결·인증 실패 또는 잘못된 응답

[응답 예시 - 200]

{
  "code": "SUCCESS",
  "message": null,
  "data": {
    "accessToken": "forgather-access-token",
    "refreshToken": "forgather-refresh-token"
  }
}

[응답 쿠키]

쿠키 Path HttpOnly
access_token / true
refresh_token /auth/refresh true

기존 로그인 및 토큰 재발급 API와 동일한 AuthCookieProvider 정책을 적용합니다.


Apple authorization code 교환

[인증 기준]

모든 Apple 로그인에서 authorization code를 Apple /auth/token에 교환합니다.

클라이언트가 전달한 ID token으로 code 교환 실패를 우회하지 않으며, Apple 서버가 반환한 ID token만 검증해 사용자를 식별합니다.

[AppleAuthClient]

Apple token endpoint에 application/x-www-form-urlencoded 형식으로 다음 값을 전달합니다.

  • client_id
  • client_secret
  • code
  • grant_type=authorization_code

응답의 access token, refresh token, ID token 및 만료 시간이 모두 존재하는지 확인합니다. Apple 오류 응답은 invalid_request, invalid_grant, invalid_client 등의 유형에 따라 400, 401 또는 502 응답으로 변환합니다.

[HTTP timeout]

Apple 외부 API 호출이 무기한 대기하지 않도록 전용 RestClient에 connect timeout 3초와 read timeout 5초를 적용했습니다.


Apple client secret

[ES256 서명]

Apple Team ID, Client ID, Key ID와 .p8 private key를 사용해 5분 동안 유효한 client secret JWT를 ES256으로 생성합니다.

[초기화 검증]

애플리케이션 초기화 시 private key 형식을 검증하고 파싱한 키를 재사용합니다. 잘못된 private key 설정은 실제 로그인 요청 시점이 아니라 Provider 생성 시점에 확인됩니다.

private key와 생성된 client secret은 로그에 기록하지 않습니다.


Apple ID token 검증

[공개키 조회]

SocialProviderAPPLE을 추가하고, SocialAuthClient가 Apple JWKS URL의 공개키를 provider별로 조회·캐시하도록 확장했습니다.

[검증 항목]

Apple token 교환 응답의 ID token에 대해 다음 항목을 검증합니다.

  • JWT 서명과 만료 시간
  • iss가 Apple issuer와 일치하는지
  • aud가 설정된 Apple Client ID와 일치하는지
  • subemail이 존재하는지
  • email_verified가 boolean 또는 문자열 true인지
  • ID token의 nonce가 SHA-256(raw_nonce)와 일치하는지

audience는 문자열과 배열 형식을 모두 지원합니다.


Apple 회원 로그인 처리

[신규 회원]

검증된 ID token의 sub에 연결된 Apple 계정이 없으면 다음 정보를 저장합니다.

  • Host.name: 클라이언트가 전달한 full_name
  • Host.email: 검증된 Apple ID token의 email
  • Host.pictureUrl: null
  • AppleHost.userId: 검증된 Apple ID token의 sub
  • AppleHost.refreshToken: Apple code 교환 응답의 refresh token

신규 회원에게 full_name이 없거나 공백이면 로그인을 거부합니다.

[기존 회원]

기존 Apple 회원은 sub로 조회하며, 요청의 이름이나 ID token의 이메일로 기존 Host 정보를 갱신하지 않습니다.

Apple code 교환에서 새로 받은 refresh token만 저장한 뒤 기존 Host로 로그인합니다.

[Forgather 토큰]

회원 확인 또는 생성이 완료되면 Forgather access token과 refresh token을 발급해 기존 LoginResponse 응답 바디와 HttpOnly 쿠키에 함께 담아 반환합니다.

Apple access token 저장, Apple 계정과 다른 소셜 계정의 자동 연결, 회원 탈퇴 시 Apple token revoke는 이번 작업 범위에 포함하지 않았습니다.


DB 마이그레이션 및 설정

[host]

Apple ID token에서 확인한 이메일을 저장할 수 있도록 host.email 컬럼을 추가했습니다.

[host_apple]

V22__add_apple_login.sql에 Apple 계정 연동 테이블을 추가했습니다.

  • Host와 일대일 연결되는 host_id
  • Apple 사용자 식별자인 user_id
  • Apple code 교환 응답의 refresh_token
  • host_id, user_id unique 제약
  • host 테이블 외래 키

[환경 설정]

환경별 설정에 다음 Apple 로그인 값을 추가했습니다.

  • JWKS URL과 issuer
  • Client ID와 Team ID
  • Key ID와 private key
  • Token endpoint URL

Summary by CodeRabbit

  • 새로운 기능
    • Apple 로그인을 지원하고 인증 코드 교환 및 ID 토큰 검증을 통해 로그인할 수 있습니다.
    • Apple 로그인 확인 API를 추가했으며, 요청 데이터(인증서/nonce/사용자 정보 등) 검증을 강화했습니다.
    • Apple 계정 정보를 저장하고, 기존 계정은 리프레시 토큰을 갱신합니다.
  • 데이터베이스
    • 호스트의 이메일 저장을 추가하고, Apple 로그인 연동 테이블과 refresh 토큰 저장을 도입했습니다.

kjyyjk added 4 commits July 8, 2026 15:37
- Apple JWKS 설정과 공개키 캐시 provider를 추가

- Apple ID Token 검증과 로그인 API를 추가

- Apple 계정 연결 모델과 마이그레이션을 추가
- Apple audience claim이 문자열과 배열 모두 허용 목록과 비교되도록 보정

- Apple ID Token claim DTO가 audience와 email_verified 타입 변형을 수용하도록 보정

- 신규 Apple 가입 시 Host 저장을 명시
- Apple JWKS provider와 ID Token 검증 테스트를 추가

- Apple 로그인 서비스 흐름과 audience 허용 정책 테스트를 추가
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kjyyjk, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 45d00b45-a67c-421c-9842-e3e620b3b97b

📥 Commits

Reviewing files that changed from the base of the PR and between 44da952 and e5fb596.

📒 Files selected for processing (1)
  • src/main/java/com/forgather/global/auth/controller/AuthController.java

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key: "tools"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
📝 Walkthrough

Walkthrough

Apple OAuth 토큰 교환과 ES256 client secret 생성을 추가했습니다. Apple JWKS를 사용한 ID 토큰 서명 및 issuer, audience, email, email_verified, nonce 검증을 구현했습니다. AppleHost 엔티티와 저장소, Host 이메일 필드, 데이터베이스 마이그레이션을 추가했습니다. 로그인 확인 API와 AuthService 처리 흐름을 연결하고, Apple 인증 설정 및 테스트 환경을 확장했습니다.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kjyyjk
kjyyjk marked this pull request as ready for review July 8, 2026 07:15

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements Apple OAuth login functionality, introducing Apple-specific properties, DTOs, entities, repositories, and updating the authentication service and controller. While the implementation is comprehensive and includes thorough tests, several critical improvements are recommended: handling potential JwtException and null values during token parsing and validation to prevent 500 errors, implementing a fallback mechanism for missing Apple user profiles during registration to avoid blocking users, and adding unique constraints to the host_apple table in the database migration to ensure data integrity.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/main/java/com/forgather/global/auth/util/JwtParser.java
Comment thread src/main/java/com/forgather/global/auth/util/JwtParser.java
Comment on lines +73 to +93
private AppleHost toAppleHost(AppleLoginConfirmRequest request) {
AppleIdToken idToken = jwtParser.parseAppleIdToken(request.idToken(), request.rawNonce());
Optional<AppleHost> appleHost = appleHostRepository.findByUserId(idToken.sub());
return appleHost.orElseGet(() -> {
AppleUser appleUser = parseAppleUser(request.user());
Host host = new Host(appleUser.fullName(), null, idToken.email());
hostRepository.save(host);
return appleHostRepository.save(new AppleHost(host, idToken.sub()));
});
}

private AppleUser parseAppleUser(String userJson) {
if (!StringUtils.hasText(userJson)) {
throw new BaseException("회원가입을 위해 애플 사용자 정보가 필요합니다.", HttpStatus.BAD_REQUEST);
}
try {
return objectMapper.readValue(userJson, AppleUser.class);
} catch (JsonProcessingException e) {
throw new BaseException("애플 사용자 정보 형식이 올바르지 않습니다.", HttpStatus.BAD_REQUEST, e);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

애플 로그인은 보안 정책상 최초 로그인 시에만 user 객체(이름 등)를 제공하며, 이후 로그인 시에는 user 객체가 전달되지 않습니다.\n\n만약 최초 가입 과정에서 일시적인 오류(예: DB 장애, 네트워크 지연 등)로 인해 가입 처리가 완료되지 않았거나 사용자가 앱을 탈퇴한 후 재가입하려는 경우, request.user()가 null로 들어오게 되어 BaseException이 발생하고 사용자는 영구적으로 가입할 수 없는 상태(블로킹)에 빠지게 됩니다. (사용자가 직접 애플 ID 설정에서 앱 권한을 해제해야만 다시 user를 받을 수 있어 UX에 치명적입니다.)\n\n따라서 신규 가입 시 user 정보가 없더라도 가입을 차단하기보다는, 이메일의 앞부분을 이름으로 사용하거나 기본값(예: 'Apple 사용자')을 부여하여 가입을 성공적으로 완료할 수 있도록 폴백(Fallback) 처리를 구현하는 것을 강력히 권장합니다.

    private AppleHost toAppleHost(AppleLoginConfirmRequest request) {
        AppleIdToken idToken = jwtParser.parseAppleIdToken(request.idToken(), request.rawNonce());
        Optional<AppleHost> appleHost = appleHostRepository.findByUserId(idToken.sub());
        return appleHost.orElseGet(() -> {
            String name = getAppleHostName(request.user(), idToken.email());
            Host host = new Host(name, null, idToken.email());
            hostRepository.save(host);
            return appleHostRepository.save(new AppleHost(host, idToken.sub()));
        });
    }

    private String getAppleHostName(String userJson, String email) {
        if (StringUtils.hasText(userJson)) {
            try {
                AppleUser appleUser = objectMapper.readValue(userJson, AppleUser.class);
                return appleUser.fullName();
            } catch (Exception e) {
                // JSON 파싱 실패 시 폴백 진행
            }
        }
        if (StringUtils.hasText(email) && email.contains("@")) {
            return email.split("@")[0];
        }
        return "Apple 사용자";
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

탈퇴 시에 애플의 revoke 엔드포인트 요청하므로 탈퇴 후 재가입 시 애플은 다시 최초 유저 정보를 제공함.
일시적 오류로 인한 재시도 시 클라이언트에서 사용자 정보를 보관한 뒤에 다시 요청할 것이므로 제안한 폴백을 반영하지 않을 예정

Comment thread src/main/resources/db/migration/V22__add_apple_login.sql
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Test Results

 98 files  + 3   98 suites  +3   1m 10s ⏱️ +4s
624 tests +16  624 ✅ +16  0 💤 ±0  0 ❌ ±0 
654 runs  +16  654 ✅ +16  0 💤 ±0  0 ❌ ±0 

Results for commit e5fb596. ± Comparison against base commit c3dd913.

This pull request removes 14 and adds 30 tests. Note that renamed tests count towards both.
com.forgather.domain.model.SoftDeleteEntityTest ‑ [2] entity=com.forgather.domain.space.model.SpacePhoto@122e4e2f
com.forgather.domain.model.SoftDeleteEntityTest ‑ [2] entity=com.forgather.domain.space.model.SpacePhoto@7a08611c
com.forgather.domain.model.SoftDeleteEntityTest ‑ [3] entity=com.forgather.global.auth.model.SpaceHost@208c918d
com.forgather.domain.model.SoftDeleteEntityTest ‑ [3] entity=com.forgather.global.auth.model.SpaceHost@692ac8d
com.forgather.domain.model.SoftDeleteEntityTest ‑ [4] entity=com.forgather.domain.guestbook.model.GuestBookCard@40321e13
com.forgather.domain.model.SoftDeleteEntityTest ‑ [4] entity=com.forgather.domain.guestbook.model.GuestBookCard@76c523a2
com.forgather.domain.model.SoftDeleteEntityTest ‑ [5] entity=com.forgather.domain.guestbook.model.GuestBookCardPhoto@172ac29d
com.forgather.domain.model.SoftDeleteEntityTest ‑ [5] entity=com.forgather.domain.guestbook.model.GuestBookCardPhoto@40d3ad1a
com.forgather.domain.model.SoftDeleteEntityTest ‑ [6] entity=com.forgather.domain.product.model.Product@7795b961
com.forgather.domain.model.SoftDeleteEntityTest ‑ [6] entity=com.forgather.domain.product.model.Product@796a349d
…
com.forgather.domain.model.SoftDeleteEntityTest ‑ [2] entity=com.forgather.domain.space.model.SpacePhoto@571fca73
com.forgather.domain.model.SoftDeleteEntityTest ‑ [2] entity=com.forgather.domain.space.model.SpacePhoto@5e178a71
com.forgather.domain.model.SoftDeleteEntityTest ‑ [3] entity=com.forgather.global.auth.model.SpaceHost@1d9dd314
com.forgather.domain.model.SoftDeleteEntityTest ‑ [3] entity=com.forgather.global.auth.model.SpaceHost@307dc690
com.forgather.domain.model.SoftDeleteEntityTest ‑ [4] entity=com.forgather.domain.guestbook.model.GuestBookCard@314f81eb
com.forgather.domain.model.SoftDeleteEntityTest ‑ [4] entity=com.forgather.domain.guestbook.model.GuestBookCard@333fea7e
com.forgather.domain.model.SoftDeleteEntityTest ‑ [5] entity=com.forgather.domain.guestbook.model.GuestBookCardPhoto@3d74e292
com.forgather.domain.model.SoftDeleteEntityTest ‑ [5] entity=com.forgather.domain.guestbook.model.GuestBookCardPhoto@52c0d2c4
com.forgather.domain.model.SoftDeleteEntityTest ‑ [6] entity=com.forgather.domain.product.model.Product@5f52b34f
com.forgather.domain.model.SoftDeleteEntityTest ‑ [6] entity=com.forgather.domain.product.model.Product@7ba5d6bd
…

♻️ This comment has been updated with latest results.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/com/forgather/global/auth/dto/AppleUser.java`:
- Around line 13-18: In AppleUser.fullName(), the BaseException is redundantly
passed HttpStatus.BAD_REQUEST even though BaseException already defaults to 400
when no status is provided. Update the exception throw in fullName() to use the
constructor without the explicit HttpStatus argument, keeping the existing
message and null/blank checks unchanged.

In `@src/main/java/com/forgather/global/auth/model/AppleHost.java`:
- Around line 26-27: `AppleHost.userId` currently only has a non-null column
mapping, so duplicate Apple account records can still be created under
concurrent saves. Add a UNIQUE constraint to the `AppleHost` entity on `userId`
and mirror the same uniqueness in `V21__add_apple_login.sql`; update the mapping
around `AppleHost.userId` so the database enforces one host per Apple account
even when `findByUserId(...)->orElseGet(save)` races.

In `@src/main/java/com/forgather/global/auth/util/JwtParser.java`:
- Around line 81-94: `validateAppleIdToken`에서 `rawNonce`가 null일 때
`hashRawNonce(rawNonce)`가 NPE를 일으키지 않도록, 메서드 초반에 null/blank 검증을 추가해
`JwtParseException`으로 처리하세요. `JwtParser.validateAppleIdToken`와 `hashRawNonce`를
기준으로 수정하고, `AppleLoginConfirmRequest.rawNonce()`가 비어 있으면 `Apple nonce가 올바르지
않습니다.`와 같은 인증 실패로 반환되게 맞추면 됩니다.
- Around line 57-58: `JwtParser.parseClaims` can throw an uncaught NPE when
`idToken` is null because it immediately calls `split("\\.")`; add an explicit
null check at the start of `parseClaims` and fail fast with a handled
`IllegalArgumentException` or the same exception type expected by the caller so
the existing `catch (JsonProcessingException | IllegalArgumentException)` path
can handle it. Use the `parseClaims(String idToken, SocialProvider provider)`
method as the main fix point, and ensure any null `idToken` from request objects
like `AppleLoginConfirmRequest.idToken()` is validated before token parsing
begins.

In `@src/main/resources/db/migration/V21__add_apple_login.sql`:
- Around line 4-13: The `host_apple` table definition in
`V21__add_apple_login.sql` is missing the required uniqueness constraints for
`host_id` and `user_id`, which can allow duplicate mappings and break the
single-row assumption in `findByUserId()` and the `@OneToOne` relationship.
Update the `CREATE TABLE` for `host_apple` (and its constraints, if needed) to
enforce UNIQUE on both `host_id` and `user_id`, using the `host_apple` table
definition and the foreign key setup as the place to apply the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7077436a-8afa-4109-96da-625cf1809928

📥 Commits

Reviewing files that changed from the base of the PR and between cc8640d and 3a70e8f.

📒 Files selected for processing (19)
  • src/main/java/com/forgather/global/auth/client/SocialAuthClient.java
  • src/main/java/com/forgather/global/auth/client/SocialProvider.java
  • src/main/java/com/forgather/global/auth/controller/AuthController.java
  • src/main/java/com/forgather/global/auth/dto/AppleIdToken.java
  • src/main/java/com/forgather/global/auth/dto/AppleLoginConfirmRequest.java
  • src/main/java/com/forgather/global/auth/dto/AppleUser.java
  • src/main/java/com/forgather/global/auth/model/AppleHost.java
  • src/main/java/com/forgather/global/auth/model/Host.java
  • src/main/java/com/forgather/global/auth/repository/AppleHostRepository.java
  • src/main/java/com/forgather/global/auth/service/AuthService.java
  • src/main/java/com/forgather/global/auth/util/JwtParser.java
  • src/main/java/com/forgather/global/config/AppleProperties.java
  • src/main/resources/application.yml
  • src/main/resources/db/migration/V21__add_apple_login.sql
  • src/test/java/com/forgather/global/auth/client/SocialAuthClientTest.java
  • src/test/java/com/forgather/global/auth/service/AuthServiceTest.java
  • src/test/java/com/forgather/global/auth/util/JwtParserTest.java
  • src/test/java/com/forgather/global/config/ApplePropertiesTest.java
  • src/test/resources/cleanup.sql

Comment thread src/main/java/com/forgather/global/auth/dto/AppleUser.java Outdated
Comment thread src/main/java/com/forgather/global/auth/model/AppleHost.java Outdated
Comment thread src/main/java/com/forgather/global/auth/util/JwtParser.java
Comment thread src/main/java/com/forgather/global/auth/util/JwtParser.java
Comment thread src/main/resources/db/migration/V22__add_apple_login.sql
@kjyyjk
kjyyjk marked this pull request as draft July 8, 2026 13:15
kjyyjk added 6 commits July 11, 2026 14:17
- Apple authorization code를 서버에서 token으로 교환

- Apple ID token 검증 후 refresh token과 회원 연동 정보를 저장

- client secret 생성, 전용 HTTP timeout, 설정 및 스키마 반영
- authorization code 교환 요청과 오류 응답 매핑 검증

- ES256 client secret의 서명과 문자열 audience 검증
- Apple 로그인 요청의 snake_case 매핑과 토큰 교환 오류 처리를 보완

- Apple 설정을 시작 시 검증하고 JWT 검증 실패를 인증 예외로 변환
- invalid_scope 응답의 예외 변환을 검증

- 클라이언트 단위 테스트와 중복되는 서비스 예외 테스트를 정리
@kjyyjk
kjyyjk marked this pull request as ready for review July 17, 2026 05:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/java/com/forgather/global/auth/controller/AuthController.java (1)

69-79: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Apple 로그인 완료 응답 시 HTTPOnly 쿠키를 설정하도록 수정하세요.

카카오 로그인(kakaoLoginConfirm)과 동일하게 액세스 토큰과 리프레시 토큰을 HTTPOnly 쿠키에 담아 반환해야 클라이언트 측 인증 세션이 정상적으로 동작합니다. 현재는 응답 바디로만 토큰을 반환하고 있으므로, 쿠키를 함께 설정하는 createTokenResponse 헬퍼 메서드를 사용하도록 수정해야 합니다.

🐛 제안하는 수정
     public ResponseEntity<ApiResponse<LoginResponse>> appleLoginConfirm(
         `@RequestBody` AppleLoginConfirmRequest request
     ) {
         var response = authService.appleLoginConfirm(request);
-        return ResponseEntity.ok(ApiResponse.success(response));
+        return createTokenResponse(response);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/forgather/global/auth/controller/AuthController.java`
around lines 69 - 79, Update AuthController.appleLoginConfirm to return the
authentication response through the same createTokenResponse helper used by
kakaoLoginConfirm, so both access and refresh tokens are set as HTTPOnly cookies
while preserving the existing login response payload.
src/test/java/com/forgather/global/auth/service/AuthServiceTest.java (1)

142-180: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Apple API의 실제 응답 특성에 맞춰 테스트 케이스를 개선해 보세요. (선택 사항)

기존 가입자가 다시 로그인하는 경우 Apple은 refresh_token을 반환하지 않으므로(null), 테스트에서도 null을 반환하도록 설정하고 기존 AppleHost의 리프레시 토큰이 덮어쓰이지 않고 온전히 유지되는지 검증하는 것이 실제 프로덕션 동작과 더 일치합니다.

💡 제안하는 수정
         Host host = new Host("기존사용자", null, "old@example.com");
         AppleHost appleHost = new AppleHost(host, "apple-sub", "old-apple-refresh-token");
         when(appleAuthClient.exchangeAuthorizationCode("authorization-code"))
-            .thenReturn(appleTokenResponse("new-apple-refresh-token"));
+            .thenReturn(appleTokenResponse(null));
         when(jwtParser.parseAppleIdToken("apple-server-id-token", "raw-nonce"))
             .thenReturn(new AppleIdToken(

아래 검증 부에서도 기존 토큰이 유지되는지 확인합니다.

         verify(appleHostRepository, never()).save(any());
         assertThat(host.getEmail()).isEqualTo("old@example.com");
-        assertThat(appleHost.getRefreshToken()).isEqualTo("new-apple-refresh-token");
+        assertThat(appleHost.getRefreshToken()).isEqualTo("old-apple-refresh-token");
         assertThat(response.accessToken()).isEqualTo("access-token");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/com/forgather/global/auth/service/AuthServiceTest.java` around
lines 142 - 180, Update
appleLoginConfirm_existingAppleHostDoesNotRequireFullName so
exchangeAuthorizationCode returns an Apple token response with a null refresh
token, matching Apple’s existing-user login behavior. Keep the existing
AppleHost refresh token unchanged and assert that the original token remains
after authService.appleLoginConfirm(request).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/com/forgather/global/auth/client/AppleAuthClient.java`:
- Around line 42-44: Update the authorizationCode validation in AppleAuthClient
to construct BaseException with only the existing message, removing the
redundant HttpStatus.BAD_REQUEST argument and preserving the current validation
behavior.

In `@src/main/java/com/forgather/global/auth/service/AuthService.java`:
- Around line 90-105: Update toAppleHost so existing AppleHost records only call
updateRefreshToken when appleRefreshToken is non-null and non-empty; preserve
the previously stored token when Apple omits refresh_token during subsequent
logins.

In `@src/main/java/com/forgather/global/auth/util/AppleClientSecretProvider.java`:
- Around line 28-44: Cache the parsed PrivateKey in AppleClientSecretProvider
during bean initialization via a `@PostConstruct` method, parsing the configured
key once and storing it in a field. Update generate() to reuse the cached key in
signWith instead of calling parsePrivateKey() on every invocation.

---

Outside diff comments:
In `@src/main/java/com/forgather/global/auth/controller/AuthController.java`:
- Around line 69-79: Update AuthController.appleLoginConfirm to return the
authentication response through the same createTokenResponse helper used by
kakaoLoginConfirm, so both access and refresh tokens are set as HTTPOnly cookies
while preserving the existing login response payload.

In `@src/test/java/com/forgather/global/auth/service/AuthServiceTest.java`:
- Around line 142-180: Update
appleLoginConfirm_existingAppleHostDoesNotRequireFullName so
exchangeAuthorizationCode returns an Apple token response with a null refresh
token, matching Apple’s existing-user login behavior. Keep the existing
AppleHost refresh token unchanged and assert that the original token remains
after authService.appleLoginConfirm(request).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a5d88a05-2dc9-49f7-a840-029e78c08e25

📥 Commits

Reviewing files that changed from the base of the PR and between 3a70e8f and 8f4c7b8.

📒 Files selected for processing (24)
  • src/main/java/com/forgather/global/auth/client/AppleAuthClient.java
  • src/main/java/com/forgather/global/auth/controller/AuthController.java
  • src/main/java/com/forgather/global/auth/dto/AppleLoginConfirmRequest.java
  • src/main/java/com/forgather/global/auth/dto/AppleTokenErrorResponse.java
  • src/main/java/com/forgather/global/auth/dto/AppleTokenResponse.java
  • src/main/java/com/forgather/global/auth/model/AppleHost.java
  • src/main/java/com/forgather/global/auth/model/Host.java
  • src/main/java/com/forgather/global/auth/service/AuthService.java
  • src/main/java/com/forgather/global/auth/util/AppleClientSecretProvider.java
  • src/main/java/com/forgather/global/auth/util/JwtParser.java
  • src/main/java/com/forgather/global/config/AppleProperties.java
  • src/main/java/com/forgather/global/config/RestClientConfig.java
  • src/main/resources/application-dev.yml
  • src/main/resources/application-prod.yml
  • src/main/resources/application.yml
  • src/main/resources/db/migration/V22__add_apple_login.sql
  • src/test/java/com/forgather/global/auth/client/AppleAuthClientTest.java
  • src/test/java/com/forgather/global/auth/client/SocialAuthClientTest.java
  • src/test/java/com/forgather/global/auth/service/AuthServiceTest.java
  • src/test/java/com/forgather/global/auth/util/AppleClientSecretProviderTest.java
  • src/test/java/com/forgather/global/auth/util/JwtParserTest.java
  • src/test/java/com/forgather/global/config/ApplePropertiesTest.java
  • src/test/resources/application.yml
  • src/test/resources/cleanup.sql

Comment thread src/main/java/com/forgather/global/auth/client/AppleAuthClient.java
Comment thread src/main/java/com/forgather/global/auth/service/AuthService.java
Comment on lines +29 to +30
@Column(name = "refresh_token", length = 512)
private String refreshToken;

@kjyyjk kjyyjk Jul 11, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

ios 앱 심사를 통과하려면 회원탈퇴 시 애플쪽에 revoke 요청을 보내 연동된 정보를 삭제해야합니다. 그때 refresh_token이 사용되기 때문에 refresh_token을 저장해줍니다.

Comment on lines +18 to +20
public boolean isEmailVerified() {
return Boolean.TRUE.equals(emailVerified)
|| "true".equalsIgnoreCase(String.valueOf(emailVerified));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

apple 공식 문서 상 boolean 타입과 string 타입 중 하나라고 명시되어있어 두 타입 모두 호환 가능하도록 구현했습니다.


import io.swagger.v3.oas.annotations.media.Schema;

public record AppleLoginConfirmRequest(

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

애플 네이티브 sdk는 카카오 네이티브 sdk와 달리 access/refresh_token을 발급해주지 않습니다. 따라서 클라이언트에게서 access/refresh_token을 받아오는 카카오와는 다르게 authorization code를 전달 받아 직접 토큰으로 교환하도록 했습니다.
(회원 탈퇴 시 refresh_token 필요)

.single(APPLE_ISSUER)
.issuedAt(Date.from(issuedAt))
.expiration(Date.from(expiresAt))
.signWith(parsePrivateKey(), Jwts.SIG.ES256)

@kjyyjk kjyyjk Jul 11, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

애플 개발자 페이지에서 다운 받은 개인키로 서명한 토큰을 애플 로그인 시 client secret으로 활용합니다.
애플은 공개키로 서명을 검증하고 토큰을 교환해줍니다.

Comment on lines +11 to +12
CONSTRAINT `UK_host_apple_host_id` UNIQUE (`host_id`),
CONSTRAINT `UK_host_apple_user_id` UNIQUE (`user_id`)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

회원탈퇴, soft delete, 재가입 시 user_id가 중복되어 유니크 제약 조건에 걸릴 수 있습니다.
현재 회원가입 pr에서는 중복 가입 방지 차원에서 user_id에 유니크를 걸어두고 재가입은 회원탈퇴 pr에서 다룰께요!

Comment on lines +14 to +15
private static final Duration APPLE_CONNECT_TIMEOUT = Duration.ofSeconds(3);
private static final Duration APPLE_READ_TIMEOUT = Duration.ofSeconds(5);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

초기 구현 단계이므로 너무 짧지도 길지도 않은 수치로 설정했습니다.
특히 인가 코드 -> 토큰 교환 과정에서 한번 소비된 인가 코드는 재사용이 불가하므로 재시도 없이 타임아웃을 여유있게 잡아주었습니다.

Comment on lines +78 to +85
apple:
jwks-url: http://localhost/apple/auth/keys
issuer: https://appleid.apple.com
client-id: test-apple-client-id
team-id: test-apple-team-id
key-id: test-apple-key-id
private-key: test-apple-private-key
token-url: http://localhost/apple/auth/token

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

실제 application.yml도 이렇게 mock 데이터인데,
이 부분은 애플 개발자 계정 결제 후에 별도 pr에서 채워놓겠습니다.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

이제 git-crypt 말고 별도 .env 파일로 환경 변수 주입하는 방식에 대해 어떻게 생각하시나요?

이전에는 우테코 인프라 제약으로 인해 환경 변수 주입이 까다로워 git-crypt를 선택했던걸로 기억하는데,
아무리 암호화한다고 하더라도 여전히 휴먼에러 등으로 인한 보안 문제가 도사리고 있으며
애플리케이션 실행 시점에 .env 파일로 주입하는게 더 유연하다고 생각합니다.

관리 포인트도 저희 둘 로컬과 서버뿐이기도 하고요.

Comment on lines +73 to +93
private AppleHost toAppleHost(AppleLoginConfirmRequest request) {
AppleIdToken idToken = jwtParser.parseAppleIdToken(request.idToken(), request.rawNonce());
Optional<AppleHost> appleHost = appleHostRepository.findByUserId(idToken.sub());
return appleHost.orElseGet(() -> {
AppleUser appleUser = parseAppleUser(request.user());
Host host = new Host(appleUser.fullName(), null, idToken.email());
hostRepository.save(host);
return appleHostRepository.save(new AppleHost(host, idToken.sub()));
});
}

private AppleUser parseAppleUser(String userJson) {
if (!StringUtils.hasText(userJson)) {
throw new BaseException("회원가입을 위해 애플 사용자 정보가 필요합니다.", HttpStatus.BAD_REQUEST);
}
try {
return objectMapper.readValue(userJson, AppleUser.class);
} catch (JsonProcessingException e) {
throw new BaseException("애플 사용자 정보 형식이 올바르지 않습니다.", HttpStatus.BAD_REQUEST, e);
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

탈퇴 시에 애플의 revoke 엔드포인트 요청하므로 탈퇴 후 재가입 시 애플은 다시 최초 유저 정보를 제공함.
일시적 오류로 인한 재시도 시 클라이언트에서 사용자 정보를 보관한 뒤에 다시 요청할 것이므로 제안한 폴백을 반영하지 않을 예정

Comment thread src/main/java/com/forgather/global/auth/service/AuthService.java
kjyyjk added 2 commits July 17, 2026 15:40
- Provider 생성 시 private key 형식을 검증하고 파싱 결과를 재사용

- 잘못된 private key 입력에 대한 예외 테스트 추가

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/test/resources/application.yml (1)

78-91: 📐 Maintainability & Code Quality | 🔵 Trivial

git-crypt 대신 .env 및 환경 변수를 활용하는 방식에 적극 찬성합니다.

과거 코멘트에서 질문해 주신 환경 변수 관리 방식 변경에 대해 답변드립니다.
말씀하신 것처럼 git-crypt는 파일 자체가 암호화되더라도 복호화 키 관리 문제나 휴먼 에러로 인한 시크릿 유출 위험이 지속적으로 존재합니다.

.env 파일이나 런타임 환경 변수로 설정값을 주입하는 방식(12-Factor App 방법론)은 코드와 설정을 완전히 분리하므로 다음과 같은 이점이 있습니다:

  • 로컬, 테스트, 운영 등 각 환경 간 유연한 전환 및 개별 설정 관리 용이
  • CI/CD 파이프라인(예: GitHub Actions Secrets)과의 안전하고 원활한 연동
  • 시크릿 값만 변경될 때 불필요한 코드 커밋 생성 방지

현재 관리 포인트가 서버와 로컬 환경 정도로 단순하다면, 말씀하신 대로 .env 방식을 도입하는 것이 관리의 유연성과 보안 측면에서 더 나은 방향이 될 것입니다. 추후 환경 변수 주입 방식으로 전환하는 리팩토링 진행을 권장합니다.

(참고: 정적 분석 도구에서 해당 파일 내의 Private Key를 감지하여 보안 경고를 발생시킬 수 있으나, 주석에 명시된 바와 같이 테스트 전용 Mock 데이터이므로 무시하셔도 무방합니다.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/resources/application.yml` around lines 78 - 91, application.yml의
apple.private-key에 하드코딩된 테스트 개인 키를 제거하고 .env 또는 런타임 환경 변수에서 주입되도록 변경하세요. Apple
설정의 private-key 참조가 환경 변수 값을 사용하도록 수정하고, 기존 테스트 설정에서 해당 값이 정상적으로 로드되도록 구성하세요.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/test/resources/application.yml`:
- Around line 78-91: application.yml의 apple.private-key에 하드코딩된 테스트 개인 키를 제거하고
.env 또는 런타임 환경 변수에서 주입되도록 변경하세요. Apple 설정의 private-key 참조가 환경 변수 값을 사용하도록 수정하고,
기존 테스트 설정에서 해당 값이 정상적으로 로드되도록 구성하세요.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: acb3fd9a-a16e-41b5-b253-ea7d6fc00f81

📥 Commits

Reviewing files that changed from the base of the PR and between 8f4c7b8 and 44da952.

📒 Files selected for processing (6)
  • src/main/java/com/forgather/global/auth/util/AppleClientSecretProvider.java
  • src/main/resources/application-dev.yml
  • src/main/resources/application-prod.yml
  • src/main/resources/application.yml
  • src/test/java/com/forgather/global/auth/util/AppleClientSecretProviderTest.java
  • src/test/resources/application.yml

- 다른 로그인 API와 동일하게 토큰을 응답 바디와 HttpOnly 쿠키로 반환
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: 애플 로그인 추가

1 participant