Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/apigw/apiv1/handlers_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func (c *Client) OAuthPar(ctx context.Context, req *openid4vci.PARRequest) (*ope
WalletClientID: req.ClientID,
WalletURI: req.RedirectURI,
ExpiresAt: time.Now().Add(60 * time.Second).Unix(),
DynamicParams: req.DynamicParams,
}

azt.Nonce, err = crypto.GenerateSecureToken(0, 32)
Expand Down
43 changes: 42 additions & 1 deletion internal/apigw/apiv1/handlers_oidcrp.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/SUNET/vc/internal/gen/issuer/apiv1_issuer"
"github.com/SUNET/vc/pkg/crypto"
"github.com/SUNET/vc/pkg/grpchelpers"
"github.com/SUNET/vc/pkg/issuance"
"github.com/SUNET/vc/pkg/model"
"github.com/SUNET/vc/pkg/openid4vci"

Expand Down Expand Up @@ -70,7 +71,13 @@ func (c *Client) OIDCRPInitiate(ctx context.Context, req *OIDCRPInitiateRequest,
return nil, fmt.Errorf("OIDC RP service not available")
}

authReq, err := service.InitiateAuth(ctx, req.CredentialType)
// Look up per-scope OIDC request params
var oidcParams *model.OIDCRequestParams
if scopeCfg := c.cfg.APIGW.DataSources.LookupScopePolicyConfig(req.CredentialType); scopeCfg != nil {
oidcParams = scopeCfg.OIDCRequestParams
}

authReq, err := service.InitiateAuth(ctx, req.CredentialType, oidcParams, nil)
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
Expand Down Expand Up @@ -180,6 +187,40 @@ func (c *Client) OIDCRPCallback(ctx context.Context, req *OIDCRPCallbackRequest,
"claim_keys", claimKeys,
"subject", authResp.IDToken.Subject)

// Evaluate issuance policy (if configured for this scope).
// This uses SPOCP rules to gate credential issuance on claim values.
// The raw OIDC claims (pre-transformation) are used for policy evaluation
// since the rules reference OIDC claim names, not mapped credential claim names.
if scopeCfg := c.cfg.APIGW.DataSources.LookupScopePolicyConfig(session.CredentialType); scopeCfg != nil && scopeCfg.IssuancePolicy != nil {
policyEngine, policyErr := issuance.GetPolicyEngine(scopeCfg.IssuancePolicy)
if policyErr != nil {
span.SetStatus(codes.Error, policyErr.Error())
return nil, fmt.Errorf("failed to initialize issuance policy engine: %w", policyErr)
}
Comment thread
leifj marked this conversation as resolved.
if policyEngine != nil {
// Merge dynamic params into claims for policy evaluation.
// Dynamic params from the authentic source are available as dimensions;
// OIDC claims take precedence over dynamic params.
policyClaims := maps.Clone(authResp.Claims)
for k, v := range session.DynamicParams {
if _, exists := policyClaims[k]; !exists {
policyClaims[k] = v
}
}
if policyErr := policyEngine.Evaluate(session.CredentialType, policyClaims, scopeCfg.IssuancePolicy.QueryTemplate); policyErr != nil {
c.log.Warn("Issuance policy denied credential",
Comment thread
leifj marked this conversation as resolved.
"credential_type", session.CredentialType,
"subject", authResp.IDToken.Subject,
"error", policyErr)
span.SetStatus(codes.Error, policyErr.Error())
return nil, fmt.Errorf("credential issuance denied: %w", policyErr)
}
c.log.Info("Issuance policy evaluation passed",
"credential_type", session.CredentialType,
"subject", authResp.IDToken.Subject)
}
}

// VCI mode: if the OIDC session was initiated from the OpenID4VCI consent flow,
// store the transformed claims as a document in the VCI session cache and signal
// the httpserver to redirect back to the consent page.
Expand Down
99 changes: 92 additions & 7 deletions internal/apigw/auth_providers/oidcrp/service.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package oidcrp

