From bd68cda6b7b93c63ec9050cb9b7967f826b86e7e Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 25 Nov 2025 15:55:13 +0100 Subject: [PATCH 1/4] docs: update Priority 11 with detailed OIDC RP architecture --- ROADMAP.md | 171 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 141 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e73cf194..77045f1d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,17 +29,19 @@ Improve error message quality and structure for better developer experience. **Benefits**: Faster integration for API clients, reduced support burden, better debugging. -## Priority 3: VCTM Caching Optimization +## Priority 3: Configuration Management Improvements -Optimize VCTM retrieval and caching for improved performance. +Enhance configuration flexibility and operational convenience. -- Implement in-memory cache for VCTM metadata -- Add cache invalidation strategies (TTL, manual refresh) -- Support cache warming on startup -- Add metrics for cache hit/miss rates -- Consider distributed cache for multi-instance deployments +- Support hot-reload of VCTM files without service restart +- Add configuration validation on startup with detailed error reporting +- Support environment-specific configuration overrides +- Add configuration versioning and change tracking +- Implement configuration validation endpoint for pre-deployment checks +- Improve error messages for configuration issues +- Add admin endpoint to refresh configuration without restart -**Benefits**: Reduced latency for credential issuance, lower load on metadata storage. +**Benefits**: Faster deployments, reduced downtime, better developer experience, easier troubleshooting. ## Priority 4: Batch Credential Issuance @@ -133,34 +135,143 @@ Implement support for the W3C Digital Credentials API in the verifier component. - [W3C Digital Credentials API](https://wicg.github.io/digital-credentials/) - Related to OpenID4VP browser flows -## Priority 11: OpenID Authentication Flow for Credential Issuance - -Implement support for issuing credentials based on OpenID Connect authentication flows, analogous to the existing SAML authentication integration. - -- Design OpenID Connect integration architecture -- Implement OIDC authentication handler -- Create claim transformation mappings (similar to SAML ClaimTransformer) -- Support standard OIDC claims mapping to VCTM -- Add YAML-based configuration for OIDC providers -- Support multiple OIDC identity providers -- Implement session management and callback handling -- Add comprehensive testing including mock OIDC provider - -**Benefits**: Broader identity provider compatibility, modern authentication protocol support, flexibility for different deployment scenarios. - -**Design Considerations**: -- Reuse patterns from existing SAML integration -- Protocol-agnostic claim transformation layer -- Configuration-driven provider setup -- Similar to `endpoints_saml.go` but for OIDC +## Priority 11: OpenID Connect Relying Party for Credential Issuance + +Implement OIDC Relying Party (RP) functionality to issue credentials based on OpenID Connect authentication flows, analogous to the existing SAML Service Provider integration. + +### Architecture Overview + +The implementation will add OIDC RP capabilities in `pkg/oidcrp/` and `internal/apigw/httpserver/`, following the same patterns established by the SAML integration. This allows issuing credentials based on authentication against external OIDC Providers (Google, Azure AD, Keycloak, etc.). + +### Key Components + +**1. Configuration Structure** (`pkg/model/config.go`): +```yaml +apigw: + oidcrp: + enabled: true + client_id: "my-client-id" + client_secret: "my-client-secret" + redirect_uri: "https://issuer.example.com/oidcrp/callback" + issuer_url: "https://accounts.google.com" # For OIDC Discovery + scopes: ["openid", "profile", "email"] + session_duration: 3600 + + # Reuse existing CredentialMapping structure (protocol-agnostic) + credential_mappings: + pid: + credential_config_id: "urn:eudi:pid:1" + attributes: + sub: {claim: "identity.unique_id", required: true} + given_name: {claim: "identity.given_name", required: true} + family_name: {claim: "identity.family_name", required: true} + email: {claim: "identity.email", required: false} +``` + +**2. OIDC RP Service** (`pkg/oidcrp/service.go`): +- OIDC Provider discovery (`.well-known/openid-configuration`) +- OAuth2 authorization code flow with PKCE +- ID token verification and claim extraction +- Session management (state, nonce, code_verifier) +- UserInfo endpoint support for additional claims + +**3. API Endpoints** (`internal/apigw/httpserver/`): +- `POST /oidcrp/initiate` - Start OIDC authentication +- `GET /oidcrp/callback` - Handle OIDC provider callback + +**4. Authentication Flow**: +1. Client calls `/oidcrp/initiate` with credential_type +2. Service generates OAuth2 authorization URL with PKCE +3. User authenticates at OIDC Provider +4. Provider redirects to `/oidcrp/callback` with authorization code +5. Service exchanges code for tokens, verifies ID token +6. Claims extracted from ID token (+ UserInfo if needed) +7. Claims transformed using existing `ClaimTransformer` (protocol-agnostic) +8. Credential issued via issuer gRPC +9. Credential + offer returned to client + +### Reusable Components + +**Already Protocol-Agnostic** (shared with SAML): +- ✅ `AttributeConfig` - Claim mapping configuration +- ✅ `CredentialMapping` - Credential type configuration +- ✅ `ClaimTransformer` - Claim transformation logic (supports dot-notation paths, transforms, defaults) + +**OIDC-Specific** (new): +- OIDC Provider discovery and metadata caching +- OAuth2 code flow with PKCE implementation +- JWT/JWK verification using standard libraries +- Session store for OAuth2 state/nonce + +### Implementation Phases + +**Phase 1 - Core OIDC RP**: +- `pkg/oidcrp/` package structure +- Service initialization with OIDC Discovery +- Session management (state, nonce, PKCE) +- Authorization code flow implementation +- APIGW route registration with build tags + +**Phase 2 - Claim Transformation**: +- Reuse `pkg/saml/transformer.go` (already supports OIDC claims) +- Configuration-driven mappings +- Support UserInfo endpoint for extended claims +- Handle nested claim paths + +**Phase 3 - Credential Issuance**: +- Integration with issuer gRPC service +- Credential offer generation (OpenID4VCI) +- Error handling and validation +- Session cleanup + +**Phase 4 - Production Features**: +- Multiple OIDC Provider support +- Dynamic Client Registration (RFC 7591) support +- Comprehensive testing with real providers +- Documentation and examples + +### Dependencies + +Standard Go libraries for OIDC: +- `github.com/coreos/go-oidc/v3/oidc` - Provider discovery, token verification +- `golang.org/x/oauth2` - OAuth2 flows + +### Key Differences from SAML + +| Aspect | SAML | OIDC RP | +|--------|------|---------| +| Metadata | XML (SP/IdP metadata) | JSON (OIDC Discovery) | +| Cryptography | X.509 certificates | JWK, JWT signatures | +| Protocol Flow | SAML AuthnRequest → Assertion | OAuth2 code flow → ID Token | +| Attributes | SAML Attributes (OIDs) | OIDC Claims (JSON) | +| Session Security | RelayState | OAuth2 state + nonce + PKCE | +| Provider Discovery | MDQ or static metadata | `.well-known/openid-configuration` | + +### Benefits + +- **Broader Compatibility**: Support Google, Microsoft, Keycloak, Auth0, etc. +- **Modern Protocol**: Industry-standard OAuth2/OIDC flows +- **Reusable Architecture**: Shares claim transformation with SAML +- **Flexible Deployment**: Discovery or static configuration +- **Secure**: PKCE, nonce, standard JWT verification +- **Optional Build**: Compile-time flag like SAML (`-tags=oidcrp`) + +### Design Considerations + +- Reuse `ClaimTransformer` from SAML (already protocol-agnostic) +- Follow same configuration patterns as `SAMLConfig` +- Use build tags for optional compilation +- Mirror endpoint structure (`endpoints_saml.go` → `endpoints_oidcrp.go`) +- Support both OIDC Discovery and static provider configuration +- Implement standard OAuth2 security (PKCE, state, nonce) --- ## Timeline and Sequencing **Phase 1 - Foundation** (Priorities 1-3): -- Core quality and performance improvements -- Better error handling and caching +- Core quality improvements +- Better error handling and configuration management **Phase 2 - Feature Expansion** (Priorities 4-5, 10-11): - Batch operations and lifecycle management From 369795a29ec16e44fbd757992fd18db7d8af974a Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 25 Nov 2025 21:27:19 +0100 Subject: [PATCH 2/4] feat: Add W3C Digital Credentials API support with multi-format credentials - Add configuration model for Digital Credentials API and CSS customization - Implement browser-side JavaScript client for navigator.credentials.get() - Create enhanced authorization page with configurable themes and QR fallback - Add JAR (JWT Authorization Request) support with format negotiation - Support multiple credential formats: vc+sd-jwt, dc+sd-jwt, mso_mdoc - Update direct_post endpoint to handle both form-encoded and JSON responses - Add format preference negotiation in request objects - Include comprehensive documentation and example configuration This implementation allows browser-based credential presentation while maintaining backward compatibility with QR code flows. Relying Parties continue using standard OIDC with zero code changes. --- config.digital-credentials-example.yaml | 90 +++ docs/DIGITAL_CREDENTIALS_API.md | 312 ++++++++++ internal/verifier_proxy/apiv1/handler_api.go | 51 +- internal/verifier_proxy/apiv1/handler_oidc.go | 66 ++- .../verifier_proxy/apiv1/handler_openid4vp.go | 50 +- .../httpserver/endpoints_oidc.go | 34 +- .../httpserver/endpoints_openid4vp.go | 25 +- .../httpserver/static/authorize_enhanced.html | 539 ++++++++++++++++++ .../httpserver/static/digital-credentials.js | 317 ++++++++++ pkg/model/config.go | 66 ++- 10 files changed, 1511 insertions(+), 39 deletions(-) create mode 100644 config.digital-credentials-example.yaml create mode 100644 docs/DIGITAL_CREDENTIALS_API.md create mode 100644 internal/verifier_proxy/httpserver/static/authorize_enhanced.html create mode 100644 internal/verifier_proxy/httpserver/static/digital-credentials.js diff --git a/config.digital-credentials-example.yaml b/config.digital-credentials-example.yaml new file mode 100644 index 00000000..855d794f --- /dev/null +++ b/config.digital-credentials-example.yaml @@ -0,0 +1,90 @@ +# Example configuration for W3C Digital Credentials API support in verifier-proxy +# This file demonstrates how to enable browser-based credential presentation +# alongside traditional QR code wallet flows + +verifier_proxy: + external_url: "https://verifier.example.com" + + api_server: + address: ":8080" + tls_enabled: false + + oidc: + issuer: "https://verifier.example.com" + session_duration: 600 # 10 minutes + code_duration: 300 # 5 minutes + token_duration: 3600 # 1 hour + + openid4vp: + supported_credentials: + - vct: "https://credentials.example.com/1.0/EmployeeCredential" + scopes: + - "employee_id" + claims: + - "employee_number" + - "department" + - "email" + + # W3C Digital Credentials API Configuration + digital_credentials: + # Enable browser-based credential presentation + enabled: true + + # Use JWT Authorization Request (JAR) for security + # Signed JWT sent to wallet instead of plain JSON + use_jar: true + + # Preferred credential formats (in order of preference) + # Supported formats: vc+sd-jwt, dc+sd-jwt, mso_mdoc + preferred_formats: + - "vc+sd-jwt" # SD-JWT Verifiable Credentials + - "dc+sd-jwt" # Digital Credentials SD-JWT variant + - "mso_mdoc" # ISO/IEC 18013-5 mdoc format + + # Response mode for Digital Credentials API + # Options: dc_api.jwt (encrypted), direct_post.jwt (signed), direct_post (plain) + response_mode: "dc_api.jwt" + + # Allow automatic fallback to QR code if browser doesn't support DC API + allow_qr_fallback: true + + # Deep link scheme for mobile wallet integration + # Used to open mobile wallet apps from the browser + deep_link_scheme: "openid4vp://" + + # Authorization Page CSS Customization + authorization_page_css: + # Page title (shown in browser tab and page header) + title: "Employee Verification" + + # Subtitle text (shown below title) + subtitle: "Present your employee credential to access this service" + + # Theme preset: light, dark, blue, purple + theme: "blue" + + # Primary color (hex format) - overrides theme default + primary_color: "#1e40af" + + # Secondary color (hex format) - overrides theme default + secondary_color: "#1e3a8a" + + # Custom logo URL (optional) + logo_url: "https://example.com/images/company-logo.png" + + # Inline custom CSS (optional) + # Use this for quick styling tweaks + custom_css: | + .container { + max-width: 600px; + } + .btn { + font-size: 18px; + } + + # External CSS file path (optional) + # Use this for complete custom styling + # css_file: "/custom/styles/verifier-theme.css" + +# Additional configuration sections... +# (database, logging, etc. - omitted for brevity) diff --git a/docs/DIGITAL_CREDENTIALS_API.md b/docs/DIGITAL_CREDENTIALS_API.md new file mode 100644 index 00000000..1e9bb3a9 --- /dev/null +++ b/docs/DIGITAL_CREDENTIALS_API.md @@ -0,0 +1,312 @@ +# W3C Digital Credentials API Integration + +## Overview + +The verifier-proxy now supports the **W3C Digital Credentials API** for browser-based credential presentation. This modern approach allows users to present digital credentials directly from their browser's built-in wallet, providing a seamless user experience without requiring QR code scanning. + +## What is the W3C Digital Credentials API? + +The [W3C Digital Credentials API](https://wicg.github.io/digital-credentials/) is a browser API that enables web applications to request verifiable credentials from digital wallets. It provides: + +- **Native browser integration** - No separate wallet app required +- **Improved UX** - Present credentials with a single click +- **Strong security** - Browser-mediated credential exchange +- **Format flexibility** - Supports SD-JWT and mdoc formats + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ +│ Relying Party │ │ Verifier-Proxy │ +│ (Your App) │ │ (This Service) │ +└────────┬────────┘ └────────┬─────────┘ + │ │ + │ 1. Standard OIDC authorize │ + │─────────────────────────────────────>│ + │ │ + │ 2. Render authorize.html │ + │<─────────────────────────────────────│ + │ │ + ┌────▼────────────────────────────────┐ │ + │ Browser (User's Device) │ │ + │ │ │ + │ 3. navigator.credentials.get() │ │ + │ ┌──────────────────────────┐ │ │ + │ │ Built-in Wallet │ │ │ + │ │ (Browser Credentials) │ │ │ + │ └────────────┬─────────────┘ │ │ + │ │ 4. User approves │ │ + │ └──────┐ │ │ + │ │ │ │ + │ 5. POST vp_token to direct_post │ │ + │─────────────────────────────────────────>│ + │ │ │ + │ 6. Redirect with auth code │ │ + │<─────────────────────────────────────────│ + └─────────────────────────────────────┘ │ + │ │ + │ 7. Exchange code for tokens │ + │─────────────────────────────────────>│ + │ │ + │ 8. ID token with verified claims │ + │<─────────────────────────────────────│ + │ │ +``` + +### Key Benefits for RPs + +1. **Zero changes required** - RPs use standard OIDC authorization code flow +2. **No wallet knowledge needed** - Verifier-proxy handles all wallet interactions +3. **Automatic fallback** - QR code flow works when DC API is unavailable +4. **Standard claims** - Receive verified claims in standard OIDC ID tokens + +## Configuration + +Enable the W3C Digital Credentials API in your `config.yaml`: + +```yaml +verifier_proxy: + digital_credentials: + enabled: true + use_jar: true + preferred_formats: + - "vc+sd-jwt" + - "dc+sd-jwt" + - "mso_mdoc" + response_mode: "dc_api.jwt" + allow_qr_fallback: true + deep_link_scheme: "openid4vp://" +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | boolean | `false` | Enable W3C Digital Credentials API support | +| `use_jar` | boolean | `false` | Use JWT Authorization Request (JAR) for security | +| `preferred_formats` | array | `["vc+sd-jwt"]` | Credential formats in preference order | +| `response_mode` | string | `"direct_post"` | How wallet sends response: `dc_api.jwt`, `direct_post.jwt`, `direct_post` | +| `allow_qr_fallback` | boolean | `true` | Auto-fallback to QR code if DC API unavailable | +| `deep_link_scheme` | string | `"openid4vp://"` | Deep link scheme for mobile wallets | + +### Supported Formats + +- **`vc+sd-jwt`** - SD-JWT Verifiable Credentials (W3C standard) +- **`dc+sd-jwt`** - Digital Credentials SD-JWT variant +- **`mso_mdoc`** - ISO/IEC 18013-5 mobile driving license format + +## UI Customization + +Customize the authorization page appearance: + +```yaml +verifier_proxy: + authorization_page_css: + title: "Employee Verification" + subtitle: "Present your employee credential to access this service" + theme: "blue" # light, dark, blue, purple + primary_color: "#1e40af" # Override theme color + secondary_color: "#1e3a8a" + logo_url: "https://example.com/logo.png" + custom_css: | + .container { max-width: 600px; } + # css_file: "/path/to/custom.css" +``` + +### Theming Options + +| Theme | Primary Color | Secondary Color | Best For | +|-------|--------------|-----------------|----------| +| `light` | `#3182ce` | `#2c5282` | Default, general purpose | +| `dark` | `#3182ce` | `#2c5282` | Dark mode preference | +| `blue` | `#3182ce` | `#2c5282` | Corporate, professional | +| `purple` | `#805ad5` | `#553c9a` | Creative, modern | + +## Browser Support + +The W3C Digital Credentials API is currently supported in: + +- **Chrome/Edge 116+** (with experimental flag) +- **Safari 17+** (partial support) +- **Firefox** (planned) + +**Graceful Degradation**: When the API is not available, the authorization page automatically shows the QR code as a fallback. No user interaction is broken. + +## Security Considerations + +### JWT Authorization Request (JAR) + +When `use_jar: true`, the verifier-proxy: + +1. Creates a signed JWT containing the authorization request +2. Wallet validates the signature before processing +3. Prevents parameter tampering and injection attacks + +**Recommendation**: Always enable JAR in production environments. + +### Response Modes + +| Mode | Security | Use Case | +|------|----------|----------| +| `dc_api.jwt` | Encrypted JWT | Maximum security, browser-based wallets | +| `direct_post.jwt` | Signed JWT | Moderate security, standard wallets | +| `direct_post` | Plain form | Legacy support, testing only | + +**Recommendation**: Use `dc_api.jwt` for production deployments with DC API. + +## Format Negotiation + +The verifier-proxy requests credentials in **order of preference**: + +```yaml +preferred_formats: + - "vc+sd-jwt" # Try this first + - "dc+sd-jwt" # Fallback to this + - "mso_mdoc" # Last resort +``` + +The wallet selects the **first supported format** and returns credentials accordingly. The verifier-proxy automatically extracts claims from any format. + +### Format-Specific Processing + +- **SD-JWT formats** (`vc+sd-jwt`, `dc+sd-jwt`): Claims extracted from disclosed fields +- **mdoc format** (`mso_mdoc`): Claims extracted from ISO 18013-5 data elements + +## Relying Party Integration + +### Standard OIDC Flow (No Changes) + +```javascript +// Your RP code remains unchanged +window.location = 'https://verifier.example.com/authorize?' + + 'response_type=code&' + + 'client_id=your_client_id&' + + 'redirect_uri=https://your-app.com/callback&' + + 'scope=openid employee_id&' + + 'state=xyz123'; + +// User presents credential via DC API or QR code +// Your callback receives the authorization code + +// Exchange code for tokens +const tokenResponse = await fetch('https://verifier.example.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authorizationCode, + redirect_uri: 'https://your-app.com/callback', + client_id: 'your_client_id', + client_secret: 'your_secret' + }) +}); + +const { id_token } = await tokenResponse.json(); +// id_token contains verified claims from the credential +``` + +### What Happens Behind the Scenes + +1. **Browser Detection**: Page checks if `navigator.credentials` API exists +2. **Format Selection**: Sends `vp_formats` preference to wallet +3. **User Consent**: Browser shows native credential selection UI +4. **Credential Presentation**: Wallet returns VP token to verifier-proxy +5. **Claim Extraction**: Verifier-proxy maps credential claims to OIDC claims +6. **Code Issuance**: Returns standard OIDC authorization code to RP + +## Testing + +### Local Development + +1. **Enable Chrome Experimental Features**: + - Navigate to `chrome://flags` + - Enable "Web Authentication API for Digital Credentials" + - Restart browser + +2. **Configure Test Credentials**: + ```yaml + verifier_proxy: + digital_credentials: + enabled: true + use_jar: false # Easier debugging + response_mode: "direct_post" # Simpler for testing + preferred_formats: ["vc+sd-jwt"] + ``` + +3. **Test with QR Fallback**: + - Open authorization page in non-supporting browser + - Verify QR code appears automatically + - Scan with mobile wallet app + +### Integration Testing + +The verifier-proxy includes automated tests for: + +- DC API request object generation +- Format negotiation (mdoc vs SD-JWT) +- Response handling (encrypted JWT and form-encoded) +- Graceful fallback scenarios + +Run tests: +```bash +make test +``` + +## Troubleshooting + +### Issue: DC API Button Not Showing + +**Check**: +1. `digital_credentials.enabled: true` in config +2. Browser supports `navigator.credentials.get()` +3. HTTPS enabled (required for secure contexts) + +**Solution**: Enable experimental flags or use QR fallback + +### Issue: "NotSupportedError" from Wallet + +**Cause**: Wallet doesn't support requested credential format + +**Solution**: Add more formats to `preferred_formats`: +```yaml +preferred_formats: + - "vc+sd-jwt" + - "dc+sd-jwt" + - "mso_mdoc" # Add this for broader compatibility +``` + +### Issue: Encrypted Response Failing + +**Note**: Encrypted response (`dc_api.jwt`) decryption is **not yet implemented**. Use `direct_post` mode for now: + +```yaml +response_mode: "direct_post" +``` + +**Planned**: Full JARM decryption support in future release. + +## Roadmap + +- [x] Basic DC API integration +- [x] Multi-format support (SD-JWT, mdoc) +- [x] JAR (signed request objects) +- [x] CSS customization +- [ ] Encrypted response decryption (JARM) +- [ ] Enhanced format-specific claim mapping +- [ ] mdoc device signature validation +- [ ] Browser wallet API v2 support + +## References + +- [W3C Digital Credentials API Specification](https://wicg.github.io/digital-credentials/) +- [OpenID4VP Specification](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) +- [SD-JWT Specification](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt) +- [ISO 18013-5 mdoc](https://www.iso.org/standard/69084.html) +- [JWT Authorization Request (JAR)](https://datatracker.ietf.org/doc/html/rfc9101) + +## Support + +For questions or issues: +- Open an issue on GitHub +- Check existing documentation in `/docs` +- Review example configuration in `config.digital-credentials-example.yaml` diff --git a/internal/verifier_proxy/apiv1/handler_api.go b/internal/verifier_proxy/apiv1/handler_api.go index 62b07fd2..63b91590 100644 --- a/internal/verifier_proxy/apiv1/handler_api.go +++ b/internal/verifier_proxy/apiv1/handler_api.go @@ -30,8 +30,9 @@ type GetRequestObjectResponse struct { // DirectPostRequest represents a direct_post callback from a wallet type DirectPostRequest struct { State string `form:"state" binding:"required"` - VPToken string `form:"vp_token" binding:"required"` - PresentationSubmission string `form:"presentation_submission"` + VPToken string `form:"vp_token"` // For standard direct_post + PresentationSubmission string `form:"presentation_submission"` // For standard direct_post + Response string `form:"response"` // For DC API encrypted JWT response } // DirectPostResponse contains the response to a direct_post request @@ -227,11 +228,42 @@ func (c *Client) ProcessDirectPost(ctx context.Context, req *DirectPostRequest) return nil, ErrSessionNotFound } + var vpToken string + var presentationSubmission any + + // Check if this is a DC API response (encrypted JWT) or standard form-encoded + if req.Response != "" { + // DC API response - decrypt and extract vp_token + // TODO: Implement JWT decryption using OIDC keys + // For now, assume the response JWT contains vp_token in its payload + c.log.Debug("Processing DC API encrypted response", "state", req.State) + + // Placeholder: In production, decrypt the JWT and extract vp_token + // For now, treat response as the vp_token (this will be replaced with proper decryption) + vpToken = req.Response + + c.log.Info("DC API response decryption not yet implemented, treating response as vp_token") + } else if req.VPToken != "" { + // Standard direct_post with form-encoded parameters + vpToken = req.VPToken + + // Parse presentation submission if provided + if req.PresentationSubmission != "" { + if err := json.Unmarshal([]byte(req.PresentationSubmission), &presentationSubmission); err != nil { + c.log.Error(err, "Failed to parse presentation submission") + // Continue anyway - presentation submission is optional + } + } + } else { + c.log.Error(nil, "Neither vp_token nor response parameter provided") + return nil, ErrInvalidRequest + } + // Validate and parse VP token using sdjwt3 - c.log.Debug("Processing VP token", "state", req.State, "vp_token_length", len(req.VPToken)) + c.log.Debug("Processing VP token", "state", req.State, "vp_token_length", len(vpToken)) // Extract and map claims from VP token - oidcClaims, err := c.extractAndMapClaims(ctx, req.VPToken, session.OIDCRequest.Scope) + oidcClaims, err := c.extractAndMapClaims(ctx, vpToken, session.OIDCRequest.Scope) if err != nil { c.log.Error(err, "Failed to extract and map claims from VP token") return nil, ErrInvalidVP @@ -243,17 +275,8 @@ func (c *Client) ProcessDirectPost(ctx context.Context, req *DirectPostRequest) // TODO: Retrieve public key from wallet metadata or cnf claim // For now, we accept the parsed claims (signature validation would go here) - // Parse presentation submission if provided - var presentationSubmission any - if req.PresentationSubmission != "" { - if err := json.Unmarshal([]byte(req.PresentationSubmission), &presentationSubmission); err != nil { - c.log.Error(err, "Failed to parse presentation submission") - // Continue anyway - presentation submission is optional - } - } - // Update session with VP data - session.OpenID4VP.VPToken = req.VPToken + session.OpenID4VP.VPToken = vpToken session.OpenID4VP.PresentationSubmission = presentationSubmission session.VerifiedClaims = oidcClaims // Store mapped OIDC claims session.Status = db.SessionStatusCodeIssued diff --git a/internal/verifier_proxy/apiv1/handler_oidc.go b/internal/verifier_proxy/apiv1/handler_oidc.go index 30edc53e..0e5d585f 100644 --- a/internal/verifier_proxy/apiv1/handler_oidc.go +++ b/internal/verifier_proxy/apiv1/handler_oidc.go @@ -35,11 +35,22 @@ type AuthorizeRequest struct { // AuthorizeResponse represents the response to an authorization request type AuthorizeResponse struct { - SessionID string `json:"session_id"` - QRCodeData string `json:"qr_code_data"` - QRCodeImageURL string `json:"qr_code_image_url"` - DeepLinkURL string `json:"deep_link_url"` - PollURL string `json:"poll_url"` + SessionID string `json:"session_id"` + QRCodeData string `json:"qr_code_data"` + QRCodeImageURL string `json:"qr_code_image_url"` + DeepLinkURL string `json:"deep_link_url"` + PollURL string `json:"poll_url"` + PreferredFormats []string `json:"preferred_formats"` + UseJAR bool `json:"use_jar"` + ResponseMode string `json:"response_mode"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + PrimaryColor string `json:"primary_color"` + SecondaryColor string `json:"secondary_color"` + Theme string `json:"theme"` + CustomCSS string `json:"custom_css"` + CSSFile string `json:"css_file"` + LogoURL string `json:"logo_url"` } // Authorize handles the OIDC authorization request @@ -132,13 +143,54 @@ func (c *Client) Authorize(ctx context.Context, req *AuthorizeRequest) (*Authori sessionID, ) - return &AuthorizeResponse{ + // Build response with DC API configuration + response := &AuthorizeResponse{ SessionID: sessionID, QRCodeData: authzReqURL, QRCodeImageURL: fmt.Sprintf("/qr/%s", sessionID), DeepLinkURL: authzReqURL, PollURL: fmt.Sprintf("/poll/%s", sessionID), - }, nil + } + + // Add Digital Credentials API configuration + if c.cfg.VerifierProxy.DigitalCredentials.Enabled { + response.PreferredFormats = c.cfg.VerifierProxy.DigitalCredentials.PreferredFormats + response.UseJAR = c.cfg.VerifierProxy.DigitalCredentials.UseJAR + response.ResponseMode = c.cfg.VerifierProxy.DigitalCredentials.ResponseMode + } else { + // Defaults + response.PreferredFormats = []string{"vc+sd-jwt"} + response.UseJAR = false + response.ResponseMode = "direct_post" + } + + // Add CSS customization configuration + cssConfig := c.cfg.VerifierProxy.AuthorizationPageCSS + response.Title = cssConfig.Title + if response.Title == "" { + response.Title = "Credential Verification" + } + response.Subtitle = cssConfig.Subtitle + if response.Subtitle == "" { + response.Subtitle = "Please present your digital credential to continue" + } + response.PrimaryColor = cssConfig.PrimaryColor + if response.PrimaryColor == "" { + response.PrimaryColor = "#3182ce" + } + response.SecondaryColor = cssConfig.SecondaryColor + if response.SecondaryColor == "" { + response.SecondaryColor = "#2c5282" + } + response.Theme = cssConfig.Theme + if response.Theme == "" { + response.Theme = "light" + } + response.CustomCSS = cssConfig.CustomCSS + response.CSSFile = cssConfig.CSSFile + response.LogoURL = cssConfig.LogoURL + + return response, nil } // TokenRequest represents an OIDC token request diff --git a/internal/verifier_proxy/apiv1/handler_openid4vp.go b/internal/verifier_proxy/apiv1/handler_openid4vp.go index 857556a3..138db89f 100644 --- a/internal/verifier_proxy/apiv1/handler_openid4vp.go +++ b/internal/verifier_proxy/apiv1/handler_openid4vp.go @@ -13,6 +13,16 @@ import ( // CreateRequestObject creates and signs an OpenID4VP request object func (c *Client) CreateRequestObject(ctx context.Context, sessionID string, presentationDefinition any, nonce string) (string, error) { + // Determine response mode based on Digital Credentials API configuration + responseMode := "direct_post" + if c.cfg.VerifierProxy.DigitalCredentials.Enabled { + if c.cfg.VerifierProxy.DigitalCredentials.ResponseMode != "" { + responseMode = c.cfg.VerifierProxy.DigitalCredentials.ResponseMode + } else { + responseMode = "dc_api.jwt" // Default for DC API + } + } + // Create request object requestObject := &openid4vp.RequestObject{ ISS: c.cfg.VerifierProxy.OIDC.Issuer, @@ -21,12 +31,22 @@ func (c *Client) CreateRequestObject(ctx context.Context, sessionID string, pres ResponseType: "vp_token", ClientID: c.cfg.VerifierProxy.OIDC.Issuer, Nonce: nonce, - ResponseMode: "direct_post", + ResponseMode: responseMode, ResponseURI: c.cfg.VerifierProxy.ExternalURL + "/verification/direct_post", State: sessionID, PresentationDefinition: presentationDefinitionToPresentationDefinitionParameter(presentationDefinition), } + // Add vp_formats to client_metadata if Digital Credentials API is enabled + if c.cfg.VerifierProxy.DigitalCredentials.Enabled { + vpFormats := c.buildVPFormats() + if len(vpFormats) > 0 { + requestObject.ClientMetadata = &openid4vp.ClientMetadata{ + VPFormats: vpFormats, + } + } + } + // Sign the request object signedJWT, err := requestObject.Sign(jwt.SigningMethodRS256, c.oidcSigningKey, nil) if err != nil { @@ -40,6 +60,34 @@ func (c *Client) CreateRequestObject(ctx context.Context, sessionID string, pres return signedJWT, nil } +// buildVPFormats constructs the vp_formats object based on configured preferred formats +func (c *Client) buildVPFormats() map[string]map[string][]string { + vpFormats := make(map[string]map[string][]string) + + preferredFormats := c.cfg.VerifierProxy.DigitalCredentials.PreferredFormats + if len(preferredFormats) == 0 { + // Default to SD-JWT if no preferences specified + preferredFormats = []string{"vc+sd-jwt"} + } + + for _, format := range preferredFormats { + switch format { + case "vc+sd-jwt", "dc+sd-jwt": + // SD-JWT format with supported algorithms + vpFormats[format] = map[string][]string{ + "alg": {"ES256", "ES384", "ES512", "RS256"}, + } + case "mso_mdoc": + // mdoc format with supported algorithms + vpFormats["mso_mdoc"] = map[string][]string{ + "alg": {"ES256", "ES384", "ES512"}, + } + } + } + + return vpFormats +} + // createPresentationDefinition maps OIDC scopes to OpenID4VP presentation definition func (c *Client) createPresentationDefinition(scopes []string) (any, error) { // If presentation builder is configured, use template-based approach diff --git a/internal/verifier_proxy/httpserver/endpoints_oidc.go b/internal/verifier_proxy/httpserver/endpoints_oidc.go index 33fb71eb..f583c0c3 100644 --- a/internal/verifier_proxy/httpserver/endpoints_oidc.go +++ b/internal/verifier_proxy/httpserver/endpoints_oidc.go @@ -83,13 +83,33 @@ func (s *Service) endpointAuthorize(ctx context.Context, c *gin.Context) (any, e return nil, nil } - // Render authorization page with QR code - c.HTML(http.StatusOK, "authorize.html", gin.H{ - "SessionID": response.SessionID, - "QRCodeData": response.QRCodeData, - "DeepLinkURL": response.DeepLinkURL, - "PollURL": response.PollURL, - }) + // Render authorization page with QR code and DC API support + templateData := gin.H{ + "SessionID": response.SessionID, + "QRCodeData": response.QRCodeData, + "DeepLinkURL": response.DeepLinkURL, + "PollURL": response.PollURL, + "PreferredFormats": response.PreferredFormats, + "UseJAR": response.UseJAR, + "ResponseMode": response.ResponseMode, + "Title": response.Title, + "Subtitle": response.Subtitle, + "PrimaryColor": response.PrimaryColor, + "SecondaryColor": response.SecondaryColor, + "Theme": response.Theme, + "CustomCSS": response.CustomCSS, + "CSSFile": response.CSSFile, + "LogoURL": response.LogoURL, + "Config": gin.H{ + "DigitalCredentials": gin.H{ + "Enabled": s.cfg.VerifierProxy.DigitalCredentials.Enabled, + "AllowQRFallback": s.cfg.VerifierProxy.DigitalCredentials.AllowQRFallback, + "DeepLinkScheme": s.cfg.VerifierProxy.DigitalCredentials.DeepLinkScheme, + }, + }, + } + + c.HTML(http.StatusOK, "authorize_enhanced.html", templateData) return nil, nil } diff --git a/internal/verifier_proxy/httpserver/endpoints_openid4vp.go b/internal/verifier_proxy/httpserver/endpoints_openid4vp.go index 233a9266..55ca5e01 100644 --- a/internal/verifier_proxy/httpserver/endpoints_openid4vp.go +++ b/internal/verifier_proxy/httpserver/endpoints_openid4vp.go @@ -54,13 +54,26 @@ func (s *Service) endpointDirectPost(ctx context.Context, c *gin.Context) (any, s.log.Debug("endpointDirectPost called") - // Parse request + // Parse request - support both form-encoded and JSON request := &apiv1.DirectPostRequest{} - if err := c.ShouldBind(request); err != nil { - span.SetStatus(codes.Error, err.Error()) - s.log.Error(err, "Failed to bind direct_post request") - c.AbortWithStatus(http.StatusBadRequest) - return nil, nil + contentType := c.GetHeader("Content-Type") + + if contentType == "application/json" { + // DC API may send JSON + if err := c.ShouldBindJSON(request); err != nil { + span.SetStatus(codes.Error, err.Error()) + s.log.Error(err, "Failed to bind JSON direct_post request") + c.AbortWithStatus(http.StatusBadRequest) + return nil, nil + } + } else { + // Standard form-encoded + if err := c.ShouldBind(request); err != nil { + span.SetStatus(codes.Error, err.Error()) + s.log.Error(err, "Failed to bind direct_post request") + c.AbortWithStatus(http.StatusBadRequest) + return nil, nil + } } // Process VP token diff --git a/internal/verifier_proxy/httpserver/static/authorize_enhanced.html b/internal/verifier_proxy/httpserver/static/authorize_enhanced.html new file mode 100644 index 00000000..83034fc0 --- /dev/null +++ b/internal/verifier_proxy/httpserver/static/authorize_enhanced.html @@ -0,0 +1,539 @@ + + + + + + {{.Title}} + + {{if .CSSFile}} + + {{end}} + + +
+ {{if .LogoURL}} + + {{end}} + +

