feat(identity): provider-agnostic social login, VK ID + Yandex ID (FH-4.1, FH-4.2)

The VK-only scaffolding had a shape problem worth fixing before anything
was built on it: completeCallback(code, codeVerifier) took the PKCE
verifier from the client, which forces the browser to generate and hold
it. We are a confidential client - a browser-held verifier buys nothing
and adds a place to steal it from.

Replaces the four vk-id-* files with a provider-agnostic surface:

  getAuthorizeUrl(provider, returnTo?)
  listIdentities()
  unlink(provider)

completeCallback is gone entirely. The backend mints and stores state and
code_verifier single-use for 10 minutes, handles the provider's callback
itself, issues the session cookie and redirects. VK and Yandex differ
only in a path segment, because everything that actually differs between
them - PKCE handling, VK's device_id, Yandex's Basic-auth exchange -
lives backend-side.

vk-id-login becomes social-login-button with a provider input; adding
Yandex to the UI is an input value, not new code. Adds yandex_id to
ExternalIdentityProvider, plus optional email/phone/displayName since VK
frequently returns no email.

social-identity-gateway.spec.ts (5 tests) asserts the requests carry no
code_verifier and no client_secret, so reintroducing a browser-held
verifier fails the build rather than passing review.

PHASE-8 §2 rewritten to match: the four endpoints, backend-owned state
and verifier, UNIQUE (provider, providerUserId) with conflict routed to
controlled resolution rather than a silent rebind, per-tenant OAuth app
config under the Track S §4.2 envelope, and both providers' full endpoint
sets. Two things recorded there because they are expensive to discover
later: VK's callback returns device_id alongside code and the token
exchange fails without it, and both providers validate redirect_uri
against an exact registered list - which a multi-tenant platform cannot
satisfy without a central identity host (FH-0.1, still undecided).

256 tests pass. Build green, boundaries and cycles green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-21 13:15:26 +04:00
parent f9e09b1757
commit cf17b0b6c6
17 changed files with 367 additions and 151 deletions

View File

