개발/백엔드

API 서버에서 애플 로그인 검증 및 테스트하는 방법 (1/5)

제하쓰 2025. 3. 13. 14:06

 

안녕하세요 제하쓰의 제이입니다 :)

현재 회사에서 유일한 백엔드 개발자로 일하고 있습니다.
저희 회사 앱은 총 5개의 소셜 공급자를 이용한 간편 로그인 시스템을 지원하고 있는데요, 서버 입장에서 어떤식으로 구현하였는지 어떤점을 고려해야하는지를 좀 풀어볼까 합니다.

총 5개의 시리즈를 통해 살펴볼 예정입니다.

API 서버에서 애플 로그인 검증 및 테스트하는 방법  (1/5)
API 서버에서 카카오 로그인 검증 및 테스트하는 방법  (2/5)
API 서버에서 네이버 로그인 검증 및 테스트하는 방법  (3/5)
API 서버에서 구글로그인 검증 및 테스트하는 방법  (4/5)
API 서버에서 메타(페이스북) 로그인 검증 및 테스트하는 방법  (5/5)

사내 벡엔드 개발 스택은 파이썬을 이용한 FastAPI 프레임워크로 구현하고 있습니다. 서버 배포는 AWS를 이용한 Fargate로 구현이 되어있는데 별도 글로 소개를 해보겠습니다 ㅎㅎ.

 

저희 서버는 클라이언트에게 access_token과 refresh_token을 전달하여 로그인 과정을 처리하고 있습니다. 소셜 로그인의 경우 클라이언트가 애플 인증 서버에게 토큰을 발급받아 저희 서버에 전달해주면 벡엔드 단에서 해당 토큰을 검증하여
클라이언트에게 access_token과 refresh_token을 전달해주는 방식입니다.

1 ~ 2 번은 클라이언트에서 애플로부터 id_token을 발급받는 과정입니다. 애플은 공식 문서가 영어로만 제공되고 있고 사용자 친화적이지 못하게 문서를 제공하고 있다고 생각합니다.(네이버나 카카오에 비하면..)

 

 

공식문서에서 보시면 성공적으로 애플 서버에 로그인한경우 identity token 즉 id_token을 발급받습니다. 서버입장에서는 클라이언트에서 해당 id_token과 provider로 apple을 지정해 저희 API 서버로 소셜 로그인을 요청한다고 가정합니다.

//auth_controller.py
@router.post("/social-signin/{provider}", response_model=prod_schemas.TokenResponse)
async def valid_social_login(provider: str, token_info: schemas.SocialTokenInfo, db: Session = Depends(get_db)):
    # 추후 일괄적으로 파라미터를 token으로 변경
    if provider == "naver":
        email, uuid = await service.valid_naver_token(db, token_info.id_token)
    elif provider == "kakao":
        email, uuid = await service.valid_kakao_token(db, token_info.id_token)
    elif provider == "google":
        email, uuid = await service.valid_google_token(db, token_info.id_token)
    elif provider == "apple":
        email, uuid = await service.valid_apple_token(db, token_info.id_token) // 애플 토큰 검증
    elif provider == "facebook":
        email, uuid = await service.valid_facebook_token(db, token_info.access_token)
    else:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid provider",
        )
    await service.check_user_deleted(db, email)
    access_token = security.create_access_token(data={"sub": email})
    expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
    refresh_token = security.create_refresh_token(data={"sub": email})
    refresh_expires_in = settings.REFRESH_TOKEN_EXPIRE_MINUTES * 60
    await service.create_refresh_token(db, refresh_token, uuid)

    return {
        "access_token": access_token,
        "expires_in": expires_in,
        "refresh_token": refresh_token,
        "refresh_expires_in": refresh_expires_in,
        "id": uuid,
        "token_type": "bearer",
    }


//auth_schemas.py

class SocialTokenInfo(BaseModel):
    id_token: Optional[str] = None
    access_token: Optional[str] = None

