// 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 "" }