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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 설정을 사용 |

## 라이선스

Expand Down
40 changes: 13 additions & 27 deletions internal/auth/apple_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,23 +73,18 @@ 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"`
IDToken string `json:"id_token"`
}

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 {
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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
}
Expand All @@ -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)),
}
Expand All @@ -234,18 +223,15 @@ 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 {
return nil, ErrInvalidAppleIDToken
}
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
}
Expand Down Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions internal/auth/apple_login_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"`
Expand All @@ -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
}
Expand Down
12 changes: 3 additions & 9 deletions internal/auth/apple_login_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
33 changes: 12 additions & 21 deletions internal/auth/apple_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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)
}
}
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
Loading