feat(account): VK ID web login to link a VK identity from a browser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.
- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
transcode link.vk.confirm/merge (registered only when configured) + config
GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
boot callback handling; a merge re-authorizes for a fresh code (VK codes are
single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/connector"
|
||||
"scrabble/gateway/internal/vkauth"
|
||||
"scrabble/gateway/internal/vkid"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
@@ -152,6 +153,15 @@ func WithVKAuth(secret string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithVKLink registers the VK web-link ops (link.vk.confirm/merge), which complete a
|
||||
// browser VK ID authorization via the server-side code exchange. A nil exchanger leaves
|
||||
// them unregistered, so the ops are unknown wherever VK ID web login is not configured.
|
||||
func WithVKLink(ex VKIDExchanger) Option {
|
||||
return func(r *Registry, backend *backendclient.Client) {
|
||||
registerVKLinkOps(r, backend, ex)
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the operation for messageType, and whether it is registered.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
op, ok := r.ops[messageType]
|
||||
@@ -172,6 +182,9 @@ func DomainCode(err error) (string, bool) {
|
||||
if errors.Is(err, vkauth.ErrInvalid) {
|
||||
return "invalid_vk_params", true
|
||||
}
|
||||
if errors.Is(err, vkid.ErrInvalid) {
|
||||
return "invalid_vk_id", true
|
||||
}
|
||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||
return "invalid_login_widget", true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/vkid"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,8 @@ const (
|
||||
MsgLinkEmailMerge = "link.email.merge"
|
||||
MsgLinkTelegram = "link.telegram.confirm"
|
||||
MsgLinkTelegramMerge = "link.telegram.merge"
|
||||
MsgLinkVK = "link.vk.confirm"
|
||||
MsgLinkVKMerge = "link.vk.merge"
|
||||
MsgLinkUnlink = "link.unlink"
|
||||
MsgEmailChangeRequest = "link.email.change.request"
|
||||
MsgEmailChangeConfirm = "link.email.change.confirm"
|
||||
@@ -154,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
|
||||
return encodeLinkResult(res), nil
|
||||
}
|
||||
}
|
||||
|
||||
// VKIDExchanger completes a VK ID web authorization-code exchange, returning the
|
||||
// launching user's trusted VK identity. It is satisfied by *vkid.Exchanger and lets the
|
||||
// VK web-link ops resolve a browser VK login, which — unlike the Mini App path — has no
|
||||
// offline launch signature to verify.
|
||||
type VKIDExchanger interface {
|
||||
Exchange(ctx context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error)
|
||||
}
|
||||
|
||||
// registerVKLinkOps adds the VK web-link ops when a VK ID exchanger is configured; a nil
|
||||
// exchanger leaves them unregistered (VK ID web login not configured).
|
||||
func registerVKLinkOps(r *Registry, backend *backendclient.Client, ex VKIDExchanger) {
|
||||
if ex == nil {
|
||||
return
|
||||
}
|
||||
r.ops[MsgLinkVK] = Op{Handler: linkVKHandler(backend, ex, false), Auth: true}
|
||||
r.ops[MsgLinkVKMerge] = Op{Handler: linkVKHandler(backend, ex, true), Auth: true}
|
||||
}
|
||||
|
||||
// linkVKHandler completes the VK ID code exchange (server-side, under the app's
|
||||
// protected key) and then calls the backend's link or merge endpoint with the trusted
|
||||
// VK external id.
|
||||
func linkVKHandler(backend *backendclient.Client, ex VKIDExchanger, merge bool) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsLinkVKRequest(req.Payload, 0)
|
||||
user, err := ex.Exchange(ctx, string(in.Code()), string(in.DeviceId()), string(in.CodeVerifier()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res backendclient.LinkResultResp
|
||||
if merge {
|
||||
res, err = backend.LinkVKMerge(ctx, req.UserID, user.ExternalID)
|
||||
} else {
|
||||
res, err = backend.LinkVK(ctx, req.UserID, user.ExternalID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeLinkResult(res), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package transcode_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/gateway/internal/transcode"
|
||||
"scrabble/gateway/internal/vkid"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
func linkVKPayload(code, deviceID, verifier string) []byte {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
c := b.CreateString(code)
|
||||
d := b.CreateString(deviceID)
|
||||
v := b.CreateString(verifier)
|
||||
fb.LinkVKRequestStart(b)
|
||||
fb.LinkVKRequestAddCode(b, c)
|
||||
fb.LinkVKRequestAddDeviceId(b, d)
|
||||
fb.LinkVKRequestAddCodeVerifier(b, v)
|
||||
b.Finish(fb.LinkVKRequestEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// fakeVKIDExchanger records the exchange inputs and returns a canned identity.
|
||||
type fakeVKIDExchanger struct {
|
||||
id vkid.Identity
|
||||
err error
|
||||
gotCode, gotDevice, gotVerifier string
|
||||
}
|
||||
|
||||
func (f *fakeVKIDExchanger) Exchange(_ context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error) {
|
||||
f.gotCode, f.gotDevice, f.gotVerifier = code, deviceID, codeVerifier
|
||||
return f.id, f.err
|
||||
}
|
||||
|
||||
func TestLinkVKExchangesAndForwards(t *testing.T) {
|
||||
var gotExternalID string
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/user/link/vk" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
var body struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotExternalID = body.ExternalID
|
||||
_, _ = w.Write([]byte(`{"status":"linked"}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
ex := &fakeVKIDExchanger{id: vkid.Identity{ExternalID: "777"}}
|
||||
reg := transcode.NewRegistry(backend, nil, transcode.WithVKLink(ex))
|
||||
op, ok := reg.Lookup(transcode.MsgLinkVK)
|
||||
if !ok {
|
||||
t.Fatal("link.vk.confirm not registered")
|
||||
}
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkVKPayload("the-code", "dev-1", "verifier-1")})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
// The PKCE inputs from the wire must reach the exchanger verbatim...
|
||||
if ex.gotCode != "the-code" || ex.gotDevice != "dev-1" || ex.gotVerifier != "verifier-1" {
|
||||
t.Errorf("exchange got %q/%q/%q", ex.gotCode, ex.gotDevice, ex.gotVerifier)
|
||||
}
|
||||
// ...and the resolved vk id must be the one forwarded to the backend.
|
||||
if gotExternalID != "777" {
|
||||
t.Errorf("backend external_id = %q, want 777", gotExternalID)
|
||||
}
|
||||
if string(fb.GetRootAsLinkResult(payload, 0).Status()) != "linked" {
|
||||
t.Error("expected a linked result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkVKUnregisteredWithoutExchanger(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
if _, ok := reg.Lookup(transcode.MsgLinkVK); ok {
|
||||
t.Error("link.vk.confirm must be unregistered when no VK ID exchanger is configured")
|
||||
}
|
||||
if _, ok := reg.Lookup(transcode.MsgLinkVKMerge); ok {
|
||||
t.Error("link.vk.merge must be unregistered when no VK ID exchanger is configured")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user