523 lines
14 KiB
Go
523 lines
14 KiB
Go
|
|
package auth
|
||
|
|
|
||
|
|
// End-to-end tests for the Pocket-ID OIDC middleware against an in-process
|
||
|
|
// mock OP (httptest.Server). The mock serves:
|
||
|
|
//
|
||
|
|
// GET /.well-known/openid-configuration
|
||
|
|
// GET /jwks
|
||
|
|
// POST /token — authorization_code grant, returns a signed RS256 id_token
|
||
|
|
//
|
||
|
|
// We spin up a real net/http.ServeMux with the middleware mounted, drive the
|
||
|
|
// browser-side of the flow with a cookie jar, and assert the full /login →
|
||
|
|
// authorize → /callback → /admin round-trip, plus the negative paths
|
||
|
|
// (missing cookie → 302, wrong group → 403, bad nonce/state → 400, kid
|
||
|
|
// unknown, aud mismatch, expired id_token).
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/rsa"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"math/big"
|
||
|
|
"net/http"
|
||
|
|
"net/http/cookiejar"
|
||
|
|
"net/http/httptest"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// --- Mock Pocket-ID OP ---------------------------------------------------
|
||
|
|
|
||
|
|
type mockOP struct {
|
||
|
|
srv *httptest.Server
|
||
|
|
issuer string
|
||
|
|
key *rsa.PrivateKey
|
||
|
|
kid string
|
||
|
|
clientID string
|
||
|
|
clientSec string
|
||
|
|
redirect string
|
||
|
|
now func() time.Time
|
||
|
|
groups []string
|
||
|
|
sub string
|
||
|
|
sid string
|
||
|
|
audOverride string
|
||
|
|
tokenCalls int
|
||
|
|
}
|
||
|
|
|
||
|
|
func newMockOP(t *testing.T) *mockOP {
|
||
|
|
t.Helper()
|
||
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("rsa gen: %v", err)
|
||
|
|
}
|
||
|
|
mop := &mockOP{
|
||
|
|
key: key,
|
||
|
|
kid: "test-key-1",
|
||
|
|
now: func() time.Time { return time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) },
|
||
|
|
groups: []string{"proofedmesh-admin", "other"},
|
||
|
|
sub: "user-admin-42",
|
||
|
|
sid: "SES-abc",
|
||
|
|
}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("/.well-known/openid-configuration", mop.handleDiscovery)
|
||
|
|
mux.HandleFunc("/jwks", mop.handleJWKS)
|
||
|
|
mux.HandleFunc("/authorize", mop.handleAuthorize)
|
||
|
|
mux.HandleFunc("/token", mop.handleToken)
|
||
|
|
mop.srv = httptest.NewServer(mux)
|
||
|
|
mop.issuer = mop.srv.URL
|
||
|
|
t.Cleanup(mop.srv.Close)
|
||
|
|
return mop
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockOP) handleDiscovery(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"issuer": m.issuer,
|
||
|
|
"authorization_endpoint": m.issuer + "/authorize",
|
||
|
|
"token_endpoint": m.issuer + "/token",
|
||
|
|
"jwks_uri": m.issuer + "/jwks",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockOP) handleJWKS(w http.ResponseWriter, r *http.Request) {
|
||
|
|
pub := m.key.PublicKey
|
||
|
|
n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
|
||
|
|
eBytes := big.NewInt(int64(pub.E)).Bytes()
|
||
|
|
e := base64.RawURLEncoding.EncodeToString(eBytes)
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"keys": []map[string]any{{
|
||
|
|
"kty": "RSA",
|
||
|
|
"alg": "RS256",
|
||
|
|
"use": "sig",
|
||
|
|
"kid": m.kid,
|
||
|
|
"n": n,
|
||
|
|
"e": e,
|
||
|
|
}},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleAuthorize is unused by the Go tests (we skip the browser trip to the
|
||
|
|
// OP's login UI and jump straight to the callback with a hand-rolled code),
|
||
|
|
// but Pocket-ID would normally serve the login UI here.
|
||
|
|
func (m *mockOP) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||
|
|
http.Error(w, "tests do not drive the UI; call /token directly", http.StatusNotImplemented)
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleToken verifies grant_type/code/pkce/basic-auth and returns a signed
|
||
|
|
// id_token with the configured sub/sid/groups and the request's nonce.
|
||
|
|
func (m *mockOP) handleToken(w http.ResponseWriter, r *http.Request) {
|
||
|
|
m.tokenCalls++
|
||
|
|
if err := r.ParseForm(); err != nil {
|
||
|
|
http.Error(w, "bad form", 400)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if r.Form.Get("grant_type") != "authorization_code" {
|
||
|
|
http.Error(w, "unsupported grant", 400)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
u, p, ok := r.BasicAuth()
|
||
|
|
if !ok || u != m.clientID || p != m.clientSec {
|
||
|
|
http.Error(w, "bad basic auth", 401)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
code := r.Form.Get("code")
|
||
|
|
// our "code" encoding is `nonce.verifierHash`; the middleware will send
|
||
|
|
// the raw verifier, we verify its sha256 matches the challenge we baked
|
||
|
|
// into the code.
|
||
|
|
parts := strings.Split(code, ".")
|
||
|
|
if len(parts) != 2 {
|
||
|
|
http.Error(w, "bad code", 400)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
nonce := parts[0]
|
||
|
|
challenge := parts[1]
|
||
|
|
verifier := r.Form.Get("code_verifier")
|
||
|
|
if verifier == "" {
|
||
|
|
http.Error(w, "missing code_verifier", 400)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sum := sha256.Sum256([]byte(verifier))
|
||
|
|
if base64.RawURLEncoding.EncodeToString(sum[:]) != challenge {
|
||
|
|
http.Error(w, "pkce mismatch", 400)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
aud := m.clientID
|
||
|
|
if m.audOverride != "" {
|
||
|
|
aud = m.audOverride
|
||
|
|
}
|
||
|
|
now := m.now()
|
||
|
|
claims := map[string]any{
|
||
|
|
"iss": m.issuer,
|
||
|
|
"aud": aud,
|
||
|
|
"sub": m.sub,
|
||
|
|
"sid": m.sid,
|
||
|
|
"iat": now.Unix(),
|
||
|
|
"nbf": now.Unix(),
|
||
|
|
"exp": now.Add(10 * time.Minute).Unix(),
|
||
|
|
"nonce": nonce,
|
||
|
|
"groups": m.groups,
|
||
|
|
}
|
||
|
|
idToken := m.signIDToken(claims)
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"id_token": idToken,
|
||
|
|
"access_token": "ignored-by-admin-api",
|
||
|
|
"token_type": "Bearer",
|
||
|
|
"expires_in": 600,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockOP) signIDToken(claims map[string]any) string {
|
||
|
|
hdr := map[string]any{"alg": "RS256", "typ": "JWT", "kid": m.kid}
|
||
|
|
hdrJSON, _ := json.Marshal(hdr)
|
||
|
|
payloadJSON, _ := json.Marshal(claims)
|
||
|
|
h := base64.RawURLEncoding.EncodeToString(hdrJSON)
|
||
|
|
p := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||
|
|
signing := h + "." + p
|
||
|
|
sum := sha256.Sum256([]byte(signing))
|
||
|
|
sig, err := rsa.SignPKCS1v15(rand.Reader, m.key, crypto.SHA256, sum[:])
|
||
|
|
if err != nil {
|
||
|
|
panic(err)
|
||
|
|
}
|
||
|
|
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Test harness --------------------------------------------------------
|
||
|
|
|
||
|
|
type harness struct {
|
||
|
|
t *testing.T
|
||
|
|
op *mockOP
|
||
|
|
mw *Middleware
|
||
|
|
srv *httptest.Server
|
||
|
|
cli *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
func newHarness(t *testing.T) *harness {
|
||
|
|
t.Helper()
|
||
|
|
op := newMockOP(t)
|
||
|
|
op.clientID = "admin-api-test"
|
||
|
|
op.clientSec = "s3cret"
|
||
|
|
|
||
|
|
secret := make([]byte, 32)
|
||
|
|
if _, err := rand.Read(secret); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("GET /admin/ping", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
_, _ = w.Write([]byte("principal=" + PrincipalFrom(r.Context()) + "; session=" + SessionFrom(r.Context())))
|
||
|
|
})
|
||
|
|
|
||
|
|
var mw *Middleware
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// /login and /callback are mounted on the mux unprotected; everything
|
||
|
|
// else goes through Protect.
|
||
|
|
switch r.URL.Path {
|
||
|
|
case "/login", "/callback":
|
||
|
|
mux.ServeHTTP(w, r)
|
||
|
|
default:
|
||
|
|
mw.Protect(mux).ServeHTTP(w, r)
|
||
|
|
}
|
||
|
|
}))
|
||
|
|
t.Cleanup(srv.Close)
|
||
|
|
|
||
|
|
op.redirect = srv.URL + "/callback"
|
||
|
|
m, err := New(Config{
|
||
|
|
Issuer: op.issuer,
|
||
|
|
ClientID: op.clientID,
|
||
|
|
ClientSecret: op.clientSec,
|
||
|
|
RedirectURL: op.redirect,
|
||
|
|
CookieSecret: secret,
|
||
|
|
Secure: false,
|
||
|
|
Now: op.now,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("New: %v", err)
|
||
|
|
}
|
||
|
|
mw = m
|
||
|
|
mw.Register(mux)
|
||
|
|
|
||
|
|
jar, _ := cookiejar.New(nil)
|
||
|
|
cli := &http.Client{
|
||
|
|
Jar: jar,
|
||
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
|
|
// stop after /login → authorize so we can inspect the state cookie
|
||
|
|
// and jump straight to /callback.
|
||
|
|
return http.ErrUseLastResponse
|
||
|
|
},
|
||
|
|
}
|
||
|
|
return &harness{t: t, op: op, mw: mw, srv: srv, cli: cli}
|
||
|
|
}
|
||
|
|
|
||
|
|
// simulateCallback runs /login, extracts the authorize-redirect query (state,
|
||
|
|
// nonce, code_challenge, next hint), hands them to the mock OP's /token path
|
||
|
|
// via a hand-rolled callback hit, and returns the response.
|
||
|
|
func (h *harness) simulateCallback(path, next string) *http.Response {
|
||
|
|
h.t.Helper()
|
||
|
|
|
||
|
|
loginURL := h.srv.URL + path
|
||
|
|
if next != "" {
|
||
|
|
loginURL += "?" + url.Values{"next": {next}}.Encode()
|
||
|
|
}
|
||
|
|
resp, err := h.cli.Get(loginURL)
|
||
|
|
if err != nil {
|
||
|
|
h.t.Fatal(err)
|
||
|
|
}
|
||
|
|
resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusFound {
|
||
|
|
h.t.Fatalf("/login expected 302, got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
authURL, err := url.Parse(resp.Header.Get("Location"))
|
||
|
|
if err != nil {
|
||
|
|
h.t.Fatal(err)
|
||
|
|
}
|
||
|
|
q := authURL.Query()
|
||
|
|
state := q.Get("state")
|
||
|
|
nonce := q.Get("nonce")
|
||
|
|
challenge := q.Get("code_challenge")
|
||
|
|
if state == "" || nonce == "" || challenge == "" {
|
||
|
|
h.t.Fatalf("authorize URL missing params: %q", authURL.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
// build a code the mock OP will "accept"
|
||
|
|
code := nonce + "." + challenge
|
||
|
|
|
||
|
|
cbURL := h.srv.URL + "/callback?" + url.Values{
|
||
|
|
"state": {state},
|
||
|
|
"code": {code},
|
||
|
|
}.Encode()
|
||
|
|
cbResp, err := h.cli.Get(cbURL)
|
||
|
|
if err != nil {
|
||
|
|
h.t.Fatal(err)
|
||
|
|
}
|
||
|
|
return cbResp
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Tests ---------------------------------------------------------------
|
||
|
|
|
||
|
|
func TestFullLoginFlow_SetsSessionAndRedirects(t *testing.T) {
|
||
|
|
h := newHarness(t)
|
||
|
|
|
||
|
|
resp := h.simulateCallback("/login", "/admin/ping")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusFound {
|
||
|
|
t.Fatalf("/callback expected 302, got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
if got := resp.Header.Get("Location"); got != "/admin/ping" {
|
||
|
|
t.Fatalf("expected redirect to /admin/ping, got %q", got)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Follow it to confirm the session is accepted and the principal lands
|
||
|
|
// in the context.
|
||
|
|
h.cli.CheckRedirect = nil
|
||
|
|
pingResp, err := h.cli.Get(h.srv.URL + "/admin/ping")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer pingResp.Body.Close()
|
||
|
|
if pingResp.StatusCode != 200 {
|
||
|
|
t.Fatalf("/admin/ping expected 200, got %d", pingResp.StatusCode)
|
||
|
|
}
|
||
|
|
buf := make([]byte, 128)
|
||
|
|
n, _ := pingResp.Body.Read(buf)
|
||
|
|
body := string(buf[:n])
|
||
|
|
if !strings.Contains(body, "principal=user-admin-42") {
|
||
|
|
t.Fatalf("expected principal in body, got %q", body)
|
||
|
|
}
|
||
|
|
if !strings.Contains(body, "session=SES-abc") {
|
||
|
|
t.Fatalf("expected session in body, got %q", body)
|
||
|
|
}
|
||
|
|
if h.op.tokenCalls != 1 {
|
||
|
|
t.Fatalf("expected one /token call, got %d", h.op.tokenCalls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProtect_UnauthenticatedRedirectsToLogin(t *testing.T) {
|
||
|
|
h := newHarness(t)
|
||
|
|
|
||
|
|
resp, err := h.cli.Get(h.srv.URL + "/admin/ping?debug=1")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusFound {
|
||
|
|
t.Fatalf("expected 302, got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
loc := resp.Header.Get("Location")
|
||
|
|
if !strings.HasPrefix(loc, "/login?") {
|
||
|
|
t.Fatalf("expected /login redirect, got %q", loc)
|
||
|
|
}
|
||
|
|
u, _ := url.Parse(loc)
|
||
|
|
if next := u.Query().Get("next"); next != "/admin/ping?debug=1" {
|
||
|
|
t.Fatalf("next not preserved, got %q", next)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallback_MissingGroupRejected(t *testing.T) {
|
||
|
|
h := newHarness(t)
|
||
|
|
h.op.groups = []string{"other-group"} // drop proofedmesh-admin
|
||
|
|
|
||
|
|
resp := h.simulateCallback("/login", "")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusForbidden {
|
||
|
|
t.Fatalf("expected 403 for missing group, got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallback_NonceMismatchRejected(t *testing.T) {
|
||
|
|
h := newHarness(t)
|
||
|
|
|
||
|
|
loginURL := h.srv.URL + "/login"
|
||
|
|
resp, err := h.cli.Get(loginURL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
resp.Body.Close()
|
||
|
|
authURL, _ := url.Parse(resp.Header.Get("Location"))
|
||
|
|
state := authURL.Query().Get("state")
|
||
|
|
challenge := authURL.Query().Get("code_challenge")
|
||
|
|
|
||
|
|
// code encodes the WRONG nonce
|
||
|
|
cb := h.srv.URL + "/callback?" + url.Values{
|
||
|
|
"state": {state},
|
||
|
|
"code": {"bogus-nonce." + challenge},
|
||
|
|
}.Encode()
|
||
|
|
cbResp, err := h.cli.Get(cb)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer cbResp.Body.Close()
|
||
|
|
if cbResp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("expected 401 for nonce mismatch, got %d", cbResp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallback_StateMismatchRejected(t *testing.T) {
|
||
|
|
h := newHarness(t)
|
||
|
|
|
||
|
|
resp, err := h.cli.Get(h.srv.URL + "/login")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
resp.Body.Close()
|
||
|
|
authURL, _ := url.Parse(resp.Header.Get("Location"))
|
||
|
|
nonce := authURL.Query().Get("nonce")
|
||
|
|
challenge := authURL.Query().Get("code_challenge")
|
||
|
|
|
||
|
|
cb := h.srv.URL + "/callback?" + url.Values{
|
||
|
|
"state": {"not-the-state-we-stored"},
|
||
|
|
"code": {nonce + "." + challenge},
|
||
|
|
}.Encode()
|
||
|
|
cbResp, err := h.cli.Get(cb)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer cbResp.Body.Close()
|
||
|
|
if cbResp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Fatalf("expected 400 for state mismatch, got %d", cbResp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallback_AudienceMismatchRejected(t *testing.T) {
|
||
|
|
h := newHarness(t)
|
||
|
|
h.op.audOverride = "some-other-client"
|
||
|
|
|
||
|
|
resp := h.simulateCallback("/login", "")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("expected 401 for aud mismatch, got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSessionExpiredCookieRedirects(t *testing.T) {
|
||
|
|
h := newHarness(t)
|
||
|
|
|
||
|
|
resp := h.simulateCallback("/login", "/admin/ping")
|
||
|
|
resp.Body.Close()
|
||
|
|
|
||
|
|
// Advance clock past SessionTTL (default 8h); re-init middleware clock.
|
||
|
|
h.mw.cfg.Now = func() time.Time { return time.Date(2026, 10, 26, 1, 0, 0, 0, time.UTC) }
|
||
|
|
|
||
|
|
h.cli.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }
|
||
|
|
pingResp, err := h.cli.Get(h.srv.URL + "/admin/ping")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer pingResp.Body.Close()
|
||
|
|
if pingResp.StatusCode != http.StatusFound {
|
||
|
|
t.Fatalf("expected 302 after session expiry, got %d", pingResp.StatusCode)
|
||
|
|
}
|
||
|
|
if !strings.HasPrefix(pingResp.Header.Get("Location"), "/login") {
|
||
|
|
t.Fatalf("expected /login redirect, got %q", pingResp.Header.Get("Location"))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestVerifyIDToken_InvalidKidRejected(t *testing.T) {
|
||
|
|
// unit-level cross-check for the JWT verifier: a token signed with an
|
||
|
|
// unknown kid is rejected even if the signature math would otherwise
|
||
|
|
// pass against *some* key.
|
||
|
|
op := newMockOP(t)
|
||
|
|
op.clientID = "x"
|
||
|
|
op.clientSec = "y"
|
||
|
|
keys := map[string]*rsa.PublicKey{"other-kid": &op.key.PublicKey}
|
||
|
|
tok := op.signIDToken(map[string]any{
|
||
|
|
"iss": op.issuer,
|
||
|
|
"aud": op.clientID,
|
||
|
|
"sub": "u1",
|
||
|
|
"exp": op.now().Add(1 * time.Hour).Unix(),
|
||
|
|
"iat": op.now().Unix(),
|
||
|
|
"nonce": "n1",
|
||
|
|
})
|
||
|
|
_, err := verifyIDToken(tok, keys, op.issuer, op.clientID, "n1", op.now())
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected unknown-kid rejection, got nil")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "kid") {
|
||
|
|
t.Fatalf("expected kid error, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestVerifyIDToken_ExpiredRejected(t *testing.T) {
|
||
|
|
op := newMockOP(t)
|
||
|
|
op.clientID = "x"
|
||
|
|
op.clientSec = "y"
|
||
|
|
keys := map[string]*rsa.PublicKey{op.kid: &op.key.PublicKey}
|
||
|
|
tok := op.signIDToken(map[string]any{
|
||
|
|
"iss": op.issuer,
|
||
|
|
"aud": op.clientID,
|
||
|
|
"sub": "u1",
|
||
|
|
"exp": op.now().Add(-10 * time.Minute).Unix(),
|
||
|
|
"iat": op.now().Add(-20 * time.Minute).Unix(),
|
||
|
|
"nonce": "n1",
|
||
|
|
})
|
||
|
|
_, err := verifyIDToken(tok, keys, op.issuer, op.clientID, "n1", op.now())
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "expired") {
|
||
|
|
t.Fatalf("expected expired error, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSanitizeNext_RejectsOffsiteRedirects(t *testing.T) {
|
||
|
|
cases := []struct {
|
||
|
|
in, want string
|
||
|
|
}{
|
||
|
|
{"", "/default"},
|
||
|
|
{"/admin/x", "/admin/x"},
|
||
|
|
{"//evil.example", "/default"},
|
||
|
|
{"https://evil.example/x", "/default"},
|
||
|
|
{"javascript:alert(1)", "/default"},
|
||
|
|
{"../etc/passwd", "/default"},
|
||
|
|
}
|
||
|
|
for _, tc := range cases {
|
||
|
|
got := sanitizeNext(tc.in, "/default")
|
||
|
|
if got != tc.want {
|
||
|
|
t.Errorf("sanitizeNext(%q) = %q, want %q", tc.in, got, tc.want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|