package app import ( "context" "fmt" "time" "golang.org/x/crypto/bcrypt" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) type AuthService struct { repo repository.AuthRepository jwt *auth.Manager sessions *auth.SessionStore } func NewAuthService(repo repository.AuthRepository, jwt *auth.Manager, sessions *auth.SessionStore) *AuthService { return &AuthService{repo: repo, jwt: jwt, sessions: sessions} } type LoginRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` } type LoginResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresAt int64 `json:"expires_at"` User UserInfo `json:"user"` } type UserInfo struct { ID int64 `json:"id"` Email string `json:"email"` Name string `json:"name"` AvatarURL *string `json:"avatar_url"` TenantID int64 `json:"tenant_id"` TenantRole string `json:"tenant_role"` Permissions []string `json:"permissions"` } func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) { user, err := s.repo.GetUserByEmail(ctx, req.Email) if err != nil { return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect") } membership, err := s.repo.GetTenantMembership(ctx, user.ID) if err != nil { return nil, response.ErrForbidden(40301, "no_tenant", "user has no active tenant membership") } pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.TenantRole) if err != nil { return nil, fmt.Errorf("issue token: %w", err) } tokenHash := auth.HashToken(pair.RefreshToken) if err := s.sessions.SaveRefresh(ctx, pair.RefreshJTI, tokenHash, s.jwt.RefreshTTL()); err != nil { return nil, fmt.Errorf("save refresh: %w", err) } return &LoginResponse{ AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken, ExpiresAt: pair.ExpiresAt, User: UserInfo{ ID: user.ID, Email: user.Email, Name: user.Name, AvatarURL: user.AvatarURL, TenantID: membership.TenantID, TenantRole: membership.TenantRole, Permissions: auth.PermissionsForRole(membership.TenantRole), }, }, nil } type RefreshRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` } type RefreshResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresAt int64 `json:"expires_at"` } func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*RefreshResponse, error) { claims, err := s.jwt.Parse(req.RefreshToken) if err != nil || claims.Subject != "refresh" { return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired") } pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.Role) if err != nil { return nil, fmt.Errorf("issue token: %w", err) } if err := s.sessions.RotateRefresh( ctx, claims.ID, auth.HashToken(req.RefreshToken), pair.RefreshJTI, auth.HashToken(pair.RefreshToken), s.jwt.RefreshTTL(), ); err != nil { switch err { case auth.ErrRefreshSessionNotFound: return nil, response.ErrUnauthorized(40121, "refresh_session_expired", "refresh session not found") case auth.ErrRefreshTokenMismatch: return nil, response.ErrUnauthorized(40122, "refresh_token_mismatch", "refresh token does not match") default: return nil, fmt.Errorf("rotate refresh: %w", err) } } return &RefreshResponse{ AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken, ExpiresAt: pair.ExpiresAt, }, nil } func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, error) { user, err := s.repo.GetUserByID(ctx, actor.UserID) if err != nil { return nil, response.ErrNotFound(40401, "user_not_found", "user not found") } return &UserInfo{ ID: actor.UserID, Email: user.Email, Name: user.Name, AvatarURL: user.AvatarURL, TenantID: actor.TenantID, TenantRole: actor.Role, Permissions: auth.PermissionsForRole(actor.Role), }, nil } func (s *AuthService) Logout(ctx context.Context, accessToken string) error { claims, err := s.jwt.Parse(accessToken) if err != nil { return nil } if claims.Subject == "access" && claims.ExpiresAt != nil { ttl := time.Until(claims.ExpiresAt.Time) if ttl > 0 { _ = s.sessions.Blacklist(ctx, claims.ID, ttl) } } return nil }