LogoSkills

aasa-management

AASA 동적/정적 서빙 및 경로 제어. apple-app-site-association 서빙 설정, 경로 allowlist 구성, iOS Universal Link 매칭 디버깅 시 사용합니다.

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. UserNaver login page
2. NaverServer /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#

PathReason
/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.)
/healthHealth check endpoint

Evaluation Order#

Apple AASA paths evaluation rules:

  1. NOT rules are evaluated first → If matched, excluded from Universal Links
  2. Include rules evaluated*, /books/*, etc.
  3. 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#

MethodAdvantagesDisadvantages
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#

ItemiOS AASAAndroid assetlinks.json
Path control location Server (AASA paths) App (AndroidManifest intent-filter)
Exclusion method NOT /path/* Only specify included paths in intent-filter
Config changeServer deploy requiredApp 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 → DNSFirebase Hosting CDNBackend 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 WellKnownRoute is 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 "**/.*" from ignore array (so .well-known directory 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:

FileServing DomainPurpose
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#

  1. Verify AASA Content-Type is application/json
  2. Verify direct HTTPS serving without redirects
  3. Wait for Apple CDN cache (24 hours)
  4. iOS Settings → Developer → Associated Domains diagnostics

When OAuth Callbacks Open the App#

  1. Add NOT /auth/* to AASA paths
  2. When using Firebase Hosting: Deploy custom static AASA file (server deploy alone is insufficient)
  3. Redeploy server + Firebase Hosting
  4. Wait for Apple CDN cache refresh (or reinstall app)
  5. Access callback URL directly in Safari to verify web handling

When AASA Doesn't Change After Firebase Hosting Deploy#

  1. Verify "**/.*" removed from firebase.json ignore
  2. Verify web/.well-knownbuild/web/.well-known copy in build script
  3. Wait for Firebase CDN cache (15 minutes)
  4. 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 from firebase.json
  • Firebase Hosting: .well-known copy added to build script
  • Post-deploy AASA response verified with curl
  • Tested on iOS device after Apple CDN cache refresh