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:
@@ -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;
|
||||
align-items: 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user