diff --git a/internal/apigw/apiv1/handlers_oauth.go b/internal/apigw/apiv1/handlers_oauth.go index a50883af4..826b185f0 100644 --- a/internal/apigw/apiv1/handlers_oauth.go +++ b/internal/apigw/apiv1/handlers_oauth.go @@ -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) diff --git a/internal/apigw/apiv1/handlers_oidcrp.go b/internal/apigw/apiv1/handlers_oidcrp.go index c526d544b..31755215b 100644 --- a/internal/apigw/apiv1/handlers_oidcrp.go +++ b/internal/apigw/apiv1/handlers_oidcrp.go @@ -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" @@ -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 @@ -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) + } + 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", + "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. diff --git a/internal/apigw/auth_providers/oidcrp/service.go b/internal/apigw/auth_providers/oidcrp/service.go index 9c46903c3..dd64a3ef2 100644 --- a/internal/apigw/auth_providers/oidcrp/service.go +++ b/internal/apigw/auth_providers/oidcrp/service.go @@ -1,9 +1,11 @@ package oidcrp import ( + "bytes" "context" "fmt" "net/http" + "text/template" "time" "github.com/SUNET/vc/internal/apigw/db" @@ -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) @@ -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, @@ -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 } @@ -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)) + } + + 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 diff --git a/internal/apigw/auth_providers/oidcrp/session.go b/internal/apigw/auth_providers/oidcrp/session.go index 9c3359dd7..131aaefd4 100644 --- a/internal/apigw/auth_providers/oidcrp/session.go +++ b/internal/apigw/auth_providers/oidcrp/session.go @@ -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"` } diff --git a/internal/apigw/httpserver/endpoints_oauth.go b/internal/apigw/httpserver/endpoints_oauth.go index 47e5563ef..ee2b06aae 100644 --- a/internal/apigw/httpserver/endpoints_oauth.go +++ b/internal/apigw/httpserver/endpoints_oauth.go @@ -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 diff --git a/pkg/cache/authcontext_types.go b/pkg/cache/authcontext_types.go index 1eb85482c..ee8d73d43 100644 --- a/pkg/cache/authcontext_types.go +++ b/pkg/cache/authcontext_types.go @@ -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"` diff --git a/pkg/httphelpers/middleware_jwt.go b/pkg/httphelpers/middleware_jwt.go index 9e94a99e0..9da4bba23 100644 --- a/pkg/httphelpers/middleware_jwt.go +++ b/pkg/httphelpers/middleware_jwt.go @@ -1,19 +1,16 @@ package httphelpers import ( - "bufio" "context" "encoding/json" "fmt" "net/http" - "os" - "path/filepath" "strings" "sync" "time" - "unicode" "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/spocputil" "github.com/gin-gonic/gin" "github.com/lestrrat-go/jwx/v3/jwk" @@ -97,7 +94,7 @@ func BuildSPOCPEngine(cfg model.APIAuth) (*SafeEngine, error) { engine := spocp.New() for i, r := range cfg.Rules { - elem, err := parseAdvancedSExp(r) + elem, err := spocputil.ParseAdvancedSExp(r) if err != nil { return nil, fmt.Errorf("invalid inline SPOCP rule #%d: %w", i+1, err) } @@ -105,7 +102,7 @@ func BuildSPOCPEngine(cfg model.APIAuth) (*SafeEngine, error) { } if hasFile { - if err := loadRulesFromFile(engine, cfg.RulesFile); err != nil { + if err := spocputil.LoadRulesFromFile(engine, cfg.RulesFile); err != nil { return nil, fmt.Errorf("failed to load SPOCP rules from %s: %w", cfg.RulesFile, err) } } @@ -113,253 +110,6 @@ func BuildSPOCPEngine(cfg model.APIAuth) (*SafeEngine, error) { return &SafeEngine{engine: engine}, nil } -// parseRulesFile parses a rules file and returns the elements (best-effort) -func parseRulesFile(path string) []sexp.Element { - f, err := os.Open(filepath.Clean(path)) - if err != nil { - return nil - } - defer f.Close() - - var elems []sexp.Element - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - elem, err := parseAdvancedSExp(line) - if err != nil { - continue - } - elems = append(elems, elem) - } - return elems -} - -// loadRulesFromFile reads human-readable SPOCP rules (one per line) from a file -// and adds them to the engine using parseAdvancedSExp. -func loadRulesFromFile(engine *spocp.AdaptiveEngine, path string) error { - f, err := os.Open(filepath.Clean(path)) - if err != nil { - return err - } - defer f.Close() - - scanner := bufio.NewScanner(f) - lineNum := 0 - for scanner.Scan() { - lineNum++ - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue // skip blanks and comments - } - elem, err := parseAdvancedSExp(line) - if err != nil { - return fmt.Errorf("line %d: %w", lineNum, err) - } - engine.AddRuleElement(elem) - } - return scanner.Err() -} - -// parseAdvancedSExp parses a human-readable ("advanced form") S-expression into -// a sexp.Element. This allows users to write rules as: -// -// (api (method POST)(path /api/v1/upload)(subject alice)) -// -// instead of canonical form: -// -// (3:api(6:method4:POST)(4:path15:/api/v1/upload)(7:subject5:alice)) -// -// It also supports star forms: -// -// (*) → wildcard -// (* prefix /api/v1/) → prefix match -// (* suffix .pdf) → suffix match -// (* set read write delete) → set match -func parseAdvancedSExp(input string) (sexp.Element, error) { - input = strings.TrimSpace(input) - if input == "" { - return nil, fmt.Errorf("empty S-expression") - } - - p := &advancedParser{input: []rune(input), pos: 0} - elem, err := p.parse() - if err != nil { - return nil, err - } - - // Ensure we consumed all input. - p.skipWhitespace() - if p.pos < len(p.input) { - return nil, fmt.Errorf("unexpected trailing input at position %d", p.pos) - } - return elem, nil -} - -type advancedParser struct { - input []rune - pos int -} - -func (p *advancedParser) skipWhitespace() { - for p.pos < len(p.input) && unicode.IsSpace(p.input[p.pos]) { - p.pos++ - } -} - -func (p *advancedParser) parse() (sexp.Element, error) { - p.skipWhitespace() - if p.pos >= len(p.input) { - return nil, fmt.Errorf("unexpected end of input") - } - - if p.input[p.pos] == '(' { - return p.parseList() - } - return p.parseAtom() -} - -func (p *advancedParser) parseAtom() (*sexp.Atom, error) { - p.skipWhitespace() - if p.pos >= len(p.input) { - return nil, fmt.Errorf("unexpected end of input, expected atom") - } - - start := p.pos - for p.pos < len(p.input) && p.input[p.pos] != '(' && p.input[p.pos] != ')' && !unicode.IsSpace(p.input[p.pos]) { - p.pos++ - } - if p.pos == start { - return nil, fmt.Errorf("empty atom at position %d", start) - } - return sexp.NewAtom(string(p.input[start:p.pos])), nil -} - -func (p *advancedParser) parseList() (sexp.Element, error) { - if p.input[p.pos] != '(' { - return nil, fmt.Errorf("expected '(' at position %d", p.pos) - } - p.pos++ // skip '(' - - p.skipWhitespace() - if p.pos >= len(p.input) { - return nil, fmt.Errorf("unclosed '('") - } - - // Parse the tag atom. - tag, err := p.parseAtom() - if err != nil { - return nil, fmt.Errorf("failed to parse list tag: %w", err) - } - - // Handle star forms: tag == "*" - if tag.Value == "*" { - return p.parseStarForm() - } - - // Parse remaining elements until ')'. - var elements []sexp.Element - for { - p.skipWhitespace() - if p.pos >= len(p.input) { - return nil, fmt.Errorf("unclosed list '%s'", tag.Value) - } - if p.input[p.pos] == ')' { - p.pos++ // skip ')' - return sexp.NewList(tag.Value, elements...), nil - } - elem, err := p.parse() - if err != nil { - return nil, err - } - // Treat a bare * inside a tagged list as a wildcard star form, - // so (method *) is equivalent to (method (*)). - // Treat a trailing * (e.g. /api/v1/*) as a prefix star form, - // so (path /api/v1/*) is equivalent to (path (* prefix /api/v1/)). - if atom, ok := elem.(*sexp.Atom); ok { - if atom.Value == "*" { - elem = &starform.Wildcard{} - } else if strings.HasSuffix(atom.Value, "*") { - elem = &starform.Prefix{Value: strings.TrimSuffix(atom.Value, "*")} - } - } - elements = append(elements, elem) - } -} - -// parseStarForm parses the body of a star form after the '*' tag. -// At this point the opening '(' and '*' have been consumed. -func (p *advancedParser) parseStarForm() (sexp.Element, error) { - p.skipWhitespace() - if p.pos >= len(p.input) { - return nil, fmt.Errorf("unclosed star form") - } - - // (*) → wildcard - if p.input[p.pos] == ')' { - p.pos++ - return &starform.Wildcard{}, nil - } - - // Read the star form type: set, prefix, suffix, range... - formType, err := p.parseAtom() - if err != nil { - return nil, fmt.Errorf("failed to parse star form type: %w", err) - } - - switch formType.Value { - case "prefix": - return p.parsePrefixSuffix(true) - case "suffix": - return p.parsePrefixSuffix(false) - case "set": - return p.parseSet() - default: - return nil, fmt.Errorf("unsupported star form type %q", formType.Value) - } -} - -func (p *advancedParser) parsePrefixSuffix(isPrefix bool) (sexp.Element, error) { - p.skipWhitespace() - value, err := p.parseAtom() - if err != nil { - return nil, fmt.Errorf("expected value for prefix/suffix star form: %w", err) - } - p.skipWhitespace() - if p.pos >= len(p.input) || p.input[p.pos] != ')' { - return nil, fmt.Errorf("expected ')' to close prefix/suffix star form") - } - p.pos++ - if isPrefix { - return &starform.Prefix{Value: value.Value}, nil - } - return &starform.Suffix{Value: value.Value}, nil -} - -func (p *advancedParser) parseSet() (sexp.Element, error) { - var elements []sexp.Element - for { - p.skipWhitespace() - if p.pos >= len(p.input) { - return nil, fmt.Errorf("unclosed set star form") - } - if p.input[p.pos] == ')' { - p.pos++ - if len(elements) == 0 { - return nil, fmt.Errorf("empty set star form") - } - return &starform.Set{Elements: elements}, nil - } - elem, err := p.parse() - if err != nil { - return nil, err - } - elements = append(elements, elem) - } -} - // extractSPOCPSubject returns the identity to use as the SPOCP subject // from a validated JWT, preferring eppn → email func extractSPOCPSubject(token jwt.Token) string { diff --git a/pkg/httphelpers/middleware_jwt_test.go b/pkg/httphelpers/middleware_jwt_test.go index 76e949085..b177a457f 100644 --- a/pkg/httphelpers/middleware_jwt_test.go +++ b/pkg/httphelpers/middleware_jwt_test.go @@ -16,6 +16,7 @@ import ( "github.com/SUNET/vc/pkg/logger" "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/spocputil" "github.com/SUNET/vc/pkg/trace" "github.com/gin-gonic/gin" @@ -105,7 +106,7 @@ func signJWTWithEPPN(t *testing.T, priv *ecdsa.PrivateKey, eppn, iss, aud string Issuer(iss). Audience([]string{aud}). IssuedAt(time.Now()). - Expiration(time.Now().Add(5 * time.Minute)). + Expiration(time.Now().Add(5*time.Minute)). Claim("eppn", eppn). Build() require.NoError(t, err) @@ -1212,7 +1213,7 @@ func TestExtractListValues_EmptyList(t *testing.T) { // parseAdvancedSExpT is a test helper that parses an S-expression or fails. func parseAdvancedSExpT(t *testing.T, input string) sexp.Element { t.Helper() - elem, err := parseAdvancedSExp(input) + elem, err := spocputil.ParseAdvancedSExp(input) require.NoError(t, err) return elem } diff --git a/pkg/issuance/policy.go b/pkg/issuance/policy.go new file mode 100644 index 000000000..b4f868627 --- /dev/null +++ b/pkg/issuance/policy.go @@ -0,0 +1,143 @@ +package issuance + +import ( + "fmt" + "sort" + "sync" + + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/spocputil" + + spocp "github.com/sirosfoundation/go-spocp" + "github.com/sirosfoundation/go-spocp/pkg/sexp" +) + +// PolicyEngine wraps a SPOCP engine for credential issuance policy evaluation. +type PolicyEngine struct { + mu sync.RWMutex + engine *spocp.AdaptiveEngine +} + +// NewPolicyEngine creates a PolicyEngine from an IssuancePolicy configuration. +// Returns nil if no policy is configured (no rules). +func NewPolicyEngine(policy *model.IssuancePolicy) (*PolicyEngine, error) { + if policy == nil { + return nil, nil + } + + hasInline := len(policy.Rules) > 0 + hasFile := policy.RulesFile != "" + + if !hasInline && !hasFile { + return nil, nil + } + + engine := spocp.New() + + for i, r := range policy.Rules { + elem, err := spocputil.ParseAdvancedSExp(r) + if err != nil { + return nil, fmt.Errorf("invalid inline issuance policy rule #%d: %w", i+1, err) + } + engine.AddRuleElement(elem) + } + + if hasFile { + if err := spocputil.LoadRulesFromFile(engine, policy.RulesFile); err != nil { + return nil, fmt.Errorf("failed to load issuance policy rules from %s: %w", policy.RulesFile, err) + } + } + + return &PolicyEngine{engine: engine}, nil +} + +// engineCache caches PolicyEngine instances by IssuancePolicy pointer. +// Config is loaded once at startup and pointers are stable, so pointer identity +// is a safe cache key. This avoids re-parsing rules on every OIDC callback. +var engineCache sync.Map + +// GetPolicyEngine returns a cached PolicyEngine for the given policy, creating one if needed. +func GetPolicyEngine(policy *model.IssuancePolicy) (*PolicyEngine, error) { + if policy == nil { + return nil, nil + } + + if cached, ok := engineCache.Load(policy); ok { + return cached.(*PolicyEngine), nil + } + + engine, err := NewPolicyEngine(policy) + if err != nil { + return nil, err + } + if engine == nil { + return nil, nil + } + + actual, _ := engineCache.LoadOrStore(policy, engine) + return actual.(*PolicyEngine), nil +} + +// Evaluate checks if the given claims satisfy the issuance policy for the specified scope. +// Returns nil if authorized, or an error describing why issuance was denied. +func (pe *PolicyEngine) Evaluate(scope string, claims map[string]any, queryTemplate []model.QueryDimension) error { + query := BuildQuery(scope, claims, queryTemplate) + + pe.mu.RLock() + defer pe.mu.RUnlock() + + if !pe.engine.QueryElement(query) { + return fmt.Errorf("issuance policy denied: claims do not satisfy any rule for scope %q", scope) + } + return nil +} + +// BuildQuery constructs a SPOCP query S-expression from credential scope and OIDC claims. +// The query has the form: (credential (scope ) (claim1 ) (claim2 ) ...) +func BuildQuery(scope string, claims map[string]any, queryTemplate []model.QueryDimension) sexp.Element { + elements := []sexp.Element{ + sexp.NewList("scope", sexp.NewAtom(scope)), + } + + if len(queryTemplate) > 0 { + // Use explicit template: iterate in defined order to match rule positions + for _, dim := range queryTemplate { + if value, ok := claims[dim.Claim]; ok { + elements = append(elements, sexp.NewList(dim.Dimension, sexp.NewAtom(toStringValue(value)))) + } else { + // Claim not present — include empty dimension (matches wildcard rules) + elements = append(elements, sexp.NewList(dim.Dimension)) + } + } + } else { + // Default: include all claims as dimensions, sorted by key for deterministic ordering + keys := make([]string, 0, len(claims)) + for k := range claims { + keys = append(keys, k) + } + sort.Strings(keys) + for _, claimName := range keys { + elements = append(elements, sexp.NewList(claimName, sexp.NewAtom(toStringValue(claims[claimName])))) + } + } + + return sexp.NewList("credential", elements...) +} + +func toStringValue(v any) string { + switch val := v.(type) { + case string: + return val + case bool: + if val { + return "true" + } + return "false" + case float64: + return fmt.Sprintf("%g", val) + case int: + return fmt.Sprintf("%d", val) + default: + return fmt.Sprintf("%v", val) + } +} diff --git a/pkg/issuance/policy_test.go b/pkg/issuance/policy_test.go new file mode 100644 index 000000000..4575a9e00 --- /dev/null +++ b/pkg/issuance/policy_test.go @@ -0,0 +1,312 @@ +package issuance + +import ( + "testing" + + "github.com/SUNET/vc/pkg/model" + "github.com/sirosfoundation/go-spocp/pkg/sexp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPolicyEngine_NilPolicy(t *testing.T) { + engine, err := NewPolicyEngine(nil) + require.NoError(t, err) + assert.Nil(t, engine) +} + +func TestNewPolicyEngine_EmptyPolicy(t *testing.T) { + engine, err := NewPolicyEngine(&model.IssuancePolicy{}) + require.NoError(t, err) + assert.Nil(t, engine) +} + +func TestNewPolicyEngine_InvalidRule(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{"(invalid (unclosed"}, + } + engine, err := NewPolicyEngine(policy) + assert.Error(t, err) + assert.Nil(t, engine) + assert.Contains(t, err.Error(), "invalid inline issuance policy rule #1") +} + +func TestNewPolicyEngine_ValidRules(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + "(credential (scope pid)(email_verified true))", + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + require.NotNil(t, engine) +} + +func TestEvaluate_SimpleMatch(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + "(credential (scope pid)(email_verified true))", + }, + QueryTemplate: []model.QueryDimension{ + {Dimension: "email_verified", Claim: "email_verified"}, + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + // Should pass: claims match the rule + err = engine.Evaluate("pid", map[string]any{ + "email_verified": "true", + }, policy.QueryTemplate) + assert.NoError(t, err) +} + +func TestEvaluate_SimpleDeny(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + "(credential (scope pid)(email_verified true))", + }, + QueryTemplate: []model.QueryDimension{ + {Dimension: "email_verified", Claim: "email_verified"}, + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + // Should deny: email_verified is false + err = engine.Evaluate("pid", map[string]any{ + "email_verified": "false", + }, policy.QueryTemplate) + assert.Error(t, err) + assert.Contains(t, err.Error(), "issuance policy denied") +} + +func TestEvaluate_WrongScope(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + "(credential (scope pid)(email_verified true))", + }, + QueryTemplate: []model.QueryDimension{ + {Dimension: "email_verified", Claim: "email_verified"}, + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + // Should deny: scope doesn't match + err = engine.Evaluate("ehic", map[string]any{ + "email_verified": "true", + }, policy.QueryTemplate) + assert.Error(t, err) +} + +func TestEvaluate_WildcardRule(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + // Allow any scope with any email_verified value + "(credential (scope pid)(email_verified))", + }, + QueryTemplate: []model.QueryDimension{ + {Dimension: "email_verified", Claim: "email_verified"}, + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + // Should pass: wildcard matches any value + err = engine.Evaluate("pid", map[string]any{ + "email_verified": "false", + }, policy.QueryTemplate) + assert.NoError(t, err) +} + +func TestEvaluate_StarForms(t *testing.T) { + tests := []struct { + name string + rule string + scope string + passCases []map[string]any + failCases []map[string]any + }{ + { + name: "prefix match", + rule: "(credential (scope org_cred)(acr (* prefix urn:example:loa)))", + scope: "org_cred", + passCases: []map[string]any{ + {"acr": "urn:example:loa3"}, + }, + failCases: []map[string]any{ + {"acr": "urn:other:loa3"}, + }, + }, + { + name: "set match", + rule: "(credential (scope pid)(acr (* set loa3 loa4)))", + scope: "pid", + passCases: []map[string]any{ + {"acr": "loa3"}, + {"acr": "loa4"}, + }, + failCases: []map[string]any{ + {"acr": "loa1"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{tt.rule}, + QueryTemplate: []model.QueryDimension{{Dimension: "acr", Claim: "acr"}}, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + for _, claims := range tt.passCases { + assert.NoError(t, engine.Evaluate(tt.scope, claims, policy.QueryTemplate), "expected pass for %v", claims) + } + for _, claims := range tt.failCases { + assert.Error(t, engine.Evaluate(tt.scope, claims, policy.QueryTemplate), "expected deny for %v", claims) + } + }) + } +} + +func TestEvaluate_MultipleRules(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + "(credential (scope pid)(acr loa3)(org_id 123))", + "(credential (scope pid)(acr loa4)(org_id))", + }, + QueryTemplate: []model.QueryDimension{ + {Dimension: "acr", Claim: "acr"}, + {Dimension: "org_id", Claim: "org_id"}, + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + // Should pass: matches first rule + err = engine.Evaluate("pid", map[string]any{ + "acr": "loa3", + "org_id": "123", + }, policy.QueryTemplate) + assert.NoError(t, err) + + // Should pass: matches second rule (loa4 with any org_id) + err = engine.Evaluate("pid", map[string]any{ + "acr": "loa4", + "org_id": "999", + }, policy.QueryTemplate) + assert.NoError(t, err) + + // Should deny: loa3 requires org_id 123 + err = engine.Evaluate("pid", map[string]any{ + "acr": "loa3", + "org_id": "999", + }, policy.QueryTemplate) + assert.Error(t, err) +} + +func TestEvaluate_NoQueryTemplate(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + "(credential (scope pid)(email_verified true)(sub))", + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + // Should pass: all claims included as dimensions, query template nil + err = engine.Evaluate("pid", map[string]any{ + "email_verified": "true", + "sub": "alice", + }, nil) + assert.NoError(t, err) +} + +func TestEvaluate_MissingClaim(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + // Rule requires org_id to have a value + "(credential (scope pid)(org_id 123))", + }, + QueryTemplate: []model.QueryDimension{ + {Dimension: "org_id", Claim: "org_id"}, + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + // Should deny: org_id not in claims (empty dimension doesn't match specific value) + err = engine.Evaluate("pid", map[string]any{ + "sub": "alice", + }, policy.QueryTemplate) + assert.Error(t, err) +} + +func TestEvaluate_BooleanClaims(t *testing.T) { + policy := &model.IssuancePolicy{ + Rules: []string{ + "(credential (scope pid)(email_verified true))", + }, + QueryTemplate: []model.QueryDimension{ + {Dimension: "email_verified", Claim: "email_verified"}, + }, + } + engine, err := NewPolicyEngine(policy) + require.NoError(t, err) + + // Boolean true should convert to string "true" + err = engine.Evaluate("pid", map[string]any{ + "email_verified": true, + }, policy.QueryTemplate) + assert.NoError(t, err) + + // Boolean false should convert to "false" and not match "true" + err = engine.Evaluate("pid", map[string]any{ + "email_verified": false, + }, policy.QueryTemplate) + assert.Error(t, err) +} + +func TestBuildQuery_WithTemplate(t *testing.T) { + query := BuildQuery("pid", map[string]any{ + "acr": "loa3", + "org_id": "123", + "sub": "alice", + }, []model.QueryDimension{ + {Dimension: "acr", Claim: "acr"}, + {Dimension: "org_id", Claim: "org_id"}, + }) + + // Query should be a list with tag "credential" + list, ok := query.(*sexp.List) + require.True(t, ok) + assert.Equal(t, "credential", list.Tag) + + // Should have scope + 2 template dimensions = 3 elements + assert.Len(t, list.Elements, 3) +} + +func TestBuildQuery_WithoutTemplate(t *testing.T) { + query := BuildQuery("pid", map[string]any{ + "acr": "loa3", + "sub": "alice", + }, nil) + + list, ok := query.(*sexp.List) + require.True(t, ok) + assert.Equal(t, "credential", list.Tag) + + // Should have scope + 2 claim dimensions = 3 elements + assert.Len(t, list.Elements, 3) +} + +func TestToStringValue(t *testing.T) { + assert.Equal(t, "hello", toStringValue("hello")) + assert.Equal(t, "true", toStringValue(true)) + assert.Equal(t, "false", toStringValue(false)) + assert.Equal(t, "42", toStringValue(42)) + assert.Equal(t, "3.14", toStringValue(3.14)) +} diff --git a/pkg/model/data_sources.go b/pkg/model/data_sources.go index 02ffc56f7..99459c5b6 100644 --- a/pkg/model/data_sources.go +++ b/pkg/model/data_sources.go @@ -68,6 +68,15 @@ type DatastoreScope struct { // AuthScopes lists credential keys whose VCTs are acceptable for wallet authentication (for OpenID4VP) AuthScopes []string `yaml:"auth_scopes,omitempty" doc_example:"[pid]"` + + // OIDCRequestParams configures additional parameters to include in the OIDC authorization request. + // Used when the authentic source needs to pass dynamic values to the OP. + OIDCRequestParams *OIDCRequestParams `yaml:"oidc_request_params,omitempty"` + + // IssuancePolicy defines SPOCP rules that must be satisfied by the OIDC claims for credential issuance. + // If configured, a SPOCP query is built from the returned claims and evaluated against these rules. + // A query that does not match any rule results in a hard deny. + IssuancePolicy *IssuancePolicy `yaml:"issuance_policy,omitempty"` } // ExtractIdentityClaims extracts identity field values from a claims map using @@ -94,6 +103,15 @@ type AssertionConfig struct { type AssertionScope struct { // AuthProvider is the auth provider for this credential type (saml or oidc) AuthProvider string `yaml:"auth_provider" validate:"required,oneof=saml oidc"` + + // OIDCRequestParams configures additional parameters to include in the OIDC authorization request. + // Used when the authentic source needs to pass dynamic values to the OP. + OIDCRequestParams *OIDCRequestParams `yaml:"oidc_request_params,omitempty"` + + // IssuancePolicy defines SPOCP rules that must be satisfied by the OIDC claims for credential issuance. + // If configured, a SPOCP query is built from the returned claims and evaluated against these rules. + // A query that does not match any rule results in a hard deny. + IssuancePolicy *IssuancePolicy `yaml:"issuance_policy,omitempty"` } // ExternalAPIConfig groups external API credential scopes. @@ -112,6 +130,15 @@ type ExternalAPIScope struct { // AttributeMapping defines how to map API response data to credential claims AttributeMapping AttributeMapping `yaml:"attribute_mapping,omitempty" doc_key:"attribute"` + + // OIDCRequestParams configures additional parameters to include in the OIDC authorization request. + // Used when the authentic source needs to pass dynamic values to the OP. + OIDCRequestParams *OIDCRequestParams `yaml:"oidc_request_params,omitempty"` + + // IssuancePolicy defines SPOCP rules that must be satisfied by the OIDC claims for credential issuance. + // If configured, a SPOCP query is built from the returned claims and evaluated against these rules. + // A query that does not match any rule results in a hard deny. + IssuancePolicy *IssuancePolicy `yaml:"issuance_policy,omitempty"` } // Remote defines an external API connection. @@ -220,3 +247,88 @@ func (ds *DataSources) ResolveDataSource(credentialType, authProvider string) (C "credential type %q has no data source configured for auth provider %q", credentialType, authProvider, ) } + +// OIDCRequestParams configures additional parameters to include in the OIDC authorization request. +// These allow the authentic source to inject dynamic values into the authentication flow. +type OIDCRequestParams struct { + // ACRValues requests specific authentication context class references from the OP. + // Supports Go template syntax for dynamic values: "{{.variable_name}}" + ACRValues string `yaml:"acr_values,omitempty" doc_example:"\"urn:example:loa3\""` + + // Claims is a JSON string conforming to OIDC Core §5.5 claims request parameter. + // Supports Go template syntax for dynamic values: "{{.variable_name}}" + Claims string `yaml:"claims,omitempty" doc_example:"\"{\\\"id_token\\\":{\\\"org_id\\\":{\\\"value\\\":\\\"{{.org_id}}\\\"}}}\""` + + // ExtraScopes are additional OAuth2 scopes to request beyond the default OIDC RP scopes. + ExtraScopes []string `yaml:"extra_scopes,omitempty" doc_example:"[\"organization\", \"address\"]"` + + // CustomParams are arbitrary key-value pairs to add as query parameters to the authorization request. + // Keys are static; values support Go template syntax for dynamic substitution. + CustomParams map[string]string `yaml:"custom_params,omitempty"` +} + +// IssuancePolicy defines SPOCP rules for credential issuance authorization. +// After OIDC authentication completes, a SPOCP query is built from the returned +// claims and evaluated against these rules. If no rule matches, issuance is denied. +type IssuancePolicy struct { + // Rules are inline SPOCP S-expression rules (human-readable advanced form). + // Example: "(credential (scope org_credential)(acr (* prefix urn:example:loa))(org_id))" + Rules []string `yaml:"rules,omitempty" doc_example:"[\"(credential (scope my_cred)(acr urn:example:loa3)(email_verified true))\"]"` + + // RulesFile is an optional path to a file containing SPOCP rules (one per line). + // Rules from this file are loaded in addition to the inline Rules list. + RulesFile string `yaml:"rules_file,omitempty"` + + // QueryTemplate defines how to build the SPOCP query from OIDC claims. + // The outer tag is always "credential". Each entry maps a SPOCP dimension name + // to the OIDC claim name whose value should populate it. The order of entries + // determines the positional order of dimensions in the SPOCP query, which must + // match the order used in the rules. + // Special dimension "scope" is auto-populated with the credential type name. + // If empty, a default query is built with all claims as dimensions (sorted by key). + QueryTemplate []QueryDimension `yaml:"query_template,omitempty"` +} + +// QueryDimension maps a SPOCP dimension name to the OIDC claim whose value populates it. +// Ordered slices of QueryDimension ensure deterministic query construction. +type QueryDimension struct { + Dimension string `yaml:"dimension"` + Claim string `yaml:"claim"` +} + +// ScopePolicyConfig holds the per-scope issuance policy and OIDC request params. +type ScopePolicyConfig struct { + OIDCRequestParams *OIDCRequestParams + IssuancePolicy *IssuancePolicy +} + +// LookupScopePolicyConfig returns the issuance policy and OIDC request params +// for a credential scope across all data source types. Returns nil fields if none configured. +func (ds *DataSources) LookupScopePolicyConfig(scope string) *ScopePolicyConfig { + if ds == nil { + return nil + } + + if s, ok := ds.Assertion.Scopes[scope]; ok { + return &ScopePolicyConfig{ + OIDCRequestParams: s.OIDCRequestParams, + IssuancePolicy: s.IssuancePolicy, + } + } + + if s, ok := ds.Datastore.Scopes[scope]; ok { + return &ScopePolicyConfig{ + OIDCRequestParams: s.OIDCRequestParams, + IssuancePolicy: s.IssuancePolicy, + } + } + + if s, ok := ds.ExternalAPI.Scopes[scope]; ok { + return &ScopePolicyConfig{ + OIDCRequestParams: s.OIDCRequestParams, + IssuancePolicy: s.IssuancePolicy, + } + } + + return nil +} diff --git a/pkg/openid4vci/authoriziation.go b/pkg/openid4vci/authoriziation.go index 8d3ec9d4d..f141b3367 100644 --- a/pkg/openid4vci/authoriziation.go +++ b/pkg/openid4vci/authoriziation.go @@ -46,6 +46,11 @@ type PARRequest struct { WalletIssuer string `json:"wallet_issuer" form:"wallet_issuer"` UserHint string `json:"user_hint" form:"user_hint"` IssuingState string `json:"issuing_state" form:"issuing_state"` + + // DynamicParams holds key-value parameters from the authentic source business system. + // These are used for template substitution in OIDC request parameters and for + // issuance policy evaluation. + DynamicParams map[string]string `json:"dynamic_params,omitempty" form:"dynamic_params"` } type ParResponse struct { diff --git a/pkg/spocputil/parser.go b/pkg/spocputil/parser.go new file mode 100644 index 000000000..e70835027 --- /dev/null +++ b/pkg/spocputil/parser.go @@ -0,0 +1,238 @@ +// Package spocputil provides shared utilities for working with SPOCP +// S-expressions, including parsing human-readable "advanced form" into +// sexp.Element trees and loading rules from files. +package spocputil + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "unicode" + + spocp "github.com/sirosfoundation/go-spocp" + "github.com/sirosfoundation/go-spocp/pkg/sexp" + "github.com/sirosfoundation/go-spocp/pkg/starform" +) + +// ParseAdvancedSExp parses a human-readable ("advanced form") S-expression into +// a sexp.Element. This allows users to write rules as: +// +// (credential (scope org_cred)(acr urn:example:loa3)(email_verified true)) +// +// instead of canonical form: +// +// (10:credential(5:scope8:org_cred)(3:acr19:urn:example:loa3)(14:email_verified4:true)) +// +// It also supports star forms: +// +// (*) → wildcard +// (* prefix urn:example:) → prefix match +// (* suffix @example.com) → suffix match +// (* set loa3 loa4) → set match +func ParseAdvancedSExp(input string) (sexp.Element, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, fmt.Errorf("empty S-expression") + } + + p := &advancedParser{input: []rune(input), pos: 0} + elem, err := p.parse() + if err != nil { + return nil, err + } + + // Ensure we consumed all input. + p.skipWhitespace() + if p.pos < len(p.input) { + return nil, fmt.Errorf("unexpected trailing input at position %d", p.pos) + } + return elem, nil +} + +// LoadRulesFromFile reads human-readable SPOCP rules (one per line) from a file +// and adds them to the engine using ParseAdvancedSExp. +func LoadRulesFromFile(engine *spocp.AdaptiveEngine, path string) error { + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue // skip blanks and comments + } + elem, err := ParseAdvancedSExp(line) + if err != nil { + return fmt.Errorf("line %d: %w", lineNum, err) + } + engine.AddRuleElement(elem) + } + return scanner.Err() +} + +type advancedParser struct { + input []rune + pos int +} + +func (p *advancedParser) skipWhitespace() { + for p.pos < len(p.input) && unicode.IsSpace(p.input[p.pos]) { + p.pos++ + } +} + +func (p *advancedParser) parse() (sexp.Element, error) { + p.skipWhitespace() + if p.pos >= len(p.input) { + return nil, fmt.Errorf("unexpected end of input") + } + + if p.input[p.pos] == '(' { + return p.parseList() + } + return p.parseAtom() +} + +func (p *advancedParser) parseAtom() (*sexp.Atom, error) { + p.skipWhitespace() + if p.pos >= len(p.input) { + return nil, fmt.Errorf("unexpected end of input, expected atom") + } + + start := p.pos + for p.pos < len(p.input) && p.input[p.pos] != '(' && p.input[p.pos] != ')' && !unicode.IsSpace(p.input[p.pos]) { + p.pos++ + } + if p.pos == start { + return nil, fmt.Errorf("empty atom at position %d", start) + } + return sexp.NewAtom(string(p.input[start:p.pos])), nil +} + +func (p *advancedParser) parseList() (sexp.Element, error) { + if p.input[p.pos] != '(' { + return nil, fmt.Errorf("expected '(' at position %d", p.pos) + } + p.pos++ // skip '(' + + p.skipWhitespace() + if p.pos >= len(p.input) { + return nil, fmt.Errorf("unclosed '('") + } + + // Parse the tag atom. + tag, err := p.parseAtom() + if err != nil { + return nil, fmt.Errorf("failed to parse list tag: %w", err) + } + + // Handle star forms: tag == "*" + if tag.Value == "*" { + return p.parseStarForm() + } + + // Parse remaining elements until ')'. + var elements []sexp.Element + for { + p.skipWhitespace() + if p.pos >= len(p.input) { + return nil, fmt.Errorf("unclosed list '%s'", tag.Value) + } + if p.input[p.pos] == ')' { + p.pos++ // skip ')' + return sexp.NewList(tag.Value, elements...), nil + } + elem, err := p.parse() + if err != nil { + return nil, err + } + // Treat a bare * inside a tagged list as a wildcard star form, + // so (method *) is equivalent to (method (*)). + // Treat a trailing * (e.g. /api/v1/*) as a prefix star form, + // so (path /api/v1/*) is equivalent to (path (* prefix /api/v1/)). + if atom, ok := elem.(*sexp.Atom); ok { + if atom.Value == "*" { + elem = &starform.Wildcard{} + } else if strings.HasSuffix(atom.Value, "*") { + elem = &starform.Prefix{Value: strings.TrimSuffix(atom.Value, "*")} + } + } + elements = append(elements, elem) + } +} + +func (p *advancedParser) parseStarForm() (sexp.Element, error) { + p.skipWhitespace() + if p.pos >= len(p.input) { + return nil, fmt.Errorf("unclosed star form") + } + + // (*) → wildcard + if p.input[p.pos] == ')' { + p.pos++ + return &starform.Wildcard{}, nil + } + + // Read the star form type: set, prefix, suffix, range... + formType, err := p.parseAtom() + if err != nil { + return nil, fmt.Errorf("failed to parse star form type: %w", err) + } + + switch formType.Value { + case "prefix": + return p.parsePrefixSuffix(true) + case "suffix": + return p.parsePrefixSuffix(false) + case "set": + return p.parseSet() + default: + return nil, fmt.Errorf("unsupported star form type %q", formType.Value) + } +} + +func (p *advancedParser) parsePrefixSuffix(isPrefix bool) (sexp.Element, error) { + p.skipWhitespace() + value, err := p.parseAtom() + if err != nil { + return nil, fmt.Errorf("expected value for prefix/suffix star form: %w", err) + } + p.skipWhitespace() + if p.pos >= len(p.input) || p.input[p.pos] != ')' { + return nil, fmt.Errorf("expected ')' to close prefix/suffix star form") + } + p.pos++ + if isPrefix { + return &starform.Prefix{Value: value.Value}, nil + } + return &starform.Suffix{Value: value.Value}, nil +} + +func (p *advancedParser) parseSet() (sexp.Element, error) { + var elements []sexp.Element + for { + p.skipWhitespace() + if p.pos >= len(p.input) { + return nil, fmt.Errorf("unclosed set star form") + } + if p.input[p.pos] == ')' { + p.pos++ + if len(elements) == 0 { + return nil, fmt.Errorf("empty set star form") + } + return &starform.Set{Elements: elements}, nil + } + elem, err := p.parse() + if err != nil { + return nil, err + } + elements = append(elements, elem) + } +} diff --git a/pkg/spocputil/parser_test.go b/pkg/spocputil/parser_test.go new file mode 100644 index 000000000..e90783bae --- /dev/null +++ b/pkg/spocputil/parser_test.go @@ -0,0 +1,93 @@ +package spocputil + +import ( + "testing" + + "github.com/sirosfoundation/go-spocp/pkg/sexp" + "github.com/sirosfoundation/go-spocp/pkg/starform" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAdvancedSExp_SimpleList(t *testing.T) { + elem, err := ParseAdvancedSExp("(credential (scope pid)(acr loa3))") + require.NoError(t, err) + + list, ok := elem.(*sexp.List) + require.True(t, ok) + assert.Equal(t, "credential", list.Tag) + assert.Len(t, list.Elements, 2) +} + +func TestParseAdvancedSExp_EmptySublist(t *testing.T) { + elem, err := ParseAdvancedSExp("(credential (scope pid)(org_id))") + require.NoError(t, err) + + list := elem.(*sexp.List) + assert.Len(t, list.Elements, 2) + + // org_id should be a list with no child elements (wildcard dimension) + orgIDList := list.Elements[1].(*sexp.List) + assert.Equal(t, "org_id", orgIDList.Tag) + assert.Len(t, orgIDList.Elements, 0) +} + +func TestParseAdvancedSExp_Wildcard(t *testing.T) { + elem, err := ParseAdvancedSExp("(credential (scope pid)(acr (*)))") + require.NoError(t, err) + + list := elem.(*sexp.List) + acrList := list.Elements[1].(*sexp.List) + _, ok := acrList.Elements[0].(*starform.Wildcard) + assert.True(t, ok) +} + +func TestParseAdvancedSExp_Prefix(t *testing.T) { + elem, err := ParseAdvancedSExp("(credential (acr (* prefix urn:example:)))") + require.NoError(t, err) + + list := elem.(*sexp.List) + acrList := list.Elements[0].(*sexp.List) + prefix, ok := acrList.Elements[0].(*starform.Prefix) + require.True(t, ok) + assert.Equal(t, "urn:example:", prefix.Value) +} + +func TestParseAdvancedSExp_Suffix(t *testing.T) { + elem, err := ParseAdvancedSExp("(credential (email (* suffix @example.com)))") + require.NoError(t, err) + + list := elem.(*sexp.List) + emailList := list.Elements[0].(*sexp.List) + suffix, ok := emailList.Elements[0].(*starform.Suffix) + require.True(t, ok) + assert.Equal(t, "@example.com", suffix.Value) +} + +func TestParseAdvancedSExp_Set(t *testing.T) { + elem, err := ParseAdvancedSExp("(credential (acr (* set loa3 loa4 loa5)))") + require.NoError(t, err) + + list := elem.(*sexp.List) + acrList := list.Elements[0].(*sexp.List) + set, ok := acrList.Elements[0].(*starform.Set) + require.True(t, ok) + assert.Len(t, set.Elements, 3) +} + +func TestParseAdvancedSExp_Empty(t *testing.T) { + _, err := ParseAdvancedSExp("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty S-expression") +} + +func TestParseAdvancedSExp_Unclosed(t *testing.T) { + _, err := ParseAdvancedSExp("(credential (scope pid)") + assert.Error(t, err) +} + +func TestParseAdvancedSExp_TrailingInput(t *testing.T) { + _, err := ParseAdvancedSExp("(credential) extra") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected trailing input") +}