package service import ( "context" "crypto/rand" "crypto/rsa" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math/big" "net/http" "net/url" "os" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" apprepo "github.com/Tencent/WeKnora/internal/application/repository" "github.com/Tencent/WeKnora/internal/config" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" secutils "github.com/Tencent/WeKnora/internal/utils" ) var ( jwtSecretOnce sync.Once jwtSecret string // ErrUserEmailExists is returned by Register when the target entity's // email already exists. ErrUserEmailExists = errors.New("user with this email already exists") // ErrUserUsernameExists is returned by Register when the target entity's // username already exists. ErrUserUsernameExists = errors.New("user with this username already exists") // ErrUserIdentityConflict is returned by AdminCreateUser when only part // of the requested identity (email or username) collides with an existing // user, so a blind idempotent retry would return the wrong account. ErrUserIdentityConflict = errors.New("email and username refer to conflicting existing identities") // ErrPasswordPolicy is returned when a newly chosen password does not // meet the product's public 3-32 character contract. // It is exported so HTTP handlers can translate the failure to a 400 // without exposing bcrypt or persistence errors. ErrPasswordPolicy = secutils.ErrPasswordPolicy // ErrComplexPasswordPolicy is returned when the runtime complex-password // switch is on and the new password is missing a required character class. ErrComplexPasswordPolicy = secutils.ErrComplexPasswordPolicy // ErrInvalidOldPassword is returned by ChangePassword when the supplied // current password does not match the stored hash. Handlers map this to // a 400 so callers can prompt the user without treating it as a 500. ErrInvalidOldPassword = errors.New("invalid old password") // ErrSamePassword is returned when the new password equals the current // one so callers can reject no-op rotations that would still revoke // every session. ErrSamePassword = errors.New("new password must differ from current password") ) // Machine-readable change-password failure reasons for HTTP details fields. const ( DetailInvalidOldPassword = "invalid_old_password" DetailPasswordPolicy = "password_policy" DetailSamePassword = "same_password" ) // getJwtSecret retrieves the JWT secret from the environment, falling back to a securely generated random secret. func getJwtSecret() string { jwtSecretOnce.Do(func() { if envSecret := strings.TrimSpace(os.Getenv("JWT_SECRET")); envSecret != "" { jwtSecret = envSecret return } randomBytes := make([]byte, 32) if _, err := rand.Read(randomBytes); err != nil { panic(fmt.Sprintf("failed to generate JWT secret: %v", err)) } jwtSecret = base64.StdEncoding.EncodeToString(randomBytes) }) return jwtSecret } // userService implements the UserService interface type userService struct { userRepo interfaces.UserRepository tokenRepo interfaces.AuthTokenRepository tenantService interfaces.TenantService memberService interfaces.TenantMemberService config *config.Config systemSettingSvc interfaces.SystemSettingService } // NewUserService creates a new user service instance func NewUserService( configInfo *config.Config, userRepo interfaces.UserRepository, tokenRepo interfaces.AuthTokenRepository, tenantService interfaces.TenantService, memberService interfaces.TenantMemberService, systemSettingSvc interfaces.SystemSettingService, ) interfaces.UserService { return &userService{ userRepo: userRepo, tokenRepo: tokenRepo, tenantService: tenantService, memberService: memberService, config: configInfo, systemSettingSvc: systemSettingSvc, } } func (s *userService) complexPasswordEnabled(ctx context.Context) bool { return ResolveComplexPasswordEnabled(ctx, s.config, s.systemSettingSvc) } // Register creates a new user account func (s *userService) Register(ctx context.Context, req *types.RegisterRequest) (*types.User, error) { logger.Info(ctx, "Start user registration") // Validate input if req.Username == "" || req.Email == "" || req.Password == "" { return nil, errors.New("username, email and password are required") } // Check if user already exists existingUser, _ := s.userRepo.GetUserByEmail(ctx, req.Email) if existingUser != nil { return nil, ErrUserEmailExists } existingUser, _ = s.userRepo.GetUserByUsername(ctx, req.Username) if existingUser != nil { return nil, ErrUserUsernameExists } // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { logger.Errorf(ctx, "Failed to hash password: %v", err) return nil, errors.New("failed to process password") } provisioning := req.TenantProvisioning if provisioning == "" { provisioning = types.TenantProvisioningCreatePersonal } if !provisioning.IsValid() { return nil, fmt.Errorf("invalid tenant provisioning mode %q", provisioning) } var createdTenant *types.Tenant if provisioning == types.TenantProvisioningCreatePersonal { // Note: RetrieverEngines is left empty - system will use defaults // from RETRIEVE_DRIVER env. tenant := &types.Tenant{ Name: fmt.Sprintf("%s's Workspace", secutils.SanitizeForLog(req.Username)), Description: "Default workspace", Status: "active", } createdTenant, err = s.tenantService.CreateTenant(ctx, tenant) if err != nil { logger.Errorf(ctx, "Failed to create workspace") return nil, errors.New("failed to create workspace") } } // Create user user := &types.User{ ID: uuid.New().String(), Username: req.Username, Email: req.Email, PasswordHash: string(hashedPassword), TenantID: 0, IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if createdTenant != nil { user.TenantID = createdTenant.ID } err = s.userRepo.CreateUser(ctx, user) if err != nil { logger.Errorf(ctx, "Failed to create user: %v", err) if createdTenant != nil { if rollbackErr := s.tenantService.DeleteTenant(ctx, createdTenant.ID); rollbackErr != nil { logger.Errorf(ctx, "Failed to roll back tenant %d after user creation failure: %v", createdTenant.ID, rollbackErr) } } return nil, errors.New("failed to create user") } // Bootstrap an Owner membership so the registrant has full control over // the tenant their account just created. Failure here only logs — the // user record exists and the auth middleware's orphan-tenant recovery // path will recreate the membership on next login. if createdTenant != nil && s.memberService != nil { if _, err := s.memberService.EnsureOwner(ctx, user.ID, createdTenant.ID); err != nil { logger.Errorf(ctx, "Failed to create owner membership for user %s tenant %d: %v", user.ID, createdTenant.ID, err) _ = s.userRepo.DeleteUser(ctx, user.ID) _ = s.tenantService.DeleteTenant(ctx, createdTenant.ID) return nil, errors.New("failed to finalise workspace ownership") } } logger.Info(ctx, "User registered successfully") return user, nil } // Login authenticates a user and returns tokens func (s *userService) Login(ctx context.Context, req *types.LoginRequest) (*types.LoginResponse, error) { logger.Info(ctx, "Start user login") invalidCredentials := func() (*types.LoginResponse, error) { return &types.LoginResponse{ Success: false, Message: "Invalid account or password", }, nil } if req == nil || req.Password == "" { return invalidCredentials() } username, email := strings.TrimSpace(req.Username), strings.TrimSpace(req.Email) var user *types.User var err error if username != "" { user, err = s.userRepo.GetUserByUsername(ctx, username) } else if email != "" { user, err = s.userRepo.GetUserByEmail(ctx, email) } else { return invalidCredentials() } if err != nil || user == nil { logger.Warn(ctx, "Login account lookup failed") return invalidCredentials() } // When both identifiers are supplied they must name the same account. // Never fall back to a different user's email after a username failure. if username != "" && email != "" { emailUser, emailErr := s.userRepo.GetUserByEmail(ctx, email) if emailErr != nil || emailUser == nil || emailUser.ID != user.ID { return invalidCredentials() } } if !user.IsActive { logger.Warn(ctx, "Login account is disabled") return invalidCredentials() } // Verify password err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) if err != nil { logger.Warn(ctx, "Password verification failed") return invalidCredentials() } logger.Info(ctx, "Password verification successful") // Generate tokens. Resolve the target tenant once so the JWT claim // and the tenant we return below agree — otherwise an honoured // "last active tenant" preference would mint a token for tenant N // but tell the client they're in their home tenant. logger.Info(ctx, "Generating tokens") resolvedTenantID := s.resolveLoginTenantID(ctx, user) accessToken, refreshToken, err := s.generateTokensForTenant(ctx, user, resolvedTenantID) if err != nil { logger.Errorf(ctx, "Failed to generate tokens: %v", err) return &types.LoginResponse{ Success: false, Message: "Login failed", }, nil } logger.Info(ctx, "Tokens generated successfully") // Get tenant information. A zero resolved ID is a valid tenantless // identity, not a failed tenant lookup. var tenant *types.Tenant if resolvedTenantID > 0 { tenant, err = s.tenantService.GetTenantByID(ctx, resolvedTenantID) if err != nil { logger.Warn(ctx, "Failed to get tenant info") } else { logger.Info(ctx, "Tenant information retrieved successfully") } } memberships := s.buildMembershipsForUser(ctx, user, tenant) logger.Info(ctx, "User logged in successfully") return &types.LoginResponse{ Success: true, Message: "Login successful", User: user, ActiveTenant: tenant, Memberships: memberships, Token: accessToken, RefreshToken: refreshToken, }, nil } // buildMembershipsForUser returns the user's tenant memberships projected // into the login-response shape. activeTenant (if non-nil and matching one // of the rows) is used to reuse its already-fetched name without a second // DB lookup; other tenants are looked up individually. Errors are logged // but never propagated — a missing memberships array degrades gracefully // to length 0 rather than failing the whole login. // // When the membership service is unavailable (e.g. in tests that wire only // part of the dependency graph), this falls back to a single synthesized // row built from User.TenantID + the active tenant so callers always get // at least one entry. func (s *userService) BuildLoginMemberships( ctx context.Context, user *types.User, activeTenant *types.Tenant, ) []types.Membership { return s.buildMembershipsForUser(ctx, user, activeTenant) } func (s *userService) buildMembershipsForUser( ctx context.Context, user *types.User, activeTenant *types.Tenant, ) []types.Membership { if user == nil { return []types.Membership{} } // Only synthesise a membership from User.TenantID when the membership // service is entirely unavailable (partial DI graphs / legacy tests). // Once ListByUser is reachable, an empty or fully-filtered result is // authoritative: inventing a row from a stale users.tenant_id is what // kept removed workspaces visible in the space switcher (#2586). if s.memberService == nil { return synthFallbackMembership(user, activeTenant) } rows, err := s.memberService.ListByUser(ctx, user.ID) if err != nil { logger.Warnf(ctx, "Failed to list memberships for user %s: %v", user.ID, err) return []types.Membership{} } if len(rows) == 0 { return []types.Membership{} } // 收集需要批量查询名称的 tenant id(跳过 activeTenant 因为它已经在手)。 needsLookup := make([]uint64, 0, len(rows)) for _, m := range rows { if m == nil || m.Status != types.TenantMemberStatusActive { continue } if activeTenant != nil && m.TenantID == activeTenant.ID { continue } needsLookup = append(needsLookup, m.TenantID) } tenantByID := map[uint64]*types.Tenant{} if len(needsLookup) > 0 { if found, terr := s.tenantService.GetTenantsByIDs(ctx, needsLookup); terr == nil { tenantByID = found } else { logger.Warnf(ctx, "Failed to batch-load tenants for memberships (user=%s): %v", user.ID, terr) } } out := make([]types.Membership, 0, len(rows)) for _, m := range rows { if m == nil || m.Status != types.TenantMemberStatusActive { continue } name := "" if activeTenant != nil && m.TenantID == activeTenant.ID { name = activeTenant.Name } else if t, ok := tenantByID[m.TenantID]; ok && t != nil { name = t.Name } // Drop memberships whose tenant row is gone (deleted tenant or // stale tenant_members left over from before cascade delete). if strings.TrimSpace(name) == "" { continue } out = append(out, types.Membership{ TenantID: m.TenantID, TenantName: name, Role: m.Role, }) } return out } // synthFallbackMembership returns a single-row membership list inferred // from User.TenantID. Used only when the membership service itself is // unavailable (partial DI graphs in tests, or a rollout window where the // service has not been wired yet) so the response shape stays consistent. // // Callers that successfully queried tenant_members must NOT use this // helper: an empty membership list is authoritative and synthesising // from users.tenant_id would re-surface workspaces the user was removed // from (#2586). // // The fallback role is intentionally TenantRoleViewer (least privilege): // the login response only feeds UI rendering, and the backend re-derives // the real role from tenant_members on every request. If membership data // is temporarily unavailable, showing a Viewer UI is preferable to // granting a misleading Owner UI that would surface admin controls the // backend will then 403. Once the membership row appears (via the auth // middleware's home-tenant auto-promotion or an admin invitation) the // next /auth/me-style refresh will upgrade the UI to the real role. func synthFallbackMembership(user *types.User, activeTenant *types.Tenant) []types.Membership { if user == nil || user.TenantID == 0 { // Always return a non-nil slice so the login response carries an // empty array rather than `null`, preserving the documented // "always populated" contract on LoginResponse.Memberships. return []types.Membership{} } name := "" if activeTenant != nil && activeTenant.ID == user.TenantID { name = activeTenant.Name } return []types.Membership{{ TenantID: user.TenantID, TenantName: name, Role: types.TenantRoleViewer, }} } // GetOIDCAuthorizationURL builds the OIDC authorization URL. func (s *userService) GetOIDCAuthorizationURL(ctx context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error) { cfg, err := s.getOIDCConfig(ctx) if err != nil { return nil, err } if strings.TrimSpace(redirectURI) == "" { return nil, errors.New("redirect_uri is required") } nonce, err := generateRandomString(24) if err != nil { return nil, fmt.Errorf("failed to generate state: %w", err) } state, err := secutils.SignOIDCState(&secutils.OIDCStatePayload{ Nonce: nonce, RedirectURI: strings.TrimSpace(redirectURI), }) if err != nil { return nil, fmt.Errorf("failed to encode OIDC state: %w", err) } query := url.Values{} query.Set("response_type", "code") query.Set("client_id", cfg.ClientID) query.Set("redirect_uri", redirectURI) query.Set("scope", strings.Join(cfg.Scopes, " ")) query.Set("state", state) authURL := cfg.AuthorizationEndpoint if strings.Contains(authURL, "?") { authURL += "&" + query.Encode() } else { authURL += "?" + query.Encode() } return &types.OIDCAuthURLResponse{ Success: true, ProviderDisplayName: cfg.ProviderDisplayName, AuthorizationURL: authURL, State: state, Nonce: nonce, }, nil } // LoginWithOIDC exchanges code for tokens, loads user info, provisions user if // needed, and returns local login tokens. provisioning is the default tenant // mode applied only when a brand-new local user is auto-created; it is resolved // by the caller from the shared auth.default_tenant_mode policy. func (s *userService) LoginWithOIDC( ctx context.Context, code, redirectURI string, provisioning types.TenantProvisioningMode, ) (*types.OIDCCallbackResponse, error) { if strings.TrimSpace(code) == "" { return nil, errors.New("code is required") } if strings.TrimSpace(redirectURI) == "" { return nil, errors.New("redirect_uri is required") } cfg, err := s.getOIDCConfig(ctx) if err != nil { return nil, err } tokenResp, err := s.exchangeOIDCCode(ctx, cfg, code, redirectURI) if err != nil { return nil, err } userInfo, err := s.resolveOIDCUserInfo(ctx, cfg, tokenResp) if err != nil { return nil, err } if strings.TrimSpace(userInfo.Email) == "" { return nil, errors.New("OIDC provider did not return email") } user, err := s.userRepo.GetUserByEmail(ctx, userInfo.Email) if err != nil && !isUserLookupNotFound(err) { return nil, fmt.Errorf("failed to query user by email: %w", err) } isNewUser := false if isUserLookupNotFound(err) || user == nil { user, err = s.provisionOIDCUser(ctx, userInfo, provisioning) if err != nil { return nil, err } isNewUser = true } if !user.IsActive { return &types.OIDCCallbackResponse{Success: false, Message: "Account is disabled"}, nil } // Resolve target tenant once so the JWT claim and the tenant we // return below stay in sync; see Login for the rationale. resolvedTenantID := s.resolveLoginTenantID(ctx, user) accessToken, refreshToken, err := s.generateTokensForTenant(ctx, user, resolvedTenantID) if err != nil { return nil, fmt.Errorf("failed to generate local tokens: %w", err) } // 拉取 tenant + memberships,让 OIDC 登录的返回结构与本地登录一致, // 前端无须为 OIDC 单独走一次 /auth/me 才能拿到角色。 var tenant *types.Tenant if resolvedTenantID > 0 { if t, terr := s.tenantService.GetTenantByID(ctx, resolvedTenantID); terr == nil { tenant = t } else { logger.Warnf(ctx, "OIDC login: failed to load tenant %d for user %s: %v", resolvedTenantID, user.ID, terr) } } memberships := s.buildMembershipsForUser(ctx, user, tenant) return &types.OIDCCallbackResponse{ Success: true, Message: "登录成功", User: user, Tenant: tenant, Memberships: memberships, Token: accessToken, RefreshToken: refreshToken, IsNewUser: isNewUser, }, nil } // GetUserByID gets a user by ID func (s *userService) GetUserByID(ctx context.Context, id string) (*types.User, error) { return s.userRepo.GetUserByID(ctx, id) } // GetUsersByIDs proxies to the repository batch fetch. Returns an empty // map for an empty input; missing ids are absent from the result. func (s *userService) GetUsersByIDs(ctx context.Context, ids []string) (map[string]*types.User, error) { return s.userRepo.GetUsersByIDs(ctx, ids) } // GetUserByEmail gets a user by email func (s *userService) GetUserByEmail(ctx context.Context, email string) (*types.User, error) { return s.userRepo.GetUserByEmail(ctx, email) } // GetUserByUsername gets a user by username func (s *userService) GetUserByUsername(ctx context.Context, username string) (*types.User, error) { return s.userRepo.GetUserByUsername(ctx, username) } // GetUserByTenantID gets the first user (owner) of a tenant func (s *userService) GetUserByTenantID(ctx context.Context, tenantID uint64) (*types.User, error) { return s.userRepo.GetUserByTenantID(ctx, tenantID) } // UpdateUser updates user information func (s *userService) UpdateUser(ctx context.Context, user *types.User) error { user.UpdatedAt = time.Now() return s.userRepo.UpdateUser(ctx, user) } // ListSystemAdmins lists users with IsSystemAdmin=true. Thin pass-through // to the repository; the handler enforces SystemAdmin gating, so the // service does not duplicate the role check here. func (s *userService) ListSystemAdmins( ctx context.Context, offset, limit int, ) ([]*types.User, int64, error) { return s.userRepo.ListSystemAdmins(ctx, offset, limit) } // RevokeSystemAdmin removes system-admin privileges through the // repository's transactional guard so concurrent revokes cannot remove // the final administrator. func (s *userService) RevokeSystemAdmin(ctx context.Context, userID, actorID string) (*types.User, error) { return s.userRepo.RevokeSystemAdmin(ctx, userID, actorID) } // UpdateUserPreferences applies a partial update over the user's // preferences blob. PATCH semantics: only keys present in `patch` // (non-nil pointer fields) replace the existing value; everything else // is preserved. This lets the front-end PUT only the preference that // changed without having to read-modify-write the whole struct, and // also makes the endpoint forward-compatible — older clients that // don't know about newer keys won't accidentally erase them. func (s *userService) UpdateUserPreferences( ctx context.Context, userID string, patch types.UserPreferences, ) (types.UserPreferences, error) { user, err := s.userRepo.GetUserByID(ctx, userID) if err != nil { return types.UserPreferences{}, err } merged := user.Preferences if patch.LastActiveTenantID != nil { // *0 = "forget my preference, fall back to home on next login"; // any positive value = set/replace. We do not validate membership // here — invalid values get culled on the next login via // resolveLoginTenantID, keeping this endpoint cheap. if *patch.LastActiveTenantID == 0 { merged.LastActiveTenantID = nil } else { v := *patch.LastActiveTenantID merged.LastActiveTenantID = &v } } user.Preferences = merged user.UpdatedAt = time.Now() if err := s.userRepo.UpdateUser(ctx, user); err != nil { return types.UserPreferences{}, err } return merged, nil } // DeleteUser deletes a user func (s *userService) DeleteUser(ctx context.Context, id string) error { return s.userRepo.DeleteUser(ctx, id) } // ChangePassword changes user password after verifying the current // credential. The new password must satisfy ValidatePasswordPolicy so // self-service rotation cannot introduce weaker passwords than // registration / admin reset allow. On success every outstanding session // is revoked so a stolen token cannot survive the rotation. func (s *userService) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error { user, err := s.userRepo.GetUserByID(ctx, userID) if err != nil { return err } // Verify old password before policy checks so callers with a wrong // current credential get a clear failure instead of a policy error. if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil { return ErrInvalidOldPassword } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(newPassword)); err == nil { return ErrSamePassword } if err := ValidatePasswordPolicy(newPassword, s.complexPasswordEnabled(ctx)); err != nil { return err } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return err } user.PasswordHash = string(hashedPassword) user.UpdatedAt = time.Now() if user.Preferences.OidcOnlyLogin != nil && *user.Preferences.OidcOnlyLogin { cleared := false user.Preferences.OidcOnlyLogin = &cleared } if err := s.userRepo.UpdateUser(ctx, user); err != nil { return err } // Invalidate every outstanding session so a stolen token cannot // survive a password rotation. return s.tokenRepo.RevokeTokensByUserID(ctx, userID) } // AdminResetPassword replaces a user's password without checking the previous // credential. Authorization and the cannot-reset-self rule live at the system // admin HTTP boundary; this service owns the security-critical persistence and // session invalidation so no caller can accidentally update only one of them. func (s *userService) AdminResetPassword(ctx context.Context, userID, newPassword string) error { if err := ValidatePasswordPolicy(newPassword, s.complexPasswordEnabled(ctx)); err != nil { return err } user, err := s.userRepo.GetUserByID(ctx, userID) if err != nil { return err } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return err } user.PasswordHash = string(hashedPassword) user.UpdatedAt = time.Now() if err := s.userRepo.UpdateUser(ctx, user); err != nil { return err } return s.tokenRepo.RevokeTokensByUserID(ctx, userID) } // AdminCreateUser provisions a new local user on behalf of a SystemAdmin. // // An absent password generates a random one, returned exactly once. // Any provided password, must satisfy ValidatePasswordPolicy. // // Delegates to Register, so duplicate checks, tenant provisioning and Owner // membership bootstrapping match public registration. func (s *userService) AdminCreateUser( ctx context.Context, req *types.AdminCreateUserRequest, provisioning types.TenantProvisioningMode, ) (*types.User, string, error) { if req == nil || strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Email) == "" { return nil, "", errors.New("username and email are required") } password := "" generated := false complexPasswordEnabled := s.complexPasswordEnabled(ctx) if req.Password == nil { randomPassword, err := generatePolicyCompliantPassword(complexPasswordEnabled) if err != nil { return nil, "", fmt.Errorf("failed to generate password: %w", err) } password = randomPassword generated = true } else { password = *req.Password } // Generation triggers only on an absent password. Any provided // value, empty or whitespace-only, is hashed byte-for-byte and must // satisfy the password policy. if err := ValidatePasswordPolicy(password, complexPasswordEnabled); err != nil { return nil, "", err } user, err := s.Register(ctx, &types.RegisterRequest{ Username: strings.TrimSpace(req.Username), Email: strings.TrimSpace(req.Email), Password: password, TenantProvisioning: provisioning, }) // WARN: idempotency is sequential only. Two concurrent creates of the // same identity can race past Register's check; the loser gets a 500, // and a retry resolves idempotently. if err != nil { // Register owns duplicate detection; on a duplicate we surface // the existing row so the caller can respond idempotently. The // sentinel names the colliding identity, so the lookup is // targeted at exactly that key. switch { case errors.Is(err, ErrUserEmailExists): return s.adminCreateUserOnDuplicate( ctx, req, err, func(ctx context.Context) (*types.User, error) { return s.userRepo.GetUserByEmail(ctx, strings.TrimSpace(req.Email)) }, ) case errors.Is(err, ErrUserUsernameExists): return s.adminCreateUserOnDuplicate( ctx, req, err, func(ctx context.Context) (*types.User, error) { return s.userRepo.GetUserByUsername(ctx, strings.TrimSpace(req.Username)) }, ) } return nil, "", err } if generated { return user, password, nil } return user, "", nil } func (s *userService) adminCreateUserOnDuplicate( ctx context.Context, req *types.AdminCreateUserRequest, dupErr error, lookup func(context.Context) (*types.User, error), ) (*types.User, string, error) { existing, lookupErr := lookup(ctx) if lookupErr != nil || existing == nil { return nil, "", dupErr } if adminCreateIdentityMatches(existing, req.Username, req.Email) { return existing, "", dupErr } return nil, "", ErrUserIdentityConflict } // adminCreateIdentityMatches reports whether an existing row is the exact // identity an admin create is idempotently retrying (both email and username). func adminCreateIdentityMatches(existing *types.User, username, email string) bool { if existing == nil { return false } return existing.Username == strings.TrimSpace(username) && existing.Email == strings.TrimSpace(email) } // ValidatePassword validates user password func (s *userService) ValidatePassword(ctx context.Context, userID string, password string) error { user, err := s.userRepo.GetUserByID(ctx, userID) if err != nil { return err } return bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) } // GenerateTokens generates access and refresh tokens for user. The // access token's tenant_id claim defaults to user.TenantID (home), but // if the user has persisted a still-valid "last active tenant" // preference we honour it instead — so login (and the refresh-token // rotation path that also calls into here) lands the user back where // they left off across devices. SwitchTenant remains the explicit tool // for switching to an arbitrary membership. func (s *userService) GenerateTokens( ctx context.Context, user *types.User, ) (accessToken, refreshToken string, err error) { return s.generateTokensForTenant(ctx, user, s.resolveLoginTenantID(ctx, user)) } // resolveLoginTenantID picks the tenant whose ID should be encoded in a // freshly minted access token. The contract: // // 1. If the user has no LastActiveTenantID preference set (or it points // at home), return home — the historical behaviour. A tenantless user // with an active membership adopts their earliest membership instead; // this repairs partial invitation/admin-assignment flows. // 2. Otherwise validate the preference: the tenant must still exist and // the user must still have an active membership (or be a cross-tenant // superuser). Validation failure logs a warning, best-effort clears // the stale preference (so we don't waste a DB round-trip on every // subsequent login), and falls back to home. // // This is intentionally a private method on userService so it can reach // memberService / tenantService / userRepo. Errors from the validation // path never fail login; the worst case is the user lands in home. func (s *userService) resolveLoginTenantID(ctx context.Context, user *types.User) uint64 { if user == nil { return 0 } pref := user.Preferences.LastActiveTenantID if pref == nil || *pref == 0 || *pref == user.TenantID { return s.homeOrFirstMembershipTenant(ctx, user) } preferred := *pref // Tenant must still exist. if s.tenantService != nil { if _, err := s.tenantService.GetTenantByID(ctx, preferred); err != nil { logger.Warnf(ctx, "resolveLoginTenantID: preferred tenant %d not loadable for user %s, "+ "clearing preference and falling back to home: %v", preferred, user.ID, err) s.clearLastActiveTenantPreference(ctx, user) return s.homeOrFirstMembershipTenant(ctx, user) } } // Membership (or cross-tenant superuser) must still be valid. Mirrors // the gate in SwitchTenant so the two entry points stay consistent. if !user.CanAccessAllTenants { if s.memberService == nil { logger.Warnf(ctx, "resolveLoginTenantID: member service unavailable; falling back to home for user %s", user.ID) return user.TenantID } member, err := s.memberService.GetMembership(ctx, user.ID, preferred) if err != nil || member == nil || member.Status != types.TenantMemberStatusActive { logger.Warnf(ctx, "resolveLoginTenantID: user %s no longer has active membership in tenant %d, "+ "clearing preference and falling back to home (err=%v)", user.ID, preferred, err) s.clearLastActiveTenantPreference(ctx, user) return s.homeOrFirstMembershipTenant(ctx, user) } } return preferred } // homeOrFirstMembershipTenant returns the user's home tenant, or — for a // tenantless identity (TenantID == 0) — the earliest active membership. // Shared by the happy path and the stale-preference fallbacks so a // tenantless session with a valid membership never gets a zero-tenant // token when a usable tenant is available (repairs partial // invitation/admin-assignment flows). resolveFirstMembershipTenant // best-effort persists the resolved tenant as the new home. // // When users.tenant_id is non-zero we still verify an active membership // still exists (mirroring resolveLoginTenantID's check on // LastActiveTenantID). A dangling home pointer is common after // RemoveMember: the membership row is soft-deleted but users.tenant_id // was historically left untouched, which made the removed workspace // reappear via synthFallbackMembership (#2586). Superusers that can // access every tenant skip the membership gate. func (s *userService) homeOrFirstMembershipTenant(ctx context.Context, user *types.User) uint64 { if user == nil { return 0 } if user.TenantID == 0 { return s.resolveFirstMembershipTenant(ctx, user) } if user.CanAccessAllTenants || s.memberService == nil { return user.TenantID } member, err := s.memberService.GetMembership(ctx, user.ID, user.TenantID) if err == nil && member != nil && member.Status == types.TenantMemberStatusActive { return user.TenantID } logger.Warnf(ctx, "homeOrFirstMembershipTenant: user %s home tenant %d has no active membership, "+ "clearing stale home and re-resolving (err=%v)", user.ID, user.TenantID, err) s.clearStaleHomeTenant(ctx, user) return s.resolveFirstMembershipTenant(ctx, user) } // clearStaleHomeTenant best-effort zeroes users.tenant_id (and a // LastActiveTenantID that pointed at the same workspace) after the home // membership is observed to be gone. Failures are logged but never // fail login: the in-memory user is already corrected for this request. func (s *userService) clearStaleHomeTenant(ctx context.Context, user *types.User) { if user == nil { return } staleHome := user.TenantID user.TenantID = 0 if user.Preferences.LastActiveTenantID != nil && *user.Preferences.LastActiveTenantID == staleHome { user.Preferences.LastActiveTenantID = nil } if s.userRepo == nil { return } if err := s.userRepo.UpdateUser(ctx, user); err != nil { logger.Warnf(ctx, "clearStaleHomeTenant: failed to persist cleared home for user %s (was tenant %d): %v", user.ID, staleHome, err) } } // resolveFirstMembershipTenant makes a tenantless identity usable when an // active membership already exists (for example, an invitation was accepted // but persisting the default tenant failed). ListByUser is stably ordered by // join time, so the earliest valid membership is deterministic. Persisting it // as home is best-effort: even if the repair write fails, the freshly issued // token can still be scoped to the membership and the next login retries. func (s *userService) resolveFirstMembershipTenant(ctx context.Context, user *types.User) uint64 { if user == nil || s.memberService == nil { return 0 } members, err := s.memberService.ListByUser(ctx, user.ID) if err != nil { logger.Warnf(ctx, "resolveLoginTenantID: failed to list memberships for tenantless user %s: %v", user.ID, err) return 0 } for _, member := range members { if member == nil || member.TenantID == 0 || member.Status != types.TenantMemberStatusActive { continue } if s.tenantService != nil { if _, err := s.tenantService.GetTenantByID(ctx, member.TenantID); err != nil { logger.Warnf(ctx, "resolveLoginTenantID: tenant %d for tenantless user %s is unavailable: %v", member.TenantID, user.ID, err) continue } } user.TenantID = member.TenantID if s.userRepo != nil { if err := s.userRepo.UpdateUser(ctx, user); err != nil { logger.Warnf(ctx, "resolveLoginTenantID: failed to persist tenant %d for tenantless user %s: %v", member.TenantID, user.ID, err) user.TenantID = 0 } } return member.TenantID } return 0 } // clearLastActiveTenantPreference is the best-effort cleanup half of // resolveLoginTenantID. Failures here are logged but never propagated: // the in-memory user already has the preference cleared for this login, // and the next login will re-attempt the cleanup. func (s *userService) clearLastActiveTenantPreference(ctx context.Context, user *types.User) { if user == nil { return } user.Preferences.LastActiveTenantID = nil if err := s.userRepo.UpdateUser(ctx, user); err != nil { logger.Warnf(ctx, "clearLastActiveTenantPreference: failed to persist cleared preference for user %s: %v", user.ID, err) } } // generateTokensForTenant is the shared implementation behind // GenerateTokens and SwitchTenant. It encodes activeTenantID into the // access token's tenant_id claim so the auth middleware scopes future // requests there. func (s *userService) generateTokensForTenant( ctx context.Context, user *types.User, activeTenantID uint64, ) (accessToken, refreshToken string, err error) { // Generate access token (expires in 24 hours) accessClaims := jwt.MapClaims{ "user_id": user.ID, "email": user.Email, "tenant_id": activeTenantID, "exp": time.Now().Add(24 * time.Hour).Unix(), "iat": time.Now().Unix(), "type": "access", } accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) accessToken, err = accessTokenObj.SignedString([]byte(getJwtSecret())) if err != nil { return "", "", err } // Generate refresh token (expires in 7 days) refreshClaims := jwt.MapClaims{ "user_id": user.ID, "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), "iat": time.Now().Unix(), "type": "refresh", } refreshTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) refreshToken, err = refreshTokenObj.SignedString([]byte(getJwtSecret())) if err != nil { return "", "", err } // Store tokens in database accessTokenRecord := &types.AuthToken{ ID: uuid.New().String(), UserID: user.ID, Token: accessToken, TokenType: "access_token", ExpiresAt: time.Now().Add(24 * time.Hour), CreatedAt: time.Now(), UpdatedAt: time.Now(), } refreshTokenRecord := &types.AuthToken{ ID: uuid.New().String(), UserID: user.ID, Token: refreshToken, TokenType: "refresh_token", ExpiresAt: time.Now().Add(7 * 24 * time.Hour), CreatedAt: time.Now(), UpdatedAt: time.Now(), } _ = s.tokenRepo.CreateToken(ctx, accessTokenRecord) _ = s.tokenRepo.CreateToken(ctx, refreshTokenRecord) return accessToken, refreshToken, nil } // SwitchTenant verifies that user has an active membership in // targetTenantID and issues a new token pair scoped to that tenant. // The previous refresh token (if provided) is revoked so the old session // can no longer roll forward into the source tenant. // // On success the target is also recorded as the user's last-active- // tenant preference. Refresh tokens do not carry tenant_id, so // RefreshToken / the next login re-resolve from this preference; // a successful switch therefore has to persist it before minting // tokens. A write failure aborts the switch so we never return a // token pair whose later refresh would bounce to a different workspace. // // Returns ErrMembershipNotFound when the user is not a member of the // target tenant. Cross-tenant superuser access (CanAccessAllTenants) // is allowed without a membership row, mirroring the auth middleware's // resolveTenantRole behaviour. func (s *userService) SwitchTenant( ctx context.Context, user *types.User, targetTenantID uint64, currentRefreshToken string, ) (*types.LoginResponse, error) { if user == nil { return nil, errors.New("user is required") } if targetTenantID == 0 { return nil, errors.New("target workspace ID is required") } // Verify membership unless the caller is a cross-tenant superuser // switching outside their home tenant. if !user.CanAccessAllTenants || targetTenantID == user.TenantID { if s.memberService == nil { return nil, errors.New("workspace membership service unavailable") } member, err := s.memberService.GetMembership(ctx, user.ID, targetTenantID) if err != nil { return nil, fmt.Errorf("lookup membership: %w", err) } if member == nil || member.Status != types.TenantMemberStatusActive { return nil, ErrMembershipNotFound } } tenant, err := s.tenantService.GetTenantByID(ctx, targetTenantID) if err != nil { return nil, fmt.Errorf("load target workspace: %w", err) } // Persist before minting tokens so a 200 response is also a // durable landing-preference update. RefreshToken re-reads this // field; writing after issue would leave a window where access // is scoped to targetTenantID but refresh falls back to home. if err := s.recordLastActiveTenant(ctx, user, targetTenantID); err != nil { return nil, fmt.Errorf("record last-active-tenant preference: %w", err) } accessToken, refreshToken, err := s.generateTokensForTenant(ctx, user, targetTenantID) if err != nil { return nil, fmt.Errorf("generate tokens: %w", err) } // Best-effort revoke of the previous refresh token. Failure is // logged but not fatal — the new tokens are already issued and the // old refresh token will expire naturally. if strings.TrimSpace(currentRefreshToken) != "" { if err := s.RevokeToken(ctx, currentRefreshToken); err != nil { logger.Warnf(ctx, "Failed to revoke previous refresh token during tenant switch: %v", err) } } memberships := s.buildMembershipsForUser(ctx, user, tenant) return &types.LoginResponse{ Success: true, Message: "Workspace switched", User: user, ActiveTenant: tenant, Memberships: memberships, Token: accessToken, RefreshToken: refreshToken, }, nil } // recordLastActiveTenant records tenantID as the user's last-active- // tenant preference so fresh logins and refresh-token rotations land // in the last activated workspace. Switching home writes the home ID // rather than clearing; resolveLoginTenantID treats *home the same as // nil today. (The SPA still sends 0 when switching home — landing is // equivalent unless home tenant_id is later rewritten while the old // home membership remains.) Errors are returned to the caller. func (s *userService) recordLastActiveTenant(ctx context.Context, user *types.User, tenantID uint64) error { if user == nil || tenantID == 0 { return nil } patch := types.UserPreferences{LastActiveTenantID: &tenantID} merged, err := s.UpdateUserPreferences(ctx, user.ID, patch) if err != nil { return err } // Reflect the persisted preferences in the switch response. user.Preferences = merged return nil } // ValidateToken validates an access token. The second return value is // the JWT's `tenant_id` claim — i.e. the tenant the token was minted // for, which may differ from user.TenantID after a /auth/switch-tenant // call. Tokens minted before tenant-level RBAC don't carry the claim; // in that case we fall back to user.TenantID for backward compatibility. func (s *userService) ValidateToken(ctx context.Context, tokenString string) (*types.User, uint64, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(getJwtSecret()), nil }) if err != nil || !token.Valid { return nil, 0, errors.New("invalid token") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, 0, errors.New("invalid token claims") } userID, ok := claims["user_id"].(string) if !ok { return nil, 0, errors.New("invalid user ID in token") } if isRefreshTokenClaims(claims) { return nil, 0, errors.New("refresh token cannot be used as access token") } // Check if token is revoked tokenRecord, err := s.tokenRepo.GetTokenByValue(ctx, tokenString) if err != nil || tokenRecord == nil || tokenRecord.IsRevoked { return nil, 0, errors.New("token is revoked") } if tokenRecord.TokenType == "refresh_token" { return nil, 0, errors.New("refresh token cannot be used as access token") } user, err := s.userRepo.GetUserByID(ctx, userID) if err != nil { return nil, 0, err } if user == nil || !user.IsActive { return nil, 0, errors.New("user account is inactive") } // Extract active tenant from the JWT. Anything missing or unparseable // falls back to the user's home tenant so old tokens (and tokens issued // by code paths that don't yet set the claim) keep working. activeTenantID := tenantIDFromClaims(claims, user.TenantID) return user, activeTenantID, nil } func isRefreshTokenClaims(claims jwt.MapClaims) bool { tokenType, ok := claims["type"].(string) return ok && tokenType == "refresh" } func userIDFromSignedToken(tokenString string) (string, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(getJwtSecret()), nil }, jwt.WithoutClaimsValidation()) if err != nil || token == nil || !token.Valid { return "", errors.New("invalid token") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return "", errors.New("invalid token claims") } userID, ok := claims["user_id"].(string) if !ok || strings.TrimSpace(userID) == "" { return "", errors.New("invalid user ID in token") } return userID, nil } // tenantIDFromClaims pulls the active tenant ID out of a parsed JWT // claim map. Returns fallback when the claim is missing or has an // unrecognised type. Extracted as a free function so it can be unit // tested without standing up the full userService dependency graph. // // JSON numbers come back as float64 from jwt.MapClaims; the int64 / // uint64 branches cover legacy code paths and tests that build claims // directly. Negative values are treated as missing. func tenantIDFromClaims(claims jwt.MapClaims, fallback uint64) uint64 { raw, ok := claims["tenant_id"] if !ok { return fallback } switch v := raw.(type) { case float64: if v > 0 { return uint64(v) } case int64: if v > 0 { return uint64(v) } case uint64: if v > 0 { return v } } return fallback } // RefreshToken refreshes access token using refresh token func (s *userService) RefreshToken( ctx context.Context, refreshTokenString string, ) (accessToken, newRefreshToken string, err error) { token, err := jwt.Parse(refreshTokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(getJwtSecret()), nil }) if err != nil || !token.Valid { return "", "", errors.New("invalid refresh token") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return "", "", errors.New("invalid token claims") } tokenType, ok := claims["type"].(string) if !ok || tokenType != "refresh" { return "", "", errors.New("not a refresh token") } userID, ok := claims["user_id"].(string) if !ok { return "", "", errors.New("invalid user ID in token") } // Check if token is revoked tokenRecord, err := s.tokenRepo.GetTokenByValue(ctx, refreshTokenString) if err != nil || tokenRecord == nil || tokenRecord.IsRevoked { return "", "", errors.New("refresh token is revoked") } if tokenRecord.TokenType != "refresh_token" { return "", "", errors.New("not a refresh token") } // Get user user, err := s.userRepo.GetUserByID(ctx, userID) if err != nil { return "", "", err } if user == nil || !user.IsActive { return "", "", errors.New("user account is inactive") } // Revoke old refresh token tokenRecord.IsRevoked = true _ = s.tokenRepo.UpdateToken(ctx, tokenRecord) // Generate new tokens return s.GenerateTokens(ctx, user) } // Logout invalidates every outstanding session for the user identified by // the presented JWT. Access and refresh tokens are both accepted so clients // can end the session without refreshing first; expired tokens are allowed // so logout still works after the access token TTL. func (s *userService) Logout(ctx context.Context, tokenString string) error { userID, err := userIDFromSignedToken(tokenString) if err != nil { return err } return s.tokenRepo.RevokeTokensByUserID(ctx, userID) } // RevokeToken revokes a token func (s *userService) RevokeToken(ctx context.Context, tokenString string) error { tokenRecord, err := s.tokenRepo.GetTokenByValue(ctx, tokenString) if err != nil { return err } tokenRecord.IsRevoked = true tokenRecord.UpdatedAt = time.Now() return s.tokenRepo.UpdateToken(ctx, tokenRecord) } // GetCurrentUser gets current user from context func (s *userService) GetCurrentUser(ctx context.Context) (*types.User, error) { user, ok := ctx.Value(types.UserContextKey).(*types.User) if !ok { return nil, errors.New("user not found in context") } return user, nil } // SearchUsers searches users by username or email func (s *userService) SearchUsers(ctx context.Context, query string, limit int) ([]*types.User, error) { if query == "" { return []*types.User{}, nil } return s.userRepo.SearchUsers(ctx, query, limit) } type oidcDiscoveryDocument struct { AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` UserInfoEndpoint string `json:"userinfo_endpoint"` JwksURI string `json:"jwks_uri"` Issuer string `json:"issuer"` } type oidcTokenResponse struct { AccessToken string `json:"access_token"` IDToken string `json:"id_token"` TokenType string `json:"token_type"` } func newOIDCHTTPClient() *http.Client { cfg := secutils.DefaultSSRFSafeHTTPClientConfig() cfg.Timeout = 30 * time.Second return secutils.NewSSRFSafeHTTPClient(cfg) } func validateOIDCEndpoint(label, endpoint string, required bool) error { endpoint = strings.TrimSpace(endpoint) if endpoint == "" { if required { return fmt.Errorf("OIDC %s endpoint is required", label) } return nil } if err := secutils.ValidateURLForSSRF(endpoint); err != nil { return fmt.Errorf("OIDC %s endpoint failed SSRF validation: %w", label, err) } return nil } func validateOIDCEndpoints(cfg *config.OIDCAuthConfig) error { if err := validateOIDCEndpoint("authorization", cfg.AuthorizationEndpoint, true); err != nil { return err } if err := validateOIDCEndpoint("token", cfg.TokenEndpoint, true); err != nil { return err } if err := validateOIDCEndpoint("userinfo", cfg.UserInfoEndpoint, false); err != nil { return err } if err := validateOIDCEndpoint("jwks", cfg.JwksURI, false); err != nil { return err } return nil } func (s *userService) getOIDCConfig(ctx context.Context) (*config.OIDCAuthConfig, error) { if s.config == nil || s.config.OIDCAuth == nil || !s.config.OIDCAuth.Enable { return nil, errors.New("OIDC login is disabled") } cfg := *s.config.OIDCAuth if cfg.UserInfoMapping == nil { cfg.UserInfoMapping = &config.OIDCUserInfoMapping{Username: "name", Email: "email"} } if err := s.populateOIDCEndpoints(ctx, &cfg); err != nil { return nil, err } return &cfg, nil } func oidcNeedsDiscovery(cfg *config.OIDCAuthConfig) bool { return strings.TrimSpace(cfg.AuthorizationEndpoint) == "" || strings.TrimSpace(cfg.TokenEndpoint) == "" || strings.TrimSpace(cfg.JwksURI) == "" || strings.TrimSpace(cfg.IssuerURL) == "" } func (s *userService) populateOIDCEndpoints(ctx context.Context, cfg *config.OIDCAuthConfig) error { if oidcNeedsDiscovery(cfg) { if strings.TrimSpace(cfg.DiscoveryURL) == "" { if strings.TrimSpace(cfg.AuthorizationEndpoint) == "" || strings.TrimSpace(cfg.TokenEndpoint) == "" { return errors.New("OIDC discovery_url or explicit endpoints are required") } } else if err := s.applyOIDCDiscoveryDocument(ctx, cfg); err != nil { return err } } if strings.TrimSpace(cfg.AuthorizationEndpoint) == "" || strings.TrimSpace(cfg.TokenEndpoint) == "" { return errors.New("OIDC discovery document missing required endpoints") } return validateOIDCEndpoints(cfg) } func (s *userService) applyOIDCDiscoveryDocument(ctx context.Context, cfg *config.OIDCAuthConfig) error { if err := validateOIDCEndpoint("discovery", cfg.DiscoveryURL, true); err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.DiscoveryURL, nil) if err != nil { return fmt.Errorf("failed to create OIDC discovery request: %w", err) } resp, err := newOIDCHTTPClient().Do(req) if err != nil { return fmt.Errorf("failed to load OIDC discovery document: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return fmt.Errorf("OIDC discovery request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) } var doc oidcDiscoveryDocument if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil { return fmt.Errorf("failed to decode OIDC discovery document: %w", err) } if strings.TrimSpace(cfg.AuthorizationEndpoint) == "" { cfg.AuthorizationEndpoint = doc.AuthorizationEndpoint } if strings.TrimSpace(cfg.TokenEndpoint) == "" { cfg.TokenEndpoint = doc.TokenEndpoint } if strings.TrimSpace(cfg.UserInfoEndpoint) == "" { cfg.UserInfoEndpoint = doc.UserInfoEndpoint } if strings.TrimSpace(cfg.JwksURI) == "" { cfg.JwksURI = doc.JwksURI } if strings.TrimSpace(cfg.IssuerURL) == "" { cfg.IssuerURL = doc.Issuer } return nil } func (s *userService) exchangeOIDCCode(ctx context.Context, cfg *config.OIDCAuthConfig, code, redirectURI string) (*oidcTokenResponse, error) { if err := validateOIDCEndpoint("token", cfg.TokenEndpoint, true); err != nil { return nil, err } form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("code", code) form.Set("redirect_uri", redirectURI) form.Set("client_id", cfg.ClientID) form.Set("client_secret", cfg.ClientSecret) req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenEndpoint, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("failed to create OIDC token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := newOIDCHTTPClient().Do(req) if err != nil { return nil, fmt.Errorf("failed to exchange OIDC code: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("OIDC token exchange failed: status=%d", resp.StatusCode) } var tokenResp oidcTokenResponse if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { return nil, fmt.Errorf("failed to decode OIDC token response: %w", err) } if strings.TrimSpace(tokenResp.AccessToken) == "" && strings.TrimSpace(tokenResp.IDToken) == "" { return nil, errors.New("OIDC token response missing access_token and id_token") } return &tokenResp, nil } func (s *userService) resolveOIDCUserInfo(ctx context.Context, cfg *config.OIDCAuthConfig, tokenResp *oidcTokenResponse) (*types.OIDCUserInfo, error) { claims := map[string]interface{}{} verifiedFromIDToken := false if idToken := strings.TrimSpace(tokenResp.IDToken); idToken != "" { if strings.TrimSpace(cfg.JwksURI) == "" { if strings.TrimSpace(cfg.UserInfoEndpoint) == "" || strings.TrimSpace(tokenResp.AccessToken) == "" { return nil, errors.New("cannot verify OIDC id_token: no jwks_uri configured") } logger.Warnf(ctx, "OIDC id_token ignored: no jwks_uri configured; relying on userinfo endpoint") } else { idTokenClaims, err := s.verifyOIDCIDToken(ctx, cfg, idToken) if err != nil { return nil, fmt.Errorf("OIDC id_token verification failed: %w", err) } for k, v := range idTokenClaims { claims[k] = v } verifiedFromIDToken = true } } if strings.TrimSpace(cfg.UserInfoEndpoint) != "" && strings.TrimSpace(tokenResp.AccessToken) != "" { userInfoClaims, err := s.fetchOIDCUserInfo(ctx, cfg.UserInfoEndpoint, tokenResp.AccessToken) if err != nil { if !verifiedFromIDToken { return nil, fmt.Errorf("failed to fetch OIDC userinfo: %w", err) } logger.Warnf(ctx, "Failed to fetch OIDC userinfo, using verified id_token claims: %v", err) } else { for k, v := range userInfoClaims { claims[k] = v } } } if len(claims) == 0 { return nil, errors.New("OIDC provider returned no user claims") } info := &types.OIDCUserInfo{Claims: claims} if sub, _ := claims["sub"].(string); sub != "" { info.Subject = sub } info.Username = extractClaimAsString(claims, cfg.UserInfoMapping.Username) info.Email = extractClaimAsString(claims, cfg.UserInfoMapping.Email) if info.Username == "" { info.Username = extractClaimAsString(claims, "preferred_username") } if info.Username == "" { info.Username = extractClaimAsString(claims, "name") } if info.Username == "" && info.Email != "" { info.Username = strings.Split(info.Email, "@")[0] } return info, nil } func (s *userService) fetchOIDCUserInfo(ctx context.Context, endpoint, accessToken string) (map[string]interface{}, error) { if err := validateOIDCEndpoint("userinfo", endpoint, true); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := newOIDCHTTPClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("userinfo request failed: status=%d", resp.StatusCode) } var claims map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil { return nil, err } return claims, nil } // provisionOIDCUser auto-creates a local account for a first-time OIDC // login. The provisioning mode is decided by the caller (the OIDC callback // handler resolves it from the same auth.default_tenant_mode system-setting // that governs public password registration) so both entry points share a // single deployment policy. An empty mode falls back to create_personal via // Register's own defaulting. func (s *userService) provisionOIDCUser( ctx context.Context, info *types.OIDCUserInfo, provisioning types.TenantProvisioningMode, ) (*types.User, error) { username := s.generateOIDCUsername(ctx, info) randomPassword, err := generateRandomString(32) if err != nil { return nil, fmt.Errorf("failed to generate password for OIDC user: %w", err) } user, err := s.Register(ctx, &types.RegisterRequest{ Username: username, Email: info.Email, Password: randomPassword, TenantProvisioning: provisioning, }) if err != nil { return nil, fmt.Errorf("failed to auto-provision OIDC user: %w", err) } oidcOnly := true user.Preferences.OidcOnlyLogin = &oidcOnly user.UpdatedAt = time.Now() if err := s.userRepo.UpdateUser(ctx, user); err != nil { return nil, fmt.Errorf("failed to mark OIDC-only login preference: %w", err) } return user, nil } func (s *userService) generateOIDCUsername(ctx context.Context, info *types.OIDCUserInfo) string { base := sanitizeUsernameCandidate(info.Username) if base == "" { base = sanitizeUsernameCandidate(strings.Split(info.Email, "@")[0]) } if base == "" { base = "oidc-user" } candidate := base for i := 0; i < 20; i++ { existing, err := s.userRepo.GetUserByUsername(ctx, candidate) if isUserLookupNotFound(err) || (err == nil && existing == nil) { return candidate } if err != nil && !isUserLookupNotFound(err) { logger.Warnf(ctx, "Failed to check existing OIDC username %q: %v", candidate, err) } candidate = fmt.Sprintf("%s-%d", base, i+1) } return fmt.Sprintf("%s-%d", base, time.Now().Unix()) } func generateRandomString(length int) (string, error) { buffer := make([]byte, length) if _, err := rand.Read(buffer); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buffer), nil } func getRandomChar(charset string) (byte, error) { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) if err != nil { return 0, err } return charset[n.Int64()], nil } func generateComplexPassword(length int) (string, error) { if length < 4 { return "", fmt.Errorf("password length must be at least 4") } password := make([]byte, 0, length) c, err := getRandomChar(upperChars) if err != nil { return "", err } password = append(password, c) c, err = getRandomChar(lowerChars) if err != nil { return "", err } password = append(password, c) c, err = getRandomChar(digitChars) if err != nil { return "", err } password = append(password, c) c, err = getRandomChar(passwordSpecialChars) if err != nil { return "", err } password = append(password, c) for len(password) < length { c, err = getRandomChar(allChars) if err != nil { return "", err } password = append(password, c) } // Fisher-Yates shuffle for i := len(password) - 1; i > 0; i-- { n, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) if err != nil { return "", err } j := int(n.Int64()) password[i], password[j] = password[j], password[i] } return string(password), nil } // generatePolicyCompliantPassword returns a cryptographically random // password that satisfies ValidatePasswordPolicy. // // Generated credentials remain longer than the minimum user-chosen password. func generatePolicyCompliantPassword(complexPasswordEnabled bool) (string, error) { var password string var err error for { if complexPasswordEnabled { password, err = generateComplexPassword(16) } else { password, err = generateRandomString(24) } if err != nil { return "", err } if ValidatePasswordPolicy(password, complexPasswordEnabled) == nil { return password, nil } } } // oidcJWK / oidcJWKS model the subset of a JWKS document we need to rebuild an // RSA signing key. Only RSA keys are supported (the overwhelmingly common OIDC // id_token signing type); other key types are skipped during lookup. type oidcJWK struct { Kty string `json:"kty"` Kid string `json:"kid"` Alg string `json:"alg"` Use string `json:"use"` N string `json:"n"` E string `json:"e"` } type oidcJWKS struct { Keys []oidcJWK `json:"keys"` } // rsaPublicKey rebuilds an *rsa.PublicKey from the base64url modulus/exponent of // a JWK (RFC 7518 §6.3.1). func decodeJWKBase64(value string) ([]byte, error) { if b, err := base64.RawURLEncoding.DecodeString(value); err == nil && len(b) > 0 { return b, nil } return base64.URLEncoding.DecodeString(value) } func (k oidcJWK) rsaPublicKey() (*rsa.PublicKey, error) { if !strings.EqualFold(k.Kty, "RSA") { return nil, fmt.Errorf("unsupported JWK key type: %s", k.Kty) } nBytes, err := decodeJWKBase64(k.N) if err != nil { return nil, fmt.Errorf("invalid JWK modulus: %w", err) } eBytes, err := decodeJWKBase64(k.E) if err != nil { return nil, fmt.Errorf("invalid JWK exponent: %w", err) } if len(nBytes) == 0 || len(eBytes) == 0 { return nil, errors.New("empty JWK modulus or exponent") } eInt := new(big.Int).SetBytes(eBytes) if !eInt.IsInt64() { return nil, errors.New("invalid JWK exponent value") } e := int(eInt.Int64()) if e <= 0 { return nil, errors.New("invalid JWK exponent value") } return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil } func (jwks *oidcJWKS) rsaKeyForKid(kid string) (*rsa.PublicKey, error) { var usable []oidcJWK for _, k := range jwks.Keys { if k.Use != "" && !strings.EqualFold(k.Use, "sig") { continue } if k.Kty != "" && !strings.EqualFold(k.Kty, "RSA") { continue } if kid != "" && k.Kid != kid { continue } if _, err := k.rsaPublicKey(); err != nil { continue } usable = append(usable, k) } if kid != "" { if len(usable) == 0 { return nil, fmt.Errorf("no matching JWKS RSA key for kid %q", kid) } return usable[0].rsaPublicKey() } if len(usable) == 0 { return nil, errors.New("no matching JWKS key for id_token") } if len(usable) > 1 { return nil, errors.New("id_token missing kid and JWKS contains multiple RSA signing keys") } return usable[0].rsaPublicKey() } // fetchOIDCJWKS loads the provider's JWKS document over the SSRF-safe client. func (s *userService) fetchOIDCJWKS(ctx context.Context, jwksURI string) (*oidcJWKS, error) { if err := validateOIDCEndpoint("jwks", jwksURI, true); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksURI, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") resp, err := newOIDCHTTPClient().Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("JWKS request failed: status=%d", resp.StatusCode) } var jwks oidcJWKS if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&jwks); err != nil { return nil, fmt.Errorf("failed to decode JWKS document: %w", err) } if len(jwks.Keys) == 0 { return nil, errors.New("JWKS document contains no keys") } return &jwks, nil } const oidcIDTokenLeeway = 2 * time.Minute // verifyOIDCIDToken cryptographically verifies an OIDC id_token: it checks the // RSA signature against the provider's JWKS (matched by kid) and validates the // issuer, audience (client_id), expiry and subject. It returns the verified claims. func (s *userService) verifyOIDCIDToken( ctx context.Context, cfg *config.OIDCAuthConfig, idToken string, ) (map[string]interface{}, error) { if strings.TrimSpace(cfg.JwksURI) == "" { return nil, errors.New("cannot verify OIDC id_token: no jwks_uri configured") } if strings.TrimSpace(cfg.IssuerURL) == "" { return nil, errors.New("cannot verify OIDC id_token: issuer is not configured") } if strings.TrimSpace(cfg.ClientID) == "" { return nil, errors.New("cannot verify OIDC id_token: client_id is not configured") } jwks, err := s.fetchOIDCJWKS(ctx, cfg.JwksURI) if err != nil { return nil, err } keyFunc := func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected id_token signing method: %v", token.Header["alg"]) } kid, _ := token.Header["kid"].(string) return jwks.rsaKeyForKid(kid) } claims := jwt.MapClaims{} if _, err := jwt.NewParser( jwt.WithValidMethods([]string{"RS256", "RS384", "RS512"}), jwt.WithExpirationRequired(), jwt.WithLeeway(oidcIDTokenLeeway), jwt.WithIssuer(strings.TrimSpace(cfg.IssuerURL)), jwt.WithAudience(strings.TrimSpace(cfg.ClientID)), ).ParseWithClaims(idToken, claims, keyFunc); err != nil { return nil, fmt.Errorf("id_token verification failed: %w", err) } verified := map[string]interface{}(claims) if strings.TrimSpace(extractClaimAsString(verified, "sub")) == "" { return nil, errors.New("id_token missing sub claim") } return verified, nil } func extractClaimAsString(claims map[string]interface{}, key string) string { key = strings.TrimSpace(key) if key == "" { return "" } value, ok := claims[key] if !ok || value == nil { return "" } switch v := value.(type) { case string: return strings.TrimSpace(v) default: return strings.TrimSpace(fmt.Sprint(v)) } } func sanitizeUsernameCandidate(value string) string { value = strings.TrimSpace(strings.ToLower(value)) if value == "" { return "" } var b strings.Builder lastDash := false for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '.' { b.WriteRune(r) lastDash = false continue } if !lastDash { b.WriteByte('-') lastDash = true } } result := strings.Trim(b.String(), "-._") if len(result) > 50 { result = strings.Trim(result[:50], "-._") } return result } func isUserLookupNotFound(err error) bool { if err == nil { return false } return errors.Is(err, apprepo.ErrUserNotFound) || strings.Contains(strings.ToLower(err.Error()), "user not found") }