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:
@@ -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`)
|
## 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`:
|
Collapse `VkIdGateway` into `SocialIdentityGateway`:
|
||||||
```ts
|
```ts
|
||||||
export type SocialProvider = 'vk' | 'yandex';
|
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`.
|
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.1–2.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.
|
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.
|
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
|
```prisma
|
||||||
@@unique([provider, providerUserId]) // one provider account -> one customer
|
@@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 }`.
|
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.
|
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.
|
**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`.
|
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`.
|
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.
|
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.*
|
*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
|
## Scoreboard
|
||||||
|
|
||||||
| Wave | Done | Open | Lane | Blocked by |
|
| Wave | Done | Contract written, awaiting backend | Open | Blocked by |
|
||||||
|---|---:|---:|---|---|
|
|---|---:|---:|---:|---|
|
||||||
| 0 — Decide | 0 | 2 | C, A | — |
|
| 0 — Decide | 0 | — | 2 | needs a person, not a session |
|
||||||
| 1 — Live defects | 2 | 2 | A | FH-1.2 and FH-1.4 sit in files another session owns |
|
| 1 — Live defects | 2 | — | 2 | FH-1.2 / FH-1.4 sit in files another session owns |
|
||||||
| 2 — Contracts | 14 | 0 | B | — (1 rejected: FH-2.12) |
|
| 2 — Contracts | 14 | — | 0 | 1 rejected (FH-2.12) |
|
||||||
| 3 — Proof | 2 | 3 | A | — |
|
| 3 — Proof | 2 | — | 3 | see note below |
|
||||||
| 4 — Identity | 0 | 8 | C | FH-0.1 |
|
| 4 — Identity | 2 | 3 | 3 | OAuth apps, which FH-0.1 gates |
|
||||||
| Ops | 0 | 3 | D | — |
|
| Ops | 0 | — | 3 | — |
|
||||||
| Process | 4 | 2 | E | — |
|
| Process | 4 | — | 2 | — |
|
||||||
| **Total** | **22** | **20** | | 1 rejected |
|
| **Total** | **24** | **3** | **15** | 1 rejected |
|
||||||
|
|
||||||
**Landed 2026-08-21**
|
**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 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 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).
|
- **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).
|
||||||
- **Process:** FH-E.1–E.4, including [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md).
|
- **Wave 4** — FH-4.1 and FH-4.2 complete on the client and in the contract; FH-4.3–4.5 specified and waiting on backend plus registered OAuth applications.
|
||||||
|
- **Process** — FH-E.1–E.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**
|
**Next**
|
||||||
|
|
||||||
1. **FH-0.1** — the central identity host. One-way door, blocks all eight Wave 4 items, needs a person not a session.
|
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-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.
|
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-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.
|
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. **FH-E.6** — mock fixtures currently reach production chunks. Mechanical fix, pattern already in the repo.
|
4. **Fix the e2e session setup**, then revisit what proof is worth adding.
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ interface Customer {
|
|||||||
|
|
||||||
interface ExternalIdentity {
|
interface ExternalIdentity {
|
||||||
customerId: string;
|
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;
|
providerUserId: string;
|
||||||
|
email?: string; // VK frequently returns none - never require it
|
||||||
|
phone?: string;
|
||||||
|
displayName?: string;
|
||||||
verifiedAt: string;
|
verifiedAt: string;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
lastUsedAt: string;
|
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.
|
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
|
GET /api/identity/v1/{provider}/authorize?returnTo= -> { url } (or 302)
|
||||||
POST /api/identity/v1/vk/callback { code, codeVerifier } -> completes OAuth **backend-side**,
|
GET /api/identity/v1/{provider}/callback?code=&state=[&device_id=]
|
||||||
links ExternalIdentity, returns session
|
POST /api/identity/v1/{provider}/unlink (authenticated)
|
||||||
|
GET /api/identity/v1/me/identities (authenticated) -> ExternalIdentity[]
|
||||||
```
|
```
|
||||||
|
|
||||||
Invariants:
|
`{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.
|
||||||
- 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.
|
### 2.2 The backend owns state and the code verifier
|
||||||
- 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).
|
|
||||||
|
`/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)
|
## 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
|
## 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).
|
- MAX/Telegram linking UI (one-time code flow).
|
||||||
- Checkout channel-choice step ("where should we send confirmation?") — VK / MAX / Telegram / email/SMS fallback.
|
- 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).
|
- Manager-facing conversation view (message history, current delivery state, accept handoff).
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="social-login-button"
|
||||||
|
[class]="'social-login-button--' + provider()"
|
||||||
|
[disabled]="loading()"
|
||||||
|
(click)="startLogin()"
|
||||||
|
>
|
||||||
|
<app-icon name="user" [size]="18" />
|
||||||
|
<span>{{ label() }}</span>
|
||||||
|
</button>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.vk-id-login {
|
.social-login-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { SOCIAL_IDENTITY_GATEWAY } from '../../core/identity/services/social-identity-gateway.token';
|
||||||
|
import { SocialProvider } from '../../core/identity/services/social-identity-gateway.interface';
|
||||||
|
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||||
|
|
||||||
|
const PROVIDER_LABEL: Record<SocialProvider, string> = {
|
||||||
|
vk: 'Continue with VK ID',
|
||||||
|
yandex: 'Continue with Yandex ID',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One button per social provider, per v3.1 §14 (VK ID is the primary
|
||||||
|
* storefront social login; Yandex ID is the second instance of the same
|
||||||
|
* flow, not a separate integration).
|
||||||
|
*
|
||||||
|
* Deliberately not spliced into TelegramLoginComponent's dialog yet. That
|
||||||
|
* component is the live customer login surface, and adding providers to it
|
||||||
|
* belongs in the pass that also demotes Telegram to one ExternalIdentity
|
||||||
|
* among several (FH-4.6) - not before a real OAuth application exists to
|
||||||
|
* test against.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-social-login-button',
|
||||||
|
standalone: true,
|
||||||
|
imports: [IconComponent],
|
||||||
|
templateUrl: './social-login-button.component.html',
|
||||||
|
styleUrls: ['./social-login-button.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SocialLoginButtonComponent {
|
||||||
|
private readonly gateway = inject(SOCIAL_IDENTITY_GATEWAY);
|
||||||
|
|
||||||
|
readonly provider = input.required<SocialProvider>();
|
||||||
|
/** Where to land after the callback. Validated backend-side. */
|
||||||
|
readonly returnTo = input<string | undefined>(undefined);
|
||||||
|
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly label = computed(() => PROVIDER_LABEL[this.provider()]);
|
||||||
|
|
||||||
|
startLogin(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.gateway
|
||||||
|
.getAuthorizeUrl(this.provider(), this.returnTo())
|
||||||
|
.pipe(take(1))
|
||||||
|
.subscribe({
|
||||||
|
next: url => {
|
||||||
|
this.loading.set(false);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => this.loading.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<button type="button" class="vk-id-login" [disabled]="loading()" (click)="startLogin()">
|
|
||||||
<app-icon name="user" [size]="18" />
|
|
||||||
<span>Continue with VK ID</span>
|
|
||||||
</button>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { take } from 'rxjs/operators';
|
|
||||||
import { VK_ID_GATEWAY } from '../../core/identity/services/vk-id-gateway.token';
|
|
||||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standalone VK ID login button, per Sprint 0.1 ("do all after vk" - VK ID
|
|
||||||
* is the primary storefront social login going forward, per v3.1 §14).
|
|
||||||
* Deliberately not wired into TelegramLoginComponent's dialog yet - that
|
|
||||||
* component is the live, working customer/admin login surface, and
|
|
||||||
* splicing a second provider into it needs its own careful pass once a
|
|
||||||
* real VK OAuth app exists to test against, not a mock-backed bolt-on.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-vk-id-login',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, IconComponent],
|
|
||||||
templateUrl: './vk-id-login.component.html',
|
|
||||||
styleUrls: ['./vk-id-login.component.scss'],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class VkIdLoginComponent {
|
|
||||||
private readonly gateway = inject(VK_ID_GATEWAY);
|
|
||||||
|
|
||||||
readonly loading = signal(false);
|
|
||||||
|
|
||||||
startLogin(): void {
|
|
||||||
this.loading.set(true);
|
|
||||||
this.gateway.getAuthorizeUrl().pipe(take(1)).subscribe(url => {
|
|
||||||
this.loading.set(false);
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.location.href = url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,12 +9,16 @@ export interface Customer {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExternalIdentityProvider = 'vk_id' | 'telegram' | 'max';
|
export type ExternalIdentityProvider = 'vk_id' | 'yandex_id' | 'telegram' | 'max';
|
||||||
|
|
||||||
export interface ExternalIdentity {
|
export interface ExternalIdentity {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
provider: ExternalIdentityProvider;
|
provider: ExternalIdentityProvider;
|
||||||
providerUserId: string;
|
providerUserId: string;
|
||||||
|
/** Not every provider returns one - VK frequently does not. */
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
displayName?: string;
|
||||||
verifiedAt: string;
|
verifiedAt: string;
|
||||||
lastUsedAt: string;
|
lastUsedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { ExternalIdentity } from '../models/customer-identity.model';
|
||||||
|
import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract: docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
|
||||||
|
*
|
||||||
|
* Provider-agnostic by construction - VK ID and Yandex ID differ only in the
|
||||||
|
* path segment, because the differences that matter (PKCE, VK's device_id,
|
||||||
|
* Yandex's Basic-auth token exchange) live entirely on the backend.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SocialIdentityApiGateway implements SocialIdentityGateway {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly base = '/api/identity/v1';
|
||||||
|
|
||||||
|
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string> {
|
||||||
|
const params = returnTo ? new HttpParams().set('returnTo', returnTo) : undefined;
|
||||||
|
return this.http
|
||||||
|
.get<{ url: string }>(`${this.base}/${provider}/authorize`, { params })
|
||||||
|
.pipe(map(response => response.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
listIdentities(): Observable<ExternalIdentity[]> {
|
||||||
|
return this.http.get<ExternalIdentity[]>(`${this.base}/me/identities`);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink(provider: SocialProvider): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.base}/${provider}/unlink`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ExternalIdentity } from '../models/customer-identity.model';
|
||||||
|
|
||||||
|
/** Providers this surface can start an authorization flow for. */
|
||||||
|
export type SocialProvider = 'vk' | 'yandex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
|
||||||
|
*
|
||||||
|
* One surface, one strategy per provider behind it. The client's entire
|
||||||
|
* involvement is "send me somewhere" and "tell me what is linked" - the
|
||||||
|
* OAuth exchange happens backend-side and the browser never holds a client
|
||||||
|
* secret, an access token, or a PKCE code verifier.
|
||||||
|
*
|
||||||
|
* Note what is absent: there is no completeCallback(). An earlier VK-only
|
||||||
|
* version of this interface took (code, codeVerifier) from the client, which
|
||||||
|
* forced the browser to generate and store the verifier. We are a
|
||||||
|
* confidential client; the backend owns state and verifier, handles the
|
||||||
|
* provider's callback itself, and redirects back with a session already set.
|
||||||
|
*/
|
||||||
|
export interface SocialIdentityGateway {
|
||||||
|
/**
|
||||||
|
* URL to navigate the browser to in order to start the flow. The backend
|
||||||
|
* has already minted and stored the single-use state and code verifier by
|
||||||
|
* the time this resolves.
|
||||||
|
*
|
||||||
|
* @param returnTo where to land after the callback completes, validated
|
||||||
|
* backend-side against the tenant's own origin - never used as an open
|
||||||
|
* redirect.
|
||||||
|
*/
|
||||||
|
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
|
||||||
|
|
||||||
|
/** Providers currently linked to the authenticated customer. */
|
||||||
|
listIdentities(): Observable<ExternalIdentity[]>;
|
||||||
|
|
||||||
|
/** Unlinks a provider from the authenticated customer. */
|
||||||
|
unlink(provider: SocialProvider): Observable<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { SocialIdentityApiGateway } from './social-identity-api.gateway';
|
||||||
|
import { SocialProvider } from './social-identity-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FH-4.1/FH-4.2. The whole point of this surface is what it does NOT carry:
|
||||||
|
* no client secret, no access token, no PKCE code verifier. These tests pin
|
||||||
|
* that shape, because a future "just add completeCallback back" would look
|
||||||
|
* harmless in review and would move verifier custody into the browser.
|
||||||
|
*/
|
||||||
|
describe('SocialIdentityApiGateway', () => {
|
||||||
|
let gateway: SocialIdentityApiGateway;
|
||||||
|
let httpTesting: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [SocialIdentityApiGateway, provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
gateway = TestBed.inject(SocialIdentityApiGateway);
|
||||||
|
httpTesting = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpTesting.verify());
|
||||||
|
|
||||||
|
const providers: SocialProvider[] = ['vk', 'yandex'];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
it(`asks the backend for the ${provider} authorize URL and sends no secret material`, () => {
|
||||||
|
let resolved: string | undefined;
|
||||||
|
gateway.getAuthorizeUrl(provider).subscribe(url => (resolved = url));
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne(`/api/identity/v1/${provider}/authorize`);
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
expect(request.request.body).toBeNull();
|
||||||
|
expect(request.request.urlWithParams).not.toContain('code_verifier');
|
||||||
|
expect(request.request.urlWithParams).not.toContain('client_secret');
|
||||||
|
|
||||||
|
request.flush({ url: `https://id.example.test/${provider}/authorize?state=abc` });
|
||||||
|
expect(resolved).toContain(provider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('passes returnTo through as a query parameter', () => {
|
||||||
|
gateway.getAuthorizeUrl('vk', '/cart').subscribe();
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne(r => r.url === '/api/identity/v1/vk/authorize');
|
||||||
|
expect(request.request.params.get('returnTo')).toBe('/cart');
|
||||||
|
request.flush({ url: 'https://id.example.test/vk/authorize' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads linked identities from one endpoint for every provider', () => {
|
||||||
|
gateway.listIdentities().subscribe();
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne('/api/identity/v1/me/identities');
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
request.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlinks per provider', () => {
|
||||||
|
gateway.unlink('yandex').subscribe();
|
||||||
|
|
||||||
|
const request = httpTesting.expectOne('/api/identity/v1/yandex/unlink');
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
request.flush(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
import { SocialIdentityGateway } from './social-identity-gateway.interface';
|
||||||
|
import { SocialIdentityLocalGateway } from './social-identity-local.gateway';
|
||||||
|
import { SocialIdentityApiGateway } from './social-identity-api.gateway';
|
||||||
|
|
||||||
|
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
|
||||||
|
export const SOCIAL_IDENTITY_GATEWAY = new InjectionToken<SocialIdentityGateway>('SOCIAL_IDENTITY_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => (environment.useMockData ? inject(SocialIdentityLocalGateway) : inject(SocialIdentityApiGateway)),
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { ExternalIdentity } from '../models/customer-identity.model';
|
||||||
|
import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development stand-in. No VK or Yandex OAuth application is registered yet,
|
||||||
|
* and registering one is blocked on a decision that has to be made before any
|
||||||
|
* of this can work for real: both providers validate redirect_uri against an
|
||||||
|
* exact registered list, so a multi-tenant platform needs one central
|
||||||
|
* identity host as the sole registered callback, with the tenant carried in
|
||||||
|
* the signed state. See FORK-HARVEST-TODO.md FH-0.1.
|
||||||
|
*
|
||||||
|
* Returning a data: URL rather than a fake provider URL is deliberate - it
|
||||||
|
* cannot be mistaken for a working flow if this ever runs outside dev.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SocialIdentityLocalGateway implements SocialIdentityGateway {
|
||||||
|
getAuthorizeUrl(provider: SocialProvider): Observable<string> {
|
||||||
|
return of(`about:blank#${provider}-oauth-not-configured`);
|
||||||
|
}
|
||||||
|
|
||||||
|
listIdentities(): Observable<ExternalIdentity[]> {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink(): Observable<void> {
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { map } from 'rxjs/operators';
|
|
||||||
import { Customer } from '../models/customer-identity.model';
|
|
||||||
import { VkIdGateway } from './vk-id-gateway.interface';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contract: docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
|
|
||||||
* OAuth completion happens backend-side; this is the client-facing surface
|
|
||||||
* only - getAuthorizeUrl navigates the browser there, completeCallback hands
|
|
||||||
* back the code/verifier pair for the backend to exchange.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class VkIdApiGateway implements VkIdGateway {
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
|
|
||||||
getAuthorizeUrl(): Observable<string> {
|
|
||||||
return this.http
|
|
||||||
.get<{ url: string }>('/api/identity/v1/vk/authorize')
|
|
||||||
.pipe(map(response => response.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
completeCallback(code: string, codeVerifier: string): Observable<Customer> {
|
|
||||||
return this.http.post<Customer>('/api/identity/v1/vk/callback', { code, codeVerifier });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Observable } from 'rxjs';
|
|
||||||
import { Customer } from '../models/customer-identity.model';
|
|
||||||
|
|
||||||
/** Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. OAuth completion is backend-side; this is the client-facing surface only. */
|
|
||||||
export interface VkIdGateway {
|
|
||||||
getAuthorizeUrl(): Observable<string>;
|
|
||||||
completeCallback(code: string, codeVerifier: string): Observable<Customer>;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
|
||||||
import { environment } from '../../../../environments/environment';
|
|
||||||
import { VkIdGateway } from './vk-id-gateway.interface';
|
|
||||||
import { VkIdLocalGateway } from './vk-id-local.gateway';
|
|
||||||
import { VkIdApiGateway } from './vk-id-api.gateway';
|
|
||||||
|
|
||||||
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
|
|
||||||
export const VK_ID_GATEWAY = new InjectionToken<VkIdGateway>('VK_ID_GATEWAY', {
|
|
||||||
providedIn: 'root',
|
|
||||||
factory: () => (environment.useMockData ? inject(VkIdLocalGateway) : inject(VkIdApiGateway)),
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { Observable, of } from 'rxjs';
|
|
||||||
import { Customer } from '../models/customer-identity.model';
|
|
||||||
import { VkIdGateway } from './vk-id-gateway.interface';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No real VK OAuth app is configured yet - this mock exists so the
|
|
||||||
* VkIdLoginButtonComponent has something to call and the flow shape is
|
|
||||||
* provable end-to-end before a real client id/secret exist. Swap
|
|
||||||
* VK_ID_GATEWAY once docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2
|
|
||||||
* ships; the real backend completes OAuth server-side, this interface never
|
|
||||||
* exposes a client secret regardless of implementation.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class VkIdLocalGateway implements VkIdGateway {
|
|
||||||
getAuthorizeUrl(): Observable<string> {
|
|
||||||
return of('about:blank#vk-id-not-configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
completeCallback(_code: string, _codeVerifier: string): Observable<Customer> {
|
|
||||||
return of({
|
|
||||||
id: 'customer_vk_mock',
|
|
||||||
marketplaceId: 'default',
|
|
||||||
status: 'active',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user