Skip to content

Commit 014550f

Browse files
committed
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
1 parent 5ec9989 commit 014550f

2 files changed

Lines changed: 84 additions & 10 deletions

File tree

internal/controller/oidc_controller.go

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,23 @@ import (
1515
"github.com/rs/zerolog/log"
1616
)
1717

18+
// OIDCControllerConfig holds configuration for the OIDC controller.
1819
type OIDCControllerConfig struct {
19-
AppURL string
20-
CookieDomain string
20+
AppURL string // Base URL of the application
21+
CookieDomain string // Domain for setting cookies
2122
}
2223

24+
// OIDCController handles OpenID Connect (OIDC) protocol endpoints.
25+
// It implements the OIDC provider functionality including discovery, authorization,
26+
// token exchange, userinfo, and JWKS endpoints.
2327
type OIDCController struct {
2428
config OIDCControllerConfig
2529
router *gin.RouterGroup
2630
oidc *service.OIDCService
2731
auth *service.AuthService
2832
}
2933

34+
// NewOIDCController creates a new OIDC controller with the given configuration and services.
3035
func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oidc *service.OIDCService, auth *service.AuthService) *OIDCController {
3136
return &OIDCController{
3237
config: config,
@@ -36,6 +41,13 @@ func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, oid
3641
}
3742
}
3843

