feat: add OAuth2 authentication support with new API endpoints, configuration, and identity storage.

This commit is contained in:
Haitao Pan 2026-01-30 08:46:24 +08:00
parent de5847d1f0
commit 6ba56841b5
6 changed files with 428 additions and 2 deletions

View File

@ -63,6 +63,8 @@ type handler struct {
metricsProvider service.UserMetricsProvider
agentStatusReader agentStatusReader
tokenService *auth.TokenService
oauthProviders map[string]auth.OAuthProvider
oauthFrontendURL string
}
type mfaChallenge struct {
@ -178,6 +180,20 @@ func WithTokenService(tokenService *auth.TokenService) Option {
}
}
// WithOAuthProviders configures the handler with the provided OAuth2 providers.
func WithOAuthProviders(providers map[string]auth.OAuthProvider) Option {
return func(h *handler) {
h.oauthProviders = providers
}
}
// WithOAuthFrontendURL configures the frontend URL for OAuth2 redirects.
func WithOAuthFrontendURL(url string) Option {
return func(h *handler) {
h.oauthFrontendURL = url
}
}
// RegisterRoutes attaches account service endpoints to the router.
func RegisterRoutes(r *gin.Engine, opts ...Option) {
h := &handler{
@ -215,6 +231,10 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
// Token exchange endpoint - converts public token to access/refresh tokens
auth.POST("/token/exchange", h.exchangeToken)
// OAuth2 routes
auth.GET("/oauth/login/:provider", h.oauthLogin)
auth.GET("/oauth/callback/:provider", h.oauthCallback)
// Token refresh endpoint - generates new access token using refresh token
auth.POST("/token/refresh", h.refreshToken)
@ -2235,6 +2255,124 @@ func (h *handler) disableMFA(c *gin.Context) {
})
}
func (h *handler) oauthLogin(c *gin.Context) {
providerName := c.Param("provider")
provider, ok := h.oauthProviders[providerName]
if !ok {
respondError(c, http.StatusNotFound, "provider_not_found", "oauth provider not found")
return
}
state := h.generateState()
// In a real app, we should store state in a secure cookie or session.
// For now, we'll just redirect.
c.Redirect(http.StatusTemporaryRedirect, provider.AuthCodeURL(state))
}
func (h *handler) oauthCallback(c *gin.Context) {
providerName := c.Param("provider")
provider, ok := h.oauthProviders[providerName]
if !ok {
respondError(c, http.StatusNotFound, "provider_not_found", "oauth provider not found")
return
}
code := c.Query("code")
if code == "" {
respondError(c, http.StatusBadRequest, "code_missing", "oauth code missing")
return
}
profile, err := provider.FetchProfile(c.Request.Context(), nil) // Exchange is handled inside if we want, or here.
// Let's refine the interface to handle token exchange too.
token, err := provider.Exchange(c.Request.Context(), code)
if err != nil {
respondError(c, http.StatusInternalServerError, "oauth_exchange_failed", "failed to exchange oauth code")
return
}
profile, err = provider.FetchProfile(c.Request.Context(), token)
if err != nil {
respondError(c, http.StatusInternalServerError, "fetch_profile_failed", "failed to fetch user profile")
return
}
if profile.Email == "" {
respondError(c, http.StatusBadRequest, "email_missing", "email not provided by oauth provider")
return
}
// 1. Check if user exists by identity
ctx := c.Request.Context()
user, err := h.store.GetUserByEmail(ctx, profile.Email)
if err != nil && !errors.Is(err, store.ErrUserNotFound) {
respondError(c, http.StatusInternalServerError, "store_error", "database error")
return
}
if errors.Is(err, store.ErrUserNotFound) {
// Auto-register user
user = &store.User{
Name: profile.Name,
Email: profile.Email,
EmailVerified: true, // Trusted provider
Level: store.LevelUser,
Role: store.RoleUser,
Groups: []string{"User"},
}
if err := h.store.CreateUser(ctx, user); err != nil {
respondError(c, http.StatusInternalServerError, "user_creation_failed", "failed to create user")
return
}
// Track identity
identity := &store.Identity{
UserID: user.ID,
Provider: providerName,
ExternalID: profile.ID,
}
if err := h.store.CreateIdentity(ctx, identity); err != nil {
slog.Warn("failed to create identity record", "err", err, "userID", user.ID)
}
// Provision trial
trialExpiresAt := time.Now().UTC().Add(7 * 24 * time.Hour)
trial := &store.Subscription{
UserID: user.ID,
Provider: "trial",
PaymentMethod: "trial",
Kind: "trial",
PlanID: "TRIAL-7D",
ExternalID: fmt.Sprintf("trial-%s", user.ID),
Status: "active",
Meta: map[string]any{"expiresAt": trialExpiresAt},
}
h.store.UpsertSubscription(ctx, trial)
}
// Create session or generate public token for frontend redirect
if h.tokenService == nil {
respondError(c, http.StatusServiceUnavailable, "token_service_unavailable", "token service not configured")
return
}
publicToken := h.tokenService.GeneratePublicToken(user.ID, user.Email, []string{user.Role})
// Redirect back to frontend with public token
frontendURL := h.oauthFrontendURL
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
targetURL := fmt.Sprintf("%s/login?public_token=%s", strings.TrimSuffix(frontendURL, "/"), publicToken)
c.Redirect(http.StatusTemporaryRedirect, targetURL)
}
func (h *handler) generateState() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func respondError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{
"error": code,

View File

@ -257,7 +257,33 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err
if agentRegistry != nil {
options = append(options, api.WithAgentStatusReader(agentRegistry))
}
api.RegisterRoutes(r, options...)
// Initialize OAuth providers
oauthProviders := make(map[string]auth.OAuthProvider)
if cfg.Auth.Enable {
if cfg.Auth.OAuth.GitHub.ClientID != "" {
oauthProviders["github"] = auth.NewGitHubProvider(
cfg.Auth.OAuth.GitHub.ClientID,
cfg.Auth.OAuth.GitHub.ClientSecret,
cfg.Auth.OAuth.RedirectURL,
)
}
if cfg.Auth.OAuth.Google.ClientID != "" {
oauthProviders["google"] = auth.NewGoogleProvider(
cfg.Auth.OAuth.Google.ClientID,
cfg.Auth.OAuth.Google.ClientSecret,
cfg.Auth.OAuth.RedirectURL,
)
}
}
api.RegisterRoutes(r,
api.WithStore(st),
api.WithEmailSender(emailSender),
api.WithEmailVerification(cfg.Auth.Enable),
api.WithTokenService(tokenService),
api.WithOAuthProviders(oauthProviders),
api.WithOAuthFrontendURL(cfg.Auth.OAuth.FrontendURL),
)
if agentRegistry != nil {
registerAgentAPIRoutes(r, agentRegistry, gormSource, logger)

View File

@ -78,6 +78,21 @@ type Session struct {
type Auth struct {
Enable bool `yaml:"enable"`
Token Token `yaml:"token"`
OAuth OAuth `yaml:"oauth"`
}
// OAuth defines OAuth2 configuration for multiple providers.
type OAuth struct {
RedirectURL string `yaml:"redirectUrl"`
FrontendURL string `yaml:"frontendUrl"`
GitHub OAuthProvider `yaml:"github"`
Google OAuthProvider `yaml:"google"`
}
// OAuthProvider defines configuration for a single OAuth2 provider.
type OAuthProvider struct {
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
}
// Token defines token authentication configuration.

160
internal/auth/oauth.go Normal file
View File

@ -0,0 +1,160 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
"golang.org/x/oauth2/google"
)
// OAuthUserProfile represents the unified user profile info from OAuth providers.
type OAuthUserProfile struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// OAuthProvider defines the interface for different OAuth2 providers.
type OAuthProvider interface {
AuthCodeURL(state string) string
Exchange(ctx context.Context, code string) (*oauth2.Token, error)
FetchProfile(ctx context.Context, token *oauth2.Token) (*OAuthUserProfile, error)
Name() string
}
type baseProvider struct {
config *oauth2.Config
name string
}
func (p *baseProvider) AuthCodeURL(state string) string {
return p.config.AuthCodeURL(state)
}
func (p *baseProvider) Exchange(ctx context.Context, code string) (*oauth2.Token, error) {
return p.config.Exchange(ctx, code)
}
func (p *baseProvider) Name() string {
return p.name
}
// GitHubProvider implements GitHub OAuth2.
type GitHubProvider struct {
baseProvider
}
func NewGitHubProvider(clientID, clientSecret, redirectURL string) *GitHubProvider {
return &GitHubProvider{
baseProvider: baseProvider{
name: "github",
config: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: github.Endpoint,
Scopes: []string{"user:email", "read:user"},
},
},
}
}
func (p *GitHubProvider) FetchProfile(ctx context.Context, token *oauth2.Token) (*OAuthUserProfile, error) {
client := p.config.Client(ctx, token)
resp, err := client.Get("https://api.github.com/user")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var user struct {
ID int `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
profile := &OAuthUserProfile{
ID: fmt.Sprintf("%d", user.ID),
Email: user.Email,
Name: user.Name,
}
if profile.Name == "" {
profile.Name = user.Login
}
// GitHub may return empty email if it's private.
if profile.Email == "" {
resp, err := client.Get("https://api.github.com/user/emails")
if err == nil {
defer resp.Body.Close()
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
}
if err := json.NewDecoder(resp.Body).Decode(&emails); err == nil {
for _, e := range emails {
if e.Primary {
profile.Email = e.Email
break
}
}
}
}
}
return profile, nil
}
// GoogleProvider implements Google OAuth2.
type GoogleProvider struct {
baseProvider
}
func NewGoogleProvider(clientID, clientSecret, redirectURL string) *GoogleProvider {
return &GoogleProvider{
baseProvider: baseProvider{
name: "google",
config: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: google.Endpoint,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
},
},
}
}
func (p *GoogleProvider) FetchProfile(ctx context.Context, token *oauth2.Token) (*OAuthUserProfile, error) {
client := p.config.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var user struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &OAuthUserProfile{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}, nil
}

View File

@ -1029,3 +1029,51 @@ func decodeStringSlice(raw []byte) []string {
}
return normalizeStringSlice(values)
}
func (s *postgresStore) CreateIdentity(ctx context.Context, identity *Identity) error {
if identity == nil {
return errors.New("identity is required")
}
normalizedUserID := strings.TrimSpace(identity.UserID)
if normalizedUserID == "" {
return ErrUserNotFound
}
provider := strings.TrimSpace(identity.Provider)
externalID := strings.TrimSpace(identity.ExternalID)
if provider == "" || externalID == "" {
return errors.New("provider and external_id are required")
}
const query = `INSERT INTO identities (user_uuid, provider, external_id)
VALUES ($1, $2, $3)
RETURNING uuid, created_at, updated_at`
var (
idValue any
createdAt time.Time
updatedAt time.Time
)
err := s.db.QueryRowContext(ctx, query, normalizedUserID, provider, externalID).Scan(&idValue, &createdAt, &updatedAt)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" { // unique_violation
return errors.New("identity already exists")
}
}
return err
}
identifier, err := formatIdentifier(idValue)
if err != nil {
return err
}
identity.ID = identifier
identity.CreatedAt = createdAt.UTC()
identity.UpdatedAt = updatedAt.UTC()
return nil
}

View File

@ -47,6 +47,16 @@ type Subscription struct {
CancelledAt *time.Time
}
// Identity represents a mapping between a user and a third-party authentication provider.
type Identity struct {
ID string
UserID string
Provider string
ExternalID string
CreatedAt time.Time
UpdatedAt time.Time
}
// Store provides persistence operations for users.
type Store interface {
CreateUser(ctx context.Context, user *User) error
@ -57,7 +67,7 @@ type Store interface {
UpsertSubscription(ctx context.Context, subscription *Subscription) error
ListSubscriptionsByUser(ctx context.Context, userID string) ([]Subscription, error)
CancelSubscription(ctx context.Context, userID, externalID string, cancelledAt time.Time) (*Subscription, error)
CreateIdentity(ctx context.Context, identity *Identity) error
}
// Domain level errors returned by the store implementation.
@ -81,6 +91,7 @@ type memoryStore struct {
byEmail map[string]*User
byName map[string]*User
subscriptions map[string]map[string]*Subscription
identities map[string]*Identity
}
// NewMemoryStore creates a new in-memory store implementation with super
@ -105,6 +116,7 @@ func newMemoryStore(allowSuperAdminCounting bool) Store {
byEmail: make(map[string]*User),
byName: make(map[string]*User),
subscriptions: make(map[string]map[string]*Subscription),
identities: make(map[string]*Identity),
}
}
@ -573,3 +585,30 @@ func isSuperAdmin(user *User) bool {
return false
}
// CreateIdentity persists an identity record in the in-memory store.
func (s *memoryStore) CreateIdentity(ctx context.Context, identity *Identity) error {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
if identity.ID == "" {
identity.ID = uuid.NewString()
}
now := time.Now().UTC()
if identity.CreatedAt.IsZero() {
identity.CreatedAt = now
}
if identity.UpdatedAt.IsZero() {
identity.UpdatedAt = now
}
key := identity.Provider + ":" + identity.ExternalID
if _, exists := s.identities[key]; exists {
return errors.New("identity already exists")
}
stored := *identity
s.identities[key] = &stored
return nil
}