git.iko.soy/typeable/oauth2-proxy
on master help sign in
Merge pull request #80 from 18F/pass-access-token

Pass the access token to the upstream server

commit864d4787e93cf9ee31c89f4a66bd1d99e6837d2e authorJehiah Czebotar <jehiah@gmail.com> dateFri 3 Apr 2015, 15:45 (12 years ago) parent66d4d72ad3c9a8
7 files changed+305−9
README.md+1 cookies.go+38 cookies_test.go+23 main.go+1 oauthproxy.go+58−3 oauthproxy_test.go+178−2 options.go+6−4
README.md+1
@@ -76,6 +76,7 @@ Usage of google_auth_proxy:
7676 -htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
7777 -http-address="127.0.0.1:4180": [http://]<addr>:<port> or unix://<path> to listen on for HTTP clients
7878 -login-url="": Authentication endpoint
79+ -pass-access-token=false: pass OAuth access_token to upstream via X-Forwarded-Access-Token header
7980 -pass-basic-auth=true: pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream
8081 -pass-host-header=true: pass the request Host Header to upstream
8182 -profile-url="": Profile access endpoint
cookies.go+38
@@ -1,10 +1,14 @@
11package main
22
33import (
4+ "crypto/aes"
5+ "crypto/cipher"
46 "crypto/hmac"
7+ "crypto/rand"
58 "crypto/sha1"
69 "encoding/base64"
710 "fmt"
11+ "io"
812 "net/http"
913 "strconv"
1014 "strings"
@@ -59,3 +63,37 @@ func checkHmac(input, expected string) bool {
5963 }
6064 return false
6165}
66+
67+func encodeAccessToken(aes_cipher cipher.Block, access_token string) (string, error) {
68+ ciphertext := make([]byte, aes.BlockSize+len(access_token))
69+ iv := ciphertext[:aes.BlockSize]
70+ if _, err := io.ReadFull(rand.Reader, iv); err != nil {
71+ return "", fmt.Errorf("failed to create access code initialization vector")
72+ }
73+
74+ stream := cipher.NewCFBEncrypter(aes_cipher, iv)
75+ stream.XORKeyStream(ciphertext[aes.BlockSize:], []byte(access_token))
76+ return base64.StdEncoding.EncodeToString(ciphertext), nil
77+}
78+
79+func decodeAccessToken(aes_cipher cipher.Block, encoded_access_token string) (string, error) {
80+ encrypted_access_token, err := base64.StdEncoding.DecodeString(
81+ encoded_access_token)
82+
83+ if err != nil {
84+ return "", fmt.Errorf("failed to decode access token")
85+ }
86+
87+ if len(encrypted_access_token) < aes.BlockSize {
88+ return "", fmt.Errorf("encrypted access token should be "+
89+ "at least %d bytes, but is only %d bytes",
90+ aes.BlockSize, len(encrypted_access_token))
91+ }
92+
93+ iv := encrypted_access_token[:aes.BlockSize]
94+ encrypted_access_token = encrypted_access_token[aes.BlockSize:]
95+ stream := cipher.NewCFBDecrypter(aes_cipher, iv)
96+ stream.XORKeyStream(encrypted_access_token, encrypted_access_token)
97+
98+ return string(encrypted_access_token), nil
99+}
cookies_test.gonew file+23
@@ -0,0 +1,23 @@
1+package main
2+
3+import (
4+ "crypto/aes"
5+ "github.com/bmizerany/assert"
6+ "testing"
7+)
8+
9+func TestEncodeAndDecodeAccessToken(t *testing.T) {
10+ const key = "0123456789abcdefghijklmnopqrstuv"
11+ const access_token = "my access token"
12+ c, err := aes.NewCipher([]byte(key))
13+ assert.Equal(t, nil, err)
14+
15+ encoded_token, err := encodeAccessToken(c, access_token)
16+ assert.Equal(t, nil, err)
17+
18+ decoded_token, err := decodeAccessToken(c, encoded_token)
19+ assert.Equal(t, nil, err)
20+
21+ assert.NotEqual(t, access_token, encoded_token)
22+ assert.Equal(t, access_token, decoded_token)
23+}
main.go+1
@@ -30,6 +30,7 @@ func main() {
3030 flagSet.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"")
3131 flagSet.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint. If multiple, routing is based on path")
3232 flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream")
33+ flagSet.Bool("pass-access-token", false, "pass OAuth access_token to upstream via X-Forwarded-Access-Token header")
3334 flagSet.Bool("pass-host-header", true, "pass the request Host Header to upstream")
3435 flagSet.Var(&skipAuthRegex, "skip-auth-regex", "bypass authentication for requests path's that match (may be given multiple times)")
3536
oauthproxy.go+58−3
@@ -2,6 +2,8 @@ package main
22
33import (
44 "bytes"
5+ "crypto/aes"
6+ "crypto/cipher"
57 "encoding/base64"
68 "errors"
79 "fmt"
@@ -45,6 +47,8 @@ type OauthProxy struct {
4547 DisplayHtpasswdForm bool
4648 serveMux http.Handler
4749 PassBasicAuth bool
50+ PassAccessToken bool
51+ AesCipher cipher.Block
4852 skipAuthRegex []string
4953 compiledRegex []*regexp.Regexp
5054 templates *template.Template
@@ -116,6 +120,29 @@ func NewOauthProxy(opts *Options, validator func(string) bool) *OauthProxy {
116120
117121 log.Printf("Cookie settings: secure (https):%v httponly:%v expiry:%s domain:%s", opts.CookieSecure, opts.CookieHttpOnly, opts.CookieExpire, domain)
118122
123+ var aes_cipher cipher.Block
124+
125+ if opts.PassAccessToken == true {
126+ valid_cookie_secret_size := false
127+ for _, i := range []int{16, 24, 32} {
128+ if len(opts.CookieSecret) == i {
129+ valid_cookie_secret_size = true
130+ }
131+ }
132+ if valid_cookie_secret_size == false {
133+ log.Fatal("cookie_secret must be 16, 24, or 32 bytes " +
134+ "to create an AES cipher when " +
135+ "pass_access_token == true")
136+ }
137+
138+ var err error
139+ aes_cipher, err = aes.NewCipher([]byte(opts.CookieSecret))
140+ if err != nil {
141+ log.Fatal("error creating AES cipher with "+
142+ "pass_access_token == true: %s", err)
143+ }
144+ }
145+
119146 return &OauthProxy{
120147 CookieKey: "_oauthproxy",
121148 CookieSeed: opts.CookieSecret,
@@ -136,6 +163,8 @@ func NewOauthProxy(opts *Options, validator func(string) bool) *OauthProxy {
136163 skipAuthRegex: opts.SkipAuthRegex,
137164 compiledRegex: opts.CompiledRegex,
138165 PassBasicAuth: opts.PassBasicAuth,
166+ PassAccessToken: opts.PassAccessToken,
167+ AesCipher: aes_cipher,
139168 templates: loadTemplates(opts.CustomTemplatesDir),
140169 }
141170}
@@ -337,6 +366,7 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
337366 var ok bool
338367 var user string
339368 var email string
369+ var access_token string
340370
341371 if req.URL.Path == pingPath {
342372 p.PingPage(rw)
@@ -390,7 +420,7 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
390420 return
391421 }
392422
393− _, email, err := p.redeemCode(req.Host, req.Form.Get("code"))
423+ access_token, email, err = p.redeemCode(req.Host, req.Form.Get("code"))
394424 if err != nil {
395425 log.Printf("%s error redeeming code %s", remoteAddr, err)
396426 p.ErrorPage(rw, 500, "Internal Error", err.Error())
@@ -405,7 +435,20 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
405435 // set cookie, or deny
406436 if p.Validator(email) {
407437 log.Printf("%s authenticating %s completed", remoteAddr, email)
408− p.SetCookie(rw, req, email)
438+ encoded_token := ""
439+ if p.PassAccessToken {
440+ encoded_token, err = encodeAccessToken(p.AesCipher, access_token)
441+ if err != nil {
442+ log.Printf("error encoding access token: %s", err)
443+ }
444+ }
445+ access_token = ""
446+
447+ if encoded_token != "" {
448+ p.SetCookie(rw, req, email+"|"+encoded_token)
449+ } else {
450+ p.SetCookie(rw, req, email)
451+ }
409452 http.Redirect(rw, req, redirect, 302)
410453 return
411454 } else {
@@ -417,7 +460,16 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
417460 if !ok {
418461 cookie, err := req.Cookie(p.CookieKey)
419462 if err == nil {
420− email, ok = validateCookie(cookie, p.CookieSeed)
463+ var value string
464+ value, ok = validateCookie(cookie, p.CookieSeed)
465+ components := strings.Split(value, "|")
466+ email = components[0]
467+ if len(components) == 2 {
468+ access_token, err = decodeAccessToken(p.AesCipher, components[1])
469+ if err != nil {
470+ log.Printf("error decoding access token: %s", err)
471+ }
472+ }
421473 user = strings.Split(email, "@")[0]
422474 }
423475 }
@@ -437,6 +489,9 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
437489 req.Header["X-Forwarded-User"] = []string{user}
438490 req.Header["X-Forwarded-Email"] = []string{email}
439491 }
492+ if access_token != "" {
493+ req.Header["X-Forwarded-Access-Token"] = []string{access_token}
494+ }
440495 if email == "" {
441496 rw.Header().Set("GAP-Auth", user)
442497 } else {
oauthproxy_test.go+178−2
@@ -1,12 +1,17 @@
11package main
22
33import (
4+ "github.com/bitly/go-simplejson"
5+ "github.com/bitly/google_auth_proxy/providers"
6+ "github.com/bmizerany/assert"
47 "io/ioutil"
58 "net"
69 "net/http"
710 "net/http/httptest"
811 "net/url"
12+ "strings"
913 "testing"
14+ "time"
1015)
1116
1217func TestNewReverseProxy(t *testing.T) {
@@ -18,8 +23,7 @@ func TestNewReverseProxy(t *testing.T) {
1823 defer backend.Close()
1924
2025 backendURL, _ := url.Parse(backend.URL)
21− backendHostname := "upstream.127.0.0.1.xip.io"
22− _, backendPort, _ := net.SplitHostPort(backendURL.Host)
26+ backendHostname, backendPort, _ := net.SplitHostPort(backendURL.Host)
2327 backendHost := net.JoinHostPort(backendHostname, backendPort)
2428 proxyURL, _ := url.Parse(backendURL.Scheme + "://" + backendHost + "/")
2529
@@ -61,3 +65,175 @@ func TestEncodedSlashes(t *testing.T) {
6165 t.Errorf("got bad request %q expected %q", seen, encodedPath)
6266 }
6367}
68+
69+type TestProvider struct {
70+ *providers.ProviderData
71+ EmailAddress string
72+}
73+
74+func (tp *TestProvider) GetEmailAddress(unused_auth_response *simplejson.Json,
75+ unused_access_token string) (string, error) {
76+ return tp.EmailAddress, nil
77+}
78+
79+type PassAccessTokenTest struct {
80+ provider_server *httptest.Server
81+ proxy *OauthProxy
82+ opts *Options
83+}
84+
85+type PassAccessTokenTestOptions struct {
86+ PassAccessToken bool
87+}
88+
89+func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) *PassAccessTokenTest {
90+ t := &PassAccessTokenTest{}
91+
92+ t.provider_server = httptest.NewServer(
93+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94+ url := r.URL
95+ payload := ""
96+ switch url.Path {
97+ case "/oauth/token":
98+ payload = `{"access_token": "my_auth_token"}`
99+ default:
100+ token_header := r.Header["X-Forwarded-Access-Token"]
101+ if len(token_header) != 0 {
102+ payload = token_header[0]
103+ } else {
104+ payload = "No access token found."
105+ }
106+ }
107+ w.WriteHeader(200)
108+ w.Write([]byte(payload))
109+ }))
110+
111+ t.opts = NewOptions()
112+ t.opts.Upstreams = append(t.opts.Upstreams, t.provider_server.URL)
113+ // The CookieSecret must be 32 bytes in order to create the AES
114+ // cipher.
115+ t.opts.CookieSecret = "xyzzyplughxyzzyplughxyzzyplughxp"
116+ t.opts.ClientID = "bazquux"
117+ t.opts.ClientSecret = "foobar"
118+ t.opts.CookieSecure = false
119+ t.opts.PassAccessToken = opts.PassAccessToken
120+ t.opts.Validate()
121+
122+ provider_url, _ := url.Parse(t.provider_server.URL)
123+ const email_address = "michael.bland@gsa.gov"
124+
125+ t.opts.provider = &TestProvider{
126+ ProviderData: &providers.ProviderData{
127+ ProviderName: "Test Provider",
128+ LoginUrl: &url.URL{
129+ Scheme: "http",
130+ Host: provider_url.Host,
131+ Path: "/oauth/authorize",
132+ },
133+ RedeemUrl: &url.URL{
134+ Scheme: "http",
135+ Host: provider_url.Host,
136+ Path: "/oauth/token",
137+ },
138+ ProfileUrl: &url.URL{
139+ Scheme: "http",
140+ Host: provider_url.Host,
141+ Path: "/api/v1/profile",
142+ },
143+ Scope: "profile.email",
144+ },
145+ EmailAddress: email_address,
146+ }
147+
148+ t.proxy = NewOauthProxy(t.opts, func(email string) bool {
149+ return email == email_address
150+ })
151+ return t
152+}
153+
154+func Close(t *PassAccessTokenTest) {
155+ t.provider_server.Close()
156+}
157+
158+func getCallbackEndpoint(pac_test *PassAccessTokenTest) (http_code int, cookie string) {
159+ rw := httptest.NewRecorder()
160+ req, err := http.NewRequest("GET", "/oauth2/callback?code=callback_code",
161+ strings.NewReader(""))
162+ if err != nil {
163+ return 0, ""
164+ }
165+ pac_test.proxy.ServeHTTP(rw, req)
166+ return rw.Code, rw.HeaderMap["Set-Cookie"][0]
167+}
168+
169+func getRootEndpoint(pac_test *PassAccessTokenTest, cookie string) (http_code int,
170+ access_token string) {
171+ cookie_key := pac_test.proxy.CookieKey
172+ var value string
173+ key_prefix := cookie_key + "="
174+
175+ for _, field := range strings.Split(cookie, "; ") {
176+ value = strings.TrimPrefix(field, key_prefix)
177+ if value != field {
178+ break
179+ } else {
180+ value = ""
181+ }
182+ }
183+ if value == "" {
184+ return 0, ""
185+ }
186+
187+ req, err := http.NewRequest("GET", "/", strings.NewReader(""))
188+ if err != nil {
189+ return 0, ""
190+ }
191+ req.AddCookie(&http.Cookie{
192+ Name: cookie_key,
193+ Value: value,
194+ Path: "/",
195+ Expires: time.Now().Add(time.Duration(24)),
196+ HttpOnly: true,
197+ })
198+
199+ rw := httptest.NewRecorder()
200+ pac_test.proxy.ServeHTTP(rw, req)
201+ return rw.Code, rw.Body.String()
202+}
203+
204+func TestForwardAccessTokenUpstream(t *testing.T) {
205+ pac_test := NewPassAccessTokenTest(PassAccessTokenTestOptions{
206+ PassAccessToken: true,
207+ })
208+ defer Close(pac_test)
209+
210+ // A successful validation will redirect and set the auth cookie.
211+ code, cookie := getCallbackEndpoint(pac_test)
212+ assert.Equal(t, 302, code)
213+ assert.NotEqual(t, nil, cookie)
214+
215+ // Now we make a regular request; the access_token from the cookie is
216+ // forwarded as the "X-Forwarded-Access-Token" header. The token is
217+ // read by the test provider server and written in the response body.
218+ code, payload := getRootEndpoint(pac_test, cookie)
219+ assert.Equal(t, 200, code)
220+ assert.Equal(t, "my_auth_token", payload)
221+}
222+
223+func TestDoNotForwardAccessTokenUpstream(t *testing.T) {
224+ pac_test := NewPassAccessTokenTest(PassAccessTokenTestOptions{
225+ PassAccessToken: false,
226+ })
227+ defer Close(pac_test)
228+
229+ // A successful validation will redirect and set the auth cookie.
230+ code, cookie := getCallbackEndpoint(pac_test)
231+ assert.Equal(t, 302, code)
232+ assert.NotEqual(t, nil, cookie)
233+
234+ // Now we make a regular request, but the access token header should
235+ // not be present.
236+ code, payload := getRootEndpoint(pac_test, cookie)
237+ assert.Equal(t, 200, code)
238+ assert.Equal(t, "No access token found.", payload)
239+}
options.go+6−4
@@ -30,10 +30,11 @@ type Options struct {
3030 CookieSecure bool `flag:"cookie-secure" cfg:"cookie_secure"`
3131 CookieHttpOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"`
3232
33− Upstreams []string `flag:"upstream" cfg:"upstreams"`
34− SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"`
35− PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"`
36− PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header"`
33+ Upstreams []string `flag:"upstream" cfg:"upstreams"`
34+ SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"`
35+ PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"`
36+ PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"`
37+ PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header"`
3738
3839 // These options allow for other providers besides Google, with
3940 // potential overrides.
@@ -61,6 +62,7 @@ func NewOptions() *Options {
6162 CookieHttpOnly: true,
6263 CookieExpire: time.Duration(168) * time.Hour,
6364 PassBasicAuth: true,
65+ PassAccessToken: false,
6466 PassHostHeader: true,
6567 RequestLogging: true,
6668 }