44+
// SetupRoutes registers all OIDC endpoints with the router.
45+
// This includes:
46+
// - /.well-known/openid-configuration - OIDC discovery endpoint
47+
// - /oidc/authorize - Authorization endpoint
48+
// - /oidc/token - Token endpoint
49+
// - /oidc/userinfo - UserInfo endpoint
50+
// - /oidc/jwks - JSON Web Key Set endpoint
3951
func (controller *OIDCController) SetupRoutes() {
4052
// Well-known discovery endpoint
4153
controller.router.GET("/.well-known/openid-configuration", controller.discoveryHandler)
@@ -48,6 +60,10 @@ func (controller *OIDCController) SetupRoutes() {
4860
oidcGroup.GET("/jwks", controller.jwksHandler)
4961
}
5062

63+
// discoveryHandler handles the OIDC discovery endpoint.
64+
// Returns the OpenID Connect discovery document as specified in RFC 8414.
65+
// The document contains metadata about the OIDC provider including endpoints,
66+
// supported features, and cryptographic capabilities.
5167
func (controller *OIDCController) discoveryHandler(c *gin.Context) {
5268
issuer := controller.oidc.GetIssuer()
5369
baseURL := strings.TrimSuffix(controller.config.AppURL, "/")
@@ -70,6 +86,14 @@ func (controller *OIDCController) discoveryHandler(c *gin.Context) {
7086
c.JSON(http.StatusOK, discovery)
7187
}
7288

89+
// authorizeHandler handles the OIDC authorization endpoint.
90+
// Implements the authorization code flow as specified in OAuth 2.0 RFC 6749.
91+
// Validates client credentials, redirect URI, scopes, and response type.
92+
// Supports PKCE (RFC 7636) for enhanced security.
93+
// If the user is not authenticated, redirects to the login page with the
94+
// authorization request parameters preserved for redirect after login.
95+
// On success, generates an authorization code and redirects to the client's
96+
// redirect URI with the code and state parameter.
7397
func (controller *OIDCController) authorizeHandler(c *gin.Context) {
7498
// Get query parameters
7599
clientID := c.Query("client_id")
@@ -179,6 +203,11 @@ func (controller *OIDCController) authorizeHandler(c *gin.Context) {
179203
c.Redirect(http.StatusFound, redirectURL.String())
180204
}
181205

206+
// tokenHandler handles the OIDC token endpoint.
207+
// Exchanges an authorization code for access and ID tokens.
208+
// Validates the authorization code, client credentials, redirect URI, and PKCE verifier.
209+
// Returns an access token and optionally an ID token (if openid scope is present).
210+
// Implements the authorization code grant type as specified in OAuth 2.0 RFC 6749.
182211
func (controller *OIDCController) tokenHandler(c *gin.Context) {
183212
// Get grant type
184213
grantType := c.PostForm("grant_type")
@@ -299,6 +328,10 @@ func (controller *OIDCController) tokenHandler(c *gin.Context) {
299328
c.JSON(http.StatusOK, response)
300329
}
301330

331+
// userinfoHandler handles the OIDC UserInfo endpoint.
332+
// Returns user information claims for the authenticated user based on the
333+
// provided access token. Validates the access token signature, issuer, and expiration.
334+
// Returns standard OIDC claims: sub, email, name, and preferred_username.
302335
func (controller *OIDCController) userinfoHandler(c *gin.Context) {
303336
// Get access token from Authorization header or query parameter
304337
accessToken := controller.getAccessToken(c)
@@ -310,8 +343,14 @@ func (controller *OIDCController) userinfoHandler(c *gin.Context) {
310343
return
311344
}
312345

313-
// Validate and parse access token
314-
userContext, err := controller.validateAccessToken(accessToken)
346+
// Get optional client_id from request for audience validation
347+
clientID := c.Query("client_id")
348+
if clientID == "" {
349+
clientID = c.PostForm("client_id")
350+
}
351+
352+
// Validate and parse access token with audience validation
353+
userContext, err := controller.oidc.ValidateAccessTokenForClient(accessToken, clientID)
315354
if err != nil {
316355
log.Error().Err(err).Msg("Failed to validate access token")
317356
c.JSON(http.StatusUnauthorized, gin.H{
@@ -332,6 +371,9 @@ func (controller *OIDCController) userinfoHandler(c *gin.Context) {
332371
c.JSON(http.StatusOK, userInfo)
333372
}
334373

374+
// jwksHandler handles the JSON Web Key Set (JWKS) endpoint.
375+
// Returns the public keys used to verify ID tokens and access tokens.
376+
// The keys are in JWK format as specified in RFC 7517.
335377
func (controller *OIDCController) jwksHandler(c *gin.Context) {
336378
jwks, err := controller.oidc.GetJWKS()
337379
if err != nil {
@@ -347,6 +389,9 @@ func (controller *OIDCController) jwksHandler(c *gin.Context) {
347389

348390
// Helper functions
349391

392+
// redirectError redirects the user to the redirect URI with an error response.
393+
// Includes the error code, error description, and state parameter (if provided).
394+
// If the redirect URI is invalid or empty, returns a JSON error response instead.
350395
func (controller *OIDCController) redirectError(c *gin.Context, redirectURI string, state string, errorCode string, errorDescription string) {
351396
if redirectURI == "" {
352397
c.JSON(http.StatusBadRequest, gin.H{
@@ -376,13 +421,21 @@ func (controller *OIDCController) redirectError(c *gin.Context, redirectURI stri
376421
c.Redirect(http.StatusFound, redirectURL.String())
377422
}
378423

424+
// tokenError returns a JSON error response for token endpoint errors.
425+
// Uses the standard OAuth 2.0 error format with error and error_description fields.
379426
func (controller *OIDCController) tokenError(c *gin.Context, errorCode string, errorDescription string) {
380427
c.JSON(http.StatusBadRequest, gin.H{
381428
"error": errorCode,
382429
"error_description": errorDescription,
383430
})
384431
}
385432

433+
// getClientCredentials extracts client credentials from the request.
434+
// Supports client_secret_basic (HTTP Basic Authentication) and
435+
// client_secret_post (POST form parameters) as specified in the discovery document.
436+
// Does not accept credentials via query parameters for security reasons
437+
// (they may be logged in access logs, browser history, or referrer headers).
438+
// Returns the client ID, client secret, and an error if credentials are not found.
386439
func (controller *OIDCController) getClientCredentials(c *gin.Context) (string, string, error) {
387440
// Try Basic Auth first (client_secret_basic)
388441
authHeader := c.GetHeader("Authorization")
@@ -409,6 +462,10 @@ func (controller *OIDCController) getClientCredentials(c *gin.Context) (string,
409462
return "", "", fmt.Errorf("client credentials not found")
410463
}
411464

465+
// getAccessToken extracts the access token from the request.
466+
// Checks the Authorization header (Bearer token) first, then falls back to
467+
// the access_token query parameter.
468+
// Returns an empty string if no access token is found.
412469
func (controller *OIDCController) getAccessToken(c *gin.Context) string {
413470
// Try Authorization header
414471
authHeader := c.GetHeader("Authorization")
@@ -420,9 +477,13 @@ func (controller *OIDCController) getAccessToken(c *gin.Context) string {
420477
return c.Query("access_token")
421478
}
422479

480+
// validateAccessToken validates an access token and extracts user context.
481+
// Verifies the JWT signature using the OIDC service's public key, checks the
482+
// issuer, and validates expiration. Returns the user context if valid, or an
483+
// error if validation fails.
423484
func (controller *OIDCController) validateAccessToken(accessToken string) (*config.UserContext, error) {
424485
// Validate the JWT token using the OIDC service's public key
425486
// This properly verifies the signature, issuer, and expiration
487+
// Note: This method does not validate audience - use ValidateAccessTokenForClient for that
426488
return controller.oidc.ValidateAccessToken(accessToken)
427489
}
428-

internal/service/oidc_service.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"errors"
1414
"fmt"
1515
"io"
16+
"math/big"
1617
"os"
1718
"strings"
1819
"time"
@@ -538,6 +539,13 @@ func (oidc *OIDCService) GenerateAccessToken(userContext *config.UserContext, cl
538539
}
539540

540541
func (oidc *OIDCService) ValidateAccessToken(accessToken string) (*config.UserContext, error) {
542+
return oidc.ValidateAccessTokenForClient(accessToken, "")
543+
}
544+
545+
// ValidateAccessTokenForClient validates an access token and optionally checks the audience claim.
546+
// If expectedClientID is provided, validates that the token's audience matches the expected client ID.
547+
// This prevents tokens issued for one client from being used by another client.
548+
func (oidc *OIDCService) ValidateAccessTokenForClient(accessToken string, expectedClientID string) (*config.UserContext, error) {
541549
token, err := jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) {
542550
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
543551
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
@@ -564,6 +572,14 @@ func (oidc *OIDCService) ValidateAccessToken(accessToken string) (*config.UserCo
564572
return nil, errors.New("invalid issuer")
565573
}
566574

575+
// Verify audience if expected client ID is provided
576+
if expectedClientID != "" {
577+
aud, ok := claims["aud"].(string)
578+
if !ok || aud != expectedClientID {
579+
return nil, errors.New("invalid audience")
580+
}
581+
}
582+
567583
// Check expiration
568584
exp, ok := claims["exp"].(float64)
569585
if !ok || time.Now().Unix() > int64(exp) {
@@ -629,11 +645,8 @@ func (oidc *OIDCService) GetJWKS() (map[string]interface{}, error) {
629645
e := oidc.publicKey.E
630646

631647
nBytes := n.Bytes()
632-
eBytes := make([]byte, 4)
633-
eBytes[0] = byte(e >> 24)
634-
eBytes[1] = byte(e >> 16)
635-
eBytes[2] = byte(e >> 8)
636-
eBytes[3] = byte(e)
648+
// Use minimal-octet encoding for exponent per RFC 7517
649+
eBytes := big.NewInt(int64(e)).Bytes()
637650

638651
jwk := map[string]interface{}{
639652
"kty": "RSA",

0 commit comments

Comments
 (0)