import (
"bytes"
"context"
"fmt"
"net/http"
"text/template"
"time"

"github.com/SUNET/vc/internal/apigw/db"
Expand Down Expand Up @@ -139,8 +141,10 @@ type AuthRequest struct {
State string
}

// InitiateAuth initiates an OIDC authentication flow
func (s *Service) InitiateAuth(ctx context.Context, credentialType string) (*AuthRequest, error) {
// InitiateAuth initiates an OIDC authentication flow.
// oidcParams and dynamicParams are optional: when non-nil, they customize the
// authorization request (e.g., acr_values, claims parameter, extra scopes).
func (s *Service) InitiateAuth(ctx context.Context, credentialType string, oidcParams *model.OIDCRequestParams, dynamicParams map[string]string) (*AuthRequest, error) {
s.log.Debug("Initiating OIDC auth",
"credential_type", credentialType)

Expand All @@ -150,16 +154,47 @@ func (s *Service) InitiateAuth(ctx context.Context, credentialType string) (*Aut
return nil, fmt.Errorf("failed to create session: %w", err)
}

// Store dynamic params in session for later retrieval during policy evaluation
if len(dynamicParams) > 0 {
session.DynamicParams = dynamicParams
s.sessionCache.Set(ctx, session.ID, session)
}

// Generate PKCE code_challenge from code_verifier
codeChallenge := pkgoauth2.CreateCodeChallenge(pkgoauth2.CodeChallengeMethodS256, session.CodeVerifier)

// Build authorization URL with PKCE
authURL := s.oauth2Config.AuthCodeURL(
session.State,
authOpts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("nonce", session.Nonce),
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
}

// Apply per-scope OIDC request parameters
if oidcParams != nil {
extraOpts, err := resolveOIDCRequestParams(oidcParams, dynamicParams)
if err != nil {
return nil, fmt.Errorf("failed to resolve OIDC request params: %w", err)
}
authOpts = append(authOpts, extraOpts...)
}

// If extra scopes are configured, create a temporary config with merged scopes
oauthCfg := s.oauth2Config
if oidcParams != nil && len(oidcParams.ExtraScopes) > 0 {
mergedScopes := make([]string, len(s.oauth2Config.Scopes))
copy(mergedScopes, s.oauth2Config.Scopes)
mergedScopes = append(mergedScopes, oidcParams.ExtraScopes...)
oauthCfg = &oauth2.Config{
ClientID: s.oauth2Config.ClientID,
ClientSecret: s.oauth2Config.ClientSecret,
RedirectURL: s.oauth2Config.RedirectURL,
Endpoint: s.oauth2Config.Endpoint,
Scopes: mergedScopes,
}
}

authURL := oauthCfg.AuthCodeURL(session.State, authOpts...)

s.log.Debug("OIDC authorization URL generated",
"credential_type", credentialType,
Expand All @@ -175,8 +210,8 @@ func (s *Service) InitiateAuth(ctx context.Context, credentialType string) (*Aut
// an OpenID4VCI credential issuance session. The VCI session ID is stored in
// the OIDC session so that the callback handler can route the result back into
// the VCI pipeline.
func (s *Service) InitiateAuthForVCI(ctx context.Context, credentialType, vciSessionID string) (*AuthRequest, error) {
authReq, err := s.InitiateAuth(ctx, credentialType)
func (s *Service) InitiateAuthForVCI(ctx context.Context, credentialType, vciSessionID string, oidcParams *model.OIDCRequestParams, dynamicParams map[string]string) (*AuthRequest, error) {
authReq, err := s.InitiateAuth(ctx, credentialType, oidcParams, dynamicParams)
if err != nil {
return nil, err
}
Expand All @@ -198,6 +233,56 @@ func (s *Service) InitiateAuthForVCI(ctx context.Context, credentialType, vciSes
return authReq, nil
}

// resolveOIDCRequestParams resolves template variables in OIDC request params
// and returns oauth2.AuthCodeOption values to append to the authorization URL.
func resolveOIDCRequestParams(params *model.OIDCRequestParams, dynamicParams map[string]string) ([]oauth2.AuthCodeOption, error) {
var opts []oauth2.AuthCodeOption

if params.ACRValues != "" {
resolved, err := resolveTemplate(params.ACRValues, dynamicParams)
if err != nil {
return nil, fmt.Errorf("acr_values template: %w", err)
}
opts = append(opts, oauth2.SetAuthURLParam("acr_values", resolved))
}

if params.Claims != "" {
resolved, err := resolveTemplate(params.Claims, dynamicParams)
if err != nil {
return nil, fmt.Errorf("claims template: %w", err)
}
opts = append(opts, oauth2.SetAuthURLParam("claims", resolved))
}

for key, value := range params.CustomParams {
resolvedValue, err := resolveTemplate(value, dynamicParams)
if err != nil {
return nil, fmt.Errorf("custom param %q template: %w", key, err)
}
opts = append(opts, oauth2.SetAuthURLParam(key, resolvedValue))
}
Comment thread
leifj marked this conversation as resolved.

return opts, nil
}

// resolveTemplate resolves Go template syntax in a string using dynamic params as data.
func resolveTemplate(tmplStr string, data map[string]string) (string, error) {
if len(data) == 0 {
return tmplStr, nil
}

tmpl, err := template.New("param").Option("missingkey=error").Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("invalid template %q: %w", tmplStr, err)
}

var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("template execution failed for %q: %w", tmplStr, err)
}
return buf.String(), nil
}

// AuthResponse represents the result of OIDC authentication
type AuthResponse struct {
IDToken *oidc.IDToken
Expand Down
4 changes: 4 additions & 0 deletions internal/apigw/auth_providers/oidcrp/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ type Session struct {

// VCI flow integration fields (set when initiated from OpenID4VCI consent)
VCISessionID string `json:"vci_session_id" bson:"vci_session_id"` // Links back to the VCI AuthorizationContext session

// DynamicParams holds key-value parameters from the authentic source for template substitution
// in OIDC request parameters. Propagated from AuthorizationContext.DynamicParams.
DynamicParams map[string]string `json:"dynamic_params,omitempty" bson:"dynamic_params,omitempty"`
}
14 changes: 13 additions & 1 deletion internal/apigw/httpserver/endpoints_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,19 @@ func (s *Service) endpointOAuthAuthorizationConsent(ctx context.Context, c *gin.
}

s.log.Debug("consent: initiating OIDC auth for VCI", "scope", scope, "session_id", sessionID)
authReq, err := s.authProviders.OIDC().InitiateAuthForVCI(ctx, scope, sessionID)

// Look up per-scope OIDC request params and dynamic params from auth context
var oidcParams *model.OIDCRequestParams
var dynamicParams map[string]string
if scopeCfg := s.cfg.APIGW.DataSources.LookupScopePolicyConfig(scope); scopeCfg != nil {
oidcParams = scopeCfg.OIDCRequestParams
}
authCtx, authCtxErr := s.cacheService.AuthContext.Get(ctx, &cache.AuthorizationContext{SessionID: sessionID})
if authCtxErr == nil && len(authCtx.DynamicParams) > 0 {
dynamicParams = authCtx.DynamicParams
}

authReq, err := s.authProviders.OIDC().InitiateAuthForVCI(ctx, scope, sessionID, oidcParams, dynamicParams)
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
Expand Down
5 changes: 5 additions & 0 deletions pkg/cache/authcontext_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ type AuthorizationContext struct {
DataSource string `json:"data_source,omitempty" bson:"data_source,omitempty" validate:"omitempty,max=32,printascii"`
RemoteName string `json:"remote_name,omitempty" bson:"remote_name,omitempty" validate:"omitempty,max=128,printascii"`

// DynamicParams holds key-value parameters provided by the authentic source business system
// at flow initiation time. These values are used for template substitution in OIDC request
// parameters (e.g., acr_values, claims) and are available during issuance policy evaluation.
DynamicParams map[string]string `json:"dynamic_params,omitempty" bson:"dynamic_params,omitempty" validate:"omitempty,dive,keys,max=64,printascii,endkeys,max=1024,printascii"`

// Verifier-specific fields (presentation/RP flows)
RedirectURI string `json:"redirect_uri,omitempty" bson:"redirect_uri,omitempty" validate:"omitempty,max=2048,printascii"`
ResponseType string `json:"response_type,omitempty" bson:"response_type,omitempty" validate:"omitempty,max=32,printascii"`
Expand Down
Loading