init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,993 @@
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 ""
}
}