Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apiserver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
type Config struct {
Database DatabaseConfig `mapstructure:"database" yaml:"database"`
Jwt JwtConfig `mapstructure:"jwt" yaml:"jwt"`
OAuth OAuthConfig `mapstructure:"oauth" yaml:"oauth"`
Server ServerConfig `mapstructure:"server" yaml:"server"`
SchedulerJobs SchedulerConfig `mapstructure:"scheduler_jobs" yaml:"scheduler_jobs"`
EmailConfig EmailConfig `mapstructure:"email" yaml:"email"`
Expand All @@ -26,6 +27,18 @@ type JwtConfig struct {
MaxRefresh time.Duration `mapstructure:"max_refresh" yaml:"max_refresh"`
}

type OAuthConfig struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
ClientID string `mapstructure:"client_id" yaml:"client_id"`
ClientSecret string `mapstructure:"client_secret" yaml:"client_secret"`
TenantID string `mapstructure:"tenant_id" yaml:"tenant_id"`
AuthorizeURL string `mapstructure:"authorize_url" yaml:"authorize_url"`
TokenURL string `mapstructure:"token_url" yaml:"token_url"`
RedirectURL string `mapstructure:"redirect_url" yaml:"redirect_url"`
Scope string `mapstructure:"scope" yaml:"scope"`
JwksURL string `mapstructure:"jwks_url" yaml:"jwks_url"`
}

type ServerConfig struct {
HostName string `mapstructure:"host_name" yaml:"host_name"`
Port int `mapstructure:"port" yaml:"port"`
Expand Down Expand Up @@ -75,6 +88,15 @@ func LoadConfig(configFile string) *Config {

// Allow values with secrets to be set via environment variables
_ = viper.BindEnv("jwt.secret", "TW_JWT_SECRET")
_ = viper.BindEnv("oauth.enabled", "TW_OAUTH_ENABLED")
_ = viper.BindEnv("oauth.client_id", "TW_OAUTH_CLIENT_ID")
_ = viper.BindEnv("oauth.client_secret", "TW_OAUTH_CLIENT_SECRET")
_ = viper.BindEnv("oauth.tenant_id", "TW_OAUTH_TENANT_ID")
_ = viper.BindEnv("oauth.authorize_url", "TW_OAUTH_AUTHORIZE_URL")
_ = viper.BindEnv("oauth.token_url", "TW_OAUTH_TOKEN_URL")
_ = viper.BindEnv("oauth.redirect_url", "TW_OAUTH_REDIRECT_URL")
_ = viper.BindEnv("oauth.scope", "TW_OAUTH_SCOPE")
_ = viper.BindEnv("oauth.jwks_url", "TW_OAUTH_JWKS_URL")
_ = viper.BindEnv("email.host", "TW_EMAIL_HOST")
_ = viper.BindEnv("email.port", "TW_EMAIL_PORT")
_ = viper.BindEnv("email.email", "TW_EMAIL_SENDER")
Expand Down
10 changes: 10 additions & 0 deletions apiserver/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ jwt:
secret: "secret"
session_time: 168h
max_refresh: 168h
oauth:
enabled: false
client_id: ""
client_secret: ""
tenant_id: ""
authorize_url: ""
token_url: ""
redirect_url: ""
scope: ""
jwks_url: ""
server:
host_name: example.com
port: 2021
Expand Down
3 changes: 3 additions & 0 deletions apiserver/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ require (
)

require (
github.com/coreos/go-oidc/v3 v3.16.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
)

require (
Expand Down
6 changes: 6 additions & 0 deletions apiserver/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand All @@ -32,6 +34,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
Expand Down Expand Up @@ -163,6 +167,8 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
225 changes: 225 additions & 0 deletions apiserver/internal/apis/oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package apis

import (
"fmt"
"net/http"
"time"

"dkhalife.com/tasks/core/config"
"dkhalife.com/tasks/core/internal/models"
uRepo "dkhalife.com/tasks/core/internal/repos/user"
"dkhalife.com/tasks/core/internal/services/logging"
auth "dkhalife.com/tasks/core/internal/utils/auth"
middleware "dkhalife.com/tasks/core/internal/utils/middleware"
"dkhalife.com/tasks/core/internal/utils/oauth"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
limiter "github.com/ulule/limiter/v3"
)

type OAuthAPIHandler struct {
userRepo uRepo.IUserRepo
oauthProvider *oauth.OAuthProvider
cfg *config.Config
jwtMiddleware *jwt.GinJWTMiddleware
}

func OAuthAPI(
ur uRepo.IUserRepo,
cfg *config.Config,
jwtMiddleware *jwt.GinJWTMiddleware,
) (*OAuthAPIHandler, error) {
provider, err := oauth.NewOAuthProvider(&cfg.OAuth)
if err != nil {
return nil, err
}

return &OAuthAPIHandler{
userRepo: ur,
oauthProvider: provider,
cfg: cfg,
jwtMiddleware: jwtMiddleware,
}, nil
}

// getOAuthConfig returns OAuth configuration for the frontend
func (h *OAuthAPIHandler) getOAuthConfig(c *gin.Context) {
if !h.cfg.OAuth.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth not configured",
})
return
}

c.JSON(http.StatusOK, gin.H{
"enabled": h.cfg.OAuth.Enabled,
"client_id": h.cfg.OAuth.ClientID,
"authorize_url": h.cfg.OAuth.AuthorizeURL,
"scope": h.cfg.OAuth.Scope,
"redirect_url": h.cfg.OAuth.RedirectURL,
})
}

