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:
@@ -21,8 +21,11 @@ interface Customer {
|
||||
|
||||
interface ExternalIdentity {
|
||||
customerId: string;
|
||||
provider: 'vk_id' | 'telegram' | 'max';
|
||||
provider: 'vk_id' | 'yandex_id' | 'telegram' | 'max'; // yandex_id added 2026-08-21, FH-4.5
|
||||
providerUserId: string;
|
||||
email?: string; // VK frequently returns none - never require it
|
||||
phone?: string;
|
||||
displayName?: string;
|
||||
verifiedAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
lastUsedAt: string;
|
||||
@@ -55,18 +58,78 @@ interface MessagingConsent {
|
||||
|
||||
Telegram is demoted from sole identity to one `ExternalIdentity` provider among several — it must remain fully functional, just no longer the only path.
|
||||
|
||||
## 2. Sprint 8.2 — VK ID (build first)
|
||||
## 2. Sprint 8.2 — Social identity: VK ID first, Yandex ID second
|
||||
|
||||
Rewritten 2026-08-21 (FH-4.1–FH-4.5). Previously this section specified a VK-only pair of endpoints where the callback took `{ code, codeVerifier }` from the browser. Two changes: the surface is provider-agnostic, and the PKCE verifier stops travelling through the client.
|
||||
|
||||
### 2.1 Surface
|
||||
|
||||
```
|
||||
GET /api/identity/v1/vk/authorize -> redirects into VK's OAuth 2.1/PKCE flow
|
||||
POST /api/identity/v1/vk/callback { code, codeVerifier } -> completes OAuth **backend-side**,
|
||||
links ExternalIdentity, returns session
|
||||
GET /api/identity/v1/{provider}/authorize?returnTo= -> { url } (or 302)
|
||||
GET /api/identity/v1/{provider}/callback?code=&state=[&device_id=]
|
||||
POST /api/identity/v1/{provider}/unlink (authenticated)
|
||||
GET /api/identity/v1/me/identities (authenticated) -> ExternalIdentity[]
|
||||
```
|
||||
|
||||
Invariants:
|
||||
- OAuth completion happens entirely backend-side; the VK client secret never reaches the frontend.
|
||||
- A repeat login for the same `providerUserId` must resolve to the same `Customer`, never create a duplicate.
|
||||
- If `providerUserId` is already linked to a *different* `Customer` than the one currently authenticated (or none), this is an identity conflict — route to controlled resolution, never silently overwrite the existing binding (plan §14.3).
|
||||
`{provider}` is `vk` or `yandex` today; `telegram` and `max` join it when §4 migrates them onto `ExternalIdentity`. One controller, one strategy object per provider. The frontend gateway is a single interface (`src/app/core/identity/services/social-identity-gateway.interface.ts`) — providers differ only in a path segment, because everything that actually differs between them is backend-side.
|
||||
|
||||
### 2.2 The backend owns state and the code verifier
|
||||
|
||||
`/authorize` generates `state` and `code_verifier`, stores `{ state, codeVerifier, marketplaceId, returnTo, expiresAt }` server-side or in a signed `HttpOnly` cookie, TTL 10 minutes, **single use — deleted on first presentation**. It returns (or redirects to) the provider URL carrying `code_challenge` (S256) and `state`.
|
||||
|
||||
`/callback` validates `state`, exchanges the code using the stored verifier, fetches the profile, resolves or links the `ExternalIdentity`, issues the customer session cookie ([Track S §2.1](TRACK-S-SECURITY-RBAC-CONTRACT.md)), and redirects to `returnTo`.
|
||||
|
||||
- The client never sees a client secret, an access token, or a code verifier. We are a confidential client; a browser-held verifier buys nothing and adds a place to steal it from.
|
||||
- An unknown, expired, or replayed `state` is a generic error. Do not distinguish the cases to the caller.
|
||||
- `returnTo` is validated against the tenant's own verified origin. It is an open redirect otherwise.
|
||||
|
||||
### 2.3 Identity resolution
|
||||
|
||||
- A repeat login for the same `providerUserId` resolves to the same `Customer`, never a duplicate.
|
||||
- `UNIQUE (provider, providerUserId)`. If a provider account is already bound to a *different* `Customer`, that is an identity conflict — route to controlled resolution, never silently rebind (plan §14.3). The unique index is what enforces this; a service-layer check is not sufficient.
|
||||
- Whether one VK account across two of our storefronts is one `Customer` or two is a **product decision that must be made before implementation** (FH-0.1). `Customer.marketplaceId` currently implies two, and two is the safer default for data protection.
|
||||
- Email is optional on `Customer`. Yandex returns one in most cases; VK frequently does not.
|
||||
|
||||
### 2.4 Per-tenant OAuth applications
|
||||
|
||||
Client id and secret are per marketplace, stored with the [Track S §4.2](TRACK-S-SECURITY-RBAC-CONTRACT.md) envelope: `{ clientId, clientSecret, scopes[], redirectUri }`. Never returned by any endpoint.
|
||||
|
||||
**The redirect_uri problem, which must be solved before any code is written.** Both providers validate `redirect_uri` against an exact registered list. We cannot register one per tenant domain and we cannot let tenants supply their own. Resolution: one **central identity host** as the sole registered callback, the origin tenant carried inside the signed `state`, then a 302 back to the tenant domain with a short-lived signed one-time handoff token that the tenant API exchanges for its session cookie. This is a one-way door — retrofitting it after the first provider is live is expensive.
|
||||
|
||||
### 2.5 Provider notes
|
||||
|
||||
Confirm exact parameter and scope names against live provider documentation before implementing; both providers have revised their flows recently.
|
||||
|
||||
**VK ID** — OAuth 2.1, PKCE mandatory (S256).
|
||||
|
||||
```
|
||||
authorize GET https://id.vk.com/authorize
|
||||
client_id, redirect_uri, response_type=code,
|
||||
code_challenge, code_challenge_method=S256, state, scope
|
||||
token POST https://id.vk.com/oauth2/auth
|
||||
grant_type=authorization_code, code, code_verifier,
|
||||
device_id, client_id, redirect_uri
|
||||
profile POST https://id.vk.com/oauth2/user_info
|
||||
logout https://id.vk.com/oauth2/logout (call on unlink)
|
||||
```
|
||||
|
||||
**The callback returns `device_id` alongside `code`, and the token exchange fails without it.** This is the single most common VK ID integration bug; it is in this contract so it is not rediscovered at debugging time.
|
||||
|
||||
**Yandex ID** — OAuth 2.0, PKCE supported; use it.
|
||||
|
||||
```
|
||||
authorize GET https://oauth.yandex.ru/authorize
|
||||
response_type=code, client_id, redirect_uri, state,
|
||||
code_challenge, code_challenge_method=S256
|
||||
token POST https://oauth.yandex.ru/token
|
||||
grant_type=authorization_code, code, code_verifier
|
||||
HTTP Basic: client_id:client_secret
|
||||
profile GET https://login.yandex.ru/info?format=json
|
||||
Authorization: OAuth <access_token>
|
||||
-> id, login, default_email, default_phone, psuid
|
||||
```
|
||||
|
||||
Yandex is a second strategy object against the same surface, not a second integration. Build it after VK works.
|
||||
|
||||
## 3. Sprint 8.3 — Email/phone OTP (after VK ID)
|
||||
|
||||
@@ -145,7 +208,8 @@ POST /api/admin/v2/orders/{orderId}/conversation/handoff
|
||||
|
||||
## 6. What the frontend will start doing once this ships
|
||||
|
||||
- VK ID login button + OAuth redirect flow on storefront (primary social login).
|
||||
- VK ID and Yandex ID login buttons on the storefront. The client half already exists and is provider-agnostic: `SocialLoginButtonComponent` behind `SOCIAL_IDENTITY_GATEWAY`, with a real HTTP gateway waiting on §2.1's endpoints. Adding Yandex once VK works is a `provider` input, not new code.
|
||||
- Account linking screen (`GET /me/identities`, link/unlink), including the identity-conflict resolution path from §2.3.
|
||||
- MAX/Telegram linking UI (one-time code flow).
|
||||
- Checkout channel-choice step ("where should we send confirmation?") — VK / MAX / Telegram / email/SMS fallback.
|
||||
- Manager-facing conversation view (message history, current delivery state, accept handoff).
|
||||
|
||||
Reference in New Issue
Block a user