diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0d89631..70e356f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -38,7 +38,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '>= 1.21.5' + go-version: '>= 1.23.0' - name: build run: | diff --git a/Dockerfile b/Dockerfile index dc5047c..a19a274 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ -FROM --platform=${BUILDPLATFORM} golang:1.25-alpine AS build +FROM --platform=${BUILDPLATFORM} golang:1.26-alpine AS build ARG BUILDPLATFORM +ARG TARGETPLATFORM ARG TARGETOS ARG TARGETARCH @@ -14,7 +15,7 @@ COPY . . RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o did-helper . -FROM --platform=${BUILDPLATFORM} alpine:3.21 +FROM alpine:3.23 ENV KEY_TYPE_TO_GENERATE="EC" diff --git a/README.md b/README.md index adbca94..5e4f2de 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,15 @@ The container can be configured, using the following environment-variables: | KEY_PATH | Path to the key PEM certificate | string | | OUTPUT_FORMAT | Output format for the did result file. | "json", "env", "json_jwk" | "json" | | OUTPUT_FILE | File to write the did, format depends on the requested format. Will not write the file if empty. | string | "/cert/did.json" | -| DID_TYPE | Type of the did to generate. | "key", "jwk" or "web" | "key" | +| DID_TYPE | Type of the did to generate. | "key", "jwk", "web" or "keycloak" | "key" | | KEY_TYPE | Type of the key provided. | "P-256", "P-384" or "ED-25519" | "P-256" | | HOST_URL | Base URL where the DID document will be located, excluding 'did.json'. (e.g., https://example.com/alice for https://example.com/alice/did.json). Required for did:web | | | CERT_URL | URL to retrieve the public certificate | string | `HOST_URL` + `/.well-known/tls.crt` | RUN_SERVER | Run a server with /did.json and /.well-known/tls.crt endpoints | false | SERVER_PORT | Server port | 8080 | +| KEYCLOAK_HOST | Base URL of the Keycloak instance used to fetch JWKS for realm-based DID documents (e.g., `https://keycloak.example.com`). Required when `DID_TYPE=keycloak`. | string | | +| KEYCLOAK_REALM | Fixed Keycloak realm. When set with `DID_TYPE=keycloak`, serves a static DID document for this realm at the path derived from `HOST_URL`, instead of the dynamic `/{realm}/did.json` pattern. | string | | +| IGNORE_TLS_VALIDATION | Disable TLS certificate validation when connecting to Keycloak. Do not use in production. | "true", "false" | "false" | | KEY_TYPE_TO_GENERATE | Type of the key to be generated. RSA is only supported for did:jwk | "EC", "ED-25519" or "RSA" | "EC" | | KEY_ALIAS | Alias for the key inside the keystore | string | "myAlias" | | COUNTRY | Country to be set for the created certificate. | string | "DE" | @@ -128,4 +131,45 @@ Usage of ./did-helper: Server port. Default 8080. (env SERVER_PORT) (default 8080) -server Run a server with /did.json and /.well-known/tls.crt endpoints. (env RUN_SERVER) + -keycloakHost string + URL of the Keycloak instance used to construct the OIDC discovery and JWKS endpoints for the realms. (env KEYCLOAK_HOST) + -keycloakRealm string + Fixed Keycloak realm. When set with didType=keycloak, serves a fixed DID document for this realm at the path derived from hostUrl. (env KEYCLOAK_REALM) + -ignoreTlsValidation + Disable TLS validation when connecting to Keycloak. Do not use it in production. (env IGNORE_TLS_VALIDATION) (default false) +``` + +## Keycloak Integration + +The helper supports reading key material directly from a [Keycloak](https://www.keycloak.org/) instance. When configured with `-didType=keycloak`, the server exposes DID documents based on Keycloak realms using the following URL pattern: + +``` +/{realm}/did.json +``` + +Where `{realm}` is the **base64-encoded** name of the Keycloak realm. The helper will decode it, fetch the JWKS from the corresponding Keycloak realm and build the DID document accordingly. + +### Configuration + +| Parameter | Env Var | Description | +|-----------|---------|-------------| +| `-didType=keycloak` | `DID_TYPE=keycloak` | Enables Keycloak mode | +| `-keycloakHost` | `KEYCLOAK_HOST` | Base URL of the Keycloak instance (e.g., `https://keycloak.example.com`) | +| `-keycloakRealm` | `KEYCLOAK_REALM` | Fixed realm name. When set, the server exposes a single static DID document at the path derived from `-hostUrl` (e.g., `/my/path/did.json`) instead of the dynamic `/{realm}/did.json` pattern. The certificate is available at `/.well-known/tls.crt` under the same base path. | +| `-ignoreTlsValidation` | `IGNORE_TLS_VALIDATION` | Disable TLS certificate validation when connecting to Keycloak. Do not use in production. | + +### Example + +```shell +./did-helper -didType=keycloak -keycloakHost=https://keycloak.example.com -server +``` + +Once running, the DID document for a realm `my-realm` would be available at: + +```shell +# Encode the realm id in base64 +REALM_B64=$(echo -n "my-realm" | base64) + +# Request the DID document +curl http://localhost:8080/${REALM_B64}/did.json ``` diff --git a/did/cert_utils.go b/did/cert_utils.go index 0ea4781..d3bb7d2 100644 --- a/did/cert_utils.go +++ b/did/cert_utils.go @@ -6,6 +6,7 @@ import ( "fmt" "os" + "go.uber.org/zap" "software.sslmate.com/src/go-pkcs12" ) @@ -92,3 +93,25 @@ func LoadCertsConfigFromPem(config *Config) (err error) { } return } + +func LoadCertificates(config *Config) (err error) { + var source string + if config.KeyPath != "" || config.CertPath != "" { + err = LoadCertsConfigFromPem(config) + } else { + config.Certificates.PrivateKey, config.Certificates.PublicKey, err = GetCertFromKeyStore(config.KeystorePath, config.KeystorePassword) + } + if err != nil { + zap.L().Sugar().Warnf("Was not able to load certs from %s %s", source, config.KeystorePath, "error", err) + return err + } + return nil +} + +func GetCert(config Config) (certRaw []byte, err error) { + pemBlock := &pem.Block{ + Type: "CERTIFICATE", + Bytes: config.Certificates.PublicKey.Raw, + } + return pem.EncodeToMemory(pemBlock), nil +} diff --git a/did/did.go b/did/did.go index 034ba0c..d6c33e8 100644 --- a/did/did.go +++ b/did/did.go @@ -21,22 +21,25 @@ type VerificationMethod struct { } type Config struct { - KeystorePath string `flag:"keystorePath" default:"" usage:"Path to the keystore to be read."` - KeystorePassword string `flag:"keystorePassword" default:"" usage:"Password for the keystore."` - CertPath string `flag:"certPath" default:"" usage:"Path to the PEM certificate."` - KeyPath string `flag:"keyPath" default:"" usage:"Path to the key PEM certificate."` - OutputFormat string `flag:"outputFormat" default:"json" usage:"Output format for the DID result file. Can be json, env or json_jwk."` - OutputFile string `flag:"outputFile" default:"" usage:"File to write the DID; will not write if empty."` - DidType string `flag:"didType" default:"key" usage:"Type of the DID to generate. did:key and did:jwk are supported."` - KeyType string `flag:"keyType" default:"P-256" usage:"Type of the DID key to be created. Supported: ED-25519, P-256, P-384."` - HostUrl string `flag:"hostUrl" default:"" usage:"Base URL where the DID document will be located, excluding 'did.json'."` - CertUrl string `flag:"certUrl" default:"" usage:"URL to retrieve the public certificate. Defaults to 'hostUrl' + /.well-known/tls.crt"` - RunServer bool `flag:"server" default:"false" usage:"Run a server with /did.json and /.well-known/tls.crt endpoints."` - ServerPort int `flag:"port" default:"8080" usage:"Server port. Default 8080."` - Certificates Certificates `flag:""` + KeystorePath string `flag:"keystorePath" default:"" usage:"Path to the keystore to be read."` + KeystorePassword string `flag:"keystorePassword" default:"" usage:"Password for the keystore."` + CertPath string `flag:"certPath" default:"" usage:"Path to the PEM certificate."` + KeyPath string `flag:"keyPath" default:"" usage:"Path to the key PEM certificate."` + OutputFormat string `flag:"outputFormat" default:"json" usage:"Output format for the DID result file. Can be json, env or json_jwk."` + OutputFile string `flag:"outputFile" default:"" usage:"File to write the DID; will not write if empty."` + DidType string `flag:"didType" default:"key" usage:"Type of the DID to generate. did:key and did:jwk are supported."` + KeyType string `flag:"keyType" default:"P-256" usage:"Type of the DID key to be created. Supported: ED-25519, P-256, P-384."` + HostUrl string `flag:"hostUrl" default:"" usage:"Base URL where the DID document will be located, excluding 'did.json'."` + CertUrl string `flag:"certUrl" default:"" usage:"URL to retrieve the public certificate. Defaults to 'hostUrl' + /.well-known/tls.crt"` + RunServer bool `flag:"server" default:"false" usage:"Run a server with /did.json and /.well-known/tls.crt endpoints."` + ServerPort int `flag:"port" default:"8080" usage:"Server port. Default 8080."` + Certificates Certificates `flag:""` + KeycloakHost string `flag:"keycloakHost" usage:"URL of the Keycloak instance used to construct the OIDC discovery and JWKS endpoints for the realms"` + KeycloakRealm string `flag:"keycloakRealm" usage:"Fixed Keycloak realm. When set with didType=keycloak, serves a fixed DID document for this realm at the path derived from hostUrl."` + IgnoreTlsValidation bool `flag:"ignoreTlsValidation" default:"false" usage:"Disable TLS validation. Do not use it in production"` } type Certificates struct { PublicKey *x509.Certificate - PrivateKey interface{} + PrivateKey any } diff --git a/did/did_document.go b/did/did_document.go new file mode 100644 index 0000000..053c9b7 --- /dev/null +++ b/did/did_document.go @@ -0,0 +1,81 @@ +package did + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "fmt" + + "go.uber.org/zap" +) + +type DIDTransformer struct { + Logger *zap.Logger +} + +func NewDIDTransformer(logger *zap.Logger) *DIDTransformer { + return &DIDTransformer{Logger: logger} +} + +func (t *DIDTransformer) TransformJWKSToDID(jwks *JWKS, host string, realm string) (map[string]any, error) { + didID := fmt.Sprintf("did:web:%s:%s", host, realm) + return t.TransformJWKSToDIDByID(jwks, didID) +} + +func (t *DIDTransformer) TransformJWKSToDIDByID(jwks *JWKS, didID string) (map[string]any, error) { + var verificationMethods []map[string]any + var keyIDs []string + + for _, key := range jwks.Keys { + if key.Use == "sig" { + currentKeyID := fmt.Sprintf("%s#%s", didID, key.Kid) + + if key.Kty == "RSA" && (key.N == "" || key.E == "") && len(key.X5c) > 0 { + derBytes, err := base64.StdEncoding.DecodeString(key.X5c[0]) + if err != nil { + t.Logger.Warn("Failed to decode x5c for RSA key", zap.String("kid", key.Kid), zap.Error(err)) + } else if cert, err := x509.ParseCertificate(derBytes); err != nil { + t.Logger.Warn("Failed to parse x5c certificate for RSA key", zap.String("kid", key.Kid), zap.Error(err)) + } else if rsaPub, ok := cert.PublicKey.(*rsa.PublicKey); !ok { + t.Logger.Warn("x5c does not contain RSA public key", zap.String("kid", key.Kid)) + } else { + key.N = base64.RawURLEncoding.EncodeToString(rsaPub.N.Bytes()) + eBytes := make([]byte, 4) + binary.BigEndian.PutUint32(eBytes, uint32(rsaPub.E)) + for len(eBytes) > 1 && eBytes[0] == 0 { + eBytes = eBytes[1:] + } + key.E = base64.RawURLEncoding.EncodeToString(eBytes) + t.Logger.Info("Recovered n/e from x5c", zap.String("kid", key.Kid)) + } + } + + vm := map[string]any{ + "id": currentKeyID, + "type": "JsonWebKey2020", + "controller": didID, + "publicKeyJwk": key, + } + + verificationMethods = append(verificationMethods, vm) + keyIDs = append(keyIDs, currentKeyID) + } + } + + if len(verificationMethods) == 0 { + return nil, fmt.Errorf("no signing keys found in JWKS") + } + + didDocument := map[string]any{ + "@context": []string{ + "https://www.w3.org/ns/did/v1", + }, + "id": didID, + "verificationMethod": verificationMethods, + "assertionMethod": keyIDs, + "authentication": keyIDs, + } + + return didDocument, nil +} diff --git a/did/helper.go b/did/did_generator.go similarity index 83% rename from did/helper.go rename to did/did_generator.go index 4e9f51e..79f73f3 100644 --- a/did/helper.go +++ b/did/did_generator.go @@ -8,7 +8,6 @@ import ( "crypto/x509" "encoding/base64" "encoding/json" - "encoding/pem" "errors" "net/url" "strings" @@ -18,22 +17,6 @@ import ( "go.uber.org/zap" ) -func LoadCertificates(config *Config) (err error) { - - var source string - if config.KeyPath != "" || config.CertPath != "" { - err = LoadCertsConfigFromPem(config) - } else { - config.Certificates.PrivateKey, config.Certificates.PublicKey, err = GetCertFromKeyStore(config.KeystorePath, config.KeystorePassword) - } - - if err != nil { - zap.L().Sugar().Warnf("Was not able to load certs from %s %s", source, config.KeystorePath, "error", err) - return err - } - return nil -} - func GetDIDKey(config Config) (did string, err error) { switch config.KeyType { @@ -138,16 +121,6 @@ func GenerateJWK(config Config) (jwkKey jwk.Key, err error) { return } -func GetCert(config Config) (certRaw []byte, err error) { - - pemBlock := &pem.Block{ - Type: "CERTIFICATE", - Bytes: config.Certificates.PublicKey.Raw, - } - - return pem.EncodeToMemory(pemBlock), nil -} - func generateJwk(cert *x509.Certificate) (jwkKey jwk.Key, err error) { jwkPrivkey, err := jwk.PublicKeyOf(cert.PublicKey) if err != nil { diff --git a/did/did_server.go b/did/did_server.go new file mode 100644 index 0000000..cd6a8ac --- /dev/null +++ b/did/did_server.go @@ -0,0 +1,77 @@ +package did + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "go.uber.org/zap" +) + +type DidServer struct { + BaseServer + DidJSONContent string + TlsCRTContent string +} + +func NewDidServer(didJSON string, tlsCRT string, port int, basepath string, didFilename string) *DidServer { + logger, err := zap.NewProduction() + if err != nil { + log.Fatalf("Failed to initialize Zap logger: %v", err) + } + mux := http.NewServeMux() + + didPath := buildDidPath(basepath, didFilename) + certPath := strings.TrimSuffix(basepath, "/") + "/.well-known/tls.crt" + + s := &DidServer{ + DidJSONContent: didJSON, + TlsCRTContent: tlsCRT, + BaseServer: BaseServer{ + Logger: logger, + Server: &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + }, + }, + } + logger.Info("Base path: " + basepath) + logger.Info("Server initialized", zap.String("didPath", didPath), zap.String("certPath", certPath)) + mux.HandleFunc(didPath, s.handleDidJSON) + mux.HandleFunc(certPath, s.handleTlsCRT) + + return s +} + +func (s *DidServer) handleDidJSON(w http.ResponseWriter, r *http.Request) { + s.Logger.Info("Request received", + zap.String("path", r.URL.Path), + zap.String("method", r.Method), + zap.String("remote_addr", r.RemoteAddr), + ) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(s.DidJSONContent)); err != nil { + s.Logger.Error("Error writing response for /did.json", zap.Error(err)) + } else { + s.Logger.Debug("Response sent successfully", zap.Int("status", http.StatusOK)) + } +} + +func (s *DidServer) handleTlsCRT(w http.ResponseWriter, r *http.Request) { + s.Logger.Info("Request received", zap.String("path", r.URL.Path)) + + w.Header().Set("Content-Type", "application/x-x509-ca-cert") + w.WriteHeader(http.StatusOK) + + if _, err := w.Write([]byte(s.TlsCRTContent)); err != nil { + s.Logger.Error("Error writing response for /tls.crt", zap.Error(err)) + } else { + s.Logger.Debug("Response sent successfully", zap.Int("status", http.StatusOK)) + } +} diff --git a/did/keycloak_client.go b/did/keycloak_client.go new file mode 100644 index 0000000..107f666 --- /dev/null +++ b/did/keycloak_client.go @@ -0,0 +1,35 @@ +package did + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type KeycloakClient struct { + Host string + IgnoreTlsValidation bool +} + +func (c *KeycloakClient) FetchJWKS(realm string) (*JWKS, int, error) { + keycloakURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", c.Host, realm) + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: c.IgnoreTlsValidation}, //nolint:gosec + } + client := http.Client{Timeout: 5 * time.Second, Transport: tr} + resp, err := client.Get(keycloakURL) + if err != nil { + return nil, http.StatusBadGateway, fmt.Errorf("identity provider unreachable: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, resp.StatusCode, fmt.Errorf("realm not found or Keycloak error") + } + var jwks JWKS + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("invalid response from identity provider") + } + return &jwks, http.StatusOK, nil +} diff --git a/did/keycloak_server.go b/did/keycloak_server.go new file mode 100644 index 0000000..596ca2a --- /dev/null +++ b/did/keycloak_server.go @@ -0,0 +1,151 @@ +package did + +import ( + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "log" + "net" + "net/http" + "strings" + "time" + + "go.uber.org/zap" +) + +type KeycloakServer struct { + BaseServer + Client *KeycloakClient + Realm string + DID string + Transformer *DIDTransformer +} + +// NewKeycloakServer creates a Keycloak-backed DID server. +// Dynamic mode (realm == ""): registers /{realm}/did.json; realm and DID are derived from each request. +// Fixed mode (realm != ""): registers a static path from basepath and uses the pre-computed didID. +func NewKeycloakServer(keycloakHost string, port int, ignoreTlsValidation bool, realm, didID, basepath string) *KeycloakServer { + logger, err := zap.NewProduction() + if err != nil { + log.Fatalf("Failed to initialize Zap logger: %v", err) + } + if ignoreTlsValidation { + logger.Warn("Ignore TLS Validation is enabled. Do not use it in production") + } + mux := http.NewServeMux() + s := &KeycloakServer{ + Client: &KeycloakClient{Host: keycloakHost, IgnoreTlsValidation: ignoreTlsValidation}, + Realm: realm, + DID: didID, + Transformer: NewDIDTransformer(logger), + BaseServer: BaseServer{ + Logger: logger, + Server: &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + }, + }, + } + + if realm == "" { + mux.HandleFunc("/{realm}/did.json", s.handlerRealm) + mux.HandleFunc("/{realm}/.well-known/tls.crt", s.handlerCert) + } else { + didPath := buildDidPath(basepath, "did.json") + certPath := strings.TrimSuffix(basepath, "/") + "/.well-known/tls.crt" + logger.Info("Keycloak fixed realm server initialized", + zap.String("realm", realm), + zap.String("didPath", didPath), + zap.String("certPath", certPath), + zap.String("did", didID), + ) + mux.HandleFunc(didPath, s.handlerRealm) + mux.HandleFunc(certPath, s.handlerCert) + } + return s +} + +// resolveRealm returns the realm name: from the fixed config (fixed mode) or decoded from the path (dynamic mode). +func (s *KeycloakServer) resolveRealm(r *http.Request) (string, error) { + if s.Realm != "" { + return s.Realm, nil + } + realm := r.PathValue("realm") + if realm == "" { + return "", fmt.Errorf("missing realm") + } + return realm, nil +} + +func (s *KeycloakServer) handlerRealm(w http.ResponseWriter, r *http.Request) { + realm, err := s.resolveRealm(r) + if err != nil { + s.Logger.Warn("Could not resolve realm", zap.Error(err)) + s.BaseServer.respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + var didID string + if s.Realm != "" { + didID = s.DID + } else { + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + host = r.Host + } + didID = fmt.Sprintf("did:web:%s:%s", host, realm) + } + + jwks, statusCode, err := s.Client.FetchJWKS(realm) + if err != nil { + s.Logger.Error("Failed to fetch JWKS", zap.Error(err), zap.String("realm", realm)) + s.BaseServer.respondWithError(w, statusCode, err.Error()) + return + } + + didDoc, err := s.Transformer.TransformJWKSToDIDByID(jwks, didID) + if err != nil { + s.Logger.Warn("Transformation failed", zap.Error(err)) + s.BaseServer.respondWithError(w, http.StatusNotFound, err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(didDoc); err != nil { + s.Logger.Error("Failed to encode JSON response", zap.Error(err)) + } +} + +func (s *KeycloakServer) handlerCert(w http.ResponseWriter, r *http.Request) { + realm, err := s.resolveRealm(r) + if err != nil { + s.Logger.Warn("Could not resolve realm", zap.Error(err)) + s.BaseServer.respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + jwks, statusCode, err := s.Client.FetchJWKS(realm) + if err != nil { + s.Logger.Error("Failed to fetch JWKS", zap.Error(err), zap.String("realm", realm)) + s.BaseServer.respondWithError(w, statusCode, err.Error()) + return + } + + for _, key := range jwks.Keys { + if key.Use == "sig" && len(key.X5c) > 0 { + certDER, err := base64.StdEncoding.DecodeString(key.X5c[0]) + if err != nil { + s.Logger.Error("Failed to decode x5c certificate", zap.Error(err)) + s.BaseServer.respondWithError(w, http.StatusInternalServerError, "Invalid certificate in JWKS") + return + } + w.Header().Set("Content-Type", "application/x-x509-ca-cert") + w.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})) + return + } + } + s.BaseServer.respondWithError(w, http.StatusNotFound, "No signing certificate found in JWKS") +} diff --git a/did/server.go b/did/server.go index e41da3c..169162c 100644 --- a/did/server.go +++ b/did/server.go @@ -2,122 +2,53 @@ package did import ( "context" + "encoding/json" "fmt" - "log" "net/http" + "os/signal" "strings" "syscall" "time" - "os/signal" - "go.uber.org/zap" ) -type DidServer struct { - DidJSONContent string - TlsCRTContent string - Server *http.Server - Logger *zap.Logger +type BaseServer struct { + Server *http.Server + Logger *zap.Logger } -func NewDidServer(didJSON string, tlsCRT string, port int, basepath string, didFilename string) *DidServer { - logger, err := zap.NewProduction() - if err != nil { - log.Fatalf("Failed to initialize Zap logger: %v", err) - } - s := &DidServer{ - DidJSONContent: didJSON, - TlsCRTContent: tlsCRT, - Logger: logger, - } +func buildDidPath(basepath, filename string) string { basepath = strings.TrimSuffix(basepath, "/") - mux := http.NewServeMux() - - var didPath string - var certPath = basepath + "/.well-known/tls.crt" - // Ensure basepath has no trailing slash if basepath == "" { - didPath = "/.well-known/" + didFilename - } else { - didPath = basepath + "/" + didFilename + return "/.well-known/" + filename } - - mux.HandleFunc(didPath, s.handleDidJSON) - mux.HandleFunc(certPath, s.handleTlsCRT) - - s.Server = &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: mux, - ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - } - - return s + return basepath + "/" + filename } -func (s *DidServer) handleDidJSON(w http.ResponseWriter, r *http.Request) { - // Log the incoming request details - s.Logger.Info("Request received", - zap.String("path", r.URL.Path), - zap.String("method", r.Method), - zap.String("remote_addr", r.RemoteAddr), - ) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte(s.DidJSONContent)); err != nil { - s.Logger.Error("Error writing response for /did.json", zap.Error(err)) - } else { - s.Logger.Debug("Response sent successfully", zap.Int("status", http.StatusOK)) - } -} - -func (s *DidServer) handleTlsCRT(w http.ResponseWriter, r *http.Request) { - s.Logger.Info("Request received", zap.String("path", r.URL.Path)) - - w.Header().Set("Content-Type", "application/x-x509-ca-cert") - w.WriteHeader(http.StatusOK) - - if _, err := w.Write([]byte(s.TlsCRTContent)); err != nil { - s.Logger.Error("Error writing response for /tls.crt", zap.Error(err)) - } else { - s.Logger.Debug("Response sent successfully", zap.Int("status", http.StatusOK)) - } -} -func (s *DidServer) Start() error { +func (s *BaseServer) Start() error { s.Logger.Info("Starting server", zap.String("address", s.Server.Addr)) - // Create context to listen for OS signals. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() // Ensures context cancellation resource is released + defer stop() - // 1. Run the server in a goroutine go func() { if err := s.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - // Use Zap logger for the fatal start error s.Logger.Fatal("Could not start server listener", zap.Error(err)) } }() - // Sync the logger before exiting, ensuring all buffered logs are written. - // This defer is placed here to ensure it runs when the function exits (after shutdown). defer s.Logger.Sync() - // 2. Block until context is canceled (i.e., SIGTERM/SIGINT is received) <-ctx.Done() - // 3. Graceful Shutdown initiated s.Logger.Info("Shutdown signal received. Initiating graceful shutdown...") - // 4. Create a timeout context for the shutdown (e.g., 10 seconds) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - // Execute the graceful shutdown if err := s.Shutdown(shutdownCtx); err != nil { s.Logger.Error("Server forced to shutdown after timeout", zap.Error(err)) - // Kubernetes will still kill the pod, but we log the forced shutdown. return fmt.Errorf("server shutdown error: %w", err) } @@ -125,7 +56,17 @@ func (s *DidServer) Start() error { return nil } -func (s *DidServer) Shutdown(ctx context.Context) error { +func (s *BaseServer) Shutdown(ctx context.Context) error { s.Logger.Info("Shutting down server...") return s.Server.Shutdown(ctx) } + +func (s *BaseServer) respondWithError(w http.ResponseWriter, code int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + + json.NewEncoder(w).Encode(ErrorResponse{ + Error: http.StatusText(code), + Message: message, + }) +} diff --git a/did/types.go b/did/types.go new file mode 100644 index 0000000..d6299fb --- /dev/null +++ b/did/types.go @@ -0,0 +1,25 @@ +package did + +type JWKS struct { + Keys []JWK `json:"keys"` +} + +type JWK struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + Use string `json:"use"` + N string `json:"n,omitempty"` + E string `json:"e,omitempty"` + X5c []string `json:"x5c"` + X5t string `json:"x5t"` + X5tS256 string `json:"x5t#S256"` + Crv string `json:"crv,omitempty"` + X string `json:"x,omitempty"` + Y string `json:"y,omitempty"` +} + +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 26ba233..303b1e3 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -8,7 +8,7 @@ STORE_PASS="${STORE_PASS:-changeit}" cd /cert -if [[ -z "${KEYSTORE_PATH:-}" ]] && [[ -z "${CERT_URL:-}" ]]; then +if [[ -z "${KEYSTORE_PATH:-}" ]] && [[ -z "${CERT_URL:-}" ]] && [[ -z "${KEYCLOAK_HOST:-}" ]]; then case "$KEY_TYPE_TO_GENERATE" in EC) case "$KEY_TYPE" in diff --git a/main.go b/main.go index d4ed0e7..5ae80b7 100644 --- a/main.go +++ b/main.go @@ -18,93 +18,119 @@ func init() { zap.ReplaceGlobals(zap.Must(zap.NewDevelopment())) } -func main() { - var cfg did.Config - var fileContent []byte - var resultingDid string - var err error - - filler := flagsfiller.New(flagsfiller.WithEnv("")) - err = filler.Fill(flag.CommandLine, &cfg) +func getHostPath(hostUrl string) (string, error) { + webUrl, err := url.Parse(hostUrl) if err != nil { - zap.L().Sugar().Fatal("error reading config. error %s", err) - os.Exit(1) + return "", fmt.Errorf("'%s' is not a valid url", hostUrl) } - flag.Parse() + return webUrl.Path, nil +} - err = did.LoadCertificates(&cfg) - if err != nil { - os.Exit(1) - } +func resolveDID(cfg did.Config) (string, error) { switch cfg.DidType { case "key": - resultingDid, err = did.GetDIDKey(cfg) + return did.GetDIDKey(cfg) case "jwk": - resultingDid, err = did.GetDIDJWKFromKey(cfg) + return did.GetDIDJWKFromKey(cfg) case "web": - resultingDid, err = did.GetDIDWeb(cfg.HostUrl) + return did.GetDIDWeb(cfg.HostUrl) + case "keycloak": + if cfg.KeycloakRealm != "" { + return did.GetDIDWeb(cfg.HostUrl) + } + return "", nil default: - zap.L().Sugar().Warnf("Did type %s is not supported.", cfg.DidType) - os.Exit(2) - } - - if err != nil { - fmt.Println("Was not able to extract did. Err: ", err) - os.Exit(3) - } else { - fmt.Println("Did key is: ", resultingDid) + return "", fmt.Errorf("did type %s is not supported", cfg.DidType) } +} +func buildOutput(cfg *did.Config, resultingDid string) ([]byte, error) { switch cfg.OutputFormat { case "json": didJson := did.Did{IssuerDid: []string{"https://www.w3.org/ns/did/v1"}, Id: resultingDid} - fileContent, err = json.Marshal(didJson) - if err != nil { - zap.L().Sugar().Warnf("Was not able to marshal the did-json. Err: %s", err) - os.Exit(4) - } + return json.Marshal(didJson) case "env": - fileContent = ([]byte("DID=" + resultingDid)) + return []byte("DID=" + resultingDid), nil case "json_jwk": if cfg.CertUrl == "" { cfg.CertUrl = strings.TrimSuffix(cfg.HostUrl, "/") + "/.well-known/tls.crt" } - keySet, err := did.GenerateJWK(cfg) + keySet, err := did.GenerateJWK(*cfg) if err != nil { - zap.L().Sugar().Warnf("Error generating keyset. Err: %s", err) - os.Exit(5) + return nil, fmt.Errorf("error generating keyset: %w", err) } verificationMethod := did.VerificationMethod{Id: resultingDid, Type: "JsonWebKey2020", Controller: resultingDid, PublicKeyJwk: keySet} didJson := did.Did{Context: []string{"https://www.w3.org/ns/did/v1"}, Id: resultingDid, VerificationMethod: []did.VerificationMethod{verificationMethod}} - fileContent, err = json.MarshalIndent(didJson, "", " ") - if err != nil { - zap.L().Sugar().Warnf("Error printing keyset") - os.Exit(6) + return json.MarshalIndent(didJson, "", " ") + } + return nil, nil +} + +func startServer(cfg did.Config, fileContent []byte, resultingDid string) error { + if cfg.DidType == "keycloak" { + var basepath string + if cfg.KeycloakRealm != "" { + var err error + basepath, err = getHostPath(cfg.HostUrl) + if err != nil { + return err + } } + server := did.NewKeycloakServer(cfg.KeycloakHost, cfg.ServerPort, cfg.IgnoreTlsValidation, cfg.KeycloakRealm, resultingDid, basepath) + return server.Start() } - if cfg.OutputFile != "" { - err = os.WriteFile(cfg.OutputFile, fileContent, 0644) - if err != nil { - zap.L().Sugar().Warnf("Was not able to write the did-json to %s. Err: %s", cfg.OutputFile, err) - os.Exit(7) + cert, _ := did.GetCert(cfg) + didFilename := "did.json" + if cfg.OutputFormat == "env" { + didFilename = "did.env" + } + hostPath, err := getHostPath(cfg.HostUrl) + if err != nil { + return err + } + server := did.NewDidServer(string(fileContent), string(cert), cfg.ServerPort, hostPath, didFilename) + return server.Start() +} + +func main() { + var cfg did.Config + + filler := flagsfiller.New(flagsfiller.WithEnv("")) + if err := filler.Fill(flag.CommandLine, &cfg); err != nil { + zap.L().Sugar().Fatalf("error reading config: %s", err) + } + flag.Parse() + + if cfg.DidType != "keycloak" { + if err := did.LoadCertificates(&cfg); err != nil { + os.Exit(1) } - } else if cfg.RunServer { - // Error is detected genering the content - cert, _ := did.GetCert(cfg) - webUrl, err := url.Parse(cfg.HostUrl) - if err != nil { - zap.L().Sugar().Errorf("'%s' is not a valid url") - os.Exit(7) + } + + resultingDid, err := resolveDID(cfg) + if err != nil { + zap.L().Sugar().Fatalf("was not able to resolve did: %s", err) + } + if resultingDid != "" { + fmt.Println("Did key is: ", resultingDid) + } + + fileContent, err := buildOutput(&cfg, resultingDid) + if err != nil { + zap.L().Sugar().Fatalf("was not able to build output: %s", err) + } + + switch { + case cfg.OutputFile != "": + if err := os.WriteFile(cfg.OutputFile, fileContent, 0644); err != nil { + zap.L().Sugar().Fatalf("was not able to write to %s: %s", cfg.OutputFile, err) } - didFilename := "did.json" - if cfg.OutputFormat == "env" { - didFilename = "did.env" + case cfg.RunServer: + if err := startServer(cfg, fileContent, resultingDid); err != nil { + zap.L().Sugar().Fatalf("server error: %s", err) } - - server := did.NewDidServer(string(fileContent), string(cert), cfg.ServerPort, webUrl.Path, didFilename) - server.Start() - } else { + default: fmt.Println("Output: ", string(fileContent)) } }