package service import ( "context" "encoding/json" "errors" "sort" "strings" "time" "github.com/golang-jwt/jwt/v5" "wucher/internal/domain/auth" "wucher/internal/shared/pkg/uuidv7" ) type authSessionRecord struct { SessionID string `json:"session_id"` UserID string `json:"user_id"` CurrentJTI string `json:"current_jti"` RefreshExpAt time.Time `json:"refresh_exp_at"` AuthProvider string `json:"auth_provider,omitempty"` MicrosoftSID string `json:"microsoft_sid,omitempty"` DeviceType string `json:"device_type,omitempty"` UserAgent string `json:"user_agent,omitempty"` DeviceName string `json:"device_name"` IPAddress string `json:"ip_address,omitempty"` LongLived bool `json:"long_lived"` CreatedAt time.Time `json:"created_at"` LastActiveAt time.Time `json:"last_active_at"` } type UserSession struct { ID string `json:"id"` DeviceName string `json:"device_name"` UserAgent string `json:"user_agent,omitempty"` IPAddress string `json:"ip_address,omitempty"` CreatedAt time.Time `json:"created_at"` LastActiveAt time.Time `json:"last_active_at"` ActiveNow bool `json:"active_now"` } const ( refreshReplayTTL = 5 * time.Second refreshLockTTL = 15 * time.Second refreshReplayWaitTimeout = 5 * time.Second refreshReplayPollInterval = 50 * time.Millisecond ) type refreshReplayPayload struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` RefreshTTLSecond int64 `json:"refresh_ttl_second"` SessionID string `json:"session_id"` UserID string `json:"user_id"` } func (p refreshReplayPayload) toTokenPair() TokenPair { return TokenPair{ AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, RefreshTTL: time.Duration(p.RefreshTTLSecond) * time.Second, SessionID: p.SessionID, } } func newRefreshReplayPayload(pair TokenPair, userID, sessionID string) refreshReplayPayload { refreshTTL := pair.RefreshTTL if refreshTTL <= 0 { refreshTTL = time.Second } return refreshReplayPayload{ AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken, RefreshTTLSecond: int64(refreshTTL.Seconds()), SessionID: strings.TrimSpace(sessionID), UserID: strings.TrimSpace(userID), } } func (s *AuthService) IssueTokensWithClient(ctx context.Context, user *auth.User, userAgent, ipAddress string) (TokenPair, error) { return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTLocalRefreshTTL, false, userAgent, ipAddress, "", "", "") } func (s *AuthService) IssueTokensAfterTOTPWithClient(ctx context.Context, user *auth.User, userAgent, ipAddress string) (TokenPair, error) { return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTRefreshTOTPTTL, true, userAgent, ipAddress, "", "", "") } func (s *AuthService) IssueTokensWithClientAndDeviceType( ctx context.Context, user *auth.User, userAgent, ipAddress, deviceTypeHint string, ) (TokenPair, error) { return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTLocalRefreshTTL, false, userAgent, ipAddress, "", deviceTypeHint, "") } func (s *AuthService) IssueTokensAfterTOTPWithClientAndDeviceType( ctx context.Context, user *auth.User, userAgent, ipAddress, deviceTypeHint string, ) (TokenPair, error) { return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTRefreshTOTPTTL, true, userAgent, ipAddress, "", deviceTypeHint, "") } func (s *AuthService) IssueTokensWithClientForProvider( ctx context.Context, user *auth.User, userAgent, ipAddress, authProvider string, ) (TokenPair, error) { refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider) return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, "", "") } func (s *AuthService) IssueTokensWithClientForProviderAndDeviceType( ctx context.Context, user *auth.User, userAgent, ipAddress, authProvider, deviceTypeHint string, ) (TokenPair, error) { refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider) return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, deviceTypeHint, "") } func (s *AuthService) IssueTokensWithClientForProviderAndMicrosoftSID( ctx context.Context, user *auth.User, userAgent, ipAddress, authProvider, microsoftSID string, ) (TokenPair, error) { refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider) return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, "", microsoftSID) } // providerRefreshTTL resolves the refresh-token lifetime for a login session. // For Microsoft/SSO sessions it follows the lifetime of the stored Microsoft // refresh token so the app session stays valid for exactly as long as Microsoft // will keep refreshing it (validated on every /auth/refresh). When Microsoft // does not report an expiry it falls back to the configured SSO default. Other // providers keep the standard refresh TTL. func (s *AuthService) providerRefreshTTL(ctx context.Context, userID []byte, authProvider string) time.Duration { if !strings.EqualFold(strings.TrimSpace(authProvider), ssoProviderMicrosoft) { return s.cfg.JWTRefreshTTL } fallback := s.cfg.JWTSSORefreshTTL if fallback <= 0 { fallback = s.cfg.JWTRefreshTTL } repo, ok := s.repo.(microsoftOAuthTokenStore) if !ok { return fallback } record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft) if err != nil || record == nil || record.RefreshTokenExpiresAt == nil { return fallback } remaining := time.Until(*record.RefreshTokenExpiresAt) if remaining <= time.Minute { return fallback } return remaining } func (s *AuthService) issueLoginTokensWithRefreshTTLAndClient( ctx context.Context, user *auth.User, refreshTTL time.Duration, longLived bool, userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID string, ) (TokenPair, error) { if user == nil || len(user.ID) != 16 { return TokenPair{}, ErrInvalidToken } if !user.IsActive { return TokenPair{}, ErrInvalidToken } return s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, "", userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID) } func (s *AuthService) issueTokensWithRefreshTTLAndClient( ctx context.Context, user *auth.User, refreshTTL time.Duration, longLived bool, sessionID, userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID string, ) (TokenPair, error) { if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" { return TokenPair{}, errors.New("jwt secrets not configured") } if refreshTTL <= 0 { refreshTTL = s.cfg.JWTRefreshTTL } now := time.Now().UTC() newLogin := strings.TrimSpace(sessionID) == "" if newLogin { sessionID, _ = uuidv7.BytesToString(uuidv7.MustBytes()) } existingSessionIDs := []string{} if s.tokens != nil && newLogin { if ids, err := s.loadUserSessionIDs(ctx, user.ID); err == nil { existingSessionIDs = ids } } sessionVersion, _ := s.sessionVersion(ctx, user.ID) accessClaims := jwt.MapClaims{ "sub": bytesToString(user.ID), "iat": now.Unix(), "exp": now.Add(s.cfg.JWTAccessTTL).Unix(), "type": "access", "sv": sessionVersion, "sid": sessionID, } accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(s.cfg.JWTAccessSecret)) if err != nil { return TokenPair{}, err } jti, err := randomToken(32) if err != nil { return TokenPair{}, err } refreshClaims := jwt.MapClaims{ "sub": bytesToString(user.ID), "iat": now.Unix(), "exp": now.Add(refreshTTL).Unix(), "jti": jti, "type": "refresh", "sv": sessionVersion, "sid": sessionID, } if longLived { refreshClaims["ll"] = true } refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(s.cfg.JWTRefreshSecret)) if err != nil { return TokenPair{}, err } if s.tokens != nil { _ = s.tokens.Set(ctx, refreshKey(jti), []byte(sessionID), refreshTTL) if strings.EqualFold(strings.TrimSpace(authProvider), ssoProviderMicrosoft) && strings.TrimSpace(microsoftSID) != "" { _ = s.tokens.Set(ctx, microsoftSessionKey(microsoftSID), []byte(sessionID), refreshTTL) } createdAt := now record, _ := s.loadSessionRecord(ctx, sessionID) if record != nil && record.UserID == bytesToString(user.ID) { createdAt = record.CreatedAt if strings.TrimSpace(authProvider) == "" { authProvider = strings.TrimSpace(record.AuthProvider) } if strings.TrimSpace(userAgent) == "" { userAgent = record.UserAgent } if strings.TrimSpace(ipAddress) == "" { ipAddress = record.IPAddress } } session := &authSessionRecord{ SessionID: sessionID, UserID: bytesToString(user.ID), CurrentJTI: jti, RefreshExpAt: now.Add(refreshTTL), AuthProvider: strings.TrimSpace(authProvider), MicrosoftSID: strings.TrimSpace(microsoftSID), DeviceType: classifyDeviceType(strings.TrimSpace(userAgent), deviceTypeHint), UserAgent: strings.TrimSpace(userAgent), DeviceName: deriveDeviceName(strings.TrimSpace(userAgent)), IPAddress: sanitizeIP(strings.TrimSpace(ipAddress), s.ipProtector), LongLived: longLived, CreatedAt: createdAt, LastActiveAt: now, } if err := s.saveSessionRecord(ctx, session, refreshTTL); err == nil { if newLogin { _ = s.enforceUserDeviceSlots(ctx, user.ID, existingSessionIDs, session) } else { _ = s.addUserSessionID(ctx, user.ID, sessionID) } } } return TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, RefreshTTL: refreshTTL, SessionID: sessionID, }, nil } func (s *AuthService) RefreshTokensWithClient(ctx context.Context, refreshToken, userAgent, ipAddress string) (TokenPair, error) { return s.RefreshTokensWithClientAndDeviceType(ctx, refreshToken, userAgent, ipAddress, "") } func (s *AuthService) RefreshTokensWithClientAndDeviceType( ctx context.Context, refreshToken, userAgent, ipAddress, deviceTypeHint string, ) (TokenPair, error) { if refreshToken == "" { return TokenPair{}, ErrInvalidToken } parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, ErrInvalidToken } return []byte(s.cfg.JWTRefreshSecret), nil }) if err != nil || !parsed.Valid { return TokenPair{}, ErrInvalidToken } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok || claims["type"] != "refresh" { return TokenPair{}, ErrInvalidToken } jti, _ := claims["jti"].(string) sub, _ := claims["sub"].(string) sid, _ := claims["sid"].(string) if jti == "" || sub == "" || sid == "" { return TokenPair{}, ErrInvalidToken } userID, err := uuidv7.ParseString(sub) if err != nil { return TokenPair{}, ErrInvalidToken } if s.tokens == nil { return TokenPair{}, ErrInvalidToken } if !s.validSessionVersion(ctx, userID, claims["sv"]) { return TokenPair{}, ErrInvalidToken } if !s.isSessionActive(ctx, userID, sid) { return TokenPair{}, ErrInvalidToken } if replay, ok, err := s.loadRefreshReplay(ctx, jti, userID, sid); err != nil { return TokenPair{}, ErrInvalidToken } else if ok { return replay, nil } acquired := false acquired, err = s.tokens.SetIfNotExists(ctx, refreshLockKey(sid), []byte(jti), refreshLockTTL) if err != nil { return TokenPair{}, ErrInvalidToken } if !acquired { replay, ok, waitErr := s.waitForRefreshReplay(ctx, jti, userID, sid) if waitErr != nil { return TokenPair{}, ErrInvalidToken } if ok { return replay, nil } return TokenPair{}, ErrInvalidToken } defer func() { _ = s.tokens.Delete(ctx, refreshLockKey(sid)) }() var record *authSessionRecord var raw []byte raw, err = s.tokens.Get(ctx, refreshKey(jti)) if err != nil || raw == nil || !subtleConstantTimeCompare(raw, []byte(sid)) { return TokenPair{}, ErrInvalidToken } record, err = s.loadSessionRecord(ctx, sid) if err != nil || record == nil { return TokenPair{}, ErrInvalidToken } if record.UserID != bytesToString(userID) || record.CurrentJTI != jti { return TokenPair{}, ErrInvalidToken } if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) { if _, err := s.RefreshMicrosoftAccessToken(ctx, userID); err != nil { // Only revoke when Microsoft says the grant is permanently invalid // (invalid_grant/interaction_required/consent_required, or the // stored refresh token is missing). Transient failures — network // errors, 5xx, throttling, request timeouts, or an open circuit // breaker — must NOT log the user out: keep the app session alive // and retry Microsoft on the next refresh cycle. if errors.Is(err, ErrMicrosoftReauthRequired) { _ = s.RevokeUserSession(ctx, userID, sid) return TokenPair{}, ErrInvalidToken } } } if s.isSessionIdleExpired(record) { // SSO sessions may continue when Microsoft token refresh is still valid. if !s.canRecoverSSOSessionOnIdle(ctx, userID, record) { _ = s.RevokeUserSession(ctx, userID, sid) return TokenPair{}, ErrInvalidToken } } user, err := s.repo.GetUserByID(ctx, userID) if err != nil || user == nil || !user.IsActive { return TokenPair{}, ErrInvalidToken } longLived := boolClaim(claims["ll"]) refreshTTL := s.cfg.JWTRefreshTTL if longLived { refreshTTL = s.cfg.JWTRefreshTOTPTTL } else if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) { // Keep the session sliding with the Microsoft refresh token lifetime. refreshTTL = s.providerRefreshTTL(ctx, userID, record.AuthProvider) } pair, err := s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, sid, userAgent, ipAddress, "", deviceTypeHint, "") if err != nil { return TokenPair{}, err } if s.tokens != nil { replay := newRefreshReplayPayload(pair, bytesToString(userID), sid) if payload, marshalErr := json.Marshal(replay); marshalErr == nil { _ = s.tokens.Set(ctx, refreshReplayKey(jti), payload, refreshReplayTTL) } } return pair, nil } func (s *AuthService) loadRefreshReplay(ctx context.Context, jti string, userID []byte, sessionID string) (TokenPair, bool, error) { if s.tokens == nil { return TokenPair{}, false, nil } raw, err := s.tokens.Get(ctx, refreshReplayKey(jti)) if err != nil || raw == nil { return TokenPair{}, false, err } var payload refreshReplayPayload if err := json.Unmarshal(raw, &payload); err != nil { return TokenPair{}, false, err } if strings.TrimSpace(payload.SessionID) != strings.TrimSpace(sessionID) { return TokenPair{}, false, nil } if strings.TrimSpace(payload.UserID) != bytesToString(userID) { return TokenPair{}, false, nil } if !s.isSessionActive(ctx, userID, sessionID) { return TokenPair{}, false, nil } return payload.toTokenPair(), true, nil } func (s *AuthService) waitForRefreshReplay(ctx context.Context, jti string, userID []byte, sessionID string) (TokenPair, bool, error) { deadline := time.Now().UTC().Add(refreshReplayWaitTimeout) for { replay, ok, err := s.loadRefreshReplay(ctx, jti, userID, sessionID) if err != nil { return TokenPair{}, false, err } if ok { return replay, true, nil } if time.Now().UTC().After(deadline) { return TokenPair{}, false, nil } select { case <-ctx.Done(): return TokenPair{}, false, ctx.Err() case <-time.After(refreshReplayPollInterval): } } } func (s *AuthService) revokeRefreshTokenWithSession(ctx context.Context, refreshToken string) error { if refreshToken == "" { return nil } parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, ErrInvalidToken } return []byte(s.cfg.JWTRefreshSecret), nil }) if err != nil || !parsed.Valid { return nil } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { return nil } jti, _ := claims["jti"].(string) sid, _ := claims["sid"].(string) sub, _ := claims["sub"].(string) if sid != "" && sub != "" { if userID, err := uuidv7.ParseString(sub); err == nil { _ = s.RevokeUserSession(ctx, userID, sid) return nil } } if jti != "" && s.tokens != nil { _ = s.tokens.Delete(ctx, refreshKey(jti)) } return nil } func (s *AuthService) ParseAccessTokenSessionID(ctx context.Context, accessToken string) (string, error) { _, sid, err := s.parseAccessTokenWithSession(ctx, accessToken) if err != nil { return "", err } return sid, nil } func (s *AuthService) GetSessionAuthContextFromAccessToken(ctx context.Context, accessToken string) (string, string, error) { _, sid, err := s.parseAccessTokenWithSession(ctx, accessToken) if err != nil { return "", "", err } if s.tokens == nil { return "email", "email", nil } record, err := s.loadSessionRecord(ctx, sid) if err != nil || record == nil { return "", "", ErrInvalidToken } provider := strings.ToLower(strings.TrimSpace(record.AuthProvider)) if provider == ssoProviderMicrosoft { return "sso", ssoProviderMicrosoft, nil } return "email", "email", nil } func (s *AuthService) ListUserSessions(ctx context.Context, userID []byte, currentSessionID string) ([]UserSession, error) { if len(userID) != 16 { return nil, ErrInvalidToken } if s.tokens == nil { return []UserSession{}, nil } ids, err := s.loadUserSessionIDs(ctx, userID) if err != nil { return nil, err } out := make([]UserSession, 0, len(ids)) for i := range ids { record, err := s.loadSessionRecord(ctx, ids[i]) if err != nil || record == nil { continue } if record.UserID != bytesToString(userID) { continue } out = append(out, UserSession{ ID: record.SessionID, DeviceName: record.DeviceName, UserAgent: record.UserAgent, IPAddress: record.IPAddress, CreatedAt: record.CreatedAt, LastActiveAt: record.LastActiveAt, ActiveNow: record.SessionID == currentSessionID, }) } sort.Slice(out, func(i, j int) bool { return out[i].LastActiveAt.After(out[j].LastActiveAt) }) return out, nil } func (s *AuthService) RevokeUserSession(ctx context.Context, userID []byte, sessionID string) error { if len(userID) != 16 || strings.TrimSpace(sessionID) == "" { return ErrInvalidToken } if s.tokens == nil { return nil } record, err := s.loadSessionRecord(ctx, sessionID) if err != nil { return err } if record == nil { return ErrSessionNotFound } if record.UserID != bytesToString(userID) { return ErrInvalidToken } if strings.TrimSpace(record.CurrentJTI) != "" { _ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI)) } if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) && strings.TrimSpace(record.MicrosoftSID) != "" { _ = s.tokens.Delete(ctx, microsoftSessionKey(record.MicrosoftSID)) } _ = s.tokens.Delete(ctx, sessionKey(sessionID)) _ = s.removeUserSessionID(ctx, userID, sessionID) return nil } func (s *AuthService) RevokeSessionByID(ctx context.Context, sessionID string) error { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return ErrInvalidToken } if s.tokens == nil { return nil } record, err := s.loadSessionRecord(ctx, sessionID) if err != nil { return err } if record == nil { return ErrSessionNotFound } userID, err := uuidv7.ParseString(strings.TrimSpace(record.UserID)) if err != nil { return ErrInvalidToken } return s.RevokeUserSession(ctx, userID, sessionID) } func (s *AuthService) RevokeMicrosoftSessionBySID(ctx context.Context, microsoftSID string) error { microsoftSID = strings.TrimSpace(microsoftSID) if microsoftSID == "" { return ErrInvalidToken } if s.tokens == nil { return nil } raw, err := s.tokens.Get(ctx, microsoftSessionKey(microsoftSID)) if err != nil || raw == nil { return err } sessionID := strings.TrimSpace(string(raw)) if sessionID == "" { _ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID)) return ErrSessionNotFound } record, err := s.loadSessionRecord(ctx, sessionID) if err != nil { return err } if record == nil { _ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID)) return ErrSessionNotFound } if !strings.EqualFold(strings.TrimSpace(record.MicrosoftSID), microsoftSID) { _ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID)) return ErrSessionNotFound } userID, err := uuidv7.ParseString(strings.TrimSpace(record.UserID)) if err != nil { return ErrInvalidToken } return s.RevokeUserSession(ctx, userID, sessionID) } func (s *AuthService) RevokeAllUserSessions(ctx context.Context, userID []byte) error { if len(userID) != 16 { return ErrInvalidToken } if s.tokens == nil { return nil } sessionIDs, err := s.loadUserSessionIDs(ctx, userID) if err != nil { return err } for i := range sessionIDs { sessionID := strings.TrimSpace(sessionIDs[i]) if sessionID == "" { continue } record, loadErr := s.loadSessionRecord(ctx, sessionID) if loadErr != nil { return loadErr } if record != nil && record.UserID == bytesToString(userID) && strings.TrimSpace(record.CurrentJTI) != "" { _ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI)) if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) && strings.TrimSpace(record.MicrosoftSID) != "" { _ = s.tokens.Delete(ctx, microsoftSessionKey(record.MicrosoftSID)) } } _ = s.tokens.Delete(ctx, sessionKey(sessionID)) } _ = s.replaceUserSessionIDs(ctx, userID, []string{}) return nil } func (s *AuthService) isSessionActive(ctx context.Context, userID []byte, sessionID string) bool { if s.tokens == nil || len(userID) != 16 || strings.TrimSpace(sessionID) == "" { return false } ids, err := s.loadUserSessionIDs(ctx, userID) if err != nil { return false } active := false for i := range ids { if ids[i] == sessionID { active = true break } } if !active { return false } record, err := s.loadSessionRecord(ctx, sessionID) if err != nil { return false } if record == nil { return false } if record.UserID != bytesToString(userID) { return false } return true } func (s *AuthService) isSessionIdleExpired(record *authSessionRecord) bool { if record == nil || s.cfg.SessionIdleTTL <= 0 || record.LastActiveAt.IsZero() { return false } if !strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) { return false } return record.LastActiveAt.Add(s.cfg.SessionIdleTTL).Before(time.Now().UTC()) } func (s *AuthService) canRecoverSSOSessionOnIdle(ctx context.Context, userID []byte, record *authSessionRecord) bool { if record == nil || !strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) { return false } if _, err := s.RefreshMicrosoftAccessToken(ctx, userID); err != nil { // Transient Microsoft failures should not force a re-login on an idle // session; only a permanent reauth-required error means it cannot // recover. return !errors.Is(err, ErrMicrosoftReauthRequired) } return true } func (s *AuthService) loadSessionRecord(ctx context.Context, sessionID string) (*authSessionRecord, error) { raw, err := s.tokens.Get(ctx, sessionKey(sessionID)) if err != nil || raw == nil { return nil, err } var record authSessionRecord if err := json.Unmarshal(raw, &record); err != nil { return nil, err } return &record, nil } func (s *AuthService) saveSessionRecord(ctx context.Context, session *authSessionRecord, ttl time.Duration) error { if session == nil || strings.TrimSpace(session.SessionID) == "" { return ErrInvalidToken } payload, err := json.Marshal(session) if err != nil { return err } if !session.RefreshExpAt.IsZero() { remaining := time.Until(session.RefreshExpAt) if remaining <= 0 { return s.tokens.Delete(ctx, sessionKey(session.SessionID)) } ttl = remaining } return s.tokens.Set(ctx, sessionKey(session.SessionID), payload, ttl) } func (s *AuthService) touchSessionActivity(ctx context.Context, userID []byte, sessionID string) { if s.tokens == nil || len(userID) != 16 || strings.TrimSpace(sessionID) == "" { return } record, err := s.loadSessionRecord(ctx, sessionID) if err != nil || record == nil || record.UserID != bytesToString(userID) { return } record.LastActiveAt = time.Now().UTC() if record.RefreshExpAt.IsZero() { _ = s.saveSessionRecord(ctx, record, s.cfg.JWTRefreshTTL) return } _ = s.saveSessionRecord(ctx, record, time.Until(record.RefreshExpAt)) } func (s *AuthService) replaceUserSessionIDs(ctx context.Context, userID []byte, sessionIDs []string) error { unique := make([]string, 0, len(sessionIDs)) seen := make(map[string]struct{}, len(sessionIDs)) for i := range sessionIDs { sessionID := strings.TrimSpace(sessionIDs[i]) if sessionID == "" { continue } if _, ok := seen[sessionID]; ok { continue } seen[sessionID] = struct{}{} unique = append(unique, sessionID) } payload, err := json.Marshal(unique) if err != nil { return err } return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0) } func (s *AuthService) loadUserSessionIDs(ctx context.Context, userID []byte) ([]string, error) { raw, err := s.tokens.Get(ctx, userSessionsKey(userID)) if err != nil || raw == nil { return []string{}, err } var ids []string if err := json.Unmarshal(raw, &ids); err != nil { return []string{}, nil } return ids, nil } func (s *AuthService) addUserSessionID(ctx context.Context, userID []byte, sessionID string) error { if strings.TrimSpace(sessionID) == "" { return nil } ids, err := s.loadUserSessionIDs(ctx, userID) if err != nil { return err } for i := range ids { if ids[i] == sessionID { return nil } } ids = append(ids, sessionID) payload, err := json.Marshal(ids) if err != nil { return err } return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0) } func (s *AuthService) revokeStaleUserSessions(ctx context.Context, userID []byte, sessionIDs []string, keepSessionID string) error { expectedUserID := bytesToString(userID) seen := make(map[string]struct{}, len(sessionIDs)) for i := range sessionIDs { sessionID := strings.TrimSpace(sessionIDs[i]) if sessionID == "" || sessionID == keepSessionID { continue } if _, ok := seen[sessionID]; ok { continue } seen[sessionID] = struct{}{} record, err := s.loadSessionRecord(ctx, sessionID) if err != nil { return err } if record != nil && record.UserID == expectedUserID && strings.TrimSpace(record.CurrentJTI) != "" { _ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI)) } _ = s.tokens.Delete(ctx, sessionKey(sessionID)) } return nil } func (s *AuthService) enforceUserDeviceSlots(ctx context.Context, userID []byte, existingSessionIDs []string, current *authSessionRecord) error { if current == nil { return nil } currentType := strings.TrimSpace(current.DeviceType) if currentType == "" { currentType = "unknown" } kept := make([]string, 0, len(existingSessionIDs)+1) seen := map[string]struct{}{} for i := range existingSessionIDs { sessionID := strings.TrimSpace(existingSessionIDs[i]) if sessionID == "" || sessionID == current.SessionID { continue } if _, ok := seen[sessionID]; ok { continue } seen[sessionID] = struct{}{} record, err := s.loadSessionRecord(ctx, sessionID) if err != nil { return err } if record == nil || record.UserID != bytesToString(userID) { continue } recordType := strings.TrimSpace(record.DeviceType) if recordType == "" { recordType = classifyDeviceType(record.UserAgent, "") } if currentType != "unknown" && recordType == currentType { if strings.TrimSpace(record.CurrentJTI) != "" { _ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI)) } _ = s.tokens.Delete(ctx, sessionKey(sessionID)) continue } kept = append(kept, sessionID) } kept = append(kept, current.SessionID) return s.replaceUserSessionIDs(ctx, userID, kept) } func (s *AuthService) removeUserSessionID(ctx context.Context, userID []byte, sessionID string) error { ids, err := s.loadUserSessionIDs(ctx, userID) if err != nil { return err } out := make([]string, 0, len(ids)) for i := range ids { if ids[i] == sessionID { continue } out = append(out, ids[i]) } payload, err := json.Marshal(out) if err != nil { return err } return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0) } func sessionKey(sessionID string) string { return "auth:session:" + sessionID } func microsoftSessionKey(sessionID string) string { return "auth:session:microsoft:sid:" + strings.TrimSpace(sessionID) } func userSessionsKey(userID []byte) string { return "auth:sessions:user:" + bytesToString(userID) } func deriveDeviceName(userAgent string) string { ua := strings.ToLower(strings.TrimSpace(userAgent)) if ua == "" { return "Unknown device" } os := "Unknown OS" switch { case strings.Contains(ua, "iphone"): os = "iPhone" case strings.Contains(ua, "ipad"): os = "iPad" case strings.Contains(ua, "android"): os = "Android" case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"): os = "macOS" case strings.Contains(ua, "windows"): os = "Windows" case strings.Contains(ua, "linux"): os = "Linux" } browser := "Unknown browser" switch { case strings.Contains(ua, "edg/"): browser = "Edge" case strings.Contains(ua, "chrome/"): browser = "Chrome" case strings.Contains(ua, "firefox/"): browser = "Firefox" case strings.Contains(ua, "safari/"): browser = "Safari" } return browser + " on " + os } func classifyDeviceType(userAgent, hint string) string { if h := normalizeDeviceTypeHint(hint); h != "" { return h } ua := strings.ToLower(strings.TrimSpace(userAgent)) if ua == "" { return "unknown" } switch { case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "android"), strings.Contains(ua, "mobile"): return "mobile" case strings.Contains(ua, "windows"), strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"), strings.Contains(ua, "linux"), strings.Contains(ua, "x11"): return "desktop" default: return "unknown" } } func normalizeDeviceTypeHint(v string) string { switch strings.ToLower(strings.TrimSpace(v)) { case "mobile": return "mobile" case "desktop": return "desktop" default: return "" } }