release version 3

This commit is contained in:
Sergio Peñafiel 2025-09-16 08:00:11 +10:00
parent f3ee84b5ca
commit a1651c914e
35 changed files with 734 additions and 66 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -13,3 +13,8 @@ POSTGRES_PASSWORD=keycloak
KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl
FHIR_BASE_URL=http://lacpass.create.cl:8080 FHIR_BASE_URL=http://lacpass.create.cl:8080
VHL_BASE_URL=http://lacpass.create.cl:8182 VHL_BASE_URL=http://lacpass.create.cl:8182
FHIR_MEDIATOR_BASE_URL=http://lacpass.create.cl:3000
WALLET_ENABLED=false
WALLET_URL=https://conectathon-balancer.izer.tech
WALLET_IDENTIFIER=test
WALLET_API_KEY=""

1
.gitignore vendored
View File

@ -12,4 +12,3 @@ content-generator
templates templates
out out
debugger debugger
.DS_Store

View File

@ -42,18 +42,6 @@ The architecture of this backend system is designed to separate concerns between
- [Authentication](/docs/authentication.md) - [Authentication](/docs/authentication.md)
- IPS Lacpass API (WIP) - IPS Lacpass API (WIP)
### ⚠️ Complete the not implemented calls
This template backend application includes some functionalities that are intentionally left unimplemented. These are meant to be completed by the participant to assess their knowledge of the FHIR/VHL standards.
To complete these functionalities, search the code for the `TODO: To be implemented by the participant` message. You will find 3 missing functionaties for:
- Implementing an ITI-67 call
- Implementing an ITI-68 call
- Implementing a QR code generation using VHL
After completing these steps, you can proceed to the next section on running the application.
### Running the Application ### Running the Application
Open a terminal in the root directory of the project and run the following command: Open a terminal in the root directory of the project and run the following command:

View File

