Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OIDC access token validation #107

Merged
merged 27 commits into from
Dec 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ This project tries to follow [SemVer 2.0.0](https://semver.org/).
`WHARF_HTTP_CORS_ALLOWORIGINS` or the YAML key `http.cors.allowOrigins`. This
is to make sending `Authorization` headers possible. (#101)

- Added a slew of options for setting OIDC parameters (see WHARF_HTTP_OIDC_*)
for JWT token verification. Upon setting `WHARF_HTTP_OIDC_ENABLE=true` a
check will be enforced for requests sent to the api such that all
requests not carrying a valid bearer token will fail.

- Added support for Sqlite. Default database driver is still Postgres.

Note: wharf-api must be compiled with `CGO_ENABLED=1` (which is the default
Expand Down
56 changes: 56 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ type HTTPConfig struct {
//
// Added in v4.2.0.
BasicAuth string

// OIDC is only secure if using HTTPS/SSL.
// Requires CORS set to specific origins.
//
// If enabled, HTTP requests without a valid OIDC access token in the Authorization
// header will be rejected with Unauthorized 401.
//
// Added in v5.0.0.
OIDC OIDCConfig
}

// CORSConfig holds settings for the HTTP server's CORS settings.
Expand All @@ -130,6 +139,41 @@ type CORSConfig struct {
AllowOrigins []string
}

// OIDCConfig holds settings for the HTTP server's OIDC access token validation settings.
type OIDCConfig struct {

// Enable functions as a switch to enable or disable the validation of OIDC
// access bearer tokens.
//
// Added in v5.0.0.
Enable bool

// IssuerURL is an integral part of the access token. It should be checked such that
// only allowed OIDC targets can pass token validation.
//
// Added in v5.0.0.
IssuerURL string

// AudienceURL is an integral part of the access token. It should be checked such that
// only the allowed application within a OIDC target can pass validation.
//
// Added in v5.0.0.
AudienceURL string

// KeysURL is an integral part of the access token. It should be checked such that
// only OIDC targets with the expected keys pass validation.
//
// Added in v5.0.0.
KeysURL string

// UpdateInterval defines the key rotation of the public RSA keys obtained
// by the OIDC keys URL. A value of 25 hours is both default and
// recommended.
//
// Added in v5.0.0.
UpdateInterval time.Duration
}

// CertConfig holds settings for certificates verification used when talking
// to remote services over HTTPS.
type CertConfig struct {
Expand Down Expand Up @@ -274,6 +318,18 @@ type DBConfig struct {
var DefaultConfig = Config{
HTTP: HTTPConfig{
BindAddress: "0.0.0.0:8080",
CORS: CORSConfig{
// :4200 is used when running wharf-web via `npm start` locally
// :5000 is used when running wharf-web via docker-compose locally
AllowOrigins: []string{"http://localhost:4200", "http://localhost:5000"},
},
OIDC: OIDCConfig{
Enable: false,
IssuerURL: "https://sts.windows.net/841df554-ef9d-48b1-bc6e-44cf8543a8fc/",
AudienceURL: "api://wharf-internal",
KeysURL: "https://login.microsoftonline.com/841df554-ef9d-48b1-bc6e-44cf8543a8fc/discovery/v2.0/keys",
UpdateInterval: time.Hour * 25,
},
},
DB: DBConfig{
Driver: DBDriverPostgres,
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/gin-contrib/cors v1.3.1
github.com/gin-gonic/gin v1.7.4
github.com/go-playground/validator/v10 v10.9.0 // indirect
github.com/golang-jwt/jwt/v4 v4.1.0
github.com/iver-wharf/wharf-core v1.3.0
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFG
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-jwt/jwt/v4 v4.1.0 h1:XUgk2Ex5veyVFVeLm0xhusUTQybEbexJXrvPNOKkSY0=
github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
Expand Down
11 changes: 11 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ func main() {
healthModule{}.DeprecatedRegister(r)
healthModule{}.Register(r.Group("/api"))

if config.HTTP.OIDC.Enable {
rsaKeys, err := GetOIDCPublicKeys(config.HTTP.OIDC.KeysURL)
if err != nil {
log.Error().WithError(err).Message("Failed to obtain OIDC public keys.")
os.Exit(1)
}
m := newOIDCMiddleware(rsaKeys, config.HTTP.OIDC)
r.Use(m.VerifyTokenMiddleware)
m.SubscribeToKeyURLUpdates()
}

setupBasicAuth(r, config)

modules := []httpModule{
Expand Down
161 changes: 161 additions & 0 deletions oidc_middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package main

import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
"github.com/iver-wharf/wharf-core/pkg/ginutil"
"github.com/iver-wharf/wharf-core/pkg/problem"

"github.com/golang-jwt/jwt/v4"
)

// This is a modified version of the code provided in the follow blog post and
// GitHub repository:
// - https://developer.okta.com/blog/2021/01/04/offline-jwt-validation-with-go
// - https://github.com/oktadev/okta-offline-jwt-validation-example/tree/a61cc73bf893686c1efe67ce86448047205826bc
//
// Copyright 2019 Okta, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// GetOIDCPublicKeys returns the public keys of the currently set WHARF_HTTP_OIDC_KEYSURL.
func GetOIDCPublicKeys(keysURL string) (map[string]*rsa.PublicKey, error) {
rsaKeys := make(map[string]*rsa.PublicKey)
resp, err := http.Get(keysURL)
if err != nil {
return nil, fmt.Errorf("http GET keys URL: %w", err)
}
var body struct {
Keys []struct {
KeyID string `json:"kid"`
Number string `json:"n"`
} `json:"keys"`
}
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
return nil, fmt.Errorf("decode keys payload: %w", err)
}
log.Debug().Message("Updating keys for oidc.")
rsaExponent := 65537
for _, key := range body.Keys {
kid := key.KeyID
rsakey := new(rsa.PublicKey)
number, err := base64.RawURLEncoding.DecodeString(key.Number)
if err != nil {
return nil, fmt.Errorf("decode JWT 'n' field: %w", err)
}
rsakey.N = new(big.Int).SetBytes(number)
rsakey.E = rsaExponent
rsaKeys[kid] = rsakey
}
return rsaKeys, nil
}

func newOIDCMiddleware(rsaKeys map[string]*rsa.PublicKey, config OIDCConfig) *oidcMiddleware {
return &oidcMiddleware{
rsaKeys: rsaKeys,
config: config,
}
}

type oidcMiddleware struct {
rsaKeys map[string]*rsa.PublicKey
config OIDCConfig
}

// VerifyTokenMiddleware is a gin middleware function that enforces validity of the access bearer token on every
// request. This uses the environment vars WHARF_HTTP_OIDC_ISSUERURL and WHARF_HTTP_OIDC_AUDIENCEURL as limiters
// that control the variety of tokens that pass validation.
func (m *oidcMiddleware) VerifyTokenMiddleware(ginContext *gin.Context) {
if m.rsaKeys == nil {
ginutil.WriteProblem(ginContext, problem.Response{
Type: "/prob/api/oidc/missing-rsa-keys",
Title: "Missing OIDC public keys.",
Status: http.StatusInternalServerError,
Detail: "The OIDC RSA public keys were not properly set up during initialization of the wharf-api.",
})
ginContext.Abort()
return
}
isValid := false
errorMessage := ""
tokenString := ginContext.Request.Header.Get("Authorization")
if !strings.HasPrefix(tokenString, "Bearer ") {
ginutil.WriteUnauthorized(ginContext, "Expected authorization scheme to be 'Bearer' (case sensitive), but was not.")
ginContext.Abort()
return
}
tokenString = strings.TrimPrefix(tokenString, "Bearer ")
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if kid, ok := token.Header["kid"].(string); ok {
return m.rsaKeys[kid], nil
}
return nil, errors.New("expected JWT to have string 'kid' field")
})
if err != nil {
errorMessage = err.Error()
} else if !token.Valid {
errorMessage = "invalid access bearer token."
} else if token.Header["alg"] == nil {
errorMessage = "missing 'alg' field."
} else if token.Claims.(jwt.MapClaims)["aud"] != m.config.AudienceURL {
errorMessage = "invalid 'aud' field."
} else if iss, ok := token.Claims.(jwt.MapClaims)["iss"].(string); !ok {
errorMessage = "invalid or missing 'iss' field: should be string."
} else if !strings.Contains(iss, m.config.IssuerURL) {
errorMessage = "invalid 'iss' field: disallowed issuer."
} else {
isValid = true
}
if !isValid {
ginutil.WriteUnauthorized(ginContext, "Invalid JWT: "+errorMessage)
ginContext.Abort()
}
}

// SubscribeToKeyURLUpdates ensures new keys are fetched as necessary.
// As a standard OIDC login provider keys should be checked for updates ever 1 day 1 hour.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// As a standard OIDC login provider keys should be checked for updates ever 1 day 1 hour.
// As a standard OIDC login provider keys should be checked for updates every 25 hours.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the connection is easier to make towards daily keyrotation if the text mentions day so i would skip this fix.

func (m *oidcMiddleware) SubscribeToKeyURLUpdates() {
fetchOidcKeysTicker := time.NewTicker(m.config.UpdateInterval)
fredx30 marked this conversation as resolved.
Show resolved Hide resolved
log.Debug().WithDuration("interval", m.config.UpdateInterval).
Message("Subscribing to OIDC public keys rotation via periodic check timer.")
go func() {
for {
<-fetchOidcKeysTicker.C
fredx30 marked this conversation as resolved.
Show resolved Hide resolved
m.updateOIDCPublicKeys()
}
}()
}

func (m *oidcMiddleware) updateOIDCPublicKeys() {
newKeys, err := GetOIDCPublicKeys(m.config.KeysURL)
if err != nil {
log.Warn().WithError(err).
WithDuration("interval", m.config.UpdateInterval).
Message("Failed to update OIDC public keys.")
} else {
m.rsaKeys = newKeys
log.Info().
WithDuration("interval", m.config.UpdateInterval).
Message("Successfully updated OIDC public keys.")
}
}