Files
ResolveSpec/pkg/security/OAUTH2.md
Hein e11e6a8bf7 feat(security): Add OAuth2 authentication examples and methods
* Introduce OAuth2 authentication examples for Google, GitHub, and custom providers.
* Implement OAuth2 methods for handling authentication, token refresh, and logout.
* Create a flexible structure for supporting multiple OAuth2 providers.
* Enhance DatabaseAuthenticator to manage OAuth2 sessions and user creation.
* Add database schema setup for OAuth2 user and session management.
2026-01-31 22:35:40 +02:00

17 KiB

OAuth2 Authentication Guide

Overview

The security package provides OAuth2 authentication support for any OAuth2-compliant provider including Google, GitHub, Microsoft, Facebook, and custom providers.

Features

  • Universal OAuth2 Support: Works with any OAuth2 provider
  • Pre-configured Providers: Google, GitHub, Microsoft, Facebook
  • Multi-Provider Support: Use all OAuth2 providers simultaneously
  • Custom Providers: Easy configuration for any OAuth2 service
  • Session Management: Database-backed session storage
  • Token Refresh: Automatic token refresh support
  • State Validation: Built-in CSRF protection
  • User Auto-Creation: Automatically creates users on first login
  • Unified Authentication: OAuth2 and traditional auth share same session storage

Quick Start

1. Database Setup

-- Run the schema from database_schema.sql
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(255) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255),
    user_level INTEGER DEFAULT 0,
    roles VARCHAR(500),
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_login_at TIMESTAMP,
    remote_id VARCHAR(255),
    auth_provider VARCHAR(50)
);

CREATE TABLE IF NOT EXISTS user_sessions (
    id SERIAL PRIMARY KEY,
    session_token VARCHAR(500) NOT NULL UNIQUE,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_activity_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    ip_address VARCHAR(45),
    user_agent TEXT,
    access_token TEXT,
    refresh_token TEXT,
    token_type VARCHAR(50) DEFAULT 'Bearer',
    auth_provider VARCHAR(50)
);

-- OAuth2 stored procedures (7 functions)
-- See database_schema.sql for full implementation

2. Google OAuth2

import "github.com/bitechdev/ResolveSpec/pkg/security"

// Create authenticator
oauth2Auth := security.NewGoogleAuthenticator(
    "your-google-client-id",
    "your-google-client-secret",
    "http://localhost:8080/auth/google/callback",
    db,
)

// Login route - redirects to Google
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
    state, _ := oauth2Auth.OAuth2GenerateState()
    authURL, _ := oauth2Auth.OAuth2GetAuthURL(state)
    http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
})

// Callback route - handles Google response
router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")
    state := r.URL.Query().Get("state")

    loginResp, err := oauth2Auth.OAuth2HandleCallback(r.Context(), code, state)
    if err != nil {
        http.Error(w, err.Error(), http.StatusUnauthorized)
        return
    }

    // Set session cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "session_token",
        Value:    loginResp.Token,
        Path:     "/",
        MaxAge:   int(loginResp.ExpiresIn),
        HttpOnly: true,
        Secure:   true,
    })

    http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
})

3. GitHub OAuth2

oauth2Auth := security.NewGitHubAuthenticator(
    "your-github-client-id",
    "your-github-client-secret",
    "http://localhost:8080/auth/github/callback",
    db,
)

// Same routes pattern as Google
router.HandleFunc("/auth/github/login", ...)
router.HandleFunc("/auth/github/callback", ...)

4. Microsoft OAuth2

oauth2Auth := security.NewMicrosoftAuthenticator(
    "your-microsoft-client-id",
    "your-microsoft-client-secret",
    "http://localhost:8080/auth/microsoft/callback",
    db,
)

5. Facebook OAuth2

oauth2Auth := security.NewFacebookAuthenticator(
    "your-facebook-client-id",
    "your-facebook-client-secret",
    "http://localhost:8080/auth/facebook/callback",
    db,
)

Custom OAuth2 Provider

