feat(account): VK ID web login to link a VK identity from a browser #166
@@ -330,6 +330,10 @@ jobs:
|
||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
||||
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
||||
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
|
||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||
# App above. Empty leaves the link.vk.* ops disabled.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.TEST_GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||
# Transactional email via the shared Selectel relay. Empty host leaves the
|
||||
@@ -369,6 +373,10 @@ jobs:
|
||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
|
||||
# VK ID web login: the "Web" app id + its trusted redirect URL. One value each feeds
|
||||
# both the SPA (the authorize URL) and the gateway (client_id / exchange redirect_uri).
|
||||
VITE_VK_APP_ID: ${{ vars.TEST_VITE_VK_APP_ID }}
|
||||
VITE_VK_ID_REDIRECT_URL: ${{ vars.TEST_VK_ID_REDIRECT_URL }}
|
||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||
|
||||
@@ -41,6 +41,9 @@ jobs:
|
||||
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
|
||||
# VK ID web login: the "Web" app id + trusted redirect URL, baked into the SPA authorize URL.
|
||||
VITE_VK_APP_ID: ${{ vars.PROD_VITE_VK_APP_ID }}
|
||||
VITE_VK_ID_REDIRECT_URL: ${{ vars.PROD_VK_ID_REDIRECT_URL }}
|
||||
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||
@@ -84,6 +87,11 @@ jobs:
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
||||
# VK ID web login: the SPA app id + redirect URL (the gateway reuses them as its
|
||||
# client_id / exchange redirect_uri at runtime) and the "Web" app's protected key.
|
||||
VITE_VK_APP_ID: ${{ vars.PROD_VITE_VK_APP_ID }}
|
||||
VITE_VK_ID_REDIRECT_URL: ${{ vars.PROD_VK_ID_REDIRECT_URL }}
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.PROD_GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||
# Transactional email via the shared Selectel relay (confirm-codes).
|
||||
@@ -167,6 +175,11 @@ jobs:
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
# VK ID web login: needed at runtime — the gateway's GATEWAY_VK_ID_* env resolves
|
||||
# its app id / redirect URL from these VITE_ values (one source, shared with the SPA).
|
||||
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
||||
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
||||
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
||||
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
||||
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
|
||||
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
|
||||
|
||||
@@ -355,3 +355,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
t.Errorf("email owner = %s, want durable", owner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
|
||||
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
|
||||
func TestAccountLinkFreeVK(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
links := newLinkService(&capturingMailer{})
|
||||
|
||||
guest := provisionGuest(t)
|
||||
vkID := "vk-" + uuid.NewString()
|
||||
res, err := links.ConfirmVK(ctx, guest, vkID)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm vk: %v", err)
|
||||
}
|
||||
if !res.Linked || res.MergeRequired {
|
||||
t.Fatalf("confirm = %+v, want linked", res)
|
||||
}
|
||||
if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
|
||||
t.Error("guest flag should clear once VK is linked")
|
||||
}
|
||||
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
|
||||
t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
|
||||
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
|
||||
// stays primary and keeps its session, and the VK identity repoints to the caller.
|
||||
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
links := newLinkService(&capturingMailer{})
|
||||
|
||||
caller := provisionAccount(t)
|
||||
other := provisionAccount(t)
|
||||
vkID := "vk-" + uuid.NewString()
|
||||
if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
|
||||
t.Fatalf("seed vk on other: %v", err)
|
||||
}
|
||||
|
||||
confirm, err := links.ConfirmVK(ctx, caller, vkID)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm vk: %v", err)
|
||||
}
|
||||
if !confirm.MergeRequired || confirm.SecondaryID != other {
|
||||
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
|
||||
}
|
||||
merge, err := links.MergeVK(ctx, caller, vkID)
|
||||
if err != nil {
|
||||
t.Fatalf("merge vk: %v", err)
|
||||
}
|
||||
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
|
||||
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
|
||||
}
|
||||
if mergedInto(t, other) != caller {
|
||||
t.Error("other should be tombstoned into caller")
|
||||
}
|
||||
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
|
||||
t.Errorf("vk owner = %s, want caller after merge", owner)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
|
||||
return s.accounts.ClearGuest(ctx, callerID)
|
||||
}
|
||||
|
||||
// ConfirmVK attaches a gateway-validated VK identity to the caller (Linked) or
|
||||
// reports that it belongs to another account (MergeRequired). The gateway has already
|
||||
// completed the VK ID code exchange, so externalID is the trusted vk user id.
|
||||
func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
|
||||
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
if err := s.attachVK(ctx, callerID, externalID); err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
if owner == callerID {
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
|
||||
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
|
||||
// (subject to the guest-primary rule).
|
||||
func (s *Service) MergeVK(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
|
||||
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
if err := s.attachVK(ctx, callerID, externalID); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
return MergeResult{PrimaryID: callerID}, nil
|
||||
}
|
||||
if owner == callerID {
|
||||
return MergeResult{PrimaryID: callerID}, nil
|
||||
}
|
||||
return s.merge(ctx, callerID, owner)
|
||||
}
|
||||
|
||||
// attachVK links the identity to the caller and promotes a guest.
|
||||
func (s *Service) attachVK(ctx context.Context, callerID uuid.UUID, externalID string) error {
|
||||
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindVK, externalID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accounts.ClearGuest(ctx, callerID)
|
||||
}
|
||||
|
||||
// merge decides the primary (the caller, unless it is a guest and the other is
|
||||
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
||||
// session when the active account switches.
|
||||
|
||||
@@ -74,6 +74,8 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
||||
u.POST("/link/telegram", s.handleLinkTelegram)
|
||||
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
||||
u.POST("/link/vk", s.handleLinkVK)
|
||||
u.POST("/link/vk/merge", s.handleLinkVKMerge)
|
||||
u.POST("/link/unlink", s.handleUnlink)
|
||||
// Change the account's confirmed email: mail a code to the new address, then
|
||||
// atomically switch on confirm (a new address owned by another account is
|
||||
|
||||
@@ -34,6 +34,12 @@ type linkTelegramBody struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
// linkVKBody carries a gateway-validated VK identity (the trusted vk user id the
|
||||
// gateway resolved from the VK ID code exchange).
|
||||
type linkVKBody struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
// linkResultResponse is the unified result of a confirm or merge step. Status is
|
||||
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
||||
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
||||
@@ -233,6 +239,48 @@ func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
||||
}
|
||||
|
||||
// handleLinkVK attaches a gateway-validated VK identity to the caller or reports a
|
||||
// required merge.
|
||||
func (s *Server) handleLinkVK(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkVKBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "missing external_id")
|
||||
return
|
||||
}
|
||||
res, err := s.links.ConfirmVK(c.Request.Context(), uid, req.ExternalID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
|
||||
}
|
||||
|
||||
// handleLinkVKMerge merges the account owning a gateway-validated VK identity into
|
||||
// the caller's.
|
||||
func (s *Server) handleLinkVKMerge(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
var req linkVKBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "missing external_id")
|
||||
return
|
||||
}
|
||||
res, err := s.links.MergeVK(c.Request.Context(), uid, req.ExternalID)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
||||
}
|
||||
|
||||
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
||||
// or a completed link (the active account's refreshed profile).
|
||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||
|
||||
@@ -68,6 +68,8 @@ VITE_TELEGRAM_BOT_ID=
|
||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri
|
||||
VITE_GATEWAY_URL=
|
||||
|
||||
# --- Grafana ----------------------------------------------------------------
|
||||
@@ -97,3 +99,7 @@ TELEGRAM_API_BASE_URL=
|
||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||
GATEWAY_VK_APP_SECRET=
|
||||
# VK ID web login (browser VK-identity linking) — a SEPARATE VK "Web" app: the gateway runs
|
||||
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
|
||||
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||
|
||||
+5
-2
@@ -95,6 +95,9 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
|
||||
| `VITE_VK_APP_ID` | variable | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. |
|
||||
| `VITE_VK_ID_REDIRECT_URL` | variable | _(empty)_ | VK ID trusted redirect URL (e.g. `https://erudit-game.ru/app/`) — must match the app's registered redirect exactly. Used by the SPA and as the gateway's exchange `redirect_uri`. |
|
||||
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). |
|
||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||
| `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||
| `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||
@@ -212,11 +215,11 @@ players arrive.
|
||||
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
||||
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
|
||||
SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables:
|
||||
SMTP_RELAY_USER, SMTP_RELAY_PASS, GATEWAY_VK_ID_CLIENT_SECRET}`; variables:
|
||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
||||
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK,
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK, VITE_VK_APP_ID, VITE_VK_ID_REDIRECT_URL,
|
||||
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL,
|
||||
SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
|
||||
GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`.
|
||||
|
||||
@@ -190,6 +190,8 @@ services:
|
||||
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: ${APP_VERSION:-dev}
|
||||
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
||||
@@ -207,6 +209,14 @@ services:
|
||||
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
||||
# the VK auth path (auth.vk is then unregistered).
|
||||
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
||||
# VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code
|
||||
# exchange runs server-side under the VK ID "Web" app's protected key. This is a
|
||||
# SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id
|
||||
# and redirect URL are the same values the SPA builds its authorize URL from (one
|
||||
# source each). All three empty disables the link.vk.* ops.
|
||||
GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-}
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-}
|
||||
GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
||||
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
||||
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
||||
@@ -264,6 +274,8 @@ services:
|
||||
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: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
|
||||
+10
-2
@@ -257,11 +257,19 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
control of the identity before attaching it: **email** through the confirm-code
|
||||
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
||||
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
||||
passes the trusted `external_id` to the backend, as for `auth.telegram`). The
|
||||
passes the trusted `external_id` to the backend, as for `auth.telegram`), and **VK**
|
||||
through **VK ID web login** (`gateway/internal/vkid`): the browser runs the VK ID raw
|
||||
OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no `@vkid/sdk` —
|
||||
and the gateway completes the **confidential code exchange** server-side under the VK
|
||||
"Web" app's protected key (a distinct VK app from the Mini App) to obtain the trusted
|
||||
`external_id`. A browser has no signed launch parameters, so unlike the VK Mini App
|
||||
(offline HMAC, §12) this makes an outbound call to `id.vk.com`, and it is web-only — a
|
||||
full-page redirect would strand a native Mini App webview. The
|
||||
request step **always** sends/accepts the proof (no pre-send "already taken"
|
||||
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
||||
is revealed **only after** the proof is verified and is performed behind an
|
||||
explicit, irreversible confirmation. A free identity is simply attached (and a
|
||||
explicit, irreversible confirmation (for VK, whose authorization code is single-use,
|
||||
the merge re-authorizes for a fresh code). A free identity is simply attached (and a
|
||||
guest is promoted to durable, clearing `is_guest`).
|
||||
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
|
||||
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
|
||||
|
||||
+4
-2
@@ -108,8 +108,10 @@ account is kept and the guest's games move into it. A merge is blocked only whil
|
||||
two accounts share a game still in progress.
|
||||
|
||||
The profile lists the account's **sign-in methods**. On the web a player can add
|
||||
Telegram; adding VK on the web is not offered yet, and inside a Mini App the host
|
||||
platform is already linked. A linked provider can be **unlinked** — except the last
|
||||
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||||
back); inside a Mini App the host platform is already linked. Linking a provider that
|
||||
already belongs to another account offers the same irreversible **merge** as email
|
||||
linking. A linked provider can be **unlinked** — except the last
|
||||
remaining sign-in method, which is refused so the account stays reachable. **Email is
|
||||
never unlinked; it is changed**: the player enters a new address, confirms a code
|
||||
mailed to it, and the account switches to it atomically, freeing the old address. A new
|
||||
|
||||
@@ -112,8 +112,10 @@ Telegram держит **единого бота**: все игроки поль
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||
Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин
|
||||
уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||
обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже
|
||||
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
||||
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
||||
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -501,6 +501,17 @@ table LinkTelegramRequest {
|
||||
data:string;
|
||||
}
|
||||
|
||||
// LinkVKRequest carries a VK ID web authorization for attaching a VK identity to the
|
||||
// current account. The fields are the PKCE code-exchange inputs the frontend obtains
|
||||
// from the VK ID SDK (One Tap): the authorization code, the device id issued with it,
|
||||
// and the PKCE code verifier. The gateway completes the confidential code exchange
|
||||
// server-side (under the app's protected key) to obtain the trusted vk user id.
|
||||
table LinkVKRequest {
|
||||
code:string;
|
||||
device_id:string;
|
||||
code_verifier:string;
|
||||
}
|
||||
|
||||
// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the
|
||||
// caller's account; email is never unlinked (it is changed).
|
||||
table LinkUnlinkRequest {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type LinkVKRequest struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsLinkVKRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkVKRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &LinkVKRequest{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishLinkVKRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsLinkVKRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkVKRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &LinkVKRequest{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedLinkVKRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *LinkVKRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *LinkVKRequest) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *LinkVKRequest) Code() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *LinkVKRequest) DeviceId() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *LinkVKRequest) CodeVerifier() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LinkVKRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
}
|
||||
func LinkVKRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0)
|
||||
}
|
||||
func LinkVKRequestAddDeviceId(builder *flatbuffers.Builder, deviceId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(deviceId), 0)
|
||||
}
|
||||
func LinkVKRequestAddCodeVerifier(builder *flatbuffers.Builder, codeVerifier flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(codeVerifier), 0)
|
||||
}
|
||||
func LinkVKRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export { LinkEmailRequest } from './scrabblefb/link-email-request.js';
|
||||
export { LinkResult } from './scrabblefb/link-result.js';
|
||||
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
|
||||
export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js';
|
||||
export { LinkVKRequest } from './scrabblefb/link-vkrequest.js';
|
||||
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
|
||||
export { MatchResult } from './scrabblefb/match-result.js';
|
||||
export { MoveRecord } from './scrabblefb/move-record.js';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class LinkVKRequest {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):LinkVKRequest {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsLinkVKRequest(bb:flatbuffers.ByteBuffer, obj?:LinkVKRequest):LinkVKRequest {
|
||||
return (obj || new LinkVKRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsLinkVKRequest(bb:flatbuffers.ByteBuffer, obj?:LinkVKRequest):LinkVKRequest {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new LinkVKRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
code():string|null
|
||||
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
code(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
deviceId():string|null
|
||||
deviceId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
deviceId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
codeVerifier():string|null
|
||||
codeVerifier(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
codeVerifier(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startLinkVKRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, codeOffset, 0);
|
||||
}
|
||||
|
||||
static addDeviceId(builder:flatbuffers.Builder, deviceIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, deviceIdOffset, 0);
|
||||
}
|
||||
|
||||
static addCodeVerifier(builder:flatbuffers.Builder, codeVerifierOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, codeVerifierOffset, 0);
|
||||
}
|
||||
|
||||
static endLinkVKRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createLinkVKRequest(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, deviceIdOffset:flatbuffers.Offset, codeVerifierOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
LinkVKRequest.startLinkVKRequest(builder);
|
||||
LinkVKRequest.addCode(builder, codeOffset);
|
||||
LinkVKRequest.addDeviceId(builder, deviceIdOffset);
|
||||
LinkVKRequest.addCodeVerifier(builder, codeVerifierOffset);
|
||||
return LinkVKRequest.endLinkVKRequest(builder);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
telegramCloudSet,
|
||||
} from './telegram';
|
||||
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
||||
import { pendingVKLink, type VKLinkCallback } from './vkid';
|
||||
import { haptic } from './haptics';
|
||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||
import { parseStartParam } from './deeplink';
|
||||
@@ -96,6 +97,10 @@ export const app = $state<{
|
||||
/** Set once the account has been deleted: App.svelte shows the terminal "account deleted"
|
||||
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
|
||||
accountDeleted: boolean;
|
||||
/** A VK ID web-link authorization callback captured on boot (see lib/vkid): the browser
|
||||
* returned from VK with an auth code. Profile consumes it on mount to finish the link or
|
||||
* merge (a full-page redirect loses the route, so boot routes here). Null on a normal load. */
|
||||
vkLinkPending: VKLinkCallback | null;
|
||||
toast: Toast | null;
|
||||
lastEvent: PushEvent | null;
|
||||
theme: ThemePref;
|
||||
@@ -145,6 +150,7 @@ export const app = $state<{
|
||||
profile: null,
|
||||
blocked: null,
|
||||
accountDeleted: false,
|
||||
vkLinkPending: null,
|
||||
toast: null,
|
||||
lastEvent: null,
|
||||
theme: 'auto',
|
||||
@@ -778,9 +784,19 @@ export async function bootstrap(): Promise<void> {
|
||||
}
|
||||
|
||||
const saved = await loadSession();
|
||||
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||
const vkcb = pendingVKLink();
|
||||
if (saved) {
|
||||
await adoptSession(saved);
|
||||
if (router.route.name === 'login') navigate('/');
|
||||
if (vkcb) {
|
||||
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
||||
// finishes the link or merge on mount.
|
||||
app.vkLinkPending = vkcb;
|
||||
navigate('/profile');
|
||||
} else if (router.route.name === 'login') {
|
||||
navigate('/');
|
||||
}
|
||||
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
@@ -177,6 +177,10 @@ export interface GatewayClient {
|
||||
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
||||
linkTelegram(data: string): Promise<LinkResult>;
|
||||
linkTelegramMerge(data: string): Promise<LinkResult>;
|
||||
/** Link a VK identity from the web: the args are the PKCE outputs of the VK ID
|
||||
* authorization callback, exchanged for the trusted vk id on the gateway. */
|
||||
linkVK(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
|
||||
linkVKMerge(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
|
||||
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
|
||||
* unlinked. Returns the refreshed link result (status 'unlinked'). */
|
||||
linkUnlink(kind: string): Promise<LinkResult>;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
decodeDeleteRequestResult,
|
||||
encodeGuestLogin,
|
||||
encodeLinkUnlink,
|
||||
encodeLinkVK,
|
||||
encodeStateRequest,
|
||||
encodeSubmitPlay,
|
||||
encodeTarget,
|
||||
@@ -466,6 +467,13 @@ describe('codec', () => {
|
||||
expect(r.kind()).toBe('telegram');
|
||||
});
|
||||
|
||||
it('encodes a VK link request carrying the PKCE code-exchange inputs', () => {
|
||||
const r = fb.LinkVKRequest.getRootAsLinkVKRequest(new ByteBuffer(encodeLinkVK('the-code', 'dev-1', 'verifier-1')));
|
||||
expect(r.code()).toBe('the-code');
|
||||
expect(r.deviceId()).toBe('dev-1');
|
||||
expect(r.codeVerifier()).toBe('verifier-1');
|
||||
});
|
||||
|
||||
it('round-trips the account-delete confirm + request-result wire', () => {
|
||||
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
|
||||
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
|
||||
|
||||
@@ -739,6 +739,18 @@ export function encodeLinkUnlink(kind: string): Uint8Array {
|
||||
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
|
||||
}
|
||||
|
||||
export function encodeLinkVK(code: string, deviceId: string, codeVerifier: string): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const c = b.createString(code);
|
||||
const d = b.createString(deviceId);
|
||||
const v = b.createString(codeVerifier);
|
||||
fb.LinkVKRequest.startLinkVKRequest(b);
|
||||
fb.LinkVKRequest.addCode(b, c);
|
||||
fb.LinkVKRequest.addDeviceId(b, d);
|
||||
fb.LinkVKRequest.addCodeVerifier(b, v);
|
||||
return finish(b, fb.LinkVKRequest.endLinkVKRequest(b));
|
||||
}
|
||||
|
||||
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const c = b.createString(code);
|
||||
|
||||
@@ -173,6 +173,7 @@ export const en = {
|
||||
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
||||
'profile.linkAccount': 'Link an account',
|
||||
'profile.linkTelegram': 'Link Telegram',
|
||||
'profile.linkVK': 'Link VK',
|
||||
'profile.linked': 'Account linked.',
|
||||
'profile.merged': 'Accounts merged.',
|
||||
'profile.mergeTitle': 'Merge accounts?',
|
||||
|
||||
@@ -173,6 +173,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
||||
'profile.linkAccount': 'Привязать аккаунт',
|
||||
'profile.linkTelegram': 'Привязать Telegram',
|
||||
'profile.linkVK': 'Привязать VK',
|
||||
'profile.linked': 'Аккаунт привязан.',
|
||||
'profile.merged': 'Аккаунты объединены.',
|
||||
'profile.mergeTitle': 'Объединить аккаунты?',
|
||||
|
||||
@@ -674,6 +674,16 @@ export class MockGateway implements GatewayClient {
|
||||
this.profile.telegramLinked = true;
|
||||
return { ...emptyLinked(), status: 'merged' };
|
||||
}
|
||||
async linkVK(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
|
||||
this.profile.isGuest = false;
|
||||
this.profile.vkLinked = true;
|
||||
return emptyLinked();
|
||||
}
|
||||
async linkVKMerge(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
|
||||
this.profile.isGuest = false;
|
||||
this.profile.vkLinked = true;
|
||||
return { ...emptyLinked(), status: 'merged' };
|
||||
}
|
||||
async linkUnlink(kind: string): Promise<LinkResult> {
|
||||
if (kind === 'telegram') this.profile.telegramLinked = false;
|
||||
if (kind === 'vk') this.profile.vkLinked = false;
|
||||
|
||||
@@ -268,6 +268,12 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async linkTelegramMerge(data) {
|
||||
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
|
||||
},
|
||||
async linkVK(code, deviceId, codeVerifier) {
|
||||
return codec.decodeLinkResult(await exec('link.vk.confirm', codec.encodeLinkVK(code, deviceId, codeVerifier)));
|
||||
},
|
||||
async linkVKMerge(code, deviceId, codeVerifier) {
|
||||
return codec.decodeLinkResult(await exec('link.vk.merge', codec.encodeLinkVK(code, deviceId, codeVerifier)));
|
||||
},
|
||||
async linkUnlink(kind) {
|
||||
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind)));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// VK ID web login for linking a VK identity to the current account from a browser.
|
||||
// Unlike the VK Mini App (whose signed launch params authenticate offline), a browser
|
||||
// has no launch signature, so it runs the VK ID raw OAuth 2.1 flow (PKCE, no @vkid/sdk):
|
||||
// a full-page redirect to VK's hosted login, then a callback to our redirect URL carrying
|
||||
// the authorization code. The gateway completes the confidential code exchange. This is
|
||||
// web-only — a full-page redirect would strand a native Mini App webview, where the
|
||||
// platform identity is already linked anyway.
|
||||
|
||||
import { insideTelegram } from './telegram';
|
||||
import { insideVK } from './vk';
|
||||
|
||||
function isMock(): boolean {
|
||||
return import.meta.env.MODE === 'mock';
|
||||
}
|
||||
|
||||
function vkAppId(): string {
|
||||
return ((import.meta.env.VITE_VK_APP_ID as string | undefined) ?? '').trim();
|
||||
}
|
||||
|
||||
function vkRedirectUrl(): string {
|
||||
return ((import.meta.env.VITE_VK_ID_REDIRECT_URL as string | undefined) ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* vkWebLinkAvailable reports whether the "Link VK" control should be shown: on the plain
|
||||
* web (not inside a Telegram/VK Mini App, where a redirect would break the webview) and
|
||||
* either the mock build or a configured VK ID app id and redirect URL.
|
||||
*/
|
||||
export function vkWebLinkAvailable(): boolean {
|
||||
if (insideTelegram() || insideVK()) return false;
|
||||
if (isMock()) return true;
|
||||
return vkAppId() !== '' && vkRedirectUrl() !== '';
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'vkid.link';
|
||||
|
||||
// PendingState is stashed before the redirect and consumed on the callback: the PKCE
|
||||
// verifier (needed for the gateway exchange), the CSRF state, and whether this is an
|
||||
// initial link or the re-authorization for a merge (VK codes are single-use, so a merge
|
||||
// cannot reuse the link's code — it re-authorizes for a fresh one).
|
||||
type PendingState = { verifier: string; state: string; mode: 'link' | 'merge' };
|
||||
|
||||
/** VKLinkCallback carries a verified VK ID authorization callback for the gateway exchange. */
|
||||
export type VKLinkCallback = { code: string; deviceId: string; verifier: string; mode: 'link' | 'merge' };
|
||||
|
||||
// b64url encodes bytes as base64url without padding (the PKCE / VK ID alphabet).
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function randomToken(bytes: number): string {
|
||||
const a = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(a);
|
||||
return b64url(a);
|
||||
}
|
||||
|
||||
async function challengeFrom(verifier: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
||||
return b64url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/**
|
||||
* startVKLink begins VK ID web authorization by redirecting the whole tab to VK's hosted
|
||||
* login. The PKCE verifier and CSRF state survive the redirect in sessionStorage; VK
|
||||
* returns to VITE_VK_ID_REDIRECT_URL with ?code&device_id&state, processed on the next
|
||||
* boot via pendingVKLink. mode distinguishes an initial link from a merge re-auth. The
|
||||
* mock build never leaves the SPA.
|
||||
*/
|
||||
export async function startVKLink(mode: 'link' | 'merge'): Promise<void> {
|
||||
if (isMock()) return;
|
||||
const verifier = randomToken(48); // 64 base64url chars (VK ID requires 43..128)
|
||||
const state = randomToken(24); // 32 base64url chars (VK ID requires >= 32)
|
||||
const challenge = await challengeFrom(verifier);
|
||||
const pending: PendingState = { verifier, state, mode };
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(pending));
|
||||
const url = new URL('https://id.vk.com/authorize');
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', vkAppId());
|
||||
url.searchParams.set('redirect_uri', vkRedirectUrl());
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
url.searchParams.set('scope', 'vkid.personal_info');
|
||||
location.assign(url.toString());
|
||||
}
|
||||
|
||||
// stripCallbackQuery removes the OAuth query params from the URL so a refresh does not
|
||||
// re-process the callback, keeping the path and any hash route intact.
|
||||
function stripCallbackQuery(): void {
|
||||
if (typeof history !== 'undefined' && history.replaceState) {
|
||||
history.replaceState(null, '', location.pathname + location.hash);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pendingVKLink extracts and clears a VK ID authorization callback from the current URL,
|
||||
* verifying the returned state against the one stored before the redirect (CSRF). It
|
||||
* returns null on a normal load or when the state does not match, and always strips the
|
||||
* query so a refresh does not re-run it.
|
||||
*/
|
||||
export function pendingVKLink(): VKLinkCallback | null {
|
||||
if (typeof location === 'undefined') return null;
|
||||
const q = new URLSearchParams(location.search);
|
||||
const code = q.get('code');
|
||||
const deviceId = q.get('device_id');
|
||||
const returnedState = q.get('state');
|
||||
if (!code || !deviceId || !returnedState) return null;
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
stripCallbackQuery();
|
||||
if (!raw) return null;
|
||||
let pending: PendingState;
|
||||
try {
|
||||
pending = JSON.parse(raw) as PendingState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (pending.state !== returnedState) return null;
|
||||
return { code, deviceId, verifier: pending.verifier, mode: pending.mode };
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
||||
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import {
|
||||
awayDurationOk,
|
||||
@@ -45,7 +46,7 @@
|
||||
let emailSent = $state(false);
|
||||
// A pending irreversible merge surfaced after the code/widget was verified; the
|
||||
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
|
||||
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null);
|
||||
let pendingMerge = $state<null | { kind: 'email' | 'telegram' | 'vk'; name: string; games: number; friends: number }>(null);
|
||||
let tgData = '';
|
||||
// The change-email sub-form is open (a new address is being entered/confirmed).
|
||||
let changingEmail = $state(false);
|
||||
@@ -56,6 +57,7 @@
|
||||
let deleteCode = $state('');
|
||||
let deletePhrase = $state('');
|
||||
const telegramLinkable = loginWidgetAvailable();
|
||||
const vkLinkable = vkWebLinkAvailable();
|
||||
|
||||
function defaultTz(): string {
|
||||
const b = browserOffset();
|
||||
@@ -82,7 +84,12 @@
|
||||
notificationsInAppOnly = p.notificationsInAppOnly;
|
||||
variantPrefs = [...p.variantPreferences];
|
||||
}
|
||||
onMount(populate);
|
||||
onMount(() => {
|
||||
populate();
|
||||
// A VK ID web-link callback captured on boot (app.vkLinkPending) is finished here, since
|
||||
// the redirect back from VK lost the in-app route and lands the app on a fresh mount.
|
||||
if (app.vkLinkPending) void processVKLink();
|
||||
});
|
||||
|
||||
const awayStart = $derived(`${startH}:${startM}`);
|
||||
const awayEnd = $derived(`${endH}:${endM}`);
|
||||
@@ -180,8 +187,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
// addVK starts VK ID web login for linking: it redirects the whole tab to VK's hosted login,
|
||||
// returning to the app with an auth code that boot hands back to processVKLink.
|
||||
function addVK() {
|
||||
void startVKLink('link');
|
||||
}
|
||||
|
||||
// processVKLink finishes a VK ID web-link callback captured on boot: it exchanges the code via
|
||||
// the gateway and either completes the link or opens the merge dialog. VK's authorization code
|
||||
// is single-use, so a required merge re-authorizes for a fresh code (see confirmMerge); that
|
||||
// returning 'merge' callback lands back here and completes.
|
||||
async function processVKLink() {
|
||||
const cb = app.vkLinkPending;
|
||||
app.vkLinkPending = null;
|
||||
if (!cb) return;
|
||||
try {
|
||||
if (cb.mode === 'merge') {
|
||||
await applyLinkResult(await gateway.linkVKMerge(cb.code, cb.deviceId, cb.verifier));
|
||||
populate();
|
||||
showToast(t('profile.merged'));
|
||||
return;
|
||||
}
|
||||
const r = await gateway.linkVK(cb.code, cb.deviceId, cb.verifier);
|
||||
if (r.status === 'merge_required') {
|
||||
pendingMerge = { kind: 'vk', name: r.secondaryDisplayName, games: r.secondaryGames, friends: r.secondaryFriends };
|
||||
return;
|
||||
}
|
||||
await applyLinkResult(r);
|
||||
populate();
|
||||
showToast(t('profile.linked'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMerge() {
|
||||
if (!pendingMerge) return;
|
||||
// A VK merge cannot reuse VK's single-use authorization code, so it re-authorizes for a fresh
|
||||
// one; the returning callback completes it (processVKLink). Email/Telegram re-send their proof.
|
||||
if (pendingMerge.kind === 'vk') {
|
||||
void startVKLink('merge');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r =
|
||||
pendingMerge.kind === 'email'
|
||||
@@ -344,7 +391,7 @@
|
||||
|
||||
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
|
||||
(add) triggers the merge dialog below. On the web an account can add Telegram via the
|
||||
login widget; adding VK on the web is deferred (no VK OAuth) and inside a Mini App the
|
||||
login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the
|
||||
host provider is already linked. Email is never unlinked — it is changed. Unlink is
|
||||
offered only when another identity remains (the backend also refuses the last one). -->
|
||||
<section class="accounts">
|
||||
@@ -413,6 +460,10 @@
|
||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if vkLinkable}
|
||||
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
|
||||
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user