diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..9a0abd5 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.sample b/.env.sample index 734a67b..9b8628e 100644 --- a/.env.sample +++ b/.env.sample @@ -13,3 +13,8 @@ POSTGRES_PASSWORD=keycloak KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl FHIR_BASE_URL=http://lacpass.create.cl:8080 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="" diff --git a/.gitignore b/.gitignore index 00f4323..dd2638b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,14 @@ -.env -.idea - -temp/* -tmp/* -*.exe -mock_* -content-generator -*.tfvars -.terraform -.terraform.lock.hcl -templates -out -debugger -.DS_Store +.env +.idea + +temp/* +tmp/* +*.exe +mock_* +content-generator +*.tfvars +.terraform +.terraform.lock.hcl +templates +out +debugger \ No newline at end of file diff --git a/README.md b/README.md index 5bcf26e..97fe51a 100644 --- a/README.md +++ b/README.md @@ -42,18 +42,6 @@ The architecture of this backend system is designed to separate concerns between - [Authentication](/docs/authentication.md) - 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 Open a terminal in the root directory of the project and run the following command: @@ -69,7 +57,7 @@ This command will: - Create and start the containers for all three services. - Attach your terminal to the logs of all running containers. -### Setup Keycloak +### Setup Keycloak After all services are running, you need to setup keycloak to have the correct configurations for the backend to authenticate and create users. Before contuining please follow the instructions [here](/docs/keycloak-setup.md). @@ -85,9 +73,9 @@ Once the services are running, you can access the Keycloak Admin Console to conf ### IPS Lacpass API -IPS Lacpass API will be accessible at `http://localhost:9081`. You can use a tool like `curl` or Postman to interact -with your API endpoints. Remember that your API endpoints will be protected by Keycloak, so you will need to obtain a -valid JWT from Keycloak to make successful requests. There is a [helper script](./scripts/auth.sh), where you can request +IPS Lacpass API will be accessible at `http://localhost:9081`. You can use a tool like `curl` or Postman to interact +with your API endpoints. Remember that your API endpoints will be protected by Keycloak, so you will need to obtain a +valid JWT from Keycloak to make successful requests. There is a [helper script](./scripts/auth.sh), where you can request a token using: ```bash diff --git a/cmd/api/config.go b/cmd/api/config.go index b806cd8..1561e86 100644 --- a/cmd/api/config.go +++ b/cmd/api/config.go @@ -17,8 +17,13 @@ type Config struct { AuthEmailClientID string FhirBaseUrl string VhlBaseUrl string + FhirMediatorBaseUrl string APISwagger bool LogLevel string + WalletEnabled bool + WalletUrl string + WalletIdentifier string + WalletAPIKey string } func LoadConfig() Config { @@ -34,8 +39,13 @@ func LoadConfig() Config { AuthEmailClientID: "app", FhirBaseUrl: "http://lacpass.create.cl:8080", VhlBaseUrl: "http://lacpass.create.cl:8182", + FhirMediatorBaseUrl: "http://lacpass.create.cl:3000/", APISwagger: false, LogLevel: "info", + WalletEnabled: false, + WalletUrl: "https://conectathon-balancer.izer.tech/", + WalletIdentifier: "test", + WalletAPIKey: "", } if serverPort, exists := os.LookupEnv("API_PORT"); exists { @@ -75,6 +85,9 @@ func LoadConfig() Config { if fhirBaseUrl, exists := os.LookupEnv("FHIR_BASE_URL"); exists { 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 { cfg.VhlBaseUrl = vhlBaseUrl } @@ -85,5 +98,19 @@ func LoadConfig() Config { 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 } diff --git a/cmd/api/main.go b/cmd/api/main.go index 9237aaa..3a7363c 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,9 +14,9 @@ import ( // @host localhost:9081 -// @securityDefinitions.apikey ApiKeyAuth -// @in header -// @name Authorization +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization func main() { app := New(LoadConfig()) diff --git a/cmd/api/routes.go b/cmd/api/routes.go index bcb9941..698a354 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -24,6 +24,10 @@ import ( vhlClient "ips-lacpass-backend/internal/vhl/client" vhlCore "ips-lacpass-backend/internal/vhl/core" 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() { @@ -80,6 +84,9 @@ func (a *App) loadRoutes() { r.Route("/ips", a.loadIpsRoute) r.Route("/users/auth", a.loadUserRoutesAuth) r.Route("/qr", a.loadVhlRoute) + if a.config.WalletEnabled { + r.Route("/wallet", a.loadWalletRoutes) + } }) 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) { - r := ipsClient.NewClient(a.config.FhirBaseUrl) + r := ipsClient.NewClient(a.config.FhirBaseUrl, a.config.FhirMediatorBaseUrl) s := ipsCore.NewService(&r) h := ipsHandler.NewHandler(&s) router.Get("/", h.Get) router.Post("/merge", h.Merge) + router.Get("/icvp", h.GetICVP) } func (a *App) loadVhlRoute(router chi.Router) { @@ -138,3 +146,10 @@ func (a *App) loadVhlRoute(router chi.Router) { router.Post("/", h.Create) 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) +} diff --git a/docker/compose.yaml b/docker/compose.yaml index 2569c27..77be7e4 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -21,7 +21,12 @@ services: AUTH_EMAIL_CLIENT_ID: ${AUTH_EMAIL_CLIENT_ID:-app} FHIR_BASE_URL: ${FHIR_BASE_URL:-http://lacpass.create.cl:8080} 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} + 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: - "9081:3000" healthcheck: diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..f2bd12b Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/images/.DS_Store b/docs/images/.DS_Store new file mode 100644 index 0000000..b58cf86 Binary files /dev/null and b/docs/images/.DS_Store differ diff --git a/docs/images/change_frontend_url.png b/docs/images/change_frontend_url.png new file mode 100644 index 0000000..93e9165 Binary files /dev/null and b/docs/images/change_frontend_url.png differ diff --git a/docs/images/email_config/configure_smtp.png b/docs/images/email_config/configure_smtp.png new file mode 100644 index 0000000..b2df087 Binary files /dev/null and b/docs/images/email_config/configure_smtp.png differ diff --git a/docs/images/email_config/go_to_email_tab.png b/docs/images/email_config/go_to_email_tab.png new file mode 100644 index 0000000..af321ef Binary files /dev/null and b/docs/images/email_config/go_to_email_tab.png differ diff --git a/docs/keycloak-setup.md b/docs/keycloak-setup.md index cb6e143..177b55a 100644 --- a/docs/keycloak-setup.md +++ b/docs/keycloak-setup.md @@ -6,29 +6,31 @@ This guide explains how to configure the backend service to work with Keycloak. Before using the API service, you must enable authentication and set the client ID so the backend can perform operations on Keycloak, such as registering users. -1. Open the Keycloak service at [http://localhost:9083/](http://localhost:9083/). -2. Once the page loads, ensure you are in the correct realm. The realm name is specified in the `.env` file: +1. Open the Keycloak service at [http://localhost:9083/](http://localhost:9083/). +2. Once the page loads, ensure you are in the correct realm. The realm name is specified in the `.env` file: ![Change Realm](./images/client_secret/keycloak_change_realm.png) 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) - - Scroll down to the **Capability Config** section and enable the two switches as shown below: + - Scroll down to the **Capability Config** section and enable the two switches as shown below: ![Set Authentication](./images/client_secret/keycloak_set_authentication.png) - Click **Save** to apply the changes. 4. To retrieve the client credentials: - - Navigate to the **Credentials** tab. - - Copy the client secret value (it may be hidden by default). + + - Navigate to the **Credentials** tab. + - Copy the client secret value (it may be hidden by default). ![Get Client Secret](./images/client_secret/keycloak_get_client_secret.png) -This client secret is required in the Docker Compose file to configure the backend service. Add it to the appropriate section: +This client secret is required in the Docker Compose file to configure the backend service. Add it to the appropriate section: ![Client ID in Docker Compose](./images/client_secret/docker_compose_client_id.png) @@ -36,25 +38,25 @@ This client secret is required in the Docker Compose file to configure the backe To allow the backend service to perform all necessary operations, the `admin` role must have all service account roles assigned. -1. Go to the **Service Account Roles** tab. -2. Click **Apply Roles** to assign roles. +1. Go to the **Service Account Roles** tab. +2. Click **Apply Roles** to assign roles. ![Service Account Roles](./images/add_roles/keycloak_service_account_assign_role.png) 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) - - Select all roles by clicking the checkbox in the table header: + - Select all roles by clicking the checkbox in the table header: ![Select All Roles](./images/add_roles/keycloak_service_accont_role_select_all.png) -4. After selecting all roles, click **Assign**. You should now see all roles listed as assigned: +4. After selecting all roles, click **Assign**. You should now see all roles listed as assigned: ![Role Account List](./images/add_roles/keycloak_service_account_list.png) - ## 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. @@ -74,3 +76,27 @@ To do this: 5. Click **Save** to apply your changes. 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 don’t 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) diff --git a/go.mod b/go.mod index 4097f05..ff0776c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module ips-lacpass-backend go 1.24 require ( + github.com/fxamacker/cbor/v2 v2.9.0 github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/httplog/v3 v3.2.2 github.com/go-playground/validator/v10 v10.26.0 @@ -11,6 +12,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.4 + github.com/veraison/go-cose v1.3.0 ) require ( @@ -34,6 +36,7 @@ require ( github.com/mailru/easyjson v0.9.0 // indirect github.com/segmentio/asm v1.2.0 // 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/net v0.41.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index 784b477..bcb26cc 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 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/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= 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= 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= diff --git a/internal/.DS_Store b/internal/.DS_Store new file mode 100644 index 0000000..875fefe Binary files /dev/null and b/internal/.DS_Store differ diff --git a/internal/ips/.DS_Store b/internal/ips/.DS_Store new file mode 100644 index 0000000..eeb14b9 Binary files /dev/null and b/internal/ips/.DS_Store differ diff --git a/internal/ips/client/models.go b/internal/ips/client/models.go index 947bcae..0f51436 100644 --- a/internal/ips/client/models.go +++ b/internal/ips/client/models.go @@ -95,7 +95,8 @@ type DocumentContent struct { type Attachment struct { ContentType string `json:"contentType"` - URL string `json:"url"` + URL string `json:"url,omitempty"` + Data string `json:"data,omitempty"` } type Signature struct { diff --git a/internal/ips/core/service.go b/internal/ips/core/service.go index 8525197..fa4a4cc 100644 --- a/internal/ips/core/service.go +++ b/internal/ips/core/service.go @@ -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 func getIPSComposition(entries []Entry) (*Composition, error) { i := slices.IndexFunc(entries, func(e Entry) bool { diff --git a/internal/ips/handler/ips.go b/internal/ips/handler/ips.go index b0fc31e..b9dad4f 100644 --- a/internal/ips/handler/ips.go +++ b/internal/ips/handler/ips.go @@ -5,9 +5,15 @@ import ( "errors" "ips-lacpass-backend/internal/ips/core" errors2 "ips-lacpass-backend/pkg/errors" + "ips-lacpass-backend/pkg/utils" "net/http" ) +type ICVPDataResponse struct { + Data string `json:"data"` + Payload map[string]interface{} `json:"payload"` +} + type MergeIPSRequest struct { CurrentIPS map[string]interface{} `json:"current_ips"` NewIPS map[string]interface{} `json:"new_ips"` @@ -25,14 +31,14 @@ func NewHandler(s *core.IpsService) *Handler { // GetIPS godoc // -// @Summary Fetch IPS from national node. +// @Summary Fetch IPS from national node. // @Description Fetch IPS from national node using session access token user identifier. // @Tags IPS FHIR // @Produce json // -// @Security ApiKeyAuth +// @Security ApiKeyAuth // -// @Success 200 {object} any +// @Success 200 {object} any // @Failure 400 // @Failure 404 // @Failure 500 @@ -76,14 +82,14 @@ func (ih *Handler) Get(w http.ResponseWriter, r *http.Request) { // MergeIPS godoc // -// @Summary Merge two IPS bundles into a unified IPS. +// @Summary Merge two IPS bundles into a unified IPS. // @Description Merge two FHIR R4 IPS bundles into a single one, removing reduncancy. // @Tags IPS FHIR // @Produce json // -// @Security ApiKeyAuth +// @Security ApiKeyAuth // -// @Param data body MergeIPSRequest true "IPS bundles to merge" +// @Param data body MergeIPSRequest true "IPS bundles to merge" // // @Success 200 {object} any // @Failure 400 @@ -116,3 +122,77 @@ func (ih *Handler) Merge(w http.ResponseWriter, r *http.Request) { 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 + } +} diff --git a/internal/users/.DS_Store b/internal/users/.DS_Store new file mode 100644 index 0000000..a2c0917 Binary files /dev/null and b/internal/users/.DS_Store differ diff --git a/internal/users/handler/user.go b/internal/users/handler/user.go index d69b451..7bc677a 100644 --- a/internal/users/handler/user.go +++ b/internal/users/handler/user.go @@ -190,16 +190,16 @@ func (u *Handler) Create(w http.ResponseWriter, r *http.Request) { // UpdateUser godoc // // @Summary Update user profile -// @Description Update user profile. Only firs name, last name for now +// @Description Update user profile. Only firs name, last name for now // @Tags Users // @Accept json // @Produce json // -// @Param user body core.UserUpdateRequest true "New user details" +// @Param user body core.UserUpdateRequest true "New user details" // -// @Security ApiKeyAuth +// @Security ApiKeyAuth // -// @Success 200 {object} UserResponse +// @Success 200 {object} UserResponse // @Failure 400 // @Failure 404 // @Failure 500 diff --git a/internal/vhl/.DS_Store b/internal/vhl/.DS_Store new file mode 100644 index 0000000..6e73729 Binary files /dev/null and b/internal/vhl/.DS_Store differ diff --git a/internal/vhl/core/service.go b/internal/vhl/core/service.go index 0f1c305..aa3abc0 100644 --- a/internal/vhl/core/service.go +++ b/internal/vhl/core/service.go @@ -71,7 +71,7 @@ func (vs *VhlService) GetQrIps(ctx context.Context, qrData string, passCode stri Err: errors.New("content cannot be empty"), } } - ipsClt := ipsClient.NewClient("") + ipsClt := ipsClient.NewClient("", "") ipsBundle, err := ipsClt.GetIpsBundle(ipsFetchUrl.Files[0].Location) if err != nil { return nil, err diff --git a/internal/vhl/handler/vhl.go b/internal/vhl/handler/vhl.go index cebc252..f1e5a3f 100644 --- a/internal/vhl/handler/vhl.go +++ b/internal/vhl/handler/vhl.go @@ -3,8 +3,10 @@ package handler import ( "encoding/json" "errors" + "fmt" "ips-lacpass-backend/internal/vhl/core" customErrors "ips-lacpass-backend/pkg/errors" + "ips-lacpass-backend/pkg/utils" "net/http" ) @@ -30,18 +32,19 @@ type VhlGetRequest struct { } type VhlResponse struct { - Data string `json:"data"` + Data string `json:"data"` + Payload map[string]interface{} `json:"payload"` } // Create QR data godoc // -// @Summary Create QR data. +// @Summary Create QR data. // @Description Create QR data from VHL issuance. // @Tags IPS FHIR // @Accept json // @Produce json // -// @Security ApiKeyAuth +// @Security ApiKeyAuth // // @Param data body VhlRequest true "Data parameters" // @@ -83,8 +86,14 @@ func (vh *Handler) Create(w http.ResponseWriter, r *http.Request) { return } + decodedPayload, err := utils.DecodeHCert(qr.Value) + if err != nil { + fmt.Println("Failed to decode hcert: ", err) + } + res, err := json.Marshal(&VhlResponse{ - Data: qr.Value, + Data: qr.Value, + Payload: decodedPayload, }) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) @@ -100,13 +109,13 @@ func (vh *Handler) Create(w http.ResponseWriter, r *http.Request) { // Get IPS Bundle from valid QR data godoc // -// @Summary Get IPS Bundle with valid VHL QR. +// @Summary Get IPS Bundle with valid VHL QR. // @Description Get IPS Bundle using a valid VHL QR. // @Tags IPS FHIR // @Accept json // @Produce json // -// @Security ApiKeyAuth +// @Security ApiKeyAuth // // @Param data body VhlGetRequest true "Data parameters" // diff --git a/internal/wallet/.DS_Store b/internal/wallet/.DS_Store new file mode 100644 index 0000000..7ad826f Binary files /dev/null and b/internal/wallet/.DS_Store differ diff --git a/internal/wallet/client/client.go b/internal/wallet/client/client.go new file mode 100644 index 0000000..b8bf728 --- /dev/null +++ b/internal/wallet/client/client.go @@ -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 +} diff --git a/internal/wallet/client/models.go b/internal/wallet/client/models.go new file mode 100644 index 0000000..9d76738 --- /dev/null +++ b/internal/wallet/client/models.go @@ -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"` +} diff --git a/internal/wallet/core/models.go b/internal/wallet/core/models.go new file mode 100644 index 0000000..ad34ce2 --- /dev/null +++ b/internal/wallet/core/models.go @@ -0,0 +1,3 @@ +package core + +// Intentionally empty for now. diff --git a/internal/wallet/core/service.go b/internal/wallet/core/service.go new file mode 100644 index 0000000..f56a750 --- /dev/null +++ b/internal/wallet/core/service.go @@ -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 +} diff --git a/internal/wallet/handler/wallet.go b/internal/wallet/handler/wallet.go new file mode 100644 index 0000000..f2b3148 --- /dev/null +++ b/internal/wallet/handler/wallet.go @@ -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 + } +} diff --git a/pkg/.DS_Store b/pkg/.DS_Store new file mode 100644 index 0000000..75dcc1d Binary files /dev/null and b/pkg/.DS_Store differ diff --git a/pkg/utils/hcert_utils.go b/pkg/utils/hcert_utils.go new file mode 100644 index 0000000..594f511 --- /dev/null +++ b/pkg/utils/hcert_utils.go @@ -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 +} diff --git a/test/hcert_test.go b/test/hcert_test.go new file mode 100644 index 0000000..aa8345e --- /dev/null +++ b/test/hcert_test.go @@ -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) + } +} \ No newline at end of file