├── .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 |
--------------------------------------------------------------------------------