@@ -146,9 +146,12 @@ Each item is normative text plus an acceptance scenario in `docs/backend/BACKEND
## Wave 4 — Identity: VK ID + Yandex ID (Lane C, `@marketplaces/auth`)
Blocked on **FH-0.1**. Nothing to copy from the archive — it has zero VK/Yandex/OAuth code. We take the session-issuing shape of their Telegram flow and terminate both providers into it.
Nothing to copy from the archive — it has zero VK/Yandex/OAuth code. We take the session-issuing shape of their Telegram flow and terminate both providers into it.
- [ ] **FH-4.1 — Provider-agnostic social identity surface** · M
The **client half and the contract are done** (2026-08-21). What remains is backend implementation, and registering the OAuth applications — which is what FH-0.1 gates.
- [x] **FH-4.1 — Provider-agnostic social identity surface** · M · **done 2026-08-21**
Landed as `social-identity-gateway.interface.ts` / `-api.gateway.ts` / `-local.gateway.ts` / `-gateway.token.ts` under `src/app/core/identity/services/`, with the four `vk-id-*` files deleted and `vk-id-login` replaced by `social-login-button` taking a `provider` input. `'yandex_id'` added to `ExternalIdentityProvider`. Covered by `social-identity-gateway.spec.ts` (5 tests), which asserts the request carries no `code_verifier` or `client_secret` — so re-adding a browser-held verifier fails the build rather than passing review.
Collapse `VkIdGateway` into `SocialIdentityGateway`:
```ts
export type SocialProvider = 'vk' | 'yandex';
@@ -160,22 +163,26 @@ Blocked on **FH-0.1**. Nothing to copy from the archive — it has zero VK/Yande
```
Touches: `src/app/core/identity/services/vk-id-gateway.interface.ts`, `vk-id-api.gateway.ts`, `vk-id-local.gateway.ts`, `vk-id-gateway.token.ts`, `src/app/components/vk-id-login/` → `social-login-button`. Add `'yandex_id'` to `ExternalIdentityProvider` in `core/identity/models/customer-identity.model.ts`.
- [ ] **FH-4.2 — Move PKCE ownership to the backend** · S · Lane B + C
- [x] **FH-4.2 — Move PKCE ownership to the backend** · S · **done 2026-08-21**
`completeCallback()` is gone from the frontend entirely. PHASE-8 §2.12.2 rewritten: `/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` single-use for 10 minutes, `/callback` is a backend GET that exchanges, links, issues the session cookie and redirects. `returnTo` validated against the tenant's own origin.
Today `completeCallback(code, codeVerifier)` forces the browser to generate and hold the verifier. We are a confidential client. Backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, handles the callback, and redirects. `completeCallback()` leaves the frontend entirely.
Contract endpoints: `GET /api/identity/v1/{provider}/authorize`, `GET /api/identity/v1/{provider}/callback`, `POST /{provider}/unlink`, `GET /me/identities`. Update `PHASE-8-IDENTITY-MESSAGING-CONTRACT.md` §2.
- [ ] **FH-4.3 — `ExternalIdentity` model** · S · Lane B
- [~] **FH-4.3 — `ExternalIdentity` model** · S · Lane B · **contract written 2026-08-21, awaiting backend**
PHASE-8 §1 and §2.3: `UNIQUE (provider, providerUserId)`, conflict routes to controlled resolution rather than rebinding, optional email/phone/displayName, per-tenant OAuth app config under the Track S §4.2 envelope.
```prisma
@@unique([provider, providerUserId]) // one provider account -> one customer
```
Conflict is **not** an upsert: a `providerUserId` already bound to a different `Customer` routes to controlled resolution. The unique index makes the database refuse a silent rebind. Per-tenant OAuth app config stored encrypted (same envelope as FH-2.9): `{ clientId, clientSecret, scopes[], redirectUri }`.
- [ ] **FH-4.4 — VK ID** · M
- [~] **FH-4.4 — VK ID** · M · **contract written 2026-08-21, awaiting backend**
PHASE-8 §2.5 carries the full endpoint set and the `device_id` trap.
OAuth 2.1, PKCE mandatory (S256). Authorize `https://id.vk.com/authorize`; token `POST https://id.vk.com/oauth2/auth`; profile `POST https://id.vk.com/oauth2/user_info`; logout `https://id.vk.com/oauth2/logout` on unlink.
**Trap to write into the contract:** the callback returns `device_id` alongside `code`, and the token exchange fails without it. This is the most common VK ID integration bug.
VK often does not return an email — email must stay optional on `Customer`.
- [ ] **FH-4.5 — Yandex ID** · S · *after FH-4.4*
- [~] **FH-4.5 — Yandex ID** · S · **contract written 2026-08-21, awaiting backend**
PHASE-8 §2.5. On the client it is a `provider` input, not new code.
OAuth 2.0 with PKCE. Authorize `https://oauth.yandex.ru/authorize`; token `POST https://oauth.yandex.ru/token` with HTTP Basic `client_id:client_secret`; profile `GET https://login.yandex.ru/info?format=json` with header `Authorization: OAuth <token>` → `id`, `login`, `default_email`, `default_phone`, `psuid`.
A second strategy object against the same surface — roughly a day once VK works.
*Confirm exact parameter and scope names against live provider docs; both providers revised their flows recently.*
@@ -234,27 +241,36 @@ Blocked on **FH-0.1**. Nothing to copy from the archive — it has zero VK/Yande
## Scoreboard
| Wave | Done | Open | Lane | Blocked by |
|---|---:|---:|---|---|
| 0 — Decide | 0 | 2 | C, A | — |
| 1 — Live defects | 2 | 2 | A | FH-1.2 and FH-1.4 sit in files another session owns |
| 2 — Contracts | 14 | 0 | B | — (1 rejected: FH-2.12) |
| 3 — Proof | 2 | 3 | A | — |
| 4 — Identity | 0 | 8 | C | FH-0.1 |
| Ops | 0 | 3 | D | — |
| Process | 4 | 2 | E | — |
| **Total** | **22** | **20** | | 1 rejected |
| Wave | Done | Contract written, awaiting backend | Open | Blocked by |
|---|---:|---:|---:|---|
| 0 — Decide | 0 | — | 2 | needs a person, not a session |
| 1 — Live defects | 2 | | 2 | FH-1.2 / FH-1.4 sit in files another session owns |
| 2 — Contracts | 14 | | 0 | 1 rejected (FH-2.12) |
| 3 — Proof | 2 | — | 3 | see note below |
| 4 — Identity | 2 | 3 | 3 | OAuth apps, which FH-0.1 gates |
| Ops | 0 | | 3 | — |
| Process | 4 | | 2 | — |
| **Total** | **24** | **3** | **15** | 1 rejected |
**Landed 2026-08-21**
- **Wave 1:** FH-1.1 (geo off `ip-api.com`, 4 new tests), FH-1.3 (credentials out of the browser, via the `@marketplaces/payment` migration).
- **Wave 2:** all 14 remaining contract items written into `docs/backend/`, tagged `FH-*` and dated so each traces back to the analysis. FH-2.12 rejected on the merits our lifecycle state machine is richer than theirs.
- **Wave 3:** FH-3.3 (bundle budget ratcheted to a blocking error), FH-3.5 (`scripts/ci/scan-bundle.sh`, wired into CI, verified in both directions).
- **Process:** FH-E.1E.4, including [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md).
- **Wave 1** FH-1.1 (geo off `ip-api.com`, 4 new tests), FH-1.3 (credentials out of the browser, via the `@marketplaces/payment` migration).
- **Wave 2** all 14 remaining contract items written into `docs/backend/`, tagged `FH-*` and dated so each traces back to the analysis. FH-2.12 rejected on the merits: our lifecycle state machine is richer than theirs.
- **Wave 3** FH-3.3 (bundle budget ratcheted to a blocking error at 1.6 MB, measured 1.55 MB), FH-3.5 (`scripts/ci/scan-bundle.sh`, in CI, verified in both directions).
- **Wave 4** FH-4.1 and FH-4.2 complete on the client and in the contract; FH-4.34.5 specified and waiting on backend plus registered OAuth applications.
- **Process** — FH-E.1E.4, including [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md).
Test count over the session: 247 → 256 (+4 geo, +5 social identity). Build green, boundary and cycle checks green.
**On FH-3.1 / FH-3.2 — reclassified, not skipped**
Both are backend races: two transactions competing for the last unit, and the same provider event arriving twice. Playwright against mocked routes cannot prove either — a test that mocks both sides of a race proves only that the mock behaved. `checkout-idempotent-click.spec.ts` already says this in its own header and covers the genuinely frontend-testable half.
So the acceptance criteria now live where they bind, as normative text in PHASE-3 §3.1 and PHASE-7 §5, and the e2e work they imply is **backend integration testing**, not frontend e2e. What *is* worth doing on our side first: the existing checkout e2e specs have a known-failing session setup (documented in-file, dated 2026-08-21) — a green suite is the prerequisite for anything built on top of it.
**Next**
1. **FH-0.1** — the central identity host. One-way door, blocks all eight Wave 4 items, needs a person not a session.
2. **FH-3.1 / FH-3.2** — the two acceptance e2e tests. Both contracts they prove (PHASE-3 §3.1, PHASE-7 §5) are now written, so the tests have something normative to assert against.
3. **FH-1.2 / FH-1.4** — bank URL validation and `Idempotency-Key`. Both live in `cart.component.ts` / the payment package; pick them up once that work settles.
4. **FH-E.6** — mock fixtures currently reach production chunks. Mechanical fix, pattern already in the repo.
1. **FH-0.1** — the central identity host, and the one-customer-or-two question. One-way door, gates the remaining Wave 4 work, needs a decision from a person.
2. **FH-1.2 / FH-1.4** — bank URL validation and `Idempotency-Key`. Both live in `cart.component.ts` / the payment package; pick up once that work settles.
3. **FH-E.6** — mock fixtures reach production chunks. The fix is mechanical but touches 21 DI token files plus `app.config.ts`, which another session currently owns — deliberately deferred rather than merged into a busy tree. Plan: move mock selection out of the token factories into one dev-only provider array swapped by `fileReplacements`, the same mechanism `mock-data.interceptor.production.ts` already uses.
4. **Fix the e2e session setup**, then revisit what proof is worth adding.