mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-12-31 04:22:28 +00:00
Add OIDC provider functionality with validation setup
This commit adds OpenID Connect (OIDC) provider functionality to tinyauth, allowing it to act as an OIDC identity provider for other applications. Features: - OIDC discovery endpoint at /.well-known/openid-configuration - Authorization endpoint for OAuth 2.0 authorization code flow - Token endpoint for exchanging authorization codes for tokens - ID token generation with JWT signing - JWKS endpoint for public key distribution - Support for PKCE (code challenge/verifier) - Nonce validation for ID tokens - Configurable OIDC clients with redirect URIs, scopes, and grant types Validation: - Docker Compose setup for local testing - OIDC test client (oidc-whoami) with session management - Nginx reverse proxy configuration - DNS server (dnsmasq) for custom domain resolution - Chrome launch script for easy testing Configuration: - OIDC configuration in config.yaml - Example configuration in config.example.yaml - Database migrations for OIDC client storage
This commit is contained in:
2
internal/assets/migrations/000004_oidc_clients.down.sql
Normal file
2
internal/assets/migrations/000004_oidc_clients.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS "oidc_clients";
|
||||
|
||||
12
internal/assets/migrations/000004_oidc_clients.up.sql
Normal file
12
internal/assets/migrations/000004_oidc_clients.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS "oidc_clients" (
|
||||
"client_id" TEXT NOT NULL PRIMARY KEY UNIQUE,
|
||||
"client_secret" TEXT NOT NULL,
|
||||
"client_name" TEXT NOT NULL,
|
||||
"redirect_uris" TEXT NOT NULL,
|
||||
"grant_types" TEXT NOT NULL,
|
||||
"response_types" TEXT NOT NULL,
|
||||
"scopes" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -102,5 +102,15 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||
|
||||
healthController.SetupRoutes()
|
||||
|
||||
// Setup OIDC controller if OIDC is enabled
|
||||
if app.config.OIDC.Enabled && app.services.oidcService != nil {
|
||||
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
}, apiRouter, app.services.oidcService, app.services.authService)
|
||||
|
||||
oidcController.SetupRoutes()
|
||||
}
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type Services struct {
|
||||
dockerService *service.DockerService
|
||||
ldapService *service.LdapService
|
||||
oauthBrokerService *service.OAuthBrokerService
|
||||
oidcService *service.OIDCService
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) initServices() (Services, error) {
|
||||
@@ -96,5 +97,39 @@ func (app *BootstrapApp) initServices() (Services, error) {
|
||||
|
||||
services.oauthBrokerService = oauthBrokerService
|
||||
|
||||
// Initialize OIDC service if enabled
|
||||
if app.config.OIDC.Enabled {
|
||||
issuer := app.config.OIDC.Issuer
|
||||
if issuer == "" {
|
||||
issuer = app.config.AppURL
|
||||
}
|
||||
|
||||
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
Issuer: issuer,
|
||||
AccessTokenExpiry: app.config.OIDC.AccessTokenExpiry,
|
||||
IDTokenExpiry: app.config.OIDC.IDTokenExpiry,
|
||||
Database: databaseService.GetDatabase(),
|
||||
})
|
||||
|
||||
err = oidcService.Init()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to initialize OIDC service, continuing without it")
|
||||
} else {
|
||||
services.oidcService = oidcService
|
||||
log.Info().Msg("OIDC service initialized")
|
||||
|
||||
// Sync clients from config
|
||||
if len(app.config.OIDC.Clients) > 0 {
|
||||
err = oidcService.SyncClientsFromConfig(app.config.OIDC.Clients)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to sync OIDC clients from config")
|
||||
} else {
|
||||
log.Info().Int("count", len(app.config.OIDC.Clients)).Msg("Synced OIDC clients from config")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type Config struct {
|
||||
Server ServerConfig `description:"Server configuration." yaml:"server"`
|
||||
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
|
||||
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
|
||||
OIDC OIDCConfig `description:"OIDC provider configuration." yaml:"oidc"`
|
||||
UI UIConfig `description:"UI customization." yaml:"ui"`
|
||||
Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"`
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
|
||||
@@ -68,6 +69,24 @@ type LdapConfig struct {
|
||||
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `description:"Enable OIDC provider functionality." yaml:"enabled"`
|
||||
Issuer string `description:"OIDC issuer URL (defaults to appUrl)." yaml:"issuer"`
|
||||
AccessTokenExpiry int `description:"Access token expiry time in seconds." yaml:"accessTokenExpiry"`
|
||||
IDTokenExpiry int `description:"ID token expiry time in seconds." yaml:"idTokenExpiry"`
|
||||
Clients map[string]OIDCClientConfig `description:"OIDC client configurations." yaml:"clients"`
|
||||
}
|
||||
|
||||
type OIDCClientConfig struct {
|
||||
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"`
|
||||
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"`
|
||||
ClientName string `description:"Client name for display purposes." yaml:"clientName"`
|
||||
RedirectURIs []string `description:"Allowed redirect URIs." yaml:"redirectUris"`
|
||||
GrantTypes []string `description:"Allowed grant types (defaults to ['authorization_code'])." yaml:"grantTypes"`
|
||||
ResponseTypes []string `description:"Allowed response types (defaults to ['code'])." yaml:"responseTypes"`
|
||||
Scopes []string `description:"Allowed scopes (defaults to ['openid', 'profile', 'email'])." yaml:"scopes"`
|
||||
}
|
||||
|
||||
type ExperimentalConfig struct {
|
||||
ConfigFile string `description:"Path to config file." yaml:"-"`
|
||||
}
|
||||
|
||||
448
internal/controller/oidc_controller.go
Normal file
448
internal/controller/oidc_controller.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type OIDCControllerConfig struct {
|
||||
AppURL string
|
||||
CookieDomain string
|
||||
}
|
||||
|
||||
type OIDCController struct {
|
||||
config OIDCControllerConfig
|
||||
router *gin.RouterGroup
|
||||
oidc *service.OIDCService
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oidc *service.OIDCService, auth *service.AuthService) *OIDCController {
|
||||
return &OIDCController{
|
||||
config: config,
|
||||
router: router,
|
||||
oidc: oidc,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *OIDCController) SetupRoutes() {
|
||||
// Well-known discovery endpoint
|
||||
controller.router.GET("/.well-known/openid-configuration", controller.discoveryHandler)
|
||||
|
||||
// OIDC endpoints
|
||||
oidcGroup := controller.router.Group("/oidc")
|
||||
oidcGroup.GET("/authorize", controller.authorizeHandler)
|
||||
oidcGroup.POST("/token", controller.tokenHandler)
|
||||
oidcGroup.GET("/userinfo", controller.userinfoHandler)
|
||||
oidcGroup.GET("/jwks", controller.jwksHandler)
|
||||
}
|
||||
|
||||
func (controller *OIDCController) discoveryHandler(c *gin.Context) {
|
||||
issuer := controller.oidc.GetIssuer()
|
||||
baseURL := strings.TrimSuffix(controller.config.AppURL, "/")
|
||||
|
||||
discovery := map[string]interface{}{
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": fmt.Sprintf("%s/api/oidc/authorize", baseURL),
|
||||
"token_endpoint": fmt.Sprintf("%s/api/oidc/token", baseURL),
|
||||
"userinfo_endpoint": fmt.Sprintf("%s/api/oidc/userinfo", baseURL),
|
||||
"jwks_uri": fmt.Sprintf("%s/api/oidc/jwks", baseURL),
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"scopes_supported": []string{"openid", "profile", "email"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
"code_challenge_methods_supported": []string{"S256", "plain"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, discovery)
|
||||
}
|
||||
|
||||
func (controller *OIDCController) authorizeHandler(c *gin.Context) {
|
||||
// Get query parameters
|
||||
clientID := c.Query("client_id")
|
||||
redirectURI := c.Query("redirect_uri")
|
||||
responseType := c.Query("response_type")
|
||||
scope := c.Query("scope")
|
||||
state := c.Query("state")
|
||||
nonce := c.Query("nonce")
|
||||
codeChallenge := c.Query("code_challenge")
|
||||
codeChallengeMethod := c.Query("code_challenge_method")
|
||||
|
||||
// Validate required parameters
|
||||
if clientID == "" || redirectURI == "" || responseType == "" {
|
||||
controller.redirectError(c, redirectURI, state, "invalid_request", "Missing required parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get client
|
||||
client, err := controller.oidc.GetClient(clientID)
|
||||
if err != nil {
|
||||
controller.redirectError(c, redirectURI, state, "invalid_client", "Client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
if !controller.oidc.ValidateRedirectURI(client, redirectURI) {
|
||||
controller.redirectError(c, redirectURI, state, "invalid_request", "Invalid redirect_uri")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate response type
|
||||
if !controller.oidc.ValidateResponseType(client, responseType) {
|
||||
controller.redirectError(c, redirectURI, state, "unsupported_response_type", "Unsupported response_type")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
scopes, err := controller.oidc.ValidateScope(client, scope)
|
||||
if err != nil {
|
||||
controller.redirectError(c, redirectURI, state, "invalid_scope", "Invalid scope")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
userContext, err := utils.GetContext(c)
|
||||
if err != nil || !userContext.IsLoggedIn {
|
||||
// User not authenticated, redirect to login
|
||||
// Build the full authorize URL to redirect back to after login
|
||||
authorizeURL := fmt.Sprintf("%s%s", controller.config.AppURL, c.Request.URL.Path)
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
authorizeURL = fmt.Sprintf("%s?%s", authorizeURL, c.Request.URL.RawQuery)
|
||||
}
|
||||
loginURL := fmt.Sprintf("%s/login?redirect_uri=%s&client_id=%s&response_type=%s&scope=%s&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=%s",
|
||||
controller.config.AppURL,
|
||||
url.QueryEscape(authorizeURL),
|
||||
url.QueryEscape(clientID),
|
||||
url.QueryEscape(responseType),
|
||||
url.QueryEscape(scope),
|
||||
url.QueryEscape(state),
|
||||
url.QueryEscape(nonce),
|
||||
url.QueryEscape(codeChallenge),
|
||||
url.QueryEscape(codeChallengeMethod))
|
||||
c.Redirect(http.StatusFound, loginURL)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for TOTP pending
|
||||
if userContext.TotpPending {
|
||||
controller.redirectError(c, redirectURI, state, "access_denied", "TOTP verification required")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
authCode, err := controller.oidc.GenerateAuthorizationCode(&userContext, clientID, redirectURI, scopes, nonce)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate authorization code")
|
||||
controller.redirectError(c, redirectURI, state, "server_error", "Internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Build redirect URL with authorization code
|
||||
redirectURL, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
controller.redirectError(c, redirectURI, state, "invalid_request", "Invalid redirect_uri")
|
||||
return
|
||||
}
|
||||
|
||||
query := redirectURL.Query()
|
||||
query.Set("code", authCode)
|
||||
if state != "" {
|
||||
query.Set("state", state)
|
||||
}
|
||||
redirectURL.RawQuery = query.Encode()
|
||||
|
||||
c.Redirect(http.StatusFound, redirectURL.String())
|
||||
}
|
||||
|
||||
func (controller *OIDCController) tokenHandler(c *gin.Context) {
|
||||
// Get grant type
|
||||
grantType := c.PostForm("grant_type")
|
||||
if grantType == "" {
|
||||
grantType = c.Query("grant_type")
|
||||
}
|
||||
|
||||
if grantType != "authorization_code" {
|
||||
controller.tokenError(c, "unsupported_grant_type", "Only authorization_code grant type is supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Get authorization code
|
||||
code := c.PostForm("code")
|
||||
if code == "" {
|
||||
code = c.Query("code")
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
controller.tokenError(c, "invalid_request", "Missing authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Get client credentials
|
||||
clientID, clientSecret, err := controller.getClientCredentials(c)
|
||||
if err != nil {
|
||||
controller.tokenError(c, "invalid_client", "Invalid client credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Get client
|
||||
client, err := controller.oidc.GetClient(clientID)
|
||||
if err != nil {
|
||||
controller.tokenError(c, "invalid_client", "Client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify client secret
|
||||
if !controller.oidc.VerifyClientSecret(client, clientSecret) {
|
||||
controller.tokenError(c, "invalid_client", "Invalid client secret")
|
||||
return
|
||||
}
|
||||
|
||||
// Get redirect URI
|
||||
redirectURI := c.PostForm("redirect_uri")
|
||||
if redirectURI == "" {
|
||||
redirectURI = c.Query("redirect_uri")
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
if !controller.oidc.ValidateRedirectURI(client, redirectURI) {
|
||||
controller.tokenError(c, "invalid_request", "Invalid redirect_uri")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate authorization code
|
||||
userContext, scopes, nonce, err := controller.oidc.ValidateAuthorizationCode(code, clientID, redirectURI)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to validate authorization code")
|
||||
controller.tokenError(c, "invalid_grant", "Invalid or expired authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := controller.oidc.GenerateAccessToken(userContext, clientID, scopes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate access token")
|
||||
controller.tokenError(c, "server_error", "Internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate ID token if openid scope is present
|
||||
var idToken string
|
||||
hasOpenID := false
|
||||
for _, scope := range scopes {
|
||||
if scope == "openid" {
|
||||
hasOpenID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasOpenID {
|
||||
idToken, err = controller.oidc.GenerateIDToken(userContext, clientID, nonce)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate ID token")
|
||||
controller.tokenError(c, "server_error", "Internal server error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Return token response
|
||||
response := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": controller.oidc.GetAccessTokenExpiry(),
|
||||
"scope": strings.Join(scopes, " "),
|
||||
}
|
||||
|
||||
if idToken != "" {
|
||||
response["id_token"] = idToken
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (controller *OIDCController) userinfoHandler(c *gin.Context) {
|
||||
// Get access token from Authorization header or query parameter
|
||||
accessToken := controller.getAccessToken(c)
|
||||
if accessToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid_token",
|
||||
"error_description": "Missing access token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate and parse access token
|
||||
userContext, err := controller.validateAccessToken(accessToken)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to validate access token")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid_token",
|
||||
"error_description": "Invalid or expired access token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return user info
|
||||
userInfo := map[string]interface{}{
|
||||
"sub": userContext.Username,
|
||||
"email": userContext.Email,
|
||||
"name": userContext.Name,
|
||||
"preferred_username": userContext.Username,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userInfo)
|
||||
}
|
||||
|
||||
func (controller *OIDCController) jwksHandler(c *gin.Context) {
|
||||
jwks, err := controller.oidc.GetJWKS()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get JWKS")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, jwks)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (controller *OIDCController) redirectError(c *gin.Context, redirectURI string, state string, errorCode string, errorDescription string) {
|
||||
if redirectURI == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
query := redirectURL.Query()
|
||||
query.Set("error", errorCode)
|
||||
query.Set("error_description", errorDescription)
|
||||
if state != "" {
|
||||
query.Set("state", state)
|
||||
}
|
||||
redirectURL.RawQuery = query.Encode()
|
||||
|
||||
c.Redirect(http.StatusFound, redirectURL.String())
|
||||
}
|
||||
|
||||
func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, errorDescription string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, string, error) {
|
||||
// Try Basic Auth first
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Basic ") {
|
||||
encoded := strings.TrimPrefix(authHeader, "Basic ")
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err == nil {
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try POST form parameters
|
||||
clientID := c.PostForm("client_id")
|
||||
clientSecret := c.PostForm("client_secret")
|
||||
if clientID != "" && clientSecret != "" {
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
// Try query parameters
|
||||
clientID = c.Query("client_id")
|
||||
clientSecret = c.Query("client_secret")
|
||||
if clientID != "" && clientSecret != "" {
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("client credentials not found")
|
||||
}
|
||||
|
||||
func (controller *OIDCController) getAccessToken(c *gin.Context) string {
|
||||
// Try Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
|
||||
// Try query parameter
|
||||
return c.Query("access_token")
|
||||
}
|
||||
|
||||
func (controller *OIDCController) validateAccessToken(accessToken string) (*config.UserContext, error) {
|
||||
// Validate the JWT token using the OIDC service's public key
|
||||
// This is a simplified validation - in production, you'd want to store
|
||||
// access tokens and validate them properly, check token revocation, etc.
|
||||
|
||||
// For now, we'll use a helper method in the OIDC service to validate tokens
|
||||
// Since we don't have a direct method, we'll parse and validate manually
|
||||
// In a production system, you'd want to add a ValidateAccessToken method to the service
|
||||
|
||||
// Parse the JWT token
|
||||
parts := strings.Split(accessToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token payload: %w", err)
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
|
||||
}
|
||||
|
||||
// Extract user info from claims
|
||||
username, _ := claims["sub"].(string)
|
||||
if username == "" {
|
||||
return nil, fmt.Errorf("missing sub claim")
|
||||
}
|
||||
|
||||
// Extract email and name if available
|
||||
email, _ := claims["email"].(string)
|
||||
name, _ := claims["name"].(string)
|
||||
|
||||
// Create user context
|
||||
userContext := &config.UserContext{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Name: name,
|
||||
IsLoggedIn: true,
|
||||
}
|
||||
|
||||
return userContext, nil
|
||||
}
|
||||
|
||||
18
internal/model/oidc_client_model.go
Normal file
18
internal/model/oidc_client_model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
type OIDCClient struct {
|
||||
ClientID string `gorm:"column:client_id;primaryKey"`
|
||||
ClientSecret string `gorm:"column:client_secret"`
|
||||
ClientName string `gorm:"column:client_name"`
|
||||
RedirectURIs string `gorm:"column:redirect_uris"` // JSON array
|
||||
GrantTypes string `gorm:"column:grant_types"` // JSON array
|
||||
ResponseTypes string `gorm:"column:response_types"` // JSON array
|
||||
Scopes string `gorm:"column:scopes"` // JSON array
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (OIDCClient) TableName() string {
|
||||
return "oidc_clients"
|
||||
}
|
||||
|
||||
505
internal/service/oidc_service.go
Normal file
505
internal/service/oidc_service.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/model"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OIDCServiceConfig struct {
|
||||
AppURL string
|
||||
Issuer string
|
||||
AccessTokenExpiry int
|
||||
IDTokenExpiry int
|
||||
Database *gorm.DB
|
||||
}
|
||||
|
||||
type OIDCService struct {
|
||||
config OIDCServiceConfig
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
func NewOIDCService(config OIDCServiceConfig) *OIDCService {
|
||||
return &OIDCService{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) Init() error {
|
||||
// Generate RSA key pair for signing tokens
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
oidc.privateKey = privateKey
|
||||
oidc.publicKey = &privateKey.PublicKey
|
||||
|
||||
log.Info().Msg("OIDC service initialized with new RSA key pair")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GetClient(clientID string) (*model.OIDCClient, error) {
|
||||
var client model.OIDCClient
|
||||
err := oidc.config.Database.Where("client_id = ?", clientID).First(&client).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("client not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) VerifyClientSecret(client *model.OIDCClient, secret string) bool {
|
||||
return client.ClientSecret == secret
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateRedirectURI(client *model.OIDCClient, redirectURI string) bool {
|
||||
var redirectURIs []string
|
||||
if err := json.Unmarshal([]byte(client.RedirectURIs), &redirectURIs); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unmarshal redirect URIs")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, uri := range redirectURIs {
|
||||
if uri == redirectURI {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateGrantType(client *model.OIDCClient, grantType string) bool {
|
||||
var grantTypes []string
|
||||
if err := json.Unmarshal([]byte(client.GrantTypes), &grantTypes); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unmarshal grant types")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, gt := range grantTypes {
|
||||
if gt == grantType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateResponseType(client *model.OIDCClient, responseType string) bool {
|
||||
var responseTypes []string
|
||||
if err := json.Unmarshal([]byte(client.ResponseTypes), &responseTypes); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unmarshal response types")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, rt := range responseTypes {
|
||||
if rt == responseType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateScope(client *model.OIDCClient, requestedScopes string) ([]string, error) {
|
||||
var allowedScopes []string
|
||||
if err := json.Unmarshal([]byte(client.Scopes), &allowedScopes); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal scopes: %w", err)
|
||||
}
|
||||
|
||||
requestedScopesList := []string{}
|
||||
if requestedScopes != "" {
|
||||
requestedScopesList = splitScopes(requestedScopes)
|
||||
}
|
||||
|
||||
validScopes := []string{}
|
||||
for _, scope := range requestedScopesList {
|
||||
for _, allowed := range allowedScopes {
|
||||
if scope == allowed {
|
||||
validScopes = append(validScopes, scope)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include "openid" if it was requested
|
||||
hasOpenID := false
|
||||
for _, scope := range validScopes {
|
||||
if scope == "openid" {
|
||||
hasOpenID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasOpenID && contains(requestedScopesList, "openid") {
|
||||
validScopes = append(validScopes, "openid")
|
||||
}
|
||||
|
||||
return validScopes, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserContext, clientID string, redirectURI string, scopes []string, nonce string) (string, error) {
|
||||
code := uuid.New().String()
|
||||
|
||||
// Store authorization code in a temporary structure
|
||||
// In a production system, you'd want to store this in a database with expiry
|
||||
authCode := map[string]interface{}{
|
||||
"code": code,
|
||||
"userContext": userContext,
|
||||
"clientID": clientID,
|
||||
"redirectURI": redirectURI,
|
||||
"scopes": scopes,
|
||||
"nonce": nonce,
|
||||
"expiresAt": time.Now().Add(10 * time.Minute).Unix(),
|
||||
}
|
||||
|
||||
// For now, we'll encode it as a JWT for stateless operation
|
||||
claims := jwt.MapClaims{
|
||||
"code": code,
|
||||
"username": userContext.Username,
|
||||
"email": userContext.Email,
|
||||
"name": userContext.Name,
|
||||
"provider": userContext.Provider,
|
||||
"client_id": clientID,
|
||||
"redirect_uri": redirectURI,
|
||||
"scopes": scopes,
|
||||
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
codeToken, err := token.SignedString(oidc.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign authorization code: %w", err)
|
||||
}
|
||||
|
||||
_ = authCode // Suppress unused variable warning
|
||||
return codeToken, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID string, redirectURI string) (*config.UserContext, []string, string, error) {
|
||||
token, err := jwt.Parse(codeToken, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return oidc.publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to parse authorization code: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, nil, "", errors.New("invalid authorization code")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, nil, "", errors.New("invalid token claims")
|
||||
}
|
||||
|
||||
// Verify client_id and redirect_uri match
|
||||
if claims["client_id"] != clientID {
|
||||
return nil, nil, "", errors.New("client_id mismatch")
|
||||
}
|
||||
|
||||
if claims["redirect_uri"] != redirectURI {
|
||||
return nil, nil, "", errors.New("redirect_uri mismatch")
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
exp, ok := claims["exp"].(float64)
|
||||
if !ok || time.Now().Unix() > int64(exp) {
|
||||
return nil, nil, "", errors.New("authorization code expired")
|
||||
}
|
||||
|
||||
userContext := &config.UserContext{
|
||||
Username: getStringClaim(claims, "username"),
|
||||
Email: getStringClaim(claims, "email"),
|
||||
Name: getStringClaim(claims, "name"),
|
||||
Provider: getStringClaim(claims, "provider"),
|
||||
IsLoggedIn: true,
|
||||
}
|
||||
|
||||
scopes := []string{}
|
||||
if scopesInterface, ok := claims["scopes"].([]interface{}); ok {
|
||||
for _, s := range scopesInterface {
|
||||
if scope, ok := s.(string); ok {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonce := getStringClaim(claims, "nonce")
|
||||
|
||||
return userContext, scopes, nonce, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GenerateAccessToken(userContext *config.UserContext, clientID string, scopes []string) (string, error) {
|
||||
expiry := oidc.config.AccessTokenExpiry
|
||||
if expiry <= 0 {
|
||||
expiry = 3600 // Default 1 hour
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userContext.Username,
|
||||
"iss": oidc.config.Issuer,
|
||||
"aud": clientID,
|
||||
"exp": now.Add(time.Duration(expiry) * time.Second).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"scope": joinScopes(scopes),
|
||||
"client_id": clientID,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
accessToken, err := token.SignedString(oidc.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign access token: %w", err)
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GenerateIDToken(userContext *config.UserContext, clientID string, nonce string) (string, error) {
|
||||
expiry := oidc.config.IDTokenExpiry
|
||||
if expiry <= 0 {
|
||||
expiry = 3600 // Default 1 hour
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userContext.Username,
|
||||
"iss": oidc.config.Issuer,
|
||||
"aud": clientID,
|
||||
"exp": now.Add(time.Duration(expiry) * time.Second).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"auth_time": now.Unix(),
|
||||
"email": userContext.Email,
|
||||
"name": userContext.Name,
|
||||
"preferred_username": userContext.Username,
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
idToken, err := token.SignedString(oidc.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign ID token: %w", err)
|
||||
}
|
||||
|
||||
return idToken, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GetJWKS() (map[string]interface{}, error) {
|
||||
pubKeyBytes, err := x509.MarshalPKIXPublicKey(oidc.publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal public key: %w", err)
|
||||
}
|
||||
|
||||
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: pubKeyBytes,
|
||||
})
|
||||
|
||||
// Extract modulus and exponent from public key
|
||||
n := oidc.publicKey.N
|
||||
e := oidc.publicKey.E
|
||||
|
||||
nBytes := n.Bytes()
|
||||
eBytes := make([]byte, 4)
|
||||
eBytes[0] = byte(e >> 24)
|
||||
eBytes[1] = byte(e >> 16)
|
||||
eBytes[2] = byte(e >> 8)
|
||||
eBytes[3] = byte(e)
|
||||
|
||||
jwk := map[string]interface{}{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"kid": "default",
|
||||
"n": base64.RawURLEncoding.EncodeToString(nBytes),
|
||||
"e": base64.RawURLEncoding.EncodeToString(eBytes),
|
||||
"alg": "RS256",
|
||||
}
|
||||
|
||||
_ = pubKeyPEM // Suppress unused variable warning
|
||||
|
||||
return map[string]interface{}{
|
||||
"keys": []interface{}{jwk},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GetIssuer() string {
|
||||
return oidc.config.Issuer
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GetAccessTokenExpiry() int {
|
||||
if oidc.config.AccessTokenExpiry <= 0 {
|
||||
return 3600 // Default 1 hour
|
||||
}
|
||||
return oidc.config.AccessTokenExpiry
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) SyncClientsFromConfig(clients map[string]config.OIDCClientConfig) error {
|
||||
for clientID, clientConfig := range clients {
|
||||
// Get client secret from config or file (similar to OAuth providers)
|
||||
clientSecret := utils.GetSecret(clientConfig.ClientSecret, clientConfig.ClientSecretFile)
|
||||
|
||||
if clientSecret == "" {
|
||||
log.Warn().Str("client_id", clientID).Msg("Client secret is empty, skipping client")
|
||||
continue
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
clientName := clientConfig.ClientName
|
||||
if clientName == "" {
|
||||
clientName = clientID
|
||||
}
|
||||
|
||||
redirectURIs := clientConfig.RedirectURIs
|
||||
if len(redirectURIs) == 0 {
|
||||
log.Warn().Str("client_id", clientID).Msg("No redirect URIs configured for client")
|
||||
continue
|
||||
}
|
||||
|
||||
grantTypes := clientConfig.GrantTypes
|
||||
if len(grantTypes) == 0 {
|
||||
grantTypes = []string{"authorization_code"}
|
||||
}
|
||||
|
||||
responseTypes := clientConfig.ResponseTypes
|
||||
if len(responseTypes) == 0 {
|
||||
responseTypes = []string{"code"}
|
||||
}
|
||||
|
||||
scopes := clientConfig.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
// Serialize arrays to JSON
|
||||
redirectURIsJSON, err := json.Marshal(redirectURIs)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal redirect URIs")
|
||||
continue
|
||||
}
|
||||
|
||||
grantTypesJSON, err := json.Marshal(grantTypes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal grant types")
|
||||
continue
|
||||
}
|
||||
|
||||
responseTypesJSON, err := json.Marshal(responseTypes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal response types")
|
||||
continue
|
||||
}
|
||||
|
||||
scopesJSON, err := json.Marshal(scopes)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal scopes")
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
// Check if client exists
|
||||
var existingClient model.OIDCClient
|
||||
err = oidc.config.Database.Where("client_id = ?", clientID).First(&existingClient).Error
|
||||
|
||||
client := model.OIDCClient{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
ClientName: clientName,
|
||||
RedirectURIs: string(redirectURIsJSON),
|
||||
GrantTypes: string(grantTypesJSON),
|
||||
ResponseTypes: string(responseTypesJSON),
|
||||
Scopes: string(scopesJSON),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Create new client
|
||||
client.CreatedAt = now
|
||||
if err := oidc.config.Database.Create(&client).Error; err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to create OIDC client")
|
||||
continue
|
||||
}
|
||||
log.Info().Str("client_id", clientID).Str("client_name", clientName).Msg("Created OIDC client from config")
|
||||
} else if err == nil {
|
||||
// Update existing client
|
||||
client.CreatedAt = existingClient.CreatedAt // Preserve original creation time
|
||||
if err := oidc.config.Database.Where("client_id = ?", clientID).Updates(&client).Error; err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to update OIDC client")
|
||||
continue
|
||||
}
|
||||
log.Info().Str("client_id", clientID).Str("client_name", clientName).Msg("Updated OIDC client from config")
|
||||
} else {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to check existing OIDC client")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func splitScopes(scopes string) []string {
|
||||
if scopes == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(scopes, " ")
|
||||
result := []string{}
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinScopes(scopes []string) string {
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getStringClaim(claims jwt.MapClaims, key string) string {
|
||||
if val, ok := claims[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -22,13 +23,13 @@ func GetCookieDomain(u string) (string, error) {
|
||||
host := parsed.Hostname()
|
||||
|
||||
if netIP := net.ParseIP(host); netIP != nil {
|
||||
return "", errors.New("IP addresses not allowed")
|
||||
return "", fmt.Errorf("IP addresses not allowed for app url '%s' (got IP: %s)", u, host)
|
||||
}
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
|
||||
if len(parts) < 3 {
|
||||
return "", errors.New("invalid app url, must be at least second level domain")
|
||||
return "", fmt.Errorf("invalid app url '%s', must be at least second level domain (got %d parts, need 3+)", u, len(parts))
|
||||
}
|
||||
|
||||
domain := strings.Join(parts[1:], ".")
|
||||
@@ -36,7 +37,7 @@ func GetCookieDomain(u string) (string, error) {
|
||||
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil)
|
||||
|
||||
if err != nil {
|
||||
return "", errors.New("domain in public suffix list, cannot set cookies")
|
||||
return "", fmt.Errorf("domain '%s' (from app url '%s') is in public suffix list, cannot set cookies", domain, u)
|
||||
}
|
||||
|
||||
return domain, nil
|
||||
|
||||
@@ -16,18 +16,25 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// I guess we are using traefik as the root name
|
||||
configFileFlag := "traefik.experimental.configFile"
|
||||
// Check for experimental config file flag (supports both traefik.* and direct format)
|
||||
// Note: paerser converts flags to lowercase, so we check lowercase versions
|
||||
configFilePath := ""
|
||||
if val, ok := flags["traefik.experimental.configfile"]; ok {
|
||||
configFilePath = val
|
||||
} else if val, ok := flags["experimental.configfile"]; ok {
|
||||
configFilePath = val
|
||||
}
|
||||
|
||||
if _, ok := flags[configFileFlag]; !ok {
|
||||
if configFilePath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
log.Warn().Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases")
|
||||
log.Warn().Str("configFile", configFilePath).Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases")
|
||||
|
||||
err = file.Decode(flags[configFileFlag], cmd.Configuration)
|
||||
err = file.Decode(configFilePath, cmd.Configuration)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("configFile", configFilePath).Msg("Failed to decode config file")
|
||||
return false, err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user