release version 3
This commit is contained in:
parent
f3ee84b5ca
commit
a1651c914e
@ -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=""
|
||||||
|
|||||||
29
.gitignore
vendored
29
.gitignore
vendored
@ -1,15 +1,14 @@
|
|||||||
.env
|
.env
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
temp/*
|
temp/*
|
||||||
tmp/*
|
tmp/*
|
||||||
*.exe
|
*.exe
|
||||||
mock_*
|
mock_*
|
||||||
content-generator
|
content-generator
|
||||||
*.tfvars
|
*.tfvars
|
||||||
.terraform
|
.terraform
|
||||||
.terraform.lock.hcl
|
.terraform.lock.hcl
|
||||||
templates
|
templates
|
||||||
out
|
out
|
||||||
debugger
|
debugger
|
||||||
.DS_Store
|
|
||||||
20
README.md
20
README.md
@ -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:
|
||||||
@ -69,7 +57,7 @@ This command will:
|
|||||||
- Create and start the containers for all three services.
|
- Create and start the containers for all three services.
|
||||||
- Attach your terminal to the logs of all running containers.
|
- 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).
|
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
|
||||||
|
|
||||||
IPS Lacpass API will be accessible at `http://localhost:9081`. You can use a tool like `curl` or Postman to interact
|
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
|
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
|
valid JWT from Keycloak to make successful requests. There is a [helper script](./scripts/auth.sh), where you can request
|
||||||
a token using:
|
a token using:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,9 +14,9 @@ import (
|
|||||||
|
|
||||||
// @host localhost:9081
|
// @host localhost:9081
|
||||||
|
|
||||||
// @securityDefinitions.apikey ApiKeyAuth
|
// @securityDefinitions.apikey ApiKeyAuth
|
||||||
// @in header
|
// @in header
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
app := New(LoadConfig())
|
app := New(LoadConfig())
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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
BIN
docs/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
docs/images/.DS_Store
vendored
Normal file
BIN
docs/images/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
docs/images/change_frontend_url.png
Normal file
BIN
docs/images/change_frontend_url.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/images/email_config/configure_smtp.png
Normal file
BIN
docs/images/email_config/configure_smtp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/images/email_config/go_to_email_tab.png
Normal file
BIN
docs/images/email_config/go_to_email_tab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
@ -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.
|
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/).
|
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:
|
2. Once the page loads, ensure you are in the correct realm. The realm name is specified in the `.env` file:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
3. To enable authentication:
|
3. To enable authentication:
|
||||||
- Go to the `admin-cli` configuration:
|
|
||||||
|
- Go to the `admin-cli` configuration:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- 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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- 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.
|
|
||||||
- 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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@ -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.
|
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.
|
1. Go to the **Service Account Roles** tab.
|
||||||
2. Click **Apply Roles** to assign roles.
|
2. Click **Apply Roles** to assign roles.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- Select all roles by clicking the checkbox in the table header:
|
- Select all roles by clicking the checkbox in the table header:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
4. On the bottom set your STMP credentials in the **Connection & Authentication** section and save.
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
3
go.mod
3
go.mod
@ -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
6
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/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
BIN
internal/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
internal/ips/.DS_Store
vendored
Normal file
BIN
internal/ips/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"`
|
||||||
@ -25,14 +31,14 @@ func NewHandler(s *core.IpsService) *Handler {
|
|||||||
|
|
||||||
// GetIPS godoc
|
// 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.
|
// @Description Fetch IPS from national node using session access token user identifier.
|
||||||
// @Tags IPS FHIR
|
// @Tags IPS FHIR
|
||||||
// @Produce json
|
// @Produce json
|
||||||
//
|
//
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
//
|
//
|
||||||
// @Success 200 {object} any
|
// @Success 200 {object} any
|
||||||
// @Failure 400
|
// @Failure 400
|
||||||
// @Failure 404
|
// @Failure 404
|
||||||
// @Failure 500
|
// @Failure 500
|
||||||
@ -76,14 +82,14 @@ func (ih *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// MergeIPS godoc
|
// 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.
|
// @Description Merge two FHIR R4 IPS bundles into a single one, removing reduncancy.
|
||||||
// @Tags IPS FHIR
|
// @Tags IPS FHIR
|
||||||
// @Produce json
|
// @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
|
// @Success 200 {object} any
|
||||||
// @Failure 400
|
// @Failure 400
|
||||||
@ -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
BIN
internal/users/.DS_Store
vendored
Normal file
Binary file not shown.
@ -190,16 +190,16 @@ func (u *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
// UpdateUser godoc
|
// UpdateUser godoc
|
||||||
//
|
//
|
||||||
// @Summary Update user profile
|
// @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
|
// @Tags Users
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce 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 400
|
||||||
// @Failure 404
|
// @Failure 404
|
||||||
// @Failure 500
|
// @Failure 500
|
||||||
|
|||||||
BIN
internal/vhl/.DS_Store
vendored
Normal file
BIN
internal/vhl/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -30,18 +32,19 @@ 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
|
||||||
//
|
//
|
||||||
// @Summary Create QR data.
|
// @Summary Create QR data.
|
||||||
// @Description Create QR data from VHL issuance.
|
// @Description Create QR data from VHL issuance.
|
||||||
// @Tags IPS FHIR
|
// @Tags IPS FHIR
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
//
|
//
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
//
|
//
|
||||||
// @Param data body VhlRequest true "Data parameters"
|
// @Param data body VhlRequest true "Data parameters"
|
||||||
//
|
//
|
||||||
@ -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)
|
||||||
@ -100,13 +109,13 @@ func (vh *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Get IPS Bundle from valid QR data godoc
|
// 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.
|
// @Description Get IPS Bundle using a valid VHL QR.
|
||||||
// @Tags IPS FHIR
|
// @Tags IPS FHIR
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
//
|
//
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
//
|
//
|
||||||
// @Param data body VhlGetRequest true "Data parameters"
|
// @Param data body VhlGetRequest true "Data parameters"
|
||||||
//
|
//
|
||||||
|
|||||||
BIN
internal/wallet/.DS_Store
vendored
Normal file
BIN
internal/wallet/.DS_Store
vendored
Normal file
Binary file not shown.
95
internal/wallet/client/client.go
Normal file
95
internal/wallet/client/client.go
Normal 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
|
||||||
|
}
|
||||||
23
internal/wallet/client/models.go
Normal file
23
internal/wallet/client/models.go
Normal 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"`
|
||||||
|
}
|
||||||
3
internal/wallet/core/models.go
Normal file
3
internal/wallet/core/models.go
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// Intentionally empty for now.
|
||||||
24
internal/wallet/core/service.go
Normal file
24
internal/wallet/core/service.go
Normal 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
|
||||||
|
}
|
||||||
89
internal/wallet/handler/wallet.go
Normal file
89
internal/wallet/handler/wallet.go
Normal 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
BIN
pkg/.DS_Store
vendored
Normal file
Binary file not shown.
186
pkg/utils/hcert_utils.go
Normal file
186
pkg/utils/hcert_utils.go
Normal 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
76
test/hcert_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user