Skip to content

Commit 49c85f4

Browse files
committed
fix(apigw): harden SAML/OIDC integration security and error handling
- Session leak: add defer-based cleanup on error paths in both OIDC and SAML callback handlers so sessions are always deleted if document storage or credential creation fails - SAML assertion time validation: check NotBefore/NotOnOrAfter per SAML 2.0 §2.5.1; reject expired or future-dated assertions - SAML nil guards: validate IdP metadata has SSO descriptors, and assertion has Subject/NameID/Conditions before accessing them - Dynamic registration: limit response body to 1MB via io.LimitReader to prevent OOM from malicious registration endpoints - OIDC IdP errors: check for error/error_description query params in callback (RFC 6749 §4.1.2.1) instead of returning generic error - Cookie cleanup: delete auth_method and redirect URL cookies after successful OIDC/SAML callbacks and in user cancel endpoint - State leak prevention: log OIDC state at Debug (not Info) level and remove state values from error messages returned to callers - SAML standalone: return explicit error when JWK is nil instead of passing nil to credential creation - Multi-valued attrs: log warning when SAML attributes have multiple values (only first is used) - Doc retrieval: replace non-deterministic map iteration in UserLookup with firstDocument() helper that validates document is non-nil - Scope validation: validate scope maps to a credential constructor before initiating SAML/OIDC auth from consent endpoint
1 parent 6131823 commit 49c85f4

File tree

9 files changed

+113
-47
lines changed

9 files changed

+113
-47
lines changed

internal/apigw/apiv1/handlers_oidcrp.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,21 @@ func (c *Client) OIDCRPCallback(ctx context.Context, req *OIDCRPCallbackRequest,
8484
return nil, err
8585
}
8686

87-
// Retrieve session to get credential type
87+
// Retrieve session to get credential type.
88+
// ProcessCallback already validated the session, but we need it for credential type etc.
8889
session, err := service.GetSession(ctx, req.State)
8990
if err != nil {
9091
span.SetStatus(codes.Error, "session retrieval failed")
9192
return nil, fmt.Errorf("failed to retrieve session: %w", err)
9293
}
9394

