mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-12-30 20:12:29 +00:00
Compare commits
16 Commits
feat/forwa
...
shreknel-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f2f813902 | ||
|
|
014550f80e | ||
|
|
5ec9989189 | ||
|
|
ad12110fbf | ||
|
|
ca74534048 | ||
|
|
1b37096b58 | ||
|
|
cd068d16c2 | ||
|
|
5b5799ab62 | ||
|
|
672914ceb7 | ||
|
|
f006ebe5e4 | ||
|
|
dabb4398ad | ||
|
|
ef157ae9ba | ||
|
|
020fcb9878 | ||
|
|
986ac88e14 | ||
|
|
b159f44729 | ||
|
|
43487d44f7 |
1
.review_trigger
Normal file
1
.review_trigger
Normal file
@@ -0,0 +1 @@
|
||||
# Trigger automated review
|
||||
@@ -34,6 +34,10 @@ func NewTinyauthCmdConfiguration() *config.Config {
|
||||
ForgotPasswordMessage: "You can change your password by changing the configuration.",
|
||||
BackgroundImage: "/background.jpg",
|
||||
},
|
||||
Ldap: config.LdapConfig{
|
||||
Insecure: false,
|
||||
SearchFilter: "(uid=%s)",
|
||||
},
|
||||
Experimental: config.ExperimentalConfig{
|
||||
ConfigFile: "",
|
||||
},
|
||||
|
||||
@@ -63,6 +63,42 @@ oauth:
|
||||
# Allow insecure connections (self-signed certificates)
|
||||
insecure: false
|
||||
|
||||
# OIDC Provider Configuration
|
||||
oidc:
|
||||
# Enable OIDC provider functionality
|
||||
enabled: false
|
||||
# OIDC issuer URL (defaults to appUrl if not set)
|
||||
issuer: ""
|
||||
# Access token expiry in seconds (3600 = 1 hour)
|
||||
accessTokenExpiry: 3600
|
||||
# ID token expiry in seconds (3600 = 1 hour)
|
||||
idTokenExpiry: 3600
|
||||
# OIDC Client Configuration
|
||||
clients:
|
||||
# Client ID (used as the key)
|
||||
myapp:
|
||||
# Client secret (or use clientSecretFile)
|
||||
clientSecret: "your_client_secret_here"
|
||||
# Path to file containing client secret (optional, alternative to clientSecret)
|
||||
clientSecretFile: ""
|
||||
# Client name for display purposes
|
||||
clientName: "My Application"
|
||||
# Allowed redirect URIs
|
||||
redirectUris:
|
||||
- "https://myapp.example.com/callback"
|
||||
- "http://localhost:3000/callback"
|
||||
# Allowed grant types (defaults to ["authorization_code"] if not specified)
|
||||
grantTypes:
|
||||
- "authorization_code"
|
||||
# Allowed response types (defaults to ["code"] if not specified)
|
||||
responseTypes:
|
||||
- "code"
|
||||
# Allowed scopes (defaults to ["openid", "profile", "email"] if not specified)
|
||||
scopes:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
|
||||
# UI Customization
|
||||
ui:
|
||||
# Custom title for login page
|
||||
|
||||
1
go.mod
1
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/go-querystring v1.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -127,6 +127,8 @@ github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
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/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
|
||||
1
internal/assets/migrations/000003_oauth_sub.down.sql
Normal file
1
internal/assets/migrations/000003_oauth_sub.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sessions" DROP COLUMN "oauth_sub";
|
||||
1
internal/assets/migrations/000003_oauth_sub.up.sql
Normal file
1
internal/assets/migrations/000003_oauth_sub.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sessions" ADD COLUMN "oauth_sub" TEXT;
|
||||
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
|
||||
);
|
||||
|
||||
2
internal/assets/migrations/000005_oidc_keys.down.sql
Normal file
2
internal/assets/migrations/000005_oidc_keys.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS "oidc_keys";
|
||||
|
||||
7
internal/assets/migrations/000005_oidc_keys.up.sql
Normal file
7
internal/assets/migrations/000005_oidc_keys.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS "oidc_keys" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"private_key" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL,
|
||||
"updated_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS "idx_oidc_auth_codes_expires_at";
|
||||
DROP TABLE IF EXISTS "oidc_authorization_codes";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS "oidc_authorization_codes" (
|
||||
"code" TEXT NOT NULL PRIMARY KEY,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"redirect_uri" TEXT NOT NULL,
|
||||
"used" BOOLEAN NOT NULL DEFAULT 0,
|
||||
"expires_at" INTEGER NOT NULL,
|
||||
"created_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_oidc_auth_codes_expires_at" ON "oidc_authorization_codes"("expires_at");
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -79,6 +98,7 @@ const DefaultNamePrefix = "TINYAUTH_"
|
||||
// OAuth/OIDC config
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
@@ -125,6 +145,7 @@ type SessionCookie struct {
|
||||
TotpPending bool
|
||||
OAuthGroups string
|
||||
OAuthName string
|
||||
OAuthSub string
|
||||
}
|
||||
|
||||
type UserContext struct {
|
||||
@@ -138,6 +159,7 @@ type UserContext struct {
|
||||
OAuthGroups string
|
||||
TotpEnabled bool
|
||||
OAuthName string
|
||||
OAuthSub string
|
||||
}
|
||||
|
||||
// API responses and queries
|
||||
|
||||
@@ -21,6 +21,7 @@ type UserContextResponse struct {
|
||||
OAuth bool `json:"oauth"`
|
||||
TotpPending bool `json:"totpPending"`
|
||||
OAuthName string `json:"oauthName"`
|
||||
OAuthSub string `json:"oauthSub"`
|
||||
}
|
||||
|
||||
type AppContextResponse struct {
|
||||
@@ -89,6 +90,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
|
||||
OAuth: context.OAuth,
|
||||
TotpPending: context.TotpPending,
|
||||
OAuthName: context.OAuthName,
|
||||
OAuthSub: context.OAuthSub,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -44,6 +44,7 @@ var userContext = config.UserContext{
|
||||
TotpPending: false,
|
||||
OAuthGroups: "",
|
||||
TotpEnabled: false,
|
||||
OAuthSub: "",
|
||||
}
|
||||
|
||||
func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {
|
||||
|
||||
@@ -197,6 +197,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
Provider: req.Provider,
|
||||
OAuthGroups: utils.CoalesceToString(user.Groups),
|
||||
OAuthName: service.GetName(),
|
||||
OAuthSub: user.Sub,
|
||||
}
|
||||
|
||||
log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||
|
||||
489
internal/controller/oidc_controller.go
Normal file
489
internal/controller/oidc_controller.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"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"
|
||||
)
|
||||
|
||||
// OIDCControllerConfig holds configuration for the OIDC controller.
|
||||
type OIDCControllerConfig struct {
|
||||
AppURL string // Base URL of the application
|
||||
CookieDomain string // Domain for setting cookies
|
||||
}
|
||||
|
||||
// OIDCController handles OpenID Connect (OIDC) protocol endpoints.
|
||||
// It implements the OIDC provider functionality including discovery, authorization,
|
||||
// token exchange, userinfo, and JWKS endpoints.
|
||||
type OIDCController struct {
|
||||
config OIDCControllerConfig
|
||||
router *gin.RouterGroup
|
||||
oidc *service.OIDCService
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
// NewOIDCController creates a new OIDC controller with the given configuration and services.
|
||||
func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oidc *service.OIDCService, auth *service.AuthService) *OIDCController {
|
||||
return &OIDCController{
|
||||
config: config,
|
||||
router: router,
|
||||
oidc: oidc,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRoutes registers all OIDC endpoints with the router.
|
||||
// This includes:
|
||||
// - /.well-known/openid-configuration - OIDC discovery endpoint
|
||||
// - /oidc/authorize - Authorization endpoint
|
||||
// - /oidc/token - Token endpoint
|
||||
// - /oidc/userinfo - UserInfo endpoint
|
||||
// - /oidc/jwks - JSON Web Key Set endpoint
|
||||
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)
|
||||
}
|
||||
|
||||
// discoveryHandler handles the OIDC discovery endpoint.
|
||||
// Returns the OpenID Connect discovery document as specified in RFC 8414.
|
||||
// The document contains metadata about the OIDC provider including endpoints,
|
||||
// supported features, and cryptographic capabilities.
|
||||
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)
|
||||
}
|
||||
|
||||
// authorizeHandler handles the OIDC authorization endpoint.
|
||||
// Implements the authorization code flow as specified in OAuth 2.0 RFC 6749.
|
||||
// Validates client credentials, redirect URI, scopes, and response type.
|
||||
// Supports PKCE (RFC 7636) for enhanced security.
|
||||
// If the user is not authenticated, redirects to the login page with the
|
||||
// authorization request parameters preserved for redirect after login.
|
||||
// On success, generates an authorization code and redirects to the client's
|
||||
// redirect URI with the code and state parameter.
|
||||
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
|
||||
// Return JSON error instead of redirecting since redirect_uri is not yet validated
|
||||
if clientID == "" || redirectURI == "" || responseType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing required parameters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get client
|
||||
// Return JSON error instead of redirecting since redirect_uri is not yet validated
|
||||
client, err := controller.oidc.GetClient(clientID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_client",
|
||||
"error_description": "Client not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
// After this point, redirect_uri is validated and we can safely redirect
|
||||
if !controller.oidc.ValidateRedirectURI(client, redirectURI) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "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 (including PKCE challenge if provided)
|
||||
authCode, err := controller.oidc.GenerateAuthorizationCode(&userContext, clientID, redirectURI, scopes, nonce, codeChallenge, codeChallengeMethod)
|
||||
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())
|
||||
}
|
||||
|
||||
// tokenHandler handles the OIDC token endpoint.
|
||||
// Exchanges an authorization code for access and ID tokens.
|
||||
// Validates the authorization code, client credentials, redirect URI, and PKCE verifier.
|
||||
// Returns an access token and optionally an ID token (if openid scope is present).
|
||||
// Implements the authorization code grant type as specified in OAuth 2.0 RFC 6749.
|
||||
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
|
||||
}
|
||||
|
||||
// Get code_verifier for PKCE validation
|
||||
codeVerifier := c.PostForm("code_verifier")
|
||||
if codeVerifier == "" {
|
||||
codeVerifier = c.Query("code_verifier")
|
||||
}
|
||||
|
||||
// Validate authorization code
|
||||
userContext, scopes, nonce, codeChallenge, codeChallengeMethod, 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
|
||||
}
|
||||
|
||||
// Validate PKCE if code challenge was provided
|
||||
if codeChallenge != "" {
|
||||
if err := controller.oidc.ValidatePKCE(codeChallenge, codeChallengeMethod, codeVerifier); err != nil {
|
||||
log.Error().Err(err).Msg("PKCE validation failed")
|
||||
controller.tokenError(c, "invalid_grant", "Invalid code_verifier")
|
||||
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)
|
||||
}
|
||||
|
||||
// userinfoHandler handles the OIDC UserInfo endpoint.
|
||||
// Returns user information claims for the authenticated user based on the
|
||||
// provided access token. Validates the access token signature, issuer, and expiration.
|
||||
// Returns standard OIDC claims: sub, email, name, and preferred_username.
|
||||
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
|
||||
}
|
||||
|
||||
// Get optional client_id from request for audience validation
|
||||
clientID := c.Query("client_id")
|
||||
if clientID == "" {
|
||||
clientID = c.PostForm("client_id")
|
||||
}
|
||||
|
||||
// Validate and parse access token with audience validation
|
||||
userContext, err := controller.oidc.ValidateAccessTokenForClient(accessToken, clientID)
|
||||
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)
|
||||
}
|
||||
|
||||
// jwksHandler handles the JSON Web Key Set (JWKS) endpoint.
|
||||
// Returns the public keys used to verify ID tokens and access tokens.
|
||||
// The keys are in JWK format as specified in RFC 7517.
|
||||
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
|
||||
|
||||
// redirectError redirects the user to the redirect URI with an error response.
|
||||
// Includes the error code, error description, and state parameter (if provided).
|
||||
// If the redirect URI is invalid or empty, returns a JSON error response instead.
|
||||
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())
|
||||
}
|
||||
|
||||
// tokenError returns a JSON error response for token endpoint errors.
|
||||
// Uses the standard OAuth 2.0 error format with error and error_description fields.
|
||||
func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, errorDescription string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": errorCode,
|
||||
"error_description": errorDescription,
|
||||
})
|
||||
}
|
||||
|
||||
// getClientCredentials extracts client credentials from the request.
|
||||
// Supports client_secret_basic (HTTP Basic Authentication) and
|
||||
// client_secret_post (POST form parameters) as specified in the discovery document.
|
||||
// Does not accept credentials via query parameters for security reasons
|
||||
// (they may be logged in access logs, browser history, or referrer headers).
|
||||
// Returns the client ID, client secret, and an error if credentials are not found.
|
||||
func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, string, error) {
|
||||
// Try Basic Auth first (client_secret_basic)
|
||||
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 (client_secret_post)
|
||||
clientID := c.PostForm("client_id")
|
||||
clientSecret := c.PostForm("client_secret")
|
||||
if clientID != "" && clientSecret != "" {
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
// Do not accept credentials via query parameters as they are logged
|
||||
// in access logs, browser history, and referrer headers
|
||||
return "", "", fmt.Errorf("client credentials not found")
|
||||
}
|
||||
|
||||
// getAccessToken extracts the access token from the request.
|
||||
// Checks the Authorization header (Bearer token) first, then falls back to
|
||||
// the access_token query parameter.
|
||||
// Returns an empty string if no access token is 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")
|
||||
}
|
||||
|
||||
// validateAccessToken validates an access token and extracts user context.
|
||||
// Verifies the JWT signature using the OIDC service's public key, checks the
|
||||
// issuer, and validates expiration. Returns the user context if valid, or an
|
||||
// error if validation fails.
|
||||
func (controller *OIDCController) validateAccessToken(accessToken string) (*config.UserContext, error) {
|
||||
// Validate the JWT token using the OIDC service's public key
|
||||
// This properly verifies the signature, issuer, and expiration
|
||||
// Note: This method does not validate audience - use ValidateAccessTokenForClient for that
|
||||
return controller.oidc.ValidateAccessToken(accessToken)
|
||||
}
|
||||
@@ -239,6 +239,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
|
||||
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
|
||||
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
|
||||
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub))
|
||||
|
||||
controller.setHeaders(c, acls)
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
Provider: cookie.Provider,
|
||||
OAuthGroups: cookie.OAuthGroups,
|
||||
OAuthName: cookie.OAuthName,
|
||||
OAuthSub: cookie.OAuthSub,
|
||||
IsLoggedIn: true,
|
||||
OAuth: true,
|
||||
})
|
||||
|
||||
15
internal/model/oidc_authorization_code_model.go
Normal file
15
internal/model/oidc_authorization_code_model.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type OIDCAuthorizationCode struct {
|
||||
Code string `gorm:"column:code;primaryKey"`
|
||||
ClientID string `gorm:"column:client_id;not null"`
|
||||
RedirectURI string `gorm:"column:redirect_uri;not null"`
|
||||
Used bool `gorm:"column:used;default:false"`
|
||||
ExpiresAt int64 `gorm:"column:expires_at;not null"`
|
||||
CreatedAt int64 `gorm:"column:created_at;not null"`
|
||||
}
|
||||
|
||||
func (OIDCAuthorizationCode) TableName() string {
|
||||
return "oidc_authorization_codes"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
13
internal/model/oidc_key_model.go
Normal file
13
internal/model/oidc_key_model.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
type OIDCKey struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
PrivateKey string `gorm:"column:private_key;not null"`
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (OIDCKey) TableName() string {
|
||||
return "oidc_keys"
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@ type Session struct {
|
||||
OAuthGroups string `gorm:"column:oauth_groups"`
|
||||
Expiry int64 `gorm:"column:expiry"`
|
||||
OAuthName string `gorm:"column:oauth_name"`
|
||||
OAuthSub string `gorm:"column:oauth_sub"`
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio
|
||||
OAuthGroups: data.OAuthGroups,
|
||||
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
|
||||
OAuthName: data.OAuthName,
|
||||
OAuthSub: data.OAuthSub,
|
||||
}
|
||||
|
||||
err = gorm.G[model.Session](auth.database).Create(c, &session)
|
||||
@@ -314,6 +315,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie,
|
||||
TotpPending: session.TOTPPending,
|
||||
OAuthGroups: session.OAuthGroups,
|
||||
OAuthName: session.OAuthName,
|
||||
OAuthSub: session.OAuthSub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
@@ -27,6 +28,7 @@ type GithubEmailResponse []struct {
|
||||
type GithubUserInfoResponse struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type GithubOAuthService struct {
|
||||
@@ -172,6 +174,7 @@ func (github *GithubOAuthService) Userinfo() (config.Claims, error) {
|
||||
|
||||
user.PreferredUsername = userInfo.Login
|
||||
user.Name = userInfo.Name
|
||||
user.Sub = strconv.Itoa(userInfo.ID)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -17,12 +17,7 @@ import (
|
||||
"golang.org/x/oauth2/endpoints"
|
||||
)
|
||||
|
||||
var GoogleOAuthScopes = []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}
|
||||
|
||||
type GoogleUserInfoResponse struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var GoogleOAuthScopes = []string{"openid", "email", "profile"}
|
||||
|
||||
type GoogleOAuthService struct {
|
||||
config oauth2.Config
|
||||
@@ -91,7 +86,7 @@ func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
|
||||
|
||||
client := google.config.Client(google.context, google.token)
|
||||
|
||||
res, err := client.Get("https://www.googleapis.com/userinfo/v2/me")
|
||||
res, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo")
|
||||
if err != nil {
|
||||
return config.Claims{}, err
|
||||
}
|
||||
@@ -106,16 +101,12 @@ func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
|
||||
return config.Claims{}, err
|
||||
}
|
||||
|
||||
var userInfo GoogleUserInfoResponse
|
||||
|
||||
err = json.Unmarshal(body, &userInfo)
|
||||
err = json.Unmarshal(body, &user)
|
||||
if err != nil {
|
||||
return config.Claims{}, err
|
||||
}
|
||||
|
||||
user.PreferredUsername = strings.Split(userInfo.Email, "@")[0]
|
||||
user.Name = userInfo.Name
|
||||
user.Email = userInfo.Email
|
||||
user.PreferredUsername = strings.SplitN(user.Email, "@", 2)[0]
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
822
internal/service/oidc_service.go
Normal file
822
internal/service/oidc_service.go
Normal file
@@ -0,0 +1,822 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"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"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"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
|
||||
masterKey []byte // Master key for encrypting private keys (optional)
|
||||
}
|
||||
|
||||
func NewOIDCService(config OIDCServiceConfig) *OIDCService {
|
||||
return &OIDCService{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// encryptPrivateKey encrypts a private key PEM string using AES-GCM
|
||||
func (oidc *OIDCService) encryptPrivateKey(plaintext string) (string, error) {
|
||||
if len(oidc.masterKey) == 0 {
|
||||
// No encryption key set, return plaintext
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// Derive AES-256 key from master key using HKDF
|
||||
hkdfReader := hkdf.New(sha256.New, oidc.masterKey, nil, []byte("oidc-aes-256-key-v1"))
|
||||
key := make([]byte, 32) // AES-256 requires 32 bytes
|
||||
if _, err := io.ReadFull(hkdfReader, key); err != nil {
|
||||
return "", fmt.Errorf("failed to derive encryption key: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
// Encode as base64 for storage
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// decryptPrivateKey decrypts an encrypted private key PEM string
|
||||
func (oidc *OIDCService) decryptPrivateKey(encrypted string) (string, error) {
|
||||
if len(oidc.masterKey) == 0 {
|
||||
// No encryption key set, assume plaintext
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// Try to decode as base64 (encrypted) first
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
// Not base64, assume it's plaintext (backward compatibility)
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// Derive AES-256 key from master key using HKDF
|
||||
hkdfReader := hkdf.New(sha256.New, oidc.masterKey, nil, []byte("oidc-aes-256-key-v1"))
|
||||
key := make([]byte, 32) // AES-256 requires 32 bytes
|
||||
if _, err := io.ReadFull(hkdfReader, key); err != nil {
|
||||
return "", fmt.Errorf("failed to derive decryption key: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
// Too short to be encrypted, assume plaintext
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) Init() error {
|
||||
// Load master key from environment (optional)
|
||||
masterKeyEnv := os.Getenv("OIDC_RSA_MASTER_KEY")
|
||||
if masterKeyEnv != "" {
|
||||
oidc.masterKey = []byte(masterKeyEnv)
|
||||
if len(oidc.masterKey) < 32 {
|
||||
log.Warn().Msg("OIDC_RSA_MASTER_KEY is shorter than 32 bytes, consider using a longer key for better security")
|
||||
}
|
||||
log.Info().Msg("RSA private key encryption enabled (using OIDC_RSA_MASTER_KEY)")
|
||||
} else {
|
||||
log.Info().Msg("RSA private key encryption disabled (OIDC_RSA_MASTER_KEY not set)")
|
||||
}
|
||||
// Check if multiple keys exist (for warning)
|
||||
var keyCount int64
|
||||
if err := oidc.config.Database.Model(&model.OIDCKey{}).Count(&keyCount).Error; err != nil {
|
||||
return fmt.Errorf("failed to count RSA keys: %w", err)
|
||||
}
|
||||
if keyCount > 1 {
|
||||
log.Warn().Int64("count", keyCount).Msg("Multiple RSA keys detected in database, loading most recently created key. Consider cleaning up older keys.")
|
||||
}
|
||||
|
||||
// Try to load existing key from database (most recently created)
|
||||
var keyRecord model.OIDCKey
|
||||
err := oidc.config.Database.Order("created_at DESC").First(&keyRecord).Error
|
||||
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("failed to query for existing RSA key: %w", err)
|
||||
}
|
||||
|
||||
var privateKey *rsa.PrivateKey
|
||||
|
||||
if err == nil && keyRecord.PrivateKey != "" {
|
||||
// Decrypt private key if encrypted
|
||||
privateKeyPEM, err := oidc.decryptPrivateKey(keyRecord.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
// Load existing key
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return fmt.Errorf("failed to decode PEM block from stored key")
|
||||
}
|
||||
|
||||
parsedKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
// Try PKCS8 format as fallback
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse stored private key: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
privateKey, ok = key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("stored key is not an RSA private key")
|
||||
}
|
||||
} else {
|
||||
privateKey = parsedKey
|
||||
}
|
||||
|
||||
oidc.privateKey = privateKey
|
||||
oidc.publicKey = &privateKey.PublicKey
|
||||
|
||||
log.Info().Msg("OIDC service initialized with existing RSA key pair from database")
|
||||
return nil
|
||||
}
|
||||
|
||||
// No existing key found, generate new one
|
||||
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
// Encode private key to PEM format
|
||||
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: privateKeyBytes,
|
||||
})
|
||||
|
||||
// Encrypt private key before storing
|
||||
encryptedPrivateKey, err := oidc.encryptPrivateKey(string(privateKeyPEM))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt private key: %w", err)
|
||||
}
|
||||
|
||||
// Save to database
|
||||
now := time.Now().Unix()
|
||||
keyRecord = model.OIDCKey{
|
||||
PrivateKey: encryptedPrivateKey,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := oidc.config.Database.Create(&keyRecord).Error; err != nil {
|
||||
return fmt.Errorf("failed to save RSA key to database: %w", err)
|
||||
}
|
||||
|
||||
oidc.privateKey = privateKey
|
||||
oidc.publicKey = &privateKey.PublicKey
|
||||
|
||||
log.Info().Msg("OIDC service initialized with new RSA key pair (saved to database)")
|
||||
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 {
|
||||
// Use bcrypt for constant-time comparison to prevent timing attacks
|
||||
err := bcrypt.CompareHashAndPassword([]byte(client.ClientSecret), []byte(secret))
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("client_id", client.ClientID).Msg("Client secret verification failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validScopes, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) GenerateAuthorizationCode(userContext *config.UserContext, clientID string, redirectURI string, scopes []string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||
code := uuid.New().String()
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(10 * time.Minute).Unix()
|
||||
|
||||
// Store authorization code in database for replay protection
|
||||
authCodeRecord := model.OIDCAuthorizationCode{
|
||||
Code: code,
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
Used: false,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: now.Unix(),
|
||||
}
|
||||
|
||||
if err := oidc.config.Database.Create(&authCodeRecord).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to store authorization code: %w", err)
|
||||
}
|
||||
|
||||
// Encode as JWT for stateless operation (but code is tracked in DB)
|
||||
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": expiresAt,
|
||||
"iat": now.Unix(),
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
|
||||
// Store PKCE challenge if provided
|
||||
if codeChallenge != "" {
|
||||
claims["code_challenge"] = codeChallenge
|
||||
if codeChallengeMethod != "" {
|
||||
claims["code_challenge_method"] = codeChallengeMethod
|
||||
} else {
|
||||
// Default to plain if method not specified
|
||||
claims["code_challenge_method"] = "plain"
|
||||
}
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
codeToken, err := token.SignedString(oidc.privateKey)
|
||||
if err != nil {
|
||||
// Clean up the database record if JWT signing fails
|
||||
oidc.config.Database.Delete(&authCodeRecord)
|
||||
return "", fmt.Errorf("failed to sign authorization code: %w", err)
|
||||
}
|
||||
|
||||
return codeToken, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidateAuthorizationCode(codeToken string, clientID string, redirectURI string) (*config.UserContext, []string, string, 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")
|
||||
}
|
||||
|
||||
// Extract code from JWT for database lookup
|
||||
code, ok := claims["code"].(string)
|
||||
if !ok || code == "" {
|
||||
return nil, nil, "", "", "", errors.New("missing code in authorization code token")
|
||||
}
|
||||
|
||||
// Check database for replay protection - verify code exists and hasn't been used
|
||||
var authCodeRecord model.OIDCAuthorizationCode
|
||||
err = oidc.config.Database.Where("code = ?", code).First(&authCodeRecord).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, "", "", "", errors.New("authorization code not found")
|
||||
}
|
||||
return nil, nil, "", "", "", fmt.Errorf("failed to query authorization code: %w", err)
|
||||
}
|
||||
|
||||
// Check if code has already been used (replay attack protection)
|
||||
if authCodeRecord.Used {
|
||||
return nil, nil, "", "", "", errors.New("authorization code has already been used")
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().Unix() > authCodeRecord.ExpiresAt {
|
||||
return nil, nil, "", "", "", errors.New("authorization code expired")
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Verify database record matches request parameters
|
||||
if authCodeRecord.ClientID != clientID {
|
||||
return nil, nil, "", "", "", errors.New("client_id mismatch")
|
||||
}
|
||||
|
||||
if authCodeRecord.RedirectURI != redirectURI {
|
||||
return nil, nil, "", "", "", errors.New("redirect_uri mismatch")
|
||||
}
|
||||
|
||||
// Mark code as used to prevent replay attacks
|
||||
authCodeRecord.Used = true
|
||||
if err := oidc.config.Database.Save(&authCodeRecord).Error; err != nil {
|
||||
return nil, nil, "", "", "", fmt.Errorf("failed to mark authorization code as used: %w", err)
|
||||
}
|
||||
|
||||
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")
|
||||
codeChallenge := getStringClaim(claims, "code_challenge")
|
||||
codeChallengeMethod := getStringClaim(claims, "code_challenge_method")
|
||||
|
||||
return userContext, scopes, nonce, codeChallenge, codeChallengeMethod, nil
|
||||
}
|
||||
|
||||
func (oidc *OIDCService) ValidatePKCE(codeChallenge string, codeChallengeMethod string, codeVerifier string) error {
|
||||
if codeChallenge == "" {
|
||||
// PKCE not used, validation passes
|
||||
return nil
|
||||
}
|
||||
|
||||
if codeVerifier == "" {
|
||||
return errors.New("code_verifier required when code_challenge is present")
|
||||
}
|
||||
|
||||
switch codeChallengeMethod {
|
||||
case "S256":
|
||||
// Compute SHA256 hash of code_verifier
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
// Base64URL encode (without padding)
|
||||
computedChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
if computedChallenge != codeChallenge {
|
||||
return errors.New("code_verifier does not match code_challenge")
|
||||
}
|
||||
case "plain":
|
||||
// Direct comparison
|
||||
if codeVerifier != codeChallenge {
|
||||
return errors.New("code_verifier does not match code_challenge")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported code_challenge_method: %s", codeChallengeMethod)
|
||||
}
|
||||
|
||||
return 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) ValidateAccessToken(accessToken string) (*config.UserContext, error) {
|
||||
return oidc.ValidateAccessTokenForClient(accessToken, "")
|
||||
}
|
||||
|
||||
// ValidateAccessTokenForClient validates an access token and optionally checks the audience claim.
|
||||
// If expectedClientID is provided, validates that the token's audience matches the expected client ID.
|
||||
// This prevents tokens issued for one client from being used by another client.
|
||||
func (oidc *OIDCService) ValidateAccessTokenForClient(accessToken string, expectedClientID string) (*config.UserContext, error) {
|
||||
token, err := jwt.Parse(accessToken, 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, fmt.Errorf("failed to parse access token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid access token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
|
||||
// Verify issuer
|
||||
iss, ok := claims["iss"].(string)
|
||||
if !ok || iss != oidc.config.Issuer {
|
||||
return nil, errors.New("invalid issuer")
|
||||
}
|
||||
|
||||
// Verify audience if expected client ID is provided
|
||||
if expectedClientID != "" {
|
||||
aud, ok := claims["aud"].(string)
|
||||
if !ok || aud != expectedClientID {
|
||||
return nil, errors.New("invalid audience")
|
||||
}
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
exp, ok := claims["exp"].(float64)
|
||||
if !ok || time.Now().Unix() > int64(exp) {
|
||||
return nil, errors.New("access token expired")
|
||||
}
|
||||
|
||||
// Extract user info from claims
|
||||
username, ok := claims["sub"].(string)
|
||||
if !ok || username == "" {
|
||||
return nil, errors.New("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
|
||||
}
|
||||
|
||||
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) {
|
||||
// Extract modulus and exponent from public key
|
||||
n := oidc.publicKey.N
|
||||
e := oidc.publicKey.E
|
||||
|
||||
nBytes := n.Bytes()
|
||||
// Use minimal-octet encoding for exponent per RFC 7517
|
||||
eBytes := big.NewInt(int64(e)).Bytes()
|
||||
|
||||
jwk := map[string]interface{}{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"kid": "default",
|
||||
"n": base64.RawURLEncoding.EncodeToString(nBytes),
|
||||
"e": base64.RawURLEncoding.EncodeToString(eBytes),
|
||||
"alg": "RS256",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Hash client secret with bcrypt before storing
|
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to hash client secret")
|
||||
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: string(hashedSecret),
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
14
validation/Dockerfile
Normal file
14
validation/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir requests authlib
|
||||
|
||||
COPY oidc_whoami.py /app/oidc_whoami.py
|
||||
|
||||
RUN chmod +x /app/oidc_whoami.py
|
||||
|
||||
EXPOSE 8765
|
||||
|
||||
CMD ["python3", "/app/oidc_whoami.py"]
|
||||
|
||||
181
validation/README.md
Normal file
181
validation/README.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# OIDC Validation Setup
|
||||
|
||||
This directory contains a docker-compose setup for testing tinyauth's OIDC provider functionality with a minimal test client.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Build the OIDC test client image:**
|
||||
```bash
|
||||
docker build -t oidc-whoami-test:latest .
|
||||
```
|
||||
|
||||
2. **Start the services:**
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
### nginx
|
||||
- **Purpose:** Reverse proxy for `auth.example.com` → tinyauth
|
||||
- **Ports:** 80 (exposed to host)
|
||||
- **Access:** http://auth.example.com/ (via nginx on port 80)
|
||||
|
||||
### dns
|
||||
- **Purpose:** DNS server (dnsmasq) that resolves `auth.example.com` to the tinyauth container
|
||||
- **Configuration:** Resolves `auth.example.com` to the `tinyauth` container IP (172.28.0.20) within the Docker network
|
||||
- **Ports:** 53 (UDP/TCP) - not exposed to host (only for container-to-container communication)
|
||||
|
||||
### tinyauth
|
||||
- **URL:** http://auth.example.com/ (via nginx)
|
||||
- **Credentials:** `user` / `pass`
|
||||
- **OIDC Discovery:** http://auth.example.com/api/.well-known/openid-configuration
|
||||
- **OIDC Client ID:** `testclient`
|
||||
- **OIDC Client Secret:** `test-secret-123`
|
||||
- **Ports:** Not exposed to host (accessed via nginx on port 80)
|
||||
|
||||
### oidc-whoami
|
||||
- **Callback URL:** http://localhost:8765/callback
|
||||
- **Purpose:** Minimal OIDC test client that validates the OIDC flow
|
||||
- **Ports:** 8765 (exposed to host)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start all services:**
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
2. **Launch Chrome with host-resolver-rules:**
|
||||
```bash
|
||||
./launch-chrome-host.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
google-chrome \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1" \
|
||||
--disable-features=HttpsOnlyMode \
|
||||
--unsafely-treat-insecure-origin-as-secure=http://auth.example.com \
|
||||
--user-data-dir=/tmp/chrome-test-profile \
|
||||
http://auth.example.com/
|
||||
```
|
||||
|
||||
**Note:** The `--user-data-dir` flag uses a temporary profile to avoid HSTS (HTTP Strict Transport Security) issues that might force HTTPS redirects.
|
||||
|
||||
3. **Access tinyauth:** http://auth.example.com/
|
||||
- Login with: `user` / `pass`
|
||||
|
||||
4. **Test OIDC flow:**
|
||||
```bash
|
||||
# Get authorization URL from oidc-whoami logs
|
||||
docker compose logs oidc-whoami | grep "Authorization URL"
|
||||
# Open that URL in Chrome (already configured with host-resolver-rules)
|
||||
```
|
||||
|
||||
## Connecting from Chrome/Browser
|
||||
|
||||
Since the DNS server is only accessible within the Docker network, you have several options to access `auth.example.com` from your browser:
|
||||
|
||||
### Option 1: Use /etc/hosts (Simplest)
|
||||
|
||||
Add this line to your `/etc/hosts` file (or `C:\Windows\System32\drivers\etc\hosts` on Windows):
|
||||
|
||||
```
|
||||
127.0.0.1 auth.example.com
|
||||
```
|
||||
|
||||
Then access: http://auth.example.com/
|
||||
|
||||
**To edit /etc/hosts on Linux/Mac:**
|
||||
```bash
|
||||
sudo nano /etc/hosts
|
||||
# Add: 127.0.0.1 auth.example.com
|
||||
```
|
||||
|
||||
**To edit hosts on Windows:**
|
||||
1. Open Notepad as Administrator
|
||||
2. Open `C:\Windows\System32\drivers\etc\hosts`
|
||||
3. Add: `127.0.0.1 auth.example.com`
|
||||
|
||||
### Option 2: Use Chrome's `--host-resolver-rules` (Chrome-specific, No System Changes)
|
||||
|
||||
Chrome has a command-line flag that lets you map hostnames directly, bypassing DNS entirely. This is perfect for testing without modifying system settings.
|
||||
|
||||
**To use it:**
|
||||
|
||||
1. **Make sure services are running:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **Launch Chrome with the host resolver rule:**
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
**Mac:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
"C:\Program Files\Google\ Chrome\Application\chrome.exe" --host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
3. **Or modify Chrome's shortcut:**
|
||||
- Right-click Chrome shortcut → Properties
|
||||
- In "Target" field, append: ` --host-resolver-rules="MAP auth.example.com 127.0.0.1"`
|
||||
- Click OK
|
||||
|
||||
4. **Access:** http://auth.example.com/
|
||||
|
||||
**Note:** This only affects Chrome, not other applications. The DNS server on port 5353 isn't needed for this approach.
|
||||
|
||||
### Option 3: Use System DNS (All Applications)
|
||||
|
||||
If you want to use the DNS server on port 5353 for all applications (not just Chrome), configure your system DNS:
|
||||
|
||||
**Linux (with systemd-resolved):**
|
||||
```bash
|
||||
# Configure systemd-resolved to use our DNS
|
||||
sudo resolvectl dns lo 127.0.0.1:5353
|
||||
```
|
||||
|
||||
**Linux (without systemd-resolved):**
|
||||
```bash
|
||||
# Edit /etc/resolv.conf
|
||||
sudo nano /etc/resolv.conf
|
||||
# Add: nameserver 127.0.0.1
|
||||
# Note: This won't work with port 5353, you'd need port 53
|
||||
```
|
||||
|
||||
**Note:** Most systems expect DNS on port 53. To use port 5353, you'd need a DNS proxy or configure Chrome specifically (see Option 2 above).
|
||||
|
||||
## Testing
|
||||
|
||||
1. Start the services with `docker compose up --build -d`
|
||||
2. Launch Chrome: `./launch-chrome-host.sh` (or use `--host-resolver-rules` manually)
|
||||
3. Navigate to: http://auth.example.com/
|
||||
4. Login with `user` / `pass`
|
||||
5. Test the OIDC flow by accessing the discovery endpoint: http://auth.example.com/api/.well-known/openid-configuration
|
||||
|
||||
## Configuration
|
||||
|
||||
The tinyauth configuration is in `config.yaml`:
|
||||
- OIDC is enabled
|
||||
- Single user: `user` with password `pass`
|
||||
- OIDC client `testclient` is configured with redirect URI `http://localhost:8765/callback`
|
||||
- App URL and OIDC issuer: `http://auth.example.com` (via nginx on port 80)
|
||||
|
||||
## Notes
|
||||
|
||||
- All containers are on a custom Docker network (`tinyauth-network`) with a DNS server for domain resolution
|
||||
- The DNS server resolves `auth.example.com` to the tinyauth container within the network
|
||||
- The redirect URI must match exactly what's configured in tinyauth
|
||||
- Data is persisted in the `./data` directory
|
||||
- The domain `auth.example.com` is used to satisfy cookie domain validation requirements (needs at least 3 domain parts and not in public suffix list)
|
||||
36
validation/config.yaml
Normal file
36
validation/config.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
appUrl: "http://auth.example.com"
|
||||
logLevel: "info"
|
||||
databasePath: "/data/tinyauth.db"
|
||||
|
||||
auth:
|
||||
users: "user:$2b$12$mWEdxub8KTTBLK/f7dloKOS4t3kIeLOpme5pMXci5.lXNPANjCT5u" # user:pass
|
||||
secureCookie: false
|
||||
sessionExpiry: 3600
|
||||
loginTimeout: 300
|
||||
loginMaxRetries: 3
|
||||
|
||||
oidc:
|
||||
enabled: true
|
||||
issuer: "http://auth.example.com"
|
||||
accessTokenExpiry: 3600
|
||||
idTokenExpiry: 3600
|
||||
clients:
|
||||
testclient:
|
||||
clientSecret: "test-secret-123"
|
||||
clientName: "OIDC Test Client"
|
||||
redirectUris:
|
||||
- "http://client.example.com/callback"
|
||||
- "http://localhost:8765/callback"
|
||||
- "http://127.0.0.1:8765/callback"
|
||||
grantTypes:
|
||||
- "authorization_code"
|
||||
responseTypes:
|
||||
- "code"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
|
||||
ui:
|
||||
title: "Tinyauth OIDC Test"
|
||||
|
||||
91
validation/docker-compose.yml
Normal file
91
validation/docker-compose.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
dns:
|
||||
container_name: dns-server
|
||||
image: strm/dnsmasq:latest
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
command:
|
||||
- "--no-daemon"
|
||||
- "--log-queries"
|
||||
- "--no-resolv"
|
||||
- "--server=8.8.8.8"
|
||||
- "--server=8.8.4.4"
|
||||
- "--address=/auth.example.com/172.28.0.2"
|
||||
- "--address=/client.example.com/172.28.0.2"
|
||||
# DNS port not exposed to host - only needed for container-to-container communication
|
||||
# Chrome uses --host-resolver-rules instead
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.10
|
||||
|
||||
nginx:
|
||||
container_name: nginx-proxy
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- tinyauth-network
|
||||
# Use Docker's built-in DNS (127.0.0.11) for service name resolution
|
||||
# Our custom DNS (172.28.0.10) is only used via resolver directive in nginx.conf
|
||||
depends_on:
|
||||
- tinyauth
|
||||
- dns
|
||||
- oidc-whoami
|
||||
|
||||
|
||||
tinyauth:
|
||||
container_name: tinyauth-oidc-test
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
command: ["--experimental.configfile=/config/config.yaml"]
|
||||
# Port not exposed to host - accessed via nginx
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config.yaml:/config/config.yaml:ro
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.20
|
||||
depends_on:
|
||||
- dns
|
||||
healthcheck:
|
||||
test: ["CMD", "tinyauth", "healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
oidc-whoami:
|
||||
container_name: oidc-whoami-test
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- OIDC_ISSUER=http://auth.example.com
|
||||
- CLIENT_ID=testclient
|
||||
- CLIENT_SECRET=test-secret-123
|
||||
# Port not exposed to host - accessed via nginx
|
||||
depends_on:
|
||||
- tinyauth
|
||||
- dns
|
||||
# Use Docker's built-in DNS first, then our custom DNS for custom domains
|
||||
dns:
|
||||
- 127.0.0.11
|
||||
- 172.28.0.10
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.30
|
||||
# Note: Using custom network with DNS server to resolve auth.example.test
|
||||
# The redirect URI must match what's configured in tinyauth (http://localhost:8765/callback)
|
||||
# Using auth.example.test domain to satisfy cookie domain validation requirements (needs 3+ parts, not in public suffix list)
|
||||
|
||||
networks:
|
||||
tinyauth-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
|
||||
39
validation/launch-chrome-host.sh
Executable file
39
validation/launch-chrome-host.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Launch Chrome from host (not in container)
|
||||
# This script should be run on your host machine
|
||||
|
||||
set -e
|
||||
|
||||
echo "Launching Chrome for OIDC test setup..."
|
||||
|
||||
# Detect Chrome
|
||||
if command -v google-chrome &> /dev/null; then
|
||||
CHROME_CMD="google-chrome"
|
||||
elif command -v chromium-browser &> /dev/null; then
|
||||
CHROME_CMD="chromium-browser"
|
||||
elif command -v chromium &> /dev/null; then
|
||||
CHROME_CMD="chromium"
|
||||
elif [ -f "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then
|
||||
CHROME_CMD="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
else
|
||||
echo "Error: Chrome not found. Please install Google Chrome or Chromium."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using: $CHROME_CMD"
|
||||
echo "Opening: http://client.example.com/ (OIDC test client)"
|
||||
echo ""
|
||||
|
||||
$CHROME_CMD \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1, MAP client.example.com 127.0.0.1" \
|
||||
--disable-features=HttpsOnlyMode \
|
||||
--unsafely-treat-insecure-origin-as-secure=http://auth.example.com,http://client.example.com \
|
||||
--user-data-dir=/tmp/chrome-test-profile-$(date +%s) \
|
||||
--new-window \
|
||||
http://client.example.com/ \
|
||||
> /dev/null 2>&1 &
|
||||
|
||||
echo "Chrome launched!"
|
||||
echo "OIDC test client: http://client.example.com/"
|
||||
echo "Tinyauth: http://auth.example.com/"
|
||||
|
||||
68
validation/launch-chrome.sh
Executable file
68
validation/launch-chrome.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Chrome Launcher for OIDC Test Setup"
|
||||
echo "=========================================="
|
||||
|
||||
# Wait for nginx to be ready
|
||||
echo "Waiting for nginx to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://127.0.0.1/ > /dev/null 2>&1; then
|
||||
echo "✓ Nginx is ready"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "✗ Nginx not ready after 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Try to find Chrome on the host system
|
||||
# Since we're in a container, we need to check common locations
|
||||
CHROME_PATHS=(
|
||||
"/usr/bin/google-chrome"
|
||||
"/usr/bin/google-chrome-stable"
|
||||
"/usr/bin/chromium-browser"
|
||||
"/usr/bin/chromium"
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
)
|
||||
|
||||
CHROME_CMD=""
|
||||
for path in "${CHROME_PATHS[@]}"; do
|
||||
if [ -f "$path" ] || command -v "$(basename "$path")" &> /dev/null; then
|
||||
CHROME_CMD="$(basename "$path")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$CHROME_CMD" ]; then
|
||||
echo ""
|
||||
echo "Chrome not found in container. This is expected."
|
||||
echo "Please launch Chrome manually on your host with:"
|
||||
echo ""
|
||||
echo ' google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1" http://auth.example.com/'
|
||||
echo ""
|
||||
echo "Or use the launch script on your host:"
|
||||
echo " ./launch-chrome.sh"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found Chrome: $CHROME_CMD"
|
||||
echo "Launching Chrome with host-resolver-rules..."
|
||||
echo ""
|
||||
|
||||
$CHROME_CMD \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1" \
|
||||
--new-window \
|
||||
http://auth.example.com/ \
|
||||
> /dev/null 2>&1 &
|
||||
|
||||
echo "✓ Chrome launched!"
|
||||
echo ""
|
||||
echo "Access tinyauth at: http://auth.example.com/"
|
||||
echo "OIDC test client callback: http://127.0.0.1:8765/callback"
|
||||
echo ""
|
||||
|
||||
43
validation/nginx.conf
Normal file
43
validation/nginx.conf
Normal file
@@ -0,0 +1,43 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Use Docker's built-in DNS (127.0.0.11) for service name resolution
|
||||
# This allows nginx to resolve Docker service names like "tinyauth" and "oidc-whoami"
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name auth.example.com;
|
||||
|
||||
location / {
|
||||
# Use variable to enable dynamic resolution at request time
|
||||
set $backend "tinyauth:3000";
|
||||
proxy_pass http://$backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name client.example.com;
|
||||
|
||||
location / {
|
||||
# Use variable to enable dynamic resolution at request time
|
||||
set $backend "oidc-whoami:8765";
|
||||
proxy_pass http://$backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
298
validation/oidc_whoami.py
Normal file
298
validation/oidc_whoami.py
Normal file
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import html
|
||||
import webbrowser
|
||||
import secrets
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
import requests
|
||||
from authlib.integrations.requests_client import OAuth2Session
|
||||
from authlib.oidc.core import CodeIDToken
|
||||
from authlib.jose import jwt
|
||||
|
||||
# ---- config via env ----
|
||||
ISSUER = os.environ["OIDC_ISSUER"]
|
||||
CLIENT_ID = os.environ["CLIENT_ID"]
|
||||
CLIENT_SECRET= os.environ.get("CLIENT_SECRET") # optional (public clients ok)
|
||||
REDIRECT_URI = "http://client.example.com/callback"
|
||||
SCOPE = "openid profile email"
|
||||
|
||||
# ---- discovery ----
|
||||
# Retry discovery in case nginx isn't ready yet
|
||||
discovery = None
|
||||
for attempt in range(10):
|
||||
try:
|
||||
discovery = requests.get(
|
||||
f"{ISSUER.rstrip('/')}/api/.well-known/openid-configuration",
|
||||
timeout=5
|
||||
).json()
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < 9:
|
||||
print(f"Discovery attempt {attempt + 1} failed: {e}, retrying...")
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise
|
||||
|
||||
if discovery is None:
|
||||
raise RuntimeError("Failed to fetch OIDC discovery document after 10 attempts")
|
||||
|
||||
state = secrets.token_urlsafe(16)
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
|
||||
client = OAuth2Session(
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
scope=SCOPE,
|
||||
redirect_uri=REDIRECT_URI,
|
||||
)
|
||||
|
||||
auth_result = client.create_authorization_url(
|
||||
discovery["authorization_endpoint"],
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
auth_url = auth_result[0]
|
||||
code_verifier = auth_result[1] if len(auth_result) > 1 else None
|
||||
|
||||
# Cache JWKS for token validation
|
||||
jwk_set_cache = None
|
||||
jwk_set_cache_time = None
|
||||
|
||||
def get_jwk_set():
|
||||
"""Get JWKS with caching"""
|
||||
global jwk_set_cache, jwk_set_cache_time
|
||||
# Cache for 1 hour
|
||||
if jwk_set_cache is None or (jwk_set_cache_time and time.time() - jwk_set_cache_time > 3600):
|
||||
jwk_set_cache = requests.get(discovery["jwks_uri"]).json()
|
||||
jwk_set_cache_time = time.time()
|
||||
return jwk_set_cache
|
||||
|
||||
def parse_cookies(cookie_header):
|
||||
"""Parse cookies from Cookie header"""
|
||||
if not cookie_header:
|
||||
return {}
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(cookie_header)
|
||||
return {k: v.value for k, v in cookie.items()}
|
||||
|
||||
def validate_id_token(id_token):
|
||||
"""Validate and decode ID token"""
|
||||
try:
|
||||
jwk_set = get_jwk_set()
|
||||
claims_options = {
|
||||
"iss": {"essential": True, "value": discovery["issuer"]},
|
||||
"aud": {"essential": True, "value": CLIENT_ID},
|
||||
}
|
||||
decoded = jwt.decode(
|
||||
id_token,
|
||||
key=jwk_set,
|
||||
claims_options=claims_options
|
||||
)
|
||||
decoded.validate()
|
||||
return dict(decoded)
|
||||
except Exception as e:
|
||||
print(f"Token validation failed: {e}")
|
||||
return None
|
||||
|
||||
# ---- tiny callback server ----
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
# Handle root path - check if already logged in
|
||||
if self.path == "/" or self.path == "":
|
||||
cookies = parse_cookies(self.headers.get("Cookie"))
|
||||
id_token = cookies.get("id_token")
|
||||
|
||||
# Check if we have a valid token
|
||||
if id_token:
|
||||
claims = validate_id_token(id_token)
|
||||
if claims and claims.get("exp", 0) > time.time():
|
||||
# Already logged in - show main page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OIDC Test Client - Welcome</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}}
|
||||
.main-box {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #4285f4;
|
||||
margin-top: 0;
|
||||
}}
|
||||
.user-info {{
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
border-left: 4px solid #4285f4;
|
||||
}}
|
||||
pre {{
|
||||
background: #f9f9f9;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #ddd;
|
||||
}}
|
||||
.logout-btn {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-box">
|
||||
<h1>✅ Welcome back!</h1>
|
||||
<div class="user-info">
|
||||
<h2>User Information</h2>
|
||||
<p><strong>Username:</strong> {html.escape(str(claims.get('preferred_username', claims.get('sub', 'N/A'))))}</p>
|
||||
<p><strong>Name:</strong> {html.escape(str(claims.get('name', 'N/A')))}</p>
|
||||
<p><strong>Email:</strong> {html.escape(str(claims.get('email', 'N/A')))}</p>
|
||||
</div>
|
||||
<hr>
|
||||
<h2>ID Token Claims:</h2>
|
||||
<pre>{html.escape(json.dumps(claims, indent=2))}</pre>
|
||||
<a href="/logout" class="logout-btn">Logout</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.wfile.write(html_content.encode())
|
||||
return
|
||||
|
||||
# Not logged in - show login page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>OIDC Test Client</title></head>
|
||||
<body>
|
||||
<h1>OIDC Test Client</h1>
|
||||
<p>Click the button below to start the OIDC flow:</p>
|
||||
<a href="{auth_url}" style="display: inline-block; padding: 10px 20px; background: #4285f4; color: white; text-decoration: none; border-radius: 4px;">Login with OIDC</a>
|
||||
<hr>
|
||||
<p><small>Authorization URL: <code>{auth_url}</code></small></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.wfile.write(html_content.encode())
|
||||
return
|
||||
|
||||
# Handle logout
|
||||
if self.path == "/logout":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/")
|
||||
self.send_header("Set-Cookie", "id_token=; Path=/; Max-Age=0")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# Handle callback
|
||||
if not self.path.startswith("/callback"):
|
||||
self.send_error(404, "Not Found")
|
||||
return
|
||||
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
|
||||
if qs.get("state", [None])[0] != state:
|
||||
self.send_error(400, "Invalid state")
|
||||
return
|
||||
|
||||
code = qs.get("code", [None])[0]
|
||||
if not code:
|
||||
self.send_error(400, "Missing code")
|
||||
return
|
||||
|
||||
token = client.fetch_token(
|
||||
discovery["token_endpoint"],
|
||||
code=code,
|
||||
code_verifier=code_verifier,
|
||||
)
|
||||
|
||||
# ---- ID token validation ----
|
||||
# Decode and validate the ID token using cached JWKS
|
||||
jwk_set = get_jwk_set()
|
||||
|
||||
# Decode the JWT - make nonce optional if not provided
|
||||
claims_options = {
|
||||
"iss": {"essential": True, "value": discovery["issuer"]},
|
||||
"aud": {"essential": True, "value": CLIENT_ID},
|
||||
}
|
||||
if nonce:
|
||||
claims_options["nonce"] = {"essential": True, "value": nonce}
|
||||
|
||||
decoded = jwt.decode(
|
||||
token["id_token"],
|
||||
key=jwk_set,
|
||||
claims_options=claims_options
|
||||
)
|
||||
decoded.validate()
|
||||
|
||||
# Convert JWTClaims to dict for display
|
||||
id_token_claims = dict(decoded)
|
||||
|
||||
# Store ID token in cookie (expires when token expires)
|
||||
token_expiry = id_token_claims.get("exp", 0) - time.time()
|
||||
max_age = max(0, int(token_expiry))
|
||||
|
||||
# Redirect to main page with cookie set
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/")
|
||||
self.send_header("Set-Cookie", f"id_token={token['id_token']}; Path=/; Max-Age={max_age}; HttpOnly")
|
||||
self.end_headers()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ OIDC Authentication Successful!")
|
||||
print("=" * 60)
|
||||
print("\nID Token Claims:")
|
||||
print(json.dumps(id_token_claims, indent=2))
|
||||
print("\n" + "=" * 60)
|
||||
# Don't exit - keep server running for multiple test flows
|
||||
|
||||
# ---- run ----
|
||||
print("=" * 60)
|
||||
print("OIDC Test Client")
|
||||
print("=" * 60)
|
||||
print(f"\nAuthorization URL: {auth_url}")
|
||||
print("\nTo test the OIDC flow:")
|
||||
print("1. Open the authorization URL above in your browser")
|
||||
print("2. Login with credentials: user / pass")
|
||||
print("3. You will be redirected back to the callback")
|
||||
print("4. The ID token claims will be displayed below")
|
||||
print(f"\nWaiting for callback on {REDIRECT_URI}...")
|
||||
print("=" * 60)
|
||||
|
||||
# Try to open browser (may fail in Docker, that's OK)
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
except Exception as e:
|
||||
print(f"Could not open browser automatically: {e}")
|
||||
print("Please open the authorization URL manually")
|
||||
|
||||
HTTPServer(("0.0.0.0", 8765), CallbackHandler).serve_forever()
|
||||
Reference in New Issue
Block a user