diff --git a/README.md b/README.md index 497c278..04ef650 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ make proto # protobuf 코드 생성 | `POST` | `/auth/native/sign-up` | 네이티브 앱용. JSON 본문의 `signup_token`을 반환 | | `POST` | `/auth/native/verify-email` | 네이티브 앱용. JSON의 `signup_token`, `verification_code`를 검증 | | `POST` | `/auth/sign-in` | 이메일·비밀번호 로그인 및 토큰 발급 | -| `POST` | `/auth/apple` | Apple authorization code를 교환·검증하고 토큰 발급. `client_id`에는 허용된 iOS/macOS Bundle ID를 전송 | +| `POST` | `/auth/apple` | Apple authorization code를 교환·검증하고 토큰 발급. 서버의 `APPLE_CLIENT_ID` 설정을 사용 | ## 라이선스 diff --git a/internal/auth/apple_login.go b/internal/auth/apple_login.go index a91cd28..3a6cf1b 100644 --- a/internal/auth/apple_login.go +++ b/internal/auth/apple_login.go @@ -73,11 +73,6 @@ func LoadAppleOAuthConfigFromEnv() (AppleOAuthConfig, error) { func (c AppleOAuthConfig) Enabled() bool { return c.ClientID != "" } -func (c AppleOAuthConfig) SupportsClientID(clientID string) bool { - clientID = strings.TrimSpace(clientID) - return clientID != "" && clientID == c.ClientID -} - type AppleTokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` @@ -85,11 +80,11 @@ type AppleTokenResponse struct { } type AppleAuthorizationExchanger interface { - Exchange(context.Context, string, string) (AppleTokenResponse, error) + Exchange(context.Context, string) (AppleTokenResponse, error) } type AppleIdentityVerifier interface { - Verify(context.Context, string, string, string) (AppleIdentity, error) + Verify(context.Context, string, string) (AppleIdentity, error) } type AppleTokenRevoker interface { @@ -137,16 +132,13 @@ func NewAppleOAuthClient(config AppleOAuthConfig) (*appleOAuthClient, error) { }, nil } -func (c *appleOAuthClient) Exchange(ctx context.Context, authorizationCode, clientID string) (AppleTokenResponse, error) { - if !c.config.SupportsClientID(clientID) { - return AppleTokenResponse{}, ErrInvalidAppleIDToken - } - secret, err := c.clientSecret(clientID) +func (c *appleOAuthClient) Exchange(ctx context.Context, authorizationCode string) (AppleTokenResponse, error) { + secret, err := c.clientSecret() if err != nil { return AppleTokenResponse{}, err } form := url.Values{ - "client_id": {clientID}, + "client_id": {c.config.ClientID}, "client_secret": {secret}, "code": {strings.TrimSpace(authorizationCode)}, "grant_type": {"authorization_code"}, @@ -176,7 +168,7 @@ func (c *appleOAuthClient) Exchange(ctx context.Context, authorizationCode, clie } func (c *appleOAuthClient) Revoke(ctx context.Context, refreshToken string) error { - secret, err := c.clientSecret(c.config.ClientID) + secret, err := c.clientSecret() if err != nil { return err } @@ -203,13 +195,10 @@ func (c *appleOAuthClient) Revoke(ctx context.Context, refreshToken string) erro return nil } -func (c *appleOAuthClient) clientSecret(clientID string) (string, error) { - if !c.config.SupportsClientID(clientID) { - return "", ErrInvalidAppleIDToken - } +func (c *appleOAuthClient) clientSecret() (string, error) { now := c.now().UTC() claims := jwt.RegisteredClaims{ - Issuer: c.config.TeamID, Subject: clientID, + Issuer: c.config.TeamID, Subject: c.config.ClientID, Audience: jwt.ClaimStrings{appleIssuer}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(appleSecretTTL)), } @@ -234,10 +223,7 @@ type AppleIdentity struct { DisplayName string } -func (c *appleOAuthClient) Verify(ctx context.Context, rawToken, expectedNonce, clientID string) (AppleIdentity, error) { - if !c.config.SupportsClientID(clientID) { - return AppleIdentity{}, ErrInvalidAppleIDToken - } +func (c *appleOAuthClient) Verify(ctx context.Context, rawToken, expectedNonce string) (AppleIdentity, error) { claims := &appleIDTokenClaims{} parsed, err := jwt.ParseWithClaims(strings.TrimSpace(rawToken), claims, func(token *jwt.Token) (any, error) { if token.Method != jwt.SigningMethodRS256 { @@ -245,7 +231,7 @@ func (c *appleOAuthClient) Verify(ctx context.Context, rawToken, expectedNonce, } kid, _ := token.Header["kid"].(string) return c.keyFor(ctx, kid) - }, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), jwt.WithIssuer(appleIssuer), jwt.WithAudience(clientID), jwt.WithExpirationRequired()) + }, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), jwt.WithIssuer(appleIssuer), jwt.WithAudience(c.config.ClientID), jwt.WithExpirationRequired()) if err != nil || parsed == nil || !parsed.Valid { return AppleIdentity{}, ErrInvalidAppleIDToken } @@ -486,12 +472,12 @@ func NewAppleLoginService(exchanger AppleAuthorizationExchanger, verifier AppleI } return &AppleLoginService{exchanger: exchanger, verifier: verifier, accounts: accounts, tokens: tokens, cipher: cipher}, nil } -func (s *AppleLoginService) Login(ctx context.Context, authorizationCode, nonce, displayName, clientID string, client ClientInfo) (TokenPair, error) { - response, err := s.exchanger.Exchange(ctx, authorizationCode, clientID) +func (s *AppleLoginService) Login(ctx context.Context, authorizationCode, nonce, displayName string, client ClientInfo) (TokenPair, error) { + response, err := s.exchanger.Exchange(ctx, authorizationCode) if err != nil { return TokenPair{}, fmt.Errorf("exchange Apple authorization code: %w", err) } - identity, err := s.verifier.Verify(ctx, response.IDToken, nonce, clientID) + identity, err := s.verifier.Verify(ctx, response.IDToken, nonce) if err != nil { if errors.Is(err, ErrInvalidAppleIDToken) { return TokenPair{}, err diff --git a/internal/auth/apple_login_http.go b/internal/auth/apple_login_http.go index a8f1f15..10af110 100644 --- a/internal/auth/apple_login_http.go +++ b/internal/auth/apple_login_http.go @@ -25,7 +25,7 @@ func (h *tokenHTTPHandler) handleAppleLogin(w http.ResponseWriter, r *http.Reque h.writeError(w, r, http.StatusBadRequest, "bad_request", "Invalid Apple login request.") return } - pair, err := h.apple.Login(r.Context(), request.AuthorizationCode, request.Nonce, request.displayName(), request.ClientID, requestClientInfo(r)) + pair, err := h.apple.Login(r.Context(), request.AuthorizationCode, request.Nonce, request.displayName(), requestClientInfo(r)) if err != nil { switch { case errors.Is(err, ErrInvalidAppleIDToken), errors.Is(err, ErrUserInactive): @@ -41,7 +41,6 @@ func (h *tokenHTTPHandler) handleAppleLogin(w http.ResponseWriter, r *http.Reque type appleLoginRequest struct { AuthorizationCode string `json:"authorization_code"` - ClientID string `json:"client_id"` Nonce string `json:"nonce"` GivenName string `json:"given_name"` FamilyName string `json:"family_name"` @@ -59,10 +58,9 @@ func decodeAppleLoginRequest(w http.ResponseWriter, r *http.Request) (appleLogin return appleLoginRequest{}, errors.New("request body must contain one JSON object") } request.AuthorizationCode = strings.TrimSpace(request.AuthorizationCode) - request.ClientID = strings.TrimSpace(request.ClientID) request.Nonce = strings.TrimSpace(request.Nonce) - if request.AuthorizationCode == "" || request.ClientID == "" || len(request.AuthorizationCode) > 4096 || len(request.ClientID) > maxAppleField || len(request.Nonce) > 1024 { - return appleLoginRequest{}, errors.New("authorization_code and client_id are required") + if request.AuthorizationCode == "" || len(request.AuthorizationCode) > 4096 || len(request.Nonce) > 1024 { + return appleLoginRequest{}, errors.New("authorization_code is required") } return request, nil } diff --git a/internal/auth/apple_login_http_test.go b/internal/auth/apple_login_http_test.go index bf823f6..6d0eb6f 100644 --- a/internal/auth/apple_login_http_test.go +++ b/internal/auth/apple_login_http_test.go @@ -34,7 +34,7 @@ func testAppleLoginHTTPHandler(t *testing.T, verifier AppleIdentityVerifier) htt func TestAppleLoginHTTPIssuesTokenPair(t *testing.T) { handler := testAppleLoginHTTPHandler(t, &stubAppleVerifier{identity: AppleIdentity{Subject: "apple-subject", Email: "person@example.com", EmailVerified: true}}) - request := httptest.NewRequest(http.MethodPost, "/auth/apple", bytes.NewBufferString(`{"authorization_code":"single-use-code","client_id":"com.framework.innolive","nonce":"nonce","given_name":"Ada","family_name":"Lovelace"}`)) + request := httptest.NewRequest(http.MethodPost, "/auth/apple", bytes.NewBufferString(`{"authorization_code":"single-use-code","nonce":"nonce","given_name":"Ada","family_name":"Lovelace"}`)) request.Header.Set("Content-Type", "application/json") request.Header.Set("Origin", "http://localhost:3000") response := httptest.NewRecorder() @@ -53,19 +53,13 @@ func TestAppleLoginHTTPIssuesTokenPair(t *testing.T) { func TestAppleLoginHTTPRejectsInvalidInput(t *testing.T) { handler := testAppleLoginHTTPHandler(t, &stubAppleVerifier{err: ErrInvalidAppleIDToken}) - missingClientID := httptest.NewRequest(http.MethodPost, "/auth/apple", bytes.NewBufferString(`{"authorization_code":"code"}`)) - missingClientIDResponse := httptest.NewRecorder() - handler.ServeHTTP(missingClientIDResponse, missingClientID) - if missingClientIDResponse.Code != http.StatusBadRequest { - t.Fatalf("missing client_id status = %d", missingClientIDResponse.Code) - } - invalid := httptest.NewRequest(http.MethodPost, "/auth/apple", bytes.NewBufferString(`{"authorization_code":"code","client_id":"com.framework.innolive"}`)) + invalid := httptest.NewRequest(http.MethodPost, "/auth/apple", bytes.NewBufferString(`{"authorization_code":"code"}`)) invalidResponse := httptest.NewRecorder() handler.ServeHTTP(invalidResponse, invalid) if invalidResponse.Code != http.StatusUnauthorized { t.Fatalf("invalid token status = %d", invalidResponse.Code) } - bad := httptest.NewRequest(http.MethodPost, "/auth/apple", bytes.NewBufferString(`{"authorization_code":"code","client_id":"com.framework.innolive","unexpected":true}`)) + bad := httptest.NewRequest(http.MethodPost, "/auth/apple", bytes.NewBufferString(`{"authorization_code":"code","unexpected":true}`)) badResponse := httptest.NewRecorder() handler.ServeHTTP(badResponse, bad) if badResponse.Code != http.StatusBadRequest { diff --git a/internal/auth/apple_login_test.go b/internal/auth/apple_login_test.go index 724b6e8..9fd1ec3 100644 --- a/internal/auth/apple_login_test.go +++ b/internal/auth/apple_login_test.go @@ -19,11 +19,10 @@ type stubAppleExchanger struct { response AppleTokenResponse err error seen string - clientID string } -func (s *stubAppleExchanger) Exchange(_ context.Context, code, clientID string) (AppleTokenResponse, error) { - s.seen, s.clientID = code, clientID +func (s *stubAppleExchanger) Exchange(_ context.Context, code string) (AppleTokenResponse, error) { + s.seen = code return s.response, s.err } @@ -32,11 +31,10 @@ type stubAppleVerifier struct { err error seenID string seenNonce string - clientID string } -func (s *stubAppleVerifier) Verify(_ context.Context, idToken, nonce, clientID string) (AppleIdentity, error) { - s.seenID, s.seenNonce, s.clientID = idToken, nonce, clientID +func (s *stubAppleVerifier) Verify(_ context.Context, idToken, nonce string) (AppleIdentity, error) { + s.seenID, s.seenNonce = idToken, nonce return s.identity, s.err } @@ -62,15 +60,15 @@ func TestAppleLoginIssuesTokensAndEncryptsProviderRefreshToken(t *testing.T) { if err != nil { t.Fatal(err) } - pair, err := service.Login(context.Background(), "authorization-code", "nonce-value", "Ada Lovelace", "com.framework.innolive", ClientInfo{}) + pair, err := service.Login(context.Background(), "authorization-code", "nonce-value", "Ada Lovelace", ClientInfo{}) if err != nil { t.Fatal(err) } if pair.AccessToken == "" || pair.RefreshToken == "" { t.Fatalf("incomplete token pair: %+v", pair) } - if exchanger.seen != "authorization-code" || exchanger.clientID != "com.framework.innolive" || verifier.seenID != "apple-id-token" || verifier.seenNonce != "nonce-value" || verifier.clientID != "com.framework.innolive" { - t.Fatalf("unexpected exchange/verifier inputs: code=%q exchange_client_id=%q id=%q nonce=%q verify_client_id=%q", exchanger.seen, exchanger.clientID, verifier.seenID, verifier.seenNonce, verifier.clientID) + if exchanger.seen != "authorization-code" || verifier.seenID != "apple-id-token" || verifier.seenNonce != "nonce-value" { + t.Fatalf("unexpected exchange/verifier inputs: code=%q id=%q nonce=%q", exchanger.seen, verifier.seenID, verifier.seenNonce) } if accounts.identity.DisplayName != "Ada Lovelace" || len(accounts.ciphertext) == 0 || accounts.version == nil { t.Fatalf("resolver data = %+v ciphertext=%d version=%v", accounts.identity, len(accounts.ciphertext), accounts.version) @@ -92,7 +90,7 @@ func TestAppleLoginRejectsInvalidIDTokenAndInactiveUser(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := invalid.Login(context.Background(), "code", "", "", "com.framework.innolive", ClientInfo{}); !errors.Is(err, ErrInvalidAppleIDToken) { + if _, err := invalid.Login(context.Background(), "code", "", "", ClientInfo{}); !errors.Is(err, ErrInvalidAppleIDToken) { t.Fatalf("invalid ID token error = %v", err) } inactive, err := NewAppleLoginService( @@ -104,7 +102,7 @@ func TestAppleLoginRejectsInvalidIDTokenAndInactiveUser(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := inactive.Login(context.Background(), "code", "", "", "com.framework.innolive", ClientInfo{}); !errors.Is(err, ErrUserInactive) { + if _, err := inactive.Login(context.Background(), "code", "", "", ClientInfo{}); !errors.Is(err, ErrUserInactive) { t.Fatalf("inactive user error = %v", err) } } @@ -130,25 +128,18 @@ func TestAppleIDTokenVerifierChecksSignatureClaimsAndNonce(t *testing.T) { if err != nil { t.Fatal(err) } - identity, err := client.Verify(context.Background(), raw, "expected-nonce", "com.framework.innolive") + identity, err := client.Verify(context.Background(), raw, "expected-nonce") if err != nil { t.Fatal(err) } if identity.Subject != "apple-subject" || !identity.EmailVerified || identity.IsPrivateEmail { t.Fatalf("identity = %+v", identity) } - if _, err := client.Verify(context.Background(), raw, "wrong-nonce", "com.framework.innolive"); !errors.Is(err, ErrInvalidAppleIDToken) { + if _, err := client.Verify(context.Background(), raw, "wrong-nonce"); !errors.Is(err, ErrInvalidAppleIDToken) { t.Fatalf("nonce mismatch error = %v", err) } } -func TestAppleOAuthConfigOnlyAcceptsConfiguredClientID(t *testing.T) { - config := AppleOAuthConfig{ClientID: "com.framework.innolive"} - if !config.SupportsClientID("com.framework.innolive") || config.SupportsClientID("com.framework.innolive.macos") { - t.Fatalf("configured client ID validation failed") - } -} - func TestAppleJWKParsesRSAPublicKey(t *testing.T) { privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -174,7 +165,7 @@ func TestAppleClientSecretUsesES256AppleClaims(t *testing.T) { } now := time.Now().UTC() client := &appleOAuthClient{config: AppleOAuthConfig{TeamID: "TEAMID", ClientID: "com.framework.innolive", KeyID: "KEYID"}, privateKey: privateKey, now: func() time.Time { return now }} - raw, err := client.clientSecret("com.framework.innolive") + raw, err := client.clientSecret() if err != nil { t.Fatal(err) }