// initiateOAuth starts the OAuth flow by generating a state and returning the authorization URL
func (h *OAuthAPIHandler) initiateOAuth(c *gin.Context) {
if h.oauthProvider == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth not configured",
})
return
}

state, err := oauth.GenerateStateToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate state token",
})
return
}

// Store state in session or cookie for validation
// For now, we'll return it and expect the client to send it back
authURL := h.oauthProvider.GetAuthorizationURL(state)

c.JSON(http.StatusOK, gin.H{
"authorization_url": authURL,
"state": state,
})
}

// callbackOAuth handles the OAuth callback
func (h *OAuthAPIHandler) callbackOAuth(c *gin.Context) {
if h.oauthProvider == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth not configured",
})
return
}

log := logging.FromContext(c)

code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Authorization code not provided",
})
return
}

// Exchange code for token
token, err := h.oauthProvider.ExchangeCode(c.Request.Context(), code)
if err != nil {
log.Errorf("Failed to exchange code: %s", err.Error())
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Failed to exchange authorization code",
})
return
}

// Extract ID token if available
idToken, ok := token.Extra("id_token").(string)
if !ok || idToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "ID token not found in response",
})
return
}

// Validate the ID token
claims, err := h.oauthProvider.ValidateToken(c.Request.Context(), idToken)
if err != nil {
log.Errorf("Failed to validate token: %s", err.Error())
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Failed to validate token",
})
return
}

// Check if user exists, if not create one
email := claims.Email
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Email not found in token claims",
})
return
}

user, err := h.userRepo.FindByEmail(c.Request.Context(), email)
if err != nil {
// User doesn't exist, create one
displayName := claims.Name
if displayName == "" {
displayName = claims.PreferredUsername
}
if displayName == "" {
displayName = email
}

user = &models.User{
Email: email,
DisplayName: displayName,
Password: "", // No password for OAuth users
Disabled: false,
}

if err := h.userRepo.CreateUser(c.Request.Context(), user); err != nil {
log.Errorf("Failed to create user: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to create user",
})
return
}

// Refetch user to get ID
user, err = h.userRepo.FindByEmail(c.Request.Context(), email)
if err != nil {
log.Errorf("Failed to fetch newly created user: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to fetch user",
})
return
}
}

// Check if user is disabled
if user.Disabled {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "User account is disabled",
})
return
}

// Generate JWT token for the user
tokenString, expire, err := h.jwtMiddleware.TokenGenerator(map[string]interface{}{
auth.IdentityKey: fmt.Sprintf("%d", user.ID),
"type": "user",
"scopes": []string{"task:read", "task:write", "label:read", "label:write", "user:read", "user:write", "token:write"},
})
if err != nil {
log.Errorf("Failed to generate JWT: %s", err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate session token",
})
return
}

c.JSON(http.StatusOK, gin.H{
"token": tokenString,
"expiration": expire.Format(time.RFC3339),
})
}

func OAuthRoutes(router *gin.Engine, h *OAuthAPIHandler, limiter *limiter.Limiter) {
if h == nil || h.oauthProvider == nil {
// OAuth not configured, skip routes
return
}

oauthRoutes := router.Group("api/v1/oauth")
oauthRoutes.Use(middleware.RateLimitMiddleware(limiter))
{
oauthRoutes.GET("/config", h.getOAuthConfig)
oauthRoutes.GET("/authorize", h.initiateOAuth)
oauthRoutes.GET("/callback", h.callbackOAuth)
}
}
Loading
Loading