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

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:
Ilia Denisov
2026-07-03 17:59:33 +02:00
parent 60faa4f064
commit 2c465c01d2
36 changed files with 1131 additions and 16 deletions
+4
View File
@@ -25,12 +25,16 @@ ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
ARG VITE_VK_APP_LINK=
ARG VITE_VK_APP_ID=
ARG VITE_VK_ID_REDIRECT_URL=
ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
VITE_VK_APP_ID=$VITE_VK_APP_ID \
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION
+11 -3
View File
@@ -61,6 +61,12 @@ round-trip, then forwards the trusted `vk_user_id` (and the client-read display
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
(`auth.vk` is unregistered).
`link.vk.confirm`/`merge` link a VK identity from a **browser** (not a Mini App) via **VK ID web
login** (`internal/vkid`): the SPA runs the VK ID raw OAuth 2.1 flow (PKCE, no `@vkid/sdk`) and
the gateway completes the **confidential code exchange** at `id.vk.com` under a SEPARATE VK "Web"
app's protected key — the only op here that calls VK (the Mini App path above is offline). All
three `GATEWAY_VK_ID_*` unset leaves the ops unregistered.
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
@@ -72,9 +78,10 @@ refetch). The social/account/history ops —
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
transcode pattern (`transcode_social.go`). Account linking & merge
`link.email.request/confirm/merge` and `link.telegram.confirm/merge`
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
`link.email.request/confirm/merge`, `link.telegram.confirm/merge` and
`link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login
Widget** payload via the validator (`ValidateLoginWidget`), the vk ops complete the VK ID
web code exchange via `internal/vkid`, and both forward the trusted `external_id`. These
**superseded** the former `email.bind.*` ops, which were removed.
## Configuration
@@ -89,6 +96,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
| `GATEWAY_VK_ID_APP_ID` / `GATEWAY_VK_ID_CLIENT_SECRET` / `GATEWAY_VK_ID_REDIRECT_URL` | unset | VK ID "Web" app credentials for VK web-login linking (`link.vk.*`): the server-side confidential OAuth 2.1 code exchange. A separate VK app from `GATEWAY_VK_APP_SECRET`; all three required to enable the ops |
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
+10 -1
View File
@@ -33,6 +33,7 @@ import (
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
"scrabble/gateway/internal/vkid"
"scrabble/pkg/mtls"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
@@ -190,7 +191,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
}
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
// VK ID web login (browser VK-identity linking) is optional: build the confidential
// code-exchanger only when fully configured, else leave the interface nil so the
// link.vk.* ops stay unregistered.
var vkidExchanger transcode.VKIDExchanger
if cfg.VKID.Enabled() {
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
logger.Info("vk id web login enabled")
}
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
@@ -335,6 +335,24 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin
return out, err
}
// LinkVK attaches a gateway-validated VK identity to the caller or reports a required
// merge. externalID is the trusted vk user id resolved from the VK ID code exchange.
func (c *Client) LinkVK(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk", userID, "",
map[string]string{"external_id": externalID}, &out)
return out, err
}
// LinkVKMerge merges the account owning a gateway-validated VK identity into the
// caller's.
func (c *Client) LinkVKMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk/merge", userID, "",
map[string]string{"external_id": externalID}, &out)
return out, err
}
// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an
// authenticated email change.
func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error {
+26
View File
@@ -36,6 +36,11 @@ type Config struct {
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
VKAppSecret string
// VKID configures the VK ID web login used to link a VK identity from a browser
// (the confidential OAuth 2.1 code exchange against id.vk.com). It belongs to a
// separate VK "Web" app from VKAppSecret's Mini App, so its credentials are distinct.
// Any field empty disables the VK web-link ops (link.vk.*).
VKID VKIDConfig
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
BotLink BotLinkConfig
@@ -161,6 +166,22 @@ func DefaultAbuse() AbuseConfig {
}
}
// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
// with the app and the one the frontend uses. All three are required to enable the flow.
type VKIDConfig struct {
AppID string
ClientSecret string
RedirectURI string
}
// Enabled reports whether VK ID web login is fully configured. When false the gateway
// leaves the VK web-link ops (link.vk.*) unregistered.
func (c VKIDConfig) Enabled() bool {
return c.AppID != "" && c.ClientSecret != "" && c.RedirectURI != ""
}
// Load reads the configuration from the environment, applies defaults, and
// validates the result.
func Load() (Config, error) {
@@ -174,6 +195,11 @@ func Load() (Config, error) {
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
RedirectURI: os.Getenv("GATEWAY_VK_ID_REDIRECT_URL"),
},
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
+13
View File
@@ -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")
}
}
+163
View File
@@ -0,0 +1,163 @@
// Package vkid completes VK ID web authorization on the server. A browser linking a
// VK identity has no signed Mini App launch parameters (that offline HMAC path is
// vkauth); instead the frontend runs the VK ID raw OAuth 2.1 flow (PKCE) and hands the
// gateway the authorization code, which the gateway exchanges — confidentially, under
// the app's protected key — for the launching user's trusted VK id. Unlike the Mini App
// path this makes an outbound call to VK (there is no offline verification for the web
// flow). See docs/ARCHITECTURE.md §12.
package vkid
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
// tokenEndpoint is VK ID's OAuth 2.1 token endpoint. The frontend obtains the
// authorization code against the same host, so the confidential exchange targets it
// too. It is a fixed constant (not user input), so the outbound request carries no
// SSRF risk.
tokenEndpoint = "https://id.vk.com/oauth2/auth"
// exchangeTimeout bounds one code-for-token exchange. Linking is interactive, so an
// unreachable VK must fail fast rather than hold the request open.
exchangeTimeout = 10 * time.Second
// maxResponseBytes caps the token-response read to bound memory on an oversized body.
maxResponseBytes = 1 << 16
)
// ErrInvalid reports an authorization fault: the exchange was rejected or yielded no vk
// user id (a bad or expired code, a mismatched verifier or redirect). It is distinct
// from a transport failure reaching VK, which surfaces as a wrapped error.
var ErrInvalid = errors.New("vkid: vk id authorization exchange failed")
// Identity is the user resolved from a completed VK ID exchange. ExternalID is the vk
// user id, used as the identities external_id.
type Identity struct {
ExternalID string
}
// numericID accepts a VK user id that the token endpoint returns inconsistently as a
// JSON string or a JSON number, normalising both to their decimal string form (empty
// for a null or absent field).
type numericID string
func (n *numericID) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
if s == "null" {
s = ""
}
*n = numericID(s)
return nil
}
// Exchanger completes VK ID confidential authorization-code exchanges for one app.
type Exchanger struct {
appID string
clientSecret string
redirectURI string
endpoint string
httpClient *http.Client
}
// New constructs an Exchanger for the app credentials. redirectURI must equal the
// trusted redirect URL registered with the app and the one the frontend used, or VK
// rejects the exchange.
func New(appID, clientSecret, redirectURI string) *Exchanger {
return &Exchanger{
appID: appID,
clientSecret: clientSecret,
redirectURI: redirectURI,
endpoint: tokenEndpoint,
httpClient: &http.Client{Timeout: exchangeTimeout},
}
}
// Exchange completes the authorization-code grant and returns the trusted vk user id.
// code, deviceID and codeVerifier are the PKCE inputs the frontend obtained from the VK
// ID authorization redirect; the exchange authenticates with the app's protected key.
func (e *Exchanger) Exchange(ctx context.Context, code, deviceID, codeVerifier string) (Identity, error) {
if code == "" || deviceID == "" || codeVerifier == "" {
return Identity{}, ErrInvalid
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"code_verifier": {codeVerifier},
"device_id": {deviceID},
"client_id": {e.appID},
"client_secret": {e.clientSecret},
"redirect_uri": {e.redirectURI},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, strings.NewReader(form.Encode()))
if err != nil {
return Identity{}, fmt.Errorf("vkid: build exchange request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return Identity{}, fmt.Errorf("vkid: exchange request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return Identity{}, fmt.Errorf("vkid: read exchange response: %w", err)
}
if resp.StatusCode != http.StatusOK {
// VK answers 400 with {error, error_description} on a bad/expired code or a
// verifier/redirect mismatch — an authorization fault, not a transport error.
return Identity{}, ErrInvalid
}
var out struct {
UserID numericID `json:"user_id"`
IDToken string `json:"id_token"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &out); err != nil {
return Identity{}, ErrInvalid
}
if out.Error != "" {
return Identity{}, ErrInvalid
}
uid := string(out.UserID)
if uid == "" || uid == "0" {
uid = subjectFromIDToken(out.IDToken)
}
if uid == "" {
return Identity{}, ErrInvalid
}
return Identity{ExternalID: uid}, nil
}
// subjectFromIDToken extracts the OIDC subject (the vk user id) from the payload of a
// VK ID id_token. The token arrives inside a direct TLS response from VK, so its
// signature is not re-verified here; the claim is only a fallback for a response that
// omits an explicit user_id.
func subjectFromIDToken(idToken string) string {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
var claims struct {
Sub numericID `json:"sub"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return ""
}
if s := string(claims.Sub); s != "0" {
return s
}
return ""
}
+131
View File
@@ -0,0 +1,131 @@
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)
}
}