483 lines
19 KiB
Go
483 lines
19 KiB
Go
package main
|
|
|
|
// Keycloak sign-in (novox/hq ADR 0440 §3, issue 1263): an httptest stand-in issuer, as the lead's prepare
|
|
// findings said to check this against, minting its own ID tokens (RS256) so a test controls exactly what
|
|
// go-oidc's verifier is asked to check: the right issuer, audience, expiry and signature, and the wrong ones.
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"html"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeIssuer is a minimal OIDC issuer: discovery, JWKS and a token endpoint that answers whatever ID token the
|
|
// test set up, regardless of the code it is asked to exchange (the code's own validity is not what issue
|
|
// 1263's acceptance criteria are about; the ID token's is).
|
|
type fakeIssuer struct {
|
|
*httptest.Server
|
|
key *rsa.PrivateKey
|
|
kid string
|
|
nextIDToken string
|
|
}
|
|
|
|
func newFakeIssuer(t *testing.T) *fakeIssuer {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fi := &fakeIssuer{key: key, kid: "test-key"}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/openid-configuration", fi.discovery)
|
|
mux.HandleFunc("/jwks", fi.jwks)
|
|
mux.HandleFunc("/token", fi.token)
|
|
fi.Server = httptest.NewServer(mux)
|
|
t.Cleanup(fi.Server.Close)
|
|
return fi
|
|
}
|
|
|
|
func (fi *fakeIssuer) discovery(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"issuer": fi.Server.URL,
|
|
"authorization_endpoint": fi.Server.URL + "/authorize",
|
|
"token_endpoint": fi.Server.URL + "/token",
|
|
"jwks_uri": fi.Server.URL + "/jwks",
|
|
"userinfo_endpoint": fi.Server.URL + "/userinfo",
|
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
|
"response_types_supported": []string{"code"},
|
|
"subject_types_supported": []string{"public"},
|
|
})
|
|
}
|
|
|
|
func (fi *fakeIssuer) jwks(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
n := base64.RawURLEncoding.EncodeToString(fi.key.PublicKey.N.Bytes())
|
|
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(fi.key.PublicKey.E)).Bytes())
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]any{
|
|
{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": fi.kid, "n": n, "e": e},
|
|
}})
|
|
}
|
|
|
|
func (fi *fakeIssuer) token(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": "fake-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"id_token": fi.nextIDToken,
|
|
})
|
|
}
|
|
|
|
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
|
|
|
// sign mints an RS256 JWT of claims, signed with key under kid — the real issuer's own key unless a test asks
|
|
// for "signed by another key" (1263/AC4).
|
|
func sign(claims map[string]any, kid string, key *rsa.PrivateKey) string {
|
|
header, _ := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid})
|
|
payload, _ := json.Marshal(claims)
|
|
signingInput := b64url(header) + "." + b64url(payload)
|
|
sum := sha256.Sum256([]byte(signingInput))
|
|
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return signingInput + "." + b64url(sig)
|
|
}
|
|
|
|
// idToken is a valid ID token's claims, as claimsPatch overrides them (so a test changes exactly one thing —
|
|
// the audience, the expiry, the signing key — and nothing else).
|
|
func (fi *fakeIssuer) idToken(clientID, username string, now time.Time, claimsPatch map[string]any, key *rsa.PrivateKey, kid string) string {
|
|
claims := map[string]any{
|
|
"iss": fi.Server.URL,
|
|
"aud": clientID,
|
|
"exp": now.Add(time.Hour).Unix(),
|
|
"iat": now.Unix(),
|
|
"sub": "user-" + username,
|
|
"preferred_username": username,
|
|
}
|
|
for k, v := range claimsPatch {
|
|
claims[k] = v
|
|
}
|
|
return sign(claims, kid, key)
|
|
}
|
|
|
|
// testAuth is an Auth over fi, with its own callback redirect https://mail.g14.internal/signed-in, and
|
|
// deterministic time so a test controls what "12 hours ago" or "expired" means.
|
|
func testAuth(t *testing.T, fi *fakeIssuer, operatorsPath string, now func() time.Time) *Auth {
|
|
t.Helper()
|
|
cfg := OIDCConfig{ClientID: "mail-web", ClientSecret: "s3cr3t", Issuer: fi.Server.URL}
|
|
authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+InternalHost("g14")+SignedInPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &Auth{
|
|
Auth: authenticator,
|
|
Sessions: NewSessions(now),
|
|
Pending: NewPendingAuths(now),
|
|
Operators: Operators{Path: operatorsPath},
|
|
Logf: t.Logf,
|
|
}
|
|
}
|
|
|
|
func operatorsFile(t *testing.T, names ...string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := dir + "/operators.json"
|
|
raw, _ := json.Marshal(map[string]any{"operators": names})
|
|
writeFile(t, path, string(raw))
|
|
return path
|
|
}
|
|
|
|
// extractRefreshURL reads a bounce or callback page's own meta refresh target out of its rendered HTML
|
|
// (review round 1 finding 1): html/template's own attribute escaping turns "&" into "&" in the URL it
|
|
// carries (its contextual auto-escaping recognises content="...;url=..." as a URL inside an HTML attribute,
|
|
// and escapes it as both), so this reverses exactly that, never by building the URL by hand.
|
|
func extractRefreshURL(t *testing.T, body string) string {
|
|
t.Helper()
|
|
marker := `content="0; url=`
|
|
i := strings.Index(body, marker)
|
|
if i < 0 {
|
|
t.Fatalf("no meta refresh in the page:\n%s", body)
|
|
}
|
|
rest := body[i+len(marker):]
|
|
j := strings.Index(rest, `"`)
|
|
if j < 0 {
|
|
t.Fatalf("the meta refresh's own url is not closed:\n%s", body)
|
|
}
|
|
return html.UnescapeString(rest[:j])
|
|
}
|
|
|
|
// followBounce is RequireSession's own one-time bounce (review round 1 finding 1), followed the way a
|
|
// browser's own meta refresh would: w is the first, cookieless answer (a 200 page of this origin's own,
|
|
// never a redirect); this reads its refresh target and asks it, answering that second response — the real
|
|
// 302 to the issuer, since the cookie is still missing on a request this player never carries one on.
|
|
func followBounce(t *testing.T, s *Server, w *httptest.ResponseRecorder, host string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("the bounce: status %d, want 200 (a page of this origin's own, review round 1 finding 1): %s", w.Code, w.Body.String())
|
|
}
|
|
refreshURL := extractRefreshURL(t, w.Body.String())
|
|
w2 := httptest.NewRecorder()
|
|
r2 := httptest.NewRequest(http.MethodGet, refreshURL, nil)
|
|
r2.Host = host
|
|
s.ServeHTTP(w2, r2)
|
|
return w2
|
|
}
|
|
|
|
// 1263/AC1: no session: a redirect to the issuer's authorisation endpoint with the client id, a state, a PKCE
|
|
// challenge, and the redirect https://mail.<node>.internal/signed-in — reached only after RequireSession's own
|
|
// one-time bounce (review round 1 finding 1), as a browser would, never asked for directly.
|
|
func TestNoSessionRedirectsToTheIssuersAuthorisationEndpoint(t *testing.T) {
|
|
fi := newFakeIssuer(t)
|
|
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
|
s := NewServer(InternalHost("g14"), auth, nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Host = InternalHost("g14")
|
|
s.ServeHTTP(w, r)
|
|
w2 := followBounce(t, s, w, InternalHost("g14"))
|
|
|
|
if w2.Code != http.StatusFound {
|
|
t.Fatalf("status %d, want 302", w2.Code)
|
|
}
|
|
loc, err := url.Parse(w2.Header().Get("Location"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(loc.String(), fi.Server.URL+"/authorize") {
|
|
t.Fatalf("redirected to %s, not the issuer's authorisation endpoint", loc)
|
|
}
|
|
q := loc.Query()
|
|
if q.Get("client_id") != "mail-web" {
|
|
t.Fatalf("client_id: %q", q.Get("client_id"))
|
|
}
|
|
if q.Get("state") == "" {
|
|
t.Fatalf("no state")
|
|
}
|
|
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
|
t.Fatalf("no PKCE challenge: %q %q", q.Get("code_challenge"), q.Get("code_challenge_method"))
|
|
}
|
|
if q.Get("redirect_uri") != "https://"+InternalHost("g14")+SignedInPath {
|
|
t.Fatalf("redirect_uri: %q", q.Get("redirect_uri"))
|
|
}
|
|
}
|
|
|
|
// review round 1 finding 1: a GET with no session cookie is bounced once through this origin's own page (a
|
|
// 200 with a meta refresh back to the very page asked, marked by BounceParam) rather than sent to the issuer
|
|
// at once; a second cookieless arrival at that same marked URL goes to sign in directly — no loop — and the
|
|
// sign-in's own kept return is the page first asked, with the bounce's own marker stripped so it never leaks
|
|
// into where the browser ends up. A POST with no session cookie is never bounced (a bounce would lose its
|
|
// body): it goes to sign in directly, the same as it always has.
|
|
func TestRequireSessionBouncesOnceThenSignsIn(t *testing.T) {
|
|
fi := newFakeIssuer(t)
|
|
auth := testAuth(t, fi, operatorsFile(t, "alice"), nil)
|
|
s := NewServer(InternalHost("g14"), auth, nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/folder?account=a&folder=INBOX", nil)
|
|
r.Host = InternalHost("g14")
|
|
s.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("the first cookieless GET: status %d, want 200 (the bounce)", w.Code)
|
|
}
|
|
refreshURL := extractRefreshURL(t, w.Body.String())
|
|
if !strings.HasPrefix(refreshURL, "/folder?") {
|
|
t.Fatalf("the bounce does not return to the same page: %q", refreshURL)
|
|
}
|
|
q, err := url.ParseQuery(strings.SplitN(refreshURL, "?", 2)[1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if q.Get(BounceParam) == "" {
|
|
t.Fatalf("the bounce's own refresh does not mark itself: %q", refreshURL)
|
|
}
|
|
if q.Get("account") != "a" || q.Get("folder") != "INBOX" {
|
|
t.Fatalf("the bounce lost the page's own query: %q", refreshURL)
|
|
}
|
|
|
|
// A second cookieless arrival, at the marked URL: sign in directly, no further bounce (no loop).
|
|
w2 := httptest.NewRecorder()
|
|
r2 := httptest.NewRequest(http.MethodGet, refreshURL, nil)
|
|
r2.Host = InternalHost("g14")
|
|
s.ServeHTTP(w2, r2)
|
|
if w2.Code != http.StatusFound {
|
|
t.Fatalf("the second cookieless GET: status %d, want 302 to sign in (no loop)", w2.Code)
|
|
}
|
|
loc, err := url.Parse(w2.Header().Get("Location"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(loc.String(), fi.Server.URL+"/authorize") {
|
|
t.Fatalf("the second cookieless GET did not go to the issuer: %s", loc)
|
|
}
|
|
|
|
// The sign-in's own kept return is the page first asked, the bounce's own marker gone.
|
|
pending, ok := auth.Pending.Take(loc.Query().Get("state"))
|
|
if !ok {
|
|
t.Fatal("no pending sign-in kept under that state")
|
|
}
|
|
if pending.Return != "/folder?account=a&folder=INBOX" {
|
|
t.Fatalf("the sign-in's own return: %q, want the page first asked with no bounce marker", pending.Return)
|
|
}
|
|
|
|
// A POST with no session cookie: sign in directly, never bounced.
|
|
w3 := httptest.NewRecorder()
|
|
r3 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
|
|
r3.Host = InternalHost("g14")
|
|
s.ServeHTTP(w3, r3)
|
|
if w3.Code != http.StatusFound {
|
|
t.Fatalf("a POST with no session cookie: status %d, want 302 (no bounce)", w3.Code)
|
|
}
|
|
}
|
|
|
|
// startSignIn drives the real shape of starting a sign-in from path, as a browser would (review round 1
|
|
// finding 2): the first cookieless GET is bounced once (RequireSession's own fix, finding 1); following that
|
|
// bounce — reading its own meta refresh and asking it, never building the issuer's URL by hand — reaches the
|
|
// real 302 to the issuer, whose state and PKCE verifier the callback needs.
|
|
func startSignIn(t *testing.T, s *Server, auth *Auth, path string) (state string) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
|
r.Host = InternalHost("g14")
|
|
s.ServeHTTP(w, r)
|
|
w2 := followBounce(t, s, w, InternalHost("g14"))
|
|
if w2.Code != http.StatusFound {
|
|
t.Fatalf("after the bounce: status %d, want 302 to the issuer", w2.Code)
|
|
}
|
|
loc, err := url.Parse(w2.Header().Get("Location"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return loc.Query().Get("state")
|
|
}
|
|
|
|
func callback(s *Server, state, code string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, SignedInPath+"?state="+url.QueryEscape(state)+"&code="+url.QueryEscape(code), nil)
|
|
r.Host = InternalHost("g14")
|
|
s.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// 1263/AC2: a valid callback whose ID token names an operator sets a session cookie Secure, HttpOnly,
|
|
// SameSite=Strict, lasting 12 hours, and sends the browser back to the page it first asked for — a page the
|
|
// test starts sign-in from that is not also the no-pending-return fallback (review round 1 finding 2: "/" is
|
|
// both, so a return to it would pass even with pending.Return ignored), through a 200 refresh page, not a 302
|
|
// (finding 1: a 302 here would carry the Strict cookie just set on a navigation the browser still counts as
|
|
// begun at Keycloak).
|
|
func TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie(t *testing.T) {
|
|
fi := newFakeIssuer(t)
|
|
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
|
s := NewServer(InternalHost("g14"), auth, nil)
|
|
|
|
const returnPath = "/folder?account=a&folder=INBOX"
|
|
state := startSignIn(t, s, auth, returnPath)
|
|
// The session clock (now) is mocked to test the 12-hour bound (TestASessionTwelveHoursOldIsTreatedAsNoSession
|
|
// below); the ID token's own iat/exp are judged by go-oidc against the real wall clock, so they are minted
|
|
// from it, not from the mocked now.
|
|
fi.nextIDToken = fi.idToken("mail-web", "alice", time.Now(), nil, fi.key, fi.kid)
|
|
w := callback(s, state, "some-code")
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d, want 200 (a refresh page, not a redirect): %s", w.Code, w.Body.String())
|
|
}
|
|
if refreshURL := extractRefreshURL(t, w.Body.String()); refreshURL != returnPath {
|
|
t.Fatalf("returned to %q, want the page first asked for (%s)", refreshURL, returnPath)
|
|
}
|
|
var found *http.Cookie
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == SessionCookie {
|
|
found = c
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("no session cookie set")
|
|
}
|
|
if !found.Secure || !found.HttpOnly || found.SameSite != http.SameSiteStrictMode {
|
|
t.Fatalf("cookie attributes: Secure=%v HttpOnly=%v SameSite=%v", found.Secure, found.HttpOnly, found.SameSite)
|
|
}
|
|
if found.MaxAge != int(SessionLifetime/time.Second) {
|
|
t.Fatalf("MaxAge %d, want %d (12h)", found.MaxAge, int(SessionLifetime/time.Second))
|
|
}
|
|
}
|
|
|
|
// 1263/AC3: an ID token naming an identity not in operators is answered 403, and no session cookie is set.
|
|
func TestACallbackForAnIdentityNotInOperatorsIsRefused(t *testing.T) {
|
|
fi := newFakeIssuer(t)
|
|
now := time.Now()
|
|
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
|
s := NewServer(InternalHost("g14"), auth, nil)
|
|
|
|
state := startSignIn(t, s, auth, "/")
|
|
fi.nextIDToken = fi.idToken("mail-web", "mallory", now, nil, fi.key, fi.kid)
|
|
w := callback(s, state, "some-code")
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status %d, want 403", w.Code)
|
|
}
|
|
if len(w.Result().Cookies()) != 0 {
|
|
t.Fatalf("a cookie was set for a refused identity: %v", w.Result().Cookies())
|
|
}
|
|
}
|
|
|
|
// 1263/AC4: a state this module did not issue, an ID token of another audience, one signed by another key, or
|
|
// one expired — each answered 403, no session made.
|
|
func TestACallbackIsRefusedOnAnUnknownStateOrAnInvalidIDToken(t *testing.T) {
|
|
fi := newFakeIssuer(t)
|
|
now := time.Now()
|
|
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
setup func(auth *Auth, s *Server) (state string)
|
|
token func() string
|
|
}{
|
|
{"unknown state", func(auth *Auth, s *Server) string { return "a-state-nobody-issued" },
|
|
func() string { return fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid) }},
|
|
{"wrong audience", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
|
func() string { return fi.idToken("someone-else", "alice", now, nil, fi.key, fi.kid) }},
|
|
{"wrong signing key", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
|
func() string { return fi.idToken("mail-web", "alice", now, nil, otherKey, fi.kid) }},
|
|
{"expired", func(auth *Auth, s *Server) string { return startSignIn(t, s, auth, "/") },
|
|
func() string {
|
|
return fi.idToken("mail-web", "alice", now, map[string]any{"exp": now.Add(-time.Hour).Unix()}, fi.key, fi.kid)
|
|
}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
|
s := NewServer(InternalHost("g14"), auth, nil)
|
|
state := c.setup(auth, s)
|
|
fi.nextIDToken = c.token()
|
|
w := callback(s, state, "some-code")
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("%s: status %d, want 403: %s", c.name, w.Code, w.Body.String())
|
|
}
|
|
if len(w.Result().Cookies()) != 0 {
|
|
t.Fatalf("%s: a cookie was set", c.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 1263/AC5: a session made 12 hours ago is treated as no session: a page asked then is redirected to sign in
|
|
// again.
|
|
func TestASessionTwelveHoursOldIsTreatedAsNoSession(t *testing.T) {
|
|
fi := newFakeIssuer(t)
|
|
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
now := start
|
|
auth := testAuth(t, fi, operatorsFile(t, "alice"), func() time.Time { return now })
|
|
s := NewServer(InternalHost("g14"), auth, nil)
|
|
|
|
state := startSignIn(t, s, auth, "/")
|
|
// The ID token's own iat/exp are judged against the real wall clock (see the comment in
|
|
// TestACallbackWithAValidIDTokenNamingAnOperatorSetsTheSessionCookie above); the session's own age is what
|
|
// this test mocks, through the session clock (now).
|
|
fi.nextIDToken = fi.idToken("mail-web", "alice", time.Now(), nil, fi.key, fi.kid)
|
|
w := callback(s, state, "some-code")
|
|
var cookie *http.Cookie
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == SessionCookie {
|
|
cookie = c
|
|
}
|
|
}
|
|
if cookie == nil {
|
|
t.Fatal("no session cookie set")
|
|
}
|
|
|
|
// Just under 12 hours: still a session.
|
|
now = start.Add(12*time.Hour - time.Minute)
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Host = InternalHost("g14")
|
|
r.AddCookie(cookie)
|
|
w2 := httptest.NewRecorder()
|
|
s.ServeHTTP(w2, r)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("just under 12h: status %d, want 200", w2.Code)
|
|
}
|
|
|
|
// Past 12 hours: no session any more — redirected to sign in again.
|
|
now = start.Add(12*time.Hour + time.Minute)
|
|
r2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r2.Host = InternalHost("g14")
|
|
r2.AddCookie(cookie)
|
|
w3 := httptest.NewRecorder()
|
|
s.ServeHTTP(w3, r2)
|
|
if w3.Code != http.StatusFound {
|
|
t.Fatalf("past 12h: status %d, want 302 (redirect to sign in)", w3.Code)
|
|
}
|
|
}
|
|
|
|
// 1263/AC6: operators empty lets no identity in.
|
|
func TestCallbackWithEmptyOperatorsLetsNobodyIn(t *testing.T) {
|
|
fi := newFakeIssuer(t)
|
|
now := time.Now()
|
|
auth := testAuth(t, fi, operatorsFile(t), func() time.Time { return now })
|
|
s := NewServer(InternalHost("g14"), auth, nil)
|
|
|
|
state := startSignIn(t, s, auth, "/")
|
|
fi.nextIDToken = fi.idToken("mail-web", "alice", now, nil, fi.key, fi.kid)
|
|
w := callback(s, state, "some-code")
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status %d, want 403", w.Code)
|
|
}
|
|
}
|