Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
826bd3d
Add implementation plan for refresh token support (Ticket #34)
Apr 30, 2026
d5c8ac0
Address review feedback on PR #1
Apr 30, 2026
d1dffc0
Add refresh token configuration fields and TYPE_REFRESH_TOKEN constant
Apr 30, 2026
60d0549
Add database-backed refresh token store and generation logic
Apr 30, 2026
e1eb1cc
Address review feedback on PR #3
Apr 30, 2026
be0482d
Add refresh_token to token response model and OpenAPI spec
Apr 30, 2026
2633442
Wire refresh token into token endpoint handlers
Apr 30, 2026
d9645f0
Add comprehensive tests for refresh token flows
Apr 30, 2026
3b46c04
Refactor refresh token config into nested struct under Verifier
Mortega5 May 4, 2026
45eb68e
add refreshToken cleanup interval
Mortega5 May 4, 2026
e4a270e
hash refresh tokens with HMAC-SHA256 + token suffix
Mortega5 May 4, 2026
fa0bc3d
validate database claims
Mortega5 May 4, 2026
6751107
refactor(config): add support to env vars in the config file
Mortega5 May 4, 2026
70108d3
mask hashsalt and database password
Mortega5 May 4, 2026
c7cbce0
fix(verifier): only validate holder if needed
Mortega5 May 4, 2026
497217f
add refresh_token_expires_in field to token response
Mortega5 May 4, 2026
132b2e2
feat(refresh_token): store claims instead of signed JWT, add HMAC int…
Mortega5 May 5, 2026
3617e11
add default token cleaup interval
Mortega5 May 5, 2026
d1f39f9
add expire_at field to refresh_token integrity
Mortega5 May 5, 2026
b3108c4
add refresh_token_expires_in to api.yaml
Mortega5 May 5, 2026
f737d76
refactor(database): split repository.go into service and refresh toke…
Mortega5 May 5, 2026
7b26196
add database healthcheck to verifier
Mortega5 May 5, 2026
1620054
remove implementation plan
Mortega5 May 7, 2026
3955768
update documentation
Mortega5 May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ Key config sections: `server` (port, timeouts, template/static dirs), `logging`,
- Test fixtures in `config/data/` (YAML files)
- Logging is initialized in tests with a shared `LOGGING_CONFIG` variable

## Important Files

- **`main.go`** — Entry point; reads config, initializes verifier, sets up Gin router.
- **`config/config.go`** — All configuration structs (`Configuration`, `Verifier`, `Server`, etc.) with `mapstructure` tags and defaults.
- **`common/metadata.go`** — OAuth2 grant type and token type constants (`TYPE_CODE`, `TYPE_VP_TOKEN`, `TYPE_TOKEN_EXCHANGE`, `TYPE_ACCESS_TOKEN`).
- **`common/cache.go`** — `Cache` interface wrapping `patrickmn/go-cache` (Get/Set/Add/Delete/GetWithExpiration).
- **`common/tokenSigner.go`** — `TokenSigner` interface (Sign method using lestrrat-go/jwx).
- **`verifier/verifier.go`** — `Verifier` interface (lines 92-106) and `CredentialVerifier` implementation. Key methods: `GetToken` (authorization_code exchange, line 490), `GenerateToken` (VP token exchange, line 580), `AuthenticationResponse` (stores JWT in tokenCache, line 846), `generateJWT` (builds JWT with claims, line 1230).
- **`openapi/api_api.go`** — HTTP handlers: `GetToken` (line 97, routes by grant_type), `handleTokenTypeCode` (line 330), `handleTokenTypeVPToken` (line 290), `handleTokenTypeTokenExchange` (line 260), `verifiyVPToken` (line 309).
- **`openapi/model_token_response.go`** — `TokenResponse` struct with JSON tags.
- **`api/api.yaml`** — OpenAPI spec: `TokenRequest` schema (line 636), `TokenResponse` schema (line 676), `/token` endpoint (line 179).

### Token Flow Details

- **tokenStore** (verifier.go:248): Holds `jwt.Token` + `redirect_uri`, keyed by authorization code (random nonce) in `tokenCache`.
- **tokenCache**: Uses `patrickmn/go-cache` with `SessionExpiry`-based TTL. Tokens are **deleted after single retrieval** (get-then-delete pattern, line 498).
- **JWT signing**: RS256 or ES256 via `tokenSigner.Sign()` with `v.signingKey` (jwk.Key). Claims include issuer, audience, expiration (`jwtExpiration` duration), issuedAt, optional subject/nonce, and credential data.
- **Three grant types**: `authorization_code` (exchanges code for cached JWT), `vp_token` (direct VP token validation + JWT generation), `urn:ietf:params:oauth:grant-type:token-exchange` (RFC 8693 token exchange via VP token).

## Key Dependencies

- **trustbloc/vc-go, did-go, kms-go** — VC verification, DID resolution, key management
Expand Down
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ VCVerifier provides the necessary endpoints(see [API](./api/api.yaml)) to offer
* [Local Setup](#local-setup)
* [Configuration](#configuration)
* [Templating](#templating)
* [Database](#database)
* [Refresh Token](#refresh-token)
* [ConfigServer](#configserver)
* [WaltID SSIKit](#waltid-ssikit)
* [Usage](#usage)
* [Frontend-Integration](#frontend-integration)
Expand Down Expand Up @@ -226,6 +229,92 @@ configRepo:

The login-page, provided at ```/api/v1/loginQR```, can be configured by providing a different template in the ```templateDir```. The templateDir needs to contain a file named ```verifier_present_qr.html``` which will be rendered on calls to the login-api. The template needs to include the QR-Code via ```<img src="data:{{.qrcode}}"```. Beside that, all options provided by the [goview-framework](https://github.com/foolin/goview) can be used. Static content(like icons, images) can be provided through the ```staticDir``` and will be available at the path ```/static```.

#### Database

The VCVerifier supports an optional relational database for persistent storage. It is used to store **refresh tokens** (when [`refreshToken.enabled`](#refresh-token) is `true`) or **service trust configuration** (when [`configServer.enabled`](#configserver) is `true`). The following databases are supported:

| Database | `type` value |
|----------|-------------|
| PostgreSQL | `postgres` |
| MySQL / MariaDB | `mysql` |
| SQLite (file or in-memory) | `sqlite` |

```yaml
database:
# database backend: postgres, mysql, or sqlite
type: postgres
# hostname of the database server (default: localhost)
host: localhost
# port of the database server (default: 5432)
port: 5432
# database name; for SQLite: file path or ":memory:" for an in-memory database
name: vcverifier
# database user
user: vcverifier
# database password — never write plaintext here; use an environment variable:
password: ${DB_PASSWORD}
# TLS mode: "disable" / "require" / etc. (postgres) or "true" / "false" / "skip-verify" (mysql)
sslMode: disable
```

> :warning: **Security**: Never store the database password in plain text in `server.yaml`. Use the `${DB_PASSWORD}` interpolation syntax shown above, or set the `VCVERIFIER_DATABASE_PASSWORD` environment variable.

#### Refresh Token

When enabled, the verifier issues a `refresh_token` alongside each `access_token`. Refresh tokens are hashed and persisted in the [database](#database), so **a database connection is required**.

```yaml
verifier:
refreshToken:
# enable refresh token issuance alongside access tokens (requires database)
enabled: false
# lifetime of issued refresh tokens in minutes (default: 2880 = 48 h)
expiration: 2880
# how often (in seconds) expired tokens are purged from the database (default: 60)
cleanupInterval: 60
# HMAC-SHA256 key used to hash tokens before storage.
# When omitted a random salt is generated at startup — tokens are invalidated on restart.
# Use an environment variable to keep this secret off disk:
hashSalt: ${REFRESH_TOKEN_HASH_SALT}
```

> :warning: **Security**: The `hashSalt` value is a secret used to sign stored tokens. Never write it in plain text in `server.yaml`. Use the `${REFRESH_TOKEN_HASH_SALT}` syntax or set the `VCVERIFIER_VERIFIER_REFRESH_TOKEN_HASH_SALT` environment variable.

#### ConfigServer

The ConfigServer exposes an additional REST API that lets external tools (for example [Credentials Config Service](https://github.com/FIWARE/credentials-config-service)) manage trust configuration at runtime, without restarting the verifier. When `enabled` is `true`, a second HTTP listener is started on the configured port. **A database connection is required.**

```yaml
configServer:
# enable the ConfigServer REST API (requires database)
enabled: false
# port for the secondary HTTP listener (default: 8090)
port: 8090
# HTTP read timeout in seconds (default: 5)
readTimeout: 5
# HTTP write timeout in seconds (default: 10)
writeTimeout: 10
# keep-alive idle timeout in seconds (default: 120)
idleTimeout: 120
# graceful-shutdown timeout in seconds (default: 5)
shutdownTimeout: 5
```

The `configRepo` section controls how service scope and trust configurations are loaded. When `configEndpoint` is set, the verifier fetches service configuration from that external CCS instance. When `configEndpoint` is **not** set and a [database](#database) is configured, service configuration is read directly from the database. Services can also be defined statically in `server.yaml`:

```yaml
configRepo:
# URL of an external Credentials-Config-Service instance (optional).
# When absent and a database is configured, configuration is read from the database instead.
configEndpoint: http://config-service:8080
# how often (in seconds) to refresh configuration from configEndpoint (default: 30)
updateInterval: 30
# static service definitions — used when neither configEndpoint nor database is available,
# or to seed the database on first start
services:
...
```

## Usage

The VCVerifier provides support for integration in frontend-applications(e.g. typical H2M-interactin) or plain api-usage(mostly M2M).
Expand Down
14 changes: 12 additions & 2 deletions api/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -636,9 +636,9 @@ components:
TokenRequest:
type: object
properties:
grant_type:
grant_type:
type: string
enum: ["authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"]
enum: ["authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"]
code:
type: string
example: myRandomString
Expand Down Expand Up @@ -671,6 +671,9 @@ components:
type: string
description: An identifier that indicates the type of the security token in the subject_token parameter.
enum: ["urn:eu:oidf:vp_token"]
refresh_token:
type: string
description: The refresh token to exchange for a new access token. Required when grant_type is refresh_token.
required:
- grant_type
TokenResponse:
Expand All @@ -687,3 +690,10 @@ components:
example: 3600
access_token:
type: string
refresh_token:
type: string
description: Refresh token to obtain new access tokens. Only present when refresh tokens are enabled.
refresh_token_expires_in:
type: integer
description: Lifetime of the refresh token in seconds. Only present when refresh tokens are enabled.
example: 172800
13 changes: 13 additions & 0 deletions common/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@ type Presentation struct {
// Stored as interface{} to avoid jwx dependency in the common package.
// The verifier package type-asserts to jwk.Key.
holderKey interface{}
// rawToken stores the original VP JWT bytes for deferred signature verification.
// Only set in the SD-JWT VP path; nil for JSON-LD VPs.
rawToken []byte
}

// HolderKey returns the public key that signed the VP JWT, if available.
Expand All @@ -267,6 +270,16 @@ func (p *Presentation) SetHolderKey(key interface{}) {
p.holderKey = key
}

// RawToken returns the original VP JWT bytes, if available.
func (p *Presentation) RawToken() []byte {
return p.rawToken
}

// SetRawToken stores the original VP JWT bytes for deferred verification.
func (p *Presentation) SetRawToken(token []byte) {
p.rawToken = token
}

// Credentials returns the credentials contained in the presentation.
func (p *Presentation) Credentials() []*Credential {
return p.credentials
Expand Down
4 changes: 4 additions & 0 deletions common/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"
const TYPE_VP_TOKEN_SUBJECT = "urn:eu:oidf:vp_token"
const TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"

// TYPE_REFRESH_TOKEN is the OAuth2 grant type for exchanging a refresh token
// for a new access token (RFC 6749 Section 1.5).
const TYPE_REFRESH_TOKEN = "refresh_token"

type OpenIDProviderMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
Expand Down
51 changes: 49 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,51 @@
package config

import "github.com/fiware/VCVerifier/logging"
import (
"encoding/json"

"github.com/fiware/VCVerifier/logging"
)

const (
// DefaultRefreshTokenExpirationMinutes is the default lifetime for refresh
// tokens, expressed in minutes. 2880 minutes equals 48 hours.
DefaultRefreshTokenExpirationMinutes = 2880
)

// MaskedString is a string type for sensitive configuration values (passwords,
// secrets, HMAC keys). It marshals to "***" in JSON so that sensitive data
// never appears in logs or serialized output. The underlying string value is
// preserved and accessible normally in Go code (e.g. fmt.Sprintf, direct
// comparison), so no type conversion is needed at call sites
type MaskedString string

func (m MaskedString) MarshalJSON() ([]byte, error) {
if m == "" {
return json.Marshal("")
}
return json.Marshal("***")
}

// RefreshToken holds all configuration related to the refresh token feature.
type RefreshToken struct {
// Enabled controls whether the verifier issues refresh tokens alongside
// access tokens. When false (the default), no refresh tokens are generated
// and the refresh_token grant type is rejected.
Enabled bool `mapstructure:"enabled" default:"false"`
// Expiration is the lifetime of issued refresh tokens, in minutes.
// Defaults to 2880 (48 hours). Only meaningful when Enabled is true.
Expiration int `mapstructure:"expiration" default:"2880"`
// CleanupInterval is how often (in seconds) expired refresh token rows are
// purged from the database. 0 or negative disables cleanup.
CleanupInterval int `mapstructure:"cleanupInterval" default:"60"`
// HashSalt is the HMAC-SHA256 key used when hashing refresh tokens before
// storage. Tokens are always hashed; this field controls whether the key
// is stable across restarts. When empty a random salt is generated at
// startup, meaning all issued tokens are invalidated when the server
// restarts. Provide a fixed secret string to preserve tokens across
// restarts.
HashSalt MaskedString `mapstructure:"hashSalt"`
}

// CONFIGURATION STRUCTURE FOR THE VERIFIER CONFIG

Expand Down Expand Up @@ -30,7 +75,7 @@ type Database struct {
// User for database authentication
User string `mapstructure:"user"`
// Password for database authentication
Password string `mapstructure:"password"`
Password MaskedString `mapstructure:"password"`
// SSLMode for the postgres connection (for mysql, use "true", "false", "skip-verify", or "preferred")
SSLMode string `mapstructure:"sslMode" default:"disable"`
}
Expand Down Expand Up @@ -136,6 +181,8 @@ type Verifier struct {
// revocation check — it only parametrises the HTTP client used when at
// least one credential opts in.
StatusListHttpTimeout int `mapstructure:"statusListHttpTimeout" default:"10"`
// RefreshToken groups all refresh token configuration.
RefreshToken RefreshToken `mapstructure:"refreshToken"`
}

type ClientIdentification struct {
Expand Down
17 changes: 17 additions & 0 deletions config/data/refresh_token_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
server:
port: 3000
staticDir: "views/static"
templateDir: "views/"
logging:
level: "DEBUG"
jsonLogging: true
logRequests: true
verifier:
did: "did:key:somekey"
tirAddress: "https://test.dev/trusted_issuer/v3/issuers/"
sessionExpiry: 30
refreshToken:
enabled: true
expiration: 1440
m2m:
authEnabled: false
63 changes: 9 additions & 54 deletions config/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@ package config

import (
"fmt"
"reflect"
"os"

"github.com/gookit/config/v2"
"github.com/gookit/config/v2/yaml"
"github.com/mitchellh/mapstructure"
)

// read the config from the config file
func ReadConfig(configFile string) (configuration Configuration, err error) {
config.WithOptions(config.ParseDefault)
config.WithOptions(func(opt *config.Options) {
opt.ParseDefault = true
opt.ParseEnv = true
opt.TagName = "mapstructure"
})
config.AddDriver(yaml.Driver)
usuario := os.Getenv("DB_USER")
fmt.Println("Usuario:", usuario)

if err = config.LoadFiles(configFile); err != nil {
return
Expand All @@ -23,59 +28,9 @@ func ReadConfig(configFile string) (configuration Configuration, err error) {
return
}

raw := config.Data()
normalized := forceStringKeys(raw)

decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
TagName: "mapstructure",
Result: &configuration,
})
if err != nil {
return
}
if err = decoder.Decode(normalized); err != nil {
return
}

if err = ApplyEnvOverrides(&configuration); err != nil {
return
}

return
}

func forceStringKeys(m interface{}) interface{} {
switch v := m.(type) {
case map[interface{}]interface{}:
newMap := make(map[string]interface{})
for key, val := range v {
newMap[fmt.Sprintf("%v", key)] = forceStringKeys(val)
}
return newMap
case map[string]interface{}:
newMap := make(map[string]interface{})
for key, val := range v {
newMap[key] = forceStringKeys(val)
}
return newMap
case []interface{}:
for i := range v {
v[i] = forceStringKeys(v[i])
}
return v
default:
return v
}
}

func autoAllocHook() mapstructure.DecodeHookFunc {
return func(from reflect.Type, to reflect.Type, data interface{}) (interface{}, error) {
// If target type is a pointer to struct, and source is a map,
// allocate the target before decoding.
if to.Kind() == reflect.Ptr && to.Elem().Kind() == reflect.Struct {
v := reflect.New(to.Elem())
return v.Interface(), nil
}
return data, nil
}
return configuration, nil
}
Loading
Loading