# Ловушка 2. Вход по eID. Читать целиком до первой строчки кода входа

Это место, где ломается каждое приложение. Не «часто» — каждое. Причина в том, что
поведение кошелька отличается от того, что написано в протоколе, и это расхождение
**в официальной документации не описано**.

Ниже — измеренное поведение, а не пересказ спецификации.

---

## Как вход устроен на самом деле

```
                 ┌──────────────┐
  1. открыл      │  Браузер     │
     приложение  │  человека    │
                 └──────┬───────┘
                        │ 2. GET /api/auth/offer
                        ▼
                 ┌──────────────┐   выдаёт session (случайный id)
                 │  Ваш сервер  │   и ссылку w3ds://auth?redirect=…&session=…
                 └──────┬───────┘
                        │ 3. QR-код (с компьютера) или ссылка (с телефона)
                        ▼
                 ┌──────────────┐
                 │  Кошелёк на  │ 4. человек подтверждает
                 │   телефоне   │ 5. кошелёк ПОДПИСЫВАЕТ строку session
                 └──────┬───────┘
                        │ 6. отправляет {w3id|ename, session, signature}
                        ▼            на адрес из redirect=
                 ┌──────────────┐
                 │  Ваш сервер  │ 7. проверяет подпись через реестр и eVault
                 │              │ 8. создаёт сессию, отдаёт токен
                 └──────────────┘
```

Пункт 5 важен буквально: **подписывается голая строка `session`**, без префиксов,
без JSON, без обёртки. Если вы подписываете или проверяете что-то другое — подпись
не сойдётся, и вы будете неделю искать ошибку в криптографии, которой там нет.

---

## Четыре измеренных факта, из-за которых всё ломается

### Факт 1. Кошелёк возвращает человека на свой зашитый путь

Когда кошелёк и браузер на **одном устройстве** (человек открыл приложение с телефона),
кошелёк после подтверждения возвращает человека в браузер — и открывает **`/deeplink-login`**
вашего приложения. Не тот адрес, который вы передали в `redirect=`. Свой зашитый путь.

**Значит у вас обязана быть страница по адресу `/deeplink-login`.** Нет её — человек
попадает на «страница не найдена» и вход мёртв. Это причина №1 всех «на айфоне не работает».

Параметр `redirect=` при этом **всё равно нужен и всё равно должен указывать
на `/api/auth/login`** — это адрес, куда кошелёк отправляет подписанные данные
с сервера на сервер. Не путайте два разных возврата: данные идут на `redirect=`,
человек идёт на `/deeplink-login`.

### Факт 2. Кошелёк приходит то методом GET, то методом POST

Разные сборки кошелька ведут себя по-разному. Обработчик `/api/auth/login` должен
принимать оба метода и читать параметры и из тела, и из строки запроса.

### Факт 3. Поле зовётся то `w3id`, то `ename`

Принимайте оба:

```ts
const ename = req.body.w3id ?? req.body.ename ?? req.query.w3id ?? req.query.ename;
```

### Факт 4. На телефоне постоянное соединение с сервером обрывается

На компьютере удобно держать SSE-соединение и ждать, когда сервер скажет «вошёл».
На телефоне это не работает: пока человек в кошельке, браузер усыпляет вкладку
и соединение рвётся. Вернувшись, человек видит вечное «ждём».

Поэтому на телефоне нужен **опрос состояния**, а не ожидание соединения.

---

## Что нужно построить: пять частей

Ни одну нельзя выкинуть. Каждая закрывает свой сценарий.

### Часть 1. Выдача приглашения — `GET /api/auth/offer`

```ts
const sessionId = randomUUID();
const baseUrl = process.env.PUBLIC_BASE_URL;          // публичный адрес, см. ловушку 1
const redirectUrl = new URL("/api/auth/login", baseUrl).toString();
const offer = `w3ds://auth?redirect=${redirectUrl}&session=${sessionId}&platform=<имя>`;
res.json({ offer, sessionId });
```

Сессию запомните на сервере (в памяти достаточно) — по ней потом узнают результат.

### Часть 2. Приём подписи — `/api/auth/login`, методы GET и POST

1. Достать `ename` (или `w3id`), `session`, `signature`.
2. **Если результат по этой сессии уже есть в кеше — вернуть его и выйти.** Это не
   оптимизация: страница возврата может обратиться сюда повторно, и без кеша второй
   вызов провалится, а человек увидит ошибку после успешного входа.
3. Проверить подпись (см. ниже).
4. Создать пользователя, если его ещё нет; выдать токен.
5. Положить результат в кеш по `session` **и** оповестить ждущих (SSE).

Проверка подписи, по шагам:

```
реестр: GET /resolve?w3id=@<ename>        → адрес eVault человека
eVault: GET /whois  (заголовок X-ENAME)   → keyBindingCertificates[]
реестр: GET /.well-known/jwks.json        → ключи для проверки этих сертификатов
        проверить JWT, достать публичный ключ, проверить подпись над строкой session
