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:
Olivier Dumont
2025-12-30 12:17:40 +01:00
parent 986ac88e14
commit 020fcb9878
21 changed files with 1873 additions and 8 deletions

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS "oidc_clients";

View 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
);

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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:"-"`
}

View 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
}

View 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"
}

View 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 ""
}

View File

@@ -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

View File

@@ -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
}