Skip to content

Commit

Permalink
Add token introspection call to check if token is active
Browse files Browse the repository at this point in the history
  • Loading branch information
mrtamm committed Feb 6, 2024
1 parent 2400843 commit 690caa5
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 12 deletions.
2 changes: 1 addition & 1 deletion cmd/util/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ type BasicCredential struct {

type OidcAuth struct {
ServiceConfigUrl string
ClientId string
ClientSecret string
RequireScope string
RequireAudience string
}
Expand Down
4 changes: 4 additions & 0 deletions config/default-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
123 changes: 113 additions & 10 deletions server/auth_oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
Expand All @@ -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.
Expand Down Expand Up @@ -91,25 +97,33 @@ 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
}
}
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), " ") {
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion website/content/docs/security/oauth2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```
Expand Down

0 comments on commit 690caa5

Please sign in to comment.