```

Три вещи, на которых спотыкаются:

- **Пустой `keyBindingCertificates` — это отказ, а не «проверим позже».** Пустой список
  означает, что у личности нет привязанного ключа. Впускать нельзя.
- Публичный ключ приходит в разных видах: `0x`-hex (65 байт, начинается с `0x04`) или
  multibase с префиксом `z`. Поддержите оба.
- Подпись приходит либо «сырая» 64 байта (r‖s), либо в формате DER. Поддержите оба.

Готовая, проверенная реализация: `api/src/lib/signature-validator.ts` в шаблоне
`w3ds_casco` — скопируйте её целиком, не переписывайте.

### Часть 3. Состояние сессии — `GET /api/auth/status?session=<id>`

Три возможных ответа: `pending` (ждём), `success` (вот токен и пользователь),
`expired` (сессии нет). Это то, что опрашивает телефон.

### Часть 4. Экран входа

На компьютере: QR-код с содержимым `offer`, плюс подписка SSE.

На телефоне: **кнопка-ссылка** прямо на `offer` (не QR — сканировать нечем, кошелёк
на этом же устройстве).

И то, о чём забывают все: **перед тем как отдать управление кошельку, сохраните
`sessionId` в `localStorage`.** Когда человек вернётся, страница возврата может не получить
параметров — и тогда единственный способ понять, какую сессию проверять, это то,
что вы сохранили заранее.

Плюс проверка при возвращении фокуса — на случай, если кошелёк вернул человека
в ту же вкладку, а не на `/deeplink-login`:

```js
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', onVisible);
// onVisible: если есть sessionId — спросить /api/auth/status один раз
```

### Часть 5. Страница возврата — `/deeplink-login`

Та самая, на которую кошелёк приводит человека. Её логика:

1. **Если в адресе есть `ename`/`w3id`, `session` и `signature`** — передать их
   на `/api/auth/login` самостоятельно и войти.
2. **Если параметров нет** — взять `sessionId` из `localStorage` и **опрашивать
   `/api/auth/status`** раз в 1,5 секунды примерно минуту.
3. Если за минуту ничего — показать понятную ошибку и ссылку «вернуться ко входу».

Параметры ищите и в `?query`, и после `#` — встречались оба варианта:

```js
let search = window.location.search;
if (!search && window.location.hash.includes('?')) {
  search = window.location.hash.slice(window.location.hash.indexOf('?'));
}
```

Готовая реализация: `app/src/views/DeeplinkLogin.jsx` в шаблоне `w3ds_casco`.

---

## Частые симптомы и что они значат

| Что видит человек | Почти наверняка это |
|---|---|
| На телефоне «страница не найдена» после кошелька | нет страницы `/deeplink-login` |
| Вечное «ждём подтверждения» на телефоне | нет опроса состояния; SSE оборвался, пока человек был в кошельке |
| Вечное «ждём» и на компьютере тоже | кошелёк не достучался до сервера: адрес `localhost` или туннель упал |
| «Подпись неверна» | проверяете не голую строку `session`, либо не поддержали формат подписи |
| «Нет сертификатов ключа» | у личности не привязан ключ; это корректный отказ, а не ваша ошибка |
| Вошёл, но следом ошибка | страница возврата вызвала `/api/auth/login` второй раз, а кеша результата нет |
| Вчера работало, сегодня нет | туннель перезапустился и выдал новый адрес |

---

## Проверка, которая закрывает эти ворота

Оба сценария, руками живого человека, а не вашим описанием:

**Сценарий А — с компьютера.** Открыл приложение на компьютере → увидел QR → отсканировал
телефоном → подтвердил в кошельке → **на компьютере оказался внутри приложения**.

**Сценарий Б — с телефона.** Открыл приложение **в браузере телефона** → нажал кнопку входа
→ открылся кошелёк → подтвердил → **вернулся в браузер и оказался внутри приложения**.

Сценарий Б — тот, который не работает у всех. Если он прошёл с первого раза, проверьте
ещё раз: закройте вкладку, откройте заново, повторите. Он должен работать стабильно,
а не один раз из трёх.

Только после двух зелёных сценариев начинайте строить то, ради чего всё затевалось.
