feat: sync Telegram Login OIDC provider

This commit is contained in:
A 2026-07-21 15:46:24 +08:00
parent 30774f8c39
commit ebead9e98c
63 changed files with 11374 additions and 37 deletions

View file

@ -75,6 +75,9 @@ func botFatherSeedProfile() domain.BotProfile {
{Command: "mybots", Description: "list your bots"},
{Command: "token", Description: "show a bot's token"},
{Command: "revoke", Description: "revoke a bot's token"},
{Command: "setlogin", Description: "configure Telegram Login"},
{Command: "logininfo", Description: "show Telegram Login configuration"},
{Command: "resetloginsecret", Description: "rotate an OIDC Client Secret"},
{Command: "cancel", Description: "cancel the current operation"},
{Command: "help", Description: "show help"},
},

View file

@ -0,0 +1,721 @@
package memory
import (
"context"
"sort"
"strconv"
"sync"
"time"
"telesrv/internal/domain"
)
type telegramLoginBotPermissionWriter interface {
AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error)
}
// TelegramLoginStore is the deterministic in-memory implementation used by
// application and RPC tests. A single mutex makes the same aggregate changes
// atomic; production uses PostgreSQL row locks and one transaction.
type TelegramLoginStore struct {
mu sync.RWMutex
permissions telegramLoginBotPermissionWriter
nextURLID int64
nextAppID int64
nextRequestID int64
nextCodeID int64
clientsByID map[string]domain.TelegramLoginClient
clientByBot map[int64]string
allowedURLs map[string]domain.TelegramLoginAllowedURL
nativeApps map[int64]domain.TelegramLoginNativeApp
requests map[int64]domain.TelegramLoginRequest
requestToken map[string]int64
browserToken map[string]int64
codes map[int64]domain.TelegramLoginAuthorizationCode
codeByHash map[string]int64
codeByRequest map[int64]int64
webAuths map[int64]domain.TelegramLoginWebAuthorization
}
func NewTelegramLoginStore(permissions telegramLoginBotPermissionWriter) *TelegramLoginStore {
return &TelegramLoginStore{
permissions: permissions,
clientsByID: make(map[string]domain.TelegramLoginClient),
clientByBot: make(map[int64]string),
allowedURLs: make(map[string]domain.TelegramLoginAllowedURL),
nativeApps: make(map[int64]domain.TelegramLoginNativeApp),
requests: make(map[int64]domain.TelegramLoginRequest),
requestToken: make(map[string]int64),
browserToken: make(map[string]int64),
codes: make(map[int64]domain.TelegramLoginAuthorizationCode),
codeByHash: make(map[string]int64),
codeByRequest: make(map[int64]int64),
webAuths: make(map[int64]domain.TelegramLoginWebAuthorization),
}
}
func (s *TelegramLoginStore) CreateTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
if err := client.Validate(); err != nil {
return domain.TelegramLoginClient{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.clientByBot[client.BotUserID]; exists {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
}
if _, exists := s.clientsByID[client.ClientID]; exists {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
}
s.clientsByID[client.ClientID] = client.Clone()
s.clientByBot[client.BotUserID] = client.ClientID
return client.Clone(), nil
}
func (s *TelegramLoginStore) UpsertTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
if err := client.Validate(); err != nil {
return domain.TelegramLoginClient{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if existingID, exists := s.clientByBot[client.BotUserID]; exists && existingID != client.ClientID {
delete(s.clientsByID, existingID)
}
if existing, exists := s.clientsByID[client.ClientID]; exists && existing.BotUserID != client.BotUserID {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
s.clientsByID[client.ClientID] = client.Clone()
s.clientByBot[client.BotUserID] = client.ClientID
return client.Clone(), nil
}
func (s *TelegramLoginStore) GetTelegramLoginClient(_ context.Context, clientID string) (domain.TelegramLoginClient, bool, error) {
s.mu.RLock()
client, ok := s.clientsByID[clientID]
s.mu.RUnlock()
return client.Clone(), ok, nil
}
func (s *TelegramLoginStore) GetTelegramLoginClientByBot(_ context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error) {
s.mu.RLock()
clientID, ok := s.clientByBot[botUserID]
client := s.clientsByID[clientID]
s.mu.RUnlock()
return client.Clone(), ok, nil
}
func (s *TelegramLoginStore) RotateTelegramLoginClientSecret(_ context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error) {
if botUserID <= 0 || expectedVersion <= 0 || len(secretHash) != 32 {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
clientID, ok := s.clientByBot[botUserID]
if !ok {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
client := s.clientsByID[clientID]
if client.SecretVersion != expectedVersion {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
}
client.SecretVersion++
client.SecretHash = append([]byte(nil), secretHash...)
client.UpdatedAt = now
s.clientsByID[clientID] = client
return client.Clone(), nil
}
func (s *TelegramLoginStore) SetTelegramLoginClientSigningAlgorithm(_ context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error) {
if botUserID <= 0 || !algorithm.Valid() {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
clientID, ok := s.clientByBot[botUserID]
if !ok {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
client := s.clientsByID[clientID]
client.SigningAlgorithm = algorithm
client.UpdatedAt = now
s.clientsByID[clientID] = client
return client.Clone(), nil
}
func (s *TelegramLoginStore) SetTelegramLoginClientEnabled(_ context.Context, botUserID int64, enabled bool, now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
clientID, ok := s.clientByBot[botUserID]
if !ok {
return domain.ErrTelegramLoginClientInvalid
}
client := s.clientsByID[clientID]
client.Enabled = enabled
client.UpdatedAt = now
s.clientsByID[clientID] = client
return nil
}
func telegramLoginAllowedURLKey(botUserID int64, kind domain.TelegramLoginAllowedURLKind, value string) string {
return strconv.FormatInt(botUserID, 10) + "\x00" + string(kind) + "\x00" + value
}
func (s *TelegramLoginStore) AddTelegramLoginAllowedURL(_ context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error) {
if allowed.BotUserID <= 0 || allowed.NormalizedURL == "" || (allowed.Kind != domain.TelegramLoginAllowedWebOrigin && allowed.Kind != domain.TelegramLoginAllowedRedirectURI) {
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginURLInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.clientByBot[allowed.BotUserID]; !ok {
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginClientInvalid
}
key := telegramLoginAllowedURLKey(allowed.BotUserID, allowed.Kind, allowed.NormalizedURL)
if existing, ok := s.allowedURLs[key]; ok {
return existing, nil
}
s.nextURLID++
allowed.ID = s.nextURLID
s.allowedURLs[key] = allowed
return allowed, nil
}
func (s *TelegramLoginStore) DeleteTelegramLoginAllowedURL(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)
if _, ok := s.allowedURLs[key]; !ok {
return false, nil
}
delete(s.allowedURLs, key)
return true, nil
}
func (s *TelegramLoginStore) ListTelegramLoginAllowedURLs(_ context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error) {
s.mu.RLock()
out := make([]domain.TelegramLoginAllowedURL, 0)
for _, allowed := range s.allowedURLs {
if allowed.BotUserID == botUserID {
out = append(out, allowed)
}
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func (s *TelegramLoginStore) IsTelegramLoginURLAllowed(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
s.mu.RLock()
_, ok := s.allowedURLs[telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)]
s.mu.RUnlock()
return ok, nil
}
func (s *TelegramLoginStore) UpsertTelegramLoginNativeApp(_ context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error) {
if err := app.Validate(); err != nil {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.clientByBot[app.BotUserID]; !ok {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
if app.ID == 0 {
for id, existing := range s.nativeApps {
if existing.BotUserID == app.BotUserID && existing.Platform == app.Platform && existing.ApplicationID == app.ApplicationID && existing.VerificationID == app.VerificationID {
app.ID, app.CreatedAt = id, existing.CreatedAt
s.nativeApps[id] = app
return app, nil
}
if existing.BotUserID == app.BotUserID && existing.CallbackURI == app.CallbackURI {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestConflict
}
}
count := 0
for _, existing := range s.nativeApps {
if existing.BotUserID == app.BotUserID {
count++
}
}
if count >= domain.MaxTelegramLoginNativeApps {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestInvalid
}
s.nextAppID++
app.ID = s.nextAppID
} else if existing, ok := s.nativeApps[app.ID]; ok && existing.BotUserID != app.BotUserID {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
s.nativeApps[app.ID] = app
return app, nil
}
func (s *TelegramLoginStore) DeleteTelegramLoginNativeApp(_ context.Context, botUserID, appID int64) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
app, ok := s.nativeApps[appID]
if !ok || app.BotUserID != botUserID {
return false, nil
}
delete(s.nativeApps, appID)
return true, nil
}
func (s *TelegramLoginStore) ListTelegramLoginNativeApps(_ context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error) {
s.mu.RLock()
out := make([]domain.TelegramLoginNativeApp, 0)
for _, app := range s.nativeApps {
if app.BotUserID == botUserID {
out = append(out, app)
}
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
if len(out) > domain.MaxTelegramLoginNativeApps {
out = out[:domain.MaxTelegramLoginNativeApps]
}
return out, nil
}
func (s *TelegramLoginStore) CreateTelegramLoginRequest(_ context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error) {
if err := request.Validate(); err != nil {
return domain.TelegramLoginRequest{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
client, ok := s.clientsByID[request.ClientID]
if !ok || client.BotUserID != request.BotUserID || !client.Enabled || client.SigningAlgorithm != request.SigningAlgorithm {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginClientDisabled
}
if _, exists := s.requestToken[string(request.RequestTokenHash)]; exists {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
}
if _, exists := s.browserToken[string(request.BrowserTokenHash)]; exists {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
}
s.nextRequestID++
request.ID = s.nextRequestID
s.requests[request.ID] = request.Clone()
s.requestToken[string(request.RequestTokenHash)] = request.ID
s.browserToken[string(request.BrowserTokenHash)] = request.ID
return request.Clone(), nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequest(_ context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error) {
s.mu.RLock()
request, ok := s.requests[requestID]
s.mu.RUnlock()
return request.Clone(), ok, nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequestByTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
s.mu.RLock()
id, ok := s.requestToken[string(tokenHash)]
request := s.requests[id]
s.mu.RUnlock()
return request.Clone(), ok, nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequestByBrowserTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
s.mu.RLock()
id, ok := s.browserToken[string(tokenHash)]
request := s.requests[id]
s.mu.RUnlock()
return request.Clone(), ok, nil
}
func grantedTelegramLoginScopes(request domain.TelegramLoginRequest, approval domain.TelegramLoginApproval) ([]domain.TelegramLoginScope, error) {
if approval.WriteAllowed && !request.Requests(domain.TelegramLoginScopeBotAccess) {
return nil, domain.ErrTelegramLoginScopeInvalid
}
if approval.PhoneShared && !request.Requests(domain.TelegramLoginScopePhone) {
return nil, domain.ErrTelegramLoginScopeInvalid
}
out := make([]domain.TelegramLoginScope, 0, len(request.Scopes))
for _, scope := range request.Scopes {
if scope == domain.TelegramLoginScopePhone && !approval.PhoneShared {
continue
}
if scope == domain.TelegramLoginScopeBotAccess && !approval.WriteAllowed {
continue
}
out = append(out, scope)
}
return out, nil
}
func (s *TelegramLoginStore) ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if approval.RequestID <= 0 || approval.Identity.UserID <= 0 || webAuthorizationHash == 0 || approval.ApprovedAt.IsZero() {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
request, ok := s.requests[approval.RequestID]
if !ok {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
if request.Status != domain.TelegramLoginRequestPending {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
}
if !approval.ApprovedAt.Before(request.ExpiresAt) {
request.Status = domain.TelegramLoginRequestExpired
s.requests[request.ID] = request
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestExpired
}
client, clientExists := s.clientsByID[request.ClientID]
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginClientDisabled
}
if request.ResponseType == "code" {
_, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed
}
} else if request.ResponseType == "post_message" || request.ResponseType == "legacy_url" {
if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !ok {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
} else {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
if request.InAppOrigin != "" {
if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.InAppOrigin)]; !ok {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
}
if len(request.MatchCodes) > 0 && approval.MatchCode != request.MatchCode {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginMatchCodeInvalid
}
scopes, err := grantedTelegramLoginScopes(request, approval)
if err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
}
if _, exists := s.webAuths[webAuthorizationHash]; exists {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
}
identity, err := approval.Identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), approval.PhoneShared)
if err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
}
activeAuthorizations := 0
for _, authorization := range s.webAuths {
if authorization.UserID == identity.UserID && authorization.RevokedAt.IsZero() {
activeAuthorizations++
}
}
if activeAuthorizations >= domain.MaxTelegramLoginWebAuthorizations {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginAuthorizationsTooMany
}
if approval.WriteAllowed && s.permissions != nil {
if _, err := s.permissions.AllowBotSendMessage(ctx, request.BotUserID, identity.UserID, true); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
}
}
request.Status = domain.TelegramLoginRequestApproved
request.AuthorizedUserID = identity.UserID
request.ProfileName = identity.Name
request.GivenName = identity.GivenName
request.FamilyName = identity.FamilyName
request.PreferredUsername = identity.PreferredUsername
request.Picture = identity.Picture
request.PhoneNumber = identity.PhoneNumber
request.WriteAllowed = approval.WriteAllowed
request.PhoneShared = approval.PhoneShared
request.ApprovedAt = approval.ApprovedAt
s.requests[request.ID] = request.Clone()
web := domain.TelegramLoginWebAuthorization{
Hash: webAuthorizationHash,
RequestID: request.ID,
UserID: identity.UserID,
BotUserID: request.BotUserID,
Domain: request.Domain,
Browser: request.Browser,
Platform: request.Platform,
IP: request.IP,
Region: request.Region,
Scopes: scopes,
PhoneShared: approval.PhoneShared,
BotAccessGranted: approval.WriteAllowed,
CreatedAt: approval.ApprovedAt,
LastActiveAt: approval.ApprovedAt,
}
s.webAuths[web.Hash] = web.Clone()
return request.Clone(), web.Clone(), nil
}
func (s *TelegramLoginStore) DeclineTelegramLoginRequest(_ context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error) {
if requestID <= 0 || userID <= 0 || now.IsZero() {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
request, ok := s.requests[requestID]
if !ok {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
}
if request.Status != domain.TelegramLoginRequestPending {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
}
if !now.Before(request.ExpiresAt) {
request.Status = domain.TelegramLoginRequestExpired
s.requests[request.ID] = request
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestExpired
}
request.Status = domain.TelegramLoginRequestDeclined
request.DeclinedAt = now
s.requests[request.ID] = request.Clone()
return request.Clone(), nil
}
func (s *TelegramLoginStore) PutTelegramLoginAuthorizationCode(_ context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error) {
if code.RequestID <= 0 || len(code.CodeHash) != 32 || len(code.SealedCode) < 32 || len(code.SealNonce) < 12 || code.SealKeyID == "" || code.IssuedAt.IsZero() || !code.ExpiresAt.After(code.IssuedAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginCodeInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
request, ok := s.requests[code.RequestID]
if !ok || request.Status != domain.TelegramLoginRequestApproved {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
client, clientExists := s.clientsByID[request.ClientID]
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginClientDisabled
}
switch request.ResponseType {
case "code":
_, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
if !allowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRedirectNotAllowed
}
case "post_message":
if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !allowed {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginOriginNotAllowed
}
default:
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
web, active := s.webAuthByRequestLocked(request.ID)
if !active || !web.RevokedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
if id, exists := s.codeByRequest[code.RequestID]; exists {
return s.codes[id].Clone(), nil
}
if _, exists := s.codeByHash[string(code.CodeHash)]; exists {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
s.nextCodeID++
code.ID = s.nextCodeID
s.codes[code.ID] = code.Clone()
s.codeByHash[string(code.CodeHash)] = code.ID
s.codeByRequest[code.RequestID] = code.ID
return code.Clone(), nil
}
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByRequest(_ context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error) {
s.mu.RLock()
id, ok := s.codeByRequest[requestID]
code := s.codes[id]
s.mu.RUnlock()
return code.Clone(), ok, nil
}
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByHash(_ context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error) {
s.mu.RLock()
id, ok := s.codeByHash[string(codeHash)]
code := s.codes[id]
s.mu.RUnlock()
return code.Clone(), ok, nil
}
func (s *TelegramLoginStore) ConsumeTelegramLoginAuthorizationCode(_ context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if len(exchange.CodeHash) != 32 || exchange.ClientID == "" || exchange.ClientSecretVersion <= 0 || exchange.RedirectURI == "" || exchange.CodeChallenge == "" || exchange.Now.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.codeByHash[string(exchange.CodeHash)]
if !ok {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
code := s.codes[id]
if !code.ConsumedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
}
if !exchange.Now.Before(code.ExpiresAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
request := s.requests[code.RequestID]
client, clientExists := s.clientsByID[exchange.ClientID]
if !clientExists || !client.Enabled || client.SecretVersion != exchange.ClientSecretVersion || request.ResponseType != "code" || request.ClientID != exchange.ClientID || request.RedirectURI != exchange.RedirectURI || request.CodeChallenge != exchange.CodeChallenge {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
_, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
web, exists := s.webAuthByRequestLocked(code.RequestID)
if request.Status != domain.TelegramLoginRequestApproved || !exists || !web.RevokedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
code.ConsumedAt = exchange.Now
web.LastActiveAt = exchange.Now
s.codes[id] = code.Clone()
s.webAuths[web.Hash] = web.Clone()
return code.Clone(), request.Clone(), web.Clone(), nil
}
func (s *TelegramLoginStore) ConsumeTelegramLoginDirectToken(_ context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if len(tokenHash) != 32 || origin == "" || now.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.codeByHash[string(tokenHash)]
if !ok {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
code := s.codes[id]
if !code.ConsumedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
}
if !now.Before(code.ExpiresAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
request := s.requests[code.RequestID]
client, clientExists := s.clientsByID[request.ClientID]
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID ||
request.Status != domain.TelegramLoginRequestApproved || request.Source != domain.TelegramLoginRequestMiniApp ||
request.ResponseType != "post_message" || request.Origin != origin || request.InAppOrigin != origin {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, origin)]; !allowed {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
web, exists := s.webAuthByRequestLocked(code.RequestID)
if !exists || !web.RevokedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
code.ConsumedAt = now
web.LastActiveAt = now
s.codes[id] = code.Clone()
s.webAuths[web.Hash] = web.Clone()
return code.Clone(), request.Clone(), web.Clone(), nil
}
func (s *TelegramLoginStore) webAuthByRequestLocked(requestID int64) (domain.TelegramLoginWebAuthorization, bool) {
for _, web := range s.webAuths {
if web.RequestID == requestID {
return web, true
}
}
return domain.TelegramLoginWebAuthorization{}, false
}
func (s *TelegramLoginStore) nativeCallbackAllowedLocked(botUserID int64, callbackURI string) bool {
for _, app := range s.nativeApps {
if app.BotUserID == botUserID && app.Enabled && app.CallbackURI == callbackURI {
return true
}
}
return false
}
func (s *TelegramLoginStore) ListTelegramLoginWebAuthorizations(_ context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) {
s.mu.RLock()
out := make([]domain.TelegramLoginWebAuthorization, 0, min(len(s.webAuths), domain.MaxTelegramLoginWebAuthorizations))
for _, web := range s.webAuths {
if web.UserID == userID && web.RevokedAt.IsZero() {
out = append(out, web.Clone())
}
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].LastActiveAt.Equal(out[j].LastActiveAt) {
return out[i].Hash > out[j].Hash
}
return out[i].LastActiveAt.After(out[j].LastActiveAt)
})
if len(out) > domain.MaxTelegramLoginWebAuthorizations {
out = out[:domain.MaxTelegramLoginWebAuthorizations]
}
return out, nil
}
func (s *TelegramLoginStore) RevokeTelegramLoginWebAuthorization(_ context.Context, userID, hash int64, now time.Time) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
web, ok := s.webAuths[hash]
if !ok || web.UserID != userID || !web.RevokedAt.IsZero() {
return false, nil
}
web.RevokedAt = now
s.webAuths[hash] = web
return true, nil
}
func (s *TelegramLoginStore) RevokeAllTelegramLoginWebAuthorizations(_ context.Context, userID int64, now time.Time) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var count int64
for hash, web := range s.webAuths {
if web.UserID == userID && web.RevokedAt.IsZero() {
web.RevokedAt = now
s.webAuths[hash] = web
count++
}
}
return count, nil
}
func (s *TelegramLoginStore) DeleteExpiredTelegramLoginArtifacts(_ context.Context, before time.Time, limit int) (int64, error) {
if limit <= 0 || limit > 1000 {
return 0, domain.ErrTelegramLoginRequestInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
var deleted int64
for id, code := range s.codes {
if deleted >= int64(limit) {
break
}
if code.ExpiresAt.Before(before) || (!code.ConsumedAt.IsZero() && code.ConsumedAt.Before(before)) {
delete(s.codes, id)
delete(s.codeByHash, string(code.CodeHash))
delete(s.codeByRequest, code.RequestID)
deleted++
}
}
for id, request := range s.requests {
if deleted >= int64(limit) {
break
}
deleteRequest := (request.Status == domain.TelegramLoginRequestPending || request.Status == domain.TelegramLoginRequestDeclined || request.Status == domain.TelegramLoginRequestExpired) && request.ExpiresAt.Before(before)
var revokedWebHash int64
if request.Status == domain.TelegramLoginRequestApproved && !request.ApprovedAt.IsZero() && request.ApprovedAt.Before(before) {
// Approved requests remain the immutable claim snapshot behind an active
// web authorization. They may only be collected after the grant itself
// was revoked and every exchange code has left the retention window.
for hash, web := range s.webAuths {
if web.RequestID == id && !web.RevokedAt.IsZero() && web.RevokedAt.Before(before) {
deleteRequest = true
revokedWebHash = hash
break
}
}
if _, hasCode := s.codeByRequest[id]; hasCode {
deleteRequest = false
}
}
if !deleteRequest {
continue
}
delete(s.requests, id)
delete(s.requestToken, string(request.RequestTokenHash))
delete(s.browserToken, string(request.BrowserTokenHash))
if revokedWebHash != 0 {
delete(s.webAuths, revokedWebHash)
}
deleted++
}
return deleted, nil
}

View file

@ -0,0 +1,320 @@
package memory
import (
"context"
"crypto/sha256"
"errors"
"sync"
"testing"
"time"
"telesrv/internal/domain"
)
type telegramLoginPermissionRecorder struct {
mu sync.Mutex
grants map[[2]int64]int
}
func (r *telegramLoginPermissionRecorder) AllowBotSendMessage(_ context.Context, botUserID, userID int64, _ bool) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.grants == nil {
r.grants = make(map[[2]int64]int)
}
key := [2]int64{botUserID, userID}
created := r.grants[key] == 0
r.grants[key]++
return created, nil
}
func telegramLoginTestHash(value string) []byte {
sum := sha256.Sum256([]byte(value))
return sum[:]
}
func seedTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, now time.Time) domain.TelegramLoginRequest {
t.Helper()
ctx := context.Background()
client := domain.TelegramLoginClient{
BotUserID: 9001,
ClientID: "9001",
SecretHash: telegramLoginTestHash("client-secret"),
SecretVersion: 1,
SigningAlgorithm: domain.TelegramLoginSigningRS256,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := s.UpsertTelegramLoginClient(ctx, client); err != nil {
t.Fatalf("UpsertTelegramLoginClient: %v", err)
}
if _, err := s.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
NormalizedURL: "https://rp.example/callback", CreatedAt: now,
}); err != nil {
t.Fatalf("AddTelegramLoginAllowedURL: %v", err)
}
request := domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginTestHash("request-token"),
BrowserTokenHash: telegramLoginTestHash("browser-token"),
BotUserID: client.BotUserID,
ClientID: client.ClientID,
SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestWeb,
ResponseType: "code",
RedirectURI: "https://rp.example/callback",
Origin: "https://rp.example",
Domain: "rp.example",
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone, domain.TelegramLoginScopeBotAccess},
State: "state",
Nonce: "nonce",
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
CodeChallengeMethod: "S256",
Browser: "Firefox",
Platform: "Windows",
IP: "192.0.2.10",
Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"},
MatchCode: "🔵",
MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending,
CreatedAt: now,
ExpiresAt: now.Add(5 * time.Minute),
}
created, err := s.CreateTelegramLoginRequest(ctx, request)
if err != nil {
t.Fatalf("CreateTelegramLoginRequest: %v", err)
}
return created
}
func approveTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, request domain.TelegramLoginRequest, now time.Time) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization) {
t.Helper()
approved, web, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{
RequestID: request.ID,
Identity: domain.TelegramLoginIdentitySnapshot{
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
PreferredUsername: "alice", Picture: "https://oauth.example/userpic/42",
},
WriteAllowed: true,
PhoneShared: false,
MatchCode: request.MatchCode,
ApprovedAt: now,
}, 7000+request.ID)
if err != nil {
t.Fatalf("ApproveTelegramLoginRequest: %v", err)
}
return approved, web
}
func TestTelegramLoginApproveIsAtomicAndShrinksConsent(t *testing.T) {
now := time.Unix(1_780_000_000, 0)
permissions := &telegramLoginPermissionRecorder{}
s := NewTelegramLoginStore(permissions)
request := seedTelegramLoginRequest(t, s, now)
approved, web := approveTelegramLoginRequest(t, s, request, now.Add(time.Second))
if approved.Status != domain.TelegramLoginRequestApproved || approved.AuthorizedUserID != 42 {
t.Fatalf("approved request = %#v", approved)
}
if web.PhoneShared || web.BotAccessGranted != true {
t.Fatalf("web consent = %#v", web)
}
if len(web.Scopes) != 3 || web.Scopes[0] != domain.TelegramLoginScopeOpenID || web.Scopes[1] != domain.TelegramLoginScopeProfile || web.Scopes[2] != domain.TelegramLoginScopeBotAccess {
t.Fatalf("granted scopes = %#v", web.Scopes)
}
permissions.mu.Lock()
grants := permissions.grants[[2]int64{9001, 42}]
permissions.mu.Unlock()
if grants != 1 {
t.Fatalf("bot permission grants = %d, want 1", grants)
}
}
func TestTelegramLoginAcceptDeclineRaceHasOneTerminalState(t *testing.T) {
now := time.Unix(1_780_000_000, 0)
s := NewTelegramLoginStore(nil)
request := seedTelegramLoginRequest(t, s, now)
start := make(chan struct{})
errs := make(chan error, 2)
go func() {
<-start
_, _, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{
RequestID: request.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: 42, Name: "Alice", GivenName: "Alice"},
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
}, 7001)
errs <- err
}()
go func() {
<-start
_, err := s.DeclineTelegramLoginRequest(context.Background(), request.ID, 42, now.Add(time.Second))
errs <- err
}()
close(start)
var success, conflict int
for range 2 {
err := <-errs
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
conflict++
default:
t.Fatalf("unexpected race error: %v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("success=%d conflict=%d, want 1/1", success, conflict)
}
}
func TestTelegramLoginAuthorizationCodeSingleConsumeAndRevocation(t *testing.T) {
now := time.Unix(1_780_000_000, 0)
s := NewTelegramLoginStore(nil)
request := seedTelegramLoginRequest(t, s, now)
approveTelegramLoginRequest(t, s, request, now.Add(time.Second))
code := domain.TelegramLoginAuthorizationCode{
RequestID: request.ID,
CodeHash: telegramLoginTestHash("authorization-code"),
SealedCode: append(make([]byte, 32), 1),
SealNonce: make([]byte, 12),
SealKeyID: "test-key",
IssuedAt: now.Add(2 * time.Second),
ExpiresAt: now.Add(time.Minute),
}
if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code); err != nil {
t.Fatalf("PutTelegramLoginAuthorizationCode: %v", err)
}
start := make(chan struct{})
errs := make(chan error, 8)
for range 8 {
go func() {
<-start
_, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: request.ClientID, ClientSecretVersion: 1,
RedirectURI: request.RedirectURI, CodeChallenge: request.CodeChallenge, Now: now.Add(3 * time.Second),
})
errs <- err
}()
}
close(start)
var success, consumed int
for range 8 {
err := <-errs
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
consumed++
default:
t.Fatalf("unexpected consume error: %v", err)
}
}
if success != 1 || consumed != 7 {
t.Fatalf("success=%d consumed=%d, want 1/7", success, consumed)
}
request2 := request.Clone()
request2.ID = 0
request2.RequestTokenHash = telegramLoginTestHash("request-token-2")
request2.BrowserTokenHash = telegramLoginTestHash("browser-token-2")
request2, err := s.CreateTelegramLoginRequest(context.Background(), request2)
if err != nil {
t.Fatalf("Create second request: %v", err)
}
_, web2 := approveTelegramLoginRequest(t, s, request2, now.Add(4*time.Second))
code2 := code.Clone()
code2.ID = 0
code2.RequestID = request2.ID
code2.CodeHash = telegramLoginTestHash("authorization-code-2")
if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code2); err != nil {
t.Fatalf("Put second code: %v", err)
}
if revoked, err := s.RevokeTelegramLoginWebAuthorization(context.Background(), web2.UserID, web2.Hash, now.Add(5*time.Second)); err != nil || !revoked {
t.Fatalf("RevokeTelegramLoginWebAuthorization = %v,%v", revoked, err)
}
if _, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{
CodeHash: code2.CodeHash, ClientID: request2.ClientID, ClientSecretVersion: 1,
RedirectURI: request2.RedirectURI, CodeChallenge: request2.CodeChallenge, Now: now.Add(6 * time.Second),
}); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
t.Fatalf("consume after revoke error = %v, want code invalid", err)
}
}
func TestTelegramLoginRetentionPreservesActiveAndReferencedApprovals(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_780_000_000, 0)
before := now.Add(24 * time.Hour)
s := NewTelegramLoginStore(nil)
active := seedTelegramLoginRequest(t, s, now)
_, activeWeb := approveTelegramLoginRequest(t, s, active, now.Add(time.Second))
revoked := active.Clone()
revoked.ID = 0
revoked.RequestTokenHash = telegramLoginTestHash("retention-revoked-request")
revoked.BrowserTokenHash = telegramLoginTestHash("retention-revoked-browser")
revoked.Status = domain.TelegramLoginRequestPending
revoked.AuthorizedUserID = 0
revoked.ProfileName, revoked.GivenName, revoked.FamilyName = "", "", ""
revoked.PreferredUsername, revoked.Picture, revoked.PhoneNumber = "", "", ""
revoked.WriteAllowed, revoked.PhoneShared = false, false
revoked.ApprovedAt = time.Time{}
revoked, err := s.CreateTelegramLoginRequest(ctx, revoked)
if err != nil {
t.Fatalf("create revoked request: %v", err)
}
_, revokedWeb := approveTelegramLoginRequest(t, s, revoked, now.Add(2*time.Second))
if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, revokedWeb.UserID, revokedWeb.Hash, now.Add(3*time.Second)); err != nil || !ok {
t.Fatalf("revoke old authorization = %v,%v", ok, err)
}
referenced := revoked.Clone()
referenced.ID = 0
referenced.RequestTokenHash = telegramLoginTestHash("retention-referenced-request")
referenced.BrowserTokenHash = telegramLoginTestHash("retention-referenced-browser")
referenced.Status = domain.TelegramLoginRequestPending
referenced.AuthorizedUserID = 0
referenced.ProfileName, referenced.GivenName, referenced.FamilyName = "", "", ""
referenced.PreferredUsername, referenced.Picture, referenced.PhoneNumber = "", "", ""
referenced.WriteAllowed, referenced.PhoneShared = false, false
referenced.ApprovedAt = time.Time{}
referenced, err = s.CreateTelegramLoginRequest(ctx, referenced)
if err != nil {
t.Fatalf("create referenced request: %v", err)
}
_, referencedWeb := approveTelegramLoginRequest(t, s, referenced, now.Add(4*time.Second))
if _, err := s.PutTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginAuthorizationCode{
RequestID: referenced.ID, CodeHash: telegramLoginTestHash("retention-live-code"),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "test-key",
IssuedAt: before.Add(time.Hour), ExpiresAt: before.Add(2 * time.Hour),
}); err != nil {
t.Fatalf("put retained code: %v", err)
}
if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, referencedWeb.UserID, referencedWeb.Hash, now.Add(5*time.Second)); err != nil || !ok {
t.Fatalf("revoke referenced authorization = %v,%v", ok, err)
}
deleted, err := s.DeleteExpiredTelegramLoginArtifacts(ctx, before, 100)
if err != nil {
t.Fatalf("delete expired artifacts: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted = %d, want revoked request only", deleted)
}
if _, found, _ := s.GetTelegramLoginRequest(ctx, active.ID); !found {
t.Fatal("active authorization request was deleted")
}
if _, found, _ := s.GetTelegramLoginRequest(ctx, referenced.ID); !found {
t.Fatal("request with retained code was deleted")
}
if _, found, _ := s.GetTelegramLoginRequest(ctx, revoked.ID); found {
t.Fatal("old revoked authorization request was retained")
}
listed, err := s.ListTelegramLoginWebAuthorizations(ctx, activeWeb.UserID)
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
t.Fatalf("active authorizations after retention = %#v, %v", listed, err)
}
}

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 124 {
t.Fatalf("migration status = %+v, want clean version 124", status)
if status.Dirty || status.Empty || status.Version != 125 {
t.Fatalf("migration status = %+v, want clean version 125", status)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,434 @@
package postgres
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
func telegramLoginPGHash(value string) []byte {
sum := sha256.Sum256([]byte(value))
return sum[:]
}
func TestTelegramLoginStorePostgresAtomicStateAndCodeConsumption(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.UnixNano() % 1_000_000_000
users := NewUserStore(pool)
bots := NewBotStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: suffix + 101,
Phone: fmt.Sprintf("1777%09d", suffix),
FirstName: "OIDC Owner",
})
if err != nil {
t.Fatalf("create oidc owner: %v", err)
}
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: suffix + 102,
FirstName: "OIDC Test Bot",
Username: fmt.Sprintf("oidc_%09d_bot", suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "bot-secret"})
if err != nil {
t.Fatalf("create oidc bot: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID)
})
store := NewTelegramLoginStore(pool)
client, err := store.UpsertTelegramLoginClient(ctx, domain.TelegramLoginClient{
BotUserID: bot.ID,
ClientID: fmt.Sprintf("%d", bot.ID),
SecretHash: telegramLoginPGHash("client-secret"),
SecretVersion: 1,
SigningAlgorithm: domain.TelegramLoginSigningRS256,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("upsert oidc client: %v", err)
}
redirectURI := fmt.Sprintf("https://rp-%d.example/callback", suffix)
origin := fmt.Sprintf("https://rp-%d.example", suffix)
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
NormalizedURL: redirectURI, CreatedAt: now,
}); err != nil {
t.Fatalf("add redirect: %v", err)
}
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedWebOrigin,
NormalizedURL: origin, CreatedAt: now,
}); err != nil {
t.Fatalf("add web origin: %v", err)
}
newRequest := func(label string) domain.TelegramLoginRequest {
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash("request-" + label),
BrowserTokenHash: telegramLoginPGHash("browser-" + label),
BotUserID: bot.ID,
ClientID: client.ClientID,
SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestWeb,
ResponseType: "code",
RedirectURI: redirectURI,
Origin: origin,
Domain: fmt.Sprintf("rp-%d.example", suffix),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopeBotAccess},
State: "state",
Nonce: "nonce",
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
CodeChallengeMethod: "S256",
Browser: "Firefox",
Platform: "Windows",
IP: "192.0.2.10",
Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"},
MatchCode: "🔵",
MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending,
CreatedAt: now,
ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create request %s: %v", label, err)
}
return request
}
request := newRequest(fmt.Sprintf("race-%d", suffix))
start := make(chan struct{})
errs := make(chan error, 2)
go func() {
<-start
_, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: request.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
WriteAllowed: true,
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
}, suffix+10_000)
errs <- err
}()
go func() {
<-start
_, err := store.DeclineTelegramLoginRequest(ctx, request.ID, owner.ID, now.Add(time.Second))
errs <- err
}()
close(start)
var success, conflict int
for range 2 {
err := <-errs
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
conflict++
default:
t.Fatalf("accept/decline race error: %v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("accept/decline success=%d conflict=%d, want 1/1", success, conflict)
}
codeRequest := newRequest(fmt.Sprintf("code-%d", suffix))
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: codeRequest.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
WriteAllowed: true,
MatchCode: codeRequest.MatchCode, ApprovedAt: now.Add(2 * time.Second),
}, suffix+20_000)
if err != nil {
t.Fatalf("approve code request: %v", err)
}
canSend, err := bots.CanBotSendMessage(ctx, bot.ID, owner.ID)
if err != nil || !canSend {
t.Fatalf("bot access after atomic approval = %v,%v", canSend, err)
}
code := domain.TelegramLoginAuthorizationCode{
RequestID: codeRequest.ID,
CodeHash: telegramLoginPGHash(fmt.Sprintf("code-%d", suffix)),
SealedCode: append(make([]byte, 32), 1),
SealNonce: make([]byte, 12),
SealKeyID: "integration-key",
IssuedAt: now.Add(3 * time.Second),
ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
t.Fatalf("put code: %v", err)
}
exchange := domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
RedirectURI: codeRequest.RedirectURI, CodeChallenge: codeRequest.CodeChallenge, Now: now.Add(4 * time.Second),
}
start = make(chan struct{})
errs = make(chan error, 8)
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, exchange)
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
success, conflict = 0, 0
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
conflict++
default:
t.Fatalf("code consume race error: %v", err)
}
}
if success != 1 || conflict != 7 {
t.Fatalf("code consume success=%d consumed=%d, want 1/7", success, conflict)
}
miniRequest, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-request-%d", suffix)),
BrowserTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-browser-%d", suffix)),
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestMiniApp, ResponseType: "post_message",
RedirectURI: origin + "/", Origin: origin, InAppOrigin: origin, Domain: fmt.Sprintf("rp-%d.example", suffix),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
Browser: "Telegram Mini App", Platform: "Telegram Mini App", IP: "192.0.2.11", Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"}, MatchCode: "🔵", MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create mini-app request: %v", err)
}
if _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: miniRequest.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
MatchCode: miniRequest.MatchCode, ApprovedAt: now.Add(5 * time.Second),
}, suffix+25_000); err != nil {
t.Fatalf("approve mini-app request: %v", err)
}
directToken := domain.TelegramLoginAuthorizationCode{
RequestID: miniRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("mini-token-%d", suffix)),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
IssuedAt: now.Add(6 * time.Second), ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, directToken); err != nil {
t.Fatalf("put mini-app token: %v", err)
}
start = make(chan struct{})
errs = make(chan error, 8)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, _, err := store.ConsumeTelegramLoginDirectToken(ctx, directToken.CodeHash, origin, now.Add(7*time.Second))
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
success, conflict = 0, 0
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
conflict++
default:
t.Fatalf("mini-app token consume race error: %v", err)
}
}
if success != 1 || conflict != 7 {
t.Fatalf("mini-app token consume success=%d consumed=%d, want 1/7", success, conflict)
}
if revoked, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, web.Hash, now.Add(5*time.Second)); err != nil || !revoked {
t.Fatalf("revoke web authorization = %v,%v", revoked, err)
}
if listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID); err != nil {
t.Fatalf("list web authorizations: %v", err)
} else {
for _, got := range listed {
if got.Hash == web.Hash {
t.Fatalf("revoked web authorization still listed: %#v", got)
}
}
}
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
return store.DeleteTelegramLoginAllowedURL(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
})
}
func TestTelegramLoginStorePostgresNativeCallbackAndRetention(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.UnixNano() % 1_000_000_000
users := NewUserStore(pool)
bots := NewBotStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: suffix + 301, Phone: fmt.Sprintf("1666%09d", suffix), FirstName: "Native Owner",
})
if err != nil {
t.Fatal(err)
}
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: suffix + 302, FirstName: "Native Login Bot", Username: fmt.Sprintf("native_%09d_bot", suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "native-bot-secret"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) })
store := NewTelegramLoginStore(pool)
client, err := store.CreateTelegramLoginClient(ctx, domain.TelegramLoginClient{
BotUserID: bot.ID, ClientID: fmt.Sprintf("%d", bot.ID), SecretHash: telegramLoginPGHash("native-secret"),
SecretVersion: 1, SigningAlgorithm: domain.TelegramLoginSigningRS256, Enabled: true,
CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
const callbackURI = "bedolaga://telegram-login"
nativeApp, err := store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{
BotUserID: bot.ID, Platform: domain.TelegramLoginNativeAndroid, ApplicationID: "dev.bedolaga.demo",
VerificationID: strings.Repeat("A", 64), CallbackURI: callbackURI, VerifiedDisplayName: "Bedolaga Demo",
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
createRequest := func(label string) domain.TelegramLoginRequest {
t.Helper()
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash("native-request-" + label), BrowserTokenHash: telegramLoginPGHash("native-browser-" + label),
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestNative, ResponseType: "code", RedirectURI: callbackURI,
Domain: "dev.bedolaga.demo", Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", CodeChallengeMethod: "S256",
Browser: "TelegramLogin/Android", Platform: "Android", IP: "192.0.2.20", Region: "Test Region",
IsApp: true, VerifiedAppName: "Bedolaga Demo", MatchCodes: []string{}, Status: domain.TelegramLoginRequestPending,
CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create native request: %v", err)
}
return request
}
approve := func(request domain.TelegramLoginRequest, hash int64) domain.TelegramLoginWebAuthorization {
t.Helper()
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: request.ID, Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: "Native Owner", GivenName: "Native"},
ApprovedAt: now.Add(time.Second),
}, hash)
if err != nil {
t.Fatalf("approve native request: %v", err)
}
return web
}
revokedRequest := createRequest(fmt.Sprintf("revoked-%d", suffix))
revokedWeb := approve(revokedRequest, suffix+30_000)
code := domain.TelegramLoginAuthorizationCode{
RequestID: revokedRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("native-code-%d", suffix)),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
IssuedAt: now.Add(2 * time.Second), ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
t.Fatal(err)
}
if _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
RedirectURI: callbackURI, CodeChallenge: revokedRequest.CodeChallenge, Now: now.Add(3 * time.Second),
}); err != nil {
t.Fatalf("consume native code: %v", err)
}
if ok, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, revokedWeb.Hash, now.Add(4*time.Second)); err != nil || !ok {
t.Fatalf("revoke native authorization = %v,%v", ok, err)
}
activeRequest := createRequest(fmt.Sprintf("active-%d", suffix))
activeWeb := approve(activeRequest, suffix+40_000)
deleted, err := store.DeleteExpiredTelegramLoginArtifacts(ctx, now.Add(2*time.Hour), 100)
if err != nil {
t.Fatal(err)
}
if deleted < 2 {
t.Fatalf("retention deleted=%d, want at least code and revoked request", deleted)
}
if _, found, _ := store.GetTelegramLoginRequest(ctx, revokedRequest.ID); found {
t.Fatal("revoked native request survived retention")
}
if _, found, _ := store.GetTelegramLoginRequest(ctx, activeRequest.ID); !found {
t.Fatal("active native request was deleted")
}
listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID)
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
t.Fatalf("active authorization list=%#v err=%v", listed, err)
}
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
return store.DeleteTelegramLoginNativeApp(ctx, client.BotUserID, nativeApp.ID)
})
}
func assertTelegramLoginConfigDeleteTakesClientLock(t *testing.T, pool *pgxpool.Pool, botUserID int64, remove func() (bool, error)) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
var lockedID int64
if err := tx.QueryRow(ctx, `SELECT bot_user_id FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&lockedID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
deleted, err := remove()
if err == nil && !deleted {
err = errors.New("configuration row was not deleted")
}
result <- err
}()
select {
case err := <-result:
t.Fatalf("configuration delete bypassed client serialization lock: %v", err)
case <-time.After(150 * time.Millisecond):
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("configuration delete remained blocked after client lock committed")
}
}

View file

@ -0,0 +1,49 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
// TelegramLoginStore is the single durable boundary shared by the HTTP OIDC
// adapter, MTProto URL-authorization RPCs and account Web-authorization RPCs.
// Implementations must use compare-and-set transitions and must not treat an
// in-memory cache as the source of truth.
type TelegramLoginStore interface {
CreateTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error)
UpsertTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error)
GetTelegramLoginClient(ctx context.Context, clientID string) (domain.TelegramLoginClient, bool, error)
GetTelegramLoginClientByBot(ctx context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error)
RotateTelegramLoginClientSecret(ctx context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error)
SetTelegramLoginClientSigningAlgorithm(ctx context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error)
SetTelegramLoginClientEnabled(ctx context.Context, botUserID int64, enabled bool, now time.Time) error
AddTelegramLoginAllowedURL(ctx context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error)
DeleteTelegramLoginAllowedURL(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error)
ListTelegramLoginAllowedURLs(ctx context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error)
IsTelegramLoginURLAllowed(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error)
UpsertTelegramLoginNativeApp(ctx context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error)
DeleteTelegramLoginNativeApp(ctx context.Context, botUserID, appID int64) (bool, error)
ListTelegramLoginNativeApps(ctx context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error)
CreateTelegramLoginRequest(ctx context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error)
GetTelegramLoginRequest(ctx context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error)
GetTelegramLoginRequestByTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error)
GetTelegramLoginRequestByBrowserTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error)
ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
DeclineTelegramLoginRequest(ctx context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error)
PutTelegramLoginAuthorizationCode(ctx context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error)
GetTelegramLoginAuthorizationCodeByRequest(ctx context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error)
GetTelegramLoginAuthorizationCodeByHash(ctx context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error)
ConsumeTelegramLoginAuthorizationCode(ctx context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
ConsumeTelegramLoginDirectToken(ctx context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
ListTelegramLoginWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error)
RevokeTelegramLoginWebAuthorization(ctx context.Context, userID, hash int64, now time.Time) (bool, error)
RevokeAllTelegramLoginWebAuthorizations(ctx context.Context, userID int64, now time.Time) (int64, error)
DeleteExpiredTelegramLoginArtifacts(ctx context.Context, before time.Time, limit int) (int64, error)
}