oauth2Auth := security.NewDatabaseAuthenticator(db).WithOAuth2(security.OAuth2Config{
    ClientID:     "your-client-id",
    ClientSecret: "your-client-secret",
    RedirectURL:  "http://localhost:8080/auth/callback",
    Scopes:       []string{"openid", "profile", "email"},
    AuthURL:      "https://your-provider.com/oauth/authorize",
    TokenURL:     "https://your-provider.com/oauth/token",
    UserInfoURL:  "https://your-provider.com/oauth/userinfo",
    DB:           db,
    ProviderName: "custom",

    // Optional: Custom user info parser
    UserInfoParser: func(userInfo map[string]any) (*security.UserContext, error) {
        return &security.UserContext{
            UserName:  userInfo["username"].(string),
            Email:     userInfo["email"].(string),
            RemoteID:  userInfo["id"].(string),
            UserLevel: 1,
            Roles:     []string{"user"},
            Claims:    userInfo,
        }, nil
    },
})

Protected Routes

// Create security provider
colSec := security.NewDatabaseColumnSecurityProvider(db)
rowSec := security.NewDatabaseRowSecurityProvider(db)
provider, _ := security.NewCompositeSecurityProvider(oauth2Auth, colSec, rowSec)
securityList, _ := security.NewSecurityList(provider)

// Apply middleware to protected routes
protectedRouter := router.PathPrefix("/api").Subrouter()
protectedRouter.Use(security.NewAuthMiddleware(securityList))
protectedRouter.Use(security.SetSecurityMiddleware(securityList))

protectedRouter.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
    userCtx, _ := security.GetUserContext(r.Context())
    json.NewEncoder(w).Encode(userCtx)
})

Token Refresh

OAuth2 access tokens expire after a period of time. Use the refresh token to obtain a new access token without requiring the user to log in again.

router.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
    var req struct {
        RefreshToken string `json:"refresh_token"`
        Provider     string `json:"provider"` // "google", "github", etc.
    }
    json.NewDecoder(r.Body).Decode(&req)

    // Default to google if not specified
    if req.Provider == "" {
        req.Provider = "google"
    }

    // Use OAuth2-specific refresh method
    loginResp, err := oauth2Auth.OAuth2RefreshToken(r.Context(), req.RefreshToken, req.Provider)
    if err != nil {
        http.Error(w, err.Error(), http.StatusUnauthorized)
        return
    }

    // Set new session cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "session_token",
        Value:    loginResp.Token,
        Path:     "/",
        MaxAge:   int(loginResp.ExpiresIn),
        HttpOnly: true,
        Secure:   true,
    })

    json.NewEncoder(w).Encode(loginResp)
})

Important Notes:

  • The refresh token is returned in the LoginResponse.RefreshToken field after successful OAuth2 callback
  • Store the refresh token securely on the client side
  • Each provider must be configured with the appropriate scopes to receive a refresh token (e.g., access_type=offline for Google)
  • The OAuth2RefreshToken method requires the provider name to identify which OAuth2 provider to use for refreshing

Logout

router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
    userCtx, _ := security.GetUserContext(r.Context())
    
    oauth2Auth.Logout(r.Context(), security.LogoutRequest{
        Token:  userCtx.SessionID,
        UserID: userCtx.UserID,
    })

    http.SetCookie(w, &http.Cookie{
        Name:   "session_token",
        Value:  "",
        MaxAge: -1,
    })

    w.WriteHeader(http.StatusOK)
})

Multi-Provider Setup

// Single DatabaseAuthenticator with ALL OAuth2 providers
auth := security.NewDatabaseAuthenticator(db).
    WithOAuth2(security.OAuth2Config{
        ClientID:     "google-client-id",
        ClientSecret: "google-client-secret",
        RedirectURL:  "http://localhost:8080/auth/google/callback",
        Scopes:       []string{"openid", "profile", "email"},
        AuthURL:      "https://accounts.google.com/o/oauth2/auth",
        TokenURL:     "https://oauth2.googleapis.com/token",
        UserInfoURL:  "https://www.googleapis.com/oauth2/v2/userinfo",
        ProviderName: "google",
    }).
    WithOAuth2(security.OAuth2Config{
        ClientID:     "github-client-id",
        ClientSecret: "github-client-secret",
        RedirectURL:  "http://localhost:8080/auth/github/callback",
        Scopes:       []string{"user:email"},
        AuthURL:      "https://github.com/login/oauth/authorize",
        TokenURL:     "https://github.com/login/oauth/access_token",
        UserInfoURL:  "https://api.github.com/user",
        ProviderName: "github",
    })

