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

@@ -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>

View File

@@ -1,4 +1,4 @@
.vk-id-login {
.social-login-button {
display: flex;
align-items: center;
justify-content: center;

View File

@@ -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),
});
}
}

View File

@@ -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>

View File

@@ -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;
}
});
}
}

View File

@@ -9,12 +9,16 @@ export interface Customer {
createdAt: string;
}
export type ExternalIdentityProvider = 'vk_id' | 'telegram' | 'max';
export type ExternalIdentityProvider = 'vk_id' | 'yandex_id' | 'telegram' | 'max';
export interface ExternalIdentity {
customerId: string;
provider: ExternalIdentityProvider;
providerUserId: string;
/** Not every provider returns one - VK frequently does not. */
email?: string;
phone?: string;
displayName?: string;
verifiedAt: string;
lastUsedAt: string;
}

View File

@@ -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`, {});
}
}

View File

@@ -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>;
}

View File

@@ -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);
});
});

View File

@@ -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)),
});

View File

@@ -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);
}
}

View File

@@ -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 });
}
}

View File

@@ -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>;
}

View File

@@ -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)),
});

View File

@@ -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(),
});
}
}