소셜 로그인 API router 입니다. 저희는 클라이언트단에 소셜 로그인을 호출할때 공급자별로 토큰을 다르게 보내주는 방식을 사용하고 있는데요. 이 부분은 개인적으로 코드가 조금 지저분하여 리팩토링을 진행하고 싶은 마음입니다. (언젠간..?)

POST /social-signin/apple
Content-Type: application/json
{
  "id_token": "apple_id_token"
}

아무튼 클라이언트 입장에서는 위와같이 소셜 로그인을 요청합니다. provider로 apple과 body에 id_token을 전송해줍니다. 서버에서 애플 로그인 요청을 받았으니 애플 토큰을 검증해보겠습니다.

async def valid_apple_token(db: Session, user_id_token: str):
    try:
        // 애플 공개키 조회
        key_payload = get("https://appleid.apple.com/auth/keys")
        kid = jwt.get_unverified_header(user_id_token)["kid"]
        jwks = key_payload.json()["keys"]
        public_key = None
        for key in jwks:
            if key["kid"] == kid:
                public_key = RSAAlgorithm.from_jwk(key)
        // 서버의 공개 키를 사용하여 JWS E256 서명을 확인 + aud 확인
        decode_id_token = jwt.decode(user_id_token, public_key, algorithms=["RS256"], audience="com.example.app")
        // iss필드 확인
        if decode_id_token["iss"] not in ["https://appleid.apple.com", "appleid.apple.com"]:
            raise ValueError("Wrong issuer.")
        // 토큰 exp 확인
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="invalid JWT Token",
        )
    except ValueError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid Social Token",
        )

    social_id = decode_id_token["sub"]
    social_user = await crud.get_social_user_by_social_id(db, social_id)

    if not social_user:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="User is not valid, please sign up",
        )
    elif social_user.social_type != "apple":
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"User is signed up with {social_user.social_type} type",
        )
    return social_user.email, social_user.id
애플 공식 문서

 애플 공식 문서에 따라 id_token을 검증하겠습니다.

먼저 애플에 공개키를 요청하는 부분입니다. GET 요청을 통해 해당 URL(https://appleid.apple.com/auth/keys)로 애플에 공개키를 요청합니다.

해당 URL에 들어가시면 이런식으로 공개키가 3종류가 존재하는걸 확인할 수 있습니다. 저희는 클라이언트가 전달해준 토큰에서 kid 값을 추출하여 3개의 공개키중 같은 kid를 찾아 해당 공개키로 decode를 수행합니다.

audience은 클라이언트가 애플 소셜 로그인을 위해 애플에 등록한 대상자입니다. 여기서는 앱 패키지 이름입니다.

jwt.decode 함수를 이용하시면 exp도 검증해주기에 편리합니다. 성공적으로 검증하면 id_token안에 값을 확인할 수 있습니다.

애플 공식 문서

nonce의 경우 클라이언트에서 로그인 요청할때 nonce 값을 전달하면 apple에서 해당 nonce를 id_token에 넣어서 보내줍니다. 저희 서버는 nonce를 받는 파라미터가 없기 때문에 해당 검증은 진행하지 않았습니다만 Relay 공격을 방지하기 위해서 나중에는 구현할 생각입니다.

토큰의 발급자 주체인 iss 값을 확인합니다. jwt.decode 함수는 하나의 string만 비교할 수 있어서 따로 코드를 작성했습니다. 애플 토큰의 경우 iss 값이 "https://"가 붙어 있는게 있고 없는게 있어서 두개 모두 허용해줍니다.

이후에는 사용자가 회원인지 확인하는 과정입니다. 다른 소셜 토큰들과 다르게 애플의 경우 까다로운 보안정책으로 인해 토큰에 이메일을 안담아서 보내줄때가 있습니다. (귀찮게합니다..) 저희 서버에서는 소셜 회원가입시 소셜 공급자로부터 유저의 고유한 id인 sub값을 저장합니다. 따라서 소셜 로그인시에는 고유값인 sub값을 기준으로 회원 여부를 판단하셔야 합니다.

여기서 social_id 값은 sub값입니다.

crud를 통해 해당 sub 값을 조회하고 일치하는 유저가 있으면 email과 user_id를 리턴하여 이후 로직을 수행합니다.

나머지는 사실 설명할게 별로없습니다. 삭제된 사용자인지 확인후 access_token과 refresh_token을 클라이언트에게 전달해줍니다.


테스트: 애플 로그인 응답(Mock 데이터) 생성

이제 테스트 코드를 통해 API가 제대로 작동하는지 검증해보겠습니다. 테스트 프레임워크로 pytest를 사용중입니다. 테스트를 위해 실제 애플 로그인 서버에 요청을 보내는 대신, 애플 로그인 응답을 Mock 데이터로 생성합니다.

//test_social_signin.py

root_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))

