commit 18f35263625dadbecac20e5dd9875b26475c068d Author: Sergio Peñafiel Date: Thu Jul 17 10:22:20 2025 -0400 first release of template diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..5329e06 --- /dev/null +++ b/.aiignore @@ -0,0 +1,5 @@ +.env +*.key +*.pem +*secret* +credentials.* \ No newline at end of file diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..f5b9e61 --- /dev/null +++ b/.env.sample @@ -0,0 +1,13 @@ +KEYCLOAK_URL=http://localhost:9083 +KEYCLOAK_REALM=lacpass +KEYCLOAK_CLIENT_ID=app +KC_BOOTSTRAP_ADMIN_USERNAME=admin +KC_BOOTSTRAP_ADMIN_PASSWORD=admin +KEYCLOAK_DEFAULT_USER=test +KEYCLOAK_DEFAULT_USER_PASSWORD=test +API_SWAGGER=false +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl +FHIR_BASE_URL=http://lacpass.create.cl:8080 +VHL_BASE_URL=http://lacpass.create.cl:8182 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd2638b --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.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/Makefile b/Makefile new file mode 100644 index 0000000..fc1626b --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +run: + go run ./cmd/api \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c8544c --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# PH4H PH4H Backend + +Template for backend for the IPS PH4H App. A unified health app for Connectathon users to view, merge, and share IPS data +securely cross-border. This project provides a backend system composed of the IPS PH4H API for handling business logic and +a Keycloak server for authentication and authorization. The entire stack is containerized using Docker and can be easily +managed with Docker Compose. + +## Table of Contents + +- [Project Overview](#project-overview) +- [Components](#components) +- [Prerequisites](#prerequisites) +- [Getting Started](#getting-started) + - [Configuration](#configuration) + - [Running the Application](#running-the-application) +- [Accessing the Services](#accessing-the-services) + - [Keycloak Admin Console](#keycloak-admin-console) + - [Golang API](#golang-api) +- [Stopping the Application](#stopping-the-application) +- [Swagger Documentation](#swagger-documentation) + +## Project Overview + +The architecture of this backend system is designed to separate concerns between the application's business logic and user authentication. + +## Components + +- **IPS PH4H API**: A lightweight, high-performance API that implements the core features of your application. + It is protected and requires a valid JWT from an Authorization server to be accessed. +- **Authorization**: Identity and Access Management solution. It handles user registration, login, and token issuance. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) +- [Go 1.24](https://go.dev/dl/) `Only if you plan to ran the API without docker` + +## Getting Started + +### Configuration + +- [Authentication](/docs/authentication.md) +- IPS PH4H 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: + +```bash + docker compose --file=./docker/compose.yaml up +``` + +This command will: + +- Build the Docker image for the IPS PH4H API. +- Pull the official Docker images for Keycloak and Postgres. +- Create and start the containers for all three services. +- Attach your terminal to the logs of all running containers. + +### 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). + +## Accessing the Services + +### Keycloak Admin Console + +Once the services are running, you can access the Keycloak Admin Console to configure realms, clients, and users. + +1. Open your web browser and navigate to `http://localhost:9083`. +2. You will be redirected to the Keycloak landing page. Click on the **Administration Console** link. +3. Log in with the admin credentials provided in your [configuration](/docs/authentication.md) + +### IPS PH4H API + +IPS PH4H 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 +sh scripts/auth.sh access-token +``` + +If the token expires you can refresh it with: + +```bash +sh scripts/auth.sh refresh-token +``` + +And to logout you can do: + +```bash +sh scripts/auth.sh logout +``` + +## Stopping the Application + +To stop and remove the containers, network, and volumes, press `Ctrl+C` in the terminal where `docker-compose` is running, and then run the following command: + +```bash +docker-compose down +``` + +# Swagger Documentation + +To activate Swagger make sure to set `API_SWAGGER=true` in your `.env` file and the `docker/compose.yaml`. + +Then, the API docs can be seen in `http://localhost:9081/swagger/index.html`. diff --git a/api/openapi/api.yaml b/api/openapi/api.yaml new file mode 100644 index 0000000..8d5bf3b --- /dev/null +++ b/api/openapi/api.yaml @@ -0,0 +1,215 @@ +openapi: 3.1.0 +info: + title: IPS Lacpass + description: IPS Lacpass + version: 1.0.0 +servers: + - url: 'http://localhost:8081' +paths: + /users: + post: + summary: "Register a new user" + description: "Register a new user in the realm. It will send a confirmation email to the user." + tags: + - User + requestBody: + description: "User registration details" + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + description: The email address for the new account. + example: john.doe@example.com + password: + type: string + format: password + description: The password for the new account. + example: MySuperSecurePassword123! + password-confirm: + type: string + format: password + description: Confirmation of the password. Must match 'password'. + example: MySuperSecurePassword123! + firstName: + type: string + description: (Optional) The first name of the user. + example: John + lastName: + type: string + description: (Optional) The last name of the user. + example: Doe + locale: + type: string + description: "Preferred language." + enum: + - en + - es + - pt-br + example: en + document_type: + type: string + description: "Identity document type" + enum: + - passport + - id + example: passport + identifier: + type: string + description: "Document identifier" + example: F12345678 + required: + - username + - email + - password + - password-confirm + - locale + - document_type + - identifier + responses: + "200": + description: "Successful registration. The user will get a confirmation e-mail." + content: + application/json: + schema: + type: object + properties: + sub: + type: string + description: "User's id on the realm." + name: + type: string + description: "Full name." + given_name: + type: string + description: "Given name or first name." + family_name: + type: string + description: "Surname or last name." + preferred_username: + type: string + description: "Preferred username." + email: + type: string + format: email + description: "E-mail address." + email_verified: + type: boolean + description: "Indicates if the user's email has been verified." + locale: + type: string + description: "Preferred language." + document_type: + type: string + description: "Identity document type" + enum: + - passport + - id + identifier: + type: string + description: "Document identifier" + example: + sub: "a8c6d49a-72c7-4402-a1b1-7a5e9f8b4d6c" + name: "John Doe" + given_name: "John" + family_name: "Doe" + preferred_username: "jdoe" + email: "johndoe@example.com" + email_verified: false + locale: "en" + document_type: "passport" + identifier: "F12345678" + "400": + description: "The request is malformed or missing required fields." + content: + application/json: + schema: + type: array + items: + type: object + properties: + error: + type: string + error_description: + type: string + required: + - error + - error_description + examples: + MissingUsername: + value: + - error: "missing_username" + error_description: "Missing required field: username" + MissingEmail: + value: + - error: "missing_email" + error_description: "Missing required field: email" + MissingPassword: + value: + - error: "missing_password" + error_description: "Missing required field: password" + MissingPasswordConfirmation: + value: + - error: "missing_password_confirmation" + error_description: "Missing required field: password-confirm" + MissingLocale: + value: + - error: "missing_locale" + error_description: "Missing required field: locale" + MissingDocumentType: + value: + - error: "missing_document_type" + error_description: "Missing required field: document_type" + MissingIdentifier: + value: + - error: "missing_identifier" + error_description: "Missing required field: identifier" + InvalidDocumentType: + value: + - error: "invalid_document_type" + error_description: "Invalid document type. Must be either 'passport' or 'id'." + InvalidIdentifierFormat: + value: + - error: "invalid_identifier_format" + error_description: "Invalid identifier format." + InvalidEmailFormat: + value: + - error: "invalid_email_format" + error_description: "Invalid email format" + PasswordMismatch: + value: + - error: "password_mismatch" + error_description: "Password and password confirmation do not match" + InvalidPasswordMinLength: + value: + - error: "invalid_password_min_length_message" + error_description: "Invalid password: minimum length " + InvalidPasswordMaxLength: + value: + - error: "invalid_password_max_length_message" + error_description: "Invalid password: maximum length " + "409": + description: "The request is malformed or missing required fields." + content: + application/json: + schema: + type: array + items: + type: object + properties: + error: + type: string + error_description: + type: string + required: + - error + - error_description + examples: + DuplicateUser: + value: + - error: "user_already_exists" + error_description: "User already exists" \ No newline at end of file diff --git a/api/openapi/auth.yaml b/api/openapi/auth.yaml new file mode 100644 index 0000000..e6782ce --- /dev/null +++ b/api/openapi/auth.yaml @@ -0,0 +1,354 @@ +openapi: 3.1.0 +info: + title: Lacpass Authentication Server + description: Authentication endpoints + version: 1.0.0 + +servers: + - url: 'http://localhost:8082' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: "Access token obtained via the Token endpoint." + schemas: + Account: + type: object + properties: + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + emailVerified: + type: boolean + attributes: + type: object + additionalProperties: + type: array + items: + type: string + UserRepresentation: + type: object + description: "Represents the current user's profile." + properties: + id: + type: string + readOnly: true + description: "User's ID." + username: + type: string + description: "User's username." + firstName: + type: string + description: "User's first name." + lastName: + type: string + description: "User's last name." + email: + type: string + format: email + description: "User's email address." + emailVerified: + type: boolean + readOnly: true + description: "Indicates if the user's email has been verified." + attributes: + type: object + additionalProperties: + type: string + description: "A map of custom user attributes." + SessionRepresentation: + type: object + description: "Represents an active user session." + properties: + id: + type: string + description: "Session ID." + ipAddress: + type: string + description: "IP address from which the session was initiated." + started: + type: integer + format: int64 + description: "Timestamp of when the session started." + lastAccess: + type: integer + format: int64 + description: "Timestamp of the last access in this session." + current: + type: boolean + description: "Indicates if this is the current session" + browser: + type: string + description: "Browser or user agent." + os: + type: string + description: "Operating system." + Error: + type: object + description: "Error" + properties: + error: + type: string + description: "Error type" + examples: ["invalid_request"] + error_description: + type: string + description: "Error description" + examples: ["Missing form parameter: grant_type"] + examples: + InvalidRequestError: + value: + error: "invalid_request" + error_description: "Missing form parameter: grant_type" + summary: "Invalid request" + InvalidUserCredentialsError: + value: + error: "invalid_grant" + error_description: "Invalid user credentials" + summary: "Invalid user credentials" + MaximumRefreshTokenUsesExceeded: + value: + error: "invalid_grant" + error_description: "Maximum allowed refresh token reuse exceeded" + summary: "Maximum allowed refresh token reuse exceeded" + + +# Group the endpoints into logical tabs. +tags: + - name: Authentication + description: "Endpoints for user authentication (OpenID Connect)." + - name: User + description: "Endpoints for user account" + +paths: + /realms/{realm}/protocol/openid-connect/token: + post: + tags: + - Authentication + summary: "Get Token" + description: "Exchanges user credentials for an access and refresh tokens" + parameters: + - name: realm + in: path + required: true + schema: + type: string + examples: ["lacpass"] + description: "Name of the realm." + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + grant_type: + description: "Can be either `password` or `refresh_token`." + type: string + enum: + - password + - refresh_token + examples: ["password", "refresh_token"] + client_id: + description: "The ID of the client application." + type: string + enum: + - app + examples: ["app"] + refresh_token: + description: "Refresh token when `grant_type` refresh_token is used." + type: string + example: "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI1ZjA5MWRkYy1kNDQ3LTQ0OGYtODBmOS01NDUyYmRiMjM0ZjQifQ.eyJleHAiOjE3NTAxNzc3OTYsImlhdCI6MTc1MDA5MTM5NiwianRpIjoiNWJiMDNkMWQtYmY2Yy00MDZjLWI5NTktYmMzMjY4NjY3MjJlIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgyL3JlYWxtcy9sYWNwYXNzIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgyL3JlYWxtcy9sYWNwYXNzIiwic3ViIjoiNjYwMWIzMjYtOTU5Yi00ZWNkLTliOGMtYTkzNThkNjAxYzRhIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6ImFwcCIsInNpZCI6IjRiODM5OWU3LTNjYzUtNDJjZi1hNTJhLWY3MmZkOTE1MGM0MyIsInNjb3BlIjoid2ViLW9yaWdpbnMgYmFzaWMgcHJvZmlsZSByb2xlcyBlbWFpbCBhY3IiLCJyZXVzZV9pZCI6IjZmNDRkMTkzLWQwODQtNGU5Yi1iMWY1LWNkNTI1OWQyZWY5NiJ9.8yv3KVAyiBNQ2q9rcO0_oHdEBfQ5eJfRLVoCsYRCQsLMwfdV9Jc4NOmIXc337artKwY8c6k0_1ILhud-STPuJg" + required: false + username: + type: string + description: "User's email. Required when `grant_type` is password." + examples: ["jdoe@example.com"] + required: false + password: + type: string + description: "User's password. Required when `grant_type` is password." + examples: ["secret-password"] + required: false + scope: + type: string + description: "The scopes being requested. `openid` scope should always be present" + examples: ["openid profile email"] + required: false + responses: + '200': + description: "Authentication successful." + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + refresh_token: + type: string + example: "pvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + not-before-policy: + type: integer + example: 0 + session_state: + type: string + example: "9c238c37-1021-43b3-a122-67301500a61d" + scope: + type: string + example: "profile email" + expires_in: + type: integer + example: 123058381 + refresh_expires_in: + type: integer + example: 181283903 + token_type: + type: string + example: "Bearer" + '401': + description: "Bad Request - a required parameter is missing or invalid." + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + invalidRequest: + $ref: '#/components/examples/InvalidRequestError' + invalidUserCredentials: + $ref: '#/components/examples/InvalidUserCredentialsError' + MaximumRefreshTokenUsesExceeded: + $ref: '#/components/examples/MaximumRefreshTokenUsesExceeded' + /realms/{realm}/protocol/openid-connect/logout: + post: + tags: + - Authentication + summary: "Log Out" + description: "Logs the user out of their session. Requires the refresh token to fully invalidate the session." + parameters: + - name: realm + in: path + required: true + schema: + type: string + description: "The name of the realm." + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + client_id: + type: string + description: "The ID of the client application." + examples: ["my-app-client"] + refresh_token: + type: string + description: "The refresh token obtained during login." + responses: + '204': + description: "Logout successful." + /realms/{realm}/protocol/openid-connect/userinfo: + get: + summary: "Get User Information" + description: "This endpoint returns claims about the authenticated end-user." + tags: + - User + parameters: + - name: realm + in: path + required: true + description: "The name of the realm." + schema: + type: string + security: + - bearerAuth: [ ] + responses: + "200": + description: "Successful response with user resource." + content: + application/json: + schema: + type: object + properties: + sub: + type: string + description: "User's id on the realm." + name: + type: string + description: "Full name." + given_name: + type: string + description: "Given name or first name." + family_name: + type: string + description: "Surname or last name." + preferred_username: + type: string + description: "Preferred username." + email: + type: string + format: email + description: "E-mail address." + email_verified: + type: boolean + description: "Indicates if the user's email has been verified." + locale: + type: string + description: "Preferred language." + document_type: + type: string + description: "Identity document type" + enum: + - passport + - id + identifier: + type: string + description: "Document identifier" + example: + sub: "a8c6d49a-72c7-4402-a1b1-7a5e9f8b4d6c" + name: "John Doe" + given_name: "John" + family_name: "Doe" + preferred_username: "jdoe" + email: "johndoe@example.com" + email_verified: true + locale: "en" + document_type: "passport" + identifier: "F12345678" + + "401": + description: "Unauthorized. The request is missing a valid bearer token." + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: "unauthorized" + error_description: + type: string + example: "Bearer token not provided or is invalid." + "403": + description: "Forbidden. The provided token does not have the required permissions (e.g., missing 'openid' scope)." + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: "insufficient_scope" + error_description: + type: string + example: "The 'openid' scope is required." diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 0000000..d9bc1ed --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" +) + +type App struct { + router http.Handler + config Config +} + +func New(config Config) *App { + app := &App{ + config: config, + } + + app.loadRoutes() + + return app +} + +func (a *App) Start(ctx context.Context) error { + server := &http.Server{ + Addr: fmt.Sprintf(":%d", a.config.ServerPort), + Handler: a.router, + } + + fmt.Println("Starting server on port", a.config.ServerPort) + + ch := make(chan error, 1) + + go func() { + err := server.ListenAndServe() + if err != nil { + ch <- fmt.Errorf("failed to start server: %w", err) + } + close(ch) + }() + + select { + case err := <-ch: + return err + case <-ctx.Done(): + timeout, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + return server.Shutdown(timeout) + } +} diff --git a/cmd/api/config.go b/cmd/api/config.go new file mode 100644 index 0000000..b806cd8 --- /dev/null +++ b/cmd/api/config.go @@ -0,0 +1,89 @@ +package main + +import ( + "os" + "strconv" +) + +type Config struct { + ServerPort uint16 + AuthHostName string + AuthInternalUrl string + AuthRealm string + AuthAdminClientID string + AuthClientSecret string + AuthEmailRedirectURI string + AuthEmailLifespan int + AuthEmailClientID string + FhirBaseUrl string + VhlBaseUrl string + APISwagger bool + LogLevel string +} + +func LoadConfig() Config { + cfg := Config{ + ServerPort: 3000, + AuthHostName: "http://localhost:9083", + AuthInternalUrl: "http://localhost:9083", + AuthRealm: "lacpass", + AuthAdminClientID: "admin-cli", + AuthClientSecret: "bbU4vnqhqe2AJ32XpdQVRVqfRMA82Hnu", + AuthEmailRedirectURI: "ph4happ://open/validated-email", + AuthEmailLifespan: 3600, + AuthEmailClientID: "app", + FhirBaseUrl: "http://lacpass.create.cl:8080", + VhlBaseUrl: "http://lacpass.create.cl:8182", + APISwagger: false, + LogLevel: "info", + } + + if serverPort, exists := os.LookupEnv("API_PORT"); exists { + if port, err := strconv.ParseUint(serverPort, 10, 16); err == nil { + cfg.ServerPort = uint16(port) + } + } + if authUrl, exists := os.LookupEnv("AUTH_INTERNAL_URL"); exists { + cfg.AuthInternalUrl = authUrl + } + if authHostname, exists := os.LookupEnv("AUTH_HOSTNAME"); exists { + cfg.AuthHostName = authHostname + } + if authRealm, exists := os.LookupEnv("AUTH_REALM"); exists { + cfg.AuthRealm = authRealm + } + if authEmailClientID, exists := os.LookupEnv("AUTH_EMAIL_CLIENT_ID"); exists { + cfg.AuthEmailClientID = authEmailClientID + } + if authClientSecret, exists := os.LookupEnv("AUTH_CLIENT_SECRET"); exists { + cfg.AuthClientSecret = authClientSecret + } + if authEmailRedirectURI, exists := os.LookupEnv("AUTH_EMAIL_REDIRECT_URI"); exists { + cfg.AuthEmailRedirectURI = authEmailRedirectURI + } + if authEmailLifespan, exists := os.LookupEnv("AUTH_EMAIL_LIFESPAN"); exists { + if lifespan, err := strconv.Atoi(authEmailLifespan); err == nil { + cfg.AuthEmailLifespan = lifespan + } + } + if authEmailClientID, exists := os.LookupEnv("AUTH_EMAIL_CLIENT_ID"); exists { + cfg.AuthEmailClientID = authEmailClientID + } + if authEmailClientID, exists := os.LookupEnv("AUTH_EMAIL_CLIENT_ID"); exists { + cfg.AuthEmailClientID = authEmailClientID + } + if fhirBaseUrl, exists := os.LookupEnv("FHIR_BASE_URL"); exists { + cfg.FhirBaseUrl = fhirBaseUrl + } + if vhlBaseUrl, exists := os.LookupEnv("VHL_BASE_URL"); exists { + cfg.VhlBaseUrl = vhlBaseUrl + } + if apiSwagger, exists := os.LookupEnv("API_SWAGGER"); exists { + cfg.APISwagger = apiSwagger == "true" + } + if logLevel, exists := os.LookupEnv("LOG_LEVEL"); exists { + cfg.LogLevel = logLevel + } + + return cfg +} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..439830d --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" +) + +// @title Lacpass App API +// @version 0.1 +// @description This is the official API for Lacpass mobile app. + +// @host localhost:9081 + +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization +func main() { + + app := New(LoadConfig()) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + err := app.Start(ctx) + if err != nil { + fmt.Println("failed to start app:", err) + } +} diff --git a/cmd/api/routes.go b/cmd/api/routes.go new file mode 100644 index 0000000..08eb92d --- /dev/null +++ b/cmd/api/routes.go @@ -0,0 +1,140 @@ +package main + +import ( + "ips-lacpass-backend/internal/core" + "ips-lacpass-backend/internal/repository/fhir" + "ips-lacpass-backend/internal/repository/keycloak" + "ips-lacpass-backend/internal/repository/vhl" + "log/slog" + "net/http" + "os" + "strings" + "time" + + _ "ips-lacpass-backend/internal/docs" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httplog/v3" + httpSwagger "github.com/swaggo/http-swagger" + + "ips-lacpass-backend/internal/handler" + customMiddleware "ips-lacpass-backend/internal/middleware" +) + +func (a *App) loadRoutes() { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RedirectSlashes) + r.Use(middleware.RealIP) + r.Use(middleware.Timeout(60 * time.Second)) + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // TODO: Add configuration for CORS + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, User-Agent") + next.ServeHTTP(w, r) + }) + }) + + if strings.ToLower(a.config.LogLevel) == "debug" { + logFormat := httplog.SchemaECS.Concise(true) + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + ReplaceAttr: logFormat.ReplaceAttr, + })).With( + slog.String("api", "lacpass"), + slog.String("version", "0.1.0"), + slog.String("env", "development"), + ) + r.Use(httplog.RequestLogger(logger, &httplog.Options{ + Level: slog.LevelDebug, + Schema: httplog.SchemaECS, + RecoverPanics: true, + Skip: nil, + LogRequestHeaders: []string{"Authorization", "Content-Type", "User-Agent"}, + })) + } + + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + r.Route("/users", a.loadUserRoutesNoAuth) + + r.Group(func(r chi.Router) { + authMiddleware := customMiddleware.NewAuthMiddleware( + a.config.AuthInternalUrl, + a.config.AuthRealm, + a.config.AuthHostName, + ) + authMiddleware.RefreshKeySet(24 * time.Hour) + r.Use(authMiddleware.Authenticator) + + r.Route("/ips", a.loadIpsRoute) + r.Route("/users/auth", a.loadUserRoutesAuth) + r.Route("/qr", a.loadVhlRoute) + }) + + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + if a.config.APISwagger { + r.Get("/swagger/*", httpSwagger.Handler()) + } + + a.router = r +} + +func (a *App) loadUserRoutesNoAuth(router chi.Router) { + r := keycloak.NewKeycloakClient( + a.config.AuthInternalUrl, + a.config.AuthRealm, + a.config.AuthAdminClientID, + a.config.AuthClientSecret, + a.config.AuthEmailRedirectURI, + a.config.AuthEmailClientID, + a.config.AuthEmailLifespan, + ) + s := core.NewUserService(r) + h := handler.NewUserHandler(s) + router.Post("/", h.Create) +} + +func (a *App) loadUserRoutesAuth(router chi.Router) { + r := keycloak.NewKeycloakClient( + a.config.AuthInternalUrl, + a.config.AuthRealm, + a.config.AuthAdminClientID, + a.config.AuthClientSecret, + a.config.AuthEmailRedirectURI, + a.config.AuthEmailClientID, + a.config.AuthEmailLifespan, + ) + s := core.NewUserService(r) + h := handler.NewUserHandler(s) + router.Put("/update", h.Update) +} + +func (a *App) loadIpsRoute(router chi.Router) { + r := fhir.FhirRepository{ + Client: &http.Client{}, + BaseURL: a.config.FhirBaseUrl, + } + s := core.NewFhirService(r) + h := handler.NewIpsHandler(s) + router.Get("/", h.Get) +} + +func (a *App) loadVhlRoute(router chi.Router) { + r := vhl.VhlRepository{ + Client: &http.Client{}, + BaseURL: a.config.VhlBaseUrl, + } + s := core.NewVhlService(r) + h := handler.NewVhlHandler(s) + router.Post("/", h.Create) +} diff --git a/config/keycloak/default_realm.json b/config/keycloak/default_realm.json new file mode 100644 index 0000000..b70fd37 --- /dev/null +++ b/config/keycloak/default_realm.json @@ -0,0 +1,2747 @@ +{ + "id": "2d2e0065-7676-49b6-956b-1a48f034490a", + "realm": "lacpass", + "displayName": "", + "displayNameHtml": "", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": true, + "refreshTokenMaxReuse": 1, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 86400, + "ssoSessionMaxLifespan": 604800, + "ssoSessionIdleTimeoutRememberMe": 604800, + "ssoSessionMaxLifespanRememberMe": 2592000, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": true, + "verifyEmail": true, + "loginWithEmailAllowed": false, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": true, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "cd599fc9-18e3-42ac-bd92-9e20dc8a4b2b", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "2d2e0065-7676-49b6-956b-1a48f034490a", + "attributes": {} + }, + { + "id": "ae68a2d1-085a-4f4d-ac91-1c7566aa0d70", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "2d2e0065-7676-49b6-956b-1a48f034490a", + "attributes": {} + }, + { + "id": "b2bd6b10-2be4-4843-bab5-095cb41397ee", + "name": "default-roles-lacpass", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "user" + ], + "client": { + "account": [ + "manage-account", + "view-profile" + ] + } + }, + "clientRole": false, + "containerId": "2d2e0065-7676-49b6-956b-1a48f034490a", + "attributes": {} + }, + { + "id": "a3972a9b-16a2-44cb-b05e-557f512f365d", + "name": "user", + "description": "app_default-role", + "composite": false, + "clientRole": false, + "containerId": "2d2e0065-7676-49b6-956b-1a48f034490a", + "attributes": {} + } + ], + "client": { + "app": [], + "realm-management": [ + { + "id": "eeabc6b7-2109-4f1a-9ae0-d88144f8f017", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "abb1219e-b54f-43cf-8545-456959643a81", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "332ef4d6-c95c-4d8f-b6e0-5e8cb0e76129", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "c3f9324e-c627-4e2b-9848-fef1b7eb7a38", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "aad62b61-b788-4290-9d2e-543efe05b42a", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "67868e53-7ada-46aa-bcb4-cf6dd9a26dd2", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "02c1bf90-2fd8-42fd-9556-dc852af7e1b0", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "7624d011-26ff-42d2-bda7-06701ea5b3ec", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "manage-clients", + "impersonation", + "manage-users", + "manage-realm", + "manage-events", + "query-users", + "manage-identity-providers", + "query-realms", + "view-clients", + "view-realm", + "query-groups", + "view-identity-providers", + "view-events", + "create-client", + "view-users", + "view-authorization", + "manage-authorization", + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "73c7cbb5-d208-4834-9fd8-a47ba09c8cae", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "33b12538-ba5c-4a00-ba70-1b91913950d0", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "e6f4d5e0-d789-4423-b13c-c3e0559db6ba", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "5392fd8f-81da-4386-bb70-04a31cc126ea", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "77574e8a-24a3-4645-aa61-bebb481ea8a9", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "38fc8a3d-e54b-45e6-84d4-c32dc670adbb", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "c77e1ccf-4738-45be-aa95-82d934e8ba95", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "c792fdf1-f55d-408a-99f6-5d2f42923ac2", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "dbd87183-124d-4801-97dd-862fdcfce5ce", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "0f046be5-c06d-4763-abcc-d6e8c745518a", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + }, + { + "id": "216f129a-d32a-4876-95f7-637ee476735b", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [ + { + "id": "92ece40a-88a5-4684-8fd2-b714b5176245", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "222fb55a-fac6-4286-9c17-8f75a90bb26b", + "attributes": {} + } + ], + "account-console": [], + "broker": [ + { + "id": "b73c19cc-29f4-4e2b-93c0-f18d45cc8280", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "70573fbc-8810-4d37-9fca-67d94f40fa2b", + "attributes": {} + } + ], + "account": [ + { + "id": "5eebc7c3-5662-4362-8e99-2292fb44c52c", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "0485270c-5a08-4666-9887-eddb547d5da3", + "attributes": {} + }, + { + "id": "6902cce9-22c4-46f3-81d2-61aace7082fe", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "0485270c-5a08-4666-9887-eddb547d5da3", + "attributes": {} + }, + { + "id": "ccb94c27-447e-4976-8cd5-0f66fa663abd", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "0485270c-5a08-4666-9887-eddb547d5da3", + "attributes": {} + }, + { + "id": "71d081f5-6af1-4f3a-ae7b-0263a86630fd", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "0485270c-5a08-4666-9887-eddb547d5da3", + "attributes": {} + }, + { + "id": "5a5e2d28-776f-40f3-b8c3-4dcf520da5f6", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "0485270c-5a08-4666-9887-eddb547d5da3", + "attributes": {} + }, + { + "id": "05b3cf2e-2608-4b7e-bda6-5160d7ffb47e", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "0485270c-5a08-4666-9887-eddb547d5da3", + "attributes": {} + }, + { + "id": "d998c967-e50b-489a-b6f0-632ed98869d8", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "0485270c-5a08-4666-9887-eddb547d5da3", + "attributes": {} + }, + { + "id": "6852a1d0-d3b6-47d9-b2d9-58c69a128e51", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "0485270c-5a08-4666-9887-eddb547d5da3", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "b2bd6b10-2be4-4843-bab5-095cb41397ee", + "name": "default-roles-lacpass", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "2d2e0065-7676-49b6-956b-1a48f034490a" + }, + "requiredCredentials": [ + "password" + ], + "passwordPolicy": "length(4)", + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": { + "pt-BR": { + "profile.attributes.documentType": "Tipo de Documento", + "profile.attributes.identifier": "identificador" + }, + "en": { + "profile.attributes.documentType": "Document Type", + "profile.attributes.identifier": "identifier" + }, + "es": { + "profile.attributes.documentType": "Tipo de Documento", + "profile.attributes.identifier": "identificador" + } + }, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "40183734-b3db-413d-9b5f-81382afd1c22", + "username": "service-account-admin-cli", + "emailVerified": false, + "createdTimestamp": 1751100134400, + "enabled": true, + "totp": false, + "serviceAccountClientId": "admin-cli", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-lacpass" + ], + "clientRoles": { + "realm-management": [ + "manage-users", + "manage-realm", + "query-users", + "manage-identity-providers", + "realm-admin", + "view-identity-providers", + "query-groups", + "view-users", + "manage-clients", + "impersonation", + "manage-events", + "view-clients", + "query-realms", + "view-realm", + "view-events", + "create-client", + "view-authorization", + "manage-authorization", + "query-clients" + ], + "admin-cli": [ + "uma_protection" + ], + "broker": [ + "read-token" + ], + "account": [ + "delete-account", + "view-applications", + "view-groups", + "manage-account", + "manage-consent", + "view-profile", + "view-consent", + "manage-account-links" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "0485270c-5a08-4666-9887-eddb547d5da3", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/lacpass/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/lacpass/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "70516e04-fc6a-4797-98f2-458d3c240eb0", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/lacpass/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/lacpass/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "1b766a3a-d05a-43da-9265-e2bd98b6260f", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "222fb55a-fac6-4286-9c17-8f75a90bb26b", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "21900", + "client.secret.creation.time": "1751100134", + "request.object.signature.alg": "any", + "request.object.encryption.alg": "any", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "standard.token.exchange.enabled": "false", + "post.logout.redirect.uris": "+", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.use.lightweight.access.token.enabled": "true", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "request.object.required": "not required", + "access.token.header.type.rfc9068": "false", + "acr.loa.map": "{}", + "require.pushed.authorization.requests": "false", + "tls.client.certificate.bound.access.tokens": "false", + "display.on.consent.screen": "false", + "request.object.encryption.enc": "any", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "service_account", + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "organization", + "microprofile-jwt" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Default Resource", + "type": "urn:admin-cli:resources:default", + "ownerManagedAccess": false, + "attributes": {}, + "uris": [ + "/*" + ] + } + ], + "policies": [ + { + "name": "Default Policy", + "description": "A policy that grants access only for users within this realm", + "type": "regex", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "code": "// by default, grants any permission associated with this policy\n$evaluation.grant();\n" + } + }, + { + "name": "Default Permission", + "description": "A permission that applies to the default resource type", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "defaultResourceType": "urn:admin-cli:resources:default", + "applyPolicies": "[\"Default Policy\"]" + } + } + ], + "scopes": [], + "decisionStrategy": "UNANIMOUS" + } + }, + { + "id": "a8fc20a8-b866-45fa-85ee-0b2e7380374e", + "clientId": "app", + "name": "App", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "ph4happ://*", + "http://localhost:8080/callback", + "ph4happ://open/validated-email" + ], + "webOrigins": [ + "http://localhost:8080" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", + "frontchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "display.on.consent.screen": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "70573fbc-8810-4d37-9fca-67d94f40fa2b", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "e05aa581-d135-45ac-8c94-70e9a9e7e081", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "68c33618-1d38-4c8d-8525-270bb827938a", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/lacpass/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/lacpass/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "db1c8c8c-16d9-428a-8d24-fac66f5ef343", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "offline_access", + "organization", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "f03108e0-e920-49a3-8f65-163d8cb5f299", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "9df8cf94-30c9-4285-a923-df6a79970716", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "2471f13f-48f2-4df0-8e21-c0317eba5c98", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "c3e28131-5200-4a1e-afee-387c5290169d", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "ccf43603-8619-4f0e-a123-3466a778fbbb", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "a5a53b73-3984-495f-8613-dc4c36136246", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f675a7b0-ffc4-4d37-947b-e7b87743034f", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "d171b6a0-455d-4093-b1fc-931f95f8f014", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "d7910b17-bc3b-459a-8013-b14272acdb33", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "8ebf3e65-7015-4182-9640-bc478ba8c34e", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "e4effb3f-8e57-45de-a40e-b33be0f616c0", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "7bf2d151-be3f-4076-a35f-57e581da8a1c", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + } + ] + }, + { + "id": "cf7aa803-54d8-4694-a693-aba2b1b3425f", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "c940bd39-40bf-4d0e-87a5-019c76340c71", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "bb8c2301-a541-42a6-9a7f-fc8afaea0ab3", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "9af92a22-4f38-4bce-98d0-ccc011ba676b", + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "5cd4b465-5cfe-4ef3-a4ae-bca923c5e078", + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "fe7b8311-4dcd-463c-b6ec-f92f26f39adf", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "2aeb4b7b-40c8-4c13-8a65-bd39430b5f84", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "4b4a6269-514a-4e02-a12d-135576392c4d", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "99ae9dc3-3d33-4555-b176-d8bb68453c6d", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "7b14e445-259e-4989-ad79-bee1c7a07036", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "91d71eee-e649-47f2-888e-988f89a52b97", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "3913445e-ffdf-4aa2-be4a-59efc709c11c", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "8c50118c-9474-4fe4-80e9-1ff8a2214a49", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "af6088fe-4700-403d-84d4-38ac57d68aa3", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "092af2ce-abfc-4e6d-8cc8-14e1e7b15469", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "0bf3c433-99f8-4179-b09a-13bc9ebabff4", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "3514c1c0-7aea-431e-b0b8-5348410162b4", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "a7f92797-9b56-4bca-9d14-8ad128c22cf5", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "b3cb38f7-dc85-4082-b3f0-8ce10cbf8aad", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "a3d81381-e72e-44bd-b2b3-b5b96d955a0d", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "b7e1382a-d914-4108-811c-407e8c3b8e96", + "name": "document type", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "documentType", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "document_type", + "jsonType.label": "String" + } + }, + { + "id": "a1896c4c-07cd-486e-af51-d067f7be7431", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "d9c87d04-4fe9-4280-9a46-800e690743b6", + "name": "identifier", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "identifier", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "identifier", + "jsonType.label": "String" + } + }, + { + "id": "6b02bbb0-a064-44a8-8396-6cf5b42689f7", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "2e00c7c1-51f0-4bc9-81a3-108278f3c31e", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "eb0dbbec-5d8f-4302-8bfd-baabf4a37c20", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "f765d810-55a3-4027-b730-d85d93d4b4d4", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "26b0972d-11d8-4689-9323-b8fb0881e8ae", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "e9b7f166-1af4-4f6f-aff6-8c80bd9407cf", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "86b248a5-a052-4626-822d-9237b99acea5", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "447f7473-2432-42b7-b4df-bb7ba3e5a6ab", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "16c91086-c74b-41e5-a36f-8a6ec1fd2e37", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "69176f8c-0293-4ab8-974c-ee1c76ce2a46", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "583a07d1-3e1a-49fb-8e3e-a396d236e3c7", + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "49b0fb29-f062-4f27-953c-9cef65feec78", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "saml_organization", + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "microprofile-jwt", + "organization" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": { + "debug": "false", + "replyToDisplayName": "", + "starttls": "false", + "auth": "", + "port": "1025", + "host": "mailcatcher", + "replyTo": "", + "from": "test@example.com", + "fromDisplayName": "", + "envelopeFrom": "", + "ssl": "false" + }, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging", + "email" + ], + "enabledEventTypes": [ + "SEND_RESET_PASSWORD", + "UPDATE_CONSENT_ERROR", + "GRANT_CONSENT", + "VERIFY_PROFILE_ERROR", + "REMOVE_TOTP", + "REVOKE_GRANT", + "UPDATE_TOTP", + "LOGIN_ERROR", + "CLIENT_LOGIN", + "RESET_PASSWORD_ERROR", + "UPDATE_CREDENTIAL", + "IMPERSONATE_ERROR", + "CODE_TO_TOKEN_ERROR", + "CUSTOM_REQUIRED_ACTION", + "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", + "RESTART_AUTHENTICATION", + "IMPERSONATE", + "UPDATE_PROFILE_ERROR", + "LOGIN", + "OAUTH2_DEVICE_VERIFY_USER_CODE", + "UPDATE_PASSWORD_ERROR", + "CLIENT_INITIATED_ACCOUNT_LINKING", + "OAUTH2_EXTENSION_GRANT", + "USER_DISABLED_BY_PERMANENT_LOCKOUT", + "REMOVE_CREDENTIAL_ERROR", + "TOKEN_EXCHANGE", + "AUTHREQID_TO_TOKEN", + "LOGOUT", + "REGISTER", + "DELETE_ACCOUNT_ERROR", + "CLIENT_REGISTER", + "IDENTITY_PROVIDER_LINK_ACCOUNT", + "USER_DISABLED_BY_TEMPORARY_LOCKOUT", + "DELETE_ACCOUNT", + "UPDATE_PASSWORD", + "CLIENT_DELETE", + "FEDERATED_IDENTITY_LINK_ERROR", + "IDENTITY_PROVIDER_FIRST_LOGIN", + "CLIENT_DELETE_ERROR", + "VERIFY_EMAIL", + "CLIENT_LOGIN_ERROR", + "RESTART_AUTHENTICATION_ERROR", + "EXECUTE_ACTIONS", + "REMOVE_FEDERATED_IDENTITY_ERROR", + "TOKEN_EXCHANGE_ERROR", + "PERMISSION_TOKEN", + "FEDERATED_IDENTITY_OVERRIDE_LINK", + "SEND_IDENTITY_PROVIDER_LINK_ERROR", + "UPDATE_CREDENTIAL_ERROR", + "EXECUTE_ACTION_TOKEN_ERROR", + "OAUTH2_EXTENSION_GRANT_ERROR", + "SEND_VERIFY_EMAIL", + "OAUTH2_DEVICE_AUTH", + "EXECUTE_ACTIONS_ERROR", + "REMOVE_FEDERATED_IDENTITY", + "OAUTH2_DEVICE_CODE_TO_TOKEN", + "IDENTITY_PROVIDER_POST_LOGIN", + "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", + "FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR", + "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", + "UPDATE_EMAIL", + "REGISTER_ERROR", + "REVOKE_GRANT_ERROR", + "EXECUTE_ACTION_TOKEN", + "LOGOUT_ERROR", + "UPDATE_EMAIL_ERROR", + "CLIENT_UPDATE_ERROR", + "AUTHREQID_TO_TOKEN_ERROR", + "INVITE_ORG_ERROR", + "UPDATE_PROFILE", + "CLIENT_REGISTER_ERROR", + "FEDERATED_IDENTITY_LINK", + "INVITE_ORG", + "SEND_IDENTITY_PROVIDER_LINK", + "SEND_VERIFY_EMAIL_ERROR", + "RESET_PASSWORD", + "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", + "OAUTH2_DEVICE_AUTH_ERROR", + "REMOVE_CREDENTIAL", + "UPDATE_CONSENT", + "REMOVE_TOTP_ERROR", + "VERIFY_EMAIL_ERROR", + "SEND_RESET_PASSWORD_ERROR", + "CLIENT_UPDATE", + "CUSTOM_REQUIRED_ACTION_ERROR", + "IDENTITY_PROVIDER_POST_LOGIN_ERROR", + "UPDATE_TOTP_ERROR", + "CODE_TO_TOKEN", + "VERIFY_PROFILE", + "GRANT_CONSENT_ERROR", + "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR" + ], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "26f35b1c-01b2-45a0-a38d-f59e272990d3", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "49b09c5a-cbcd-476e-b5ad-e4b30dc49d99", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-address-mapper", + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper" + ] + } + }, + { + "id": "94569932-5798-4c54-a360-3d0a50af11d1", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "75054e8c-d392-4543-810a-69b8463bbf41", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper", + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-address-mapper" + ] + } + }, + { + "id": "f5a59fd4-1e2f-4be6-9c2f-fe86641a7021", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "54fc52d6-0447-403b-abfa-395da6491f44", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "4919925c-c11b-48ea-b77d-81de3c7bb203", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "f309b396-7610-4d68-a053-80ea8f7d4023", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "361c23c9-078c-44c8-8b40-0bfd28456d49", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255}},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"documentType\",\"displayName\":\"${profile.attributes.documentType}\",\"validations\":{\"options\":{\"options\":[\"passport\",\"identity\"]}},\"annotations\":{\"inputType\":\"select\"},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"identifier\",\"displayName\":\"${profile.attributes.identifier}\",\"validations\":{},\"annotations\":{},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}],\"unmanagedAttributePolicy\":\"ADMIN_EDIT\"}" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "c132c66f-2e7e-4835-ac97-5d73178a20e7", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "463885e5-92f8-463e-9c10-7f17c1b0158d", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "6d00883d-0eb9-4053-9f65-77b7da2f4c66", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "c4d54a9f-a4a3-411b-acdb-6a23fca8da68", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + } + ] + }, + "internationalizationEnabled": true, + "supportedLocales": [ + "pt-BR", + "en", + "es" + ], + "defaultLocale": "es", + "authenticationFlows": [ + { + "id": "5d41da87-488d-4ae9-ba78-95b3b8b114b1", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "c735bece-19c6-4d75-9cd0-2c03a4f9ee63", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "ec539360-bfc2-4648-8e1f-8e73b03b7814", + "alias": "Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "013b3615-fa9e-4347-9d25-e37e9c4fb37d", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "5c6eb73b-6d23-4575-81c1-60a67598573f", + "alias": "First Broker Login - Conditional Organization", + "description": "Flow to determine if the authenticator that adds organization members is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "idp-add-organization-member", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "35d9ebc3-0f95-4e91-a6a1-1ad857ea6405", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "e941d60a-17ff-4e1b-94f0-22397cf84290", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "5661a418-deb1-4558-9d0f-7d1f2fc6c74a", + "alias": "Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "c1b1f0f0-fd7c-4e29-ad80-7da80315a4b0", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "bdfb0548-781a-4b23-9af7-c3155694c352", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "99d6e695-1898-43b3-8716-4bf250538943", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "fcd360e4-4464-4fc4-9070-fd16bde6334b", + "alias": "browser", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "f97d2d96-6c00-490d-8472-e40dac14ea33", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "31e79dc4-e588-4789-b74b-8db103707aaa", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "ca9d999e-e554-47fd-828d-1375fce6e649", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a6b624ab-885a-4c4f-bc95-3d9ba2bfe193", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 50, + "autheticatorFlow": true, + "flowAlias": "First Broker Login - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "abbd6f0c-6ada-4d9d-a8a1-14aa34184439", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "716a4830-2f75-4ac5-b339-3190d19fae82", + "alias": "registration", + "description": "Registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "c93dd62f-ddbf-45ec-82ba-b595aba09566", + "alias": "registration form", + "description": "Registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "ae27b8f7-cc0a-4654-8da5-515490948e9a", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "8a2f0e89-1a78-405b-b55f-180fd04381e7", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "0052ef49-2693-4ec4-a9f4-c8c4d4dba97b", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "a1f30740-6820-4d88-8750-1b7983ad277b", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": false, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaAuthRequestedUserHint": "login_hint", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "actionTokenGeneratedByUserLifespan.verify-email": "", + "actionTokenGeneratedByUserLifespan.idp-verify-account-via-email": "", + "clientOfflineSessionIdleTimeout": "0", + "actionTokenGeneratedByUserLifespan.execute-actions": "", + "cibaInterval": "5", + "realmReusableOtpCode": "false", + "cibaExpiresIn": "120", + "oauth2DeviceCodeLifespan": "600", + "saml.signature.algorithm": "", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "frontendUrl": "", + "acr.loa.map": "{}", + "shortVerificationUri": "", + "actionTokenGeneratedByUserLifespan.reset-credentials": "" + }, + "keycloakVersion": "26.2.5", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..dd248aa --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,32 @@ +# Swag stage to generate swagger docs +FROM golang:1.24-alpine AS docs + +RUN apk add --no-cache git + +WORKDIR /build + +RUN go install github.com/swaggo/swag/cmd/swag@latest + +# Download Go modules +COPY go.mod go.sum ./ +RUN go mod download + +COPY ./cmd/api/ ./ +COPY ./internal ./internal + +RUN swag fmt +RUN swag init -o internal/docs + +# Go stage to build the API +FROM golang:1.24 as builder + +WORKDIR /build +COPY --from=docs /build . + +RUN CGO_ENABLED=0 GOOS=linux go build -o api . +EXPOSE 8080 +EXPOSE 3000 + +FROM scratch +COPY --from=builder /build/api . +ENTRYPOINT ["./api"] diff --git a/docker/compose.yaml b/docker/compose.yaml new file mode 100644 index 0000000..b51c4b8 --- /dev/null +++ b/docker/compose.yaml @@ -0,0 +1,107 @@ +services: + lacpass-backend: + build: + context: .. + dockerfile: ./docker/Dockerfile + container_name: lacpass-backend + image: ips-backend + networks: + - backend + env_file: + - ../.env + environment: + API_PORT: ${API_PORT:-3000} + AUTH_INTERNAL_URL: ${AUTH_INTERNAL_URL:-http://auth:8080} + AUTH_HOSTNAME: ${AUTH_URL:-http://localhost:9083} + AUTH_REALM: ${AUTH_REALM:-lacpass} + AUTH_CLIENT_ID: ${AUTH_CLIENT_ID:-admin-cli} + # Need to set this after creating a client for Keycloak Admin API access, using service account + AUTH_CLIENT_SECRET: ${AUTH_CLIENT_SECRET:-bbU4vnqhqe2AJ32XpdQVRVqfRMA82Hnu} + AUTH_EMAIL_REDIRECT_URI: ${AUTH_EMAIL_REDIRECT_URI:-ph4happ://open/validated-email} + 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} + API_SWAGGER: ${API_SWAGGER:-true} + ports: + - "9081:3000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 10s + timeout: 5s + retries: 5 + depends_on: + auth: + condition: service_healthy + + auth: + image: bitnami/keycloak:26.2.5 + container_name: auth + env_file: + - ../.env + volumes: + - ../config/keycloak:/opt/bitnami/keycloak/data/import + environment: + KEYCLOAK_HOSTNAME: ${KEYCLOAK_HOSTNAME:-http://localhost:9083} + KC_HTTP_PORT: 8080 + KC_CACHE: local + KEYCLOAK_ADMIN_USER: ${KC_BOOTSTRAP_ADMIN_USERNAME:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:-admin} + KEYCLOAK_DATABASE_HOST: auth-db + KEYCLOAK_DATABASE_PORT: 5432 + KEYCLOAK_DATABASE_NAME: ${POSTGRES_DB:-keycloak} + KEYCLOAK_DATABASE_USER: ${POSTGRES_USER:-keycloak} + KEYCLOAK_DATABASE_PASSWORD: ${POSTGRES_PASS:-p@ssw0rd} + KEYCLOAK_ENABLE_HEALTH_ENDPOINTS: true + KEYCLOAK_EXTRA_ARGS: --import-realm + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/"] + interval: 15s + timeout: 5s + retries: 5 + ports: + - "9083:8080" + networks: + - backend + - auth + depends_on: + auth-db: + condition: service_healthy + + auth-db: + image: postgres:17.5-alpine + container_name: auth-db + volumes: + - auth-data:/var/lib/postgresql/data + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-keycloak} + POSTGRES_USER: ${POSTGRES_USER:-keycloak} + POSTGRES_PASSWORD: ${POSTGRES_PASS:-p@ssw0rd} + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-keycloak} -d ${POSTGRES_DB:-keycloak} -h localhost", + ] + interval: 5s + timeout: 3s + retries: 5 + networks: + - auth + + mailcatcher: + image: haravich/fake-smtp-server:20250615 + container_name: mailcatcher + platform: "linux/amd64" + ports: + - "25:1025" + - "9082:1080" + networks: + - auth + +volumes: + auth-data: + +networks: + auth: + backend: diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..b9039e5 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,53 @@ +# Authentication + +Lacpass backend uses [Keycloak](https://www.keycloak.org/) as its authentication service. An open source access management service. First lets make sure to have the `.env` configured. The sample environment file can be used and then edited accordingly: + +```bash +cp .env.sample .env +``` + +Then, to start keycloak we can run it from the root directory with docker compose as: + +```bash +docker compose --file=./docker/compose.yaml up auth +``` + +When the service starts we can visit http://localhost:8082 and check that is running correctly. The admin user will have +the same credentials specified in the `.env` file. A default realm `lacpass` will be created. The [openid configuration](http://localhost:8082/realms/lacpass/.well-known/openid-configuration) +should be as follows: + +```json +{ + "issuer": "http://localhost:8082/realms/lacpass", + "authorization_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/auth", + "token_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/token", + "introspection_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/token/introspect", + "userinfo_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/userinfo", + "end_session_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/logout", + ... +} +``` + +To create a test user we can enter our [local instance](http://localhost:8082) and then in the `Manage realms` tab, +select `lacpass` realm. + +![](./images/keycloak_realms.png "Keycloak realms") + +And then go to the `Users` tab and create a new user: + +![](./images/keycloak_users.png "Keycloak users") + +> In the compose we have a mail-catcher container running on port 25 that will show you any email sent by keycloak to +the users registered. This emails will not be sent out is just for development. + +Once the user is created, we can use the helper script to get an access token from Keycloak: + +```bash +sh scripts/auth.sh +``` + +For this to work we need to define both `KEYCLOAK_DEFAULT_USER_EMAIL` and `KEYCLOAK_DEFAULT_USER_PASSWORD` in our `.env` +file. + + + diff --git a/docs/images/add_roles/keycloak_assign_role_100_pages.png b/docs/images/add_roles/keycloak_assign_role_100_pages.png new file mode 100644 index 0000000..beb52d3 Binary files /dev/null and b/docs/images/add_roles/keycloak_assign_role_100_pages.png differ diff --git a/docs/images/add_roles/keycloak_service_accont_role_select_all.png b/docs/images/add_roles/keycloak_service_accont_role_select_all.png new file mode 100644 index 0000000..a0d3578 Binary files /dev/null and b/docs/images/add_roles/keycloak_service_accont_role_select_all.png differ diff --git a/docs/images/add_roles/keycloak_service_account_assign_role.png b/docs/images/add_roles/keycloak_service_account_assign_role.png new file mode 100644 index 0000000..23e9b70 Binary files /dev/null and b/docs/images/add_roles/keycloak_service_account_assign_role.png differ diff --git a/docs/images/add_roles/keycloak_service_account_list.png b/docs/images/add_roles/keycloak_service_account_list.png new file mode 100644 index 0000000..b98f52e Binary files /dev/null and b/docs/images/add_roles/keycloak_service_account_list.png differ diff --git a/docs/images/client_secret/docker_compose_client_id.png b/docs/images/client_secret/docker_compose_client_id.png new file mode 100644 index 0000000..162dae3 Binary files /dev/null and b/docs/images/client_secret/docker_compose_client_id.png differ diff --git a/docs/images/client_secret/keycloak_admin_cli.png b/docs/images/client_secret/keycloak_admin_cli.png new file mode 100644 index 0000000..5cf6fd7 Binary files /dev/null and b/docs/images/client_secret/keycloak_admin_cli.png differ diff --git a/docs/images/client_secret/keycloak_change_realm.png b/docs/images/client_secret/keycloak_change_realm.png new file mode 100644 index 0000000..ecf3bf4 Binary files /dev/null and b/docs/images/client_secret/keycloak_change_realm.png differ diff --git a/docs/images/client_secret/keycloak_get_client_secret.png b/docs/images/client_secret/keycloak_get_client_secret.png new file mode 100644 index 0000000..24a8636 Binary files /dev/null and b/docs/images/client_secret/keycloak_get_client_secret.png differ diff --git a/docs/images/client_secret/keycloak_set_authentication.png b/docs/images/client_secret/keycloak_set_authentication.png new file mode 100644 index 0000000..b2db4cb Binary files /dev/null and b/docs/images/client_secret/keycloak_set_authentication.png differ diff --git a/docs/images/keycloak_realms.png b/docs/images/keycloak_realms.png new file mode 100644 index 0000000..bddc0fc Binary files /dev/null and b/docs/images/keycloak_realms.png differ diff --git a/docs/images/keycloak_users.png b/docs/images/keycloak_users.png new file mode 100644 index 0000000..e5c31f3 Binary files /dev/null and b/docs/images/keycloak_users.png differ diff --git a/docs/images/redirect_uri/keycloak_add_new_redirect_uri.png b/docs/images/redirect_uri/keycloak_add_new_redirect_uri.png new file mode 100644 index 0000000..257040b Binary files /dev/null and b/docs/images/redirect_uri/keycloak_add_new_redirect_uri.png differ diff --git a/docs/images/redirect_uri/keycloak_add_redirect_uri.png b/docs/images/redirect_uri/keycloak_add_redirect_uri.png new file mode 100644 index 0000000..a3d88a8 Binary files /dev/null and b/docs/images/redirect_uri/keycloak_add_redirect_uri.png differ diff --git a/docs/keycloak-setup.md b/docs/keycloak-setup.md new file mode 100644 index 0000000..cb6e143 --- /dev/null +++ b/docs/keycloak-setup.md @@ -0,0 +1,76 @@ +# Keycloak Setup + +This guide explains how to configure the backend service to work with Keycloak. Throughout these instructions, we assume you are already logged in with the `admin` account. + +## Activate Authentication + +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: + + ![Change Realm](./images/client_secret/keycloak_change_realm.png) + +3. To enable authentication: + - 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: + + ![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). + + ![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: + +![Client ID in Docker Compose](./images/client_secret/docker_compose_client_id.png) + +## Set Roles for Backend Interaction + +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. + + ![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: + + ![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](./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: + + ![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. + +To do this: + +1. Navigate to the **Clients** tab in Keycloak. +2. Select the `app` client ID. + +![Navigate to app client ID](./images/redirect_uri/keycloak_add_redirect_uri.png) + +3. Go to the **Access Settings** section. +4. Under **Valid Redirect URIs**, add your desired redirect URI. + +![Add redirect URI](./images/redirect_uri/keycloak_add_new_redirect_uri.png) + +5. Click **Save** to apply your changes. + +This ensures your application can successfully handle authentication responses from Keycloak. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ba614e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module ips-lacpass-backend + +go 1.24 + +require ( + 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 + github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/swaggo/http-swagger v1.3.4 + github.com/swaggo/swag v1.16.4 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/swaggo/files v1.0.1 // 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 + golang.org/x/text v0.26.0 // indirect + golang.org/x/tools v0.34.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f2c0008 --- /dev/null +++ b/go.sum @@ -0,0 +1,117 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/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= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/httplog/v3 v3.2.2 h1:G0oYv3YYcikNjijArHFUlqfR78cQNh9fGT43i6StqVc= +github.com/go-chi/httplog/v3 v3.2.2/go.mod h1:N/J1l5l1fozUrqIVuT8Z/HzNeSy8TF2EFyokPLe6y2w= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +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/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= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/core/models.go b/internal/core/models.go new file mode 100644 index 0000000..d655de1 --- /dev/null +++ b/internal/core/models.go @@ -0,0 +1,40 @@ +package core + +type DocumentType string + +const ( + Passport DocumentType = "passport" + Identifier DocumentType = "identifier" +) + +var AllowedDocumenTypes = map[string]DocumentType{ + "identifier": Identifier, + "passport": Passport, +} + +type User struct { + ID string + Username string + Email string + FirstName string + LastName string + Locale string + DocumentType DocumentType + Identifier string +} + +type UserRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + PasswordConfirm string `json:"password_confirm" binding:"required"` + FirstName string `json:"first_name" binding:"required"` + LastName string `json:"last_name" binding:"required"` + Locale string `json:"locale" binding:"required"` + DocumentType DocumentType `json:"document_type" binding:"required"` + Identifier string `json:"identifier" binding:"required"` +} + +type UserUpdateRequest struct { + FirstName string `json:"first_name" binding:"required"` + LastName string `json:"last_name" binding:"required"` +} diff --git a/internal/core/outbound.go b/internal/core/outbound.go new file mode 100644 index 0000000..92f459c --- /dev/null +++ b/internal/core/outbound.go @@ -0,0 +1,10 @@ +package core + +import ( + "context" +) + +type UserRepository interface { + CreateUser(ctx context.Context, user User, password string) (*User, error) + UpdateUser(ctx context.Context, userUUID string, ur UserUpdateRequest) (*User, error) +} diff --git a/internal/core/service.go b/internal/core/service.go new file mode 100644 index 0000000..5d82ff4 --- /dev/null +++ b/internal/core/service.go @@ -0,0 +1,150 @@ +package core + +import ( + "context" + "errors" + "fmt" + customErrors "ips-lacpass-backend/internal/errors" + "ips-lacpass-backend/internal/repository/fhir" + "ips-lacpass-backend/internal/repository/vhl" + "sort" + + authMiddleware "ips-lacpass-backend/internal/middleware" +) + +type UserService struct { + Repository UserRepository +} + +type FhirService struct { + Repository fhir.FhirRepository +} + +type VhlService struct { + Repository vhl.VhlRepository +} + +func NewUserService(r UserRepository) UserService { + return UserService{ + Repository: r, + } +} + +func NewFhirService(r fhir.FhirRepository) FhirService { + return FhirService{ + Repository: r, + } +} + +func NewVhlService(r vhl.VhlRepository) VhlService { + return VhlService{ + Repository: r, + } +} + +func (us *UserService) CreateUser(ctx context.Context, ur UserRequest) (*User, error) { + + user := &User{ + Username: ur.Identifier, + Email: ur.Email, + FirstName: ur.FirstName, + LastName: ur.LastName, + Locale: ur.Locale, + DocumentType: ur.DocumentType, + Identifier: ur.Identifier, + } + + resp, err := us.Repository.CreateUser(ctx, *user, ur.Password) + if err != nil { + var uErr *customErrors.HttpError + if errors.As(err, &uErr) { + return nil, uErr + } + return nil, &customErrors.HttpError{ + StatusCode: 502, + Body: []map[string]interface{}{{"error": "auth_service_error", "message": "Failed to connect to authentication service"}}, Err: err, + } + } + return resp, nil +} + +func (us *UserService) UpdateUser(ctx context.Context, ur UserUpdateRequest) (*User, error) { + + userUUID, err := authMiddleware.GetUserUUIDFromContext(ctx) + if err != nil { + return nil, &customErrors.HttpError{ + StatusCode: 401, + Body: []map[string]interface{}{{"error": "user_uuid_not_found", "message": "User UUID not found in request context"}}, + Err: err, + } + } + + resp, err := us.Repository.UpdateUser(ctx, userUUID, ur) + if err != nil { + var uErr *customErrors.HttpError + if errors.As(err, &uErr) { + return nil, uErr + } + return nil, &customErrors.HttpError{ + StatusCode: 502, + Body: []map[string]interface{}{{"error": "auth_service_error", "message": "Failed to connect to authentication service"}}, Err: err, + } + } + return resp, nil +} + +func (fs *FhirService) GetIps(ctx context.Context) (map[string]interface{}, error) { + userId, err := authMiddleware.GetUserDocIDFromContext(ctx) + if err != nil { + return nil, &customErrors.HttpError{ + StatusCode: 401, + Body: []map[string]interface{}{{"error": "user_identifier_not_found", "message": "User identifier not found in request context"}}, + Err: err, + } + } + + bundle, err := fs.Repository.GetDocumentReference(userId) + if err != nil { + fmt.Printf("Error fetching document reference: %v\n", err) + return nil, err + } + entries := bundle.Entry + if len(entries) == 0 { + return nil, &customErrors.HttpError{ + StatusCode: 404, + Body: []map[string]interface{}{{"error": "not_found", "message": "No IPS found for the user"}}, + Err: fmt.Errorf("no IPS found for the user"), + } + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Resource.Meta.LastUpdated > entries[j].Resource.Meta.LastUpdated + }) + + ipsBundle, err := fs.Repository.GetIpsBundle(entries[0].Resource.Content[0].Attachment.URL) + if err != nil { + return nil, err + } + + return ipsBundle, nil + +} + +func (vs *VhlService) CreateQrCode(ctx context.Context, expiresOn *string, content *string, passCode *string) (*vhl.QrData, error) { + if content == nil || *content == "" { + return nil, &customErrors.HttpError{ + StatusCode: 400, + Body: []map[string]interface{}{{"error": "invalid_request", "message": "Content cannot be empty"}}, + Err: errors.New("content cannot be empty"), + } + } + + qrData, err := vs.Repository.CreateQr(ctx, vhl.CreateQrRequest{ + JsonContent: *content, + ExpiresOn: *expiresOn, + PassCode: *passCode, + }) + if err != nil { + return nil, err + } + return qrData, nil +} diff --git a/internal/docs/docs.go b/internal/docs/docs.go new file mode 100644 index 0000000..86f09d2 --- /dev/null +++ b/internal/docs/docs.go @@ -0,0 +1,41 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": {}, + "securityDefinitions": { + "BasicAuth": { + "type": "basic" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "0.1", + Host: "localhost:8091", + BasePath: "", + Schemes: []string{}, + Title: "Lacpass App API", + Description: "This is the official API for Lacpass mobile app.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/internal/docs/swagger.json b/internal/docs/swagger.json new file mode 100644 index 0000000..a305de1 --- /dev/null +++ b/internal/docs/swagger.json @@ -0,0 +1,16 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is the official API for Lacpass mobile app.", + "title": "Lacpass App API", + "contact": {}, + "version": "0.1" + }, + "host": "localhost:8091", + "paths": {}, + "securityDefinitions": { + "BasicAuth": { + "type": "basic" + } + } +} \ No newline at end of file diff --git a/internal/docs/swagger.yaml b/internal/docs/swagger.yaml new file mode 100644 index 0000000..52e41d0 --- /dev/null +++ b/internal/docs/swagger.yaml @@ -0,0 +1,11 @@ +host: localhost:8091 +info: + contact: {} + description: This is the official API for Lacpass mobile app. + title: Lacpass App API + version: "0.1" +paths: {} +securityDefinitions: + BasicAuth: + type: basic +swagger: "2.0" diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..37bd2a7 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,17 @@ +package errors + +import "fmt" + +type HttpError struct { + StatusCode int + Body []map[string]interface{} + Err error +} + +func (e *HttpError) Error() string { + return fmt.Sprintf("Http error: %d - %s", e.StatusCode, e.Err) +} + +func (e *HttpError) Unwrap() error { + return e.Err +} diff --git a/internal/handler/ips.go b/internal/handler/ips.go new file mode 100644 index 0000000..a2accdc --- /dev/null +++ b/internal/handler/ips.go @@ -0,0 +1,70 @@ +package handler + +import ( + "encoding/json" + "errors" + "ips-lacpass-backend/internal/core" + errors2 "ips-lacpass-backend/internal/errors" + "net/http" +) + +type IpsHandler struct { + FhirService core.FhirService +} + +func NewIpsHandler(s core.FhirService) *IpsHandler { + return &IpsHandler{ + FhirService: s, + } +} + +// GetIPS godoc +// +// @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 +// +// @Success 200 {object} fhir.Bundle +// @Failure 400 +// @Failure 404 +// @Failure 500 +// @Router /ips [get] +func (ih *IpsHandler) Get(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ips, err := ih.FhirService.GetIps(ctx) + 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 + } + + res, err := json.Marshal(ips) + if err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + 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/handler/user.go b/internal/handler/user.go new file mode 100644 index 0000000..1c0e32a --- /dev/null +++ b/internal/handler/user.go @@ -0,0 +1,277 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "ips-lacpass-backend/internal/core" + errors2 "ips-lacpass-backend/internal/errors" + "net/http" + "regexp" + "strings" + + "github.com/go-playground/validator/v10" +) + +type UserHandler struct { + UserService core.UserService +} + +func NewUserHandler(s core.UserService) *UserHandler { + return &UserHandler{UserService: s} +} + +var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") +var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") + +func toSnakeCase(str string) string { + snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") + snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") + return strings.ToLower(snake) +} + +func translateError(body []map[string]interface{}) []map[string]interface{} { + for _, m := range body { + if val, ok := m["error"]; ok { + m["error"] = toSnakeCase(val.(string)) + } + } + return body +} + +type UserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Locale string `json:"locale"` + DocumentType string `json:"document_type"` + Identifier string `json:"identifier"` +} + +type userCreationRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` + PasswordConfirm string `json:"password_confirm" validate:"required"` + FirstName string `json:"first_name,omitempty" validate:"required"` + LastName string `json:"last_name,omitempty" validate:"required"` + Locale string `json:"locale" validate:"required,oneof=es en pt-br"` + DocumentType string `json:"document_type" validate:"required,oneof=passport identifier"` + Identifier string `json:"identifier" validate:"required"` +} + +type userUpdateRequest struct { + FirstName string `json:"first_name,omitempty" validate:"required"` + LastName string `json:"last_name,omitempty" validate:"required"` +} + +// Create User godoc +// +// @Summary Register a new Keycloak user +// @Description Register a new Keycloak user +// @Tags Users +// @Accept json +// @Produce json +// +// @Param user body core.UserRequest true "New user parameters" +// +// @Success 201 {object} UserResponse +// @Failure 400 +// @Failure 404 +// @Failure 500 +// @Router /users [post] +func (u *UserHandler) Create(w http.ResponseWriter, r *http.Request) { + var body userCreationRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + validate := validator.New() + err := validate.Struct(body) + if err != nil { + var verr []map[string]string + for _, err := range err.(validator.ValidationErrors) { + name := toSnakeCase(err.Field()) + switch err.Tag() { + case "required": + verr = append(verr, map[string]string{ + "error": fmt.Sprintf("missing_%s", name), + "error_description": fmt.Sprintf("Missing required field: %s", err.Field()), + }) + case "email": + verr = append(verr, map[string]string{ + "error": fmt.Sprintf("invalid_%s", name), + "error_description": fmt.Sprintf("Invalid %s type", strings.ReplaceAll(name, "_", " ")), + }) + case "oneof": + verr = append(verr, map[string]string{ + "error": fmt.Sprintf("invalid_%s", name), + "error_description": fmt.Sprintf("Invalid %s. Must be either %s", strings.ReplaceAll(name, "_", " "), strings.ReplaceAll(err.Param(), " ", " or ")), + }) + } + } + res, err := json.Marshal(verr) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusBadRequest) + w.Write(res) + return + } + + if body.Password != body.PasswordConfirm { + w.WriteHeader(http.StatusBadRequest) + res, err := json.Marshal([]map[string]string{ + { + "error": "invalid_password_confirm", + "error_description": "Password and password confirmation do not match", + }, + }) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Write(res) + } + + user, err := u.UserService.CreateUser(r.Context(), core.UserRequest{ + Email: body.Email, + Password: body.Password, + PasswordConfirm: body.PasswordConfirm, + FirstName: body.FirstName, + LastName: body.LastName, + Locale: body.Locale, + DocumentType: core.AllowedDocumenTypes[body.DocumentType], + Identifier: body.Identifier, + }) + + if err != nil { + var cuErr *errors2.HttpError + if errors.As(err, &cuErr) { + res, err := json.Marshal(translateError(cuErr.Body)) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if cuErr.StatusCode == 409 { + // TODO Conflict error, user already exists. Cannot give this details to the user. + res = []byte(`[{"error":"user_already_exists","error_description":"User already exists"}]`) + } + w.WriteHeader(cuErr.StatusCode) + w.Write(res) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + res, err := json.Marshal( + &UserResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + Locale: user.Locale, + DocumentType: string(user.DocumentType), + Identifier: user.Identifier, + }) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + w.Write(res) +} + +// UpdateUser godoc +// +// @Summary Update user profile +// @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" +// +// @Security ApiKeyAuth +// +// @Success 200 {object} UserResponse +// @Failure 400 +// @Failure 404 +// @Failure 500 +// @Router /users/auth/update [put] +func (u *UserHandler) Update(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + var body userUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + validate := validator.New() + err := validate.Struct(body) + if err != nil { + var verr []map[string]string + for _, err := range err.(validator.ValidationErrors) { + name := toSnakeCase(err.Field()) + switch err.Tag() { + case "required": + verr = append(verr, map[string]string{ + "error": fmt.Sprintf("missing_%s", name), + "error_description": fmt.Sprintf("Missing required field: %s", err.Field()), + }) + } + } + res, err := json.Marshal(verr) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusBadRequest) + w.Write(res) + return + } + + user, err := u.UserService.UpdateUser(r.Context(), core.UserUpdateRequest{ + FirstName: body.FirstName, + LastName: body.LastName, + }) + + if err != nil { + var cuErr *errors2.HttpError + if errors.As(err, &cuErr) { + res, err := json.Marshal(translateError(cuErr.Body)) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(cuErr.StatusCode) + w.Write(res) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + res, err := json.Marshal( + &UserResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + Locale: user.Locale, + DocumentType: string(user.DocumentType), + Identifier: user.Identifier, + }) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + w.Write(res) +} diff --git a/internal/handler/vhl.go b/internal/handler/vhl.go new file mode 100644 index 0000000..45f361d --- /dev/null +++ b/internal/handler/vhl.go @@ -0,0 +1,94 @@ +package handler + +import ( + "encoding/json" + "errors" + "ips-lacpass-backend/internal/core" + customErrors "ips-lacpass-backend/internal/errors" + "net/http" +) + +type VhlHandler struct { + VhlService core.VhlService +} + +func NewVhlHandler(s core.VhlService) *VhlHandler { + return &VhlHandler{ + VhlService: s, + } +} + +type VhlRequest struct { + ExpiresOn string `json:"expires_on,omitempty"` + Content string `json:"content,required"` + PassCode string `json:"pass_code,omitempty"` +} + +type VhlResponse struct { + Data string `json:"data"` +} + +// Create QR data godoc +// +// @Summary Create QR data. +// @Description Create QR data from VHL issuance. +// @Tags IPS FHIR +// @Accept json +// @Produce json +// +// @Param data body VhlRequest true "Data parameters" +// +// @Security ApiKeyAuth +// +// @Success 200 {object} VhlResponse +// @Failure 400 +// @Failure 404 +// @Failure 500 +// @Router /qr [post] +func (vh *VhlHandler) Create(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // TODO check if user is authenticated and has the permission to create a QR code + + // TODO throw correct error body + var body VhlRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + qr, err := vh.VhlService.CreateQrCode(ctx, &body.ExpiresOn, &body.Content, &body.PassCode) + if err != nil { + var httpErr *customErrors.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 + } + + res, err := json.Marshal(&VhlResponse{ + Data: qr.Value, + }) + if err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + 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/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..cf07df8 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,224 @@ +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +type AuthMiddleware struct { + BaseURL string + WebKeySetsUrl string + Realm string + KeySet jwk.Set + Issuer string +} + +func GetUserDocIDFromContext(ctx context.Context) (string, error) { + userId, ok := ctx.Value("userDocId").(string) + if !ok { + return "", fmt.Errorf("user identifier not found in request context") + } + return userId, nil + +} + +func GetUserUUIDFromContext(ctx context.Context) (string, error) { + userUUID, ok := ctx.Value("userUUID").(string) + if !ok { + return "", fmt.Errorf("user UUID not found in request context") + } + return userUUID, nil + +} + +func NewAuthMiddleware(baseURL string, realm string, hostName string) *AuthMiddleware { + WebKeySetsUrl := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", baseURL, realm) + + keySet, err := jwk.Fetch(context.Background(), WebKeySetsUrl) + if err != nil { + fmt.Println(err) + fmt.Println("All authenticated requests will be rejected until the JWKS is available") + } + + return &AuthMiddleware{ + BaseURL: baseURL, + WebKeySetsUrl: WebKeySetsUrl, + Realm: realm, + KeySet: keySet, + Issuer: fmt.Sprintf("%s/realms/%s", hostName, realm), + } +} + +func (kam *AuthMiddleware) RefreshKeySet(interval time.Duration) { + var mu sync.Mutex + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + keySet, err := jwk.Fetch(context.Background(), kam.WebKeySetsUrl) + if err != nil { + fmt.Printf("Error fetching JWKS from %s: \n", kam.WebKeySetsUrl, err) + continue + } + + mu.Lock() + kam.KeySet = keySet + mu.Unlock() + fmt.Printf("Refreshed JWKS from %s\n", kam.WebKeySetsUrl) + } + }() +} + +func WriteError(w http.ResponseWriter, httpErr []map[string]string) { + w.WriteHeader(http.StatusUnauthorized) + res, err := json.Marshal(httpErr) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + _, err = w.Write(res) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } +} + +func (kam *AuthMiddleware) Authenticator(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if kam.KeySet == nil { + httpError := []map[string]string{ + { + "error": "key_set_not_available", + "error_description": "Key set is not available, please try again later", + }, + } + WriteError(w, httpError) + return + } + ah := r.Header.Get("Authorization") + if ah == "" { + httpError := []map[string]string{ + { + "error": "missing_authorization_header", + "error_description": "Missing Authorization header in request", + }, + } + WriteError(w, httpError) + return + } + + ts := strings.TrimPrefix(ah, "Bearer ") + if ts == ah { + httpError := []map[string]string{ + { + "error": "bad_formatted_authorization_header", + "error_description": "Missing bearer prefix in authorization header", + }, + } + WriteError(w, httpError) + return + } + + token, err := jwt.Parse([]byte(ts), jwt.WithKeySet(kam.KeySet), jwt.WithValidate(true)) + if err != nil { + httpError := []map[string]string{ + { + "error": "invalid_token", + "error_description": "Invalid token or signature", + }, + } + WriteError(w, httpError) + return + } + + if token.Issuer() != kam.Issuer { + httpError := []map[string]string{ + { + "error": "invalid_token_issuer", + "error_description": "Invalid issuer in token", + }, + } + WriteError(w, httpError) + return + } + + userId, _ := token.PrivateClaims()["identifier"].(string) + if userId == "" { + httpError := []map[string]string{ + { + "error": "token_user_id_not_found", + "error_description": "Token does not contain user identifier", + }, + } + WriteError(w, httpError) + return + } + + realmAccess, ok := token.PrivateClaims()["realm_access"].(map[string]interface{}) + if !ok { + httpError := []map[string]string{ + { + "error": "token_realm_access_not_found", + "error_description": "Token does not contain realm access information", + }, + } + WriteError(w, httpError) + return + } + + ri, ok := realmAccess["roles"].([]interface{}) + if !ok { + httpError := []map[string]string{ + { + "error": "token_roles_not_found", + "error_description": "Token does not contain roles information", + }, + } + WriteError(w, httpError) + return + } + var roles []string + for _, role := range ri { + strRole, ok := role.(string) + if !ok { + httpError := []map[string]string{ + { + "error": "token_invalid_role", + "error_description": "Token contains an invalid role format", + }, + } + WriteError(w, httpError) + return + } + roles = append(roles, strRole) + } + + userUUID := token.Subject() + if userUUID == "" { + httpError := []map[string]string{ + { + "error": "token_user_uuid_not_found", + "error_description": "Token does not contain user UUID", + }, + } + WriteError(w, httpError) + return + } + + ctx := context.WithValue(r.Context(), "userDocId", userId) + ctx = context.WithValue(ctx, "roles", roles) + ctx = context.WithValue(ctx, "userUUID", userUUID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/repository/fhir/client.go b/internal/repository/fhir/client.go new file mode 100644 index 0000000..fef2b82 --- /dev/null +++ b/internal/repository/fhir/client.go @@ -0,0 +1,30 @@ +package fhir + +import ( + "fmt" + "ips-lacpass-backend/internal/errors" + "net/http" +) + +type FhirRepository struct { + Client *http.Client + BaseURL string +} + +func (c *FhirRepository) GetDocumentReference(identifier string) (*Bundle, error) { + // TODO: To be implemented by the participant + return nil, &errors.HttpError{ + StatusCode: 500, + Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}}, + Err: fmt.Errorf("failed to get document reference"), + } +} + +func (c *FhirRepository) GetIpsBundle(url string) (map[string]interface{}, error) { + // TODO: To be implemented by the participant + return nil, &errors.HttpError{ + StatusCode: 500, + Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}}, + Err: fmt.Errorf("failed to get Ips Bundle"), + } +} diff --git a/internal/repository/fhir/models.go b/internal/repository/fhir/models.go new file mode 100644 index 0000000..1467507 --- /dev/null +++ b/internal/repository/fhir/models.go @@ -0,0 +1,115 @@ +package fhir + +type Bundle struct { + ResourceType string `json:"resourceType"` + ID string `json:"id,omitempty"` + Meta *Meta `json:"meta,omitempty"` + Identifier *Identifier `json:"identifier,omitempty"` + Type string `json:"type"` + Timestamp string `json:"timestamp,omitempty"` + Total int `json:"total,omitempty"` + Link []BundleLink `json:"link,omitempty"` + Entry []BundleEntry `json:"entry,omitempty"` + Signature *Signature `json:"signature,omitempty"` +} + +type BundleLink struct { + Relation string `json:"relation"` + URL string `json:"url"` +} + +type BundleEntry struct { + FullURL string `json:"fullUrl"` + Resource *EntryResource `json:"resource,omitempty"` + Search *BundleSearch `json:"search,omitempty"` +} + +type BundleSearch struct { + Mode string `json:"mode"` +} + +type EntryResource struct { + ResourceType string `json:"resourceType"` + ID string `json:"id,omitempty"` + Meta *Meta `json:"meta,omitempty"` + Text *ResourceText `json:"text,omitempty"` + MasterIdentifier *Identifier `json:"masterIdentifier,omitempty"` + Identifier []Identifier `json:"identifier,omitempty"` + Status string `json:"status"` + Type interface{} `json:"type,omitempty"` // This is an any type, can be a string or an object + Subject *Reference `json:"subject"` + Author []Reference `json:"author,omitempty"` + Title string `json:"title,omitempty"` + Date string `json:"date,omitempty"` + Confidentiality string `json:"confidentiality,omitempty"` + Custodian *Reference `json:"custodian,omitempty"` + Content []DocumentContent `json:"content,omitempty"` + Section []EntrySection `json:"section,omitempty"` +} + +type ResourceText struct { + Status string `json:"status"` + Div string `json:"div,omitempty"` +} + +type EntryType struct { + Coding []Coding `json:"coding"` +} + +type Coding struct { + System string `json:"system"` + Code string `json:"code"` + Display string `json:"display,omitempty"` +} + +type EntrySection struct { + Title string `json:"title,omitempty"` + Code *EntryType `json:"code,omitempty"` + Text *ResourceText `json:"text,omitempty"` + Entry []Reference `json:"entry,omitempty"` +} + +type Meta struct { + VersionID string `json:"versionId,omitempty"` + LastUpdated string `json:"lastUpdated,omitempty"` + Source string `json:"source,omitempty"` + Profile []string `json:"profile,omitempty"` +} + +type Identifier struct { + System string `json:"system,omitempty"` + Value string `json:"value,omitempty"` + Use string `json:"use,omitempty"` + Type *EntryType `json:"type,omitempty"` +} + +type Reference struct { + Reference string `json:"reference"` + Type string `json:"type,omitempty"` + Display string `json:"display,omitempty"` +} + +type DocumentContent struct { + Attachment Attachment `json:"attachment"` +} + +type Attachment struct { + ContentType string `json:"contentType"` + URL string `json:"url"` +} + +type Signature struct { + Type []SignatureType `json:"type"` + When string `json:"when"` + Who *SignatureWho `json:"who"` + Data string `json:"data"` +} + +type SignatureType struct { + System string `json:"system"` + Code string `json:"code"` +} + +type SignatureWho struct { + Identifier Identifier `json:"identifier"` +} diff --git a/internal/repository/keycloak/client.go b/internal/repository/keycloak/client.go new file mode 100644 index 0000000..5cc4eab --- /dev/null +++ b/internal/repository/keycloak/client.go @@ -0,0 +1,297 @@ +package keycloak + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "ips-lacpass-backend/internal/core" + "ips-lacpass-backend/internal/errors" + "net/http" + "net/url" + "strconv" + "strings" +) + +func NewKeycloakClient(baseURL, realm, clientId, clientSecret, emailRedirectUri, emailClientId string, emailLifeSpan int) core.UserRepository { + return &Client{ + Client: &http.Client{}, + BaseURL: baseURL, + Realm: realm, + AdminClientID: clientId, + ClientSecret: clientSecret, + EmailRedirectURI: emailRedirectUri, + EmailClientID: emailClientId, + EmailLifespan: emailLifeSpan, + } +} + +func (kc *Client) getAccessToken() (*TokenResponse, error) { + tokenEndpoint := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", kc.BaseURL, kc.Realm) + + data := url.Values{} + data.Set("client_id", kc.AdminClientID) + data.Set("client_secret", kc.ClientSecret) + data.Set("grant_type", "client_credentials") + + resp, err := http.PostForm(tokenEndpoint, data) + if err != nil { + return nil, fmt.Errorf("failed to connect to Keycloak: %w", err) + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + fmt.Errorf("failed to close response body: %s", err.Error()) + } + }(resp.Body) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to obtain token: %s", string(body)) + } + + var token TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + return nil, fmt.Errorf("failed to decode token response: %w", err) + } + + return &token, nil +} + +func (kc *Client) sendValidationEmail(ctx context.Context, userID string) error { + actions := []string{"VERIFY_EMAIL"} + body, err := json.Marshal(actions) + if err != nil { + return fmt.Errorf("failed to marshal actions: %w", err) + } + + ku := fmt.Sprintf("%s/admin/realms/%s/users/%s/execute-actions-email", kc.BaseURL, kc.Realm, userID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, ku, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + q := req.URL.Query() + q.Add("client_id", kc.EmailClientID) + q.Add("lifespan", strconv.Itoa(kc.EmailLifespan)) + q.Add("redirect_uri", kc.EmailRedirectURI) + req.URL.RawQuery = q.Encode() + + t, err := kc.getAccessToken() + if err != nil { + return fmt.Errorf("failed to get access token: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken)) + + resp, err := kc.Client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + var errorResponse map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { + return fmt.Errorf("failed to decode error response: %w", err) + } + return &errors.HttpError{ + StatusCode: resp.StatusCode, + Body: []map[string]interface{}{errorResponse}, + Err: err, + } + } + + return nil +} + +func (kc *Client) CreateUser(ctx context.Context, user core.User, password string) (*core.User, error) { + r := UserRegistrationRequest{ + Username: user.Identifier, + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + Enabled: true, + Attributes: map[string][]string{ + "locale": {user.Locale}, + "document_type": {string(user.DocumentType)}, + "identifier": {user.Identifier}, + }, + Credentials: []UserCredential{ + { + Type: "password", + Value: password, + Temporary: false, + }, + }, + } + + body, err := json.Marshal(r) + if err != nil { + return nil, fmt.Errorf("failed to marshal user payload: %w", err) + } + + ku := fmt.Sprintf("%s/admin/realms/%s/users", kc.BaseURL, kc.Realm) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ku, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + t, err := kc.getAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken)) + + resp, err := kc.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + fmt.Println("failed to close response body: %v", err) + } + }(resp.Body) + + if resp.StatusCode != http.StatusCreated { + var errorResponse map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { + return nil, fmt.Errorf("failed to decode error response: %w", err) + } + return nil, &errors.HttpError{ + StatusCode: resp.StatusCode, + Body: []map[string]interface{}{errorResponse}, + Err: err, + } + } + + location := resp.Header.Get("Location") + if location == "" { + return nil, fmt.Errorf("user created but Location header is missing") + } + + parts := strings.Split(location, "/") + userID := parts[len(parts)-1] + fmt.Println("New user ID:", userID) + + go func() { + newCtx := context.Background() + if err := kc.sendValidationEmail(newCtx, userID); err != nil { + // Log the error but don't return it since this is running asynchronously + fmt.Println("Error sending validation email: %v", err) + } + }() + // Return the first user since we're querying by username/email + return &core.User{ + ID: userID, + Username: user.Identifier, + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + Locale: user.Locale, + DocumentType: user.DocumentType, + Identifier: user.Identifier, + }, nil +} + +func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserUpdateRequest) (*core.User, error) { + // Get UserRepresentation from keycloack + ku := fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ku, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + t, err := kc.getAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken)) + resp, err := kc.Client.Do(req) + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + fmt.Println("failed to close response body: %w", err) + } + }(resp.Body) + if resp.StatusCode != http.StatusOK { + var errorResponse map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { + return nil, fmt.Errorf("failed to decode error response: %w", err) + } + return nil, &errors.HttpError{ + StatusCode: resp.StatusCode, + Body: []map[string]interface{}{errorResponse}, + Err: err, + } + } + var userRep UserRegistration + if err := json.NewDecoder(resp.Body).Decode(&userRep); err != nil { + return nil, fmt.Errorf("failed to decode user response: %w", err) + } + + // Edit User representation + // For now, only firstName and lastName can be edited + changed := false + if ur.FirstName != userRep.FirstName { + userRep.FirstName = ur.FirstName + changed = true + } + if ur.LastName != userRep.LastName { + userRep.LastName = ur.LastName + changed = true + } + if !changed { + return nil, &errors.HttpError{ + StatusCode: http.StatusBadRequest, + Body: []map[string]interface{}{{"error": "update_user_no_change", "message": "update request does not change user attributes"}}, + } + } + + //save updated user representation + body, err := json.Marshal(userRep) + if err != nil { + return nil, fmt.Errorf("failed to marshal updated user payload: %w", err) + } + ku = fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID) + req, err = http.NewRequestWithContext(ctx, http.MethodPut, ku, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken)) + + resp, err = kc.Client.Do(req) + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + fmt.Println("failed to close response body: %w", err) + } + }(resp.Body) + if resp.StatusCode != http.StatusNoContent { + var errorResponse map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { + return nil, fmt.Errorf("failed to decode error response: %w", err) + } + return nil, &errors.HttpError{ + StatusCode: resp.StatusCode, + Body: []map[string]interface{}{errorResponse}, + Err: err, + } + } + return &core.User{ + ID: userRep.ID, + Username: userRep.Username, + Email: userRep.Email, + FirstName: ur.FirstName, + LastName: ur.LastName, + Locale: userRep.Attributes["locale"][0], + DocumentType: core.AllowedDocumenTypes[userRep.Attributes["document_type"][0]], + Identifier: userRep.Attributes["identifier"][0], + }, nil +} diff --git a/internal/repository/keycloak/models.go b/internal/repository/keycloak/models.go new file mode 100644 index 0000000..494c2a8 --- /dev/null +++ b/internal/repository/keycloak/models.go @@ -0,0 +1,57 @@ +package keycloak + +import ( + "net/http" +) + +type Client struct { + Client *http.Client + BaseURL string + Realm string + AdminClientID string + ClientSecret string + EmailRedirectURI string + EmailLifespan int + EmailClientID string +} + +type UserRegistration struct { + ID string `json:"id,omitempty"` + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Attributes map[string][]string `json:"attributes,omitempty"` + Credentials []Credential `json:"credentials,omitempty"` +} + +type Credential struct { + Type string `json:"type"` + Value string `json:"value,omitempty"` + Temporary bool `json:"temporary"` + CreatedDate int64 `json:"createdDate,omitempty"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + RefreshExpiresIn int `json:"refresh_expires_in"` + TokenType string `json:"token_type"` +} + +type UserRegistrationRequest struct { + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Enabled bool `json:"enabled"` + Attributes map[string][]string `json:"attributes"` + Credentials []UserCredential `json:"credentials"` +} + +type UserCredential struct { + Type string `json:"type"` + Value string `json:"value"` + Temporary bool `json:"temporary"` +} diff --git a/internal/repository/vhl/client.go b/internal/repository/vhl/client.go new file mode 100644 index 0000000..9d7e70d --- /dev/null +++ b/internal/repository/vhl/client.go @@ -0,0 +1,22 @@ +package vhl + +import ( + "context" + "fmt" + "ips-lacpass-backend/internal/errors" + "net/http" +) + +type VhlRepository struct { + Client *http.Client + BaseURL string +} + +func (c *VhlRepository) CreateQr(ctx context.Context, body CreateQrRequest) (*QrData, error) { + // TODO: To be implemented by the participant + return nil, &errors.HttpError{ + StatusCode: 500, + Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}}, + Err: fmt.Errorf("failed to create VHL QR"), + } +} diff --git a/internal/repository/vhl/models.go b/internal/repository/vhl/models.go new file mode 100644 index 0000000..7ec5b7a --- /dev/null +++ b/internal/repository/vhl/models.go @@ -0,0 +1,11 @@ +package vhl + +type CreateQrRequest struct { + ExpiresOn string `json:"expiresOn,omitempty"` + JsonContent string `json:"jsonContent,required"` + PassCode string `json:"passCode,omitempty"` +} + +type QrData struct { + Value string +} diff --git a/scripts/auth.sh b/scripts/auth.sh new file mode 100644 index 0000000..2e7b310 --- /dev/null +++ b/scripts/auth.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +source ./.env + +if ! command -v jq >/dev/null 2>&1 +then + echo "JQ could not be found" + exit 1 +fi + +TOKEN_ENDPOINT="$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token" + +get_access_token() { + RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$KEYCLOAK_CLIENT_ID" \ + -d "username=$KEYCLOAK_DEFAULT_USER" \ + -d "password=$KEYCLOAK_DEFAULT_USER_PASSWORD" \ + -d "scope=openid" \ + -d "grant_type=password") + + if [ $? -ne 0 ]; then + echo "Error: Failed to connect to Keycloak." + exit 1 + fi + + # Extract the access token from the JSON response using jq + ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token) + REFRESH_TOKEN=$(echo "$RESPONSE" | jq -r .refresh_token) + mkdir -p ./tmp + echo -n "$REFRESH_TOKEN" > ./tmp/refresh_token + + # Check if an access token was returned + if [[ -z "${ACCESS_TOKEN}" || "$ACCESS_TOKEN" == null ]]; then + echo "Error: Failed to obtain access token. Check your credentials and client configuration." + echo "Response from Keycloak: $RESPONSE" + exit 1 + fi + + echo "Successfully logged in!" + echo "Access Token: $ACCESS_TOKEN" + exit 0 +} + +get_refresh_token() { + REFRESH_TOKEN=$(cat ./tmp/refresh_token) + if [[ -z "${REFRESH_TOKEN}" || "$REFRESH_TOKEN" == null ]]; then + echo "Could not find refresh token, getting a new access token" + get_access_token + fi + RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$KEYCLOAK_CLIENT_ID" \ + -d "refresh_token=$REFRESH_TOKEN" \ + -d "grant_type=refresh_token") + + if [ $? -ne 0 ]; then + echo "Error: Failed to connect to Keycloak." + exit 1 + fi + + # Extract the access token from the JSON response using jq + ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token) + + # Check if an access token was returned + if [[ -z "${ACCESS_TOKEN}" || "$ACCESS_TOKEN" == null ]]; then + echo "Error: Failed to refresh token. Check your credentials and client configuration." + echo "Response from Keycloak: $RESPONSE" + exit 1 + fi + + echo "Successfully refreshed token!" + echo "Access Token: $ACCESS_TOKEN" + exit 0 +} + +logout() { + LOGOUT_ENDPOINT="$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/logout" + REFRESH_TOKEN=$(cat ./tmp/refresh_token) + if [[ -z "${REFRESH_TOKEN}" || "$REFRESH_TOKEN" == null ]]; then + echo "Could not find refresh token, cannot logout" + exit 1 + fi + RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$KEYCLOAK_CLIENT" \ + -d "refresh_token=$REFRESH_TOKEN" \ + "$LOGOUT_ENDPOINT") + if [ "$RESPONSE" -eq 204 ]; then + echo "Success: Logout successful. The refresh token has been invalidated." + exit 0 + elif [ "$RESPONSE" -eq 400 ]; then + echo "Error: Bad Request (400). The refresh token was likely invalid or already revoked." + elif [ "$RESPONSE" -eq 401 ]; then + echo "Error: Unauthorized (401). Check if your KEYCLOAK_CLIENT_SECRET is correct." + else + echo "Error: An unexpected error occurred. Keycloak responded with HTTP status $RESPONSE." + fi + exit 1 +} + +case $1 in + access-token) + get_access_token + ;; + refresh-token) + get_refresh_token + ;; + logout) + logout + ;; +esac +