mirror of
https://github.com/hanzoai/status.git
synced 2026-10-30 18:21:48 +00:00
zip's registration is a verb method on the group, so a route that is not a typed operation says which method it answers with. The security package's own two-method router goes with it: it existed so both the app and a sub-router could satisfy one name, and restating a routing surface is what let the two disagree — the methods it named no longer exist. The app spells itself as a group, which registers at exactly the paths it did. The toolchain and the pin move together, because below zip v1.36.50 the JSON encoder was selected on a build tag: a repo whose go directive rises first writes a wire that is neither the old release's nor the new one's until the pin catches up. metrics_test.go still does not compile, before and after: it compares a metric registry against a gatherer from a different metrics library, so the two Gather signatures disagree. That is its own change. Co-authored-by: Hanzo Dev <[email protected]>
530 lines
18 KiB
Go
530 lines
18 KiB
Go
package security
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/zap-proto/zip"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// The OIDC callback is the one place a browser turns an authorization code into
|
|
// a session cookie, so its answer is a security surface and not just an answer:
|
|
// the status a browser reads, the exact refusal it is told, and — on the one
|
|
// path that succeeds — the cookie it is handed. This table pins all of that
|
|
// BYTE FOR BYTE, so a change of transport underneath is provably not a change
|
|
// of behaviour on the wire.
|
|
//
|
|
// Every case is driven through the real router (app.Fiber().Test), not by
|
|
// calling the handler directly, because the transport is exactly what is under
|
|
// test. The identity provider is local and deterministic: an RSA key signs the
|
|
// id_token, oidc.StaticKeySet verifies it, and a httptest server plays the
|
|
// token endpoint. Nothing here reaches the network.
|
|
|
|
// wire is one expected answer. A field left at its zero value is not asserted,
|
|
// EXCEPT status and body, which every case states.
|
|
type wire struct {
|
|
status int
|
|
contentType string
|
|
body string
|
|
// nosniff records whether X-Content-Type-Options: nosniff is expected.
|
|
nosniff bool
|
|
location string
|
|
// cookie is the name of a Set-Cookie expected on the response.
|
|
cookie string
|
|
}
|
|
|
|
func (w wire) check(t *testing.T, resp *http.Response, body string) {
|
|
t.Helper()
|
|
if resp.StatusCode != w.status {
|
|
t.Errorf("status = %d, want %d", resp.StatusCode, w.status)
|
|
}
|
|
if got := resp.Header.Get("Content-Type"); got != w.contentType {
|
|
t.Errorf("Content-Type = %q, want %q", got, w.contentType)
|
|
}
|
|
if body != w.body {
|
|
t.Errorf("body = %q, want %q", body, w.body)
|
|
}
|
|
want := ""
|
|
if w.nosniff {
|
|
want = "nosniff"
|
|
}
|
|
if got := resp.Header.Get("X-Content-Type-Options"); got != want {
|
|
t.Errorf("X-Content-Type-Options = %q, want %q", got, want)
|
|
}
|
|
if got := resp.Header.Get("Location"); got != w.location {
|
|
t.Errorf("Location = %q, want %q", got, w.location)
|
|
}
|
|
name := ""
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == w.cookie {
|
|
name = c.Name
|
|
}
|
|
}
|
|
if w.cookie != "" && name == "" {
|
|
t.Errorf("no Set-Cookie named %q; got %v", w.cookie, resp.Cookies())
|
|
}
|
|
if w.cookie == "" && len(resp.Cookies()) != 0 {
|
|
t.Errorf("unexpected Set-Cookie: %v", resp.Cookies())
|
|
}
|
|
}
|
|
|
|
// idp is a local identity provider: it signs id_tokens and answers the token
|
|
// endpoint, so the success path can be exercised without a network.
|
|
type idp struct {
|
|
key *rsa.PrivateKey
|
|
issuer string
|
|
clientID string
|
|
server *httptest.Server
|
|
// token is what the token endpoint returns; a test sets it per case.
|
|
token string
|
|
// noIDToken omits the id_token field entirely, which is a different thing
|
|
// from sending it empty: an empty string still type-asserts to a string, so
|
|
// the handler carries on to the verifier instead of refusing early. Measured.
|
|
noIDToken bool
|
|
}
|
|
|
|
func newIDP(t *testing.T) *idp {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
|
}
|
|
p := &idp{key: key, issuer: "https://idp.test", clientID: "client-id"}
|
|
p.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
body := map[string]any{"access_token": "access", "token_type": "Bearer"}
|
|
if !p.noIDToken {
|
|
body["id_token"] = p.token
|
|
}
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}))
|
|
t.Cleanup(p.server.Close)
|
|
return p
|
|
}
|
|
|
|
// sign builds an RS256 JWT over claims. Hand-rolled because a JWT is three
|
|
// base64 segments and a signature, and the standard library signs.
|
|
func (p *idp) sign(t *testing.T, claims map[string]any) string {
|
|
t.Helper()
|
|
seg := func(v any) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
signing := seg(map[string]any{"alg": "RS256", "typ": "JWT"}) + "." + seg(claims)
|
|
sum := sha256.Sum256([]byte(signing))
|
|
sig, err := rsa.SignPKCS1v15(rand.Reader, p.key, crypto.SHA256, sum[:])
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
|
|
}
|
|
|
|
// config returns an OIDCConfig wired to this provider, bypassing initialize()
|
|
// (which performs discovery over the network) by setting the two fields it
|
|
// would have set.
|
|
func (p *idp) config(allowed ...string) *OIDCConfig {
|
|
return &OIDCConfig{
|
|
IssuerURL: p.issuer,
|
|
RedirectURL: "http://localhost:80/authorization-code/callback",
|
|
ClientID: p.clientID,
|
|
ClientSecret: "client-secret",
|
|
Scopes: []string{"openid"},
|
|
AllowedSubjects: allowed,
|
|
SessionTTL: DefaultOIDCSessionTTL,
|
|
oauth2Config: oauth2.Config{
|
|
ClientID: p.clientID,
|
|
ClientSecret: "client-secret",
|
|
Scopes: []string{"openid"},
|
|
RedirectURL: "http://localhost:80/authorization-code/callback",
|
|
Endpoint: oauth2.Endpoint{TokenURL: p.server.URL + "/token", AuthURL: p.server.URL + "/auth"},
|
|
},
|
|
verifier: oidc.NewVerifier(p.issuer,
|
|
&oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{&p.key.PublicKey}},
|
|
&oidc.Config{ClientID: p.clientID}),
|
|
}
|
|
}
|
|
|
|
// callbackApp registers the callback on a router exactly as RegisterHandlers
|
|
// does. This one line is the transport under test; the table below never moves.
|
|
func callbackApp(c *OIDCConfig) *zip.App {
|
|
app := zip.New(zip.Config{})
|
|
app.Raw(zip.MethodAll, "/authorization-code/callback", c.callbackHandler)
|
|
return app
|
|
}
|
|
|
|
func ask(t *testing.T, app *zip.App, method, target string, cookies ...*http.Cookie) (*http.Response, string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, target, http.NoBody)
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
resp, err := app.Fiber().Test(req)
|
|
if err != nil {
|
|
t.Fatalf("Test(%s %s): %v", method, target, err)
|
|
}
|
|
b, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
return resp, string(b)
|
|
}
|
|
|
|
const callback = "/authorization-code/callback"
|
|
|
|
func TestCallbackWire_Refusals(t *testing.T) {
|
|
p := newIDP(t)
|
|
app := callbackApp(p.config())
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
cookies []*http.Cookie
|
|
want wire
|
|
}{
|
|
{
|
|
name: "provider reported an error",
|
|
method: "GET",
|
|
target: callback + "?error=access_denied&error_description=user+said+no",
|
|
want: wire{
|
|
status: http.StatusBadRequest,
|
|
contentType: "text/plain; charset=utf-8",
|
|
body: "access_denied: user said no\n",
|
|
nosniff: true,
|
|
},
|
|
},
|
|
{
|
|
name: "no state cookie",
|
|
method: "GET",
|
|
target: callback,
|
|
want: wire{
|
|
status: http.StatusBadRequest,
|
|
contentType: "text/plain; charset=utf-8",
|
|
body: "state not found\n",
|
|
nosniff: true,
|
|
},
|
|
},
|
|
{
|
|
name: "state did not match",
|
|
method: "GET",
|
|
target: callback + "?state=attacker",
|
|
cookies: []*http.Cookie{{Name: cookieNameState, Value: "browser"}},
|
|
want: wire{
|
|
status: http.StatusBadRequest,
|
|
contentType: "text/plain; charset=utf-8",
|
|
body: "state did not match\n",
|
|
nosniff: true,
|
|
},
|
|
},
|
|
{
|
|
// The route is All, so form_post callbacks arrive as POST. The
|
|
// refusal must read the same to a browser either way.
|
|
name: "state did not match, POST",
|
|
method: "POST",
|
|
target: callback + "?state=attacker",
|
|
cookies: []*http.Cookie{{Name: cookieNameState, Value: "browser"}},
|
|
want: wire{
|
|
status: http.StatusBadRequest,
|
|
contentType: "text/plain; charset=utf-8",
|
|
body: "state did not match\n",
|
|
nosniff: true,
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resp, body := ask(t, app, tc.method, tc.target, tc.cookies...)
|
|
tc.want.check(t, resp, body)
|
|
})
|
|
}
|
|
}
|
|
|
|
// State and nonce are compared against a cookie, and an empty value compares
|
|
// equal to an empty value. So a cookie the browser holds as empty — which any
|
|
// sibling host can plant, HttpOnly being no defence against writing one — turns
|
|
// both comparisons into no-ops: the state check that exists to stop a login
|
|
// CSRF, and the nonce that binds the id_token to this browser's own login.
|
|
// Neither may be satisfiable by having nothing.
|
|
func TestCallbackWire_EmptyStateAndNonce(t *testing.T) {
|
|
p := newIDP(t)
|
|
|
|
t.Run("an empty state is not a matching state", func(t *testing.T) {
|
|
resp, body := ask(t, callbackApp(p.config()), "GET", callback+"?state=",
|
|
&http.Cookie{Name: cookieNameState, Value: ""})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 — an empty state must not reach the token exchange", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
|
t.Errorf("Content-Type = %q, want text/plain; charset=utf-8", got)
|
|
}
|
|
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
if body != "state not found\n" {
|
|
t.Errorf("body = %q, want %q", body, "state not found\n")
|
|
}
|
|
if len(resp.Cookies()) != 0 {
|
|
t.Errorf("unexpected Set-Cookie: %v", resp.Cookies())
|
|
}
|
|
})
|
|
|
|
t.Run("an absent nonce claim is not a matching nonce", func(t *testing.T) {
|
|
c := p.config()
|
|
// A well-formed, correctly signed id_token that simply carries no nonce.
|
|
p.token = p.sign(t, map[string]any{
|
|
"iss": p.issuer, "aud": p.clientID, "sub": "[email protected]",
|
|
"exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
|
|
})
|
|
resp, body := ask(t, callbackApp(c), "GET", callback+"?state=s&code=c",
|
|
&http.Cookie{Name: cookieNameState, Value: "s"},
|
|
&http.Cookie{Name: cookieNameNonce, Value: ""})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 — an unbound id_token must not mint a session", resp.StatusCode)
|
|
}
|
|
if body != "nonce not found\n" {
|
|
t.Errorf("body = %q, want %q", body, "nonce not found\n")
|
|
}
|
|
for _, ck := range resp.Cookies() {
|
|
if ck.Name == cookieNameSession {
|
|
t.Error("a session was handed out for an id_token bound to no login")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCallbackWire_TokenNotUsable(t *testing.T) {
|
|
p := newIDP(t)
|
|
c := p.config()
|
|
app := callbackApp(c)
|
|
state := []*http.Cookie{{Name: cookieNameState, Value: "s"}}
|
|
|
|
t.Run("id_token missing from the token response", func(t *testing.T) {
|
|
p.noIDToken = true
|
|
defer func() { p.noIDToken = false }()
|
|
resp, body := ask(t, app, "GET", callback+"?state=s&code=c", state...)
|
|
wire{
|
|
status: http.StatusInternalServerError,
|
|
contentType: "text/plain; charset=utf-8",
|
|
body: "Missing 'id_token' in oauth2 token\n",
|
|
nosniff: true,
|
|
}.check(t, resp, body)
|
|
})
|
|
|
|
t.Run("id_token does not verify", func(t *testing.T) {
|
|
p.token = "not.a.jwt"
|
|
resp, body := ask(t, app, "GET", callback+"?state=s&code=c", state...)
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want 500", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
|
t.Errorf("Content-Type = %q, want text/plain; charset=utf-8", got)
|
|
}
|
|
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
// The verifier's message is its own; what this pins is the prefix the
|
|
// handler puts in front of it, and that the answer ends in a newline.
|
|
if !strings.HasPrefix(body, "Failed to verify id_token: ") || !strings.HasSuffix(body, "\n") {
|
|
t.Errorf("body = %q, want %q-prefixed and newline-terminated", body, "Failed to verify id_token: ")
|
|
}
|
|
if len(resp.Cookies()) != 0 {
|
|
t.Errorf("unexpected Set-Cookie on a token that did not verify: %v", resp.Cookies())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCallbackWire_Nonce(t *testing.T) {
|
|
p := newIDP(t)
|
|
c := p.config()
|
|
app := callbackApp(c)
|
|
p.token = p.sign(t, map[string]any{
|
|
"iss": p.issuer,
|
|
"aud": p.clientID,
|
|
"sub": "[email protected]",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
"nonce": "from-the-provider",
|
|
})
|
|
|
|
t.Run("no nonce cookie", func(t *testing.T) {
|
|
resp, body := ask(t, app, "GET", callback+"?state=s&code=c",
|
|
&http.Cookie{Name: cookieNameState, Value: "s"})
|
|
wire{
|
|
status: http.StatusBadRequest,
|
|
contentType: "text/plain; charset=utf-8",
|
|
body: "nonce not found\n",
|
|
nosniff: true,
|
|
}.check(t, resp, body)
|
|
})
|
|
|
|
t.Run("nonce did not match", func(t *testing.T) {
|
|
resp, body := ask(t, app, "GET", callback+"?state=s&code=c",
|
|
&http.Cookie{Name: cookieNameState, Value: "s"},
|
|
&http.Cookie{Name: cookieNameNonce, Value: "a-different-one"})
|
|
wire{
|
|
status: http.StatusBadRequest,
|
|
contentType: "text/plain; charset=utf-8",
|
|
body: "nonce did not match\n",
|
|
nosniff: true,
|
|
}.check(t, resp, body)
|
|
})
|
|
}
|
|
|
|
// signedIn is the shape of the two answers that hand out a session, and the one
|
|
// that refuses to. These are the cases where the wire carries a credential.
|
|
func TestCallbackWire_SignedIn(t *testing.T) {
|
|
p := newIDP(t)
|
|
nonce := &http.Cookie{Name: cookieNameNonce, Value: "n"}
|
|
state := &http.Cookie{Name: cookieNameState, Value: "s"}
|
|
claims := func(sub string) map[string]any {
|
|
return map[string]any{
|
|
"iss": p.issuer, "aud": p.clientID, "sub": sub,
|
|
"exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
|
|
"nonce": "n",
|
|
}
|
|
}
|
|
|
|
// A redirect here is Location and status and nothing else — the same answer
|
|
// the login half of this flow has always given (see TestLoginAndCallback-
|
|
// RedirectAlike). It does not carry the short HTML anchor net/http writes
|
|
// for a GET, which no browser renders and which made the two halves of one
|
|
// login answer differently. What the browser acts on — status, Location and
|
|
// Set-Cookie — is unchanged, and the method no longer changes the answer.
|
|
t.Run("no allowed subjects means every subject is allowed", func(t *testing.T) {
|
|
c := p.config()
|
|
p.token = p.sign(t, claims("[email protected]"))
|
|
resp, body := ask(t, callbackApp(c), "GET", callback+"?state=s&code=c", state, nonce)
|
|
wire{
|
|
status: http.StatusFound,
|
|
body: "",
|
|
location: "/",
|
|
cookie: cookieNameSession,
|
|
}.check(t, resp, body)
|
|
})
|
|
|
|
t.Run("an allowed subject", func(t *testing.T) {
|
|
c := p.config("[email protected]") // the comparison is case-insensitive
|
|
p.token = p.sign(t, claims("[email protected]"))
|
|
resp, body := ask(t, callbackApp(c), "GET", callback+"?state=s&code=c", state, nonce)
|
|
wire{
|
|
status: http.StatusFound,
|
|
body: "",
|
|
location: "/",
|
|
cookie: cookieNameSession,
|
|
}.check(t, resp, body)
|
|
})
|
|
|
|
t.Run("a subject that is not allowed gets no session", func(t *testing.T) {
|
|
c := p.config("[email protected]")
|
|
p.token = p.sign(t, claims("[email protected]"))
|
|
resp, body := ask(t, callbackApp(c), "GET", callback+"?state=s&code=c", state, nonce)
|
|
wire{
|
|
status: http.StatusFound,
|
|
body: "",
|
|
location: "/?error=access_denied",
|
|
}.check(t, resp, body)
|
|
})
|
|
|
|
// form_post callbacks arrive as POST, and must be answered the same way.
|
|
t.Run("POST is redirected the same way", func(t *testing.T) {
|
|
c := p.config()
|
|
p.token = p.sign(t, claims("[email protected]"))
|
|
resp, body := ask(t, callbackApp(c), "POST", callback+"?state=s&code=c", state, nonce)
|
|
wire{
|
|
status: http.StatusFound,
|
|
body: "",
|
|
location: "/",
|
|
cookie: cookieNameSession,
|
|
}.check(t, resp, body)
|
|
})
|
|
}
|
|
|
|
// The two halves of one login now answer a redirect identically. This is the
|
|
// measurement behind dropping net/http's HTML courtesy body from the callback:
|
|
// loginHandler has always redirected with Location alone, so the callback
|
|
// carrying an anchor was the two halves disagreeing, not a contract.
|
|
func TestLoginAndCallbackRedirectAlike(t *testing.T) {
|
|
p := newIDP(t)
|
|
c := p.config()
|
|
p.token = p.sign(t, map[string]any{
|
|
"iss": p.issuer, "aud": p.clientID, "sub": "[email protected]",
|
|
"exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "nonce": "n",
|
|
})
|
|
|
|
login := zip.New(zip.Config{})
|
|
login.Raw(zip.MethodAll, "/oidc/login", c.loginHandler)
|
|
loginResp, loginBody := ask(t, login, "GET", "/oidc/login")
|
|
|
|
callbackResp, callbackBody := ask(t, callbackApp(c), "GET", callback+"?state=s&code=c",
|
|
&http.Cookie{Name: cookieNameState, Value: "s"},
|
|
&http.Cookie{Name: cookieNameNonce, Value: "n"})
|
|
|
|
if loginResp.StatusCode != callbackResp.StatusCode {
|
|
t.Errorf("status: login %d, callback %d", loginResp.StatusCode, callbackResp.StatusCode)
|
|
}
|
|
if loginBody != "" || callbackBody != "" {
|
|
t.Errorf("body: login %q, callback %q; want both empty", loginBody, callbackBody)
|
|
}
|
|
lct, cct := loginResp.Header.Get("Content-Type"), callbackResp.Header.Get("Content-Type")
|
|
if lct != cct {
|
|
t.Errorf("Content-Type: login %q, callback %q", lct, cct)
|
|
}
|
|
// Both name where to go next; only the destinations differ.
|
|
if loginResp.Header.Get("Location") == "" || callbackResp.Header.Get("Location") == "" {
|
|
t.Error("a redirect with no Location")
|
|
}
|
|
}
|
|
|
|
// The session cookie is the credential this whole exchange exists to hand out.
|
|
// Its attributes are what keep it from being sent by a foreign site or read by
|
|
// a script, so they are pinned separately from the status line.
|
|
func TestCallbackWire_SessionCookieAttributes(t *testing.T) {
|
|
p := newIDP(t)
|
|
c := p.config()
|
|
p.token = p.sign(t, map[string]any{
|
|
"iss": p.issuer, "aud": p.clientID, "sub": "[email protected]",
|
|
"exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "nonce": "n",
|
|
})
|
|
resp, _ := ask(t, callbackApp(c), "GET", callback+"?state=s&code=c",
|
|
&http.Cookie{Name: cookieNameState, Value: "s"},
|
|
&http.Cookie{Name: cookieNameNonce, Value: "n"})
|
|
|
|
var session *http.Cookie
|
|
for _, ck := range resp.Cookies() {
|
|
if ck.Name == cookieNameSession {
|
|
session = ck
|
|
}
|
|
}
|
|
if session == nil {
|
|
t.Fatalf("no %s cookie; got %v", cookieNameSession, resp.Cookies())
|
|
}
|
|
if session.Path != "/" {
|
|
t.Errorf("Path = %q, want %q", session.Path, "/")
|
|
}
|
|
if session.MaxAge != int(DefaultOIDCSessionTTL.Seconds()) {
|
|
t.Errorf("MaxAge = %d, want %d", session.MaxAge, int(DefaultOIDCSessionTTL.Seconds()))
|
|
}
|
|
if session.SameSite != http.SameSiteStrictMode {
|
|
t.Errorf("SameSite = %v, want Strict", session.SameSite)
|
|
}
|
|
// The value is a session id the server can resolve, not the subject.
|
|
if session.Value == "[email protected]" {
|
|
t.Error("the cookie carries the subject itself")
|
|
}
|
|
if subject, ok := sessions.Get(session.Value); !ok || subject != "[email protected]" {
|
|
t.Errorf("sessions[%q] = %v, %v; want the subject", session.Value, subject, ok)
|
|
}
|
|
}
|