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