diff --git a/README.md b/README.md index 02370c93cf7baa8693370e7155913c90cd045b60..429d13a318384bed2aa855b5123a8b9621900ded 100644 --- a/README.md +++ b/README.md @@ -266,3 +266,12 @@ Or on the command line The proxy support enforcing mutual TLS for the clients by simply adding the --tls-ca-certificate command line option or config file option. All clients connecting must present a ceritificate which was signed by the CA being used. + +#### **Endpoints** + +* **/oauth/authorize** is authentication endpoint which will generate the openid redirect to the provider +* **/oauth/callback** is provider openid callback endpoint +* **/oauth/expired** is a helper endpoint to check if a access token has expired, 200 for ok and, 401 for no token and 401 for expired +* **/oauth/token** is a helper endpoint which will display the current access token for you +* **/oauth/health** is the health checking endpoint for the proxy + diff --git a/config_sample.yml b/config_sample.yml index e825b156b837cde38f8b49708f2c647706816756..b7b9529b289a8e4f9722666ae44264e010c0a211 100644 --- a/config_sample.yml +++ b/config_sample.yml @@ -16,6 +16,8 @@ max_session: 1h log_requests: true # log in json format log_json_format: true +# do not redirec the request, simple 307 it +no-redirects: false # the location of a certificate you wish the proxy to use for TLS support tls_cert: # the location of a private key for TLS diff --git a/doc.go b/doc.go index a87fcc9349e8a2c8e4b5549d2fbdb8debfaf8164..71fdafc114cb474fff6a12c2759434730057fc8e 100644 --- a/doc.go +++ b/doc.go @@ -38,6 +38,8 @@ const ( authorizationURL = oauthURL + "/authorize" callbackURL = oauthURL + "/callback" healthURL = oauthURL + "/health" + tokenURL = oauthURL + "/token" + expiredURL = oauthURL + "/expired" ) var ( diff --git a/handlers.go b/handlers.go index 2715c728d982ab53b39f5b6c12de75e689761d6e..81e4444fe2c201cf34d2b1c331c8cab5cc2f0cac 100644 --- a/handlers.go +++ b/handlers.go @@ -16,6 +16,7 @@ limitations under the License. package main import ( + "fmt" "net/http" "path" "regexp" @@ -25,6 +26,7 @@ import ( log "github.com/Sirupsen/logrus" "github.com/coreos/go-oidc/jose" "github.com/coreos/go-oidc/oauth2" + "github.com/coreos/go-oidc/oidc" "github.com/gin-gonic/gin" "github.com/unrolled/secure" ) @@ -569,6 +571,62 @@ func (r *KeycloakProxy) oauthCallbackHandler(cx *gin.Context) { r.redirectToURL(state, cx) } +// +// expirationHandler checks if the token has expired +// +func (r *KeycloakProxy) expirationHandler(cx *gin.Context) { + // step: get the access token from the request + token, err := r.getSession(cx) + if err != nil { + if err == ErrSessionNotFound { + cx.AbortWithError(http.StatusUnauthorized, err) + return + } + cx.AbortWithError(http.StatusInternalServerError, err) + return + } + + // step: decode the claims from the tokens + claims, err := token.Claims() + if err != nil { + cx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("unable to extract the claims")) + return + } + // step: extract the identity + identity, err := oidc.IdentityFromClaims(claims) + if err != nil { + cx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("unable to extract identity")) + return + } + + // step: check if token expired + if time.Now().After(identity.ExpiresAt) { + cx.AbortWithStatus(http.StatusForbidden) + } else { + cx.AbortWithStatus(http.StatusOK) + } +} + +// +// tokenHandle display access token to screen +// +func (r *KeycloakProxy) tokenHandler(cx *gin.Context) { + // step: extract the access token from the request + token, err := r.getSession(cx) + if err != nil { + if err == ErrSessionNotFound { + cx.AbortWithError(http.StatusOK, err) + return + } + cx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("unable to retrieve session, error: %s", err)) + return + } + + // step: write the json content + cx.Writer.Header().Set("Content-Type", "application/json") + cx.String(http.StatusOK, fmt.Sprintf("%s", token.Payload)) +} + // // healthHandler is a health check handler for the service // diff --git a/handlers_test.go b/handlers_test.go index 127ddfb1882afcd680de162e553e041859f38a30..c2225ff463662d794b8923c90ce11f7444b3b37e 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -16,9 +16,11 @@ limitations under the License. package main import ( + "fmt" "net/http" "strings" "testing" + "time" "github.com/coreos/go-oidc/jose" "github.com/gin-gonic/gin" @@ -382,6 +384,73 @@ func TestSecurityHandler(t *testing.T) { } } +func newFakeJWTToken(t *testing.T, claims jose.Claims) *jose.JWT { + token, err := jose.NewJWT( + jose.JOSEHeader{"alg": "RS256"}, claims, + ) + if err != nil { + t.Fatalf("failed to create the jwt token, error: %s", err) + } + + return &token +} + +func TestExpirationHandler(t *testing.T) { + proxy := newFakeKeycloakProxy(t) + + cases := []struct { + Token *jose.JWT + HTTPCode int + }{ + { + HTTPCode: http.StatusUnauthorized, + }, + { + Token: newFakeJWTToken(t, jose.Claims{ + "exp": float64(time.Now().Add(-24 * time.Hour).Unix()), + }), + HTTPCode: http.StatusInternalServerError, + }, + { + Token: newFakeJWTToken(t, jose.Claims{ + "exp": float64(time.Now().Add(10 * time.Hour).Unix()), + "iss": "https://keycloak.example.com/auth/realms/commons", + "sub": "1e11e539-8256-4b3b-bda8-cc0d56cddb48", + "email": "gambol99@gmail.com", + "name": "Rohith Jayawardene", + "preferred_username": "rjayawardene", + }), + HTTPCode: http.StatusOK, + }, + { + Token: newFakeJWTToken(t, jose.Claims{ + "exp": float64(time.Now().Add(-24 * time.Hour).Unix()), + "iss": "https://keycloak.example.com/auth/realms/commons", + "sub": "1e11e539-8256-4b3b-bda8-cc0d56cddb48", + "email": "gambol99@gmail.com", + "name": "Rohith Jayawardene", + "preferred_username": "rjayawardene", + }), + HTTPCode: http.StatusForbidden, + }, + } + + for i, c := range cases { + // step: inject a resource + cx := newFakeGinContext("GET", "/oauth/expiration") + // step: add the token is there is one + if c.Token != nil { + cx.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token.Encode())) + } + // step: if closure so we need to get the handler each time + proxy.expirationHandler(cx) + // step: check the content result + if cx.Writer.Status() != c.HTTPCode { + t.Errorf("test case %d should have recieved: %d, but got %d", i, c.HTTPCode, cx.Writer.Status()) + } + } +} + func TestHealthHandler(t *testing.T) { proxy := newFakeKeycloakProxy(t) context := newFakeGinContext("GET", healthURL) diff --git a/server.go b/server.go index 61b8f243749e2e07777471fd9e38377dcacc2c91..d314c5c6073571e6523b2d3beec5e917577ddb38 100644 --- a/server.go +++ b/server.go @@ -133,6 +133,9 @@ func (r KeycloakProxy) initializeRouter() { r.router.GET(authorizationURL, r.oauthAuthorizationHandler) r.router.GET(callbackURL, r.oauthCallbackHandler) r.router.GET(healthURL, r.healthHandler) + r.router.GET(tokenURL, r.tokenHandler) + r.router.GET(expiredURL, r.expirationHandler) + r.router.Use(r.entryPointHandler(), r.authenticationHandler(), r.admissionHandler()) } diff --git a/server_test.go b/server_test.go index ac17cb9ff6e63417f7a73f41ab2e4c416d1e03b9..8cbfb90d38881232c026eee6b0d2332ad6c98088 100644 --- a/server_test.go +++ b/server_test.go @@ -17,6 +17,7 @@ package main import ( "bufio" + "bytes" "io/ioutil" "net" "net/http" @@ -194,6 +195,7 @@ func newFakeResponse() *fakeResponse { func newFakeGinContext(method, uri string) *gin.Context { return &gin.Context{ + Request: &http.Request{ Method: method, Host: "127.0.0.1", @@ -218,6 +220,7 @@ type fakeResponse struct { size int status int headers http.Header + body bytes.Buffer } func (r *fakeResponse) Flush() {} diff --git a/session.go b/session.go index 424ec47f6f9e22bcc98ca3e632c7d6583749b113..e7f07a65080d2006b0320e0195463eac38406ac0 100644 --- a/session.go +++ b/session.go @@ -117,6 +117,13 @@ func (r *KeycloakProxy) getSessionToken(cx *gin.Context) (jose.JWT, bool, error) return jwt, isBearer, nil } +// getSession is a helper methods for those whom dont care if it's a bearer token +func (r *KeycloakProxy) getSession(cx *gin.Context) (jose.JWT, error) { + token, _, err := r.getSessionToken(cx) + + return token, err +} + // getSessionState retrieves the session state from the request func (r *KeycloakProxy) getSessionState(cx *gin.Context) (*sessionState, error) { // step: find the session data cookie @@ -193,7 +200,7 @@ func (r *KeycloakProxy) getUserContext(token jose.JWT) (*userContext, error) { // createSession creates a session cookie with the access token func (r *KeycloakProxy) createSession(token jose.JWT, expires time.Time, cx *gin.Context) error { - http.SetCookie(cx.Writer, createSessionCookie(token.Encode(), cx.Request.Host, expires)) + http.SetCookie(cx.Writer, createSessionCookie(token.Encode(), cx.Request.Host, expires.Add(time.Duration(5)*time.Minute))) return nil }