{{.Title}}

+

{{.Subtitle}}

+ + {{/* Status indicators */}} +
+ Waiting for credential... +
+ + + + {{/* Error message display */}} + + + {{/* Digital Credentials API section (shown if supported) */}} + + + {{/* QR Code section (shown as fallback or default) */}} +
+
+ QR Code +
+ +
+

+ How to use: +

+
    +
  1. Open your digital wallet app
  2. +
  3. Scan this QR code
  4. +
  5. Review and authorize the credential request
  6. +
  7. You'll be redirected automatically
  8. +
+
+ + {{/* Mobile deep link */}} + +
+ + + + {{/* Toggle between methods */}} + +
+ + + + diff --git a/internal/verifier_proxy/httpserver/static/digital-credentials.js b/internal/verifier_proxy/httpserver/static/digital-credentials.js new file mode 100644 index 00000000..b257fdd9 --- /dev/null +++ b/internal/verifier_proxy/httpserver/static/digital-credentials.js @@ -0,0 +1,317 @@ +/** + * W3C Digital Credentials API Client for Verifier Proxy + * + * Implements browser-based credential presentation using the W3C Digital Credentials API + * with support for multiple formats (SD-JWT, mdoc) and graceful fallback to QR codes. + * + * Specification: https://wicg.github.io/digital-credentials/ + * OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html + */ + +/** + * Check if W3C Digital Credentials API is available in the browser + * @returns {boolean} True if the API is supported + */ +export function isDigitalCredentialsSupported() { + return !!( + navigator.credentials && + navigator.credentials.get && + window.DigitalCredentialRequestOptions + ); +} + +/** + * Credential format configuration + */ +const CREDENTIAL_FORMATS = { + SD_JWT: 'vc+sd-jwt', + DC_SD_JWT: 'dc+sd-jwt', + MDOC: 'mso_mdoc' +}; + +/** + * Digital Credentials API Client + */ +export class DigitalCredentialsClient { + /** + * @param {Object} config Configuration options + * @param {string} config.sessionId Session identifier + * @param {string} config.baseUrl Base URL of verifier-proxy + * @param {string[]} config.preferredFormats Ordered list of preferred credential formats + * @param {boolean} config.useJAR Whether to use JWT Authorization Request + * @param {string} config.responseMode OpenID4VP response mode (dc_api.jwt, direct_post.jwt, direct_post) + * @param {Function} config.onProgress Progress callback + * @param {Function} config.onError Error callback + * @param {Function} config.onSuccess Success callback + */ + constructor(config) { + this.sessionId = config.sessionId; + this.baseUrl = config.baseUrl || window.location.origin; + this.preferredFormats = config.preferredFormats || [ + CREDENTIAL_FORMATS.SD_JWT, + CREDENTIAL_FORMATS.DC_SD_JWT, + CREDENTIAL_FORMATS.MDOC + ]; + this.useJAR = config.useJAR !== false; // Default to true + this.responseMode = config.responseMode || 'dc_api.jwt'; + this.onProgress = config.onProgress || (() => {}); + this.onError = config.onError || console.error; + this.onSuccess = config.onSuccess || (() => {}); + } + + /** + * Request credential from user's wallet using W3C Digital Credentials API + * @returns {Promise} + */ + async requestCredential() { + try { + this.onProgress('Checking browser support...'); + + if (!isDigitalCredentialsSupported()) { + throw new Error('W3C Digital Credentials API not supported in this browser'); + } + + this.onProgress('Fetching authorization request...'); + + // Get the authorization request (signed JWT if JAR is enabled) + const authRequest = await this.fetchAuthorizationRequest(); + + this.onProgress('Requesting credential from wallet...'); + + // Request credential using Digital Credentials API + const credential = await this.invokeDigitalCredentialsAPI(authRequest); + + this.onProgress('Submitting credential...'); + + // Submit the credential response to verifier-proxy + await this.submitCredentialResponse(credential); + + this.onSuccess('Credential verified successfully'); + + } catch (error) { + this.onError(error); + throw error; + } + } + + /** + * Fetch authorization request from verifier-proxy + * Returns either a signed JWT (JAR) or plain request object + * @returns {Promise} + */ + async fetchAuthorizationRequest() { + const endpoint = this.useJAR + ? `/verification/request-object/${this.sessionId}` + : `/verification/request/${this.sessionId}`; + + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'GET', + headers: { + 'Accept': this.useJAR + ? 'application/oauth-authz-req+jwt' + : 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch authorization request: ${response.status} ${response.statusText}`); + } + + if (this.useJAR) { + // Return signed JWT for JAR flow + return await response.text(); + } else { + // Return JSON request object for direct parameter flow + return await response.json(); + } + } + + /** + * Invoke W3C Digital Credentials API to request credential from wallet + * @param {string|Object} authRequest Authorization request (JWT or object) + * @returns {Promise} + */ + async invokeDigitalCredentialsAPI(authRequest) { + // Build the Digital Credentials API request + const digitalCredentialOptions = { + digital: { + providers: [{ + protocol: 'openid4vp', + request: typeof authRequest === 'string' ? authRequest : JSON.stringify(authRequest) + }] + } + }; + + try { + // Request credential from wallet + const credential = await navigator.credentials.get(digitalCredentialOptions); + + if (!credential) { + throw new Error('No credential received from wallet'); + } + + return credential; + + } catch (error) { + // Handle specific error cases + if (error.name === 'NotAllowedError') { + throw new Error('User denied the credential request'); + } else if (error.name === 'NotSupportedError') { + throw new Error('Wallet does not support the requested credential type'); + } else if (error.name === 'SecurityError') { + throw new Error('Security error: Credential request blocked'); + } + throw error; + } + } + + /** + * Submit credential response to verifier-proxy + * @param {DigitalCredential} credential The digital credential from wallet + * @returns {Promise} + */ + async submitCredentialResponse(credential) { + const endpoint = `${this.baseUrl}/verification/direct_post`; + + // Extract data from the credential + const credentialData = credential.data || credential; + + // Prepare the submission based on response mode + let body; + let contentType; + + if (this.responseMode === 'dc_api.jwt' || this.responseMode === 'direct_post.jwt') { + // For JWT response modes, the wallet returns encrypted/signed JWT + body = new URLSearchParams({ + response: typeof credentialData === 'string' ? credentialData : JSON.stringify(credentialData), + state: this.sessionId + }); + contentType = 'application/x-www-form-urlencoded'; + } else { + // For direct_post, submit vp_token and presentation_submission + body = new URLSearchParams({ + vp_token: credentialData.vp_token || credentialData, + presentation_submission: JSON.stringify(credentialData.presentation_submission || {}), + state: this.sessionId + }); + contentType = 'application/x-www-form-urlencoded'; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': contentType + }, + body: body + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to submit credential: ${response.status} - ${errorText}`); + } + + // Check if response includes a redirect + if (response.redirected) { + window.location.href = response.url; + return; + } + + // Handle JSON response + const result = await response.json(); + if (result.redirect_uri) { + window.location.href = result.redirect_uri; + } + } + + /** + * Get format capabilities string for presentation definition + * @returns {Object} Format capabilities object + */ + getFormatCapabilities() { + const formats = {}; + + this.preferredFormats.forEach(format => { + switch (format) { + case CREDENTIAL_FORMATS.SD_JWT: + case CREDENTIAL_FORMATS.DC_SD_JWT: + formats[format] = { + 'sd-jwt_alg_values': ['ES256', 'ES384', 'ES512', 'RS256'], + 'kb-jwt_alg_values': ['ES256', 'ES384', 'ES512', 'RS256'] + }; + break; + case CREDENTIAL_FORMATS.MDOC: + formats[format] = { + 'alg_values': ['ES256', 'ES384', 'ES512'] + }; + break; + } + }); + + return formats; + } +} + +/** + * Utility: Detect if user is on mobile device + * @returns {boolean} True if mobile device detected + */ +export function isMobileDevice() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +} + +/** + * Utility: Generate deep link URL for mobile wallet + * @param {string} authRequestURI The request_uri for OpenID4VP + * @param {string} deepLinkScheme Custom URL scheme (e.g., 'eudi-wallet://') + * @returns {string} Deep link URL + */ +export function generateDeepLink(authRequestURI, deepLinkScheme = 'openid4vp://') { + return `${deepLinkScheme}?${new URLSearchParams({ request_uri: authRequestURI })}`; +} + +/** + * Utility: Create QR code data URL for fallback + * @param {string} sessionId Session identifier + * @param {string} baseUrl Base URL of verifier-proxy + * @returns {string} QR code image URL + */ +export function getQRCodeURL(sessionId, baseUrl = window.location.origin) { + return `${baseUrl}/qr/${sessionId}`; +} + +/** + * Format detection helper + * @param {string} credentialType VCT or doctype + * @returns {string[]} Suitable formats for this credential type + */ +export function detectSuitableFormats(credentialType) { + // mdoc typically used for ISO/IEC 18013-5 mDL and similar + if (credentialType.includes('mdl') || credentialType.includes('iso.18013')) { + return [CREDENTIAL_FORMATS.MDOC, CREDENTIAL_FORMATS.SD_JWT]; + } + + // SD-JWT for most verifiable credentials + return [CREDENTIAL_FORMATS.SD_JWT, CREDENTIAL_FORMATS.DC_SD_JWT, CREDENTIAL_FORMATS.MDOC]; +} + +/** + * Error messages mapping for user-friendly display + */ +export const ERROR_MESSAGES = { + 'NotAllowedError': 'You denied the credential request. Please try again if this was a mistake.', + 'NotSupportedError': 'Your wallet does not support this type of credential. Please use a compatible wallet.', + 'SecurityError': 'Security error occurred. Please ensure you are on a secure connection (HTTPS).', + 'NetworkError': 'Network error occurred. Please check your connection and try again.', + 'TimeoutError': 'The request timed out. Please try again.', + 'default': 'An unexpected error occurred. Please try again or use the QR code.' +}; + +/** + * Get user-friendly error message + * @param {Error} error The error object + * @returns {string} User-friendly error message + */ +export function getUserFriendlyErrorMessage(error) { + const errorType = error.name || 'default'; + return ERROR_MESSAGES[errorType] || ERROR_MESSAGES.default; +} diff --git a/pkg/model/config.go b/pkg/model/config.go index ecd72f85..32be69d4 100644 --- a/pkg/model/config.go +++ b/pkg/model/config.go @@ -285,10 +285,12 @@ type Verifier struct { // VerifierProxy holds the verifier proxy configuration type VerifierProxy struct { - APIServer APIServer `yaml:"api_server" validate:"required"` - ExternalURL string `yaml:"external_url" validate:"required"` - OIDC OIDCConfig `yaml:"oidc" validate:"required"` - OpenID4VP OpenID4VPConfig `yaml:"openid4vp" validate:"required"` + APIServer APIServer `yaml:"api_server" validate:"required"` + ExternalURL string `yaml:"external_url" validate:"required"` + OIDC OIDCConfig `yaml:"oidc" validate:"required"` + OpenID4VP OpenID4VPConfig `yaml:"openid4vp" validate:"required"` + DigitalCredentials DigitalCredentialsConfig `yaml:"digital_credentials,omitempty"` + AuthorizationPageCSS AuthorizationPageCSSConfig `yaml:"authorization_page_css,omitempty"` } // OIDCConfig holds OIDC-specific configuration for the verifier-proxy's role as an OpenID Provider. @@ -317,6 +319,62 @@ type OpenID4VPConfig struct { PresentationRequestsDir string `yaml:"presentation_requests_dir,omitempty"` // Optional: directory with presentation request templates } +// DigitalCredentialsConfig holds W3C Digital Credentials API configuration +type DigitalCredentialsConfig struct { + // Enabled toggles W3C Digital Credentials API support in browser + Enabled bool `yaml:"enabled"` + + // UseJAR enables JWT Authorization Request (JAR) for wallet communication + // When true, request objects are signed JWTs instead of plain JSON + UseJAR bool `yaml:"use_jar"` + + // PreferredFormats specifies the order of preference for credential formats + // Supported values: "vc+sd-jwt", "dc+sd-jwt", "mso_mdoc" + // Default: ["vc+sd-jwt", "dc+sd-jwt", "mso_mdoc"] + PreferredFormats []string `yaml:"preferred_formats,omitempty"` + + // ResponseMode specifies the OpenID4VP response mode for DC API flows + // Supported values: "dc_api.jwt" (encrypted), "direct_post.jwt" (signed), "direct_post" + // Default: "dc_api.jwt" + ResponseMode string `yaml:"response_mode,omitempty" validate:"omitempty,oneof=dc_api.jwt direct_post.jwt direct_post"` + + // AllowQRFallback enables automatic fallback to QR code if DC API is unavailable + // Default: true + AllowQRFallback bool `yaml:"allow_qr_fallback"` + + // DeepLinkScheme for mobile wallet integration (e.g., "eudi-wallet://") + DeepLinkScheme string `yaml:"deep_link_scheme,omitempty"` +} + +// AuthorizationPageCSSConfig allows deployers to customize the authorization page styling +type AuthorizationPageCSSConfig struct { + // CustomCSS is inline CSS that will be injected into the authorization page + // Allows deployers to override default styling without modifying templates + CustomCSS string `yaml:"custom_css,omitempty"` + + // CSSFile is a path to an external CSS file to include + // If both CustomCSS and CSSFile are provided, both are included + CSSFile string `yaml:"css_file,omitempty"` + + // Theme sets predefined color scheme: "light" (default), "dark", "blue", "purple" + Theme string `yaml:"theme,omitempty" validate:"omitempty,oneof=light dark blue purple"` + + // PrimaryColor overrides the primary brand color (hex format: #667eea) + PrimaryColor string `yaml:"primary_color,omitempty"` + + // SecondaryColor overrides the secondary brand color (hex format: #764ba2) + SecondaryColor string `yaml:"secondary_color,omitempty"` + + // LogoURL provides a URL to a custom logo image + LogoURL string `yaml:"logo_url,omitempty"` + + // Title overrides the page title (default: "Wallet Authorization") + Title string `yaml:"title,omitempty"` + + // Subtitle overrides the page subtitle + Subtitle string `yaml:"subtitle,omitempty"` +} + // SupportedCredentialConfig maps credential types to OIDC scopes type SupportedCredentialConfig struct { VCT string `yaml:"vct" validate:"required"` From a2cb1f1909ec8bc990f4c1d35a9aa564ca08df97 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 25 Nov 2025 21:48:49 +0100 Subject: [PATCH 3/4] feat: Add configurable credential display for debugging and consent - Add CredentialDisplayConfig to verifier-proxy configuration - Implement optional checkbox on authorization page - Create credential display page showing claims and raw credential - Add session preference storage and confirmation flow - Support both optional and mandatory display modes - Include comprehensive documentation and examples This feature serves as both a debugging tool (showing raw credentials and parsed claims) and an optional additional consent step in the OIDC flow. It's disabled by default and fully backward compatible. --- config.digital-credentials-example.yaml | 25 ++ docs/DIGITAL_CREDENTIALS_API.md | 66 ++++ internal/verifier_proxy/apiv1/handler_api.go | 22 +- .../apiv1/handler_session_preference.go | 181 +++++++++ internal/verifier_proxy/db/session.go | 1 + .../endpoints_credential_display.go | 120 ++++++ internal/verifier_proxy/httpserver/service.go | 3 + .../httpserver/static/authorize_enhanced.html | 60 ++- .../httpserver/static/credential_display.html | 356 ++++++++++++++++++ pkg/model/config.go | 24 ++ 10 files changed, 855 insertions(+), 3 deletions(-) create mode 100644 internal/verifier_proxy/apiv1/handler_session_preference.go create mode 100644 internal/verifier_proxy/httpserver/endpoints_credential_display.go create mode 100644 internal/verifier_proxy/httpserver/static/credential_display.html diff --git a/config.digital-credentials-example.yaml b/config.digital-credentials-example.yaml index 855d794f..fb3b573c 100644 --- a/config.digital-credentials-example.yaml +++ b/config.digital-credentials-example.yaml @@ -85,6 +85,31 @@ verifier_proxy: # External CSS file path (optional) # Use this for complete custom styling # css_file: "/custom/styles/verifier-theme.css" + + # Credential Display Configuration + # Shows credentials and claims before completing authorization + credential_display: + # Enable credential display feature + # When enabled, users see a checkbox on the authorization page + enabled: true + + # Require users to review credentials before authorization + # When true, credential display is mandatory (checkbox pre-checked and disabled) + # When false, credential display is optional (users can choose) + require_confirmation: false + + # Show the raw VP token/credential in the display page + # Useful for debugging and technical users + # Shows the full JWT or mdoc structure + show_raw_credential: true + + # Show parsed claims that will be sent to the RP + # Recommended for transparency - shows what data is being shared + show_claims: true + + # Future feature: Allow users to redact certain claims + # Not currently implemented + # allow_edit: false # Additional configuration sections... # (database, logging, etc. - omitted for brevity) diff --git a/docs/DIGITAL_CREDENTIALS_API.md b/docs/DIGITAL_CREDENTIALS_API.md index 1e9bb3a9..62cb7280 100644 --- a/docs/DIGITAL_CREDENTIALS_API.md +++ b/docs/DIGITAL_CREDENTIALS_API.md @@ -214,6 +214,72 @@ const { id_token } = await tokenResponse.json(); 5. **Claim Extraction**: Verifier-proxy maps credential claims to OIDC claims 6. **Code Issuance**: Returns standard OIDC authorization code to RP +## Credential Display & Debugging + +The verifier-proxy includes an optional **credential display feature** that shows users the credential details before completing authorization. This serves two purposes: + +1. **Debugging Tool** - Developers can see the raw credential and extracted claims +2. **Transparency/Consent** - Users can review what data is being shared with the RP + +### Configuration + +```yaml +verifier_proxy: + credential_display: + enabled: true # Show checkbox on authorization page + require_confirmation: false # Make it optional (true = mandatory) + show_raw_credential: true # Display raw VP token/JWT + show_claims: true # Display parsed claims +``` + +### How It Works + +1. **Authorization Page**: When enabled, users see a checkbox "Show credential details before completing authorization" +2. **User Choice**: + - If unchecked (default): Normal flow - credential sent directly to RP + - If checked: User is redirected to credential display page after presenting credential +3. **Display Page**: Shows: + - **Parsed Claims** - The exact claims that will be sent to the RP in the ID token + - **Raw Credential** (optional) - The full VP token in JWT or mdoc format for debugging + - **RP Information** - Which service is requesting the data +4. **Confirmation**: User must click "Confirm & Continue" to proceed, or "Cancel" to abort + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | boolean | `false` | Show credential display checkbox on authorization page | +| `require_confirmation` | boolean | `false` | Make credential display mandatory (checkbox pre-checked and disabled) | +| `show_raw_credential` | boolean | `false` | Display raw VP token/credential in JWT or mdoc format | +| `show_claims` | boolean | `true` | Display parsed claims that will be sent to RP | + +### Use Cases + +**For Developers**: +- Debug credential parsing issues +- Verify claim mapping is working correctly +- Inspect raw credential structure +- Test with different credential formats + +**For Production** (Optional): +- Add transparency layer showing users exactly what's shared +- Provide additional consent step beyond wallet approval +- Help users understand credential contents +- Debugging specific user issues + +**Recommendation**: +- **Development**: `enabled: true, require_confirmation: false, show_raw_credential: true` +- **Production**: `enabled: false` (unless regulatory requirements demand additional consent) +- **Support/Debug**: Enable on demand for specific sessions + +### Security Note + +When `show_raw_credential: true`, the full credential (including potentially undisclosed fields in SD-JWTs) may be visible. This is intentional for debugging, but be aware: + +- Raw credentials may contain more data than what's disclosed to the RP +- Only enable in trusted environments or for debugging +- The RP still only receives the disclosed claims in the ID token + ## Testing ### Local Development diff --git a/internal/verifier_proxy/apiv1/handler_api.go b/internal/verifier_proxy/apiv1/handler_api.go index 63b91590..d2a2f304 100644 --- a/internal/verifier_proxy/apiv1/handler_api.go +++ b/internal/verifier_proxy/apiv1/handler_api.go @@ -279,13 +279,33 @@ func (c *Client) ProcessDirectPost(ctx context.Context, req *DirectPostRequest) session.OpenID4VP.VPToken = vpToken session.OpenID4VP.PresentationSubmission = presentationSubmission session.VerifiedClaims = oidcClaims // Store mapped OIDC claims - session.Status = db.SessionStatusCodeIssued // Extract wallet ID from claims (sub or other identifier) if sub, ok := oidcClaims["sub"].(string); ok { session.OpenID4VP.WalletID = sub } + // Check if user requested credential display + if session.OIDCRequest.ShowCredentialDetails { + // Update session status to indicate we're waiting for user confirmation + session.Status = db.SessionStatusAwaitingPresentation + + if err := c.db.Sessions.Update(ctx, session); err != nil { + c.log.Error(err, "Failed to update session") + return nil, ErrServerError + } + + c.log.Info("Redirecting to credential display page", "session_id", session.ID) + + // Redirect to credential display page instead of completing authorization + return &DirectPostResponse{ + RedirectURI: fmt.Sprintf("%s/verification/display/%s", c.cfg.VerifierProxy.ExternalURL, session.ID), + }, nil + } + + // Otherwise, issue authorization code immediately + session.Status = db.SessionStatusCodeIssued + // Generate authorization code code := c.generateAuthorizationCode() codeExpiry := time.Now().Add(time.Duration(c.cfg.VerifierProxy.OIDC.CodeDuration) * time.Second) diff --git a/internal/verifier_proxy/apiv1/handler_session_preference.go b/internal/verifier_proxy/apiv1/handler_session_preference.go new file mode 100644 index 00000000..023a8819 --- /dev/null +++ b/internal/verifier_proxy/apiv1/handler_session_preference.go @@ -0,0 +1,181 @@ +package apiv1 + +import ( + "context" + "fmt" + "time" + "vc/internal/verifier_proxy/db" +) + +// UpdateSessionPreferenceRequest represents a request to update session display preference +type UpdateSessionPreferenceRequest struct { + SessionID string `json:"session_id" binding:"required"` + ShowCredentialDetails bool `json:"show_credential_details"` +} + +// UpdateSessionPreferenceResponse contains the response +type UpdateSessionPreferenceResponse struct { + Success bool `json:"success"` +} + +// UpdateSessionPreference updates the session's credential display preference +func (c *Client) UpdateSessionPreference(ctx context.Context, req *UpdateSessionPreferenceRequest) (*UpdateSessionPreferenceResponse, error) { + // Get session + session, err := c.db.Sessions.GetByID(ctx, req.SessionID) + if err != nil { + return nil, ErrSessionNotFound + } + if session == nil { + return nil, ErrSessionNotFound + } + + // Update preference + session.OIDCRequest.ShowCredentialDetails = req.ShowCredentialDetails + + if err := c.db.Sessions.Update(ctx, session); err != nil { + c.log.Error(err, "Failed to update session preference") + return nil, ErrServerError + } + + return &UpdateSessionPreferenceResponse{Success: true}, nil +} + +// ConfirmCredentialDisplayRequest represents a confirmation from the credential display page +type ConfirmCredentialDisplayRequest struct { + Confirmed bool `json:"confirmed"` +} + +// ConfirmCredentialDisplayResponse contains the redirect URI +type ConfirmCredentialDisplayResponse struct { + RedirectURI string `json:"redirect_uri"` +} + +// ConfirmCredentialDisplay handles user confirmation after viewing credential details +func (c *Client) ConfirmCredentialDisplay(ctx context.Context, sessionID string, req *ConfirmCredentialDisplayRequest) (*ConfirmCredentialDisplayResponse, error) { + // Get session + session, err := c.db.Sessions.GetByID(ctx, sessionID) + if err != nil { + return nil, ErrSessionNotFound + } + if session == nil { + return nil, ErrSessionNotFound + } + + // Verify session is in the right state + if session.Status != db.SessionStatusAwaitingPresentation { + c.log.Info("Session not awaiting confirmation", "session_id", sessionID, "status", session.Status) + return nil, ErrInvalidRequest + } + + if !req.Confirmed { + // User cancelled - return error to RP + c.log.Info("User cancelled credential display", "session_id", sessionID) + session.Status = db.SessionStatusError + c.db.Sessions.Update(ctx, session) + + redirectURI := "" + if session.OIDCRequest.RedirectURI != "" && session.OIDCRequest.State != "" { + redirectURI = fmt.Sprintf("%s?error=access_denied&error_description=User+cancelled&state=%s", + session.OIDCRequest.RedirectURI, + session.OIDCRequest.State, + ) + } + + return &ConfirmCredentialDisplayResponse{ + RedirectURI: redirectURI, + }, nil + } + + // User confirmed - issue authorization code + code := c.generateAuthorizationCode() + codeExpiry := time.Now().Add(time.Duration(c.cfg.VerifierProxy.OIDC.CodeDuration) * time.Second) + + session.Status = db.SessionStatusCodeIssued + session.Tokens.AuthorizationCode = code + session.Tokens.CodeExpiresAt = codeExpiry + + if err := c.db.Sessions.Update(ctx, session); err != nil { + c.log.Error(err, "Failed to update session after confirmation") + return nil, ErrServerError + } + + c.log.Info("User confirmed credential display, code issued", "session_id", sessionID) + + // Return redirect URI with code + redirectURI := "" + if session.OIDCRequest.RedirectURI != "" { + redirectURI = fmt.Sprintf("%s?code=%s&state=%s", + session.OIDCRequest.RedirectURI, + code, + session.OIDCRequest.State, + ) + } + + return &ConfirmCredentialDisplayResponse{ + RedirectURI: redirectURI, + }, nil +} + +// GetCredentialDisplayDataRequest represents a request to get display data +type GetCredentialDisplayDataRequest struct { + SessionID string +} + +// GetCredentialDisplayDataResponse contains data for the credential display page +type GetCredentialDisplayDataResponse struct { + SessionID string `json:"session_id"` + VPToken string `json:"vp_token"` + Claims map[string]any `json:"claims"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + State string `json:"state"` + ShowRawCredential bool `json:"show_raw_credential"` + ShowClaims bool `json:"show_claims"` + PrimaryColor string `json:"primary_color"` + SecondaryColor string `json:"secondary_color"` + CustomCSS string `json:"custom_css"` +} + +// GetCredentialDisplayData retrieves data needed for the credential display page +func (c *Client) GetCredentialDisplayData(ctx context.Context, req *GetCredentialDisplayDataRequest) (*GetCredentialDisplayDataResponse, error) { + // Get session + session, err := c.db.Sessions.GetByID(ctx, req.SessionID) + if err != nil { + return nil, ErrSessionNotFound + } + if session == nil { + return nil, ErrSessionNotFound + } + + // Verify session has VP data + if session.OpenID4VP.VPToken == "" { + c.log.Info("Session has no VP token", "session_id", req.SessionID) + return nil, ErrInvalidRequest + } + + // Build response + response := &GetCredentialDisplayDataResponse{ + SessionID: session.ID, + VPToken: session.OpenID4VP.VPToken, + Claims: session.VerifiedClaims, + ClientID: session.OIDCRequest.ClientID, + RedirectURI: session.OIDCRequest.RedirectURI, + State: session.OIDCRequest.State, + ShowRawCredential: c.cfg.VerifierProxy.CredentialDisplay.ShowRawCredential, + ShowClaims: c.cfg.VerifierProxy.CredentialDisplay.ShowClaims, + PrimaryColor: c.cfg.VerifierProxy.AuthorizationPageCSS.PrimaryColor, + SecondaryColor: c.cfg.VerifierProxy.AuthorizationPageCSS.SecondaryColor, + CustomCSS: c.cfg.VerifierProxy.AuthorizationPageCSS.CustomCSS, + } + + // Set defaults + if response.PrimaryColor == "" { + response.PrimaryColor = "#3182ce" + } + if response.SecondaryColor == "" { + response.SecondaryColor = "#2c5282" + } + + return response, nil +} + diff --git a/internal/verifier_proxy/db/session.go b/internal/verifier_proxy/db/session.go index 4abb8d8d..fe8f48c1 100644 --- a/internal/verifier_proxy/db/session.go +++ b/internal/verifier_proxy/db/session.go @@ -59,6 +59,7 @@ type OIDCRequest struct { IDTokenHint string `bson:"id_token_hint,omitempty" json:"id_token_hint,omitempty"` LoginHint string `bson:"login_hint,omitempty" json:"login_hint,omitempty"` ACRValues []string `bson:"acr_values,omitempty" json:"acr_values,omitempty"` + ShowCredentialDetails bool `bson:"show_credential_details,omitempty" json:"show_credential_details,omitempty"` // User requested to view credential before authorization } // OpenID4VPSession represents the OpenID4VP interaction diff --git a/internal/verifier_proxy/httpserver/endpoints_credential_display.go b/internal/verifier_proxy/httpserver/endpoints_credential_display.go new file mode 100644 index 00000000..64f04105 --- /dev/null +++ b/internal/verifier_proxy/httpserver/endpoints_credential_display.go @@ -0,0 +1,120 @@ +package httpserver + +import ( + "context" + "net/http" + "vc/internal/verifier_proxy/apiv1" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/codes" +) + +// endpointSessionPreference handles session preference updates +func (s *Service) endpointSessionPreference(ctx context.Context, c *gin.Context) (any, error) { + ctx, span := s.tracer.Start(ctx, "httpserver:endpointSessionPreference") + defer span.End() + + request := &apiv1.UpdateSessionPreferenceRequest{} + if err := c.ShouldBindJSON(request); err != nil { + span.SetStatus(codes.Error, err.Error()) + s.log.Error(err, "Failed to bind session preference request") + c.AbortWithStatus(http.StatusBadRequest) + return nil, nil + } + + response, err := s.apiv1.UpdateSessionPreference(ctx, request) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + s.log.Error(err, "Failed to update session preference") + c.AbortWithStatus(http.StatusInternalServerError) + return nil, nil + } + + return response, nil +} + +// endpointCredentialDisplay shows the credential display page +func (s *Service) endpointCredentialDisplay(ctx context.Context, c *gin.Context) (any, error) { + ctx, span := s.tracer.Start(ctx, "httpserver:endpointCredentialDisplay") + defer span.End() + + sessionID := c.Param("session_id") + if sessionID == "" { + span.SetStatus(codes.Error, "Missing session_id") + c.AbortWithStatus(http.StatusBadRequest) + return nil, nil + } + + // Get display data + request := &apiv1.GetCredentialDisplayDataRequest{ + SessionID: sessionID, + } + + response, err := s.apiv1.GetCredentialDisplayData(ctx, request) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + s.log.Error(err, "Failed to get credential display data") + + if err == apiv1.ErrSessionNotFound { + c.AbortWithStatus(http.StatusNotFound) + return nil, nil + } + + c.AbortWithStatus(http.StatusInternalServerError) + return nil, nil + } + + // Render credential display page + c.HTML(http.StatusOK, "credential_display.html", gin.H{ + "SessionID": response.SessionID, + "VPToken": response.VPToken, + "Claims": response.Claims, + "ClientID": response.ClientID, + "RedirectURI": response.RedirectURI, + "State": response.State, + "ShowRawCredential": response.ShowRawCredential, + "ShowClaims": response.ShowClaims, + "PrimaryColor": response.PrimaryColor, + "SecondaryColor": response.SecondaryColor, + "CustomCSS": response.CustomCSS, + }) + + return nil, nil +} + +// endpointConfirmCredentialDisplay handles confirmation from credential display page +func (s *Service) endpointConfirmCredentialDisplay(ctx context.Context, c *gin.Context) (any, error) { + ctx, span := s.tracer.Start(ctx, "httpserver:endpointConfirmCredentialDisplay") + defer span.End() + + sessionID := c.Param("session_id") + if sessionID == "" { + span.SetStatus(codes.Error, "Missing session_id") + c.AbortWithStatus(http.StatusBadRequest) + return nil, nil + } + + request := &apiv1.ConfirmCredentialDisplayRequest{} + if err := c.ShouldBindJSON(request); err != nil { + span.SetStatus(codes.Error, err.Error()) + s.log.Error(err, "Failed to bind confirmation request") + c.AbortWithStatus(http.StatusBadRequest) + return nil, nil + } + + response, err := s.apiv1.ConfirmCredentialDisplay(ctx, sessionID, request) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + s.log.Error(err, "Failed to process confirmation") + + if err == apiv1.ErrSessionNotFound { + c.AbortWithStatus(http.StatusNotFound) + return nil, nil + } + + c.AbortWithStatus(http.StatusInternalServerError) + return nil, nil + } + + return response, nil +} diff --git a/internal/verifier_proxy/httpserver/service.go b/internal/verifier_proxy/httpserver/service.go index c00b2c8a..b722f40e 100644 --- a/internal/verifier_proxy/httpserver/service.go +++ b/internal/verifier_proxy/httpserver/service.go @@ -108,6 +108,9 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, tracer *trace s.httpHelpers.Server.RegEndpoint(ctx, rgVerification, http.MethodGet, "request-object/:session_id", http.StatusOK, s.endpointRequestObject) s.httpHelpers.Server.RegEndpoint(ctx, rgVerification, http.MethodPost, "direct_post", http.StatusOK, s.endpointDirectPost) s.httpHelpers.Server.RegEndpoint(ctx, rgVerification, http.MethodGet, "callback", http.StatusOK, s.endpointCallback) + s.httpHelpers.Server.RegEndpoint(ctx, rgVerification, http.MethodPost, "session-preference", http.StatusOK, s.endpointSessionPreference) + s.httpHelpers.Server.RegEndpoint(ctx, rgVerification, http.MethodGet, "display/:session_id", http.StatusOK, s.endpointCredentialDisplay) + s.httpHelpers.Server.RegEndpoint(ctx, rgVerification, http.MethodPost, "confirm/:session_id", http.StatusOK, s.endpointConfirmCredentialDisplay) // UI Endpoints s.httpHelpers.Server.RegEndpoint(ctx, rgRoot, http.MethodGet, "qr/:session_id", http.StatusOK, s.endpointQRCode) diff --git a/internal/verifier_proxy/httpserver/static/authorize_enhanced.html b/internal/verifier_proxy/httpserver/static/authorize_enhanced.html index 83034fc0..e581809f 100644 --- a/internal/verifier_proxy/httpserver/static/authorize_enhanced.html +++ b/internal/verifier_proxy/httpserver/static/authorize_enhanced.html @@ -289,6 +289,25 @@

{{.Title}}

{{/* Error message display */}} + {{/* Credential Display Option */}} + {{if .Config.CredentialDisplay.Enabled}} +
+ +
+ {{end}} + {{/* Digital Credentials API section (shown if supported) */}}