// Get list of configured providers
providers := auth.OAuth2GetProviders() // ["google", "github"]

// Google routes
router.HandleFunc("/auth/google/login", func(w http.ResponseWriter, r *http.Request) {
    state, _ := auth.OAuth2GenerateState()
    authURL, _ := auth.OAuth2GetAuthURL("google", state)
    http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
})

router.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
    loginResp, err := auth.OAuth2HandleCallback(r.Context(), "google", 
        r.URL.Query().Get("code"), r.URL.Query().Get("state"))
    // ... handle response
})

// GitHub routes
router.HandleFunc("/auth/github/login", func(w http.ResponseWriter, r *http.Request) {
    state, _ := auth.OAuth2GenerateState()
    authURL, _ := auth.OAuth2GetAuthURL("github", state)
    http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
})

router.HandleFunc("/auth/github/callback", func(w http.ResponseWriter, r *http.Request) {
    loginResp, err := auth.OAuth2HandleCallback(r.Context(), "github",
        r.URL.Query().Get("code"), r.URL.Query().Get("state"))
    // ... handle response
})

// Use same authenticator for protected routes - works for ALL providers
provider, _ := security.NewCompositeSecurityProvider(auth, colSec, rowSec)
securityList, _ := security.NewSecurityList(provider)

Configuration Options

OAuth2Config Fields

Field Type Description
ClientID string OAuth2 client ID from provider
ClientSecret string OAuth2 client secret
RedirectURL string Callback URL registered with provider
Scopes []string OAuth2 scopes to request
AuthURL string Provider's authorization endpoint
TokenURL string Provider's token endpoint
UserInfoURL string Provider's user info endpoint
DB *sql.DB Database connection for sessions
UserInfoParser func Custom parser for user info (optional)
StateValidator func Custom state validator (optional)
ProviderName string Provider name for logging (optional)

User Info Parsing

The default parser extracts these standard fields:

  • sub → RemoteID
  • email → Email, UserName
  • name → UserName
  • login → UserName (GitHub)

Custom parser example:

UserInfoParser: func(userInfo map[string]any) (*security.UserContext, error) {
    // Extract custom fields
    ctx := &security.UserContext{
        UserName:  userInfo["preferred_username"].(string),
        Email:     userInfo["email"].(string),
        RemoteID:  userInfo["sub"].(string),
        UserLevel: 1,
        Roles:     []string{"user"},
        Claims:    userInfo, // Store all claims
    }

    // Add custom roles based on provider data
    if groups, ok := userInfo["groups"].([]interface{}); ok {
        for _, g := range groups {
            ctx.Roles = append(ctx.Roles, g.(string))
        }
    }

    return ctx, nil
}

Security Best Practices

  1. Always use HTTPS in production

    http.SetCookie(w, &http.Cookie{
        Secure:   true,  // Only send over HTTPS
        HttpOnly: true,  // Prevent XSS access
        SameSite: http.SameSiteLaxMode, // CSRF protection
    })
    
  2. Store secrets securely

    clientID := os.Getenv("GOOGLE_CLIENT_ID")
    clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
    
  3. Validate redirect URLs

    • Only register trusted redirect URLs with OAuth2 providers
    • Never accept redirect URL from request parameters
  4. Session expiration

    • OAuth2 sessions automatically expire based on token expiry
    • Clean up expired sessions periodically:
    DELETE FROM user_sessions WHERE expires_at < NOW();
    
  5. State parameter

    • Automatically generated with cryptographic randomness
    • One-time use and expires after 10 minutes
    • Prevents CSRF attacks

Implementation Details

All database operations use stored procedures for consistency and security:

  • resolvespec_oauth_getorcreateuser - Find or create OAuth2 user
  • resolvespec_oauth_createsession - Create OAuth2 session
  • resolvespec_oauth_getsession - Validate and retrieve session
  • resolvespec_oauth_deletesession - Logout/delete session
  • resolvespec_oauth_getrefreshtoken - Get session by refresh token
  • resolvespec_oauth_updaterefreshtoken - Update tokens after refresh
  • resolvespec_oauth_getuser - Get user data by ID