@ -17,8 +17,13 @@ type Config struct {
AuthEmailClientID string AuthEmailClientID string
FhirBaseUrl string FhirBaseUrl string
VhlBaseUrl string VhlBaseUrl string
FhirMediatorBaseUrl string
APISwagger bool APISwagger bool
LogLevel string LogLevel string
WalletEnabled bool
WalletUrl string
WalletIdentifier string
WalletAPIKey string
} }
func LoadConfig() Config { func LoadConfig() Config {
@ -34,8 +39,13 @@ func LoadConfig() Config {
AuthEmailClientID: "app", AuthEmailClientID: "app",
FhirBaseUrl: "http://lacpass.create.cl:8080", FhirBaseUrl: "http://lacpass.create.cl:8080",
VhlBaseUrl: "http://lacpass.create.cl:8182", VhlBaseUrl: "http://lacpass.create.cl:8182",
FhirMediatorBaseUrl: "http://lacpass.create.cl:3000/",
APISwagger: false, APISwagger: false,
LogLevel: "info", LogLevel: "info",
WalletEnabled: false,
WalletUrl: "https://conectathon-balancer.izer.tech/",
WalletIdentifier: "test",
WalletAPIKey: "",
} }
if serverPort, exists := os.LookupEnv("API_PORT"); exists { if serverPort, exists := os.LookupEnv("API_PORT"); exists {
@ -75,6 +85,9 @@ func LoadConfig() Config {
if fhirBaseUrl, exists := os.LookupEnv("FHIR_BASE_URL"); exists { if fhirBaseUrl, exists := os.LookupEnv("FHIR_BASE_URL"); exists {
cfg.FhirBaseUrl = fhirBaseUrl cfg.FhirBaseUrl = fhirBaseUrl
} }
if fhirMediatorBaseUrl, exists := os.LookupEnv("FHIR_MEDIATOR_BASE_URL"); exists {
cfg.FhirMediatorBaseUrl = fhirMediatorBaseUrl
}
if vhlBaseUrl, exists := os.LookupEnv("VHL_BASE_URL"); exists { if vhlBaseUrl, exists := os.LookupEnv("VHL_BASE_URL"); exists {
cfg.VhlBaseUrl = vhlBaseUrl cfg.VhlBaseUrl = vhlBaseUrl
} }
@ -85,5 +98,19 @@ func LoadConfig() Config {
cfg.LogLevel = logLevel cfg.LogLevel = logLevel
} }
if walletEnabled, exists := os.LookupEnv("WALLET_ENABLED"); exists {
cfg.WalletEnabled = walletEnabled == "true"
}
if walletUrl, exists := os.LookupEnv("WALLET_URL"); exists {
cfg.WalletUrl = walletUrl
}
if walletIdentifier, exists := os.LookupEnv("WALLET_IDENTIFIER"); exists {
cfg.WalletIdentifier = walletIdentifier
}
if walletAPIKey, exists := os.LookupEnv("WALLET_API_KEY"); exists {
cfg.WalletAPIKey = walletAPIKey
}
return cfg return cfg
} }

View File

@ -24,6 +24,10 @@ import (
vhlClient "ips-lacpass-backend/internal/vhl/client" vhlClient "ips-lacpass-backend/internal/vhl/client"
vhlCore "ips-lacpass-backend/internal/vhl/core" vhlCore "ips-lacpass-backend/internal/vhl/core"
vhlHandler "ips-lacpass-backend/internal/vhl/handler" vhlHandler "ips-lacpass-backend/internal/vhl/handler"
walletClient "ips-lacpass-backend/internal/wallet/client"
walletCore "ips-lacpass-backend/internal/wallet/core"
walletHandler "ips-lacpass-backend/internal/wallet/handler"
) )
func (a *App) loadRoutes() { func (a *App) loadRoutes() {
@ -80,6 +84,9 @@ func (a *App) loadRoutes() {
r.Route("/ips", a.loadIpsRoute) r.Route("/ips", a.loadIpsRoute)
r.Route("/users/auth", a.loadUserRoutesAuth) r.Route("/users/auth", a.loadUserRoutesAuth)
r.Route("/qr", a.loadVhlRoute) r.Route("/qr", a.loadVhlRoute)
if a.config.WalletEnabled {
r.Route("/wallet", a.loadWalletRoutes)
}
}) })
r.Get("/*", func(w http.ResponseWriter, r *http.Request) { r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
@ -124,11 +131,12 @@ func (a *App) loadUserRoutesAuth(router chi.Router) {
} }
func (a *App) loadIpsRoute(router chi.Router) { func (a *App) loadIpsRoute(router chi.Router) {
r := ipsClient.NewClient(a.config.FhirBaseUrl) r := ipsClient.NewClient(a.config.FhirBaseUrl, a.config.FhirMediatorBaseUrl)
s := ipsCore.NewService(&r) s := ipsCore.NewService(&r)
h := ipsHandler.NewHandler(&s) h := ipsHandler.NewHandler(&s)
router.Get("/", h.Get) router.Get("/", h.Get)
router.Post("/merge", h.Merge) router.Post("/merge", h.Merge)
router.Get("/icvp", h.GetICVP)
} }
func (a *App) loadVhlRoute(router chi.Router) { func (a *App) loadVhlRoute(router chi.Router) {
@ -138,3 +146,10 @@ func (a *App) loadVhlRoute(router chi.Router) {
router.Post("/", h.Create) router.Post("/", h.Create)
router.Post("/fetch", h.Get) router.Post("/fetch", h.Get)
} }
func (a *App) loadWalletRoutes(router chi.Router) {
r := walletClient.NewClient(a.config.WalletUrl, a.config.WalletIdentifier, a.config.WalletAPIKey)
s := walletCore.NewService(&r)
h := walletHandler.NewHandler(&s)
router.Post("/generate-link", h.GenerateWalletLink)
}

View File

@ -21,7 +21,12 @@ services:
AUTH_EMAIL_CLIENT_ID: ${AUTH_EMAIL_CLIENT_ID:-app} AUTH_EMAIL_CLIENT_ID: ${AUTH_EMAIL_CLIENT_ID:-app}
FHIR_BASE_URL: ${FHIR_BASE_URL:-http://lacpass.create.cl:8080} FHIR_BASE_URL: ${FHIR_BASE_URL:-http://lacpass.create.cl:8080}
VHL_BASE_URL: ${VHL_BASE_URL:-http://lacpass.create.cl:8182} VHL_BASE_URL: ${VHL_BASE_URL:-http://lacpass.create.cl:8182}
FHIR_MEDIATOR_BASE_URL: ${FHIR_MEDIATOR_BASE_URL:-http://lacpass.create.cl:3000}
API_SWAGGER: ${API_SWAGGER:-true} API_SWAGGER: ${API_SWAGGER:-true}
WALLET_ENABLED: ${WALLET_ENABLED:-0}
WALLET_URL: ${WALLET_URL:-https://conectathon-balancer.izer.tech/}
WALLET_IDENTIFIER: ${WALLET_IDENTIFIER:-test}
WALLET_API_KEY: ${WALLET_API_KEY:-}
ports: ports:
- "9081:3000" - "9081:3000"
healthcheck: healthcheck:

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

BIN
docs/images/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

View File

@ -12,6 +12,7 @@ Before using the API service, you must enable authentication and set the client
![Change Realm](./images/client_secret/keycloak_change_realm.png) ![Change Realm](./images/client_secret/keycloak_change_realm.png)
3. To enable authentication: 3. To enable authentication:
- Go to the `admin-cli` configuration: - Go to the `admin-cli` configuration:
![Admin CLI Access](./images/client_secret/keycloak_admin_cli.png) ![Admin CLI Access](./images/client_secret/keycloak_admin_cli.png)
@ -23,6 +24,7 @@ Before using the API service, you must enable authentication and set the client
- Click **Save** to apply the changes. - Click **Save** to apply the changes.
4. To retrieve the client credentials: 4. To retrieve the client credentials:
- Navigate to the **Credentials** tab. - Navigate to the **Credentials** tab.
- Copy the client secret value (it may be hidden by default). - Copy the client secret value (it may be hidden by default).
@ -42,6 +44,7 @@ To allow the backend service to perform all necessary operations, the `admin` ro
![Service Account Roles](./images/add_roles/keycloak_service_account_assign_role.png) ![Service Account Roles](./images/add_roles/keycloak_service_account_assign_role.png)
3. To simplify selection: 3. To simplify selection:
- Change the page size to show 100 roles per page: - Change the page size to show 100 roles per page:
![Show 100 Roles](./images/add_roles/keycloak_assign_role_100_pages.png) ![Show 100 Roles](./images/add_roles/keycloak_assign_role_100_pages.png)
@ -54,7 +57,6 @@ To allow the backend service to perform all necessary operations, the `admin` ro
![Role Account List](./images/add_roles/keycloak_service_account_list.png) ![Role Account List](./images/add_roles/keycloak_service_account_list.png)
## Set Custom Redirect URI (Optional) ## Set Custom Redirect URI (Optional)
If you are not using the provided P4H4 application and plan to integrate with the Keycloak service directly, you must configure your own redirect URIs. If you are not using the provided P4H4 application and plan to integrate with the Keycloak service directly, you must configure your own redirect URIs.
@ -74,3 +76,27 @@ To do this:
5. Click **Save** to apply your changes. 5. Click **Save** to apply your changes.
This ensures your application can successfully handle authentication responses from Keycloak. This ensures your application can successfully handle authentication responses from Keycloak.
## Set Frontend URL (for local development)
To test all Keycloak features in your local environment or when using IP addresses as domains, you need to configure the **Frontend URL** in your realm settings. You can do this by going to **Realm Settings → General**, as shown in the image below:
![Add redirect URI](./images/change_frontend_url.png)
When working locally, do **not** use `localhost`. Instead, use `10.0.2.2`.
This should point to the URL where Keycloak is running, so dont forget to include the port.
## Set Authenticationl email
For recover password and similar services, a SMTP email account must be set to send emails to users.
To do this:
1. Ensure you are in **lacpass** realm.
2. Go to **Realm settings** tab on the botton left.
3. Go to **Email** tab.
![Go to mail tab](./images/email_config/go_to_email_tab.png)
4. On the bottom set your STMP credentials in the **Connection & Authentication** section and save.
![Configure your SMTP](./images/email_config/configure_smtp.png)

3
go.mod
View File

@ -3,6 +3,7 @@ module ips-lacpass-backend
go 1.24 go 1.24
require ( require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/httplog/v3 v3.2.2 github.com/go-chi/httplog/v3 v3.2.2
github.com/go-playground/validator/v10 v10.26.0 github.com/go-playground/validator/v10 v10.26.0
@ -11,6 +12,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4 github.com/swaggo/swag v1.16.4
github.com/veraison/go-cose v1.3.0
) )
require ( require (
@ -34,6 +36,7 @@ require (
github.com/mailru/easyjson v0.9.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/swaggo/files v1.0.1 // indirect github.com/swaggo/files v1.0.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.39.0 // indirect golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect

6
go.sum
View File

@ -5,6 +5,8 @@ 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/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 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
@ -72,6 +74,10 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/veraison/go-cose v1.3.0 h1:2/H5w8kdSpQJyVtIhx8gmwPJ2uSz1PkyWFx0idbd7rk=
github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=

BIN
internal/.DS_Store vendored Normal file

Binary file not shown.

BIN
internal/ips/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -95,7 +95,8 @@ type DocumentContent struct {
type Attachment struct { type Attachment struct {
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
URL string `json:"url"` URL string `json:"url,omitempty"`
Data string `json:"data,omitempty"`
} }
type Signature struct { type Signature struct {

View File

@ -62,6 +62,14 @@ func (is *IpsService) GetIps(ctx context.Context) (map[string]interface{}, error
} }
func (is *IpsService) GetIpsICVP(ctx context.Context, idBundle string, immunizationId *string) (string, error) {
result, err := is.Repository.GetIpsICVP(idBundle, immunizationId)
if err != nil {
return "", err
}
return result, nil
}
// Will return the IPS composition sections // Will return the IPS composition sections
func getIPSComposition(entries []Entry) (*Composition, error) { func getIPSComposition(entries []Entry) (*Composition, error) {
i := slices.IndexFunc(entries, func(e Entry) bool { i := slices.IndexFunc(entries, func(e Entry) bool {

View File

@ -5,9 +5,15 @@ import (
"errors" "errors"
"ips-lacpass-backend/internal/ips/core" "ips-lacpass-backend/internal/ips/core"
errors2 "ips-lacpass-backend/pkg/errors" errors2 "ips-lacpass-backend/pkg/errors"
"ips-lacpass-backend/pkg/utils"
"net/http" "net/http"
) )
type ICVPDataResponse struct {
Data string `json:"data"`
Payload map[string]interface{} `json:"payload"`
}
type MergeIPSRequest struct { type MergeIPSRequest struct {
CurrentIPS map[string]interface{} `json:"current_ips"` CurrentIPS map[string]interface{} `json:"current_ips"`
NewIPS map[string]interface{} `json:"new_ips"` NewIPS map[string]interface{} `json:"new_ips"`
@ -116,3 +122,77 @@ func (ih *Handler) Merge(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
// GetICVP godoc
//
// @Summary Generate ICVP certificate from an IPS
// @Description Generate ICVP vaccination certificate using the id of an IPS and optionally the id of an immunization.
// @Tags IPS FHIR
// @Produce json
//
// @Security ApiKeyAuth
//
// @Param bundleId query string true "IPS bundle id"
// @Param immunizationId query string false "Immunization id"
//
// @Success 200 {object} ICVPDataResponse
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /ips/icvp [get]
func (ih *Handler) GetICVP(w http.ResponseWriter, r *http.Request) {
bundleId := r.URL.Query().Get("bundleId")
if bundleId == "" {
http.Error(w, "bundleId query parameter is required", http.StatusBadRequest)
return
}
immunizationId := r.URL.Query().Get("immunizationId")
ctx := r.Context()
var immunizationIdPtr *string
if immunizationId != "" {
immunizationIdPtr = &immunizationId
}
icvp, err := ih.IpsService.GetIpsICVP(ctx, bundleId, immunizationIdPtr)
if err != nil {
var httpErr *errors2.HttpError
if errors.As(err, &httpErr) {
res, err := json.Marshal(httpErr.Body)
if err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
return
}
w.WriteHeader(httpErr.StatusCode)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
decodedPayload, err := utils.DecodeHCert(icvp)
if err != nil {
http.Error(w, "Failed to decode hcert", http.StatusInternalServerError)
return
}
response := ICVPDataResponse{Data: icvp, Payload: decodedPayload}
res, err := json.Marshal(response)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
}

BIN
internal/users/.DS_Store vendored Normal file

Binary file not shown.

BIN
internal/vhl/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -71,7 +71,7 @@ func (vs *VhlService) GetQrIps(ctx context.Context, qrData string, passCode stri
Err: errors.New("content cannot be empty"), Err: errors.New("content cannot be empty"),
} }
} }
ipsClt := ipsClient.NewClient("") ipsClt := ipsClient.NewClient("", "")
ipsBundle, err := ipsClt.GetIpsBundle(ipsFetchUrl.Files[0].Location) ipsBundle, err := ipsClt.GetIpsBundle(ipsFetchUrl.Files[0].Location)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -3,8 +3,10 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"ips-lacpass-backend/internal/vhl/core" "ips-lacpass-backend/internal/vhl/core"
customErrors "ips-lacpass-backend/pkg/errors" customErrors "ips-lacpass-backend/pkg/errors"
"ips-lacpass-backend/pkg/utils"
"net/http" "net/http"
) )
@ -31,6 +33,7 @@ type VhlGetRequest struct {
type VhlResponse struct { type VhlResponse struct {
Data string `json:"data"` Data string `json:"data"`
Payload map[string]interface{} `json:"payload"`
} }
// Create QR data godoc // Create QR data godoc
@ -83,8 +86,14 @@ func (vh *Handler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
decodedPayload, err := utils.DecodeHCert(qr.Value)
if err != nil {
fmt.Println("Failed to decode hcert: ", err)
}
res, err := json.Marshal(&VhlResponse{ res, err := json.Marshal(&VhlResponse{
Data: qr.Value, Data: qr.Value,
Payload: decodedPayload,
}) })
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)

BIN
internal/wallet/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,95 @@
package client
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"ips-lacpass-backend/pkg/errors"
"ips-lacpass-backend/pkg/utils"
"net/http"
)
type ClientInterface interface {
GenerateWalletLink(ctx context.Context, claims map[string]interface{}) (*GenerateWalletLinkResponse, error)
}
type WalletClient struct {
Client *http.Client
BaseURL string
Identifier string
APIKey string
}
func NewClient(baseURL string, identifier string, apiKey string) WalletClient {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return WalletClient{
Client: &http.Client{Transport: tr},
BaseURL: baseURL,
Identifier: identifier,
APIKey: apiKey,
}
}
func (c *WalletClient) GenerateWalletLink(ctx context.Context, claims map[string]interface{}, credentialType CredentialType) (*GenerateWalletLinkResponse, error) {
url := fmt.Sprintf("%s/credentials/%s", c.BaseURL, c.Identifier)
reqBody := GenerateWalletLinkRequest{
Claims: claims,
CredentialType: credentialType,
PinRequired: false,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, &errors.HttpError{
StatusCode: http.StatusInternalServerError,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to marshal request body"}},
Err: err,
}
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
if err != nil {
return nil, &errors.HttpError{
StatusCode: http.StatusInternalServerError,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to create request"}},
Err: err,
}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", c.APIKey)
resp, err := c.Client.Do(req)
if err != nil {
return nil, &errors.HttpError{
StatusCode: http.StatusServiceUnavailable,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to wallet service"}},
Err: err,
}
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{{"error": "wallet_service_error", "message": "Wallet service returned an error"}},
Err: fmt.Errorf("wallet service returned status code %d", resp.StatusCode),
}
}
var walletResponse GenerateWalletLinkResponse
if err := json.NewDecoder(resp.Body).Decode(&walletResponse); err != nil {
return nil, &errors.HttpError{
StatusCode: http.StatusInternalServerError,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to decode response"}},
Err: fmt.Errorf("failed to decode response: %w", err),
}
}
return &walletResponse, nil
}

View File

@ -0,0 +1,23 @@
package client
type CredentialType string
const (
VerifiableHealthLink CredentialType = "VerifiableHealthLink"
ICVP CredentialType = "ICVP"
)
// GenerateWalletLinkRequest represents the request body for generating a wallet link.
type GenerateWalletLinkRequest struct {
Claims map[string]interface{} `json:"claims"`
CredentialType CredentialType `json:"credentialType"`
PinRequired bool `json:"pinRequired"`
}
// GenerateWalletLinkResponse represents the response from the wallet service.
type GenerateWalletLinkResponse struct {
PreAuthorizedCode string `json:"preAuthorizedCode"`
QrURL string `json:"qrUrl"`
CoURL string `json:"coUrl"`
Location string `json:"location"`
}

View File

@ -0,0 +1,3 @@
package core
// Intentionally empty for now.

View File

@ -0,0 +1,24 @@
package core
import (
"context"
"ips-lacpass-backend/internal/wallet/client"
)
type WalletService struct {
Repository *client.WalletClient
}
func NewService(r *client.WalletClient) WalletService {
return WalletService{
Repository: r,
}
}
func (ws *WalletService) GenerateWalletLink(ctx context.Context, claims map[string]interface{}, credentialType client.CredentialType) (*client.GenerateWalletLinkResponse, error) {
walletResponse, err := ws.Repository.GenerateWalletLink(ctx, claims, credentialType)
if err != nil {
return nil, err
}
return walletResponse, nil
}

View File

@ -0,0 +1,89 @@
package handler
import (
"encoding/json"
"errors"
"ips-lacpass-backend/internal/wallet/client"
"ips-lacpass-backend/internal/wallet/core"
customErrors "ips-lacpass-backend/pkg/errors"
"net/http"
)
type Handler struct {
WalletService *core.WalletService
}
func NewHandler(s *core.WalletService) *Handler {
return &Handler{
WalletService: s,
}
}
type GenerateWalletLinkRequest struct {
Claims map[string]interface{} `json:"claims"`
CredentialType client.CredentialType `json:"credentialType"`
}
// GenerateWalletLink godoc
//
// @Summary Generate a wallet link.
// @Description Generate a new wallet link with the given claims. Must enable wallet in config.
// @Tags Wallet
// @Accept json
// @Produce json
//
// @Security ApiKeyAuth
//
// @Param data body GenerateWalletLinkRequest true "Claims for the wallet link"
//
// @Success 200 {object} client.GenerateWalletLinkResponse
// @Failure 400
// @Failure 500
// @Router /wallet/generate-link [post]
func (h *Handler) GenerateWalletLink(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var reqBody GenerateWalletLinkRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, `{"error": "invalid_request_body"}`, http.StatusBadRequest)
return
}
if reqBody.CredentialType != client.VerifiableHealthLink && reqBody.CredentialType != client.ICVP {
http.Error(w, `{"error": "invalid_credential_type", "message": "credentialType must be either VerifiableHealthLink or ICVP"}`, http.StatusBadRequest)
return
}
ctx := r.Context()
walletResponse, err := h.WalletService.GenerateWalletLink(ctx, reqBody.Claims, reqBody.CredentialType)
if err != nil {
var httpErr *customErrors.HttpError
if errors.As(err, &httpErr) {
res, err := json.Marshal(httpErr.Body)
if err != nil {
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(httpErr.StatusCode)
_, err = w.Write(res)
if err != nil {
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
return
}
} else {
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
}
return
}
res, err := json.Marshal(walletResponse)
if err != nil {
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
return
}
}

BIN
pkg/.DS_Store vendored Normal file

Binary file not shown.

186
pkg/utils/hcert_utils.go Normal file
View File

@ -0,0 +1,186 @@
package utils
import (
"bytes"
"compress/zlib"
"fmt"
"io"
"strconv"
"strings"
"github.com/fxamacker/cbor/v2"
"github.com/veraison/go-cose"
)
const base45Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
var base45reverse map[byte]int
func init() {
base45reverse = make(map[byte]int)
for i, c := range []byte(base45Alphabet) {
base45reverse[c] = i
}
}
func convertInterfaceMap(m map[interface{}]interface{}) map[string]interface{} {
result := make(map[string]interface{})
for k, v := range m {
var keyStr string
switch kType := k.(type) {
case string:
keyStr = kType
case float64:
keyStr = strconv.FormatFloat(kType, 'f', -1, 64)
case int:
keyStr = strconv.Itoa(kType)
case uint64:
keyStr = strconv.FormatUint(kType, 10)
case int64:
keyStr = strconv.FormatInt(kType, 10)
default:
fmt.Printf("Encountered unexpected key type: %T with value: %v\n", kType, k)
continue
}
// Check if the value is a nested map
if nestedMap, isMap := v.(map[interface{}]interface{}); isMap {
result[keyStr] = convertInterfaceMap(nestedMap)
} else if nestedSlice, isSlice := v.([]interface{}); isSlice {
result[keyStr] = convertInterfaceSlice(nestedSlice)
} else {
result[keyStr] = v
}
}
return result
}
func convertInterfaceSlice(s []interface{}) []interface{} {
result := make([]interface{}, len(s))
for i, v := range s {
if nestedMap, isMap := v.(map[interface{}]interface{}); isMap {
result[i] = convertInterfaceMap(nestedMap)
} else if nestedSlice, isSlice := v.([]interface{}); isSlice {
result[i] = convertInterfaceSlice(nestedSlice)
} else {
result[i] = v
}
}
return result
}
func decodeBase45(s string) ([]byte, error) {
var out []byte
for i := 0; i < len(s); {
if len(s)-i < 2 {
return nil, fmt.Errorf("invalid base45 string length")
}
if len(s)-i < 3 {
// 2-character case
c1, ok1 := base45reverse[s[i]]
c2, ok2 := base45reverse[s[i+1]]
if !ok1 || !ok2 {
return nil, fmt.Errorf("invalid character in base45 string")
}
val := c1 + c2*45
if val > 255 {
return nil, fmt.Errorf("invalid 2-character encoding")
}
out = append(out, byte(val))
i += 2
} else {
// 3-character case
c1, ok1 := base45reverse[s[i]]
c2, ok2 := base45reverse[s[i+1]]
c3, ok3 := base45reverse[s[i+2]]
if !ok1 || !ok2 || !ok3 {
return nil, fmt.Errorf("invalid character in base45 string")
}
val := c1 + c2*45 + c3*45*45
if val > 65535 {
return nil, fmt.Errorf("invalid 3-character encoding")
}
out = append(out, byte(val>>8), byte(val&0xFF))
i += 3
}
}
return out, nil
}
// DecodeHCert decodes a base45 string encoded using Zlib/COSE/CBOR pipeline.
// The string is an HCERT so it starts with "HC1:".
func DecodeHCert(hcert string) (map[string]interface{}, error) {
if !strings.HasPrefix(hcert, "HC1:") {
return nil, fmt.Errorf("invalid HCERT prefix")
}
// 1. Remove "HC1:" prefix
base45Encoded := strings.TrimPrefix(hcert, "HC1:")
// 2. Base45 decode
compressedCose, err := decodeBase45(base45Encoded)
if err != nil {
return nil, fmt.Errorf("base45 decoding failed: %w", err)
}
// 3. Zlib decompress
var cosePayload []byte
r, err := zlib.NewReader(bytes.NewReader(compressedCose))
if err != nil {
// Not zlib compressed, use as is
cosePayload = compressedCose
} else {
decompressed, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("zlib decompression failed: %w", err)
}
cosePayload = decompressed
defer r.Close()
}
// 4. COSE decode
var payload []byte
var msg cose.Sign1Message
if err := msg.UnmarshalCBOR(cosePayload); err == nil {
payload = msg.Payload
} else {
var signMessage cose.SignMessage
if err2 := signMessage.UnmarshalCBOR(cosePayload); err2 == nil {
payload = signMessage.Payload
} else {
// Both failed, try manual parsing
var rawCoseMessage interface{}
if err3 := cbor.Unmarshal(cosePayload, &rawCoseMessage); err3 != nil {
return nil, fmt.Errorf("cose unmarshalling failed for both Sign1Message and SignMessage and raw: %v, %v, %v", err, err2, err3)
}
coseArray, ok := rawCoseMessage.([]interface{})
if !ok {
if tagged, ok := rawCoseMessage.(cbor.Tag); ok {
if content, ok := tagged.Content.([]interface{}); ok {
coseArray = content
}
}
}
if coseArray == nil || len(coseArray) < 3 {
return nil, fmt.Errorf("cose unmarshalling failed for both Sign1Message and SignMessage: %v, %v", err, err2)
}
p, ok := coseArray[2].([]byte)
if !ok {
return nil, fmt.Errorf("failed to extract payload from cose message: payload is not a byte string")
}
payload = p
}
}
// 5. CBOR decode the payload from COSE message
var cborData map[interface{}]interface{}
if err := cbor.Unmarshal(payload, &cborData); err != nil {
return nil, fmt.Errorf("cbor unmarshalling of payload failed: %w", err)
}
cborDataConverted := convertInterfaceMap(cborData)
return cborDataConverted, nil
}

76
test/hcert_test.go Normal file
View File

@ -0,0 +1,76 @@
package test
import (
"encoding/json"
"ips-lacpass-backend/pkg/utils"
"testing"
)
func TestHCertICVPDecode(t *testing.T) {
hcert := "HC1:6BFOXN%TSMAHN-HJM80DOO8W%TG34UE726*2OC9Y.TW1ANU9SCE7JM:UC*ELIQ5B264IM:/42JO2 7V35U:7+V4YC5/HQ6EOHCRBK81EPFJM5C9YCBJ%GBVCL+9-0G2PBUDBACARDAEI97KE*LHXQM.FDBIK4LD JM3.K/HLNOI3.KH+G7IKSH9NOIEJK5+K6IASD9YHI1KKK3MYII3IKEIAM0G6JK%86%X49/SQN4:U45ALD-4$XKHBTQ1LTA3$73HRJFRJ9STE-4/-KFU4-EF:57MUBMTF*MCXJL RGBFH*RK%4U7U*+RDQJHY23QPX4MQ2S1$U4ST236MDNW*PGNETTU4DK/$TJ7PS4JLDV%0K1GDMDP $A*EK/JP:T3%.4OYB"
expectedJSON := `{
"-260": {
"-6": {
"dob": "1905-08-23",
"n": "Aulo Agerio",
"ndt": "NI",
"nid": "16337361-9",
"s": "male",
"v": {
"bo": "123123123",
"dt": "2017-12-11",
"vls": "2017-12-11",
"vp": "YellowFeverProductd2c75a15ed309658b3968519ddb31690"
}
}
},
"1": "XCL",
"6": 1757187943
}`
decoded, err := utils.DecodeHCert(hcert)
if err != nil {
t.Fatalf("Failed to decode HCert: %v", err)
}
decodedPretty, err := json.MarshalIndent(decoded, "", " ")
if err != nil {
t.Fatalf("Failed to decode JSON: %v", err)
}
if string(expectedJSON) != string(decodedPretty) {
t.Errorf("Decoded value is not correct. Decoded: %s, Expected: %s", decodedPretty, expectedJSON)
}
}
func TestHCertVHLDecode(t *testing.T) {
hcert := "HC1:6BFOXNMG2N9HZBPYHQ3D69SO5D6%9L60JO DJS4L:P:R8LCDO%08JJG.NSOEV 9OG6%6Q4TJ7AJENS:NK7VCECM:MQ0FE%JC5Y479D/*8G.CV3NV3OVLD86J:KE2HF86GX2BTLHA9A86GNY8XOIROBZQMQOB9MEBED:KE87B MH:8DZYK%KNU9O%UL75E2*KH42$T8CRJ.V89:GF-K8JV Y8GJNKY8%97JR8ZV0:JVIP46+8KD35T8/Z8ZIV-YKAUVH40DQL2I6AI8LZP9WHHK5.SMIY9TO6YN6MJE2I6DF5P.P%OE-M6U JGKETW7YP6GUMY.HBNMAP50TBIM5GUMSWPVYB5RH+PEGKE5SG7UT4L5%K82OO-L8+$RTNKCZUN.DSB1971PFU%0F$5MH6QTMUEO1HB5*%L4NH7KEK%56VEUS17%E2F14LETP5 9VZ*MTJR.*U6.CH8795KTD8B836B4X/9+JIQT24GA-+DVE9B2K9FDJ4N172IM2%-2SFL -UNNF0GJG0AG16%$V%*C9:A8+I2QOHUQDVJ7VF +AU61$8IE0U4NOKIS1RE0BBSEWUVKI9K4/TQQP5U974CI9JQI10DEG30QUKL1"
expectedJSON := `{
"-260": {
"5": [
{
"u": "shlink://eyJ1cmwiOiJodHRwOi8vbGFjcGFzcy5jcmVhdGUuY2w6ODE4Mi92Mi9tYW5pZmVzdHMvYjEzYzA0Y2QtMDc1Yy00YjY4LTgyOTQtMzJhZTMwN2YxYjA5IiwiZmxhZyI6IlAiLCJleHAiOjE3NjAxNDAyMjEwMDAsImtleSI6IkxTTnVaTXFHZEo1cmdQLUpJSEoySllLaWtuYzJXZDcwaG1VMFBSZFAwSHM9IiwibGFiZWwiOiJHREhDTiBWYWxpZGF0b3IifQ=="
}
]
},
"1": "XJ",
"4": 1760140221,
"6": 1757271643804
}`
decoded, err := utils.DecodeHCert(hcert)
if err != nil {
t.Fatalf("Failed to decode HCert: %v", err)
}
decodedPretty, err := json.MarshalIndent(decoded, "", " ")
if err != nil {
t.Fatalf("Failed to decode JSON: %v", err)
}
if string(expectedJSON) != string(decodedPretty) {
t.Errorf("Decoded value is not correct. Decoded: %s, Expected: %s", decodedPretty, expectedJSON)
}
}