diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b3829d8..8221e3d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,4 +32,20 @@ jobs: - uses: shogo82148/actions-goveralls@v1 with: - path-to-profile: profile.cov \ No newline at end of file + path-to-profile: profile.cov + + integration-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.24 + + - name: Run integration tests + working-directory: integration_test + run: | + go test -tags integration -v -count=1 -timeout 10m ./... \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..50a6710 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +VCVerifier is a FIWARE component implementing SIOP-2/OIDC4VP authentication flows. It exchanges Verifiable Credentials (VCs) for JWTs, enabling VC-based authentication and authorization. Supports multiple trust frameworks (EBSI, Gaia-X) and credential formats (JSON-LD VCs, SD-JWTs). + +## Build & Test Commands + +```bash +# Build +go build -o VCVerifier . + +# Run all tests +go test ./... -v + +# Run all tests with coverage +go test ./... -v -coverprofile=profile.cov + +# Run tests for a single package +go test ./verifier/... -v + +# Run a specific test +go test ./verifier/... -v -run TestVerifyConfig + +# Docker build (multi-platform) +docker build -t vcverifier . +``` + +There is no Makefile or linter configuration. CI runs `go test ./... -v` with Go 1.24. + +## Configuration + +Runtime config is loaded from `server.yaml` (override with `CONFIG_FILE` env var). The config is parsed by `config.ReadConfig()` using gookit/config with YAML driver and mapstructure tags. + +Key config sections: `server` (port, timeouts, template/static dirs), `logging`, `verifier` (DID, TIR address, policies, validation mode, key algorithm), `ssiKit` (auditor URL), `configRepo` (dynamic service configurations with scopes and trust endpoints). + +## Architecture + +**Entry point**: `main.go` — reads config, initializes logging and verifier, sets up Gin router with routes from `openapi/`, serves on configured port with graceful shutdown. + +### Package Responsibilities + +- **`verifier/`** — Core package (~1500 lines in `verifier.go`). Session management, JWT creation (RS256/ES256), QR code generation, nonce/state management. Request object modes: `urlEncoded`, `byValue`, `byReference`. Also contains: + - `presentation_parser.go` — Parses VP tokens (JSON-LD and SD-JWT formats), JSON-LD document loading with caching + - `jwt_verifier.go` — VC validation with modes: `none`, `combined`, `jsonLd`, `baseContext`. DID verification method resolution for did:key, did:web, did:jwk + - `trustedissuer.go` / `trustedparticipant.go` — EBSI registry verification + - `compliance.go` — Policy compliance checking (signatures, dates, etc.) + - `holder.go` — Holder verification + - `gaiax.go` — Gaia-X compliance checks + - `elsi_proof_checker.go` — JAdES signature validation for did:elsi + - `credentialsConfig.go` — Credential configuration management + - `caching_client.go` — HTTP caching layer + +- **`openapi/`** — HTTP handlers generated from OpenAPI spec (`api/api.yaml`). Routes defined in `routers.go`. Handlers in `api_api.go` (token, authorization, authentication) and `api_frontend.go` (frontend endpoints, WebSocket polling). + +- **`tir/`** — Trusted Issuers Registry client. Queries EBSI v3/v4 endpoints, caches results. Includes M2M auth via `tokenProvider.go` and `authorizationClient.go`. + +- **`gaiax/`** — Gaia-X compliance client. did:web resolution, X.509 certificate chain validation, trust anchor verification. + +- **`jades/`** — JAdES signature validation for did:elsi credentials. + +- **`config/`** — Configuration structs and YAML parsing. Test fixtures in `config/data/`. + +- **`logging/`** — Zap-based structured logging with Gin middleware integration. + +- **`common/`** — Shared types: cache interfaces (ServiceCache, TirEndpoints, IssuersCache), clock utilities, HTTP helpers, token signer interfaces. + +- **`views/`** — HTML templates and static assets for QR code presentation frontend. + +### Request Flow + +1. Client hits OpenAPI endpoints (`/api/v1/authorization`, `/token`, etc.) +2. `openapi/` handlers delegate to `verifier/` for session management and credential exchange +3. Verifier validates presentations using the VC verification chain (parsing, signature validation, policy compliance, trust registry checks) +4. Trust anchors are consulted via `tir/` (EBSI) or `gaiax/` clients +5. On success, a JWT is issued to the client + +## Testing Patterns + +- Uses `github.com/stretchr/testify` for assertions +- Table-driven tests with `type test struct` and `t.Run()` loops +- Mock implementations within test files (e.g., `mockNonceGenerator`, `mockSessionCache`) +- Test fixtures in `config/data/` (YAML files) +- Logging is initialized in tests with a shared `LOGGING_CONFIG` variable + +## Key Dependencies + +- **trustbloc/vc-go, did-go, kms-go** — VC verification, DID resolution, key management +- **gin-gonic/gin** — HTTP framework +- **lestrrat-go/jwx/v3** — JWT/JWS/JWK handling +- **piprate/json-gold** — JSON-LD processing +- **gookit/config** — Configuration management +- **foolin/goview** — Template rendering for Gin diff --git a/README.md b/README.md index 9d05995..4cf945b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ VCVerifier provides the necessary endpoints(see [API](./api/api.yaml)) to offer * [API](#api) * [Open Issues](#open-issues) * [Testing](#testing) + * [Unit Tests](#unit-tests) + * [Integration Tests](#integration-tests) * [License](#license) ## Background @@ -427,8 +429,98 @@ The VCVerifier does currently not support all functionalities defined in the con ## Testing +### Unit Tests + Functionality of the verifier is tested via parameterized Unit-Tests, following golang-bestpractices. In addition, the verifier is integrated into the [VC-Integration-Test](https://github.com/fiware/VC-Integration-Test), involving all components used in a typical, VerifiableCredentials based, scenario. +Run unit tests: +```shell +go test ./... -v +``` + +### Integration Tests + +A black-box integration test suite lives in `integration_test/`. It builds the verifier binary, launches it as a subprocess with generated YAML configs, and interacts purely over HTTP — no internal Go imports from verifier packages. Tests are gated behind the `integration` build tag so they never run during regular `go test ./...`. + +#### Running + +By default, the test suite builds the verifier binary from source. You can override this with environment variables to test a pre-built binary instead: + +| Environment Variable | Description | +|----------------------|-------------| +| `VERIFIER_BINARY` | Path to a local pre-built verifier binary | +| `VERIFIER_BINARY_URL` | URL to download a verifier binary from (made executable automatically) | + +If both are set, `VERIFIER_BINARY` takes precedence. + +```shell +# All integration tests (builds from source) +cd integration_test && go test -tags integration -v -count=1 ./... + +# Test a pre-built local binary +VERIFIER_BINARY=/path/to/vcverifier go test -tags integration -v -count=1 ./... + +# Test a binary downloaded from a URL +VERIFIER_BINARY_URL=https://example.com/releases/vcverifier-linux-amd64 \ + go test -tags integration -v -count=1 ./... + +# By category +go test -tags integration -v -count=1 -run TestM2M ./... +go test -tags integration -v -count=1 -run TestFrontendV2 ./... +go test -tags integration -v -count=1 -run TestDeeplink ./... +go test -tags integration -v -count=1 -run TestEndpoints ./... +``` + +#### Test Categories + +| Category | Tests | Description | +|----------|-------|-------------| +| M2M Success | 7 | VP-token-to-JWT exchange with JWT-VC, SD-JWT, did:key, did:web, cnf/claim holder verification | +| M2M Failure | 6 | Rejection of invalid credentials, untrusted issuers, signature mismatches | +| Frontend V2 | 2 | Cross-device flow with QR code, WebSocket notifications, authorization code exchange | +| Deeplink | 2 | Same-device flow with openid4vp:// redirects and 302 authentication responses | +| Endpoints | 8 | JWKS, OpenID configuration, health check, and parameter validation errors | + +#### How to Add a New Test + +1. **Pick or create a test file.** Each file covers a flow category (e.g., `m2m_test.go`, `deeplink_test.go`). Every file must start with `//go:build integration`. + +2. **Create a fixture function** that sets up identities, a mock TIR, a verifier config, and starts the verifier process. Follow the pattern in existing `setup*` functions: + + ```go + func setupMyTest(t *testing.T) *myFixture { + t.Helper() + issuer, _ := helpers.GenerateDidKeyIdentity() + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ /* ... */ }) + port, _ := helpers.GetFreePort() + keyPath, _ := helpers.GenerateSigningKeyPEM(t.TempDir()) + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "MyCredential", tirServer.URL). + Build() + vp, _ := helpers.StartVerifier(config, projectRoot, binaryPath) + return &myFixture{verifier: vp, cleanup: func() { vp.Stop(); tirServer.Close() }} + } + ``` + +3. **Write the test function.** Use parameterized sub-tests (`t.Run`) when testing multiple variations. Interact with the verifier only via HTTP. Call `defer fixture.cleanup()` to stop the verifier after the test. + +4. **Use the helper library** in `integration_test/helpers/`: + - `identity.go` — Generate `did:key` and `did:web` identities + - `credentials.go` — Create JWT-VC, SD-JWT, VP tokens, and DCQL responses + - `config.go` — Fluent `ConfigBuilder` for verifier YAML configs + - `process.go` — Build, start, health-poll, and stop the verifier binary + - `tir_mock.go` — Mock Trusted Issuers Registry + - `did_web_mock.go` — Mock `did:web` TLS server + +5. **Run your new test** in isolation first, then as part of the full suite: + ```shell + cd integration_test + go test -tags integration -v -count=1 -run TestMyNewTest ./... + go test -tags integration -v -count=1 ./... + ``` + ## License diff --git a/RELEASE_NOTES_INTEGRATION_TESTS.md b/RELEASE_NOTES_INTEGRATION_TESTS.md new file mode 100644 index 0000000..8201c88 --- /dev/null +++ b/RELEASE_NOTES_INTEGRATION_TESTS.md @@ -0,0 +1,58 @@ +# Release Notes: Integration Test Framework + +## Overview + +A comprehensive black-box integration test framework for VCVerifier, treating the verifier as an opaque HTTP service. The test suite builds the verifier binary, launches it as a subprocess with generated YAML configs, and interacts purely over HTTP — no internal Go imports from verifier packages. + +## New Files + +### Test helpers (`integration_test/helpers/`) + +- **`process.go`** — Build, launch, health-poll, and graceful shutdown of the verifier binary +- **`config.go`** — Fluent `ConfigBuilder` API for generating YAML configs with DCQL, holder verification, JWT inclusion, trusted participants +- **`identity.go`** — `TestIdentity` generation for `did:key` and `did:web` (ECDSA P-256) +- **`credentials.go`** — JWT-VC, SD-JWT, VP token, and DCQL response creation with cryptographic signing +- **`tir_mock.go`** — Mock Trusted Issuers Registry (`httptest.Server`) with percent-encoded DID support +- **`did_web_mock.go`** — Mock `did:web` TLS server with dynamic DID document serving + +### Test files + +- **`m2m_test.go`** — 7 parameterized M2M success tests (JWT-VC, SD-JWT, did:key, did:web, cnf holder, claim holder) +- **`m2m_failure_test.go`** — 6 parameterized M2M failure tests (wrong type, missing claims, untrusted issuer, invalid VP signature, invalid cnf, invalid claim holder) +- **`frontend_v2_test.go`** — 2 end-to-end Frontend V2 cross-device tests (byReference, byValue) with WebSocket +- **`deeplink_test.go`** — 2 end-to-end Deeplink same-device tests (byReference, byValue) +- **`endpoints_test.go`** — 8 endpoint validation tests (JWKS, OpenID config, health, error cases) +- **`helpers_test.go`** — Unit tests for the helper functions themselves + +## Test Coverage Summary + +| Category | Tests | Description | +|----------|-------|-------------| +| M2M Success | 7 | VP-token-to-JWT exchange with various credential formats and DID methods | +| M2M Failure | 6 | Rejection of invalid credentials, untrusted issuers, signature mismatches | +| Frontend V2 | 2 | Cross-device flow with QR code, WebSocket notifications, authorization code exchange | +| Deeplink | 2 | Same-device flow with openid4vp:// redirects and 302 authentication responses | +| Endpoints | 8 | JWKS, OpenID configuration, health check, and parameter validation errors | +| **Total** | **25** | | + +## Dependencies Added (integration_test/go.mod only) + +- `github.com/gorilla/websocket` — WebSocket client for Frontend V2 cross-device flow tests +- `github.com/lestrrat-go/jwx/v3` — JWT/JWK creation and verification +- `github.com/trustbloc/kms-go` — did:key identity generation +- `github.com/stretchr/testify` — Test assertions + +## Running + +```bash +# All integration tests +cd integration_test && go test -tags integration -v -count=1 ./... + +# By category +go test -tags integration -v -count=1 -run TestM2M ./... +go test -tags integration -v -count=1 -run TestFrontendV2 ./... +go test -tags integration -v -count=1 -run TestDeeplink ./... +go test -tags integration -v -count=1 -run TestEndpoints ./... +``` + +The `integration` build tag ensures these tests don't run during regular `go test ./...`. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..71aadb1 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,427 @@ +# Integration test + +An integration test framework should be integrated as part of this project. It should use a full instance of the VCVerifier, with all other components being mocked or test-doubles/implementations being used. It should at least test the following flows. + +## Flows + +The following flows need to be tested. They should be tested for case: + +### Success + +- multiple JWT-VCs requested and presented +- One JWT-VC requested and presented +- multiple SD-JWT requested and presented +- One SD-JWT requested and presented +- issuer uses did:key as id +- issuer uses did:web as id +- correctly holder-bound credentials(cnf) presented +- correctly claim-based holder-bound credentials presented + +### Failure + +- wrong credentials presented +- credentials without the requested claims presented +- invalid credentials presented +- invalid signed presentation presented +- invalid cnf presented +- invalid claim-based holder binding presented + +### Authorization flows to test + +#### Frontend v2 + +- (Test)Frontend-Client initiates at /api/v1/authorization +- returns redirect for /api/v2/loginQR +- follows redirect, get QR +- scans QR, starts Cross-Device Flow +- handles authentication request: + - byReference + - byValue +- anwers request +- verifier redirects to application +- applications get JWT + +#### Deeplink + +- Client initiates at /api/v1/authorization +- returns redirect to openid-deeplink, to fullfil the same-device flow +- Test-Client follows redirect +- handles authentication request: + - byReference + - byValue +- anwers request +- client follows redirect, get JWT + +## Implementation plan + +### Analysis + +#### Black-box approach + +The integration tests treat the VCVerifier as an opaque HTTP service. The test suite: + +1. **Builds** the verifier binary via `go build` +2. **Generates** a YAML config file at test setup time, pointing to mock HTTP servers +3. **Launches** the binary as a subprocess with `CONFIG_FILE=` environment variable +4. **Interacts** with it purely over HTTP — no Go imports from any verifier-internal package +5. **Tears down** the process after each test group + +This ensures the tests validate the actual shipped artifact and cannot accidentally depend on internal state, unexported functions, or in-process shortcuts. No source code changes to the verifier are required. + +#### External dependencies — mock HTTP servers + +The test harness starts lightweight `httptest.Server` instances before launching the verifier. Their URLs are injected into the generated YAML config. + +- **TIR (Trusted Issuers Registry)** — mock server at a random port, handles `GET /v4/issuers/` returning `TrustedIssuer` JSON or 404. Also handles `GET /v4/issuers` for IsTrustedParticipant calls (returns 200 if DID is trusted, 404 otherwise). +- **did:web resolution** — mock server serving `GET /.well-known/did.json` for the did:web issuer test case. The `did:web` DID is derived from the mock server's `localhost:` address. +- (Gaia-X and JAdES mocks are not needed for the defined test flows — they can be added later.) + +#### Config generation with DCQL + +Each test group generates a `server.yaml` in a temp directory. Service scopes use **DCQL** (Digital Credentials Query Language) to define which credentials the verifier requests. The verifier embeds the DCQL query as `dcql_query` in the request object JWT (byValue/byReference modes). The wallet (test client) responds with a `vp_token` whose format depends on the DCQL query structure. + +Example generated config: + +```yaml +server: + port: + host: "http://localhost:" + templateDir: "views/" + staticDir: "views/static/" +logging: + level: "DEBUG" + jsonLogging: true + logRequests: true +verifier: + did: "did:key:" + tirAddress: "http://localhost:" + validationMode: "none" + keyAlgorithm: "ES256" + generateKey: true + sessionExpiry: 30 + jwtExpiration: 30 + supportedModes: ["byValue", "byReference"] + clientIdentification: + id: "did:key:" + keyPath: "" + requestKeyAlgorithm: "ES256" +m2m: + authEnabled: false +configRepo: + services: + - id: "" + defaultOidcScope: "" + authorizationType: "" + oidcScopes: + : + credentials: + - type: "" + trustedIssuersLists: + - "http://localhost:" + holderVerification: + enabled: + claim: "" + dcql: + credentials: + - id: "" + format: "jwt_vc_json" + meta: + vct_values: + - "" + claims: + - path: ["$.vc.credentialSubject.someField"] +``` + +For SD-JWT credential types, the `format` field in the DCQL query is `dc+sd-jwt` instead of `jwt_vc_json`. + +The `clientIdentification.keyPath` points to a PEM file generated at test setup time (ECDSA P-256 key), which the verifier uses for signing request objects in byValue/byReference modes. + +#### DCQL response format + +When the verifier's request object contains a `dcql_query`, the wallet responds with a `vp_token` that is a **JSON map** keyed by credential query IDs from the DCQL query. Each value is a VP JWT (or SD-JWT) answering that query: + +```json +{ + "query-id-1": "", + "query-id-2": "" +} +``` + +The verifier's `getPresentationFromQuery` function parses this map, extracts each VP, and merges all credentials into a single presentation for validation. + +For a **single credential query**, the map has one entry. For **multiple credential queries**, each query ID maps to its own VP JWT. The test helpers must construct this map format and base64-encode or JSON-encode it as the `vp_token` form value. + +#### Test credential creation + +The test helpers create real, cryptographically signed VCs and VPs using the same libraries the verifier depends on (trustbloc, lestrrat-go/jwx). These helpers are in a separate Go module under `integration_test/` to avoid polluting the main module's dependencies: + +- **did:key identities**: Generate ECDSA P-256 key pairs, derive `did:key` DIDs using `trustbloc/did-go/method/key` Creator +- **JWT-VC signing**: Build JWT claims for a VC, sign with `jws.Sign()` using the issuer's private key, `kid` header set to the issuer's DID key ID +- **VP signing**: Build JWT claims wrapping VC JWTs in the `vp` claim, sign with the holder's private key +- **SD-JWT**: Construct SD-JWT strings (issuer JWT + disclosures + optional key binding JWT) following RFC 9449 +- **DCQL vp_token map**: Build a `map[string]string` mapping DCQL credential query IDs to VP JWT strings, then JSON-encode it for the `vp_token` form value +- **did:web DID documents**: Build a DID document JSON containing the identity's public key, served by the did:web mock server + +#### Process lifecycle management + +``` +TestMain (or suite setup) + ├── go build -o /vcverifier . + │ + For each test group: + ├── Start mock TIR server (httptest.Server) + ├── Start mock did:web server if needed (httptest.Server) + ├── Generate signing key PEM file + ├── Generate server.yaml → /server.yaml + ├── Launch: CONFIG_FILE=/server.yaml /vcverifier + ├── Wait for health check: poll GET /health until 200 (with timeout) + ├── Run test cases against http://localhost: + ├── Send SIGTERM to verifier process + └── Clean up temp files and mock servers +``` + +The verifier process is started fresh for each test group (not each individual test case) to keep test execution fast. Test groups that need different configurations (e.g., different `authorizationType`, different holder verification settings) each get their own process. + +#### Health check wait + +After launching the subprocess, poll `GET /health` with a short interval (100ms) and a timeout (10s). If the process exits before becoming healthy, capture stderr for diagnostics. + +### Build tag + +All integration test files use `//go:build integration` so they don't run during `go test ./...`. Run explicitly: + +```bash +go test -tags integration ./integration_test/... -v -count=1 +``` + +The `-count=1` disables test caching since integration tests depend on external processes. + +### Package structure + +``` +integration_test/ + go.mod -- separate Go module (depends on trustbloc, jwx for credential creation) + go.sum + helpers/ + identity.go -- TestIdentity struct, GenerateDidKeyIdentity(), GenerateDidWebIdentity() + credentials.go -- CreateJWTVC(), CreateVPToken(), CreateSDJWT(), CreateDCQLResponse() + process.go -- VerifierProcess: build, launch, health-wait, shutdown + tir_mock.go -- Mock TIR httptest.Server returning TrustedIssuer JSON + did_web_mock.go -- Mock did:web httptest.Server serving /.well-known/did.json + config.go -- YAML config generation with DCQL, free port allocation, PEM key file generation + m2m_test.go -- M2M success + failure flow tests (vp_token grant type) + frontend_v2_test.go -- Frontend v2 cross-device flow tests + deeplink_test.go -- Deeplink same-device flow tests +``` + +Using a separate `go.mod` ensures: +- The test helper dependencies (trustbloc for did:key creation, jwx for signing) don't leak into the main module if they diverge +- The integration tests are clearly decoupled from the verifier source +- `go test ./...` from the project root naturally skips them (separate module) + +### Steps + +#### Step 1: Test infrastructure and helpers + +**Goal**: The build/launch/teardown harness and credential creation helpers that all tests depend on. + +`helpers/process.go`: +- `BuildVerifier(projectRoot string) (binaryPath string, err error)` — runs `go build -o /vcverifier .` in the project root +- `VerifierProcess` struct: holds `cmd *exec.Cmd`, `Port int`, `BaseURL string`, `configDir string` +- `StartVerifier(configYAML string, projectRoot string, binaryPath string) (*VerifierProcess, error)` — writes config to temp file, starts binary with `CONFIG_FILE` env var, polls `/health` +- `(*VerifierProcess) Stop()` — sends SIGTERM, waits with timeout, kills if needed, cleans temp dir +- `waitForHealthy(baseURL string, timeout time.Duration) error` — polls `GET /health` +- `GetFreePort() (int, error)` — binds to `:0`, reads the assigned port, closes + +`helpers/config.go`: +- `ConfigBuilder` struct with fluent API for constructing the YAML config: + - `NewConfigBuilder(verifierPort int, tirURL string) *ConfigBuilder` + - `WithService(id, scope, authzType string) *ConfigBuilder` + - `WithCredential(serviceId, scope, credType, tirURL string) *ConfigBuilder` + - `WithHolderVerification(serviceId, scope, credType, claim string) *ConfigBuilder` + - `WithDCQL(serviceId, scope string, dcql DCQLConfig) *ConfigBuilder` + - `WithSigningKey(keyPath string) *ConfigBuilder` + - `Build() string` — returns YAML string +- `DCQLConfig` struct: mirrors the DCQL YAML structure for config generation + - `CredentialQuery` struct: `Id string`, `Format string`, `Meta *MetaConfig`, `Claims []ClaimConfig` + - Helper: `NewJWTVCQuery(id, credType string) CredentialQuery` + - Helper: `NewSDJWTQuery(id, vctValue string) CredentialQuery` +- `GenerateSigningKeyPEM(dir string) (keyPath string, err error)` — generates ECDSA P-256 key, writes PEM to file +- `GenerateVerifierDID() (did string, err error)` — generates a did:key for the verifier's identity + +`helpers/identity.go`: +- `TestIdentity` struct: `PrivateKey crypto.Signer`, `PublicKeyJWK jwk.Key`, `DID string`, `KeyID string` +- `GenerateDidKeyIdentity() (*TestIdentity, error)` — ECDSA P-256 key → did:key DID via trustbloc Creator +- `GenerateDidWebIdentity(host string) (*TestIdentity, error)` — ECDSA P-256 key → did:web DID derived from host + +`helpers/credentials.go`: +- `CreateJWTVC(issuer *TestIdentity, credType string, subject map[string]interface{}) (string, error)` — signed JWT-VC +- `CreateJWTVCWithHolder(issuer *TestIdentity, credType string, subject map[string]interface{}, holderDID string) (string, error)` — JWT-VC with claim-based holder binding (adds holder DID into credentialSubject) +- `CreateJWTVCWithCnf(issuer *TestIdentity, credType string, subject map[string]interface{}, holderJWK jwk.Key) (string, error)` — JWT-VC with `cnf` holder binding (adds `cnf.jwk` to the credential) +- `CreateVPToken(holder *TestIdentity, nonce string, audience string, vcJWTs ...string) (string, error)` — signed VP JWT wrapping one or more VC JWTs +- `CreateSDJWT(issuer *TestIdentity, vct string, claims map[string]interface{}, disclosedClaims []string) (string, error)` — SD-JWT credential string +- `CreateVPWithSDJWT(holder *TestIdentity, nonce string, audience string, sdJWTs ...string) (string, error)` — VP JWT containing SD-JWT credentials +- `CreateDCQLResponse(queryResponses map[string]string) (string, error)` — takes a map of DCQL credential query ID → VP JWT string, JSON-encodes it into the `vp_token` value expected by the verifier + +`helpers/tir_mock.go`: +- `MockTIR` struct: maps DID → `TrustedIssuer` (struct defined locally in test helpers, mirroring the TIR JSON schema) +- `TrustedIssuer` struct: `Did string`, `Attributes []IssuerAttribute` +- `IssuerAttribute` struct: `Hash string`, `Body string` (base64-encoded JSON of credential config), `IssuerType string`, `Tao string`, `RootTao string` +- `NewMockTIR(issuers map[string]TrustedIssuer) *httptest.Server` — returns running mock +- Handles: + - `GET /v4/issuers/` → 200 with TrustedIssuer JSON, or 404 + - `GET /v4/issuers?page=&size=` → paginated list (for IsTrustedParticipant) +- `BuildIssuerAttribute(credentialType string, claims []string) IssuerAttribute` — helper to build properly base64-encoded attribute bodies + +`helpers/did_web_mock.go`: +- `NewDidWebServer(identity *TestIdentity) *httptest.Server` — serves `GET /.well-known/did.json` with a DID document containing the identity's public key in JWK format + +#### Step 2: M2M flow tests — success cases + +**Goal**: Test the VP-token-to-JWT exchange via `POST /services/:service_id/token` (grant_type=vp_token). This exercises the full credential validation pipeline without session management. + +**File**: `integration_test/m2m_test.go` + +Table-driven parameterized tests. Each test case gets a fresh verifier process only if the config differs from the previous one (optimization: group cases that share the same config). + +**DCQL config per test case**: Each test case defines its DCQL query in the service config. For single-credential tests, one `CredentialQuery` entry. For multi-credential tests, multiple `CredentialQuery` entries. The test client builds a DCQL response map matching the query IDs. + +**Test cases**: + +| Test name | Format | Count | Issuer DID | Holder binding | DCQL query | +|---|---|---|---|---|---| +| One JWT-VC with did:key issuer | JWT-VC | 1 | did:key | none | 1 query: `jwt_vc_json`, vct `CustomerCredential` | +| Multiple JWT-VCs with did:key issuer | JWT-VC | 2 | did:key | none | 2 queries: `jwt_vc_json`, vct `TypeA` + `TypeB` | +| One SD-JWT with did:key issuer | SD-JWT | 1 | did:key | none | 1 query: `dc+sd-jwt`, vct `CustomerCredential` | +| Multiple SD-JWTs with did:key issuer | SD-JWT | 2 | did:key | none | 2 queries: `dc+sd-jwt`, vct `TypeA` + `TypeB` | +| JWT-VC with did:web issuer | JWT-VC | 1 | did:web | none | 1 query: `jwt_vc_json`, vct `CustomerCredential` | +| JWT-VC with cnf holder binding | JWT-VC | 1 | did:key | cnf | 1 query: `jwt_vc_json`, vct `CustomerCredential` | +| JWT-VC with claim-based holder binding | JWT-VC | 1 | did:key | claim | 1 query: `jwt_vc_json`, vct `CustomerCredential` | + +**Test pattern** (each case): +1. Generate issuer + holder identities (did:key or did:web) +2. Start mock TIR with trusted issuer entries allowing the credential type +3. If did:web: start mock did:web server +4. Generate verifier config YAML with DCQL query matching the credential format and type +5. Start verifier process +6. Create signed VCs (JWT-VC or SD-JWT) +7. Create signed VP(s) containing the VCs +8. Build DCQL response map: `{"": "", ...}` and JSON-encode it via `CreateDCQLResponse()` +9. `POST http://localhost:/services/{serviceId}/token` with form body `grant_type=vp_token&vp_token=&scope=` +10. Assert HTTP 200, parse JSON response body as `{"token_type":"Bearer","access_token":"...","id_token":"...","expires_in":...}` +11. Verify the returned JWT: `GET http://localhost:/.well-known/jwks`, parse JWKS, verify JWT signature, check claims +12. Stop verifier, close mocks + +#### Step 3: M2M flow tests — failure cases + +**Goal**: Test all failure scenarios for the VP-token exchange. + +**File**: `integration_test/m2m_failure_test.go` + +**Test cases**: + +| Test name | Setup | Expected | +|---|---|---| +| Wrong credential type | DCQL requests `TypeA`, VP contains `TypeB` | 400 | +| Missing required claims | VC lacks claims that TIR requires in its attribute body | 400 | +| Untrusted issuer | VC signed by issuer whose DID is not in mock TIR | 400 | +| Invalid VP signature | VP JWT signed with a different key than the holder's | 400 | +| Invalid cnf binding | VC has cnf.jwk for holder A, but VP is signed by holder B | 400 | +| Invalid claim-based holder binding | VC's holder claim contains DID-A, but VP is signed by DID-B | 400 | + +**Test pattern**: Same as Step 2, but the DCQL response map contains VPs with the invalid credentials. Assert non-200 status code and verify the error response body. + +#### Step 4: Frontend v2 flow tests (cross-device) + +**Goal**: End-to-end test of the frontend v2 cross-device flow, treating the verifier as a black box. Verifies that the `dcql_query` claim is present in the request object and that DCQL-formatted responses are accepted. + +**File**: `integration_test/frontend_v2_test.go` + +Configure the service with `authorizationType: "FRONTEND_V2"` and a DCQL query in the generated YAML. + +Two sub-tests: `byReference` and `byValue`. Use an HTTP client configured with `CheckRedirect` returning `http.ErrUseLastResponse` to capture redirects without following them. + +**Test flow (byReference)**: +1. `GET /api/v1/authorization?client_id=&response_type=code&scope=&state=&redirect_uri=&nonce=` +2. Assert 302, parse Location header → confirm it points to `/api/v2/loginQR?state=...&client_id=...&redirect_uri=...&scope=...&nonce=...&request_mode=byReference` +3. `GET /api/v2/loginQR?` — returns HTML page containing the `openid4vp://` URL +4. Parse the HTML response to extract the `openid4vp://` authentication request URL (regex or string scan for the protocol scheme) +5. Parse the `openid4vp://` URL → extract `request_uri` query parameter +6. `GET /api/v1/request/` — fetch the request object (JWT string in response body) +7. Decode the request object JWT (without verification — it's the verifier's own JWT) to extract `response_uri`, `state`, `nonce`, and `dcql_query` +8. Assert `dcql_query` is present and matches the configured DCQL query structure (correct credential query IDs, format, vct_values) +9. Create valid VCs matching the DCQL query +10. Build DCQL response map keyed by the query IDs from step 7 +11. Open WebSocket connection to `ws://localhost:/ws?state=` (using `gorilla/websocket` or `nhooyr.io/websocket`) +12. `POST /api/v1/authentication_response` with form body `state=&vp_token=` +13. Assert HTTP 200 +14. Read WebSocket message — parse JSON to extract `redirectUrl` containing the authorization `code` +15. `POST /token` with form body `grant_type=authorization_code&code=&redirect_uri=` +16. Assert HTTP 200 with valid JWT in response + +**Test flow (byValue)**: Same but the request object JWT is embedded in the `openid4vp://` URL query parameter `request` instead of fetched by reference. Skip step 6; decode the JWT from the URL directly. + +**Note on the QR/HTML step**: Since this is a black-box test, we must work with the HTML response from `/api/v2/loginQR`. The `openid4vp://` URL is embedded in the page for QR code rendering. Extracting it via string matching on the HTML is acceptable for integration tests. + +#### Step 5: Deeplink flow tests (same-device) + +**Goal**: End-to-end test of the deeplink/same-device flow. Same DCQL verification as Frontend v2 but using the same-device redirect pattern. + +**File**: `integration_test/deeplink_test.go` + +Configure the service with `authorizationType: "DEEPLINK"` and a DCQL query in the generated YAML. + +Two sub-tests: `byReference` and `byValue`. The deeplink flow uses byReference by default from the authorization endpoint. + +**Test flow (byReference)**: +1. `GET /api/v1/authorization?client_id=&response_type=code&scope=&state=&redirect_uri=&nonce=` +2. Assert 302, parse Location header → confirm it starts with `openid4vp://` +3. Parse the `openid4vp://` URL → extract `request_uri` query parameter +4. `GET /api/v1/request/` — fetch request object JWT +5. Decode JWT → extract `response_uri`, `state`, `nonce`, and `dcql_query` +6. Assert `dcql_query` is present with expected query structure +7. Create valid VCs matching the DCQL query, build DCQL response map +8. `POST ` (= `http://localhost:/api/v1/authentication_response`) with form body `state=&vp_token=` +9. Assert 302, parse Location header → extract `code` and `state` query parameters from the redirect URL +10. `POST /token` with form body `grant_type=authorization_code&code=&redirect_uri=` +11. Assert HTTP 200 with valid JWT in response + +**Test flow (byValue)**: Same but the `openid4vp://` URL from step 2 contains the request object JWT directly in a `request` parameter. Skip step 4. + +#### Step 6: Cross-cutting concerns and edge cases + +**Goal**: Additional tests not specific to one flow. + +**File**: `integration_test/endpoints_test.go` + +These tests run against a single verifier process with a basic config (including a DCQL query). + +- `GET /.well-known/jwks` → 200, response is valid JWKS JSON with at least one key +- `GET /services//.well-known/openid-configuration` → 200, response contains `issuer`, `token_endpoint`, `jwks_uri` +- `GET /health` → 200 +- `POST /token` without `grant_type` → 400 +- `POST /token` with `grant_type=unsupported` → 400 +- `GET /api/v1/authorization` without `client_id` → 400 +- `GET /api/v1/authorization` without `scope` → 400 +- `GET /api/v1/authorization` without `state` → 400 + +### Test execution summary + +```bash +# Build + run all integration tests +go test -tags integration ./integration_test/... -v -count=1 + +# Run only M2M tests +go test -tags integration ./integration_test/... -v -count=1 -run TestM2M + +# Run only deeplink tests +go test -tags integration ./integration_test/... -v -count=1 -run TestDeeplink +``` + +### Test matrix summary + +The success/failure credential variations (Step 2 + 3) are tested via the M2M flow — this directly exercises the full credential validation pipeline without session management overhead. The authorization flow tests (Step 4 + 5) additionally verify that: +- The request object JWT contains a `dcql_query` claim matching the service config +- The test client can parse the DCQL query, construct a matching DCQL response map, and complete the flow + +All tests use DCQL for credential query configuration. The `vp_token` is always submitted in the DCQL response map format (`{"": ""}`). All tests are true black-box: they only interact with the verifier over HTTP and only depend on its public configuration contract (server.yaml + CONFIG_FILE env var). diff --git a/integration_test/deeplink_test.go b/integration_test/deeplink_test.go new file mode 100644 index 0000000..b251f8f --- /dev/null +++ b/integration_test/deeplink_test.go @@ -0,0 +1,287 @@ +//go:build integration + +package integration_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/fiware/VCVerifier/integration_test/helpers" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // deeplinkState is the session state parameter used in deeplink tests. + deeplinkState = "deeplink-state-42" +) + +// deeplinkFixture holds all infrastructure for a deeplink test. +type deeplinkFixture struct { + verifier *helpers.VerifierProcess + issuer *helpers.TestIdentity + holder *helpers.TestIdentity + cleanup func() +} + +// setupDeeplink creates a verifier configured for DEEPLINK with a simple JWT-VC credential. +func setupDeeplink(t *testing.T) *deeplinkFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + vp, err := helpers.StartVerifier(config, projectRoot, binaryPath) + require.NoError(t, err) + + return &deeplinkFixture{ + verifier: vp, + issuer: issuer, + holder: holder, + cleanup: func() { + vp.Stop() + tirServer.Close() + }, + } +} + +// TestDeeplinkByReference tests the deeplink same-device flow using byReference mode. +// The authorization endpoint redirects directly to openid4vp:// with a request_uri. +func TestDeeplinkByReference(t *testing.T) { + fixture := setupDeeplink(t) + defer fixture.cleanup() + + noRedirectClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // Step 1: Initiate authorization → expect redirect to openid4vp:// + authURL := fmt.Sprintf("%s/api/v1/authorization?client_id=%s&response_type=code&scope=%s&state=%s&redirect_uri=%s&nonce=test-nonce", + fixture.verifier.BaseURL, serviceID, scopeName, deeplinkState, url.QueryEscape(redirectURI)) + + resp, err := noRedirectClient.Get(authURL) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusFound, resp.StatusCode, "authorization should redirect") + + // Step 2: Parse the openid4vp:// redirect Location header + location := resp.Header.Get("Location") + require.NotEmpty(t, location, "redirect Location header must be set") + assert.True(t, strings.HasPrefix(location, "openid4vp://"), "should redirect to openid4vp:// URL, got: %s", location) + + // Step 3: Extract request_uri from the openid4vp URL + parsedAuth, err := url.Parse(location) + require.NoError(t, err) + requestURI := parsedAuth.Query().Get("request_uri") + require.NotEmpty(t, requestURI, "request_uri must be present in openid4vp URL") + + // Step 4: Fetch the request object JWT + requestObjResp, err := http.Get(requestURI) + require.NoError(t, err) + defer requestObjResp.Body.Close() + assert.Equal(t, http.StatusOK, requestObjResp.StatusCode, "request object endpoint should return 200") + + requestObjBody, err := io.ReadAll(requestObjResp.Body) + require.NoError(t, err) + + // Step 5: Decode request object JWT to extract claims + requestToken, err := jwt.Parse([]byte(requestObjBody), jwt.WithVerify(false)) + require.NoError(t, err, "request object should be valid JWT") + + responseURIRaw := getStringClaim(t, requestToken, "response_uri") + require.NotEmpty(t, responseURIRaw, "response_uri must be in request object") + + stateClaim := getStringClaim(t, requestToken, "state") + require.NotEmpty(t, stateClaim, "state must be in request object") + + // Step 6: Verify dcql_query is present + assertHasClaim(t, requestToken, "dcql_query") + + // Step 7: Create valid credentials and DCQL response + vc, err := helpers.CreateJWTVC(fixture.issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(fixture.holder, "", serviceID, vc) + require.NoError(t, err) + + vpToken, err := helpers.CreateDCQLResponse(map[string]string{"cred-1": vpJWT}) + require.NoError(t, err) + + // Step 8: POST authentication response → expect 302 redirect with code + authResp, err := noRedirectClient.PostForm(responseURIRaw, url.Values{ + "state": {stateClaim}, + "vp_token": {vpToken}, + }) + require.NoError(t, err) + defer authResp.Body.Close() + assert.Equal(t, http.StatusFound, authResp.StatusCode, "authentication response should redirect (same-device)") + + // Step 9: Extract authorization code from redirect Location + authRedirect := authResp.Header.Get("Location") + require.NotEmpty(t, authRedirect, "redirect Location must be set after authentication") + + parsedRedirect, err := url.Parse(authRedirect) + require.NoError(t, err) + code := parsedRedirect.Query().Get("code") + require.NotEmpty(t, code, "authorization code must be in redirect URL") + + // Step 10: Exchange authorization code for JWT + // For DEEPLINK same-device flow, the stored callback is the verifier's own base URL + // (since redirectPath is empty in the authorization endpoint call). + // The redirect_uri in the token exchange must match the stored callback. + tokenResp, err := http.PostForm(fmt.Sprintf("%s/token", fixture.verifier.BaseURL), url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {fixture.verifier.BaseURL}, + }) + require.NoError(t, err) + defer tokenResp.Body.Close() + assert.Equal(t, http.StatusOK, tokenResp.StatusCode, "token exchange should return 200") + + var tokenBody map[string]interface{} + err = json.NewDecoder(tokenResp.Body).Decode(&tokenBody) + require.NoError(t, err) + assert.Equal(t, "Bearer", tokenBody["token_type"]) + accessToken, ok := tokenBody["access_token"].(string) + require.True(t, ok && accessToken != "", "access_token must be a non-empty string") + + // Step 11: Verify the returned JWT + verifyAccessToken(t, fixture.verifier.BaseURL, accessToken) +} + +// TestDeeplinkByValue tests the deeplink same-device flow using byValue mode. +// The openid4vp:// URL contains the request object JWT directly in a "request" parameter. +func TestDeeplinkByValue(t *testing.T) { + fixture := setupDeeplink(t) + defer fixture.cleanup() + + noRedirectClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // Step 1: Use /api/v1/samedevice with request_mode=byValue to get the openid4vp:// URL + // The authorization endpoint always forces byReference, so we use the samedevice endpoint directly. + sameDeviceURL := fmt.Sprintf("%s/api/v1/samedevice?state=%s&client_id=%s&scope=%s&request_mode=byValue", + fixture.verifier.BaseURL, deeplinkState, serviceID, scopeName) + + resp, err := noRedirectClient.Get(sameDeviceURL) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusFound, resp.StatusCode, "samedevice should redirect") + + // Step 2: Parse the openid4vp:// redirect with embedded request JWT + location := resp.Header.Get("Location") + require.NotEmpty(t, location, "redirect Location header must be set") + assert.True(t, strings.HasPrefix(location, "openid4vp://"), "should redirect to openid4vp:// URL") + + parsedAuth, err := url.Parse(location) + require.NoError(t, err) + requestJWTStr := parsedAuth.Query().Get("request") + require.NotEmpty(t, requestJWTStr, "request parameter must be present in openid4vp URL (byValue mode)") + + // Step 3: Decode the embedded request object JWT + requestToken, err := jwt.Parse([]byte(requestJWTStr), jwt.WithVerify(false)) + require.NoError(t, err, "embedded request object should be valid JWT") + + responseURIRaw := getStringClaim(t, requestToken, "response_uri") + require.NotEmpty(t, responseURIRaw, "response_uri must be in request object") + + stateClaim := getStringClaim(t, requestToken, "state") + require.NotEmpty(t, stateClaim, "state must be in request object") + + // Verify dcql_query is present + assertHasClaim(t, requestToken, "dcql_query") + + // Step 4: Create valid credentials and DCQL response + vc, err := helpers.CreateJWTVC(fixture.issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(fixture.holder, "", serviceID, vc) + require.NoError(t, err) + + vpToken, err := helpers.CreateDCQLResponse(map[string]string{"cred-1": vpJWT}) + require.NoError(t, err) + + // Step 5: POST authentication response → expect 302 redirect with code + authResp, err := noRedirectClient.PostForm(responseURIRaw, url.Values{ + "state": {stateClaim}, + "vp_token": {vpToken}, + }) + require.NoError(t, err) + defer authResp.Body.Close() + assert.Equal(t, http.StatusFound, authResp.StatusCode, "authentication response should redirect (same-device)") + + // Step 6: Extract authorization code from redirect + authRedirect := authResp.Header.Get("Location") + require.NotEmpty(t, authRedirect, "redirect Location must be set after authentication") + + parsedRedirect, err := url.Parse(authRedirect) + require.NoError(t, err) + code := parsedRedirect.Query().Get("code") + require.NotEmpty(t, code, "authorization code must be in redirect URL") + + // Step 7: Exchange authorization code for JWT + // The stored callback for same-device flow is the verifier's own base URL. + tokenResp, err := http.PostForm(fmt.Sprintf("%s/token", fixture.verifier.BaseURL), url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {fixture.verifier.BaseURL}, + }) + require.NoError(t, err) + defer tokenResp.Body.Close() + assert.Equal(t, http.StatusOK, tokenResp.StatusCode, "token exchange should return 200") + + var tokenBody map[string]interface{} + err = json.NewDecoder(tokenResp.Body).Decode(&tokenBody) + require.NoError(t, err) + assert.Equal(t, "Bearer", tokenBody["token_type"]) + accessToken, ok := tokenBody["access_token"].(string) + require.True(t, ok && accessToken != "", "access_token must be a non-empty string") + + verifyAccessToken(t, fixture.verifier.BaseURL, accessToken) +} diff --git a/integration_test/endpoints_test.go b/integration_test/endpoints_test.go new file mode 100644 index 0000000..5699d98 --- /dev/null +++ b/integration_test/endpoints_test.go @@ -0,0 +1,157 @@ +//go:build integration + +package integration_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + + "github.com/fiware/VCVerifier/integration_test/helpers" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// endpointFixture holds a running verifier for endpoint tests. +type endpointFixture struct { + verifier *helpers.VerifierProcess + cleanup func() +} + +// setupEndpointTests creates a verifier with a basic DEEPLINK config for endpoint testing. +func setupEndpointTests(t *testing.T) *endpointFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + vp, err := helpers.StartVerifier(config, projectRoot, binaryPath) + require.NoError(t, err) + + return &endpointFixture{ + verifier: vp, + cleanup: func() { + vp.Stop() + tirServer.Close() + }, + } +} + +// TestEndpoints runs parameterized tests for cross-cutting endpoint concerns and edge cases. +func TestEndpoints(t *testing.T) { + fixture := setupEndpointTests(t) + defer fixture.cleanup() + + baseURL := fixture.verifier.BaseURL + + t.Run("JWKS", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("%s/.well-known/jwks", baseURL)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + keySet, err := jwk.Parse(body) + require.NoError(t, err, "response must be valid JWKS JSON") + assert.True(t, keySet.Len() > 0, "JWKS should contain at least one key") + }) + + t.Run("OpenIDConfiguration", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("%s/services/%s/.well-known/openid-configuration", baseURL, serviceID)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var config map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&config) + require.NoError(t, err, "response must be valid JSON") + + assert.NotEmpty(t, config["issuer"], "openid-configuration must contain issuer") + assert.NotEmpty(t, config["token_endpoint"], "openid-configuration must contain token_endpoint") + assert.NotEmpty(t, config["jwks_uri"], "openid-configuration must contain jwks_uri") + }) + + t.Run("Health", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("%s/health", baseURL)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("TokenWithoutGrantType", func(t *testing.T) { + resp, err := http.PostForm(fmt.Sprintf("%s/token", baseURL), url.Values{ + "code": {"some-code"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("TokenWithUnsupportedGrantType", func(t *testing.T) { + resp, err := http.PostForm(fmt.Sprintf("%s/token", baseURL), url.Values{ + "grant_type": {"unsupported_type"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("AuthorizationWithoutClientID", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("%s/api/v1/authorization?scope=%s&state=test-state&redirect_uri=http://example.com", + baseURL, scopeName)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("AuthorizationWithoutScope", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("%s/api/v1/authorization?client_id=%s&state=test-state&redirect_uri=http://example.com", + baseURL, serviceID)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("AuthorizationWithoutState", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("%s/api/v1/authorization?client_id=%s&scope=%s&redirect_uri=http://example.com", + baseURL, serviceID, scopeName)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} diff --git a/integration_test/frontend_v2_test.go b/integration_test/frontend_v2_test.go new file mode 100644 index 0000000..d37c209 --- /dev/null +++ b/integration_test/frontend_v2_test.go @@ -0,0 +1,386 @@ +//go:build integration + +package integration_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "testing" + "time" + + "github.com/fiware/VCVerifier/integration_test/helpers" + "github.com/gorilla/websocket" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // redirectURI is the simulated callback URI for the frontend application. + redirectURI = "http://localhost:9999/callback" + // testState is the session state parameter used in frontend v2 tests. + testState = "test-state-12345" +) + +// frontendV2Fixture holds all infrastructure for a frontend v2 test. +type frontendV2Fixture struct { + verifier *helpers.VerifierProcess + issuer *helpers.TestIdentity + holder *helpers.TestIdentity + cleanup func() +} + +// setupFrontendV2 creates a verifier configured for FRONTEND_V2 with a simple JWT-VC credential. +func setupFrontendV2(t *testing.T) *frontendV2Fixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "FRONTEND_V2"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + vp, err := helpers.StartVerifier(config, projectRoot, binaryPath) + require.NoError(t, err) + + return &frontendV2Fixture{ + verifier: vp, + issuer: issuer, + holder: holder, + cleanup: func() { + vp.Stop() + tirServer.Close() + }, + } +} + +// TestFrontendV2ByReference tests the complete frontend v2 cross-device flow using byReference mode. +func TestFrontendV2ByReference(t *testing.T) { + fixture := setupFrontendV2(t) + defer fixture.cleanup() + + noRedirectClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // Step 1: Initiate authorization → expect redirect to /api/v2/loginQR + authURL := fmt.Sprintf("%s/api/v1/authorization?client_id=%s&response_type=code&scope=%s&state=%s&redirect_uri=%s&nonce=test-nonce", + fixture.verifier.BaseURL, serviceID, scopeName, testState, url.QueryEscape(redirectURI)) + + resp, err := noRedirectClient.Get(authURL) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusFound, resp.StatusCode, "authorization should redirect") + + location := resp.Header.Get("Location") + require.NotEmpty(t, location, "redirect Location header must be set") + assert.Contains(t, location, "/api/v2/loginQR", "should redirect to loginQR v2") + assert.Contains(t, location, "request_mode=byReference", "should use byReference mode") + + // Step 2: Follow redirect to /api/v2/loginQR → get HTML with openid4vp:// URL + loginQRURL := location + // The redirect URL may be relative or absolute; handle both. + if !strings.HasPrefix(loginQRURL, "http") { + loginQRURL = fixture.verifier.BaseURL + loginQRURL + } + + qrResp, err := http.Get(loginQRURL) + require.NoError(t, err) + defer qrResp.Body.Close() + assert.Equal(t, http.StatusOK, qrResp.StatusCode, "loginQR should return 200") + + htmlBody, err := io.ReadAll(qrResp.Body) + require.NoError(t, err) + htmlStr := string(htmlBody) + + // Step 3: Extract openid4vp:// URL from the HTML (it's in the href of the "Open in Wallet" link) + authRequestURL := extractOpenID4VPURL(t, htmlStr) + require.NotEmpty(t, authRequestURL, "openid4vp URL must be present in HTML") + + // Step 4: Parse the openid4vp:// URL and extract request_uri + // The HTML template may encode ampersands as & in href attributes. + authRequestURL = strings.ReplaceAll(authRequestURL, "&", "&") + parsedAuth, err := url.Parse(authRequestURL) + require.NoError(t, err) + requestURI := parsedAuth.Query().Get("request_uri") + require.NotEmpty(t, requestURI, "request_uri must be present in openid4vp URL") + + // Step 5: Fetch the request object JWT + requestObjResp, err := http.Get(requestURI) + require.NoError(t, err) + defer requestObjResp.Body.Close() + assert.Equal(t, http.StatusOK, requestObjResp.StatusCode, "request object endpoint should return 200") + + requestObjBody, err := io.ReadAll(requestObjResp.Body) + require.NoError(t, err) + requestJWT := string(requestObjBody) + + // Step 6: Decode request object JWT (without verification) to extract claims + requestToken, err := jwt.Parse([]byte(requestJWT), jwt.WithVerify(false)) + require.NoError(t, err, "request object should be valid JWT") + + responseURIRaw := getStringClaim(t, requestToken, "response_uri") + require.NotEmpty(t, responseURIRaw, "response_uri must be in request object") + // The loginQR handler hardcodes "https" as protocol, but our test verifier runs plain HTTP. + responseURI := strings.Replace(responseURIRaw, "https://", "http://", 1) + + stateClaim := getStringClaim(t, requestToken, "state") + require.NotEmpty(t, stateClaim, "state must be in request object") + + // Verify dcql_query is present + assertHasClaim(t, requestToken, "dcql_query") + + // Step 7: Create valid credentials matching the DCQL query + vc, err := helpers.CreateJWTVC(fixture.issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(fixture.holder, "", serviceID, vc) + require.NoError(t, err) + + vpToken, err := helpers.CreateDCQLResponse(map[string]string{"cred-1": vpJWT}) + require.NoError(t, err) + + // Step 8: Open WebSocket connection BEFORE posting the authentication response + wsURL := fmt.Sprintf("ws://localhost:%d/ws?state=%s", fixture.verifier.Port, stateClaim) + wsConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err, "WebSocket connection should succeed") + defer wsConn.Close() + + // Read the initial "session" message + var sessionMsg map[string]interface{} + err = wsConn.ReadJSON(&sessionMsg) + require.NoError(t, err, "should receive session message") + assert.Equal(t, "session", sessionMsg["type"], "first message should be type=session") + + // Step 9: POST authentication response + authResp, err := http.PostForm(responseURI, url.Values{ + "state": {stateClaim}, + "vp_token": {vpToken}, + }) + require.NoError(t, err) + defer authResp.Body.Close() + assert.Equal(t, http.StatusOK, authResp.StatusCode, "authentication response should return 200") + + // Step 10: Read WebSocket "authenticated" message with redirect URL + wsConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var authMsg map[string]interface{} + err = wsConn.ReadJSON(&authMsg) + require.NoError(t, err, "should receive authenticated message") + assert.Equal(t, "authenticated", authMsg["type"], "message should be type=authenticated") + + wsRedirectURL, ok := authMsg["redirectUrl"].(string) + require.True(t, ok, "redirectUrl must be a string") + require.NotEmpty(t, wsRedirectURL, "redirectUrl must not be empty") + + // Step 11: Extract authorization code from the redirect URL + parsedRedirect, err := url.Parse(wsRedirectURL) + require.NoError(t, err) + code := parsedRedirect.Query().Get("code") + require.NotEmpty(t, code, "authorization code must be in redirect URL") + + // Step 12: Exchange authorization code for JWT at /token endpoint + tokenResp, err := http.PostForm(fmt.Sprintf("%s/token", fixture.verifier.BaseURL), url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + }) + require.NoError(t, err) + defer tokenResp.Body.Close() + assert.Equal(t, http.StatusOK, tokenResp.StatusCode, "token exchange should return 200") + + var tokenBody map[string]interface{} + err = json.NewDecoder(tokenResp.Body).Decode(&tokenBody) + require.NoError(t, err) + assert.Equal(t, "Bearer", tokenBody["token_type"]) + accessToken, ok := tokenBody["access_token"].(string) + require.True(t, ok && accessToken != "", "access_token must be a non-empty string") + + // Step 13: Verify the returned JWT + verifyAccessToken(t, fixture.verifier.BaseURL, accessToken) +} + +// TestFrontendV2ByValue tests the complete frontend v2 cross-device flow using byValue mode. +// In byValue mode, the request object JWT is embedded directly in the openid4vp:// URL. +func TestFrontendV2ByValue(t *testing.T) { + fixture := setupFrontendV2(t) + defer fixture.cleanup() + + // For byValue, call /api/v2/loginQR directly with request_mode=byValue + loginQRURL := fmt.Sprintf("%s/api/v2/loginQR?state=%s&client_id=%s&redirect_uri=%s&scope=%s&nonce=test-nonce&request_mode=byValue", + fixture.verifier.BaseURL, testState, serviceID, url.QueryEscape(redirectURI), scopeName) + + qrResp, err := http.Get(loginQRURL) + require.NoError(t, err) + defer qrResp.Body.Close() + assert.Equal(t, http.StatusOK, qrResp.StatusCode, "loginQR should return 200") + + htmlBody, err := io.ReadAll(qrResp.Body) + require.NoError(t, err) + + // Step 1: Extract openid4vp:// URL from HTML + authRequestURL := extractOpenID4VPURL(t, string(htmlBody)) + require.NotEmpty(t, authRequestURL, "openid4vp URL must be present in HTML") + + // Step 2: In byValue mode, the request JWT is embedded in the "request" query parameter + // The HTML template may encode ampersands as & in href attributes. + authRequestURL = strings.ReplaceAll(authRequestURL, "&", "&") + parsedAuth, err := url.Parse(authRequestURL) + require.NoError(t, err) + requestJWTStr := parsedAuth.Query().Get("request") + require.NotEmpty(t, requestJWTStr, "request parameter must be present in openid4vp URL (byValue mode)") + + // Step 3: Decode the request object JWT (without verification) + requestToken, err := jwt.Parse([]byte(requestJWTStr), jwt.WithVerify(false)) + require.NoError(t, err, "embedded request object should be valid JWT") + + responseURIRaw := getStringClaim(t, requestToken, "response_uri") + require.NotEmpty(t, responseURIRaw, "response_uri must be in request object") + // The loginQR handler hardcodes "https" as protocol, but our test verifier runs plain HTTP. + responseURI := strings.Replace(responseURIRaw, "https://", "http://", 1) + + stateClaim := getStringClaim(t, requestToken, "state") + require.NotEmpty(t, stateClaim, "state must be in request object") + + // Verify dcql_query is present + assertHasClaim(t, requestToken, "dcql_query") + + // Step 4: Create valid credentials + vc, err := helpers.CreateJWTVC(fixture.issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(fixture.holder, "", serviceID, vc) + require.NoError(t, err) + + vpToken, err := helpers.CreateDCQLResponse(map[string]string{"cred-1": vpJWT}) + require.NoError(t, err) + + // Step 5: Open WebSocket connection + wsURL := fmt.Sprintf("ws://localhost:%d/ws?state=%s", fixture.verifier.Port, stateClaim) + wsConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err, "WebSocket connection should succeed") + defer wsConn.Close() + + // Read initial session message + var sessionMsg map[string]interface{} + err = wsConn.ReadJSON(&sessionMsg) + require.NoError(t, err, "should receive session message") + assert.Equal(t, "session", sessionMsg["type"]) + + // Step 6: POST authentication response + authResp, err := http.PostForm(responseURI, url.Values{ + "state": {stateClaim}, + "vp_token": {vpToken}, + }) + require.NoError(t, err) + defer authResp.Body.Close() + assert.Equal(t, http.StatusOK, authResp.StatusCode) + + // Step 7: Read WebSocket authenticated message + wsConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var authMsg map[string]interface{} + err = wsConn.ReadJSON(&authMsg) + require.NoError(t, err, "should receive authenticated message") + assert.Equal(t, "authenticated", authMsg["type"]) + + wsRedirectURL, ok := authMsg["redirectUrl"].(string) + require.True(t, ok && wsRedirectURL != "", "redirectUrl must be a non-empty string") + + // Step 8: Extract code and exchange for JWT + parsedRedirect, err := url.Parse(wsRedirectURL) + require.NoError(t, err) + code := parsedRedirect.Query().Get("code") + require.NotEmpty(t, code) + + tokenResp, err := http.PostForm(fmt.Sprintf("%s/token", fixture.verifier.BaseURL), url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + }) + require.NoError(t, err) + defer tokenResp.Body.Close() + assert.Equal(t, http.StatusOK, tokenResp.StatusCode) + + var tokenBody map[string]interface{} + err = json.NewDecoder(tokenResp.Body).Decode(&tokenBody) + require.NoError(t, err) + assert.Equal(t, "Bearer", tokenBody["token_type"]) + accessToken, ok := tokenBody["access_token"].(string) + require.True(t, ok && accessToken != "", "access_token must be a non-empty string") + + verifyAccessToken(t, fixture.verifier.BaseURL, accessToken) +} + +// --- Helper functions --- + +// openid4vpHrefPattern matches href="openid4vp://..." in the HTML template. +var openid4vpHrefPattern = regexp.MustCompile(`href="(openid4vp://[^"]+)"`) + +// extractOpenID4VPURL extracts the openid4vp:// URL from the loginQR HTML page. +func extractOpenID4VPURL(t *testing.T, html string) string { + t.Helper() + matches := openid4vpHrefPattern.FindStringSubmatch(html) + if len(matches) < 2 { + return "" + } + return matches[1] +} + +// getStringClaim extracts a string claim from a JWT token. +func getStringClaim(t *testing.T, token jwt.Token, key string) string { + t.Helper() + var val string + err := token.Get(key, &val) + if err != nil { + return "" + } + return val +} + +// assertHasClaim asserts that a JWT token contains the given claim. +func assertHasClaim(t *testing.T, token jwt.Token, key string) { + t.Helper() + var val interface{} + err := token.Get(key, &val) + require.NoError(t, err, "claim %q must be present in JWT", key) + require.NotNil(t, val, "claim %q must not be nil", key) +} + diff --git a/integration_test/go.mod b/integration_test/go.mod new file mode 100644 index 0000000..16f04a0 --- /dev/null +++ b/integration_test/go.mod @@ -0,0 +1,41 @@ +module github.com/fiware/VCVerifier/integration_test + +go 1.23.0 + +toolchain go1.24.2 + +require ( + github.com/lestrrat-go/jwx/v3 v3.0.1 + github.com/stretchr/testify v1.10.0 + github.com/trustbloc/kms-go v1.1.0 +) + +require ( + github.com/IBM/mathlib v0.0.3-0.20231011094432-44ee0eb539da // indirect + github.com/bits-and-blooms/bitset v1.7.0 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect + github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce // indirect + github.com/consensys/bavard v0.1.13 // indirect + github.com/consensys/gnark-crypto v0.12.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.1-0.20221117193127-916db76e8214 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/hyperledger/fabric-amcl v0.0.0-20230602173724-9e02669dceb2 // indirect + github.com/kilic/bls12-381 v0.1.1-0.20210503002446-7b7597926c69 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/mmcloughlin/addchain v0.4.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/teserakt-io/golang-ed25519 v0.0.0-20210104091850-3888c087a4c8 // indirect + github.com/trustbloc/bbs-signature-go v1.0.1 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sys v0.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + rsc.io/tmplfunc v0.0.3 // indirect +) diff --git a/integration_test/go.sum b/integration_test/go.sum new file mode 100644 index 0000000..50a915b --- /dev/null +++ b/integration_test/go.sum @@ -0,0 +1,118 @@ +github.com/IBM/mathlib v0.0.3-0.20231011094432-44ee0eb539da h1:qqGozq4tF6EOVnWoTgBoJGudRKKZXSAYnEtDggzTnsw= +github.com/IBM/mathlib v0.0.3-0.20231011094432-44ee0eb539da/go.mod h1:Tco9QzE3fQzjMS7nPbHDeFfydAzctStf1Pa8hsh6Hjs= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/bits-and-blooms/bitset v1.7.0 h1:YjAGVd3XmtK9ktAbX8Zg2g2PwLIMjGREZJHlV4j7NEo= +github.com/bits-and-blooms/bitset v1.7.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce h1:YtWJF7RHm2pYCvA5t0RPmAaLUhREsKuKd+SLhxFbFeQ= +github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce/go.mod h1:0DVlHczLPewLcPGEIeUEzfOJhqGPQ0mJJRDBtD307+o= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ= +github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= +github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M= +github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-jose/go-jose/v3 v3.0.1-0.20221117193127-916db76e8214 h1:w5li6eMV6NCHh1YVbKRM/gMCVtZ2w7mnwq78eNnHXQQ= +github.com/go-jose/go-jose/v3 v3.0.1-0.20221117193127-916db76e8214/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hyperledger/fabric-amcl v0.0.0-20230602173724-9e02669dceb2 h1:B1Nt8hKb//KvgGRprk0h1t4lCnwhE9/ryb1WqfZbV+M= +github.com/hyperledger/fabric-amcl v0.0.0-20230602173724-9e02669dceb2/go.mod h1:X+DIyUsaTmalOpmpQfIvFZjKHQedrURQ5t4YqquX7lE= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kilic/bls12-381 v0.1.1-0.20210503002446-7b7597926c69 h1:kMJlf8z8wUcpyI+FQJIdGjAhfTww1y0AbQEv86bpVQI= +github.com/kilic/bls12-381 v0.1.1-0.20210503002446-7b7597926c69/go.mod h1:tlkavyke+Ac7h8R3gZIjI5LKBcvMlSWnXNMgT3vZXo8= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c= +github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY= +github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE= +github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY= +github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= +github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= +github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teserakt-io/golang-ed25519 v0.0.0-20210104091850-3888c087a4c8 h1:RBkacARv7qY5laaXGlF4wFB/tk5rnthhPb8oIBGoagY= +github.com/teserakt-io/golang-ed25519 v0.0.0-20210104091850-3888c087a4c8/go.mod h1:9PdLyPiZIiW3UopXyRnPYyjUXSpiQNHRLu8fOsR3o8M= +github.com/trustbloc/bbs-signature-go v1.0.1 h1:Nv/DCGVMQiY27dV0mD4U4924jGAnru/u3V+/QWivm8c= +github.com/trustbloc/bbs-signature-go v1.0.1/go.mod h1:gjYaYD+/wqBsA0IIdZBoCKSNKPXi775J2LE45u6pX+8= +github.com/trustbloc/kms-go v1.1.0 h1:npKO9hLrE1GbLmVw0Trpkiad5xNnSRmmhUk+80qYe0A= +github.com/trustbloc/kms-go v1.1.0/go.mod h1:FEo4tIRWv7zNwgZsWZ4g10e/gOkJL5ybQtSrY2UdOXM= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= +rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= diff --git a/integration_test/helpers/config.go b/integration_test/helpers/config.go new file mode 100644 index 0000000..0c9033c --- /dev/null +++ b/integration_test/helpers/config.go @@ -0,0 +1,416 @@ +package helpers + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "strings" +) + +// DCQLConfig represents a DCQL query configuration for service scopes. +type DCQLConfig struct { + Credentials []CredentialQuery `json:"credentials"` + CredentialSets []CredentialSetQuery `json:"credential_sets,omitempty"` +} + +// CredentialQuery defines a single credential query within DCQL. +type CredentialQuery struct { + Id string `json:"id"` + Format string `json:"format"` + Meta *MetaQuery `json:"meta,omitempty"` + Claims []ClaimDef `json:"claims,omitempty"` +} + +// MetaQuery defines metadata constraints for a credential query. +type MetaQuery struct { + VctValues []string `json:"vct_values,omitempty"` +} + +// ClaimDef defines a claim to be requested in a DCQL query. +type ClaimDef struct { + Path []string `json:"path"` +} + +// CredentialSetQuery defines additional constraints on which credentials to return. +type CredentialSetQuery struct { + Options [][]string `json:"options"` + Purpose string `json:"purpose,omitempty"` +} + +// NewJWTVCQuery creates a CredentialQuery for a JWT-VC credential type. +func NewJWTVCQuery(id, credType string) CredentialQuery { + return CredentialQuery{ + Id: id, + Format: "jwt_vc_json", + Meta: &MetaQuery{VctValues: []string{credType}}, + } +} + +// NewSDJWTQuery creates a CredentialQuery for an SD-JWT credential type. +func NewSDJWTQuery(id, vctValue string) CredentialQuery { + return CredentialQuery{ + Id: id, + Format: "dc+sd-jwt", + Meta: &MetaQuery{VctValues: []string{vctValue}}, + } +} + +// ServiceConfig holds the configuration for a single service within the config builder. +type ServiceConfig struct { + ID string + DefaultScope string + AuthorizationType string + Scopes map[string]*ScopeConfig +} + +// ScopeConfig holds configuration for a single scope within a service. +type ScopeConfig struct { + Credentials []CredentialConfig + DCQL *DCQLConfig +} + +// CredentialConfig holds configuration for a single credential type within a scope. +type CredentialConfig struct { + Type string + TrustedIssuersLists []string + HolderVerification *HolderVerificationConfig + TrustedParticipants []TrustedParticipantsListConfig + JwtInclusion *JwtInclusionConfig +} + +// JwtInclusionConfig defines JWT inclusion settings for a credential type. +type JwtInclusionConfig struct { + Enabled bool + FullInclusion bool +} + +// HolderVerificationConfig defines holder verification settings. +type HolderVerificationConfig struct { + Enabled bool + Claim string +} + +// TrustedParticipantsListConfig defines a trusted participants list entry. +type TrustedParticipantsListConfig struct { + Type string + URL string +} + +// ConfigBuilder provides a fluent API for constructing VCVerifier YAML configs. +type ConfigBuilder struct { + verifierPort int + tirURL string + signingKey string + services map[string]*ServiceConfig +} + +// NewConfigBuilder creates a new ConfigBuilder with the verifier port and default TIR URL. +func NewConfigBuilder(verifierPort int, tirURL string) *ConfigBuilder { + return &ConfigBuilder{ + verifierPort: verifierPort, + tirURL: tirURL, + services: make(map[string]*ServiceConfig), + } +} + +// WithService adds or updates a service configuration. +func (cb *ConfigBuilder) WithService(id, defaultScope, authzType string) *ConfigBuilder { + if _, exists := cb.services[id]; !exists { + cb.services[id] = &ServiceConfig{ + ID: id, + DefaultScope: defaultScope, + AuthorizationType: authzType, + Scopes: make(map[string]*ScopeConfig), + } + } else { + cb.services[id].DefaultScope = defaultScope + cb.services[id].AuthorizationType = authzType + } + return cb +} + +// WithCredential adds a credential type to a service scope. +func (cb *ConfigBuilder) WithCredential(serviceID, scope, credType, tirURL string) *ConfigBuilder { + svc := cb.ensureService(serviceID) + sc := cb.ensureScope(svc, scope) + sc.Credentials = append(sc.Credentials, CredentialConfig{ + Type: credType, + TrustedIssuersLists: []string{tirURL}, + }) + return cb +} + +// WithHolderVerification enables holder verification for a credential in a scope. +func (cb *ConfigBuilder) WithHolderVerification(serviceID, scope, credType, claim string) *ConfigBuilder { + svc := cb.ensureService(serviceID) + sc := cb.ensureScope(svc, scope) + for i := range sc.Credentials { + if sc.Credentials[i].Type == credType { + sc.Credentials[i].HolderVerification = &HolderVerificationConfig{ + Enabled: true, + Claim: claim, + } + return cb + } + } + return cb +} + +// WithJwtInclusion enables JWT inclusion for a credential type in a scope. +func (cb *ConfigBuilder) WithJwtInclusion(serviceID, scope, credType string, fullInclusion bool) *ConfigBuilder { + svc := cb.ensureService(serviceID) + sc := cb.ensureScope(svc, scope) + for i := range sc.Credentials { + if sc.Credentials[i].Type == credType { + sc.Credentials[i].JwtInclusion = &JwtInclusionConfig{ + Enabled: true, + FullInclusion: fullInclusion, + } + return cb + } + } + return cb +} + +// WithTrustedParticipantsList adds a trusted participants list to a credential in a scope. +func (cb *ConfigBuilder) WithTrustedParticipantsList(serviceID, scope, credType, listType, listURL string) *ConfigBuilder { + svc := cb.ensureService(serviceID) + sc := cb.ensureScope(svc, scope) + for i := range sc.Credentials { + if sc.Credentials[i].Type == credType { + sc.Credentials[i].TrustedParticipants = append(sc.Credentials[i].TrustedParticipants, TrustedParticipantsListConfig{ + Type: listType, + URL: listURL, + }) + return cb + } + } + return cb +} + +// WithDCQL sets the DCQL query for a service scope. +func (cb *ConfigBuilder) WithDCQL(serviceID, scope string, dcql DCQLConfig) *ConfigBuilder { + svc := cb.ensureService(serviceID) + sc := cb.ensureScope(svc, scope) + sc.DCQL = &dcql + return cb +} + +// WithSigningKey sets the path to the verifier's signing key PEM file. +func (cb *ConfigBuilder) WithSigningKey(keyPath string) *ConfigBuilder { + cb.signingKey = keyPath + return cb +} + +// Build generates the YAML configuration string. +func (cb *ConfigBuilder) Build() string { + var b strings.Builder + + b.WriteString("server:\n") + b.WriteString(fmt.Sprintf(" port: %d\n", cb.verifierPort)) + b.WriteString(fmt.Sprintf(" host: \"http://localhost:%d\"\n", cb.verifierPort)) + b.WriteString(" templateDir: \"views/\"\n") + b.WriteString(" staticDir: \"views/static/\"\n") + + b.WriteString("logging:\n") + b.WriteString(" level: \"DEBUG\"\n") + b.WriteString(" jsonLogging: true\n") + b.WriteString(" logRequests: true\n") + + b.WriteString("verifier:\n") + b.WriteString(" did: \"did:key:test-verifier\"\n") + b.WriteString(fmt.Sprintf(" tirAddress: \"%s\"\n", cb.tirURL)) + b.WriteString(" validationMode: \"none\"\n") + b.WriteString(" keyAlgorithm: \"ES256\"\n") + b.WriteString(" generateKey: true\n") + b.WriteString(" sessionExpiry: 30\n") + b.WriteString(" jwtExpiration: 30\n") + b.WriteString(" supportedModes: [\"byValue\", \"byReference\"]\n") + + if cb.signingKey != "" { + b.WriteString(" clientIdentification:\n") + b.WriteString(" id: \"did:key:test-verifier\"\n") + b.WriteString(fmt.Sprintf(" keyPath: \"%s\"\n", cb.signingKey)) + b.WriteString(" requestKeyAlgorithm: \"ES256\"\n") + } + + b.WriteString("m2m:\n") + b.WriteString(" authEnabled: false\n") + + if len(cb.services) > 0 { + b.WriteString("configRepo:\n") + b.WriteString(" services:\n") + for _, svc := range cb.services { + cb.writeService(&b, svc) + } + } + + return b.String() +} + +func (cb *ConfigBuilder) writeService(b *strings.Builder, svc *ServiceConfig) { + b.WriteString(fmt.Sprintf(" - id: \"%s\"\n", svc.ID)) + b.WriteString(fmt.Sprintf(" defaultOidcScope: \"%s\"\n", svc.DefaultScope)) + if svc.AuthorizationType != "" { + b.WriteString(fmt.Sprintf(" authorizationType: \"%s\"\n", svc.AuthorizationType)) + } + + if len(svc.Scopes) > 0 { + b.WriteString(" oidcScopes:\n") + for scopeName, sc := range svc.Scopes { + b.WriteString(fmt.Sprintf(" \"%s\":\n", scopeName)) + cb.writeScope(b, sc) + } + } +} + +func (cb *ConfigBuilder) writeScope(b *strings.Builder, sc *ScopeConfig) { + if len(sc.Credentials) > 0 { + b.WriteString(" credentials:\n") + for _, cred := range sc.Credentials { + cb.writeCredential(b, &cred) + } + } + + if sc.DCQL != nil { + b.WriteString(" dcql:\n") + cb.writeDCQL(b, sc.DCQL) + } +} + +func (cb *ConfigBuilder) writeCredential(b *strings.Builder, cred *CredentialConfig) { + b.WriteString(fmt.Sprintf(" - type: \"%s\"\n", cred.Type)) + + if len(cred.TrustedParticipants) > 0 { + b.WriteString(" trustedParticipantsLists:\n") + for _, tp := range cred.TrustedParticipants { + b.WriteString(fmt.Sprintf(" - type: \"%s\"\n", tp.Type)) + b.WriteString(fmt.Sprintf(" url: \"%s\"\n", tp.URL)) + } + } + + if len(cred.TrustedIssuersLists) > 0 { + b.WriteString(" trustedIssuersLists:\n") + for _, til := range cred.TrustedIssuersLists { + b.WriteString(fmt.Sprintf(" - \"%s\"\n", til)) + } + } + + if cred.HolderVerification != nil { + b.WriteString(" holderVerification:\n") + b.WriteString(fmt.Sprintf(" enabled: %t\n", cred.HolderVerification.Enabled)) + if cred.HolderVerification.Claim != "" { + b.WriteString(fmt.Sprintf(" claim: \"%s\"\n", cred.HolderVerification.Claim)) + } + } + + if cred.JwtInclusion != nil { + b.WriteString(" jwtInclusion:\n") + b.WriteString(fmt.Sprintf(" enabled: %t\n", cred.JwtInclusion.Enabled)) + b.WriteString(fmt.Sprintf(" fullInclusion: %t\n", cred.JwtInclusion.FullInclusion)) + } +} + +func (cb *ConfigBuilder) writeDCQL(b *strings.Builder, dcql *DCQLConfig) { + if len(dcql.Credentials) > 0 { + b.WriteString(" credentials:\n") + for _, cq := range dcql.Credentials { + b.WriteString(fmt.Sprintf(" - id: \"%s\"\n", cq.Id)) + b.WriteString(fmt.Sprintf(" format: \"%s\"\n", cq.Format)) + if cq.Meta != nil && len(cq.Meta.VctValues) > 0 { + b.WriteString(" meta:\n") + b.WriteString(" vct_values:\n") + for _, v := range cq.Meta.VctValues { + b.WriteString(fmt.Sprintf(" - \"%s\"\n", v)) + } + } + if len(cq.Claims) > 0 { + b.WriteString(" claims:\n") + for _, cl := range cq.Claims { + b.WriteString(" - path:\n") + for _, p := range cl.Path { + b.WriteString(fmt.Sprintf(" - \"%s\"\n", p)) + } + } + } + } + } + + if len(dcql.CredentialSets) > 0 { + b.WriteString(" credential_sets:\n") + for _, cs := range dcql.CredentialSets { + b.WriteString(" - options:\n") + for _, opt := range cs.Options { + b.WriteString(" - [") + for i, o := range opt { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(fmt.Sprintf("\"%s\"", o)) + } + b.WriteString("]\n") + } + if cs.Purpose != "" { + b.WriteString(fmt.Sprintf(" purpose: \"%s\"\n", cs.Purpose)) + } + } + } +} + +func (cb *ConfigBuilder) ensureService(serviceID string) *ServiceConfig { + svc, exists := cb.services[serviceID] + if !exists { + svc = &ServiceConfig{ + ID: serviceID, + Scopes: make(map[string]*ScopeConfig), + } + cb.services[serviceID] = svc + } + return svc +} + +func (cb *ConfigBuilder) ensureScope(svc *ServiceConfig, scope string) *ScopeConfig { + sc, exists := svc.Scopes[scope] + if !exists { + sc = &ScopeConfig{} + svc.Scopes[scope] = sc + } + return sc +} + +// GenerateSigningKeyPEM generates an ECDSA P-256 private key and writes it as PEM to a file in dir. +// Returns the file path to the PEM file. +func GenerateSigningKeyPEM(dir string) (string, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", fmt.Errorf("generating ECDSA key: %w", err) + } + + derBytes, err := x509.MarshalECPrivateKey(key) + if err != nil { + return "", fmt.Errorf("marshaling ECDSA key: %w", err) + } + + pemBlock := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: derBytes, + } + + keyPath := filepath.Join(dir, "signing-key.pem") + f, err := os.Create(keyPath) + if err != nil { + return "", fmt.Errorf("creating key file: %w", err) + } + defer f.Close() + + if err := pem.Encode(f, pemBlock); err != nil { + return "", fmt.Errorf("encoding PEM: %w", err) + } + + return keyPath, nil +} diff --git a/integration_test/helpers/credentials.go b/integration_test/helpers/credentials.go new file mode 100644 index 0000000..b6ebcab --- /dev/null +++ b/integration_test/helpers/credentials.go @@ -0,0 +1,225 @@ +package helpers + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// CreateJWTVC creates a signed JWT-VC (Verifiable Credential in JWT format). +// The credential contains the given type and subject claims, signed by the issuer. +func CreateJWTVC(issuer *TestIdentity, credType string, subject map[string]interface{}) (string, error) { + return createJWTVCInternal(issuer, credType, subject, nil) +} + +// CreateJWTVCWithHolder creates a signed JWT-VC with claim-based holder binding. +// The holderDID is added to the credentialSubject under the "holder" key. +func CreateJWTVCWithHolder(issuer *TestIdentity, credType string, subject map[string]interface{}, holderDID string) (string, error) { + subjectWithHolder := copyMap(subject) + subjectWithHolder["holder"] = holderDID + return createJWTVCInternal(issuer, credType, subjectWithHolder, nil) +} + +// CreateJWTVCWithCnf creates a signed JWT-VC with confirmation (cnf) holder binding. +// The holder's public key JWK is embedded in the credentialSubject.cnf.jwk field. +func CreateJWTVCWithCnf(issuer *TestIdentity, credType string, subject map[string]interface{}, holderJWK jwk.Key) (string, error) { + // Serialize the holder's public JWK to a map for embedding. + jwkBytes, err := json.Marshal(holderJWK) + if err != nil { + return "", fmt.Errorf("marshaling holder JWK: %w", err) + } + var jwkMap map[string]interface{} + if err := json.Unmarshal(jwkBytes, &jwkMap); err != nil { + return "", fmt.Errorf("unmarshaling holder JWK: %w", err) + } + + cnf := map[string]interface{}{ + "jwk": jwkMap, + } + return createJWTVCInternal(issuer, credType, subject, cnf) +} + +// createJWTVCInternal builds and signs a JWT-VC with optional cnf claim. +func createJWTVCInternal(issuer *TestIdentity, credType string, subject map[string]interface{}, cnf map[string]interface{}) (string, error) { + now := time.Now() + + credentialSubject := copyMap(subject) + if cnf != nil { + credentialSubject["cnf"] = cnf + } + + vcClaim := map[string]interface{}{ + "@context": []string{ + "https://www.w3.org/2018/credentials/v1", + }, + "type": []string{"VerifiableCredential", credType}, + "credentialSubject": credentialSubject, + } + + builder := jwt.NewBuilder(). + Issuer(issuer.DID). + IssuedAt(now). + Expiration(now.Add(24 * time.Hour)) + builder.Claim("vc", vcClaim) + + token, err := builder.Build() + if err != nil { + return "", fmt.Errorf("building JWT-VC token: %w", err) + } + + return signJWT(token, issuer) +} + +// CreateVPToken creates a signed VP (Verifiable Presentation) JWT wrapping one or more VC JWTs. +// The nonce and audience are included for replay protection. +func CreateVPToken(holder *TestIdentity, nonce string, audience string, vcJWTs ...string) (string, error) { + now := time.Now() + + vpClaim := map[string]interface{}{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": "VerifiablePresentation", + "holder": holder.DID, + "verifiableCredential": vcJWTs, + } + + builder := jwt.NewBuilder(). + Issuer(holder.DID). + Audience([]string{audience}). + IssuedAt(now). + Expiration(now.Add(5 * time.Minute)) + + if nonce != "" { + builder.Claim("nonce", nonce) + } + builder.Claim("vp", vpClaim) + + token, err := builder.Build() + if err != nil { + return "", fmt.Errorf("building VP token: %w", err) + } + + return signJWT(token, holder) +} + +// CreateSDJWT creates an SD-JWT credential string. +// The issuer signs the JWT containing the claims, and disclosed claims are added as disclosures. +// For simplicity in integration tests, all claims are disclosed (no selective disclosure). +func CreateSDJWT(issuer *TestIdentity, vct string, claims map[string]interface{}) (string, error) { + now := time.Now() + + builder := jwt.NewBuilder(). + Issuer(issuer.DID). + IssuedAt(now). + Expiration(now.Add(24 * time.Hour)) + builder.Claim("vct", vct) + // _sd_alg is required by the verifier's SD-JWT parser even when no claims use selective disclosure. + builder.Claim("_sd_alg", "sha-256") + + for k, v := range claims { + builder.Claim(k, v) + } + + token, err := builder.Build() + if err != nil { + return "", fmt.Errorf("building SD-JWT token: %w", err) + } + + signed, err := signJWT(token, issuer) + if err != nil { + return "", err + } + + // SD-JWT format: ~ + // The trailing ~ indicates no key binding JWT. + // No disclosures are added since all claims are plaintext (no _sd hashes). + return signed + "~", nil +} + +// CreateVPWithSDJWT creates a signed VP JWT that contains SD-JWT credentials +// in the verifiableCredential array. The VP JWT is signed by the holder. +func CreateVPWithSDJWT(holder *TestIdentity, nonce string, audience string, sdJWTs ...string) (string, error) { + // SD-JWT VPs use the same structure as regular VPs but with SD-JWT strings as credentials. + return CreateVPToken(holder, nonce, audience, sdJWTs...) +} + +// CreateVPTokenWithMismatchedSigner creates a VP JWT where the issuer/holder DID +// comes from claimedHolder, but the JWT is actually signed by actualSigner's key. +// This produces a VP whose signature cannot be verified against the claimed holder's public key. +func CreateVPTokenWithMismatchedSigner(claimedHolder, actualSigner *TestIdentity, nonce string, audience string, vcJWTs ...string) (string, error) { + now := time.Now() + + vpClaim := map[string]interface{}{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": "VerifiablePresentation", + "holder": claimedHolder.DID, + "verifiableCredential": vcJWTs, + } + + builder := jwt.NewBuilder(). + Issuer(claimedHolder.DID). + Audience([]string{audience}). + IssuedAt(now). + Expiration(now.Add(5 * time.Minute)) + + if nonce != "" { + builder.Claim("nonce", nonce) + } + builder.Claim("vp", vpClaim) + + token, err := builder.Build() + if err != nil { + return "", fmt.Errorf("building mismatched VP token: %w", err) + } + + // Sign with actualSigner's key but use claimedHolder's kid in the header. + headers := jws.NewHeaders() + if err := headers.Set(jws.KeyIDKey, claimedHolder.KeyID); err != nil { + return "", fmt.Errorf("setting kid header: %w", err) + } + + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), actualSigner.PrivateKey, jws.WithProtectedHeaders(headers))) + if err != nil { + return "", fmt.Errorf("signing mismatched VP: %w", err) + } + + return string(signed), nil +} + +// CreateDCQLResponse builds the JSON-encoded vp_token value for a DCQL response. +// The queryResponses map keys are DCQL credential query IDs and values are VP JWT strings. +func CreateDCQLResponse(queryResponses map[string]string) (string, error) { + jsonBytes, err := json.Marshal(queryResponses) + if err != nil { + return "", fmt.Errorf("marshaling DCQL response: %w", err) + } + return string(jsonBytes), nil +} + +// signJWT signs a JWT token with the identity's private key using ES256. +func signJWT(token jwt.Token, identity *TestIdentity) (string, error) { + headers := jws.NewHeaders() + if err := headers.Set(jws.KeyIDKey, identity.KeyID); err != nil { + return "", fmt.Errorf("setting kid header: %w", err) + } + + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), identity.PrivateKey, jws.WithProtectedHeaders(headers))) + if err != nil { + return "", fmt.Errorf("signing JWT: %w", err) + } + + return string(signed), nil +} + +// copyMap creates a shallow copy of a map. +func copyMap(m map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(m)) + for k, v := range m { + result[k] = v + } + return result +} diff --git a/integration_test/helpers/did_web_mock.go b/integration_test/helpers/did_web_mock.go new file mode 100644 index 0000000..1d2cf49 --- /dev/null +++ b/integration_test/helpers/did_web_mock.go @@ -0,0 +1,261 @@ +package helpers + +import ( + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" +) + +// DIDDocument represents a minimal DID document for did:web resolution. +type DIDDocument struct { + Context []string `json:"@context"` + ID string `json:"id"` + VerificationMethod []VerificationMethod `json:"verificationMethod"` + Authentication []string `json:"authentication"` + AssertionMethod []string `json:"assertionMethod"` +} + +// VerificationMethod represents a public key entry in a DID document. +type VerificationMethod struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyJwk map[string]interface{} `json:"publicKeyJwk"` +} + +// NewDidWebServer creates an httptest.Server that serves a DID document for did:web resolution. +// The server responds to GET /.well-known/did.json with the identity's DID document. +// Returns the server. The caller should use the server's URL host (without scheme) +// to construct the did:web DID via GenerateDidWebIdentity. +func NewDidWebServer(identity *TestIdentity) *httptest.Server { + // Convert the public key JWK to a map for embedding in the DID document. + jwkBytes, err := json.Marshal(identity.PublicKeyJWK) + if err != nil { + panic(fmt.Sprintf("marshaling public key JWK: %v", err)) + } + var jwkMap map[string]interface{} + if err := json.Unmarshal(jwkBytes, &jwkMap); err != nil { + panic(fmt.Sprintf("unmarshaling public key JWK: %v", err)) + } + + didDoc := DIDDocument{ + Context: []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/jws-2020/v1", + }, + ID: identity.DID, + VerificationMethod: []VerificationMethod{ + { + ID: identity.KeyID, + Type: "JsonWebKey2020", + Controller: identity.DID, + PublicKeyJwk: jwkMap, + }, + }, + Authentication: []string{identity.KeyID}, + AssertionMethod: []string{identity.KeyID}, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/did.json" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(didDoc); err != nil { + http.Error(w, fmt.Sprintf("encoding DID document: %v", err), http.StatusInternalServerError) + } + }) + + return httptest.NewServer(handler) +} + +// DidWebTLSServer wraps an HTTPS httptest.Server with the CA certificate path +// needed for the verifier process to trust the server's TLS certificate. +type DidWebTLSServer struct { + // Server is the running HTTPS test server. + Server *httptest.Server + // CACertPath is the path to a PEM file containing the server's certificate, + // suitable for use as SSL_CERT_FILE environment variable. + CACertPath string +} + +// Close shuts down the TLS server and removes the temporary CA certificate file. +func (s *DidWebTLSServer) Close() { + s.Server.Close() + if s.CACertPath != "" { + os.Remove(s.CACertPath) + } +} + +// NewDidWebTLSServer creates an HTTPS httptest.Server that serves a DID document for did:web resolution. +// The verifier's did:web resolver uses HTTPS by default, so this TLS variant is required for integration tests. +// The server's self-signed certificate is exported to a temporary PEM file accessible via CACertPath. +// Pass "SSL_CERT_FILE=" as an extra env var to StartVerifier so the verifier trusts this server. +func NewDidWebTLSServer(identity *TestIdentity) *DidWebTLSServer { + jwkBytes, err := json.Marshal(identity.PublicKeyJWK) + if err != nil { + panic(fmt.Sprintf("marshaling public key JWK: %v", err)) + } + var jwkMap map[string]interface{} + if err := json.Unmarshal(jwkBytes, &jwkMap); err != nil { + panic(fmt.Sprintf("unmarshaling public key JWK: %v", err)) + } + + didDoc := DIDDocument{ + Context: []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/jws-2020/v1", + }, + ID: identity.DID, + VerificationMethod: []VerificationMethod{ + { + ID: identity.KeyID, + Type: "JsonWebKey2020", + Controller: identity.DID, + PublicKeyJwk: jwkMap, + }, + }, + Authentication: []string{identity.KeyID}, + AssertionMethod: []string{identity.KeyID}, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/did.json" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(didDoc); err != nil { + http.Error(w, fmt.Sprintf("encoding DID document: %v", err), http.StatusInternalServerError) + } + }) + + server := httptest.NewTLSServer(handler) + + // Export the server's certificate as a PEM file so the verifier process can trust it. + cert := server.Certificate() + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + + certFile, err := os.CreateTemp("", "did-web-ca-*.pem") + if err != nil { + panic(fmt.Sprintf("creating temp cert file: %v", err)) + } + if _, err := certFile.Write(certPEM); err != nil { + panic(fmt.Sprintf("writing cert PEM: %v", err)) + } + certFile.Close() + + return &DidWebTLSServer{ + Server: server, + CACertPath: certFile.Name(), + } +} + +// HostFromURL extracts the host:port from an httptest.Server URL (e.g., "https://127.0.0.1:12345" -> "127.0.0.1:12345"). +func HostFromURL(serverURL string) string { + // Strip the scheme (http:// or https://) + for _, prefix := range []string{"https://", "http://"} { + if len(serverURL) > len(prefix) && serverURL[:len(prefix)] == prefix { + return serverURL[len(prefix):] + } + } + return serverURL +} + +// SetupDidWebTLSIdentity creates a did:web identity and a matching TLS server in a single step. +// This solves the chicken-and-egg problem: the DID contains the server's host:port, but the +// server needs the identity to serve the DID document. It uses a dynamic handler that is +// updated after the server starts and the host is known. +func SetupDidWebTLSIdentity() (*TestIdentity, *DidWebTLSServer) { + // Use a dynamic DID document that is set after we know the server's URL. + var didDocBytes []byte + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/did.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(didDocBytes) + }) + + server := httptest.NewTLSServer(handler) + host := HostFromURL(server.URL) + + identity, err := GenerateDidWebIdentity(host) + if err != nil { + server.Close() + panic(fmt.Sprintf("generating did:web identity: %v", err)) + } + + // Build the DID document now that we have the identity. + didDocBytes = buildDIDDocumentJSON(identity) + + // Export the server's certificate as a PEM file. + cert := server.Certificate() + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + + certFile, err := os.CreateTemp("", "did-web-ca-*.pem") + if err != nil { + server.Close() + panic(fmt.Sprintf("creating temp cert file: %v", err)) + } + if _, err := certFile.Write(certPEM); err != nil { + server.Close() + panic(fmt.Sprintf("writing cert PEM: %v", err)) + } + certFile.Close() + + return identity, &DidWebTLSServer{ + Server: server, + CACertPath: certFile.Name(), + } +} + +// buildDIDDocumentJSON constructs the DID document JSON for the given identity. +func buildDIDDocumentJSON(identity *TestIdentity) []byte { + jwkBytes, err := json.Marshal(identity.PublicKeyJWK) + if err != nil { + panic(fmt.Sprintf("marshaling public key JWK: %v", err)) + } + var jwkMap map[string]interface{} + if err := json.Unmarshal(jwkBytes, &jwkMap); err != nil { + panic(fmt.Sprintf("unmarshaling public key JWK: %v", err)) + } + + didDoc := DIDDocument{ + Context: []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/jws-2020/v1", + }, + ID: identity.DID, + VerificationMethod: []VerificationMethod{ + { + ID: identity.KeyID, + Type: "JsonWebKey2020", + Controller: identity.DID, + PublicKeyJwk: jwkMap, + }, + }, + Authentication: []string{identity.KeyID}, + AssertionMethod: []string{identity.KeyID}, + } + + docBytes, err := json.Marshal(didDoc) + if err != nil { + panic(fmt.Sprintf("marshaling DID document: %v", err)) + } + return docBytes +} diff --git a/integration_test/helpers/identity.go b/integration_test/helpers/identity.go new file mode 100644 index 0000000..ee40fa2 --- /dev/null +++ b/integration_test/helpers/identity.go @@ -0,0 +1,104 @@ +package helpers + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "fmt" + + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/trustbloc/kms-go/doc/jose/jwk/jwksupport" + "github.com/trustbloc/kms-go/doc/util/fingerprint" +) + +// TestIdentity represents a cryptographic identity for test purposes, +// consisting of a key pair and an associated DID. +type TestIdentity struct { + // PrivateKey is the ECDSA private key for signing. + PrivateKey crypto.Signer + // PublicKeyJWK is the public key in JWK format (lestrrat-go/jwx). + PublicKeyJWK jwk.Key + // DID is the decentralized identifier (e.g., did:key:z...). + DID string + // KeyID is the full verification method ID (e.g., did:key:z...#z...). + KeyID string +} + +// GenerateDidKeyIdentity creates a new ECDSA P-256 key pair and derives a did:key DID from it. +func GenerateDidKeyIdentity() (*TestIdentity, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating ECDSA P-256 key: %w", err) + } + + // Create the did:key using trustbloc's fingerprint utility with a JWK. + tbJWK, err := jwksupport.JWKFromKey(&privateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("creating trustbloc JWK from public key: %w", err) + } + + didKey, keyID, err := fingerprint.CreateDIDKeyByJwk(tbJWK) + if err != nil { + return nil, fmt.Errorf("creating did:key fingerprint: %w", err) + } + + // Convert public key to lestrrat-go/jwx JWK format for use in JWT headers. + jwxKey, err := jwk.Import(&privateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("importing public key to jwx JWK: %w", err) + } + if err := jwk.AssignKeyID(jwxKey); err != nil { + return nil, fmt.Errorf("assigning key ID: %w", err) + } + + return &TestIdentity{ + PrivateKey: privateKey, + PublicKeyJWK: jwxKey, + DID: didKey, + KeyID: keyID, + }, nil +} + +// GenerateDidWebIdentity creates a new ECDSA P-256 key pair and associates it with a did:web DID +// derived from the given host (e.g., "localhost:12345" -> "did:web:localhost%3A12345"). +func GenerateDidWebIdentity(host string) (*TestIdentity, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating ECDSA P-256 key: %w", err) + } + + // Build the did:web DID (colons in host are percent-encoded). + encodedHost := didWebEncode(host) + did := "did:web:" + encodedHost + keyID := did + "#key-1" + + // Convert public key to lestrrat-go/jwx JWK format. + jwxKey, err := jwk.Import(&privateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("importing public key to jwx JWK: %w", err) + } + if err := jwxKey.Set(jwk.KeyIDKey, keyID); err != nil { + return nil, fmt.Errorf("setting key ID: %w", err) + } + + return &TestIdentity{ + PrivateKey: privateKey, + PublicKeyJWK: jwxKey, + DID: did, + KeyID: keyID, + }, nil +} + +// didWebEncode percent-encodes colons in a host string for did:web. +func didWebEncode(host string) string { + var result []byte + for i := 0; i < len(host); i++ { + if host[i] == ':' { + result = append(result, '%', '3', 'A') + } else { + result = append(result, host[i]) + } + } + return string(result) +} diff --git a/integration_test/helpers/process.go b/integration_test/helpers/process.go new file mode 100644 index 0000000..a15b15a --- /dev/null +++ b/integration_test/helpers/process.go @@ -0,0 +1,263 @@ +// Package helpers provides test infrastructure for black-box integration testing of VCVerifier. +package helpers + +import ( + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "syscall" + "time" +) + +const ( + // HealthPollInterval is the interval between health check polls when waiting for the verifier to start. + HealthPollInterval = 100 * time.Millisecond + // HealthPollTimeout is the maximum time to wait for the verifier to become healthy. + HealthPollTimeout = 15 * time.Second + // ShutdownGracePeriod is the time to wait after SIGTERM before sending SIGKILL. + ShutdownGracePeriod = 5 * time.Second +) + +// VerifierProcess represents a running VCVerifier binary managed by the test harness. +type VerifierProcess struct { + cmd *exec.Cmd + Port int + BaseURL string + configDir string +} + +// BuildVerifier compiles the VCVerifier binary and places it in a temporary directory. +// projectRoot must point to the root of the VCVerifier source tree. +func BuildVerifier(projectRoot string) (binaryPath string, err error) { + tmpDir, err := os.MkdirTemp("", "vcverifier-it-*") + if err != nil { + return "", fmt.Errorf("creating temp dir for binary: %w", err) + } + + binaryPath = filepath.Join(tmpDir, "vcverifier") + cmd := exec.Command("go", "build", "-o", binaryPath, ".") + cmd.Dir = projectRoot + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("building verifier binary: %w", err) + } + + return binaryPath, nil +} + +// DownloadVerifier downloads a VCVerifier binary from the given URL and places it in a temporary directory. +// The binary is made executable after download. +func DownloadVerifier(binaryURL string) (binaryPath string, err error) { + tmpDir, err := os.MkdirTemp("", "vcverifier-it-*") + if err != nil { + return "", fmt.Errorf("creating temp dir for binary: %w", err) + } + + binaryPath = filepath.Join(tmpDir, "vcverifier") + + resp, err := http.Get(binaryURL) //nolint:gosec // URL is provided by the test operator via env var + if err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("downloading verifier binary from %s: %w", binaryURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("downloading verifier binary from %s: HTTP %d", binaryURL, resp.StatusCode) + } + + outFile, err := os.Create(binaryPath) + if err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("creating binary file: %w", err) + } + + if _, err := io.Copy(outFile, resp.Body); err != nil { + outFile.Close() + os.RemoveAll(tmpDir) + return "", fmt.Errorf("writing binary file: %w", err) + } + outFile.Close() + + if err := os.Chmod(binaryPath, 0755); err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("making binary executable: %w", err) + } + + return binaryPath, nil +} + +// StartVerifier launches a VCVerifier process with the given YAML config and waits until it is healthy. +// projectRoot is needed so the binary can find view templates via relative paths. +// Optional extraEnv entries are added to the process environment (e.g., "SSL_CERT_FILE=/path/to/ca.pem"). +func StartVerifier(configYAML string, projectRoot string, binaryPath string, extraEnv ...string) (*VerifierProcess, error) { + configDir, err := os.MkdirTemp("", "vcverifier-config-*") + if err != nil { + return nil, fmt.Errorf("creating config temp dir: %w", err) + } + + configPath := filepath.Join(configDir, "server.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0644); err != nil { + os.RemoveAll(configDir) + return nil, fmt.Errorf("writing config file: %w", err) + } + + // Parse the port from the config to know where to poll health. + port, err := extractPortFromConfig(configYAML) + if err != nil { + os.RemoveAll(configDir) + return nil, fmt.Errorf("extracting port from config: %w", err) + } + + cmd := exec.Command(binaryPath) + cmd.Dir = projectRoot + cmd.Env = append(os.Environ(), + "CONFIG_FILE="+configPath, + "GIN_MODE=release", + ) + cmd.Env = append(cmd.Env, extraEnv...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + os.RemoveAll(configDir) + return nil, fmt.Errorf("starting verifier process: %w", err) + } + + baseURL := fmt.Sprintf("http://localhost:%d", port) + vp := &VerifierProcess{ + cmd: cmd, + Port: port, + BaseURL: baseURL, + configDir: configDir, + } + + if err := waitForHealthy(baseURL, HealthPollTimeout, cmd); err != nil { + vp.Stop() + return nil, fmt.Errorf("verifier did not become healthy: %w", err) + } + + return vp, nil +} + +// Stop gracefully shuts down the verifier process and cleans up temporary files. +func (vp *VerifierProcess) Stop() { + if vp.cmd != nil && vp.cmd.Process != nil { + _ = vp.cmd.Process.Signal(syscall.SIGTERM) + + done := make(chan error, 1) + go func() { + done <- vp.cmd.Wait() + }() + + select { + case <-done: + // Process exited gracefully. + case <-time.After(ShutdownGracePeriod): + _ = vp.cmd.Process.Kill() + <-done + } + } + + if vp.configDir != "" { + os.RemoveAll(vp.configDir) + } +} + +// waitForHealthy polls the /health endpoint until it returns 200 or the timeout expires. +func waitForHealthy(baseURL string, timeout time.Duration, cmd *exec.Cmd) error { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 2 * time.Second} + healthURL := baseURL + "/health" + + for time.Now().Before(deadline) { + // Check if process has already exited. + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + return fmt.Errorf("verifier process exited prematurely with code %d", cmd.ProcessState.ExitCode()) + } + + resp, err := client.Get(healthURL) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + + time.Sleep(HealthPollInterval) + } + + return fmt.Errorf("health check at %s did not return 200 within %v", healthURL, timeout) +} + +// GetFreePort returns an available TCP port by binding to :0 and reading the assigned port. +func GetFreePort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, fmt.Errorf("binding to free port: %w", err) + } + defer listener.Close() + + addr := listener.Addr().(*net.TCPAddr) + return addr.Port, nil +} + +// extractPortFromConfig parses the port value from a YAML config string. +// This is a simple extraction to avoid pulling in a YAML library. +func extractPortFromConfig(yaml string) (int, error) { + var port int + _, err := fmt.Sscanf(findYAMLValue(yaml, "port"), "%d", &port) + if err != nil { + return 0, fmt.Errorf("parsing port from config: %w", err) + } + return port, nil +} + +// findYAMLValue does a simple line-by-line scan for "key: value" and returns the value. +func findYAMLValue(yaml string, key string) string { + lines := splitLines(yaml) + target := key + ":" + for _, line := range lines { + trimmed := trimSpace(line) + if len(trimmed) > len(target) && trimmed[:len(target)] == target { + return trimSpace(trimmed[len(target):]) + } + } + return "" +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} + +func trimSpace(s string) string { + start := 0 + for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r') { + start++ + } + end := len(s) + for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') { + end-- + } + return s[start:end] +} diff --git a/integration_test/helpers/tir_mock.go b/integration_test/helpers/tir_mock.go new file mode 100644 index 0000000..fd1a20a --- /dev/null +++ b/integration_test/helpers/tir_mock.go @@ -0,0 +1,110 @@ +package helpers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" +) + +// TrustedIssuer mirrors the EBSI TrustedIssuer JSON structure returned by the TIR API. +type TrustedIssuer struct { + Did string `json:"did"` + Attributes []IssuerAttribute `json:"attributes"` +} + +// IssuerAttribute mirrors the EBSI IssuerAttribute JSON structure. +type IssuerAttribute struct { + Hash string `json:"hash"` + Body string `json:"body"` + IssuerType string `json:"issuerType"` + Tao string `json:"tao"` + RootTao string `json:"rootTao"` +} + +// TIRCredentialConfig is the JSON structure encoded in the attribute body, +// defining what credential types and claims an issuer is allowed to issue. +type TIRCredentialConfig struct { + ValidFor TIRTimeRange `json:"validFor"` + CredentialsType string `json:"credentialsType"` + Claims []TIRClaim `json:"claims"` +} + +// TIRTimeRange defines a validity time range for issuer credentials. +type TIRTimeRange struct { + From string `json:"from"` + To string `json:"to"` +} + +// TIRClaim defines a claim constraint in the TIR issuer attribute. +type TIRClaim struct { + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + AllowedValues []interface{} `json:"allowedValues,omitempty"` +} + +// BuildIssuerAttribute creates a properly base64-encoded IssuerAttribute +// for the given credential type. Claims can be empty for unrestricted issuers. +func BuildIssuerAttribute(credentialType string, claims []TIRClaim) IssuerAttribute { + config := TIRCredentialConfig{ + ValidFor: TIRTimeRange{ + From: "2020-01-01T00:00:00Z", + To: "2030-12-31T23:59:59Z", + }, + CredentialsType: credentialType, + Claims: claims, + } + + bodyJSON, _ := json.Marshal(config) + bodyB64 := base64.StdEncoding.EncodeToString(bodyJSON) + + return IssuerAttribute{ + Hash: "", + Body: bodyB64, + IssuerType: "legal", + Tao: "", + RootTao: "", + } +} + +// NewMockTIR creates an httptest.Server that mocks the EBSI Trusted Issuers Registry API. +// The issuers map keys are DIDs and values are the TrustedIssuer responses. +// The mock handles: +// - GET /v4/issuers/ — returns the TrustedIssuer JSON or 404 +// - GET /v3/issuers/ — same as v4 (fallback path) +func NewMockTIR(issuers map[string]TrustedIssuer) *httptest.Server { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Use RawPath to preserve percent-encoding in DIDs (e.g., did:web:host%3Aport). + // RawPath is set when the path contains percent-encoded characters; otherwise fall back to Path. + path := r.URL.RawPath + if path == "" { + path = r.URL.Path + } + + // Handle v4/issuers/ and v3/issuers/ + var did string + if strings.HasPrefix(path, "/v4/issuers/") { + did = strings.TrimPrefix(path, "/v4/issuers/") + } else if strings.HasPrefix(path, "/v3/issuers/") { + did = strings.TrimPrefix(path, "/v3/issuers/") + } else { + http.NotFound(w, r) + return + } + + issuer, exists := issuers[did] + if !exists { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(issuer); err != nil { + http.Error(w, fmt.Sprintf("encoding issuer response: %v", err), http.StatusInternalServerError) + } + }) + + return httptest.NewServer(handler) +} diff --git a/integration_test/helpers_test.go b/integration_test/helpers_test.go new file mode 100644 index 0000000..9c98edc --- /dev/null +++ b/integration_test/helpers_test.go @@ -0,0 +1,251 @@ +//go:build integration + +package integration_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/fiware/VCVerifier/integration_test/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGenerateDidKeyIdentity verifies that did:key identity generation works correctly. +func TestGenerateDidKeyIdentity(t *testing.T) { + identity, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + assert.NotNil(t, identity.PrivateKey) + assert.NotNil(t, identity.PublicKeyJWK) + assert.True(t, strings.HasPrefix(identity.DID, "did:key:z"), "DID should start with did:key:z, got: %s", identity.DID) + assert.Contains(t, identity.KeyID, identity.DID, "KeyID should contain the DID") +} + +// TestGenerateDidWebIdentity verifies that did:web identity generation works correctly. +func TestGenerateDidWebIdentity(t *testing.T) { + identity, err := helpers.GenerateDidWebIdentity("localhost:12345") + require.NoError(t, err) + assert.Equal(t, "did:web:localhost%3A12345", identity.DID) + assert.Equal(t, "did:web:localhost%3A12345#key-1", identity.KeyID) + assert.NotNil(t, identity.PrivateKey) + assert.NotNil(t, identity.PublicKeyJWK) +} + +// TestCreateJWTVC verifies that JWT-VC creation produces a valid signed JWT. +func TestCreateJWTVC(t *testing.T) { + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + subject := map[string]interface{}{ + "type": "CustomerCredential", + "name": "Test User", + } + + vcJWT, err := helpers.CreateJWTVC(issuer, "CustomerCredential", subject) + require.NoError(t, err) + assert.NotEmpty(t, vcJWT) + + // JWT should have 3 parts. + parts := strings.Split(vcJWT, ".") + assert.Len(t, parts, 3, "JWT-VC should have 3 dot-separated parts") +} + +// TestCreateJWTVCWithHolder verifies that holder-bound JWT-VC includes the holder claim. +func TestCreateJWTVCWithHolder(t *testing.T) { + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + subject := map[string]interface{}{ + "type": "CustomerCredential", + } + + vcJWT, err := helpers.CreateJWTVCWithHolder(issuer, "CustomerCredential", subject, holder.DID) + require.NoError(t, err) + assert.NotEmpty(t, vcJWT) +} + +// TestCreateJWTVCWithCnf verifies that cnf-bound JWT-VC includes the confirmation claim. +func TestCreateJWTVCWithCnf(t *testing.T) { + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + subject := map[string]interface{}{ + "type": "CustomerCredential", + } + + vcJWT, err := helpers.CreateJWTVCWithCnf(issuer, "CustomerCredential", subject, holder.PublicKeyJWK) + require.NoError(t, err) + assert.NotEmpty(t, vcJWT) +} + +// TestCreateVPToken verifies that VP token creation wraps VCs correctly. +func TestCreateVPToken(t *testing.T) { + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + vcJWT, err := helpers.CreateJWTVC(issuer, "CustomerCredential", map[string]interface{}{"type": "CustomerCredential"}) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(holder, "test-nonce", "test-audience", vcJWT) + require.NoError(t, err) + assert.NotEmpty(t, vpJWT) + + parts := strings.Split(vpJWT, ".") + assert.Len(t, parts, 3, "VP JWT should have 3 dot-separated parts") +} + +// TestCreateSDJWT verifies that SD-JWT creation produces a valid token with trailing tilde. +func TestCreateSDJWT(t *testing.T) { + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + claims := map[string]interface{}{ + "familyName": "Doe", + "givenName": "John", + } + + sdJWT, err := helpers.CreateSDJWT(issuer, "PersonIdentificationData", claims) + require.NoError(t, err) + assert.NotEmpty(t, sdJWT) + assert.True(t, strings.HasSuffix(sdJWT, "~"), "SD-JWT should end with ~") +} + +// TestCreateVPWithSDJWT verifies that a VP containing SD-JWTs can be created. +func TestCreateVPWithSDJWT(t *testing.T) { + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + sdJWT, err := helpers.CreateSDJWT(issuer, "PersonIdentificationData", map[string]interface{}{"familyName": "Doe"}) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPWithSDJWT(holder, "nonce", "audience", sdJWT) + require.NoError(t, err) + assert.NotEmpty(t, vpJWT) +} + +// TestCreateDCQLResponse verifies that the DCQL response format is correct. +func TestCreateDCQLResponse(t *testing.T) { + queryResponses := map[string]string{ + "query-1": "eyJhbGciOiJFUzI1NiJ9.payload.signature", + "query-2": "eyJhbGciOiJFUzI1NiJ9.payload2.signature2", + } + + response, err := helpers.CreateDCQLResponse(queryResponses) + require.NoError(t, err) + + var parsed map[string]string + err = json.Unmarshal([]byte(response), &parsed) + require.NoError(t, err) + assert.Equal(t, queryResponses, parsed) +} + +// TestMockTIR verifies that the mock TIR server responds correctly. +func TestMockTIR(t *testing.T) { + issuerDID := "did:key:z6MkTestIssuer" + + issuers := map[string]helpers.TrustedIssuer{ + issuerDID: { + Did: issuerDID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + } + + tirServer := helpers.NewMockTIR(issuers) + defer tirServer.Close() + + // Existing issuer should return 200. + resp, err := http.Get(fmt.Sprintf("%s/v4/issuers/%s", tirServer.URL, issuerDID)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var ti helpers.TrustedIssuer + err = json.Unmarshal(body, &ti) + require.NoError(t, err) + assert.Equal(t, issuerDID, ti.Did) + assert.Len(t, ti.Attributes, 1) + + // Unknown issuer should return 404. + resp2, err := http.Get(fmt.Sprintf("%s/v4/issuers/%s", tirServer.URL, "did:key:unknown")) + require.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusNotFound, resp2.StatusCode) +} + +// TestMockDidWeb verifies that the mock did:web server serves the DID document. +func TestMockDidWeb(t *testing.T) { + identity, err := helpers.GenerateDidWebIdentity("localhost:9999") + require.NoError(t, err) + + server := helpers.NewDidWebServer(identity) + defer server.Close() + + resp, err := http.Get(server.URL + "/.well-known/did.json") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var doc helpers.DIDDocument + err = json.Unmarshal(body, &doc) + require.NoError(t, err) + assert.Equal(t, identity.DID, doc.ID) + assert.Len(t, doc.VerificationMethod, 1) + assert.Equal(t, identity.KeyID, doc.VerificationMethod[0].ID) +} + +// TestConfigBuilder verifies that the config builder produces valid YAML. +func TestConfigBuilder(t *testing.T) { + config := helpers.NewConfigBuilder(8080, "http://localhost:9090"). + WithService("test-svc", "test-scope", "DEEPLINK"). + WithCredential("test-svc", "test-scope", "CustomerCredential", "http://localhost:9090"). + WithDCQL("test-svc", "test-scope", helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-query-1", "CustomerCredential"), + }, + }). + WithSigningKey("/tmp/key.pem"). + Build() + + assert.Contains(t, config, "port: 8080") + assert.Contains(t, config, "test-svc") + assert.Contains(t, config, "CustomerCredential") + assert.Contains(t, config, "dcql:") + assert.Contains(t, config, "cred-query-1") + assert.Contains(t, config, "jwt_vc_json") + assert.Contains(t, config, "/tmp/key.pem") +} + +// TestGenerateSigningKeyPEM verifies that PEM key generation works. +func TestGenerateSigningKeyPEM(t *testing.T) { + dir := t.TempDir() + keyPath, err := helpers.GenerateSigningKeyPEM(dir) + require.NoError(t, err) + assert.FileExists(t, keyPath) +} + +// TestGetFreePort verifies that a free port can be obtained. +func TestGetFreePort(t *testing.T) { + port, err := helpers.GetFreePort() + require.NoError(t, err) + assert.Greater(t, port, 0) +} diff --git a/integration_test/m2m_failure_test.go b/integration_test/m2m_failure_test.go new file mode 100644 index 0000000..47cdeff --- /dev/null +++ b/integration_test/m2m_failure_test.go @@ -0,0 +1,449 @@ +//go:build integration + +package integration_test + +import ( + "fmt" + "io" + "net/http" + "net/url" + "testing" + + "github.com/fiware/VCVerifier/integration_test/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// m2mFailureCase defines a parameterized M2M failure test case. +type m2mFailureCase struct { + name string + // setup prepares all infrastructure and returns a fixture with intentionally invalid credentials. + setup func(t *testing.T) *m2mTestFixture + // expectedStatus is the expected HTTP status code from the token endpoint. + expectedStatus int +} + +// TestM2MFailure runs parameterized failure tests for the M2M VP-token-to-JWT exchange. +// Each test case presents invalid or mismatched credentials and asserts a non-200 response. +func TestM2MFailure(t *testing.T) { + tests := []m2mFailureCase{ + { + name: "WrongCredentialType", + setup: setupWrongCredentialType, + expectedStatus: http.StatusBadRequest, + }, + { + name: "MissingRequiredClaims", + setup: setupMissingRequiredClaims, + expectedStatus: http.StatusBadRequest, + }, + { + name: "UntrustedIssuer", + setup: setupUntrustedIssuer, + expectedStatus: http.StatusBadRequest, + }, + { + name: "InvalidVPSignature", + setup: setupInvalidVPSignature, + expectedStatus: http.StatusBadRequest, + }, + { + name: "InvalidCnfBinding", + setup: setupInvalidCnfBinding, + expectedStatus: http.StatusBadRequest, + }, + { + name: "InvalidClaimHolderBinding", + setup: setupInvalidClaimHolderBinding, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fixture := tc.setup(t) + defer fixture.cleanup() + + vp, err := helpers.StartVerifier(fixture.configYAML, projectRoot, binaryPath, fixture.extraEnv...) + require.NoError(t, err, "verifier should start successfully") + defer vp.Stop() + + vpToken, err := helpers.CreateDCQLResponse(fixture.dcqlResponse) + require.NoError(t, err) + + resp, err := http.PostForm( + fmt.Sprintf("%s/services/%s/token", vp.BaseURL, serviceID), + url.Values{ + "grant_type": {"vp_token"}, + "vp_token": {vpToken}, + "scope": {scopeName}, + }, + ) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, tc.expectedStatus, resp.StatusCode, + "expected %d, got %d: %s", tc.expectedStatus, resp.StatusCode, string(body)) + }) + } +} + +// --- Setup functions for failure test cases --- + +// setupWrongCredentialType configures the verifier to expect TypeA via DCQL, +// but the VP contains a credential of type TypeB. +func setupWrongCredentialType(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("TypeA", nil), + helpers.BuildIssuerAttribute("TypeB", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + // Config expects TypeA, but we'll present TypeB. + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "TypeA", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "TypeA", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "TypeA"), + }, + }). + Build() + + // Create a VC of TypeB instead of the expected TypeA. + vc, err := helpers.CreateJWTVC(issuer, "TypeB", map[string]interface{}{ + "type": "TypeB", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(holder, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +} + +// setupMissingRequiredClaims configures the TIR to restrict allowed values for a claim, +// then presents a VC whose claim value is not in the allowed set. +func setupMissingRequiredClaims(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + // TIR restricts the "role" claim to only allow "admin". + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", []helpers.TIRClaim{ + {Name: "role", AllowedValues: []interface{}{"admin"}}, + }), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + // VC has role=user, but TIR only allows role=admin. + vc, err := helpers.CreateJWTVC(issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + "role": "user", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(holder, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +} + +// setupUntrustedIssuer presents a VC signed by an issuer whose DID is not registered in the TIR. +func setupUntrustedIssuer(t *testing.T) *m2mTestFixture { + t.Helper() + + trustedIssuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + untrustedIssuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + // Only trustedIssuer is in the TIR; untrustedIssuer is not. + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + trustedIssuer.DID: { + Did: trustedIssuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + // VC is signed by untrustedIssuer, which the TIR does not know. + vc, err := helpers.CreateJWTVC(untrustedIssuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(holder, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +} + +// setupInvalidVPSignature presents a VP whose JWT signature does not match the holder's key. +// The VP is signed by a different key than the one whose DID is used as issuer/holder. +func setupInvalidVPSignature(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + // A different identity whose key will be used to sign the VP, + // creating a mismatch between the VP's iss/kid and the actual signing key. + wrongSigner, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + vc, err := helpers.CreateJWTVC(issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + }) + require.NoError(t, err) + + // Sign the VP with wrongSigner's key but use holder's DID as the VP issuer. + // This creates a VP whose signature cannot be verified against the holder's public key. + vpJWT, err := helpers.CreateVPTokenWithMismatchedSigner(holder, wrongSigner, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +} + +// setupInvalidCnfBinding presents a VP where the VC's cnf.jwk references holder A's key, +// but the VP is signed by holder B. Since the verifier does not enforce cnf validation, +// the VP itself must have a valid signature — the failure comes from the holder mismatch +// combined with claim-based holder verification enabled on the credential. +// Note: Since the verifier ignores cnf, we enable claim-based holder verification to detect +// the holder mismatch that cnf was supposed to prevent. +func setupInvalidCnfBinding(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holderA, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holderB, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + // Enable claim-based holder verification to detect the holder mismatch. + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithHolderVerification(serviceID, scopeName, "CustomerCredential", "holder"). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + // VC has holder claim set to holderA's DID, but VP is signed by holderB. + vc, err := helpers.CreateJWTVCWithHolder(issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + }, holderA.DID) + require.NoError(t, err) + + // holderB signs the VP — mismatch with the VC's holder claim. + vpJWT, err := helpers.CreateVPToken(holderB, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +} + +// setupInvalidClaimHolderBinding presents a VC with credentialSubject.holder set to DID-A, +// but the VP is signed by DID-B. The verifier validates that the holder claim matches. +func setupInvalidClaimHolderBinding(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holderA, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holderB, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithHolderVerification(serviceID, scopeName, "CustomerCredential", "holder"). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + // VC's holder claim is holderA's DID. + vc, err := helpers.CreateJWTVCWithHolder(issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + }, holderA.DID) + require.NoError(t, err) + + // VP is signed by holderB — mismatch with VC's holder claim. + vpJWT, err := helpers.CreateVPToken(holderB, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +} diff --git a/integration_test/m2m_test.go b/integration_test/m2m_test.go new file mode 100644 index 0000000..5cfc1ff --- /dev/null +++ b/integration_test/m2m_test.go @@ -0,0 +1,598 @@ +//go:build integration + +package integration_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/fiware/VCVerifier/integration_test/helpers" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // serviceID is the service identifier used in all M2M test configurations. + serviceID = "test-svc" + // scopeName is the OIDC scope used in all M2M test configurations. + scopeName = "test-scope" +) + +var ( + // binaryPath holds the path to the compiled verifier binary, built once per test run. + binaryPath string + // projectRoot holds the absolute path to the VCVerifier project root. + projectRoot string +) + +func TestMain(m *testing.M) { + // Determine project root (parent of integration_test/). + wd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get working directory: %v\n", err) + os.Exit(1) + } + projectRoot = filepath.Dir(wd) + + // Resolve the verifier binary: VERIFIER_BINARY (local path), VERIFIER_BINARY_URL (download), + // or build from source. + needsCleanup := false + if path := os.Getenv("VERIFIER_BINARY"); path != "" { + binaryPath = path + fmt.Fprintf(os.Stderr, "Using pre-built verifier binary: %s\n", binaryPath) + } else if binaryURL := os.Getenv("VERIFIER_BINARY_URL"); binaryURL != "" { + fmt.Fprintf(os.Stderr, "Downloading verifier binary from: %s\n", binaryURL) + binaryPath, err = helpers.DownloadVerifier(binaryURL) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to download verifier: %v\n", err) + os.Exit(1) + } + needsCleanup = true + } else { + binaryPath, err = helpers.BuildVerifier(projectRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to build verifier: %v\n", err) + os.Exit(1) + } + needsCleanup = true + } + + if needsCleanup { + defer os.RemoveAll(filepath.Dir(binaryPath)) + } + + os.Exit(m.Run()) +} + +// m2mTestCase defines a parameterized M2M success test case. +type m2mTestCase struct { + name string + // setup prepares all infrastructure (mocks, identities, config, credentials) + // and returns what's needed to execute the test. + setup func(t *testing.T) *m2mTestFixture +} + +// m2mTestFixture holds all objects needed to execute an M2M test case. +type m2mTestFixture struct { + configYAML string + extraEnv []string + dcqlResponse map[string]string + cleanup func() +} + +// TestM2MSuccess runs parameterized success tests for the M2M VP-token-to-JWT exchange +// via POST /services/:service_id/token with grant_type=vp_token. +func TestM2MSuccess(t *testing.T) { + tests := []m2mTestCase{ + { + name: "OneJWTVC_DidKeyIssuer", + setup: setupOneJWTVCDidKey, + }, + { + name: "MultipleJWTVCs_DidKeyIssuer", + setup: setupMultipleJWTVCsDidKey, + }, + { + name: "OneSDJWT_DidKeyIssuer", + setup: setupOneSDJWTDidKey, + }, + { + name: "MultipleSDJWTs_DidKeyIssuer", + setup: setupMultipleSDJWTsDidKey, + }, + { + name: "JWTVC_DidWebIssuer", + setup: setupJWTVCDidWeb, + }, + { + name: "JWTVC_CnfHolderBinding", + setup: setupJWTVCCnfHolder, + }, + { + name: "JWTVC_ClaimBasedHolderBinding", + setup: setupJWTVCClaimHolder, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fixture := tc.setup(t) + defer fixture.cleanup() + + vp, err := helpers.StartVerifier(fixture.configYAML, projectRoot, binaryPath, fixture.extraEnv...) + require.NoError(t, err, "verifier should start successfully") + defer vp.Stop() + + vpToken, err := helpers.CreateDCQLResponse(fixture.dcqlResponse) + require.NoError(t, err) + + resp, err := http.PostForm( + fmt.Sprintf("%s/services/%s/token", vp.BaseURL, serviceID), + url.Values{ + "grant_type": {"vp_token"}, + "vp_token": {vpToken}, + "scope": {scopeName}, + }, + ) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK, got %d: %s", resp.StatusCode, string(body)) + + var tokenResp tokenResponse + err = json.Unmarshal(body, &tokenResp) + require.NoError(t, err) + + assert.Equal(t, "Bearer", tokenResp.TokenType) + assert.NotEmpty(t, tokenResp.AccessToken, "access_token should not be empty") + assert.NotEmpty(t, tokenResp.IDToken, "id_token should not be empty") + + // Verify the returned JWT against the verifier's JWKS. + verifyAccessToken(t, vp.BaseURL, tokenResp.AccessToken) + }) + } +} + +// tokenResponse mirrors the verifier's token endpoint JSON response. +type tokenResponse struct { + TokenType string `json:"token_type"` + IssuedTokenType string `json:"issued_token_type"` + ExpiresIn float64 `json:"expires_in"` + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + Scope string `json:"scope"` +} + +// verifyAccessToken fetches the verifier's JWKS and verifies that the access token JWT +// has a valid signature and contains expected standard claims. +func verifyAccessToken(t *testing.T, baseURL string, accessToken string) { + t.Helper() + + // Fetch JWKS from the verifier. + resp, err := http.Get(baseURL + "/.well-known/jwks") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + jwksBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + keySet, err := jwk.Parse(jwksBytes) + require.NoError(t, err) + + // Parse and verify the JWT signature using the JWKS. + token, err := jwt.Parse([]byte(accessToken), jwt.WithKeySet(keySet, jws.WithInferAlgorithmFromKey(true))) + require.NoError(t, err, "access token JWT should be verifiable with verifier's JWKS") + + // The token should have an issuer and an expiration. + iss, issOk := token.Issuer() + assert.True(t, issOk, "JWT should have an issuer claim") + assert.NotEmpty(t, iss, "JWT issuer should not be empty") + exp, expOk := token.Expiration() + assert.True(t, expOk, "JWT should have an expiration claim") + assert.False(t, exp.IsZero(), "JWT expiration should not be zero") +} + +// --- Setup functions for each test case --- + +// setupOneJWTVCDidKey creates a single JWT-VC with a did:key issuer. +func setupOneJWTVCDidKey(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + vc, err := helpers.CreateJWTVC(issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + "name": "Test User", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(holder, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +} + +// setupMultipleJWTVCsDidKey creates two JWT-VCs of different types with a did:key issuer. +func setupMultipleJWTVCsDidKey(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("TypeA", nil), + helpers.BuildIssuerAttribute("TypeB", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "TypeA", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "TypeA", true). + WithCredential(serviceID, scopeName, "TypeB", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "TypeB", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "TypeA"), + helpers.NewJWTVCQuery("cred-2", "TypeB"), + }, + }). + Build() + + vcA, err := helpers.CreateJWTVC(issuer, "TypeA", map[string]interface{}{"type": "TypeA"}) + require.NoError(t, err) + vcB, err := helpers.CreateJWTVC(issuer, "TypeB", map[string]interface{}{"type": "TypeB"}) + require.NoError(t, err) + + // Each DCQL query gets its own VP wrapping the matching VC. + vpA, err := helpers.CreateVPToken(holder, "", serviceID, vcA) + require.NoError(t, err) + vpB, err := helpers.CreateVPToken(holder, "", serviceID, vcB) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{ + "cred-1": vpA, + "cred-2": vpB, + }, + cleanup: func() { tirServer.Close() }, + } +} + +// setupOneSDJWTDidKey creates a single SD-JWT with a did:key issuer. +func setupOneSDJWTDidKey(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewSDJWTQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + sdJWT, err := helpers.CreateSDJWT(issuer, "CustomerCredential", map[string]interface{}{ + "familyName": "Doe", + "givenName": "John", + }) + require.NoError(t, err) + + // SD-JWT is placed directly in the DCQL response (not wrapped in a VP). + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": sdJWT}, + cleanup: func() { tirServer.Close() }, + } +} + +// setupMultipleSDJWTsDidKey creates two SD-JWTs of different types from the same did:key issuer. +func setupMultipleSDJWTsDidKey(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("TypeA", nil), + helpers.BuildIssuerAttribute("TypeB", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "TypeA", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "TypeA", true). + WithCredential(serviceID, scopeName, "TypeB", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "TypeB", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewSDJWTQuery("cred-1", "TypeA"), + helpers.NewSDJWTQuery("cred-2", "TypeB"), + }, + }). + Build() + + sdJWTA, err := helpers.CreateSDJWT(issuer, "TypeA", map[string]interface{}{"familyName": "Doe"}) + require.NoError(t, err) + sdJWTB, err := helpers.CreateSDJWT(issuer, "TypeB", map[string]interface{}{"givenName": "John"}) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{ + "cred-1": sdJWTA, + "cred-2": sdJWTB, + }, + cleanup: func() { tirServer.Close() }, + } +} + +// setupJWTVCDidWeb creates a JWT-VC with a did:web issuer backed by a TLS mock server. +// Uses SetupDidWebTLSIdentity to handle the chicken-and-egg problem of needing +// the server URL for the DID while needing the identity for the DID document. +func setupJWTVCDidWeb(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, didWebServer := helpers.SetupDidWebTLSIdentity() + + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + vc, err := helpers.CreateJWTVC(issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + "name": "Test User", + }) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(holder, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + extraEnv: []string{"SSL_CERT_FILE=" + didWebServer.CACertPath}, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { + tirServer.Close() + didWebServer.Close() + }, + } +} + +// setupJWTVCCnfHolder creates a JWT-VC with cnf (confirmation) holder binding. +// The verifier does not enforce cnf validation, so this test verifies that +// VCs with cnf claims are accepted without error. +func setupJWTVCCnfHolder(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + vc, err := helpers.CreateJWTVCWithCnf(issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + "name": "Test User", + }, holder.PublicKeyJWK) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(holder, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +} + +// setupJWTVCClaimHolder creates a JWT-VC with claim-based holder binding. +// The VC's credentialSubject contains a "holder" field matching the VP signer's DID. +// The verifier validates that credentialSubject.holder == VP holder DID. +func setupJWTVCClaimHolder(t *testing.T) *m2mTestFixture { + t.Helper() + + issuer, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + holder, err := helpers.GenerateDidKeyIdentity() + require.NoError(t, err) + + tirServer := helpers.NewMockTIR(map[string]helpers.TrustedIssuer{ + issuer.DID: { + Did: issuer.DID, + Attributes: []helpers.IssuerAttribute{ + helpers.BuildIssuerAttribute("VerifiableCredential", nil), + helpers.BuildIssuerAttribute("CustomerCredential", nil), + }, + }, + }) + + port, err := helpers.GetFreePort() + require.NoError(t, err) + + keyPath, err := helpers.GenerateSigningKeyPEM(t.TempDir()) + require.NoError(t, err) + + config := helpers.NewConfigBuilder(port, tirServer.URL). + WithSigningKey(keyPath). + WithService(serviceID, scopeName, "DEEPLINK"). + WithCredential(serviceID, scopeName, "VerifiableCredential", "*"). + WithCredential(serviceID, scopeName, "CustomerCredential", tirServer.URL). + WithJwtInclusion(serviceID, scopeName, "CustomerCredential", true). + WithHolderVerification(serviceID, scopeName, "CustomerCredential", "holder"). + WithDCQL(serviceID, scopeName, helpers.DCQLConfig{ + Credentials: []helpers.CredentialQuery{ + helpers.NewJWTVCQuery("cred-1", "CustomerCredential"), + }, + }). + Build() + + vc, err := helpers.CreateJWTVCWithHolder(issuer, "CustomerCredential", map[string]interface{}{ + "type": "CustomerCredential", + "name": "Test User", + }, holder.DID) + require.NoError(t, err) + + vpJWT, err := helpers.CreateVPToken(holder, "", serviceID, vc) + require.NoError(t, err) + + return &m2mTestFixture{ + configYAML: config, + dcqlResponse: map[string]string{"cred-1": vpJWT}, + cleanup: func() { tirServer.Close() }, + } +}