mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-12-31 04:22:28 +00:00
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
490 lines
17 KiB
Go
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)
|
|
}
|