diff --git a/cmd/util/config.go b/cmd/util/config.go index 3a8c0b48..c017e3a7 100644 --- a/cmd/util/config.go +++ b/cmd/util/config.go @@ -42,7 +42,7 @@ func MergeConfigFileWithFlags(file string, flagConf config.Config) (config.Confi if conf.RPCClient.User == "" && conf.RPCClient.Password == "" { if len(conf.Server.BasicAuth) > 0 { fmt.Println("Configuration problem: RPCClient User and Password " + - "are undefined while Server.BasicAuth is enforeced.") + "are undefined while Server.BasicAuth is enforced.") os.Exit(1) } else if conf.Server.OidcAuth.ServiceConfigUrl != "" { // Generating random user/password credentials for RPC: diff --git a/config/config.go b/config/config.go index f5bfd1de..b42fecc2 100644 --- a/config/config.go +++ b/config/config.go @@ -58,6 +58,8 @@ type BasicCredential struct { type OidcAuth struct { ServiceConfigUrl string + ClientId string + ClientSecret string RequireScope string RequireAudience string } diff --git a/config/default-config.yaml b/config/default-config.yaml index b2608518..82ef1ee3 100644 --- a/config/default-config.yaml +++ b/config/default-config.yaml @@ -42,6 +42,10 @@ Server: # OidcAuth: # # URL of the OIDC service configuration: # ServiceConfigUrl: "" + # # Client ID and secret are sent with the token introspection request + # # (Basic authentication): + # ClientId: + # ClientSecret: # # Optional: if specified, this scope value must be in the token: # RequireScope: # # Optional: if specified, this audience value must be in the token: diff --git a/server/auth_oidc.go b/server/auth_oidc.go index 014bb4ad..ab07dd66 100644 --- a/server/auth_oidc.go +++ b/server/auth_oidc.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/http/httputil" "net/url" "os" "strings" @@ -18,9 +19,14 @@ import ( // JSON structure of the OIDC configuration (only some fields) type OidcRemoteConfig struct { - Issuer string `json:"issuer"` - UserinfoEndpoint string `json:"userinfo_endpoint"` - JwksURI string `json:"jwks_uri"` + Issuer string `json:"issuer"` + JwksURI string `json:"jwks_uri"` + IntrospectionEndpoint string `json:"introspection_endpoint"` +} + +// JSON structure of the OIDC token introspection response (only some fields) +type IntrospectionResponse struct { + Active bool `json:"active"` } // OIDC configuration structure used for validating input from request. @@ -91,10 +97,18 @@ func (c *OidcConfig) ParseJwt(jwtString string) *jwt.Token { return nil } + if !c.isJwtValid(&token) || !c.isJwtActive(jwtString) { + return nil + } + + return &token +} + +func (c *OidcConfig) isJwtValid(token *jwt.Token) bool { // If audience is required, it must be in the token. if c.local.RequireAudience != "" { found := false - for _, value := range token.Audience() { + for _, value := range (*token).Audience() { if value == c.local.RequireAudience { found = true break @@ -102,14 +116,14 @@ func (c *OidcConfig) ParseJwt(jwtString string) *jwt.Token { } if !found { fmt.Printf("[WARN] Audience [%s] not found in %v.", - c.local.RequireAudience, token.Audience()) - return nil + c.local.RequireAudience, (*token).Audience()) + return false } } // If scope is required, it must be in the token. if c.local.RequireScope != "" { - value, found := token.Get("scope") + value, found := (*token).Get("scope") if found { found = false for _, value := range strings.Split(value.(string), " ") { @@ -122,11 +136,100 @@ func (c *OidcConfig) ParseJwt(jwtString string) *jwt.Token { if !found { fmt.Printf("[WARN] Scope [%s] not found in [%s]", c.local.RequireScope, value) - return nil + return false } } - return &token + return true +} + +func (c *OidcConfig) isJwtActive(token string) bool { + if c.remote.IntrospectionEndpoint == "" { + fmt.Println("[WARN] JWT introspection endpoint was not defined in the OIDC " + + "(remote) configuration; therefore assuming that the token is active.") + return true + } + + client := &http.Client{} + params := url.Values{"token": {token}}.Encode() + attemptsCount := 3 + + for attemptsCount > 0 { + request, err := http.NewRequest( + http.MethodPost, + c.remote.IntrospectionEndpoint, + strings.NewReader(params)) + + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if c.local.ClientId != "" && c.local.ClientSecret != "" { + request.SetBasicAuth(c.local.ClientId, c.local.ClientSecret) + } else { + fmt.Println("[WARN] Requesting token introspection without " + + "client credentials (unspecified in the config)") + } + + response, err := client.Do(request) + + if err != nil { + fmt.Printf("[ERROR] Failed to call OIDC introspection endpoint "+ + "(POST %s): %s\n", c.remote.IntrospectionEndpoint, err) + if attemptsCount > 1 { + fmt.Println("Trying to call OIDC introspection endpoint again after a second...") + time.Sleep(1 * time.Second) + } else { + fmt.Println("[ERROR] Too many failed attempts for JWT " + + "introspection. Giving up.") + } + attemptsCount-- + continue + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + byteDump, err := httputil.DumpResponse(response, true) + if err != nil { + fmt.Printf("Failed to dump response: %s\n", err) + } else { + fmt.Print(string(byteDump)) + } + fmt.Printf("[WARN] JWT introspection call gave non-200 HTTP status %d "+ + "(thus JWT not active)\n", response.StatusCode) + return false + } + + body, err := io.ReadAll(response.Body) + + if err != nil { + fmt.Printf("[WARN] Failed to read JWT introspection response "+ + "body with HTTP status %d (thus JWT not active): %s\n", + response.StatusCode, err) + return false + } + + if !strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") { + fmt.Printf("[WARN] JWT introspection endpoint returned non-JSON "+ + "[content-type=%s] HTTP 200 response (thus JWT not active): %s\n", + response.Header.Get("Content-Type"), body) + return false + } + + if len(body) == 0 { + fmt.Println("[WARN] JWT introspection endpoint returned empty " + + "HTTP 200 response (thus JWT not active)") + return false + } + + var result IntrospectionResponse + if err := json.Unmarshal(body, &result); err != nil { + fmt.Printf("Cannot unmarshal JSON from the JWT introspection endpoint: %s", err) + } + + return result.Active + } + + return false } func validateUrl(providedUrl string) *url.URL { @@ -150,7 +253,7 @@ func fetchJson(url *url.URL) []byte { fmt.Printf("[ERROR] OIDC service configuration (%s) could not be "+ "loaded: %s.\n", url.String(), err) os.Exit(1) - } else if res.StatusCode != 200 { + } else if res.StatusCode != http.StatusOK { fmt.Printf("[ERROR] OIDC service configuration (%s) could not be "+ "loaded (HTTP response status: %d).", url.String(), res.StatusCode) os.Exit(1) diff --git a/website/content/docs/security/oauth2.md b/website/content/docs/security/oauth2.md index a022454f..64310e9e 100644 --- a/website/content/docs/security/oauth2.md +++ b/website/content/docs/security/oauth2.md @@ -13,7 +13,10 @@ JWT in the request. Funnel itself does not redirect users to perform the login. It just validates that the presented token is issued by a trusted service -(specified in the configuration file) and the token has not expired. +(specified in the YAML configuration file) and the token has not expired. +In addition, if the OIDC provides a token introspection endpoint (in its +configuration JSON), Funnel server also calls that endpoint to make sure the +token is still active (i.e., no token invalidation before expiring). Optionally, Funnel can also validate the scope and audience claims to contain specific values. @@ -25,8 +28,15 @@ Server: OidcAuth: # URL of the OIDC service configuration: ServiceConfigUrl: "https://my.oidc.service/.well-known/openid-configuration" + + # Client ID and secret are sent with the token introspection request + # (Basic authentication): + ClientId: your-client-id + ClientSecret: your-client-secret + # Optional: if specified, this scope value must be in the token: RequireScope: funnel-id + # Optional: if specified, this audience value must be in the token: RequireAudience: tes-api ```