2025. 4. 9. 17:58ㆍProgramming/Flutter
Flutter로 앱을 개발하다 보면 소셜 로그인 기능은 거의 필수적으로 구현하게 됩니다. 그중에서도 'Sign in with Apple'은 iOS 사용자들에게 편리한 경험을 제공하는 중요한 기능이죠. 그런데 이상하게도 Android나 Web 환경에서는 잘 동작하던 Apple Sign In이 유독 iOS 네이티브 앱에서만 실패하는 경우가 있습니다. Bundle ID가 변경되지 않았다면, 백엔드의 Apple ID 토큰 검증 로직에 있을 가능성이 높습니다! 특히, JWT(JSON Web Token)의 aud
(Audience) 클레임 검증 방식이 문제의 핵심일 수 있습니다.
이 글에서는 Flutter 앱에서 Apple Sign In이 실패하는 흔한 원인 중 하나인 aud
클레임 불일치 문제와 그 해결 방법을 자세히 알아보겠습니다.
플랫폼 별 Sign in with Apple 동작 방식의 차이
문제를 이해하기 위해 먼저 Sign in with Apple이 플랫폼별로 어떻게 다르게 동작하는지 알아야 합니다.
클라이언트 측 (Flutter) 관점 예시:
Flutter에서는 sign_in_with_apple
과 같은 패키지를 사용하여 Apple Sign In을 요청합니다. 이때 플랫폼에 따라 Apple로부터 받는 identityToken
내부의 aud
클레임 값이 달라집니다.
// Flutter 클라이언트 코드 예시
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'dart:io' show Platform;
Future<void> handleSignInWithApple() async {
try {
final credential = await SignInWithApple.requestAppleIdCredential(
scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
// Android/Web 에서는 Services ID, iOS 에서는 Bundle ID가 aud 클레임에 설정됨
// (실제 aud 값은 Apple이 설정하며, 클라이언트가 직접 지정하는 것은 아님)
);
// credential.identityToken (JWT)를 백엔드로 전송
String? identityToken = credential.identityToken;
if (identityToken != null) {
await sendTokenToBackend(identityToken);
print("성공, 토큰 백엔드 전송 완료");
// iOS 네이티브: identityToken의 'aud'는 com.yourcompany.yourapp
// Android/Web: identityToken의 'aud'는 com.yourcompany.yourapp.web
} else {
print("실패: identityToken을 받지 못함");
}
} catch (error) {
print("오류 발생: $error");
// 오류 처리 로직
}
}
Future<void> sendTokenToBackend(String token) async {
// 백엔드 API로 토큰 전송 로직 (HTTP POST 등)
print("백엔드로 토큰 전송 중...");
// ...
}
- iOS (네이티브 흐름):
- Apple이 제공하는 네이티브
AuthenticationServices
프레임워크를 사용합니다. - 사용자는 iOS에서 제공되는 시스템 UI를 통해 로그인합니다.
- 로그인 성공 시, 앱은 Apple로부터
identityToken
과authorizationCode
등을 받습니다. - 이때 발급되는
identityToken
(JWT)의aud
클레임 값은 앱의 Bundle Identifier (예:com.yourcompany.yourapp
)가 됩니다.
- Apple이 제공하는 네이티브
- Android / Web (웹 기반 흐름):
- Android 등 다른 플랫폼에는 네이티브 SDK가 없으므로 웹 인증 흐름을 따릅니다.
- 사용자는 웹뷰나 브라우저를 통해 Apple ID로 로그인합니다.
- 로그인 성공 시, 지정된
redirectUri
로 리디렉션되면서 인증 정보를 전달받습니다. Flutter에서는sign_in_with_apple
같은 패키지가 이 과정을 처리해 줍니다. - 이때 발급되는
identityToken
(JWT)의aud
클레임 값은 Apple Developer Console에서 설정한 Services ID (예:com.yourcompany.yourapp.web
)가 됩니다.
문제의 원인이 되는 aud
(Audience)
JWT의 aud
클레임은 해당 토큰이 어떤 대상(Audience)을 위해 발급되었는지를 나타냅니다. 백엔드는 토큰을 수신했을 때, 이 토큰이 자신(또는 자신이 신뢰하는 클라이언트)을 위한 것이 맞는지 확인하기 위해 aud
클레임을 검증합니다.
여기서 플랫폼 별 동작 방식의 차이로 인한 문제가 발생합니다.
- iOS 네이티브 앱에서 로그인하면
identityToken
의aud
는 Bundle ID입니다. - Android/Web 앱에서 로그인하면
identityToken
의aud
는 Services ID입니다.
만약 백엔드가 identityToken
의 유효성을 검증할 때, aud
클레임 값이 오직 웹 흐름 기준인 Services ID와 일치하는지만 확인하도록 구현되어 있다면 어떻게 될까요? 바로 iOS 네이티브 앱에서 보낸 토큰은 aud
값이 다르기 때문에 유효하지 않은 토큰으로 간주되어 거부됩니다. Android/Web에서는 aud
가 Services ID와 일치하므로 정상적으로 통과되는 것이죠.
백엔드에서 여러 Audience 허용하기
이 문제를 해결하는 유일하고 올바른 방법은 백엔드의 토큰 검증 로직을 수정하는 것입니다.
"Apple Sign In으로 받은
identityToken
을 검증할 때,aud
클레임 값이 웹용 Services ID (com.yourcompany.yourapp.web
) 뿐만 아니라, iOS 앱의 모든 Bundle Identifier (com.yourcompany.yourapp
,com.yourcompany.yourapp.dev
등)도 유효한 Audience로 인정하도록 검증 로직을 수정해 주세요."
대부분의 표준 JWT 라이브러리는 검증 옵션에서 여러 Audience 값을 배열 형태로 받아 처리하는 기능을 지원합니다. 예를 들어, 다음과 같이 허용할 Audience 목록을 설정할 수 있습니다.
백엔드 측 JWT 검증 로직 변경 예시 (Node.js + jsonwebtoken 라이브러리 가정):
// 백엔드 JWT 검증 로직
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa'); // Apple 공개키를 가져오기 위한 클라이언트
// Apple의 공개키 엔드포인트
const APPLE_KEYS_URL = 'https://appleid.apple.com/auth/keys';
const client = jwksClient({ jwksUri: APPLE_KEYS_URL });
// 허용할 Audience 목록 (환경 변수나 설정 파일에서 관리하는 것이 좋음)
const ALLOWED_AUDIENCES = [
'com.yourcompany.yourapp.web', // 웹용 Services ID
'com.yourcompany.yourapp', // iOS 앱 Bundle ID (Release)
'com.yourcompany.yourapp.dev' // iOS 앱 Bundle ID (Debug/Flavor)
];
// Apple 공개키를 가져오는 함수
function getKey(header, callback) {
client.getSigningKey(header.kid, function(err, key) {
if (err) {
console.error('Apple 공개키 가져오기 실패:', err);
return callback(err);
}
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
// 토큰 검증 함수
async function verifyAppleToken(identityToken) {
return new Promise((resolve, reject) => {
// 1. Apple 공개키를 이용해 서명 검증
// 2. 만료 시간(exp), 발급자(iss) 등 기본 클레임 검증
// 3. Audience(aud) 클레임 검증 (핵심!)
jwt.verify(identityToken, getKey, {
// audience: 'com.yourcompany.yourapp.web', // <- 기존: 웹용 Services ID만 허용
audience: ALLOWED_AUDIENCES, // <- 수정: 허용 목록 전체를 확인
issuer: 'https://appleid.apple.com', // 발급자 확인
algorithms: ['RS256'] // 서명 알고리즘 확인
}, (err, decoded) => {
if (err) {
console.error('Apple 토큰 검증 실패:', err.message);
// 예: 'invalid audience', 'jwt expired', 'invalid signature' 등
return reject(new Error('유효하지 않은 Apple 토큰입니다.'));
}
// 검증 성공 시 디코딩된 페이로드 반환 (사용자 정보 포함)
resolve(decoded);
});
});
}
// API 핸들러 예시
async function handleAppleSignInRequest(req, res) {
const { token } = req.body; // 클라이언트로부터 identityToken 받음
if (!token) {
return res.status(400).send('토큰이 필요합니다.');
}
try {
const appleUserInfo = await verifyAppleToken(token);
// 검증 성공! 사용자 정보(appleUserInfo)를 이용해 로그인/회원가입 처리
// ... (DB 조회, 자체 토큰 발급 등)
res.status(200).json({ message: 'Apple 로그인 성공', user: appleUserInfo.sub });
} catch (error) {
res.status(401).send(error.message);
}
}
주의: 앱(클라이언트)에서 identityToken
의 aud
값을 강제로 조작하려는 시도는 불가능하며 보안적으로도 매우 위험합니다. identityToken
은 Apple에 의해 암호화 서명되었으므로, 내용을 변경하면 서명 검증에 실패하여 백엔드에서 거부됩니다. 반드시 백엔드 수정을 통해 해결해야 합니다.
왜 기존에는 문제가 없었을까?
만약 이 문제가 최근에 발생했다면, 과거에는 백엔드에서 aud
클레임을 아예 검증하지 않았거나, 검증 로직이 지금보다 느슨했을 가능성이 높습니다. 보안 강화나 코드 변경 과정에서 aud
검증이 추가/강화되면서 문제가 발생했을 수 있습니다. 또는 Flavor 도입 등으로 Bundle ID가 변경되었는데 백엔드의 허용 목록이 업데이트되지 않았을 수도 있습니다.
마무리
Flutter에서 Sign in with apple 구현 시 플랫폼별 동작 방식의 차이를 이해하는 것은 매우 중요합니다. 특히 iOS 네이티브 흐름과 웹 기반 흐름에서 identityToken
의 aud
클레임 값이 다르게 설정된다는 점을 인지하고, 백엔드에서 두 경우 모두를 유효하게 처리하도록 검증 로직을 구현해야 예상치 못한 문제를 예방할 수 있습니다. 만약 iOS에서만 Sign in with apple이 실패하는 문제를 겪고 있다면, 백엔드 로직에서 identityToken
의 aud
클레임 검증 로직을 꼭 확인해 보시길 바랍니다!
'Programming > Flutter' 카테고리의 다른 글
[Flutter/iOS] 자체서명 인증서(Self-signed certificate)를 우회한 HLS 스트리밍 구현 / GCDWebServer를 사용한 로컬 프록시 서버 구현 (1) | 2025.04.21 |
---|---|
[Flutter] Isolate를 활용한 HTTP 클라이언트 리팩토링과 iOS에서 Isolate 초기화 문제 (0) | 2025.04.11 |
[Flutter] 때때로 생성자에서 비동기 요청을 하게되면, 비동기 요청이 실행되기 전에 dispose()가 호출될 수도 있다. (0) | 2025.03.18 |
[Dart] Completer를 사용한 비동기 제어 (0) | 2025.01.15 |
[Dart] 메시지를 통해 동작하는 Isolate를 추상화하기 (0) | 2024.07.31 |