AASA Dynamic/Static Serving and Path Control#
한마디로#
이 스킬은 "특정 웹 주소를 눌렀을 때 앱이 열릴지, 그냥 웹페이지로 열릴지"를 결정하는 규칙표(AASA)를 다룹니다. 호텔 안내데스크가 손님을 객실로 안내할지 로비에서 처리할지 정하는 것처럼, 어떤 링크는 앱으로 보내고 어떤 링크(예: 로그인 처리용 주소)는 일부러 앱으로 보내지 않도록 길을 잡아줍니다. 이 길 안내가 잘못되면 소셜 로그인이 중간에 끊기거나 링크가 앱에서 안 열리는 문제가 생깁니다.
무엇을·언제#
-
무엇을 해주나요
- 특정 주소(예: 소셜 로그인 콜백)는 앱이 가로채지 않도록 제외 규칙을 만들어 줍니다.
- 이 규칙표(AASA)를 서버가 자동으로 만들어 내보내거나, 고정 파일로 대비책을 둘 수 있게 구성합니다.
- 여러 도메인·앱 환경(개발/스테이징/운영)에서 규칙이 어긋나지 않도록 맞춰 줍니다.
- Firebase Hosting이 자동으로 만든 잘못된 규칙표를 우리 것으로 덮어쓰도록 도와줍니다.
-
언제 쓰이나요
- 소셜 로그인(네이버, 카카오 등)을 눌렀더니 로그인이 끝나기도 전에 앱이 먼저 열려서 로그인이 실패할 때
- iOS에서 링크를 눌러도 앱이 안 열리거나, 반대로 앱으로 들어가면 안 되는 주소까지 앱이 열릴 때
- 새 소셜 로그인 수단을 추가하거나, 도메인/앱을 새로 설정·디버깅할 때
핵심 용어#
| 용어 | 쉬운 설명 |
|---|---|
| AASA (apple-app-site-association) | 애플이 정한 "이 주소는 앱으로 보낼지 말지" 규칙을 담은 파일 |
| Universal Link | 웹 주소를 눌렀을 때 자동으로 앱이 열리게 해주는 애플의 링크 방식 |
| Path (경로) | 웹 주소에서 도메인 뒤에 붙는 부분(예: /auth/...), 어떤 페이지인지 가리키는 길 |
| NOT 규칙 | "이 주소들은 앱으로 보내지 마"라고 콕 집어 제외하는 규칙 |
| OAuth 콜백 | 소셜 로그인 후 서버가 인증을 마무리하기 위해 잠깐 거치는 중간 주소 |
| Dynamic AASA (동적) | 서버가 그때그때 규칙표를 만들어 내보내는 방식 |
| Static AASA (정적) | 미리 만들어 둔 고정 파일로 규칙표를 내보내는 방식 (서버가 죽어도 동작) |
| Firebase Hosting | 웹사이트를 빠르게 보여주는 구글의 호스팅/CDN 서비스 |
| CDN 캐시 | 빠른 응답을 위해 잠시 저장해 둔 사본, 바뀐 내용이 늦게 반영될 수 있음 |
| Serverpod | 이 프로젝트가 쓰는 서버 프레임워크(백엔드 도구) |
| Bundle ID / Team ID | 앱과 개발팀을 구분하는 애플의 고유 식별 번호 |
Covers dynamically generating AASA (Apple App Site Association) on the server and excluding specific paths from Universal Links.
Triggers#
- Excluding specific paths from AASA (OAuth callbacks, etc.)
- Dynamic AASA serving setup
- Multi-domain/app AASA management
- Universal Links debugging
Path Exclusion Pattern#
Problem Scenario#
When OAuth login (Naver, Kakao, etc.) server callback URLs (/auth/naver/callback) match the AASA
"paths": ["*"] setting as iOS Universal Links, the app opens before the server completes token exchange.
1. User → Naver login page
2. Naver → Server /auth/naver/callback (token exchange needed)
3. ❌ iOS redirects /auth/naver/callback to app
4. Server token exchange incomplete → Login failure
Solution: NOT Prefix#
{
" applinks " : {
" details " : [
{
" appID " : " TEAM_ID.BUNDLE_ID " ,
" paths " : [
" NOT /auth/* " ,
" NOT /internal/* " ,
" NOT /api/* " ,
" * "
]
}
]
}
}
Paths to Exclude#
| Path | Reason |
|---|---|
/auth/* | OAuth callbacks (Naver, Kakao, Google, etc.) |
/internal/* |
Server-to-server communication (Lambda callbacks, webhooks, etc.) |
/api/* | REST API endpoints |
/webhooks/* | External service webhooks (payment, notifications, etc.) |
/health | Health check endpoint |
Evaluation Order#
Apple AASA paths evaluation rules:
- NOT rules are evaluated first → If matched, excluded from Universal Links
- Include rules evaluated →
*,/books/*, etc. - First matching rule applies
Dynamic AASA Serving (Serverpod)#
WellKnownRoute Pattern#
class WellKnownRoute extends Route {
Map<String, dynamic> _appleAppSiteAssociation() {
return {
'applinks': {
'apps': <String>[],
'details': [
{
'appID': '\${EnvConfig.appleTeamId}.\${EnvConfig.packageName}',
'paths': [
'NOT /auth/*',
'NOT /internal/*',
'NOT /api/*',
'*',
],
},
],
},
'webcredentials': {
'apps': [
'\${EnvConfig.appleTeamId}.\${EnvConfig.packageName}',
],
},
};
}
@override
FutureOr<Result> handleCall(Session session, Request request) {
if (request.url.path == '/.well-known/apple-app-site-association') {
final body = jsonEncode(_appleAppSiteAssociation());
return Response.ok(
body: Body.fromString(body, mimeType: .json),
headers: Headers.build((headers) {
headers[HttpHeaders.cacheControlHeader] = ['max-age=31536000'];
}),
);
}
return Response.notFound();
}
}
Dynamic vs Static AASA#
| Method | Advantages | Disadvantages |
|---|---|---|
| Dynamic (server code) | Auto-applies per-environment Bundle ID, code-managed | Server dependency |
| Static (file) | Served even when server is down | Manual per-environment management |
Recommended: Use dynamic AASA as default, maintain static file as fallback.
Static AASA Fallback#
web/.well-known/apple-app-site-association
Include Bundle IDs for all environments (dev/stg/prod):
{
" applinks " : {
" details " : [
{
" appIDs " : [
" TEAM_ID.com.example.app.dev " ,
" TEAM_ID.com.example.app.stg " ,
" TEAM_ID.com.example.app "
],
" paths " : [
" NOT /auth/* " ,
" NOT /internal/* " ,
" NOT /api/* " ,
" * "
]
}
]
}
}
Difference from Android assetlinks.json#
| Item | iOS AASA | Android assetlinks.json |
|---|---|---|
| Path control location | Server (AASA paths) | App (AndroidManifest intent-filter) |
| Exclusion method | NOT /path/* |
Only specify included paths in intent-filter |
| Config change | Server deploy required | App update required |
→ No assetlinks.json modification needed for Android (handles domain-level verification only)
Verification#
# Check dynamic AASA
curl -s https://yourdomain.com/.well-known/apple-app-site-association | jq .
# Verify NOT rule behavior: /auth/ path should NOT open app
# Access https://yourdomain.com/auth/naver/callback in iOS Safari
# → Should be handled on web, not open the app
# Check Apple CDN cache (up to 24-hour delay)
curl -s https://app-site-association.cdn-apple.com/a/v1/yourdomain.com | jq .
Firebase Hosting AASA Interception#
Problem: Firebase Hosting Auto-Generates AASA#
When using Firebase Hosting as a reverse proxy/CDN, .well-known/apple-app-site-association
requests do not reach the backend server.
iOS device → DNS → Firebase Hosting CDN → Backend server (Serverpod, etc.)
↑
AASA intercepted here
Firebase auto-generates AASA when an iOS app is registered in the project. This auto-generated AASA only excludes Firebase internal paths (/__/auth/*,
/_/*) with NOT prefix, and does not include custom NOT rules (e.g., NOT /auth/*).
Result#
- Backend server's
WellKnownRouteis correctly configured but Firebase responds first - OAuth callbacks (
/auth/naver/callback, etc.) match as Universal Links and open the app - Server token exchange incomplete → Login failure
Solution: Deploy Custom Static AASA File#
Deploying a static AASA file in Firebase Hosting's public directory serves with higher priority
than Firebase's auto-generated AASA.
Priority:
1st: public directory static files ← Custom AASA
2nd: Firebase auto-generated AASA
3rd: rewrites rules (backend proxy)
Step 1: Create Static AASA File
web/.well-known/apple-app-site-association
{
" applinks " : {
" apps " : [],
" details " : [
{
" appID " : " TEAM_ID.BUNDLE_ID " ,
" paths " : [
" NOT /auth/* " ,
" NOT /internal/* " ,
" NOT /api/* " ,
" NOT /__/auth/action/ " ,
" NOT /__/auth/handler/ " ,
" NOT /_/* " ,
" /* "
]
}
]
}
}
Important: Firebase default NOT rules (
/__/auth/*,/_/*) must also be included.
Step 2: firebase.json Configuration
{
" hosting " : {
" public " : " build/web " ,
" ignore " : [ " firebase.json " , " **/node_modules/** " ],
" headers " : [
{
" source " : " /.well-known/apple-app-site-association " ,
" headers " : [
{ " key " : " Content-Type " , " value " : " application/json " }
]
}
]
}
}
Cautions:
-
Must remove
"**/.*"fromignorearray (so.well-knowndirectory is not ignored) - Explicitly specify Content-Type header (Firebase may not auto-detect)
Step 3: Copy .well-known in Build Script
.well-known directory is not included in build output after flutter build web, so manual copy is needed.
# deploy_web.sh or CI script
flutter build web --release
# Copy .well-known directory
if [ -d " web/.well-known " ]; then
cp -r " web/.well-known " " build/web/.well-known "
fi
# Firebase deploy
firebase deploy --only hosting
Step 4: Post-Deploy Verification
# Verify Firebase Hosting serves custom AASA
curl -s https://yourdomain.com/.well-known/apple-app-site-association | jq .
# Verify NOT /auth/* rule is included
curl -s https://yourdomain.com/.well-known/apple-app-site-association | jq ' .applinks.details[0].paths '
Dynamic and Static AASA Synchronization#
In Firebase Hosting environments, both AASA locations must be synchronized:
| File | Serving Domain | Purpose |
|---|---|---|
web/.well-known/apple-app-site-association |
Firebase Hosting domain (e.g., www.example.com) |
Replaces Firebase-intercepted AASA |
well_known_route.dart (dynamic) |
Backend direct access domain (e.g., api.example.com) |
Served directly by backend server |
The paths settings in both files must be identical.
Troubleshooting#
When Universal Links Are Not Working#
- Verify AASA Content-Type is
application/json - Verify direct HTTPS serving without redirects
- Wait for Apple CDN cache (24 hours)
- iOS Settings → Developer → Associated Domains diagnostics
When OAuth Callbacks Open the App#
- Add
NOT /auth/*to AASA paths - When using Firebase Hosting: Deploy custom static AASA file (server deploy alone is insufficient)
- Redeploy server + Firebase Hosting
- Wait for Apple CDN cache refresh (or reinstall app)
- Access callback URL directly in Safari to verify web handling
When AASA Doesn't Change After Firebase Hosting Deploy#
- Verify
"**/.*"removed fromfirebase.jsonignore - Verify
web/.well-known→build/web/.well-knowncopy in build script - Wait for Firebase CDN cache (15 minutes)
- Verify with
curl -H "Cache-Control: no-cache"to bypass cache
Checklist#
- NOT prefix applied to server-only paths
- Both dynamic and static AASA fallback have identical paths settings
- New OAuth providers covered by
/auth/*pattern - Firebase Hosting: Custom static AASA file deployed
-
Firebase Hosting:
**/.*ignore removed fromfirebase.json -
Firebase Hosting:
.well-knowncopy added to build script - Post-deploy AASA response verified with
curl - Tested on iOS device after Apple CDN cache refresh