Files
gems/opal-task/internal/api/handlers/oauth.go
T
joakim 4eb18388db feat(backend): add OAuth2/JWT authentication support
- Add OAuth2 client for Authentik integration
- Implement JWT token generation and validation
- Add refresh token support with database storage
- Update database schema with oauth_subject, oauth_provider, and refresh_tokens table
- Create auth package with config, jwt, oauth, and token management
- Add OAuth endpoints: /auth/login, /auth/callback, /auth/refresh, /auth/logout
- Update AuthMiddleware to support both JWT and API key authentication
- Add user helper functions for OAuth user creation and retrieval
- Add .env.example with OAuth configuration template

API keys still work for CLI compatibility while JWT tokens support web/mobile clients.
2026-01-06 15:42:03 +01:00

168 lines
4.3 KiB
Go

package handlers
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"git.jnss.me/joakim/opal/internal/auth"
"git.jnss.me/joakim/opal/internal/engine"
)
var authConfig = auth.LoadConfig()
var oauthClient *auth.OAuthClient
func init() {
if authConfig.OAuthEnabled {
oauthClient = auth.NewOAuthClient(authConfig)
}
}
// GetLoginURL returns the OAuth authorization URL
func GetLoginURL(w http.ResponseWriter, r *http.Request) {
if !authConfig.OAuthEnabled {
errorResponse(w, http.StatusNotImplemented, "OAuth not enabled")
return
}
state := generateState()
url := oauthClient.GetAuthURL(state)
jsonResponse(w, http.StatusOK, map[string]string{
"url": url,
"state": state,
})
}
// OAuthCallback handles the OAuth callback
func OAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
errorResponse(w, http.StatusBadRequest, "missing code parameter")
return
}
// Exchange code for token
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
oauthToken, err := oauthClient.ExchangeCode(ctx, code)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to exchange code: "+err.Error())
return
}
// Get user info
userInfo, err := oauthClient.GetUserInfo(ctx, oauthToken.AccessToken)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to get user info: "+err.Error())
return
}
// Find or create user
user, err := engine.FindOrCreateOAuthUser(userInfo.Sub, userInfo.Username, userInfo.Email)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
return
}
// Generate JWT
accessToken, expiresAt, err := auth.GenerateJWT(user.ID, user.Username, user.Email, authConfig)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to generate token: "+err.Error())
return
}
// Generate refresh token
refreshToken, err := auth.GenerateRefreshToken()
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to generate refresh token: "+err.Error())
return
}
// Store refresh token
if err := auth.StoreRefreshToken(user.ID, refreshToken, authConfig.RefreshTokenExpiry); err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to store refresh token: "+err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshToken,
"expires_at": expiresAt,
"token_type": "Bearer",
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"email": user.Email,
},
})
}
// RefreshToken handles token refresh
func RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request")
return
}
// Validate refresh token
userID, err := auth.ValidateRefreshToken(req.RefreshToken)
if err != nil {
errorResponse(w, http.StatusUnauthorized, "invalid refresh token: "+err.Error())
return
}
// Get user
user, err := engine.GetUser(userID)
if err != nil {
errorResponse(w, http.StatusNotFound, "user not found")
return
}
// Generate new access token
accessToken, expiresAt, err := auth.GenerateJWT(user.ID, user.Username, user.Email, authConfig)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to generate token")
return
}
jsonResponse(w, http.StatusOK, map[string]interface{}{
"access_token": accessToken,
"expires_at": expiresAt,
"token_type": "Bearer",
})
}
// Logout revokes refresh token
func Logout(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request")
return
}
if err := auth.RevokeRefreshToken(req.RefreshToken); err != nil {
errorResponse(w, http.StatusInternalServerError, "failed to revoke token")
return
}
jsonResponse(w, http.StatusOK, map[string]string{"message": "logged out"})
}
func generateState() string {
b := make([]byte, 16)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}