feat(account): VK ID web login to link a VK identity from a browser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.
- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
transcode link.vk.confirm/merge (registered only when configured) + config
GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
boot callback handling; a merge re-authorizes for a fresh code (VK codes are
single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
This commit is contained in:
@@ -330,6 +330,10 @@ jobs:
|
|||||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
# 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.
|
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_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).
|
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay. Empty host leaves the
|
# 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_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
|
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 }}
|
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ jobs:
|
|||||||
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
|
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 }}
|
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
||||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
@@ -84,6 +87,11 @@ jobs:
|
|||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
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).
|
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay (confirm-codes).
|
# Transactional email via the shared Selectel relay (confirm-codes).
|
||||||
@@ -167,6 +175,11 @@ jobs:
|
|||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
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 EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
||||||
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
|
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
|
||||||
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
|
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
|
||||||
|
|||||||
@@ -355,3 +355,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
|||||||
t.Errorf("email owner = %s, want durable", owner)
|
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)
|
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
|
// 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
|
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
||||||
// session when the active account switches.
|
// session when the active account switches.
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
||||||
u.POST("/link/telegram", s.handleLinkTelegram)
|
u.POST("/link/telegram", s.handleLinkTelegram)
|
||||||
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
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)
|
u.POST("/link/unlink", s.handleUnlink)
|
||||||
// Change the account's confirmed email: mail a code to the new address, then
|
// 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
|
// atomically switch on confirm (a new address owned by another account is
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ type linkTelegramBody struct {
|
|||||||
ExternalID string `json:"external_id"`
|
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
|
// 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
|
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
||||||
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
// 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))
|
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)
|
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
||||||
// or a completed link (the active account's refreshed profile).
|
// or a completed link (the active account's refreshed profile).
|
||||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
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_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_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_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=
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
# --- Grafana ----------------------------------------------------------------
|
# --- Grafana ----------------------------------------------------------------
|
||||||
@@ -97,3 +99,7 @@ TELEGRAM_API_BASE_URL=
|
|||||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
# 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.
|
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||||
GATEWAY_VK_APP_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_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_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_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). |
|
| `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_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). |
|
| `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,
|
`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,
|
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,
|
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,
|
`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,
|
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,
|
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_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,
|
SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
|
||||||
GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`.
|
GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`.
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ services:
|
|||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
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_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
# 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
|
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
||||||
# the VK auth path (auth.vk is then unregistered).
|
# the VK auth path (auth.vk is then unregistered).
|
||||||
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
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
|
# 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
|
# 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
|
# 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_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
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_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
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
|
control of the identity before attaching it: **email** through the confirm-code
|
||||||
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
||||||
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
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"
|
request step **always** sends/accepts the proof (no pre-send "already taken"
|
||||||
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
||||||
is revealed **only after** the proof is verified and is performed behind an
|
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`).
|
guest is promoted to durable, clearing `is_guest`).
|
||||||
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
|
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
|
||||||
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
|
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.
|
two accounts share a game still in progress.
|
||||||
|
|
||||||
The profile lists the account's **sign-in methods**. On the web a player can add
|
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
|
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||||||
platform is already linked. A linked provider can be **unlinked** — except the last
|
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
|
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
|
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
|
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 не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
||||||
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ ARG VITE_TELEGRAM_BOT_ID=
|
|||||||
ARG VITE_TELEGRAM_LINK=
|
ARG VITE_TELEGRAM_LINK=
|
||||||
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
|
||||||
ARG VITE_VK_APP_LINK=
|
ARG VITE_VK_APP_LINK=
|
||||||
|
ARG VITE_VK_APP_ID=
|
||||||
|
ARG VITE_VK_ID_REDIRECT_URL=
|
||||||
ARG VITE_GATEWAY_URL=
|
ARG VITE_GATEWAY_URL=
|
||||||
ARG VITE_APP_VERSION=
|
ARG VITE_APP_VERSION=
|
||||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
||||||
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
|
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_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
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
|
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
||||||
(`auth.vk` is unregistered).
|
(`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`,
|
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
||||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
`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;
|
`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`,
|
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
||||||
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
|
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
|
||||||
transcode pattern (`transcode_social.go`). Account linking & merge
|
transcode pattern (`transcode_social.go`). Account linking & merge
|
||||||
— `link.email.request/confirm/merge` and `link.telegram.confirm/merge`
|
— `link.email.request/confirm/merge`, `link.telegram.confirm/merge` and
|
||||||
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
|
`link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login
|
||||||
validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
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.
|
**superseded** the former `email.bind.*` ops, which were removed.
|
||||||
|
|
||||||
## Configuration
|
## 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_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_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_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_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_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) |
|
| `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/ratelimit"
|
||||||
"scrabble/gateway/internal/session"
|
"scrabble/gateway/internal/session"
|
||||||
"scrabble/gateway/internal/transcode"
|
"scrabble/gateway/internal/transcode"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
"scrabble/pkg/mtls"
|
"scrabble/pkg/mtls"
|
||||||
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
||||||
telegramv1 "scrabble/pkg/proto/telegram/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)")
|
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{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
|||||||
@@ -335,6 +335,24 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin
|
|||||||
return out, err
|
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
|
// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an
|
||||||
// authenticated email change.
|
// authenticated email change.
|
||||||
func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error {
|
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
|
// 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).
|
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
||||||
VKAppSecret string
|
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
|
// 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).
|
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||||
BotLink BotLinkConfig
|
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
|
// Load reads the configuration from the environment, applies defaults, and
|
||||||
// validates the result.
|
// validates the result.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -174,6 +195,11 @@ func Load() (Config, error) {
|
|||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
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,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
Abuse: DefaultAbuse(),
|
Abuse: DefaultAbuse(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
"scrabble/gateway/internal/connector"
|
"scrabble/gateway/internal/connector"
|
||||||
"scrabble/gateway/internal/vkauth"
|
"scrabble/gateway/internal/vkauth"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
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.
|
// Lookup returns the operation for messageType, and whether it is registered.
|
||||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||||
op, ok := r.ops[messageType]
|
op, ok := r.ops[messageType]
|
||||||
@@ -172,6 +182,9 @@ func DomainCode(err error) (string, bool) {
|
|||||||
if errors.Is(err, vkauth.ErrInvalid) {
|
if errors.Is(err, vkauth.ErrInvalid) {
|
||||||
return "invalid_vk_params", true
|
return "invalid_vk_params", true
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, vkid.ErrInvalid) {
|
||||||
|
return "invalid_vk_id", true
|
||||||
|
}
|
||||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||||
return "invalid_login_widget", true
|
return "invalid_login_widget", true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ const (
|
|||||||
MsgLinkEmailMerge = "link.email.merge"
|
MsgLinkEmailMerge = "link.email.merge"
|
||||||
MsgLinkTelegram = "link.telegram.confirm"
|
MsgLinkTelegram = "link.telegram.confirm"
|
||||||
MsgLinkTelegramMerge = "link.telegram.merge"
|
MsgLinkTelegramMerge = "link.telegram.merge"
|
||||||
|
MsgLinkVK = "link.vk.confirm"
|
||||||
|
MsgLinkVKMerge = "link.vk.merge"
|
||||||
MsgLinkUnlink = "link.unlink"
|
MsgLinkUnlink = "link.unlink"
|
||||||
MsgEmailChangeRequest = "link.email.change.request"
|
MsgEmailChangeRequest = "link.email.change.request"
|
||||||
MsgEmailChangeConfirm = "link.email.change.confirm"
|
MsgEmailChangeConfirm = "link.email.change.confirm"
|
||||||
@@ -154,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
|
|||||||
return encodeLinkResult(res), nil
|
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;
|
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
|
// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the
|
||||||
// caller's account; email is never unlinked (it is changed).
|
// caller's account; email is never unlinked (it is changed).
|
||||||
table LinkUnlinkRequest {
|
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 { LinkResult } from './scrabblefb/link-result.js';
|
||||||
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
|
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
|
||||||
export { LinkUnlinkRequest } from './scrabblefb/link-unlink-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 { MatchFoundEvent } from './scrabblefb/match-found-event.js';
|
||||||
export { MatchResult } from './scrabblefb/match-result.js';
|
export { MatchResult } from './scrabblefb/match-result.js';
|
||||||
export { MoveRecord } from './scrabblefb/move-record.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,
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
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 { haptic } from './haptics';
|
||||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
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"
|
/** 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. */
|
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
|
||||||
accountDeleted: boolean;
|
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;
|
toast: Toast | null;
|
||||||
lastEvent: PushEvent | null;
|
lastEvent: PushEvent | null;
|
||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
@@ -145,6 +150,7 @@ export const app = $state<{
|
|||||||
profile: null,
|
profile: null,
|
||||||
blocked: null,
|
blocked: null,
|
||||||
accountDeleted: false,
|
accountDeleted: false,
|
||||||
|
vkLinkPending: null,
|
||||||
toast: null,
|
toast: null,
|
||||||
lastEvent: null,
|
lastEvent: null,
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
@@ -778,9 +784,19 @@ export async function bootstrap(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saved = await loadSession();
|
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) {
|
if (saved) {
|
||||||
await adoptSession(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') {
|
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ export interface GatewayClient {
|
|||||||
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
||||||
linkTelegram(data: string): Promise<LinkResult>;
|
linkTelegram(data: string): Promise<LinkResult>;
|
||||||
linkTelegramMerge(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
|
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
|
||||||
* unlinked. Returns the refreshed link result (status 'unlinked'). */
|
* unlinked. Returns the refreshed link result (status 'unlinked'). */
|
||||||
linkUnlink(kind: string): Promise<LinkResult>;
|
linkUnlink(kind: string): Promise<LinkResult>;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
decodeDeleteRequestResult,
|
decodeDeleteRequestResult,
|
||||||
encodeGuestLogin,
|
encodeGuestLogin,
|
||||||
encodeLinkUnlink,
|
encodeLinkUnlink,
|
||||||
|
encodeLinkVK,
|
||||||
encodeStateRequest,
|
encodeStateRequest,
|
||||||
encodeSubmitPlay,
|
encodeSubmitPlay,
|
||||||
encodeTarget,
|
encodeTarget,
|
||||||
@@ -466,6 +467,13 @@ describe('codec', () => {
|
|||||||
expect(r.kind()).toBe('telegram');
|
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', () => {
|
it('round-trips the account-delete confirm + request-result wire', () => {
|
||||||
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
|
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
|
||||||
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
|
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
|
||||||
|
|||||||
@@ -739,6 +739,18 @@ export function encodeLinkUnlink(kind: string): Uint8Array {
|
|||||||
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
|
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 {
|
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const c = b.createString(code);
|
const c = b.createString(code);
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export const en = {
|
|||||||
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
||||||
'profile.linkAccount': 'Link an account',
|
'profile.linkAccount': 'Link an account',
|
||||||
'profile.linkTelegram': 'Link Telegram',
|
'profile.linkTelegram': 'Link Telegram',
|
||||||
|
'profile.linkVK': 'Link VK',
|
||||||
'profile.linked': 'Account linked.',
|
'profile.linked': 'Account linked.',
|
||||||
'profile.merged': 'Accounts merged.',
|
'profile.merged': 'Accounts merged.',
|
||||||
'profile.mergeTitle': 'Merge accounts?',
|
'profile.mergeTitle': 'Merge accounts?',
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
||||||
'profile.linkAccount': 'Привязать аккаунт',
|
'profile.linkAccount': 'Привязать аккаунт',
|
||||||
'profile.linkTelegram': 'Привязать Telegram',
|
'profile.linkTelegram': 'Привязать Telegram',
|
||||||
|
'profile.linkVK': 'Привязать VK',
|
||||||
'profile.linked': 'Аккаунт привязан.',
|
'profile.linked': 'Аккаунт привязан.',
|
||||||
'profile.merged': 'Аккаунты объединены.',
|
'profile.merged': 'Аккаунты объединены.',
|
||||||
'profile.mergeTitle': 'Объединить аккаунты?',
|
'profile.mergeTitle': 'Объединить аккаунты?',
|
||||||
|
|||||||
@@ -674,6 +674,16 @@ export class MockGateway implements GatewayClient {
|
|||||||
this.profile.telegramLinked = true;
|
this.profile.telegramLinked = true;
|
||||||
return { ...emptyLinked(), status: 'merged' };
|
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> {
|
async linkUnlink(kind: string): Promise<LinkResult> {
|
||||||
if (kind === 'telegram') this.profile.telegramLinked = false;
|
if (kind === 'telegram') this.profile.telegramLinked = false;
|
||||||
if (kind === 'vk') this.profile.vkLinked = false;
|
if (kind === 'vk') this.profile.vkLinked = false;
|
||||||
|
|||||||
@@ -268,6 +268,12 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async linkTelegramMerge(data) {
|
async linkTelegramMerge(data) {
|
||||||
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(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) {
|
async linkUnlink(kind) {
|
||||||
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(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 { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
||||||
|
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import {
|
import {
|
||||||
awayDurationOk,
|
awayDurationOk,
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
let emailSent = $state(false);
|
let emailSent = $state(false);
|
||||||
// A pending irreversible merge surfaced after the code/widget was verified; the
|
// 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.
|
// 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 = '';
|
let tgData = '';
|
||||||
// The change-email sub-form is open (a new address is being entered/confirmed).
|
// The change-email sub-form is open (a new address is being entered/confirmed).
|
||||||
let changingEmail = $state(false);
|
let changingEmail = $state(false);
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
let deleteCode = $state('');
|
let deleteCode = $state('');
|
||||||
let deletePhrase = $state('');
|
let deletePhrase = $state('');
|
||||||
const telegramLinkable = loginWidgetAvailable();
|
const telegramLinkable = loginWidgetAvailable();
|
||||||
|
const vkLinkable = vkWebLinkAvailable();
|
||||||
|
|
||||||
function defaultTz(): string {
|
function defaultTz(): string {
|
||||||
const b = browserOffset();
|
const b = browserOffset();
|
||||||
@@ -82,7 +84,12 @@
|
|||||||
notificationsInAppOnly = p.notificationsInAppOnly;
|
notificationsInAppOnly = p.notificationsInAppOnly;
|
||||||
variantPrefs = [...p.variantPreferences];
|
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 awayStart = $derived(`${startH}:${startM}`);
|
||||||
const awayEnd = $derived(`${endH}:${endM}`);
|
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() {
|
async function confirmMerge() {
|
||||||
if (!pendingMerge) return;
|
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 {
|
try {
|
||||||
const r =
|
const r =
|
||||||
pendingMerge.kind === 'email'
|
pendingMerge.kind === 'email'
|
||||||
@@ -344,7 +391,7 @@
|
|||||||
|
|
||||||
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
|
<!-- 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
|
(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
|
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). -->
|
offered only when another identity remains (the backend also refuses the last one). -->
|
||||||
<section class="accounts">
|
<section class="accounts">
|
||||||
@@ -413,6 +460,10 @@
|
|||||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user