95+
// Ensure session is cleaned up if any subsequent step fails
96+
defer func() {
97+
if err != nil {
98+
service.DeleteSession(ctx, req.State)
99+
}
100+
}()
101+
94102
// Build transformer from config
95103
transformer, err := service.BuildTransformer()
96104
if err != nil {
@@ -141,7 +149,8 @@ func (c *Client) OIDCRPCallback(ctx context.Context, req *OIDCRPCallbackRequest,
141149
return nil, fmt.Errorf("failed to store VCI documents: %w", err)
142150
}
143151

144-
// Clean up OIDC session
152+
// Clean up OIDC session (clear err so defer doesn't double-delete)
153+
err = nil
145154
service.DeleteSession(ctx, req.State)
146155

147156
return &OIDCRPCallbackResponse{
@@ -174,7 +183,8 @@ func (c *Client) OIDCRPCallback(ctx context.Context, req *OIDCRPCallbackRequest,
174183
return nil, fmt.Errorf("failed to generate credential offer: %w", err)
175184
}
176185

177-
// Clean up session
186+
// Clean up session (clear err so defer doesn't double-delete)
187+
err = nil
178188
service.DeleteSession(ctx, req.State)
179189

180190
c.log.Info("Credential issued successfully via OIDC RP",

internal/apigw/apiv1/handlers_users.go

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -166,27 +166,12 @@ func (c *Client) UserLookup(ctx context.Context, req *vcclient.UserLookupRequest
166166

167167
c.log.Debug("userLookup - retrieved docs from cache", "session_id", authorizationContext.SessionID, "num_docs", len(docs))
168168

169-
// TODO(masv): fix this monstrosity
170-
authenticSource := ""
171-
for key, doc := range docs {
172-
c.log.Debug("userLookup - examining doc", "key", key, "authentic_source", doc.Meta.AuthenticSource, "doc_nil", doc == nil)
173-
authenticSource = doc.Meta.AuthenticSource
174-
break
175-
}
176-
c.log.Debug("userLookup", "authenticSource", authenticSource, "docs", docs)
177-
178-
doc, ok := docs[authenticSource]
179-
if !ok {
180-
c.log.Error(nil, "no document found for authentic source", "authenticSource", authenticSource, "available_keys", func() []string {
181-
keys := make([]string, 0, len(docs))
182-
for k := range docs {
183-
keys = append(keys, k)
184-
}
185-
return keys
186-
}())
187-
return nil, fmt.Errorf("no document found for authentic source %s", authenticSource)
169+
doc, err := firstDocument(docs)
170+
if err != nil {
171+
c.log.Error(err, "no usable document in cache")
172+
return nil, err
188173
}
189-
c.log.Debug("userLookup", "doc", doc)
174+
c.log.Debug("userLookup", "authenticSource", doc.Meta.AuthenticSource, "docs", docs)
190175

191176
jsonPaths, err := req.VCTM.ClaimJSONPath()
192177
if err != nil {
@@ -240,18 +225,10 @@ func (c *Client) UserLookup(ctx context.Context, req *vcclient.UserLookupRequest
240225
c.log.Debug("userLookup - retrieved SAML/OIDC docs from cache",
241226
"session_id", authorizationContext.SessionID, "num_docs", len(docs))
242227

243-
// Extract the first document (same approach as pid_auth)
244-
authenticSource := ""
245-
for key, doc := range docs {
246-
c.log.Debug("userLookup - examining doc", "key", key,
247-
"authentic_source", doc.Meta.AuthenticSource, "doc_nil", doc == nil)
248-
authenticSource = doc.Meta.AuthenticSource
249-
break
250-
}
251-
252-
doc, ok := docs[authenticSource]
253-
if !ok {
254-
return nil, fmt.Errorf("no document found for authentic source %s", authenticSource)
228+
doc, err := firstDocument(docs)
229+
if err != nil {
230+
c.log.Error(err, "no usable document in cache for SAML/OIDC session")
231+
return nil, err
255232
}
256233

257234
jsonPaths, err := req.VCTM.ClaimJSONPath()
@@ -315,6 +292,18 @@ func (c *Client) UserLookup(ctx context.Context, req *vcclient.UserLookupRequest
315292
return reply, nil
316293
}
317294

295+
// firstDocument returns the single document from the cache map.
296+
// Returns an error if the map is empty or any entry is nil.
297+
func firstDocument(docs map[string]*model.CompleteDocument) (*model.CompleteDocument, error) {
298+
for key, doc := range docs {
299+
if doc == nil || doc.DocumentData == nil {
300+
return nil, fmt.Errorf("cached document for key %q is nil or has no data", key)
301+
}
302+
return doc, nil
303+
}
304+
return nil, fmt.Errorf("no documents in cache")
305+
}
306+
318307
// findValueByName searches the document data for a claim value matching
319308
// the VCTM claim path. It first tries an exact JSONPath-style lookup,
320309
// then falls back to searching recursively by the leaf key name.

internal/apigw/httpserver/endpoints_oauth.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package httpserver
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"net/http"
78
"vc/internal/apigw/apiv1"
89
"vc/pkg/model"
@@ -210,6 +211,11 @@ func (s *Service) endpointOAuthAuthorizationConsent(ctx context.Context, c *gin.
210211
}
211212

212213
scope, _ := session.Get("scope").(string)
214+
if s.cfg.GetCredentialConstructor(scope) == nil {
215+
err := fmt.Errorf("scope %q not configured for credential issuance", scope)
216+
span.SetStatus(codes.Error, err.Error())
217+
return nil, err
218+
}
213219

214220
// Determine the IdP entity ID: use static IdP or default from config
215221
idpEntityID := s.samlSPService.GetStaticIDPEntityID()
@@ -231,6 +237,11 @@ func (s *Service) endpointOAuthAuthorizationConsent(ctx context.Context, c *gin.
231237
}
232238

233239
scope, _ := session.Get("scope").(string)
240+
if s.cfg.GetCredentialConstructor(scope) == nil {
241+
err := fmt.Errorf("scope %q not configured for credential issuance", scope)
242+
span.SetStatus(codes.Error, err.Error())
243+
return nil, err
244+
}
234245

235246
authReq, err := s.oidcrpService.InitiateAuthForVCI(ctx, scope, sessionID)
236247
if err != nil {

internal/apigw/httpserver/endpoints_oidcrp.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ func (s *Service) endpointOIDCRPCallback(ctx context.Context, c *gin.Context) (a
7171
State: c.Query("state"),
7272
}
7373

74+
// Check for IdP error responses (RFC 6749 §4.1.2.1)
75+
if idpError := c.Query("error"); idpError != "" {
76+
idpDesc := c.Query("error_description")
77+
s.log.Error(nil, "OIDC IdP returned error",
78+
"error", idpError,
79+
"error_description", idpDesc,
80+
"state", req.State)
81+
span.SetStatus(codes.Error, "IdP error: "+idpError)
82+
return nil, fmt.Errorf("identity provider error: %s - %s", idpError, idpDesc)
83+
}
84+
7485
if req.Code == "" || req.State == "" {
7586
span.SetStatus(codes.Error, "missing code or state parameter")
7687
return nil, fmt.Errorf("missing required parameters: code and state")
@@ -85,6 +96,9 @@ func (s *Service) endpointOIDCRPCallback(ctx context.Context, c *gin.Context) (a
8596

8697
// VCI mode: redirect browser back to consent page
8798
if reply != nil && reply.VCIRedirectURL != "" {
99+
// Clean up auth cookies before redirect
100+
c.SetCookie("auth_method", "", -1, "/authorization/consent", "", false, false)
101+
c.SetCookie("oidc_redirect_url", "", -1, "/authorization/consent", "", false, false)
88102
c.Redirect(http.StatusFound, reply.VCIRedirectURL)
89103
return nil, nil
90104
}

internal/apigw/httpserver/endpoints_saml.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,18 @@ func (s *Service) endpointSAMLACS(ctx context.Context, c *gin.Context) (any, err
144144
return nil, err
145145
}
146146

147-
// Retrieve session to get credential type
147+
// Retrieve session to get credential type.
148+
// Ensure session is cleaned up if any subsequent step fails.
148149
session, err := s.samlSPService.GetSession(ctx, relayState)
149150
if err != nil {
150151
span.SetStatus(codes.Error, "session retrieval failed")
151152
return nil, fmt.Errorf("failed to retrieve session: %w", err)
152153
}
154+
defer func() {
155+
if err != nil {
156+
s.samlSPService.DeleteSession(ctx, relayState)
157+
}
158+
}()
153159

154160
// Build transformer from config
155161
transformer, err := s.samlSPService.BuildTransformer()
@@ -169,6 +175,10 @@ func (s *Service) endpointSAMLACS(ctx context.Context, c *gin.Context) (any, err
169175
// Take the first value from each attribute array
170176
samlAttrs := make(map[string]any)
171177
for key, values := range assertion.Attributes {
178+
if len(values) > 1 {
179+
s.log.Warn("SAML attribute has multiple values, using first",
180+
"attribute", key, "count", len(values))
181+
}
172182
if len(values) > 0 {
173183
samlAttrs[key] = values[0] // Use first value
174184
}
@@ -209,9 +219,14 @@ func (s *Service) endpointSAMLACS(ctx context.Context, c *gin.Context) (any, err
209219
return nil, fmt.Errorf("failed to store VCI documents: %w", err)
210220
}
211221

212-
// Clean up SAML session
222+
// Clean up SAML session (clear err so defer doesn't double-delete)
223+
err = nil
213224
s.samlSPService.DeleteSession(ctx, relayState)
214225

226+
// Clean up auth cookies before redirect
227+
c.SetCookie("auth_method", "", -1, "/authorization/consent", "", false, false)
228+
c.SetCookie("saml_redirect_url", "", -1, "/authorization/consent", "", false, false)
229+
215230
// Redirect browser back to the consent page to continue the VCI flow
216231
c.Redirect(http.StatusFound, "/authorization/consent/#/credentials")
217232
return nil, nil
@@ -229,10 +244,8 @@ func (s *Service) endpointSAMLACS(ctx context.Context, c *gin.Context) (any, err
229244
// In a production scenario, the wallet would provide the JWK
230245
jwk := session.JWK
231246
if jwk == nil {
232-
// For SAML flow without wallet involvement, we generate a JWK
233-
// This is primarily for testing and backward compatibility
234-
s.log.Info("No JWK provided in session, generating ephemeral key")
235-
// TODO: Consider if we should require JWK from wallet instead
247+
span.SetStatus(codes.Error, "JWK required for standalone SAML credential binding")
248+
return nil, fmt.Errorf("wallet must provide a JWK for credential binding in standalone SAML mode")
236249
}
237250

238251
// Create credential using the issuer gRPC API
@@ -249,7 +262,8 @@ func (s *Service) endpointSAMLACS(ctx context.Context, c *gin.Context) (any, err
249262
return nil, fmt.Errorf("failed to generate credential offer: %w", err)
250263
}
251264

252-
// Clean up SAML session
265+
// Clean up SAML session (clear err so defer doesn't double-delete)
266+
err = nil
253267
s.samlSPService.DeleteSession(ctx, relayState)
254268

255269
s.log.Info("Credential issued successfully",

internal/apigw/httpserver/endpoints_users.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ func (s *Service) endpointUserCancel(ctx context.Context, c *gin.Context) (any,
210210
// Delete all cookies, not just session.
211211
c.SetCookie("auth_method", "", -1, "/authorization/consent", "", false, false)
212212
c.SetCookie("pid_auth_redirect_url", "", -1, "/authorization/consent", "", false, false)
213+
c.SetCookie("oidc_redirect_url", "", -1, "/authorization/consent", "", false, false)
214+
c.SetCookie("saml_redirect_url", "", -1, "/authorization/consent", "", false, false)
213215

214216
client, ok := s.cfg.APIGW.OauthServer.Clients[clientId]
215217
if !ok {

internal/apigw/oidcrp/dynamic_registration.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ func (s *Service) dynamicClientRegistration(ctx context.Context, registrationEnd
117117
}
118118
defer resp.Body.Close()
119119

120-
// Read response body
121-
respBody, err := io.ReadAll(resp.Body)
120+
// Read response body with a size limit to prevent OOM from malicious endpoints
121+
const maxResponseSize = 1 << 20 // 1 MB
122+
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
122123
if err != nil {
123124
return nil, fmt.Errorf("failed to read registration response: %w", err)
124125
}

internal/apigw/oidcrp/service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ func (s *Service) InitiateAuth(ctx context.Context, credentialType string) (*Aut
168168
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
169169
)
170170

171-
s.log.Info("OIDC authorization URL generated",
171+
s.log.Debug("OIDC authorization URL generated",
172172
"credential_type", credentialType,
173173
"state", session.State)
174174

@@ -337,7 +337,7 @@ func (s *Service) createSession(ctx context.Context, credentialType string) (*Se
337337
func (s *Service) getSession(ctx context.Context, state string) (*Session, error) {
338338
session, ok := s.sessionCache.Get(ctx, state)
339339
if !ok || session == nil {
340-
return nil, fmt.Errorf("session not found or expired for state: %s", state)
340+
return nil, fmt.Errorf("session not found or expired")
341341
}
342342

343343
return session, nil

internal/apigw/samlsp/service.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ func (s *Service) InitiateAuth(ctx context.Context, idpEntityID, credentialType
161161
return nil, fmt.Errorf("failed to get IdP metadata: %w", err)
162162
}
163163

164+
// Validate IdP metadata structure before accessing
165+
if len(idpMetadata.IDPSSODescriptors) == 0 {
166+
return nil, fmt.Errorf("no IdP SSO descriptors in metadata for %s", idpEntityID)
167+
}
168+
if len(idpMetadata.IDPSSODescriptors[0].SingleSignOnServices) == 0 {
169+
return nil, fmt.Errorf("no SSO services in IdP metadata for %s", idpEntityID)
170+
}
171+
164172
// Create authentication request
165173
req, err := s.sp.MakeAuthenticationRequest(
166174
idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0].Location,
@@ -272,6 +280,23 @@ func (s *Service) ProcessAssertion(ctx context.Context, samlResponseEncoded stri
272280
}
273281
}
274282

283+
// Validate assertion subject and conditions are present
284+
if samlResp.Subject == nil || samlResp.Subject.NameID == nil {
285+
return nil, fmt.Errorf("SAML assertion missing Subject or NameID")
286+
}
287+
if samlResp.Conditions == nil {
288+
return nil, fmt.Errorf("SAML assertion missing Conditions")
289+
}
290+
291+
// Validate assertion time conditions (SAML 2.0 §2.5.1)
292+
now := time.Now()
293+
if !samlResp.Conditions.NotBefore.IsZero() && now.Before(samlResp.Conditions.NotBefore) {
294+
return nil, fmt.Errorf("SAML assertion not yet valid: now=%s, NotBefore=%s", now, samlResp.Conditions.NotBefore)
295+
}
296+
if !samlResp.Conditions.NotOnOrAfter.IsZero() && now.After(samlResp.Conditions.NotOnOrAfter) {
297+
return nil, fmt.Errorf("SAML assertion expired: now=%s, NotOnOrAfter=%s", now, samlResp.Conditions.NotOnOrAfter)
298+
}
299+
275300
return &Assertion{
276301
NameID: samlResp.Subject.NameID.Value,
277302
Attributes: attributes,
@@ -295,7 +320,7 @@ func (s *Service) DeleteSession(ctx context.Context, sessionID string) {
295320
func (s *Service) getSession(ctx context.Context, id string) (*Session, error) {
296321
session, ok := s.sessionCache.Get(ctx, id)
297322
if !ok || session == nil {
298-
return nil, fmt.Errorf("session not found or expired: %s", id)
323+
return nil, fmt.Errorf("session not found or expired")
299324
}
300325

301326
return session, nil

0 commit comments

Comments
 (0)