Provider Setup Guides

Google

  1. Go to Google Cloud Console
  2. Create a new project or select existing
  3. Enable Google+ API
  4. Create OAuth 2.0 credentials
  5. Add authorized redirect URI: http://localhost:8080/auth/google/callback
  6. Copy Client ID and Client Secret

GitHub

  1. Go to GitHub Developer Settings
  2. Click "New OAuth App"
  3. Set Homepage URL: http://localhost:8080
  4. Set Authorization callback URL: http://localhost:8080/auth/github/callback
  5. Copy Client ID and Client Secret

Microsoft

  1. Go to Azure Portal
  2. Register new application in Azure AD
  3. Add redirect URI: http://localhost:8080/auth/microsoft/callback
  4. Create client secret
  5. Copy Application (client) ID and secret value

Facebook

  1. Go to Facebook Developers
  2. Create new app
  3. Add Facebook Login product
  4. Set Valid OAuth Redirect URIs: http://localhost:8080/auth/facebook/callback
  5. Copy App ID and App Secret

Troubleshooting

"redirect_uri_mismatch" error

  • Ensure the redirect URL in code matches exactly with provider configuration
  • Include protocol (http/https), domain, port, and path

"invalid_client" error

  • Verify Client ID and Client Secret are correct
  • Check if credentials are for the correct environment (dev/prod)

"invalid_grant" error during token exchange

  • State parameter validation failed
  • Token might have expired
  • Check server time synchronization

User not created after successful OAuth2 login

  • Check database constraints (username/email unique)
  • Verify UserInfoParser is extracting required fields
  • Check database logs for constraint violations

Testing

func TestOAuth2Flow(t *testing.T) {
    // Mock database
    db, mock, _ := sqlmock.New()
    
    oauth2Auth := security.NewGoogleAuthenticator(
        "test-client-id",
        "test-client-secret",
        "http://localhost/callback",
        db,
    )

    // Test state generation
    state, err := oauth2Auth.GenerateState()
    assert.NoError(t, err)
    assert.NotEmpty(t, state)

    // Test auth URL generation
    authURL := oauth2Auth.GetAuthURL(state)
    assert.Contains(t, authURL, "accounts.google.com")
    assert.Contains(t, authURL, state)
}

API Reference

DatabaseAuthenticator with OAuth2

Method Description
WithOAuth2(cfg) Adds OAuth2 provider (can be called multiple times, returns *DatabaseAuthenticator)
OAuth2GetAuthURL(provider, state) Returns OAuth2 authorization URL for specified provider
OAuth2GenerateState() Generates random state for CSRF protection
OAuth2HandleCallback(ctx, provider, code, state) Exchanges code for token and creates session
OAuth2RefreshToken(ctx, refreshToken, provider) Refreshes expired access token using refresh token
OAuth2GetProviders() Returns list of configured OAuth2 provider names
Login(ctx, req) Standard username/password login
Logout(ctx, req) Invalidates session (works for both OAuth2 and regular sessions)
Authenticate(r) Validates session token from request (works for both OAuth2 and regular sessions)

Pre-configured Constructors

  • NewGoogleAuthenticator(clientID, secret, redirectURL, db) - Single provider
  • NewGitHubAuthenticator(clientID, secret, redirectURL, db) - Single provider
  • NewMicrosoftAuthenticator(clientID, secret, redirectURL, db) - Single provider
  • NewFacebookAuthenticator(clientID, secret, redirectURL, db) - Single provider
  • NewMultiProviderAuthenticator(db, configs) - Multiple providers at once

All return *DatabaseAuthenticator with OAuth2 pre-configured.

For multiple providers, use WithOAuth2() multiple times or NewMultiProviderAuthenticator().

Examples

Complete working examples available in oauth2_examples.go:

  • Basic Google OAuth2
  • GitHub OAuth2
  • Custom provider
  • Multi-provider setup
  • Token refresh
  • Logout flow
  • Complete integration with security middleware