# 애플의 공개키를 mock_response로 주기 위해 별도의 apple_key_mock.json 파일을 만들었습니다.
with open(os.path.join(root_path, "tests/test_app/auth/apple_key_mock.json"), encoding="utf-8") as apple_key_file:
    apple_key = json.load(apple_key_file)


@pytest.fixture(scope="function")
def mock_apple_response():
    headers = {"kid": "YuyXoY", "alg": "RS256"}
    payload = {
        "iss": "https://appleid.apple.com",
        "iat": datetime.now(),
        "exp": datetime.now() + timedelta(days=180),
        "sub": "111260650121185072907",
        "nonce": "test",
        "c_hash": "test",
        "email": "apple@icloud.com",
        "is_private_email": "true",
        "auth_time": datetime.now().isoformat(),
        "nonce_supported": True,
    }
    apple_jwt = jwt.encode(headers=headers, payload=payload, key=private_key, algorithm="RS256")
    return apple_jwt

📌 mock_apple_response() 함수

  • 애플에서 제공하는 JWT 응답을 모방하여 테스트할 수 있도록 함.
  • 사용자의 sub(고유 ID)와 email 등의 필드가 포함됨.
  • nonce는 인증 요청과 응답이 일치하는지 검증하는 데 사용됨.
  • iat, exp는 토큰의 유효 기간을 설정하는데 활용됨.
  • 실제 애플의 공개 키 대신 private_key를 사용하여 JWT를 생성함.
// apple_key_mock.json
{
        "keys": [
            {
                "kty": "RSA",
                "kid": "YuyXoY",
                "use": "sig",
                "alg": "RS256",
                "n": "vaY3z3gGwNh2CBdRkEHLuC_46Gh_D7--dN9cfcTj0aLfDK9G0_UJfVnqLDxSHwdSw3i1tUZst-ZhgPMu4BG4OSmvtQdKC0iCSn3tlyzM1hFPE1uFcOlzvlzmjEbad-husFgSUYND6myb6_zuLaOmDbE0g6gly_GdH40ExVpYevH3SwpDIgABXmIX7izd8a2oH88dINx73DuBcQPjlhEVBwqzwecVvM79MdQNxM4MNii9JLd8V9hwPX0G53lgVxEZTJrs-TiKcM-zntX5zoz3GpG6xM18dI4CIPLybNungIhlhHJkEskUbQhYYAKWPP5UqqbltQJRS3LjQnFL1LbfLw",
                "e": "AAEAAQ"
            },
            {
                "kty": "RSA",
                "kid": "fh6Bs8C",
                "use": "sig",
                "alg": "RS256",
                "n": "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw",
                "e": "AQAB"
            },
            {
                "kty": "RSA",
                "kid": "W6WcOKB",
                "use": "sig",
                "alg": "RS256",
                "n": "2Zc5d0-zkZ5AKmtYTvxHc3vRc41YfbklflxG9SWsg5qXUxvfgpktGAcxXLFAd9Uglzow9ezvmTGce5d3DhAYKwHAEPT9hbaMDj7DfmEwuNO8UahfnBkBXsCoUaL3QITF5_DAPsZroTqs7tkQQZ7qPkQXCSu2aosgOJmaoKQgwcOdjD0D49ne2B_dkxBcNCcJT9pTSWJ8NfGycjWAQsvC8CGstH8oKwhC5raDcc2IGXMOQC7Qr75d6J5Q24CePHj_JD7zjbwYy9KNH8wyr829eO_G4OEUW50FAN6HKtvjhJIguMl_1BLZ93z2KJyxExiNTZBUBQbbgCNBfzTv7JrxMw",
                "e": "AQAB"
            }
        ]
    }

