├── .gitignore ├── Gopkg.toml ├── Gopkg.lock ├── LICENSE ├── README.md ├── jwtauth.go └── jwtauth_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml for jwtauth 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | 7 | [[constraint]] 8 | branch = "master" 9 | name = "github.com/lestrrat/go-jwx" 10 | 11 | [[constraint]] 12 | name = "github.com/lpar/serial" 13 | version = "^1.0.0" 14 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "github.com/lestrrat/go-jwx" 7 | packages = ["buffer","internal/concatkdf","internal/debug","internal/emap","internal/padbuf","jwa","jwe","jwe/aescbc","jwk","jwt"] 8 | revision = "10335d0ed76ac4161f6015f9e1a4e06a1ea3a795" 9 | 10 | [[projects]] 11 | name = "github.com/lpar/serial" 12 | packages = ["."] 13 | revision = "63434b4004341dd45fc398aebbb1923afb2dfea3" 14 | version = "v1.0.0" 15 | 16 | [[projects]] 17 | name = "github.com/pkg/errors" 18 | packages = ["."] 19 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 20 | version = "v0.8.0" 21 | 22 | [solve-meta] 23 | analyzer-name = "dep" 24 | analyzer-version = 1 25 | inputs-digest = "014202b5ae4cbba7a58e922edcd3a355cceda3495a4cacad1630a5bed205015e" 26 | solver-name = "gps-cdcl" 27 | solver-version = 1 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, IBM Corportaion. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of serial nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # jwtauth 3 | 4 | This library implements (mostly) stateless web session authentication using 5 | JSON Web Tokens (JWT). 6 | 7 | As of 2018, I recommend [pasetosession](https://github.com/lpar/pasetosession) instead. 8 | 9 | Initiating login and checking credentials is left to the caller, as it depends 10 | heavily on the sign-in technology used. Once you've established who the user is 11 | once, this library gives you a way to track that information securely 12 | throughout your application without needing to keep extensive server-side 13 | state. 14 | 15 | Things to note: 16 | 17 | * The tokens are signed and encrypted to prevent tampering, using RSA. Tokens 18 | which fail decryption and signature checking are not accepted. 19 | 20 | * Nonce values are embedded in the encrypted tokens as jti parameters, to 21 | ensure that tokens cannot be reused and guard against replay attacks. 22 | 23 | * Cookies and tokens are both given expiry periods, to implement idle 24 | session timeout. 25 | 26 | * Cookies are marked HttpOnly to guard against XSS attacks. 27 | 28 | * Don't forget to protect your forms against CSRF, including your login form. 29 | 30 | A handler is provided to deal with protecting routes which require 31 | authentication. The detected identity is passed to the next handler in the 32 | chain using Go's Context mechanism. 33 | 34 | Since tokens are one time only, sessions must be kept alive by reissuing 35 | updated tokens. A heartbeat handler is provided to do this for page loads 36 | which do not require authentication. 37 | 38 | ## Other options 39 | 40 | If you want a lot more configurability at the cost of some complexity, you 41 | might want to check out 42 | [adam-hanna/jwt-auth](https://github.com/adam-hanna/jwt-auth) instead. 43 | 44 | -------------------------------------------------------------------------------- /jwtauth.go: -------------------------------------------------------------------------------- 1 | // Package jwtauth implements (mostly) stateless web session authentication 2 | // using JSON Web Tokens (JWT). 3 | package jwtauth 4 | 5 | import ( 6 | "context" 7 | "crypto/rsa" 8 | "fmt" 9 | "net/http" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/lestrrat/go-jwx/jwa" 14 | "github.com/lestrrat/go-jwx/jwe" 15 | "github.com/lestrrat/go-jwx/jwt" 16 | "github.com/lpar/serial" 17 | ) 18 | 19 | // Authenticator provides a structure for the run-time parameters controlling 20 | // authentication. 21 | type Authenticator struct { 22 | CookieName string // Name to use for cookie 23 | CookieLifespan time.Duration // How long tokens and cookies should live for 24 | ContextName string // Name to use 25 | LoginURL string // URL to initiate re-login on session fail 26 | PrivateKey *rsa.PrivateKey 27 | SerialGen *serial.Generator 28 | GC chan struct{} // Channel to control JTI nonce GC 29 | } 30 | 31 | const defaultCookieName = "token" 32 | const defaultCookieLifespan = time.Hour / 3 33 | const defaultContextName = "jwtauth" 34 | const defaultLoginURL = "/login" 35 | 36 | // NewAuthenticator sets up a new Authenticator object with sensible defaults, 37 | // using the provided RSA private key. 38 | // Once any parameters have been updated to taste, you can call StartGC to 39 | // begin a periodic background task which will expire old data from the jti 40 | // nonce blacklist. If you don't do so, you should call ExpireSeen yourself 41 | // periodically to make sure memory doesn't fill up. 42 | func NewAuthenticator(rsakey *rsa.PrivateKey) *Authenticator { 43 | auth := &Authenticator{ 44 | CookieName: defaultCookieName, 45 | CookieLifespan: defaultCookieLifespan, 46 | ContextName: defaultContextName, 47 | LoginURL: defaultLoginURL, 48 | SerialGen: serial.NewGenerator(), 49 | PrivateKey: rsakey, 50 | } 51 | return auth 52 | } 53 | 54 | // StartGC starts a periodic garbage collector which runs in a separate 55 | // goroutine, and cleans out old data from the jti (nonce) blacklist. 56 | // You should call it once, after any changes to the CookieLifespan parameter. 57 | // GC runs are performed at an interval of (cookie lifespan / 2). 58 | func (auth *Authenticator) StartGC() { 59 | auth.GC = auth.NewGarbageCollector() 60 | } 61 | 62 | // StopGC stops the garbage collector task, in case you want to stop it but 63 | // leave the application active. 64 | func (auth *Authenticator) StopGC() { 65 | close(auth.GC) 66 | } 67 | 68 | // Used when generating JTI values from int64 values, to make them as compact 69 | // as possible 70 | const jtiNumericBase = 36 71 | 72 | // Logout triggers a logout by refreshing the cookie with an empty value and 73 | // an expiry time indicating that it should immediately be deleted. 74 | func (auth *Authenticator) Logout(next ...http.Handler) http.Handler { 75 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 76 | cookie := &http.Cookie{ 77 | Name: auth.CookieName, 78 | Value: "", 79 | MaxAge: -1, 80 | HttpOnly: true, 81 | Path: "/", 82 | } 83 | http.SetCookie(w, cookie) 84 | if len(next) == 0 { 85 | http.Redirect(w, r, "/", http.StatusTemporaryRedirect) 86 | return 87 | } 88 | next[0].ServeHTTP(w, r) 89 | }) 90 | } 91 | 92 | // decodeToken extracts the JWT authentication token from the cookies of the 93 | // supplied http Request. The token is decrypted and its expiry time checked. 94 | // If the token is cryptographically secure and unexpired, it is returned. 95 | // Otherwise an error value is returned. 96 | func (auth *Authenticator) decodeToken(r *http.Request) (*jwt.ClaimSet, error) { 97 | 98 | cs := jwt.NewClaimSet() 99 | 100 | // Get token cookie 101 | ctok, err := r.Cookie(auth.CookieName) 102 | if err != nil { 103 | return cs, fmt.Errorf("no token cookie '%s'", auth.CookieName) 104 | } 105 | 106 | stok := ctok.Value 107 | // Note that we don't allow the token to determine which encryption algorithm to use! 108 | // See for why not. 109 | dec, err := jwe.Decrypt([]byte(stok), jwa.RSA_OAEP_256, auth.PrivateKey) 110 | if err != nil { 111 | return cs, err 112 | } 113 | 114 | err = cs.UnmarshalJSON(dec) 115 | if err != nil { 116 | return cs, err 117 | } 118 | 119 | // Check we got something worthwhile 120 | sub := cs.Get("sub").(string) 121 | if sub == "" { 122 | return cs, fmt.Errorf("empty claimset (no subject)") 123 | } 124 | 125 | // Check it hasn't expired 126 | tokex := cs.Get("exp").(int64) 127 | if tokex < time.Now().Unix() { 128 | return cs, fmt.Errorf("token for %s expired at %s", cs.Get("sub"), 129 | time.Unix(tokex, 0)) 130 | } 131 | 132 | // Check it hasn't already been used 133 | jti := cs.Get("jti").(string) 134 | if jti == "" { 135 | return cs, fmt.Errorf("token with no jti") 136 | } 137 | jtiint, err := strconv.ParseInt(jti, jtiNumericBase, 64) 138 | if err != nil { 139 | return cs, fmt.Errorf("garbage jti in token: %s", jti) 140 | } 141 | if auth.SerialGen.Seen(serial.Serial(jtiint)) { 142 | return cs, fmt.Errorf("attempt to reuse token: %s", jti) 143 | } 144 | auth.SerialGen.SetSeen(serial.Serial(jtiint)) 145 | 146 | // Success! 147 | return cs, nil 148 | } 149 | 150 | // NewGarbageCollector starts a goroutine to perform periodic garbage 151 | // collection of the jti nonce blacklist. Usually you'll just call StartGC 152 | // instead. 153 | func (auth *Authenticator) NewGarbageCollector() chan struct{} { 154 | ticker := time.NewTicker(auth.CookieLifespan / 2) 155 | quit := make(chan struct{}) 156 | go func() { 157 | for { 158 | select { 159 | case <-ticker.C: 160 | auth.SerialGen.ExpireSeen(auth.CookieLifespan) 161 | case <-quit: 162 | ticker.Stop() 163 | return 164 | } 165 | } 166 | }() 167 | return quit 168 | } 169 | 170 | // EncodeToken encodes a jwt.ClaimSet into a cookie and sends it to the 171 | // browser client. 172 | func (auth *Authenticator) EncodeToken(w http.ResponseWriter, cs *jwt.ClaimSet) error { 173 | 174 | expires := time.Now().Add(auth.CookieLifespan) 175 | err := cs.Set("exp", expires.Unix()) 176 | if err != nil { 177 | return fmt.Errorf("can't set exp value: %s", err) 178 | } 179 | err = cs.Set("iat", time.Now().Unix()) 180 | if err != nil { 181 | return fmt.Errorf("can't set iat value: %s", err) 182 | } 183 | jti := int64(auth.SerialGen.Generate()) 184 | err = cs.Set("jti", strconv.FormatInt(jti, jtiNumericBase)) 185 | if err != nil { 186 | return fmt.Errorf("token jti construction error: %s", err) 187 | } 188 | ntok, err := cs.MarshalJSON() 189 | if err != nil { 190 | return fmt.Errorf("token marshalling error: %s", err) 191 | } 192 | 193 | // We use A128CBC_HS256 for the internal payload encryption because the HS256 variants are more widely supported. 194 | // The session key from that is then encrypted using RSA_OAEP_256. 195 | // See 196 | enc, err := jwe.Encrypt(ntok, jwa.RSA_OAEP_256, &auth.PrivateKey.PublicKey, jwa.A128CBC_HS256, jwa.Deflate) 197 | if err != nil { 198 | return fmt.Errorf("token encryption error: %s", err) 199 | } 200 | 201 | cookie := &http.Cookie{ 202 | Name: auth.CookieName, 203 | Value: string(enc), 204 | Expires: expires, 205 | MaxAge: int(auth.CookieLifespan.Seconds()), 206 | HttpOnly: true, 207 | Path: "/", 208 | } 209 | 210 | // Send it back to the browser 211 | http.SetCookie(w, cookie) 212 | 213 | return nil 214 | } 215 | 216 | // tokenReissue handles the guts of the authentication. It checks for a token, 217 | // and if a valid token is found, a new token is issued. If no valid token is 218 | // found and 'enforce' is set to true, it issues a redirect to the LoginURL. 219 | func (auth *Authenticator) tokenReissue(w http.ResponseWriter, r *http.Request, enforce bool) *http.Request { 220 | ctx := r.Context() 221 | 222 | if auth.PrivateKey == nil { 223 | panic("No private key!") 224 | } 225 | 226 | tok, err := auth.decodeToken(r) 227 | if err != nil { 228 | if enforce { 229 | // Status 303 = change to GET when redirecting 230 | http.Redirect(w, r, auth.LoginURL, http.StatusSeeOther) 231 | return r 232 | } 233 | } else { 234 | // Token heartbeat -- it was valid so issue an updated one 235 | err := auth.EncodeToken(w, tok) 236 | if err != nil { 237 | http.Error(w, "Error encoding JSON Web Token", http.StatusInternalServerError) 238 | return r 239 | } 240 | 241 | // Put the claimset in the request context for the next handler 242 | ctx = context.WithValue(ctx, auth.ContextName, tok) 243 | r = r.WithContext(ctx) 244 | } 245 | return r 246 | } 247 | 248 | // tokenReissueHandler returns a Handler which calls tokenReissue. 249 | func (auth *Authenticator) tokenReissueHandler(xhnd http.Handler, enforce bool) http.Handler { 250 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 251 | r = auth.tokenReissue(w, r, enforce) 252 | xhnd.ServeHTTP(w, r) 253 | }) 254 | } 255 | 256 | // tokenReissueFunc returns a HandlerFunc which calls tokenReissue. 257 | func (auth *Authenticator) tokenReissueFunc(fn http.HandlerFunc, enforce bool) http.HandlerFunc { 258 | return func(w http.ResponseWriter, r *http.Request) { 259 | r = auth.tokenReissue(w, r, enforce) 260 | fn(w, r) 261 | } 262 | } 263 | 264 | // TokenAuthenticate wraps a Handler and requires a valid token (i.e. 265 | // requires authentication), or else the user is redirected to the login 266 | // page and the next handler is NOT called. 267 | func (auth *Authenticator) TokenAuthenticate(xhnd http.Handler) http.Handler { 268 | return auth.tokenReissueHandler(xhnd, true) 269 | } 270 | 271 | // TokenHeartbeat wraps a Handler and performs heartbeat update of any 272 | // token found, but does not require a token (i.e does not require 273 | // authentication). 274 | func (auth *Authenticator) TokenHeartbeat(xhnd http.Handler) http.Handler { 275 | return auth.tokenReissueHandler(xhnd, false) 276 | } 277 | 278 | // TokenAuthenticateFunc is a HandlerFunc version of TokenAuthenticate. 279 | // It wraps a HandlerFunc instead of wrapping a Handler. 280 | func (auth *Authenticator) TokenAuthenticateFunc(fn http.HandlerFunc) http.HandlerFunc { 281 | return auth.tokenReissueFunc(fn, true) 282 | } 283 | 284 | // TokenHeartbeatFunc is a HandlerFunc version of TokenHeartbeat. 285 | // It wraps a HandlerFunc instead of wrapping a Handler. 286 | func (auth *Authenticator) TokenHeartbeatFunc(fn http.HandlerFunc) http.HandlerFunc { 287 | return auth.tokenReissueFunc(fn, false) 288 | } 289 | 290 | // ClaimSetFromRequest is a convenience function to fetch the claimset 291 | // from the context on the request object. 292 | func (auth *Authenticator) ClaimSetFromRequest(r *http.Request) (*jwt.ClaimSet, bool) { 293 | ctx := r.Context() 294 | if ctx == nil { 295 | return nil, false 296 | } 297 | cs, ok := ctx.Value(auth.ContextName).(*jwt.ClaimSet) 298 | return cs, ok 299 | } 300 | -------------------------------------------------------------------------------- /jwtauth_test.go: -------------------------------------------------------------------------------- 1 | package jwtauth 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "flag" 7 | "fmt" 8 | pseudorand "math/rand" 9 | "net/http" 10 | "net/http/httptest" 11 | "os" 12 | "strconv" 13 | "strings" 14 | "testing" 15 | "time" 16 | 17 | "github.com/lestrrat/go-jwx/jwa" 18 | "github.com/lestrrat/go-jwx/jwe" 19 | "github.com/lestrrat/go-jwx/jwt" 20 | ) 21 | 22 | var auth *Authenticator 23 | 24 | func TestMain(m *testing.M) { 25 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 26 | if err != nil { 27 | fmt.Printf("error generating test RSA key: %s", err) 28 | os.Exit(1) 29 | } 30 | auth = NewAuthenticator(privateKey) 31 | 32 | flag.Parse() 33 | os.Exit(m.Run()) 34 | } 35 | 36 | var testData = map[string]string{ 37 | "sub": "test@example.com", 38 | "name": "Kevin Mitnick", 39 | "given_name": "Kevin", 40 | "family_name": "Mitnick", 41 | "email": "mitnick@example.com", 42 | } 43 | 44 | func (auth *Authenticator) testCookieHandler(w http.ResponseWriter, r *http.Request) { 45 | cs := jwt.NewClaimSet() 46 | for k, v := range testData { 47 | err := cs.Set(k, v) 48 | if err != nil { 49 | http.Error(w, fmt.Sprintf("Error setting %s value: %s", k, err.Error()), http.StatusInternalServerError) 50 | } 51 | } 52 | err := auth.EncodeToken(w, cs) 53 | if err != nil { 54 | http.Error(w, err.Error(), http.StatusInternalServerError) 55 | return 56 | } 57 | } 58 | 59 | // A handler which simply records that it was called and the context it 60 | // was called with 61 | type RecordingHandler struct { 62 | Called bool 63 | ClaimSet *jwt.ClaimSet 64 | } 65 | 66 | var recordingHandler = RecordingHandler{} 67 | 68 | func (h RecordingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 69 | // Not h, we need to store the values in the global 70 | recordingHandler.ClaimSet, 71 | recordingHandler.Called = auth.ClaimSetFromRequest(r) 72 | } 73 | 74 | // HandlerFunc method so we can test that wrapper interface too 75 | func (h RecordingHandler) Handle(w http.ResponseWriter, r *http.Request) { 76 | recordingHandler.ClaimSet, 77 | recordingHandler.Called = auth.ClaimSetFromRequest(r) 78 | } 79 | 80 | func getCookie(r *http.Response, name string) (*http.Cookie, error) { 81 | cookies := r.Cookies() 82 | for _, c := range cookies { 83 | if c.Name == name { 84 | return c, nil 85 | } 86 | } 87 | return nil, fmt.Errorf("No cookie %s found", name) 88 | } 89 | 90 | func getTestCookie(t *testing.T) (*http.Cookie, error) { 91 | ts := httptest.NewServer(http.HandlerFunc(auth.testCookieHandler)) 92 | defer ts.Close() 93 | // Subpath to test that cookie path correctly ends up / 94 | resp, err := http.Get(ts.URL + "/sub/path") 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | if resp.StatusCode != http.StatusOK { 99 | t.Fatalf("Unexpected http response %d", resp.StatusCode) 100 | } 101 | ctok, err := getCookie(resp, defaultCookieName) 102 | if err != nil { 103 | t.Errorf("Unable to issue token: %s", err) 104 | } 105 | return ctok, err 106 | } 107 | 108 | func verifyTestCookie(t *testing.T, ctok *http.Cookie) { 109 | if ctok.Path != "/" { 110 | t.Errorf("Wrong cookie path, expected / got %s", ctok.Path) 111 | } 112 | exp := ctok.Expires 113 | expexp := time.Now().Add(defaultCookieLifespan) 114 | durd := expexp.Sub(exp) 115 | if durd > time.Second { 116 | t.Errorf("Cookie lifetime incorrect, expected %v got %v", expexp.UTC(), exp.UTC()) 117 | } 118 | if !ctok.HttpOnly { 119 | t.Error("Cookie not marked as HttpOnly (XSS vulnerability)") 120 | } 121 | } 122 | 123 | func verifyClaimSet(t *testing.T, cs *jwt.ClaimSet) { 124 | for k, v := range testData { 125 | xv := cs.Get(k) 126 | if xv != v { 127 | t.Errorf("Wrong %s, expected %s got %s", k, xv, v) 128 | } 129 | } 130 | } 131 | 132 | func TestEncodeDecode(t *testing.T) { 133 | // Test encode/issue 134 | ctok, err := getTestCookie(t) 135 | if err != nil { 136 | t.Errorf("Failed to get test cookie: %s", err) 137 | } 138 | verifyTestCookie(t, ctok) 139 | 140 | // Test decode 141 | req, err := http.NewRequest("GET", "/random", nil) 142 | if err != nil { 143 | t.Errorf("Unable to create http request: %s", err) 144 | } 145 | req.AddCookie(ctok) 146 | ncs, err := auth.decodeToken(req) 147 | if err != nil { 148 | t.Errorf("Token decode failed: %s", err) 149 | } 150 | verifyClaimSet(t, ncs) 151 | } 152 | 153 | func getWithCookie(ts *httptest.Server, c *http.Cookie) (*http.Response, error) { 154 | client := &http.Client{ 155 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 156 | return http.ErrUseLastResponse 157 | }, 158 | } 159 | req, err := http.NewRequest("GET", ts.URL, nil) 160 | req.AddCookie(c) 161 | resp, err := client.Do(req) 162 | return resp, err 163 | } 164 | 165 | func TestHeartbeat(t *testing.T) { 166 | ctok, err := getTestCookie(t) 167 | if err != nil { 168 | t.Errorf("Error getting a test cookie: %s", err) 169 | } 170 | 171 | ts := httptest.NewServer(auth.TokenHeartbeat(recordingHandler)) 172 | defer ts.Close() 173 | 174 | resp, err := getWithCookie(ts, ctok) 175 | if err != nil { 176 | t.Errorf("Error performing heartbeat GET: %s", err) 177 | } 178 | if resp.StatusCode != http.StatusOK { 179 | t.Fatalf("Unexpected http response %d", resp.StatusCode) 180 | } 181 | newtok, err := getCookie(resp, defaultCookieName) 182 | if err != nil { 183 | t.Errorf("Bad cookie get on heartbeat: %s", err) 184 | } 185 | verifyTestCookie(t, newtok) 186 | 187 | if !recordingHandler.Called { 188 | t.Errorf("Heartbeat handler didn't pass through to next handler") 189 | } 190 | cs := recordingHandler.ClaimSet 191 | 192 | attrs := []string{"sub", "name", "given_name", "family_name", "email"} 193 | 194 | for _, k := range attrs { 195 | v := cs.Get(k).(string) 196 | if v != testData[k] { 197 | t.Errorf("Bad value %s in passthrough context, expected %s got %s", k, testData[k], v) 198 | } 199 | } 200 | 201 | } 202 | 203 | func TestHeartbeatFunc(t *testing.T) { 204 | ctok, err := getTestCookie(t) 205 | if err != nil { 206 | t.Errorf("Error getting a test cookie: %s", err) 207 | } 208 | 209 | ts := httptest.NewServer(http.HandlerFunc(auth.TokenHeartbeatFunc(recordingHandler.Handle))) 210 | defer ts.Close() 211 | 212 | resp, err := getWithCookie(ts, ctok) 213 | if err != nil { 214 | t.Errorf("Error performing heartbeat GET: %s", err) 215 | } 216 | if resp.StatusCode != http.StatusOK { 217 | t.Fatalf("Unexpected http response %d", resp.StatusCode) 218 | } 219 | newtok, err := getCookie(resp, defaultCookieName) 220 | if err != nil { 221 | t.Errorf("Bad cookie get on heartbeat: %s", err) 222 | } 223 | verifyTestCookie(t, newtok) 224 | 225 | if !recordingHandler.Called { 226 | t.Errorf("Heartbeat handler didn't pass through to next handler") 227 | } 228 | cs := recordingHandler.ClaimSet 229 | 230 | attrs := []string{"sub", "name", "given_name", "family_name", "email"} 231 | 232 | for _, k := range attrs { 233 | v := cs.Get(k).(string) 234 | if v != testData[k] { 235 | t.Errorf("Bad value %s in passthrough context, expected %s got %s", k, testData[k], v) 236 | } 237 | } 238 | 239 | } 240 | 241 | func TestLogout(t *testing.T) { 242 | ctok, err := getTestCookie(t) 243 | if err != nil { 244 | t.Errorf("Error getting a test cookie: %s", err) 245 | } 246 | ts := httptest.NewServer(auth.Logout(recordingHandler)) 247 | defer ts.Close() 248 | 249 | resp, err := getWithCookie(ts, ctok) 250 | cook, err := getCookie(resp, defaultCookieName) 251 | if err != nil { 252 | t.Errorf("Bad cookie get on logout: %s", err) 253 | } 254 | if cook.Name != defaultCookieName { 255 | t.Error("Cookie not set on logout") 256 | } 257 | if cook.Value != "" { 258 | t.Error("Cookie survived logout") 259 | } 260 | if cook.MaxAge > 0 { 261 | t.Error("Cookie not set to expire on logout") 262 | } 263 | } 264 | 265 | func TestAuthRedirect(t *testing.T) { 266 | ts := httptest.NewServer(auth.TokenAuthenticate(recordingHandler)) 267 | defer ts.Close() 268 | 269 | resp, err := getWithCookie(ts, &http.Cookie{}) 270 | if err != nil { 271 | t.Fatal(err) 272 | } 273 | if resp.StatusCode != http.StatusSeeOther { 274 | t.Errorf("Authentication fail (no cookie) didn't redirect, expected %d, got %d", 275 | http.StatusSeeOther, resp.StatusCode) 276 | } 277 | } 278 | 279 | func TestAuthRedirectFunc(t *testing.T) { 280 | ts := httptest.NewServer(http.HandlerFunc(auth.TokenAuthenticateFunc(recordingHandler.Handle))) 281 | defer ts.Close() 282 | 283 | resp, err := getWithCookie(ts, &http.Cookie{}) 284 | if err != nil { 285 | t.Fatal(err) 286 | } 287 | if resp.StatusCode != http.StatusSeeOther { 288 | t.Errorf("Authentication fail (no cookie) didn't redirect, expected %d, got %d", 289 | http.StatusSeeOther, resp.StatusCode) 290 | } 291 | } 292 | 293 | func corrupt(x string) string { 294 | b := []byte(x) 295 | i := pseudorand.Intn(len(b)) 296 | b[i] = b[i] ^ 1 297 | return string(b) 298 | } 299 | 300 | func TestCorruptCookie(t *testing.T) { 301 | ts := httptest.NewServer(auth.TokenAuthenticate(recordingHandler)) 302 | defer ts.Close() 303 | 304 | cook, err := getTestCookie(t) 305 | if err != nil { 306 | t.Errorf("Error getting a test cookie: %s", err) 307 | } 308 | 309 | chunks := strings.SplitN(cook.Value, ".", 3) 310 | if len(chunks) != 3 { 311 | t.Errorf("JWT had wrong number of chunks, expected 3 got %d", len(chunks)) 312 | } 313 | // Chunks are header, payload and signature. Let's try corrupting the 314 | // signature. 315 | badsig := fmt.Sprintf("%s.%s.%s", chunks[0], chunks[1], corrupt(chunks[2])) 316 | cook.Value = badsig 317 | 318 | resp, err := getWithCookie(ts, &http.Cookie{}) 319 | if err != nil { 320 | t.Fatal(err) 321 | } 322 | if resp.StatusCode != http.StatusSeeOther { 323 | t.Errorf("Authentication fail (bad cookie) didn't redirect, expected %d, got %d", 324 | http.StatusSeeOther, resp.StatusCode) 325 | } 326 | } 327 | 328 | // Make sure we don't succeed or cause a panic trying to fetch a ClaimSet 329 | // from a request which lacks one 330 | func TestSafeCSGet(t *testing.T) { 331 | r := &http.Request{} 332 | _, ok := auth.ClaimSetFromRequest(r) 333 | if ok { 334 | t.Errorf("ClaimSetFromRequest returned OK from Request with no ClaimSet") 335 | } 336 | } 337 | 338 | // Make sure that a cookie with the user's choice of a1gorithm won't work, 339 | // even if they had the right keys. 340 | // 341 | // The theory is that anyone can can get the public key, and if a token is signed with HS256 and the public key, 342 | // it will successfully verify when decoded using the private key, because HMAC-SHA is symmetric. 343 | // 344 | // See 345 | func TestEvilAlgo(t *testing.T) { 346 | 347 | // Construct a claimset complete with valid iat, jti 348 | cs := jwt.NewClaimSet() 349 | for k, v := range testData { 350 | err := cs.Set(k, v) 351 | if err != nil { 352 | t.Errorf("Error setting %s value: %s", k, err) 353 | } 354 | } 355 | err := cs.Set("iat", time.Now().Unix()) 356 | if err != nil { 357 | t.Errorf("can't set iat value: %s", err) 358 | } 359 | jti := int64(auth.SerialGen.Generate()) 360 | err = cs.Set("jti", strconv.FormatInt(jti, jtiNumericBase)) 361 | if err != nil { 362 | t.Errorf("can't set jti value: %s", err) 363 | } 364 | badtok, err := cs.MarshalJSON() 365 | if err != nil { 366 | t.Errorf("token marshalling error: %s", err) 367 | } 368 | 369 | enc, err := jwe.Encrypt(badtok, jwa.RSA1_5, &auth.PrivateKey.PublicKey, jwa.A128CBC_HS256, jwa.Deflate) 370 | if err != nil { 371 | t.Errorf("token encryption error: %s", err) 372 | } 373 | 374 | expires := time.Now().Add(time.Hour) 375 | 376 | cookie := &http.Cookie{ 377 | Name: auth.CookieName, 378 | Value: string(enc), 379 | Expires: expires, 380 | MaxAge: 86400, 381 | HttpOnly: true, 382 | Path: "/", 383 | } 384 | 385 | req, err := http.NewRequest("GET", "/random", nil) 386 | if err != nil { 387 | t.Errorf("Unable to create http request: %s", err) 388 | } 389 | req.AddCookie(cookie) 390 | 391 | _, err = auth.decodeToken(req) 392 | if err == nil { 393 | t.Errorf("Malicious signed token decode succeeded!") 394 | } 395 | 396 | } 397 | 398 | // Make sure that an unencrypted JWT assembled with algorithm 'none' is not accepted. 399 | func TestEvil(t *testing.T) { 400 | 401 | eviltoken := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IktldmluIE1pdG5pY2siLCJnaXZlbl9uYW1lIjoiS2V2aW4iLCJmYW1pbHlfbmFtZSI6Ik1pdG5pY2siLCJlbWFpbCI6Im1pdG5pY2tAZXhhbXBsZS5jb20ifQ.NzG-9jkRvm7UFkgiGdpBUottduDbSXY9xMq_EqrXdO4" 402 | 403 | expires := time.Now().Add(time.Hour) 404 | 405 | cookie := &http.Cookie{ 406 | Name: auth.CookieName, 407 | Value: eviltoken, 408 | Expires: expires, 409 | MaxAge: 86400, 410 | HttpOnly: true, 411 | Path: "/", 412 | } 413 | 414 | req, err := http.NewRequest("GET", "/random", nil) 415 | if err != nil { 416 | t.Errorf("Unable to create http request: %s", err) 417 | } 418 | req.AddCookie(cookie) 419 | 420 | _, err = auth.decodeToken(req) 421 | if err == nil { 422 | t.Errorf("Malicious token decode succeeded!") 423 | } 424 | } 425 | --------------------------------------------------------------------------------