feat: sync Telegram Login OIDC provider
This commit is contained in:
parent
30774f8c39
commit
ebead9e98c
63 changed files with 11374 additions and 37 deletions
|
|
@ -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"},
|
||||
},
|
||||
|
|
|
|||
721
internal/store/memory/telegram_login.go
Normal file
721
internal/store/memory/telegram_login.go
Normal 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
|
||||
}
|
||||
320
internal/store/memory/telegram_login_test.go
Normal file
320
internal/store/memory/telegram_login_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1106
internal/store/postgres/telegram_login.go
Normal file
1106
internal/store/postgres/telegram_login.go
Normal file
File diff suppressed because it is too large
Load diff
434
internal/store/postgres/telegram_login_integration_test.go
Normal file
434
internal/store/postgres/telegram_login_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
49
internal/store/telegram_login.go
Normal file
49
internal/store/telegram_login.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue