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.
This commit is contained in:
2026-01-06 15:42:03 +01:00
parent e506d76e6a
commit 4eb18388db
27 changed files with 965 additions and 6 deletions
+19
View File
@@ -0,0 +1,19 @@
# Server Configuration
SERVER_ADDR=:8080
# Database
OPAL_DB_PATH=/var/lib/opal/opal.db
# OAuth2 / Authentik
OAUTH_ENABLED=true
OAUTH_ISSUER=https://auth.example.com/application/o/opal/
OAUTH_CLIENT_ID=your_client_id_here
OAUTH_CLIENT_SECRET=your_client_secret_here
OAUTH_REDIRECT_URI=https://opal.example.com/auth/callback
# JWT Configuration
JWT_SECRET=generate_random_secret_with_openssl_rand_hex_32
JWT_EXPIRY=3600
# Refresh Token Configuration
REFRESH_TOKEN_EXPIRY=604800
+2
View File
@@ -16,6 +16,7 @@ require (
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -29,6 +30,7 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
+4
View File
@@ -11,6 +11,8 @@ github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -65,6 +67,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+167
View File
@@ -0,0 +1,167 @@
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)
}
+19 -6
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"git.jnss.me/joakim/opal/internal/auth"
"git.jnss.me/joakim/opal/internal/engine"
)
@@ -12,8 +13,10 @@ type contextKey string
const userIDKey contextKey = "userID"
// AuthMiddleware validates API keys and adds userID to context
// AuthMiddleware validates JWT tokens or API keys and adds userID to context
func AuthMiddleware() func(http.Handler) http.Handler {
authCfg := auth.LoadConfig()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get Authorization header
@@ -30,17 +33,27 @@ func AuthMiddleware() func(http.Handler) http.Handler {
return
}
apiKey := parts[1]
token := parts[1]
// Validate API key
valid, userID, err := engine.ValidateAPIKey(apiKey)
// Try JWT first if OAuth is enabled
if authCfg.OAuthEnabled {
if claims, err := auth.ValidateJWT(token, authCfg); err == nil {
// Valid JWT - add userID to context
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
// Fall back to API key validation (for CLI compatibility)
valid, userID, err := engine.ValidateAPIKey(token)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to validate API key")
Error(w, http.StatusInternalServerError, "failed to validate credentials")
return
}
if !valid {
Error(w, http.StatusUnauthorized, "invalid API key")
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
+6
View File
@@ -39,6 +39,12 @@ func (s *Server) setupRoutes() {
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// OAuth endpoints (no auth required)
r.Get("/auth/login", handlers.GetLoginURL)
r.Post("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/refresh", handlers.RefreshToken)
r.Post("/auth/logout", handlers.Logout)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware())
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"os"
"strconv"
)
type Config struct {
OAuthEnabled bool
OAuthIssuer string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
JWTSecret []byte
JWTExpiry int
RefreshTokenExpiry int
}
func LoadConfig() *Config {
enabled, _ := strconv.ParseBool(getEnv("OAUTH_ENABLED", "false"))
jwtExpiry, _ := strconv.Atoi(getEnv("JWT_EXPIRY", "3600"))
refreshExpiry, _ := strconv.Atoi(getEnv("REFRESH_TOKEN_EXPIRY", "604800"))
return &Config{
OAuthEnabled: enabled,
OAuthIssuer: getEnv("OAUTH_ISSUER", ""),
OAuthClientID: getEnv("OAUTH_CLIENT_ID", ""),
OAuthClientSecret: getEnv("OAUTH_CLIENT_SECRET", ""),
OAuthRedirectURI: getEnv("OAUTH_REDIRECT_URI", ""),
JWTSecret: []byte(getEnv("JWT_SECRET", "change-me-in-production")),
JWTExpiry: jwtExpiry,
RefreshTokenExpiry: refreshExpiry,
}
}
func getEnv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
+57
View File
@@ -0,0 +1,57 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
jwt.RegisteredClaims
}
func GenerateJWT(userID int, username, email string, cfg *Config) (string, int64, error) {
expiresAt := time.Now().Add(time.Duration(cfg.JWTExpiry) * time.Second)
claims := &Claims{
UserID: userID,
Username: username,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "opal-task",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := token.SignedString(cfg.JWTSecret)
if err != nil {
return "", 0, err
}
return signedToken, expiresAt.Unix(), nil
}
func ValidateJWT(tokenString string, cfg *Config) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, 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 cfg.JWTSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
+74
View File
@@ -0,0 +1,74 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"golang.org/x/oauth2"
)
type OAuthClient struct {
config *oauth2.Config
cfg *Config
}
func NewOAuthClient(cfg *Config) *OAuthClient {
return &OAuthClient{
config: &oauth2.Config{
ClientID: cfg.OAuthClientID,
ClientSecret: cfg.OAuthClientSecret,
RedirectURL: cfg.OAuthRedirectURI,
Endpoint: oauth2.Endpoint{
AuthURL: cfg.OAuthIssuer + "../authorize/",
TokenURL: cfg.OAuthIssuer + "../token/",
},
Scopes: []string{"openid", "profile", "email"},
},
cfg: cfg,
}
}
func (c *OAuthClient) GetAuthURL(state string) string {
return c.config.AuthCodeURL(state)
}
func (c *OAuthClient) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
return c.config.Exchange(ctx, code)
}
type UserInfo struct {
Sub string `json:"sub"`
Username string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
}
func (c *OAuthClient) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.cfg.OAuthIssuer+"../userinfo/", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("userinfo request failed: %s", string(body))
}
var userInfo UserInfo
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return nil, err
}
return &userInfo, nil
}
+91
View File
@@ -0,0 +1,91 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"time"
"git.jnss.me/joakim/opal/internal/engine"
)
func GenerateRefreshToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func HashToken(token string) string {
hash := sha256.Sum256([]byte(token))
return base64.URLEncoding.EncodeToString(hash[:])
}
func StoreRefreshToken(userID int, token string, expiresIn int) error {
db := engine.GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
hash := HashToken(token)
expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second).Unix()
_, err := db.Exec(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?)
`, userID, hash, expiresAt, time.Now().Unix())
return err
}
func ValidateRefreshToken(token string) (int, error) {
db := engine.GetDB()
if db == nil {
return 0, fmt.Errorf("database not initialized")
}
hash := HashToken(token)
var userID int
var expiresAt int64
var revoked int
err := db.QueryRow(`
SELECT user_id, expires_at, revoked
FROM refresh_tokens
WHERE token_hash = ?
`, hash).Scan(&userID, &expiresAt, &revoked)
if err != nil {
return 0, err
}
if revoked == 1 {
return 0, fmt.Errorf("token revoked")
}
if time.Now().Unix() > expiresAt {
return 0, fmt.Errorf("token expired")
}
return userID, nil
}
func RevokeRefreshToken(token string) error {
db := engine.GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
hash := HashToken(token)
_, err := db.Exec(`
UPDATE refresh_tokens
SET revoked = 1
WHERE token_hash = ?
`, hash)
return err
}
+16
View File
@@ -132,6 +132,8 @@ func runMigrations() error {
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT,
oauth_subject TEXT UNIQUE,
oauth_provider TEXT DEFAULT 'authentik',
created_at INTEGER NOT NULL
);
@@ -183,6 +185,20 @@ func runMigrations() error {
-- Default: keep change log for 30 days
INSERT INTO sync_config (key, value) VALUES ('change_log_retention_days', '30');
-- Refresh tokens for OAuth
CREATE TABLE refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
revoked INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
-- Triggers to populate change_log
CREATE TRIGGER track_task_create AFTER INSERT ON tasks
BEGIN
+84
View File
@@ -0,0 +1,84 @@
package engine
import (
"database/sql"
"fmt"
"time"
)
type User struct {
ID int
Username string
Email string
OAuthSubject string
OAuthProvider string
CreatedAt int64
}
func FindOrCreateOAuthUser(subject, username, email string) (*User, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
// Try to find existing user
var user User
err := db.QueryRow(`
SELECT id, username, COALESCE(email, ''), COALESCE(oauth_subject, ''), COALESCE(oauth_provider, ''), created_at
FROM users
WHERE oauth_subject = ?
`, subject).Scan(&user.ID, &user.Username, &user.Email, &user.OAuthSubject, &user.OAuthProvider, &user.CreatedAt)
if err == nil {
// User exists
return &user, nil
}
if err != sql.ErrNoRows {
return nil, err
}
// Create new user
result, err := db.Exec(`
INSERT INTO users (username, email, oauth_subject, oauth_provider, created_at)
VALUES (?, ?, ?, ?, ?)
`, username, email, subject, "authentik", time.Now().Unix())
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
user.ID = int(id)
user.Username = username
user.Email = email
user.OAuthSubject = subject
user.OAuthProvider = "authentik"
user.CreatedAt = time.Now().Unix()
return &user, nil
}
func GetUser(id int) (*User, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
var user User
err := db.QueryRow(`
SELECT id, username, COALESCE(email, ''), COALESCE(oauth_subject, ''), COALESCE(oauth_provider, ''), created_at
FROM users
WHERE id = ?
`, id).Scan(&user.ID, &user.Username, &user.Email, &user.OAuthSubject, &user.OAuthProvider, &user.CreatedAt)
if err != nil {
return nil, err
}
return &user, nil
}