Skip to content

Commit 5376286

Browse files
committed
Token Encryption (louketo#217)
* Token Encryption - adding a --enable-encrypted-token to permit access token encryption * - fixing up the comments in the Config struct
1 parent f6b183c commit 5376286

14 files changed

+117
-119
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ FEATURES
1212
* the order of the resources are no longer important, the framework will handle the routing
1313
* improved the overall spec of the proxy by removing URL inspection and prefix checking
1414
* removed the CORS implementation and using the default echo middles, which is more compliant
15+
* added the --enable-encrypted-token option to enable encrypting the access token:wq
1516

1617
BREAKING CHANGES:
1718
* the proxy no longer uses prefixes for resources, if you wish to use wildcard urls you need

Diff for: README.md

+14-10
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@
2525
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.
2626

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

3232
USAGE:
3333
keycloak-proxy [options]
3434

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

3838
AUTHOR:
3939
@@ -55,6 +55,7 @@ GLOBAL OPTIONS:
5555
--upstream-url value url for the upstream endpoint you wish to proxy [$PROXY_UPSTREAM_URL]
5656
--resources value list of resources 'uri=/admin|methods=GET,PUT|roles=role1,role2'
5757
--headers value custom headers to the upstream request, key=value
58+
--enable-encrypted-token indicates you want the access token encrypted (default: false)
5859
--enable-logging enable http logging of the requests (default: false)
5960
--enable-json-logging switch on json logging rather than text (default: false)
6061
--enable-forwarding enables the forwarding proxy mode, signing outbound request (default: false)
@@ -230,13 +231,13 @@ Note the HTTP routing rules following the guidelines from [echo](https://echo.la
230231
231232
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)*
232233
233-
``` shell
234+
```shell
234235
bin/keycloak-proxy \
235-
--discovery-url=https://accounts.google.com/.well-known/openid-configuration \
236-
--client-id=<CLIENT_ID> \
237-
--client-secret=<CLIENT_SECRET> \
238-
--resources="uri=/*" \
239-
--verbose=true
236+
--discovery-url=https://accounts.google.com/.well-known/openid-configuration \
237+
--client-id=<CLIENT_ID> \
238+
--client-secret=<CLIENT_SECRET> \
239+
--resources="uri=/*" \
240+
--verbose=true
240241
```
241242
242243
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.
@@ -259,7 +260,6 @@ Example setup:
259260
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.
260261
261262
```YAML
262-
# kubernetes pod example
263263
- name: keycloak-proxy
264264
image: quay.io/gambol99/keycloak-proxy:latest
265265
args:
@@ -287,7 +287,7 @@ Receiver side you could setup the keycloak-proxy (--no=redirects=true) and permi
287287
288288
#### **Forwarding Signing HTTPS Connect**
289289
290-
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
290+
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.
291291
292292
```shell
293293
[jest@starfury keycloak-proxy]$ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ca.key -out ca.pem
@@ -312,6 +312,10 @@ The proxy supports http listener, though the only real requirement for this woul
312312
--enable-https-redirection
313313
```
314314
315+
#### **Access Token Encryption**
316+
317+
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.
318+
315319
#### **Upstream Headers**
316320
317321
On protected resources the upstream endpoint will receive a number of headers added by the proxy, along with an custom claims.

Diff for: config.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,11 @@ func (r *Config) isValid() error {
120120
return errors.New("the security filter must be switch on for this feature: hostnames")
121121
}
122122
}
123+
if r.EnableEncryptedToken && r.EncryptionKey == "" {
124+
return errors.New("you have not specified an encryption key for encoding the access token")
125+
}
123126
if r.EnableRefreshTokens && r.EncryptionKey == "" {
124-
return errors.New("you have not specified a encryption key for encoding the session state")
127+
return errors.New("you have not specified an encryption key for encoding the session state")
125128
}
126129
if r.EnableRefreshTokens && (len(r.EncryptionKey) != 16 && len(r.EncryptionKey) != 32) {
127130
return fmt.Errorf("the encryption key (%d) must be either 16 or 32 characters for AES-128/AES-256 selection", len(r.EncryptionKey))

Diff for: config_sample.yml

+18-16
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ enable-refresh-tokens: true
1414
enable-logging: true
1515
# log in json format
1616
enable-json-logging: true
17+
# should the access token be encrypted - you need an encryption-key if 'true'
18+
enable-encrypted-token: false
1719
# do not redirec the request, simple 307 it
1820
no-redirects: false
1921
# the location of a certificate you wish the proxy to use for TLS support
@@ -55,22 +57,22 @@ add-claims:
5557
- name
5658
# a collection of resource i.e. urls that you wish to protect
5759
resources:
58-
- uri: /admin/test
59-
# the methods on this url that should be protected, if missing, we assuming all
60-
methods:
61-
- GET
62-
# a list of roles the user must have in order to accces urls under the above
63-
roles:
64-
- openvpn:vpn-test
65-
- uri: /admin/white_listed
66-
# permits a url prefix through, bypassing the admission controls
67-
white-listed: true
68-
- uri: /admin/*
69-
methods:
70-
- GET
71-
roles:
72-
- openvpn:vpn-user
73-
- openvpn:prod-vpn
60+
- uri: /admin/test
61+
# the methods on this url that should be protected, if missing, we assuming all
62+
methods:
63+
- GET
64+
# a list of roles the user must have in order to accces urls under the above
65+
roles:
66+
- openvpn:vpn-test
67+
- uri: /admin/white_listed
68+
# permits a url prefix through, bypassing the admission controls
69+
white-listed: true
70+
- uri: /admin/*
71+
methods:
72+
- GET
73+
roles:
74+
- openvpn:vpn-user
75+
- openvpn:prod-vpn
7476

7577
# an array of origins (Access-Control-Allow-Origin)
7678
cors-origins: []

Diff for: doc.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ var (
7676
ErrRefreshTokenExpired = errors.New("the refresh token has expired")
7777
// ErrNoTokenAudience indicates their is not audience in the token
7878
ErrNoTokenAudience = errors.New("the token does not audience in claims")
79+
// ErrDecryption indicates we can't decrypt the token
80+
ErrDecryption = errors.New("failed to decrypt token")
7981
)
8082

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

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

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

200204
// Store is a url for a store resource, used to hold the refresh tokens

Diff for: handlers.go

+26-42
Original file line numberDiff line numberDiff line change
@@ -137,79 +137,78 @@ func (r *oauthProxy) oauthAuthorizationHandler(cx echo.Context) error {
137137

138138
// oauthCallbackHandler is responsible for handling the response from oauth service
139139
func (r *oauthProxy) oauthCallbackHandler(cx echo.Context) error {
140-
// step: is token verification switched on?
141140
if r.config.SkipTokenVerification {
142141
return cx.NoContent(http.StatusNotAcceptable)
143142
}
144-
// step: ensure we have a authorization code to exchange
143+
// step: ensure we have a authorization code
145144
code := cx.QueryParam("code")
146145
if code == "" {
147146
return cx.NoContent(http.StatusBadRequest)
148147
}
149148

150-
// step: create a oauth client
151149
client, err := r.getOAuthClient(r.getRedirectionURL(cx))
152150
if err != nil {
153151
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to create a oauth2 client")
154-
155152
return cx.NoContent(http.StatusInternalServerError)
156153
}
157154

158-
// step: exchange the authorization for a access token
159155
resp, err := exchangeAuthenticationCode(client, code)
160156
if err != nil {
161157
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to exchange code for access token")
162-
163158
return r.accessForbidden(cx)
164159
}
165160

166-
// step: parse decode the identity token
161+
// Flow: once we exchange the authorization code we parse the ID Token; we then check for a access token,
162+
// if a access token is present and we can decode it, we use that as the session token, otherwise we default
163+
// to the ID Token.
167164
token, identity, err := parseToken(resp.IDToken)
168165
if err != nil {
169166
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to parse id token for identity")
170-
171167
return r.accessForbidden(cx)
172168
}
169+
access, id, err := parseToken(resp.AccessToken)
170+
if err == nil {
171+
token = access
172+
identity = id
173+
} else {
174+
log.WithFields(log.Fields{"error": err.Error()}).Warn("unable to parse the access token, using id token only")
175+
}
173176

174-
// step: verify the token is valid
177+
// step: check the access token is valid
175178
if err = verifyToken(r.client, token); err != nil {
176179
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to verify the id token")
177-
178180
return r.accessForbidden(cx)
179181
}
182+
accessToken := token.Encode()
180183

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

190192
log.WithFields(log.Fields{
191193
"email": identity.Email,
192194
"expires": identity.ExpiresAt.Format(time.RFC3339),
193195
"duration": time.Until(identity.ExpiresAt).String(),
194-
}).Infof("issuing access token for user, email: %s", identity.Email)
196+
}).Info("issuing access token for user")
195197

196198
// step: does the response has a refresh token and we are NOT ignore refresh tokens?
197199
if r.config.EnableRefreshTokens && resp.RefreshToken != "" {
198-
// step: encrypt the refresh token
199-
encrypted, err := encodeText(resp.RefreshToken, r.config.EncryptionKey)
200+
var encrypted string
201+
encrypted, err = encodeText(resp.RefreshToken, r.config.EncryptionKey)
200202
if err != nil {
201203
log.WithFields(log.Fields{"error": err.Error()}).Errorf("failed to encrypt the refresh token")
202-
203204
return cx.NoContent(http.StatusInternalServerError)
204205
}
205-
206206
// drop in the access token - cookie expiration = access token
207-
r.dropAccessTokenCookie(cx.Request(), cx.Response().Writer, token.Encode(),
208-
r.getAccessCookieExpiration(token, resp.RefreshToken))
207+
r.dropAccessTokenCookie(cx.Request(), cx.Response().Writer, accessToken, r.getAccessCookieExpiration(token, resp.RefreshToken))
209208

210209
switch r.useStore() {
211210
case true:
212-
if err := r.StoreRefreshToken(token, encrypted); err != nil {
211+
if err = r.StoreRefreshToken(token, encrypted); err != nil {
213212
log.WithFields(log.Fields{"error": err.Error()}).Warnf("failed to save the refresh token in the store")
214213
}
215214
default:
@@ -222,7 +221,7 @@ func (r *oauthProxy) oauthCallbackHandler(cx echo.Context) error {
222221
}
223222
}
224223
} else {
225-
r.dropAccessTokenCookie(cx.Request(), cx.Response().Writer, token.Encode(), time.Until(identity.ExpiresAt))
224+
r.dropAccessTokenCookie(cx.Request(), cx.Response().Writer, accessToken, time.Until(identity.ExpiresAt))
226225
}
227226

228227
// step: decode the state variable
@@ -245,19 +244,15 @@ func (r *oauthProxy) oauthCallbackHandler(cx echo.Context) error {
245244
// loginHandler provide's a generic endpoint for clients to perform a user_credentials login to the provider
246245
func (r *oauthProxy) loginHandler(cx echo.Context) error {
247246
errorMsg, code, err := func() (string, int, error) {
248-
// step: check if the handler is disable
249247
if !r.config.EnableLoginHandler {
250248
return "attempt to login when login handler is disabled", http.StatusNotImplemented, errors.New("login handler disabled")
251249
}
252-
253-
// step: parse the client credentials
254250
username := cx.Request().PostFormValue("username")
255251
password := cx.Request().PostFormValue("password")
256252
if username == "" || password == "" {
257253
return "request does not have both username and password", http.StatusBadRequest, errors.New("no credentials")
258254
}
259255

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

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

309-
//
310303
// logoutHandler performs a logout
311304
// - if it's just a access token, the cookie is deleted
312305
// - if the user has a refresh token, the token is invalidated by the provider
313306
// - optionally, the user can be redirected by to a url
314-
//
315307
func (r *oauthProxy) logoutHandler(cx echo.Context) error {
316308
// the user can specify a url to redirect the back
317309
redirectURL := cx.QueryParam("redirect")
@@ -321,7 +313,6 @@ func (r *oauthProxy) logoutHandler(cx echo.Context) error {
321313
if err != nil {
322314
return cx.NoContent(http.StatusBadRequest)
323315
}
324-
325316
// step: can either use the id token or the refresh token
326317
identityToken := user.token.Encode()
327318
if refresh, err := r.retrieveRefreshToken(cx.Request(), user); err == nil {
@@ -340,15 +331,12 @@ func (r *oauthProxy) logoutHandler(cx echo.Context) error {
340331
}()
341332
}
342333

343-
// step: get the revocation endpoint from either the idp and or the user config
344334
revocationURL := defaultTo(r.config.RevocationEndpoint, r.idp.EndSessionEndpoint.String())
345-
346335
// step: do we have a revocation endpoint?
347336
if revocationURL != "" {
348337
client, err := r.client.OAuthClient()
349338
if err != nil {
350339
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to retrieve the openid client")
351-
352340
return cx.NoContent(http.StatusInternalServerError)
353341
}
354342

@@ -364,19 +352,17 @@ func (r *oauthProxy) logoutHandler(cx echo.Context) error {
364352
log.WithFields(log.Fields{"error": err.Error()}).Errorf("unable to construct the revocation request")
365353
return cx.NoContent(http.StatusInternalServerError)
366354
}
367-
368355
// step: add the authentication headers and content-type
369356
request.SetBasicAuth(encodedID, encodedSecret)
370357
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
371358

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

379-
// step: add a log for debugging
365+
// step: check the response
380366
switch response.StatusCode {
381367
case http.StatusNoContent:
382368
log.WithFields(log.Fields{
@@ -390,7 +376,6 @@ func (r *oauthProxy) logoutHandler(cx echo.Context) error {
390376
}).Errorf("invalid response from revocation endpoint")
391377
}
392378
}
393-
394379
// step: should we redirect the user
395380
if redirectURL != "" {
396381
return r.redirectToURL(redirectURL, cx)
@@ -426,7 +411,6 @@ func (r *oauthProxy) tokenHandler(cx echo.Context) error {
426411
// healthHandler is a health check handler for the service
427412
func (r *oauthProxy) healthHandler(cx echo.Context) error {
428413
cx.Response().Writer.Header().Set(versionHeader, getVersion())
429-
430414
return cx.String(http.StatusOK, "OK\n")
431415
}
432416

0 commit comments

Comments
 (0)