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