Skip to content

Commit

Permalink
Token Encryption (louketo#217)
Browse files Browse the repository at this point in the history
* Token Encryption

- adding a --enable-encrypted-token to permit access token encryption

* - fixing up the comments in the Config struct
  • Loading branch information
gambol99 committed May 19, 2017
1 parent f6b183c commit 5376286
Show file tree
Hide file tree
Showing 14 changed files with 117 additions and 119 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ FEATURES
* the order of the resources are no longer important, the framework will handle the routing
* improved the overall spec of the proxy by removing URL inspection and prefix checking
* removed the CORS implementation and using the default echo middles, which is more compliant
* added the --enable-encrypted-token option to enable encrypting the access token:wq

BREAKING CHANGES:
* the proxy no longer uses prefixes for resources, if you wish to use wildcard urls you need
Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
Keycloak-proxy is a proxy service which at the risk of stating the obvious integrates with the [Keycloak](https://github.com/keycloak/keycloak) authentication service. Although technically the service has no dependency on Keycloak itself and would quite happily work with any OpenID provider. The service supports both access tokens in browser cookie or bearer tokens.

```shell
[jest@starfury keycloak-proxy]$ bin/keycloak-proxy --help
[jest@starfury keycloak-proxy]$ bin/keycloak-proxy help
NAME:
keycloak-proxy - is a proxy using the keycloak service for auth and authorization

USAGE:
keycloak-proxy [options]

VERSION:
v2.1.0 (git+sha: f74c713)
v2.1.0 (git+sha: 960c2e5-dirty, built: 25/04/2017)

AUTHOR:
Rohith <[email protected]>
Expand All @@ -55,6 +55,7 @@ GLOBAL OPTIONS:
--upstream-url value url for the upstream endpoint you wish to proxy [$PROXY_UPSTREAM_URL]
--resources value list of resources 'uri=/admin|methods=GET,PUT|roles=role1,role2'
--headers value custom headers to the upstream request, key=value
--enable-encrypted-token indicates you want the access token encrypted (default: false)
--enable-logging enable http logging of the requests (default: false)
--enable-json-logging switch on json logging rather than text (default: false)
--enable-forwarding enables the forwarding proxy mode, signing outbound request (default: false)
Expand Down Expand Up @@ -230,13 +231,13 @@ Note the HTTP routing rules following the guidelines from [echo](https://echo.la
Although the role extensions do require a Keycloak IDP or at the very least a IDP that produces a token which contains roles, there's nothing stopping you from using it against any OpenID providers, such as Google. Go to the Google Developers Console and create a new application *(via "Enable and Manage APIs -> Credentials)*. Once you've created the application, take the client id, secret and make sure you've added the callback url to the application scope *(using the default this would be http://127.0.0.1:3000/oauth/callback)*
``` shell
```shell
bin/keycloak-proxy \
--discovery-url=https://accounts.google.com/.well-known/openid-configuration \
--client-id=<CLIENT_ID> \
--client-secret=<CLIENT_SECRET> \
--resources="uri=/*" \
--verbose=true
--discovery-url=https://accounts.google.com/.well-known/openid-configuration \
--client-id=<CLIENT_ID> \
--client-secret=<CLIENT_SECRET> \
--resources="uri=/*" \
--verbose=true
```
Open a browser an go to http://127.0.0.1:3000 and you should be redirected to Google for authenticate and back the application when done and you should see something like the below.
Expand All @@ -259,7 +260,6 @@ Example setup:
You have collection of micro-services which are permitted to speak to one another; you've already setup the credentials, roles, clients etc in Keycloak, providing granular role controls over issue tokens.
```YAML
# kubernetes pod example
- name: keycloak-proxy
image: quay.io/gambol99/keycloak-proxy:latest
args:
Expand Down Expand Up @@ -287,7 +287,7 @@ Receiver side you could setup the keycloak-proxy (--no=redirects=true) and permi
#### **Forwarding Signing HTTPS Connect**
Handling HTTPS requires man in the middling the TLS connection. By default if no -tls-ca-cert and -tls-ca-key is provided the proxy will use the default certificate. If you wish to verify the trust, you'll need to generate a CA, for example
Handling HTTPS requires man in the middling the TLS connection. By default if no -tls-ca-cert and -tls-ca-key is provided the proxy will use the default certificate. If you wish to verify the trust, you'll need to generate a CA, for example.
```shell
[jest@starfury keycloak-proxy]$ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ca.key -out ca.pem
Expand All @@ -312,6 +312,10 @@ The proxy supports http listener, though the only real requirement for this woul
--enable-https-redirection
```
#### **Access Token Encryption**
By default the session token *(i.e. access/id token)* is placed into a cookie in plaintext. If prefer you to encrypt the session cookie using --enable-encrypted-token and --encryption-key options. Note, the access token forwarded in the X-Auth-Token header to upstream is unaffected.
#### **Upstream Headers**
On protected resources the upstream endpoint will receive a number of headers added by the proxy, along with an custom claims.
Expand Down
5 changes: 4 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ func (r *Config) isValid() error {
return errors.New("the security filter must be switch on for this feature: hostnames")
}
}
if r.EnableEncryptedToken && r.EncryptionKey == "" {
return errors.New("you have not specified an encryption key for encoding the access token")
}
if r.EnableRefreshTokens && r.EncryptionKey == "" {
return errors.New("you have not specified a encryption key for encoding the session state")
return errors.New("you have not specified an encryption key for encoding the session state")
}
if r.EnableRefreshTokens && (len(r.EncryptionKey) != 16 && len(r.EncryptionKey) != 32) {
return fmt.Errorf("the encryption key (%d) must be either 16 or 32 characters for AES-128/AES-256 selection", len(r.EncryptionKey))
Expand Down
34 changes: 18 additions & 16 deletions config_sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ enable-refresh-tokens: true
enable-logging: true
# log in json format
enable-json-logging: true
# should the access token be encrypted - you need an encryption-key if 'true'
enable-encrypted-token: false
# 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
Expand Down Expand Up @@ -55,22 +57,22 @@ add-claims:
- name
# a collection of resource i.e. urls that you wish to protect
resources:
- uri: /admin/test
# the methods on this url that should be protected, if missing, we assuming all
methods:
- GET
# a list of roles the user must have in order to accces urls under the above
roles:
- openvpn:vpn-test
- uri: /admin/white_listed
# permits a url prefix through, bypassing the admission controls
white-listed: true
- uri: /admin/*
methods:
- GET
roles:
- openvpn:vpn-user
- openvpn:prod-vpn
- uri: /admin/test
# the methods on this url that should be protected, if missing, we assuming all
methods:
- GET
# a list of roles the user must have in order to accces urls under the above
roles:
- openvpn:vpn-test
- uri: /admin/white_listed
# permits a url prefix through, bypassing the admission controls
white-listed: true
- uri: /admin/*
methods:
- GET
roles:
- openvpn:vpn-user
- openvpn:prod-vpn

# an array of origins (Access-Control-Allow-Origin)
cors-origins: []
Expand Down
8 changes: 6 additions & 2 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ var (
ErrRefreshTokenExpired = errors.New("the refresh token has expired")
// ErrNoTokenAudience indicates their is not audience in the token
ErrNoTokenAudience = errors.New("the token does not audience in claims")
// ErrDecryption indicates we can't decrypt the token
ErrDecryption = errors.New("failed to decrypt token")
)

// Resource represents a url resource to protect
Expand Down Expand Up @@ -119,6 +121,8 @@ type Config struct {
// Headers permits adding customs headers across the board
Headers map[string]string `json:"headers" yaml:"headers" usage:"custom headers to the upstream request, key=value"`

// EnableEncryptedToken indicates the access token should be encoded
EnableEncryptedToken bool `json:"enable-encrypted-token" yaml:"enable-encrypted-token" usage:"enable encryption for the access tokens"`
// EnableLogging indicates if we should log all the requests
EnableLogging bool `json:"enable-logging" yaml:"enable-logging" usage:"enable http logging of the requests"`
// EnableJSONLogging is the logging format
Expand Down Expand Up @@ -189,12 +193,12 @@ type Config struct {
CorsHeaders []string `json:"cors-headers" yaml:"cors-headers" usage:"set of headers to add to the CORS access control (Access-Control-Allow-Headers)"`
// CorsExposedHeaders are the exposed header fields
CorsExposedHeaders []string `json:"cors-exposed-headers" yaml:"cors-exposed-headers" usage:"expose cors headers access control (Access-Control-Expose-Headers)"`
// CorsCredentials set the creds flag
// CorsCredentials set the credentials flag
CorsCredentials bool `json:"cors-credentials" yaml:"cors-credentials" usage:"credentials access control header (Access-Control-Allow-Credentials)"`
// CorsMaxAge is the age for CORS
CorsMaxAge time.Duration `json:"cors-max-age" yaml:"cors-max-age" usage:"max age applied to cors headers (Access-Control-Max-Age)"`

// Hostname is a list of hostname's the service should response to
// Hostnames is a list of hostname's the service should response to
Hostnames []string `json:"hostnames" yaml:"hostnames" usage:"list of hostnames the service will respond to"`

// Store is a url for a store resource, used to hold the refresh tokens
Expand Down
68 changes: 26 additions & 42 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,79 +137,78 @@ func (r *oauthProxy) oauthAuthorizationHandler(cx echo.Context) error {

// oauthCallbackHandler is responsible for handling the response from oauth service
func (r *oauthProxy) oauthCallbackHandler(cx echo.Context) error {
// step: is token verification switched on?
if r.config.SkipTokenVerification {
return cx.NoContent(http.StatusNotAcceptable)
}
// step: ensure we have a authorization code to exchange
// step: ensure we have a authorization code
code := cx.QueryParam("code")
if code == "" {
return cx.NoContent(http.StatusBadRequest)
}

// step: create a oauth client
client, err := r.getOAuthClient(r.getRedirectionURL(cx))
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to create a oauth2 client")

return cx.NoContent(http.StatusInternalServerError)
}

// step: exchange the authorization for a access token
resp, err := exchangeAuthenticationCode(client, code)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to exchange code for access token")

return r.accessForbidden(cx)
}

// step: parse decode the identity token
// Flow: once we exchange the authorization code we parse the ID Token; we then check for a access token,
// if a access token is present and we can decode it, we use that as the session token, otherwise we default
// to the ID Token.
token, identity, err := parseToken(resp.IDToken)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to parse id token for identity")

return r.accessForbidden(cx)
}
access, id, err := parseToken(resp.AccessToken)
if err == nil {
token = access
identity = id
} else {
log.WithFields(log.Fields{"error": err.Error()}).Warn("unable to parse the access token, using id token only")
}

// step: verify the token is valid
// step: check the access token is valid
if err = verifyToken(r.client, token); err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to verify the id token")

return r.accessForbidden(cx)
}
accessToken := token.Encode()

// step: attempt to decode the access token else we default to the id token
access, id, err := parseToken(resp.AccessToken)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to parse the access token, using id token only")
} else {
token = access
identity = id
// step: are we encrypting the access token?
if r.config.EnableEncryptedToken {
if accessToken, err = encodeText(accessToken, r.config.EncryptionKey); err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("unable to encode the access token")
return cx.NoContent(http.StatusInternalServerError)
}
}

log.WithFields(log.Fields{
"email": identity.Email,
"expires": identity.ExpiresAt.Format(time.RFC3339),
"duration": time.Until(identity.ExpiresAt).String(),
}).Infof("issuing access token for user, email: %s", identity.Email)
}).Info("issuing access token for user")

// step: does the response has a refresh token and we are NOT ignore refresh tokens?
if r.config.EnableRefreshTokens && resp.RefreshToken != "" {
// step: encrypt the refresh token
encrypted, err := encodeText(resp.RefreshToken, r.config.EncryptionKey)
var encrypted string
encrypted, err = encodeText(resp.RefreshToken, r.config.EncryptionKey)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("failed to encrypt the refresh token")

return cx.NoContent(http.StatusInternalServerError)
}

// drop in the access token - cookie expiration = access token
r.dropAccessTokenCookie(cx.Request(), cx.Response().Writer, token.Encode(),
r.getAccessCookieExpiration(token, resp.RefreshToken))
r.dropAccessTokenCookie(cx.Request(), cx.Response().Writer, accessToken, r.getAccessCookieExpiration(token, resp.RefreshToken))

switch r.useStore() {
case true:
if err := r.StoreRefreshToken(token, encrypted); err != nil {
if err = r.StoreRefreshToken(token, encrypted); err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Warnf("failed to save the refresh token in the store")
}
default:
Expand All @@ -222,7 +221,7 @@ func (r *oauthProxy) oauthCallbackHandler(cx echo.Context) error {
}
}
} else {
r.dropAccessTokenCookie(cx.Request(), cx.Response().Writer, token.Encode(), time.Until(identity.ExpiresAt))
r.dropAccessTokenCookie(cx.Request(), cx.Response().Writer, accessToken, time.Until(identity.ExpiresAt))
}

// step: decode the state variable
Expand All @@ -245,19 +244,15 @@ func (r *oauthProxy) oauthCallbackHandler(cx echo.Context) error {
// loginHandler provide's a generic endpoint for clients to perform a user_credentials login to the provider
func (r *oauthProxy) loginHandler(cx echo.Context) error {
errorMsg, code, err := func() (string, int, error) {
// step: check if the handler is disable
if !r.config.EnableLoginHandler {
return "attempt to login when login handler is disabled", http.StatusNotImplemented, errors.New("login handler disabled")
}

// step: parse the client credentials
username := cx.Request().PostFormValue("username")
password := cx.Request().PostFormValue("password")
if username == "" || password == "" {
return "request does not have both username and password", http.StatusBadRequest, errors.New("no credentials")
}

// step: get the client
client, err := r.client.OAuthClient()
if err != nil {
return "unable to create the oauth client for user_credentials request", http.StatusInternalServerError, err
Expand All @@ -271,7 +266,6 @@ func (r *oauthProxy) loginHandler(cx echo.Context) error {
return "unable to request the access token via grant_type 'password'", http.StatusInternalServerError, err
}

// step: parse the token
_, identity, err := parseToken(token.AccessToken)
if err != nil {
return "unable to decode the access token", http.StatusNotImplemented, err
Expand Down Expand Up @@ -306,12 +300,10 @@ func emptyHandler(cx echo.Context) error {
return nil
}

//
// logoutHandler performs a logout
// - if it's just a access token, the cookie is deleted
// - if the user has a refresh token, the token is invalidated by the provider
// - optionally, the user can be redirected by to a url
//
func (r *oauthProxy) logoutHandler(cx echo.Context) error {
// the user can specify a url to redirect the back
redirectURL := cx.QueryParam("redirect")
Expand All @@ -321,7 +313,6 @@ func (r *oauthProxy) logoutHandler(cx echo.Context) error {
if err != nil {
return cx.NoContent(http.StatusBadRequest)
}

// step: can either use the id token or the refresh token
identityToken := user.token.Encode()
if refresh, err := r.retrieveRefreshToken(cx.Request(), user); err == nil {
Expand All @@ -340,15 +331,12 @@ func (r *oauthProxy) logoutHandler(cx echo.Context) error {
}()
}

// step: get the revocation endpoint from either the idp and or the user config
revocationURL := defaultTo(r.config.RevocationEndpoint, r.idp.EndSessionEndpoint.String())

// step: do we have a revocation endpoint?
if revocationURL != "" {
client, err := r.client.OAuthClient()
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to retrieve the openid client")

return cx.NoContent(http.StatusInternalServerError)
}

Expand All @@ -364,19 +352,17 @@ func (r *oauthProxy) logoutHandler(cx echo.Context) error {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to construct the revocation request")
return cx.NoContent(http.StatusInternalServerError)
}

// step: add the authentication headers and content-type
request.SetBasicAuth(encodedID, encodedSecret)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

// step: attempt to make the
response, err := client.HttpClient().Do(request)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to post to revocation endpoint")
return nil
}

// step: add a log for debugging
// step: check the response
switch response.StatusCode {
case http.StatusNoContent:
log.WithFields(log.Fields{
Expand All @@ -390,7 +376,6 @@ func (r *oauthProxy) logoutHandler(cx echo.Context) error {
}).Errorf("invalid response from revocation endpoint")
}
}

// step: should we redirect the user
if redirectURL != "" {
return r.redirectToURL(redirectURL, cx)
Expand Down Expand Up @@ -426,7 +411,6 @@ func (r *oauthProxy) tokenHandler(cx echo.Context) error {
// healthHandler is a health check handler for the service
func (r *oauthProxy) healthHandler(cx echo.Context) error {
cx.Response().Writer.Header().Set(versionHeader, getVersion())

return cx.String(http.StatusOK, "OK\n")
}

Expand Down
Loading

0 comments on commit 5376286

Please sign in to comment.