2c465c01d2
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.
132 lines
4.2 KiB
Go
132 lines
4.2 KiB
Go
package vkid
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
)
|
|
|
|
// testExchanger builds an Exchanger pointed at a test server.
|
|
func testExchanger(endpoint string) *Exchanger {
|
|
return &Exchanger{
|
|
appID: "app-1",
|
|
clientSecret: "secret-1",
|
|
redirectURI: "https://example.test/app/",
|
|
endpoint: endpoint,
|
|
httpClient: &http.Client{},
|
|
}
|
|
}
|
|
|
|
func TestExchangeSuccessSendsConfidentialPKCE(t *testing.T) {
|
|
var gotForm url.Values
|
|
var gotContentType string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
body, _ := io.ReadAll(r.Body)
|
|
gotForm, _ = url.ParseQuery(string(body))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"user_id":"12345","access_token":"a","id_token":"h.e.s"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "the-code", "dev-9", "verifier-xyz")
|
|
if err != nil {
|
|
t.Fatalf("Exchange: %v", err)
|
|
}
|
|
if id.ExternalID != "12345" {
|
|
t.Fatalf("ExternalID = %q, want 12345", id.ExternalID)
|
|
}
|
|
if gotContentType != "application/x-www-form-urlencoded" {
|
|
t.Errorf("Content-Type = %q", gotContentType)
|
|
}
|
|
// The confidential exchange must carry the PKCE inputs and the app credentials.
|
|
want := map[string]string{
|
|
"grant_type": "authorization_code",
|
|
"code": "the-code",
|
|
"code_verifier": "verifier-xyz",
|
|
"device_id": "dev-9",
|
|
"client_id": "app-1",
|
|
"client_secret": "secret-1",
|
|
"redirect_uri": "https://example.test/app/",
|
|
}
|
|
for k, v := range want {
|
|
if gotForm.Get(k) != v {
|
|
t.Errorf("form[%s] = %q, want %q", k, gotForm.Get(k), v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExchangeAcceptsNumericUserID(t *testing.T) {
|
|
// VK returns user_id as a bare JSON number in some responses; it must parse too.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"user_id":1234567890,"access_token":"a"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
|
if err != nil {
|
|
t.Fatalf("Exchange: %v", err)
|
|
}
|
|
if id.ExternalID != "1234567890" {
|
|
t.Fatalf("ExternalID = %q, want 1234567890", id.ExternalID)
|
|
}
|
|
}
|
|
|
|
func TestExchangeFallsBackToIDTokenSub(t *testing.T) {
|
|
sub := "987654321"
|
|
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"` + sub + `"}`))
|
|
idToken := "header." + payload + ".sig"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"access_token":"a","id_token":"`+idToken+`"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
|
if err != nil {
|
|
t.Fatalf("Exchange: %v", err)
|
|
}
|
|
if id.ExternalID != sub {
|
|
t.Fatalf("ExternalID = %q, want %q (from id_token sub)", id.ExternalID, sub)
|
|
}
|
|
}
|
|
|
|
func TestExchangeRejectedIsErrInvalid(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = io.WriteString(w, `{"error":"invalid_grant","error_description":"code expired"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
|
if !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("err = %v, want ErrInvalid", err)
|
|
}
|
|
}
|
|
|
|
func TestExchangeEmptyInputsFailFast(t *testing.T) {
|
|
// Missing PKCE inputs are rejected without any network call.
|
|
ex := testExchanger("http://127.0.0.1:0/never")
|
|
for _, args := range [][3]string{{"", "d", "v"}, {"c", "", "v"}, {"c", "d", ""}} {
|
|
if _, err := ex.Exchange(context.Background(), args[0], args[1], args[2]); !errors.Is(err, ErrInvalid) {
|
|
t.Errorf("Exchange%v err = %v, want ErrInvalid", args, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExchangeTransportErrorIsNotErrInvalid(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
srv.Close() // closed listener → connection refused
|
|
_, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
|
if err == nil {
|
|
t.Fatal("want a transport error")
|
|
}
|
|
if errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("transport error must not be ErrInvalid: %v", err)
|
|
}
|
|
}
|