Files
tinyauth/internal/controller/oidc_controller.go
Olivier Dumont 014550f80e CRITICAL: Add audience validation for access tokens
Access tokens include an 'aud' (audience) claim set to the client ID,
but this was never validated during token validation. This allowed
tokens issued for one client to be used by another client, violating
the OAuth 2.0 security model.

Changes:
- Add ValidateAccessTokenForClient method that validates audience
  if expectedClientID is provided
- Update ValidateAccessToken to call ValidateAccessTokenForClient
  (backward compatible, no audience check if not specified)
- Update userinfo endpoint to accept optional client_id parameter
  and validate token audience matches it

Security impact:
- Prevents token reuse across different clients
- Ensures tokens are scoped to specific clients as intended
- Prevents attackers from using tokens issued for one client to
  access resources protected by another client
2025-12-30 14:10:50 +01:00

490 lines
17 KiB
Go

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