# 앱 콜백 브리지

앱을 가진 사이트가 **웹 로그인 결과를 앱으로 되돌려받는 경로**를 엽니다.

경로 하나를 선언하면 그 주소가 `POST` 를 받아, 받은 폼 필드를 그대로 쿼리스트링으로 옮겨 담아
앱 딥링크로 302 합니다. 페이지에 붙일 코드는 없습니다 — 플랫폼이 그 경로를 직접 답합니다.

## 왜 필요한가

로그인 제공자 중에는 결과를 **요청 본문**에 담아 POST 로 보내는 쪽이 있습니다(`response_mode=form_post`).
값이 본문에 있으니 정적 페이지의 JS 가 `location.search` 로 읽는 흔한 수법이 **원리적으로 통하지 않습니다.**

가장 자주 만나는 경우가 **안드로이드의 Apple 로그인**입니다. Apple 은 안드로이드용 네이티브 API 를
제공하지 않아 앱이 Chrome Custom Tab 에서 웹 흐름을 타는데, Apple 이 개발자가 지정한 HTTPS 주소로
POST 를 보내고 그 응답이 딥링크로의 302 여야 제어가 앱으로 돌아옵니다.

같은 모양을 요구하는 제공자라면 Apple 이 아니어도 이 기능으로 붙습니다.

## 붙이기

### CLI

```bash
npx @saeroon/cli app-callback attach \
  --site <siteId> \
  --path /auth/apple/callback \
  --android-package app.example
```

응답에 **제공자 콘솔에 등록할 절대 URL** 이 함께 나옵니다.

```
  /auth/apple/callback  android_intent
    등록할 URL : https://example.saeroon.com/auth/apple/callback
    딥링크     : intent://callback?…#Intent;package=app.example;scheme=signinwithapple;end
```

`list` 로 지금 열려 있는 경로를 보고, `remove --path <경로>` 로 닫습니다.

### MCP

```
saeroon_define_app_callback
  { "path": "/auth/apple/callback", "target": "android_intent", "android_package": "app.example" }
```

`action` 은 `attach`(기본) · `list` · `remove` 입니다.

### 선언 항목

| 항목 | 기본값 | 설명 |
|---|---|---|
| `path` | — | 브리지가 응답할 사이트 내 절대 경로. 쿼리·프래그먼트 불가 |
| `target` / `kind` | `android_intent` | `android_intent` = `intent://…#Intent;…;end` · `custom_scheme` = `myapp://…` |
| `androidPackage` | — | `android_intent` 필수. 인텐트를 받을 앱 패키지명 |
| `scheme` | `signinwithapple` | 딥링크 스킴. 플러그인 규약을 그대로 쓰면 기본값이면 됩니다 |
| `callbackPath` | `callback` | 딥링크의 authority |
| `allowGet` | `false` | GET 도 릴레이할지 |

사이트당 최대 5개까지 선언할 수 있습니다.

## 무엇을 하고 무엇을 안 하나

**하는 일은 옮겨 담아 튕기는 것뿐입니다.**

```
POST https://example.saeroon.com/auth/apple/callback
Content-Type: application/x-www-form-urlencoded

code=…&id_token=…&state=…&user=…
```

```
302 Found
Location: intent://callback?code=…&id_token=…&state=…&user=…#Intent;package=app.example;scheme=signinwithapple;end
Cache-Control: no-store
```

- **받은 필드를 전부 옮깁니다.** 골라 담지 않습니다 — 제공자가 필드를 늘려도 그대로 통과합니다.
- **`error` 도 넘어갑니다.** 사용자가 취소하면 `error=user_cancelled_authorize` 가 오는데, 이걸 떨어뜨리면
  앱은 에러를 받는 게 아니라 **아무것도 못 받고 멈춥니다.**
- **응답 본문은 없습니다.** 앱은 HTTP 응답을 읽지 않고 딥링크만 받습니다.

**안 하는 일**: 토큰 검증, 코드 교환, 회원 조회, 쿠키 설정. 검증은 앱이 백엔드에 보내는 자체 로그인
호출에서 이미 일어납니다. 두 곳에서 다른 규칙이 돌면 그게 인증 버그의 산실입니다.

## 안전 규칙

**목적지는 선언에서만 옵니다.** 요청 파라미터는 쿼리스트링으로 옮겨질 뿐, 어디로 튕길지를 한 글자도
바꾸지 못합니다. 폼에 `package=com.other.app` 을 실어 보내도 그 값은 그냥 쿼리 항목 하나가 됩니다.

**웹으로 되튀는 스킴은 저장되지 않습니다.** `scheme` 이 `http`·`https`·`javascript`·`data`·`intent` 등이면
선언 자체를 거부합니다. `intent://x#Intent;scheme=https;…;end` 는 안드로이드에서 **브라우저를 여는**
인텐트라, 그 자리를 열어 두면 오픈 리다이렉트가 됩니다.

**GET 은 기본으로 꺼져 있습니다.** POST 는 자격증명이 본문에 있어 요청 URL 이 깨끗하지만, GET 은
`code`·`id_token` 이 **요청 URL 에 실려** 액세스 로그에 남습니다. `form_post` 제공자는 항상 POST 이므로
켤 일이 거의 없습니다. 정말 필요한 제공자에만 `--allow-get` 을 씁니다.

**선언하지 않은 경로는 그대로입니다.** 브리지를 안 붙인 주소의 POST 는 종전처럼 `405` 이고, GET 정적
서빙은 아무것도 바뀌지 않습니다.

## 확인

```bash
curl -i -X POST https://example.saeroon.com/auth/apple/callback \
  -d 'code=CODE123&id_token=TOK456&state=ST789'
```

`302` 와 `Location` 헤더가 나오면 됩니다. 안드로이드 기기 없이 여기까지 확인할 수 있습니다.

## Apple 로그인으로 붙일 때

1. Apple Developer 에서 **Services ID** 를 만들고 **도메인을 등록·검증**합니다.
   검증 파일(`apple-developer-domain-association.txt`)을 배포 폴더의 `.well-known/` 에 넣어 함께 배포하면
   그 주소로 서빙됩니다.
2. **Return URL** 에 위에서 받은 `등록할 URL` 을 **그대로** 붙여 넣습니다. Apple 은 바이트 단위로 대조하니
   끝의 슬래시 하나도 다르면 안 됩니다.
3. 앱의 리다이렉트 URI 상수를 같은 값으로 맞춥니다.
4. 앱은 받은 `id_token` 을 백엔드 소셜 로그인 엔드포인트로 보내 세션을 받습니다 —
   [앱 백엔드 붙이기](/developers/app-backend) 의 소셜 로그인 절을 그대로 씁니다.

> Apple 의 사용자 식별자(`sub`)는 개발자 팀 단위로 발급됩니다. 그래서 iOS 의 네이티브 흐름과
> 안드로이드의 이 웹 흐름이 **같은 사람에게 같은 값**을 주고, 두 기기에서 같은 계정으로 들어옵니다.