API 서버에서 애플 로그인 검증 테스트 코드 분석

이 테스트 코드는 애플 소셜 로그인을 검증하는 단위 테스트입니다.
애플 로그인은 첫 로그인 시 이메일 정보를 포함한 토큰을 반환하지만, 이후 로그인부터는 유저의 고유 ID(sub)만 포함합니다.

아래 두 가지 시나리오를 테스트하고 있습니다.

1️⃣ 성공적인 애플 로그인 (test_social_login_apple_success)
2️⃣ 회원가입이 필요한 경우 (test_social_login_apple_signup_required)

// test_social_signin.py    
    # 애플 소셜 로그인은 첫시도시 토큰에 이메일이 포함되어 오고 그 뒤로는 유저 고유 아이디만 준다.
    1️⃣ 성공적인 애플 로그인
	async def test_social_login_apple_success(
        self,
        test_client: httpx.AsyncClient,
        create_social_user: List[SocialUser],
        mocker: MockerFixture,
        mock_apple_response,
    ):
        mock_response = Response()
        mock_response.status_code = 200
        mock_response.json = lambda: apple_key
        mocker.patch(mock_apple_validation, return_value=mock_response)
        response = await test_client.post(
            "api/v1/example/auth/social-signin/apple", json={"id_token": mock_apple_response, "access_token": None}
        )
        assert response.status_code == status.HTTP_200_OK
  • 설명
    • Mock 데이터 활용
      • mock_apple_response: 정상적인 애플 로그인 JWT 토큰을 생성하는 Mock Fixture.
      • mock_response: 애플 공개 키를 반환하는 Mock Response.
    • 테스트 과정
      1. mocker.patch()를 이용해 애플 로그인 검증 로직 (mock_apple_validation)을 Mock 처리
      2. test_client.post()를 통해 소셜 로그인 API 엔드포인트에 요청
      3. id_token에 mock_apple_response(Mock JWT)를 넣고, access_token은 None
      4. 응답이 200 OK인지 검증
    테스트 성공 기준:
    • status_code == 200 OK이면 애플 로그인 성공
    2️⃣ 회원가입이 필요한 경우
    async def test_social_login_apple_signup_required(
        self,
        test_client: httpx.AsyncClient,
        create_social_user: List[SocialUser],
        mocker: MockerFixture,
        mock_apple_response_signup_required,
    ):
        mock_response = Response()
        mock_response.status_code = 200
        mock_response.json = lambda: apple_key
        mocker.patch(mock_apple_validation, return_value=mock_response)
        response = await test_client.post(
            "api/v1/example/auth/social-signin/apple",
            json={"id_token": mock_apple_response_signup_required, "access_token": None},
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN
        assert response.json()["detail"] == "User is not valid, please sign up"
  • 설명
    • mock_apple_response_signup_required을 사용해 회원 정보가 없는 JWT 토큰을 Mock으로 생성.
    • 테스트 과정은 위와 동일하지만, 응답이 403 Forbidden이 되어야 함.
    테스트 성공 기준:
    • status_code == 403 FORBIDDEN
    • 응답 메시지 "User is not valid, please sign up"이 포함
    📌 이 의미는?
    • 애플 로그인 시 서버에 회원 정보가 없는 경우 회원가입이 필요함.

결론

애플의 경우 까다로운 보안 정책 때문에 API 서버에서 구현하기 가장 까다로웠던 소셜이 아니었나 싶습니다. 특히 이메일을 안넘겨주는 경우와 익명의 이메일을 이용해 로그인 또는 회원가입할수 있는 방법을 제공하기에 서버 입장에서는 예외 케이스를 많이 생각해야하는 과정이었습니다. 가장 중요한것은 사내 클라이언트 쪽 개발자분과의 소통이 중요하다고 생각됩니다. (어려운일 넘기기 신공)

# 의견이나 질문, 피드백은 언제나 환영입니다. 😁