LogoSkills

aasa-management

AASA dynamic/static serving and path control

AASA Dynamic/Static Serving and Path Control#

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#

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 → 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 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-known → build/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