1589 lines
49 KiB
Go
1589 lines
49 KiB
Go
//go:build linux || freebsd
|
|
// +build linux freebsd
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ============================================================================
|
|
// basic.go tests
|
|
// ============================================================================
|
|
|
|
func TestBasicAuth(t *testing.T) {
|
|
t.Run("sets header correctly", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", "https://example.com/resource", nil)
|
|
BasicAuth(req, "alice", "secret123")
|
|
|
|
auth := req.Header.Get("Authorization")
|
|
if auth == "" {
|
|
t.Fatal("expected Authorization header to be set")
|
|
}
|
|
if !strings.HasPrefix(auth, "Basic ") {
|
|
t.Fatalf("expected Basic prefix, got %q", auth)
|
|
}
|
|
|
|
encoded := strings.TrimPrefix(auth, "Basic ")
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
t.Fatalf("failed to decode base64: %v", err)
|
|
}
|
|
expected := "alice:secret123"
|
|
if string(decoded) != expected {
|
|
t.Fatalf("expected decoded credentials %q, got %q", expected, string(decoded))
|
|
}
|
|
})
|
|
|
|
t.Run("empty username does nothing", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", "https://example.com/resource", nil)
|
|
BasicAuth(req, "", "secret123")
|
|
|
|
if h := req.Header.Get("Authorization"); h != "" {
|
|
t.Fatalf("expected no Authorization header when username is empty, got %q", h)
|
|
}
|
|
})
|
|
|
|
t.Run("empty password still sets header", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", "https://example.com/resource", nil)
|
|
BasicAuth(req, "bob", "")
|
|
|
|
auth := req.Header.Get("Authorization")
|
|
if auth == "" {
|
|
t.Fatal("expected Authorization header to be set even with empty password")
|
|
}
|
|
})
|
|
|
|
t.Run("colon in password", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", "https://example.com/resource", nil)
|
|
BasicAuth(req, "charlie", "pass:word:with:colons")
|
|
|
|
encoded := strings.TrimPrefix(req.Header.Get("Authorization"), "Basic ")
|
|
decoded, _ := base64.StdEncoding.DecodeString(encoded)
|
|
if string(decoded) != "charlie:pass:word:with:colons" {
|
|
t.Fatalf("password with colons not preserved: %q", string(decoded))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseURLAuth(t *testing.T) {
|
|
t.Run("valid URL with credentials", func(t *testing.T) {
|
|
u, _ := url.Parse("https://user:pass@example.com/path")
|
|
username, password, ok := ParseURLAuth(u)
|
|
if !ok {
|
|
t.Fatal("expected auth to be found")
|
|
}
|
|
if username != "user" {
|
|
t.Fatalf("expected username 'user', got %q", username)
|
|
}
|
|
if password != "pass" {
|
|
t.Fatalf("expected password 'pass', got %q", password)
|
|
}
|
|
})
|
|
|
|
t.Run("URL without credentials", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/path")
|
|
_, _, ok := ParseURLAuth(u)
|
|
if ok {
|
|
t.Fatal("expected no auth for URL without credentials")
|
|
}
|
|
})
|
|
|
|
t.Run("nil URL", func(t *testing.T) {
|
|
_, _, ok := ParseURLAuth(nil)
|
|
if ok {
|
|
t.Fatal("expected no auth for nil URL")
|
|
}
|
|
})
|
|
|
|
t.Run("username only (no password)", func(t *testing.T) {
|
|
u, _ := url.Parse("https://user@example.com/path")
|
|
username, password, ok := ParseURLAuth(u)
|
|
if !ok {
|
|
t.Fatal("expected auth for username-only URL")
|
|
}
|
|
if username != "user" {
|
|
t.Fatalf("expected username 'user', got %q", username)
|
|
}
|
|
_ = password // password can be empty; we just check ok is true
|
|
})
|
|
|
|
t.Run("empty username in URL", func(t *testing.T) {
|
|
u, _ := url.Parse("https://:pass@example.com/path")
|
|
_, _, ok := ParseURLAuth(u)
|
|
if ok {
|
|
t.Fatal("expected no auth for empty username")
|
|
}
|
|
})
|
|
|
|
t.Run("special characters in credentials", func(t *testing.T) {
|
|
u, _ := url.Parse("https://user%40domain:pass%21%40%23@example.com/path")
|
|
username, password, ok := ParseURLAuth(u)
|
|
if !ok {
|
|
t.Fatal("expected auth with special characters")
|
|
}
|
|
if username != "user@domain" {
|
|
t.Fatalf("expected decoded username 'user@domain', got %q", username)
|
|
}
|
|
if password != "pass!@#" {
|
|
t.Fatalf("expected decoded password 'pass!@#', got %q", password)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestStripAuthFromURL(t *testing.T) {
|
|
t.Run("removes credentials", func(t *testing.T) {
|
|
u, _ := url.Parse("https://user:pass@example.com/path?q=v")
|
|
clean := StripAuthFromURL(u)
|
|
if clean.User != nil {
|
|
t.Fatal("expected user info to be nil")
|
|
}
|
|
if clean.String() != "https://example.com/path?q=v" {
|
|
t.Fatalf("unexpected stripped URL: %s", clean.String())
|
|
}
|
|
})
|
|
|
|
t.Run("URL without credentials unchanged", func(t *testing.T) {
|
|
u, _ := url.Parse("https://example.com/path")
|
|
clean := StripAuthFromURL(u)
|
|
if clean.User != nil {
|
|
t.Fatal("expected no user info")
|
|
}
|
|
if clean.String() != "https://example.com/path" {
|
|
t.Fatalf("unexpected stripped URL: %s", clean.String())
|
|
}
|
|
})
|
|
|
|
t.Run("nil URL", func(t *testing.T) {
|
|
clean := StripAuthFromURL(nil)
|
|
if clean != nil {
|
|
t.Fatal("expected nil for nil input")
|
|
}
|
|
})
|
|
|
|
t.Run("does not modify original URL", func(t *testing.T) {
|
|
original, _ := url.Parse("https://user:pass@example.com/path")
|
|
clean := StripAuthFromURL(original)
|
|
if original.User == nil {
|
|
t.Fatal("original URL should be unmodified")
|
|
}
|
|
_ = clean // just verifying original not mutated
|
|
})
|
|
|
|
t.Run("preserves path and query", func(t *testing.T) {
|
|
u, _ := url.Parse("https://alice:s3cret@host.com/api/v1?key=val&foo=bar")
|
|
clean := StripAuthFromURL(u)
|
|
want := "https://host.com/api/v1?key=val&foo=bar"
|
|
got := clean.String()
|
|
if got != want {
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
}
|
|
})
|
|
|
|
t.Run("preserves port", func(t *testing.T) {
|
|
u, _ := url.Parse("https://u:p@host.com:8443/path")
|
|
clean := StripAuthFromURL(u)
|
|
if clean.Port() != "8443" {
|
|
t.Fatalf("expected port 8443, got %q", clean.Port())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMaskPassword(t *testing.T) {
|
|
t.Run("empty password", func(t *testing.T) {
|
|
if m := MaskPassword(""); m != "" {
|
|
t.Fatalf("expected empty string, got %q", m)
|
|
}
|
|
})
|
|
|
|
t.Run("short password (<=4 chars)", func(t *testing.T) {
|
|
if m := MaskPassword("ab"); m != "****" {
|
|
t.Fatalf("expected '****', got %q", m)
|
|
}
|
|
if m := MaskPassword("1234"); m != "****" {
|
|
t.Fatalf("expected '****', got %q", m)
|
|
}
|
|
})
|
|
|
|
t.Run("longer password shows first 2 chars", func(t *testing.T) {
|
|
m := MaskPassword("hello123")
|
|
if !strings.HasPrefix(m, "he") {
|
|
t.Fatalf("expected prefix 'he', got %q", m)
|
|
}
|
|
if len(m) != len("hello123") {
|
|
t.Fatalf("expected masked length %d, got %d", len("hello123"), len(m))
|
|
}
|
|
// Count asterisks
|
|
asterisks := strings.Count(m, "*")
|
|
if asterisks != len("hello123")-2 {
|
|
t.Fatalf("expected %d asterisks, got %d", len("hello123")-2, asterisks)
|
|
}
|
|
})
|
|
|
|
t.Run("exactly 5 chars shows first 2 chars + 3 asterisks", func(t *testing.T) {
|
|
m := MaskPassword("abcde")
|
|
want := "ab***"
|
|
if m != want {
|
|
t.Fatalf("expected %q, got %q", want, m)
|
|
}
|
|
})
|
|
|
|
t.Run("unicode password still shows first 2 runes", func(t *testing.T) {
|
|
m := MaskPassword("abčdef")
|
|
if !strings.HasPrefix(m, "ab") {
|
|
t.Fatalf("expected prefix 'ab', got %q", m)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetAuthForURL(t *testing.T) {
|
|
makeURL := func(raw string) *url.URL {
|
|
u, _ := url.Parse(raw)
|
|
return u
|
|
}
|
|
|
|
t.Run("CLI has highest priority", func(t *testing.T) {
|
|
u := makeURL("https://urluser:urlpass@example.com/path")
|
|
user, pass := GetAuthForURL("cliuser", "clipass", "cfguser", "cfgpass", u)
|
|
if user != "cliuser" || pass != "clipass" {
|
|
t.Fatalf("expected CLI credentials, got %q:%q", user, pass)
|
|
}
|
|
})
|
|
|
|
t.Run("URL credentials are second priority", func(t *testing.T) {
|
|
u := makeURL("https://urluser:urlpass@example.com/path")
|
|
user, pass := GetAuthForURL("", "", "cfguser", "cfgpass", u)
|
|
if user != "urluser" || pass != "urlpass" {
|
|
t.Fatalf("expected URL credentials, got %q:%q", user, pass)
|
|
}
|
|
})
|
|
|
|
t.Run("Config credentials are third priority", func(t *testing.T) {
|
|
u := makeURL("https://example.com/path")
|
|
user, pass := GetAuthForURL("", "", "cfguser", "cfgpass", u)
|
|
if user != "cfguser" || pass != "cfgpass" {
|
|
t.Fatalf("expected config credentials, got %q:%q", user, pass)
|
|
}
|
|
})
|
|
|
|
t.Run("no credentials available", func(t *testing.T) {
|
|
u := makeURL("https://example.com/path")
|
|
user, pass := GetAuthForURL("", "", "", "", u)
|
|
if user != "" || pass != "" {
|
|
t.Fatalf("expected empty credentials, got %q:%q", user, pass)
|
|
}
|
|
})
|
|
|
|
t.Run("CLI with empty password still wins", func(t *testing.T) {
|
|
u := makeURL("https://urluser:urlpass@example.com/path")
|
|
user, pass := GetAuthForURL("cliuser", "", "cfguser", "cfgpass", u)
|
|
if user != "cliuser" || pass != "" {
|
|
t.Fatalf("expected CLI username with empty password, got %q:%q", user, pass)
|
|
}
|
|
})
|
|
|
|
t.Run("nil URL falls back to config", func(t *testing.T) {
|
|
user, pass := GetAuthForURL("", "", "cfguser", "cfgpass", nil)
|
|
if user != "cfguser" || pass != "cfgpass" {
|
|
t.Fatalf("expected config credentials for nil URL, got %q:%q", user, pass)
|
|
}
|
|
})
|
|
|
|
t.Run("nil URL with CLI still returns CLI", func(t *testing.T) {
|
|
user, pass := GetAuthForURL("cliuser", "clipass", "", "", nil)
|
|
if user != "cliuser" || pass != "clipass" {
|
|
t.Fatalf("expected CLI credentials for nil URL, got %q:%q", user, pass)
|
|
}
|
|
})
|
|
|
|
t.Run("all empty returns empty", func(t *testing.T) {
|
|
user, pass := GetAuthForURL("", "", "", "", nil)
|
|
if user != "" || pass != "" {
|
|
t.Fatalf("expected empty for all empty inputs + nil URL, got %q:%q", user, pass)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// digest.go tests
|
|
// ============================================================================
|
|
|
|
func TestParseDigestParams(t *testing.T) {
|
|
t.Run("simple key=value pairs", func(t *testing.T) {
|
|
params := parseDigestParams(`realm="testrealm@host.com", nonce="abc123"`)
|
|
if params["realm"] != "testrealm@host.com" {
|
|
t.Fatalf("expected realm 'testrealm@host.com', got %q", params["realm"])
|
|
}
|
|
if params["nonce"] != "abc123" {
|
|
t.Fatalf("expected nonce 'abc123', got %q", params["nonce"])
|
|
}
|
|
})
|
|
|
|
t.Run("unquoted values", func(t *testing.T) {
|
|
params := parseDigestParams(`qop=auth, algorithm=MD5`)
|
|
if params["qop"] != "auth" {
|
|
t.Fatalf("expected qop 'auth', got %q", params["qop"])
|
|
}
|
|
if params["algorithm"] != "MD5" {
|
|
t.Fatalf("expected algorithm 'MD5', got %q", params["algorithm"])
|
|
}
|
|
})
|
|
|
|
t.Run("mixed quoted and unquoted", func(t *testing.T) {
|
|
params := parseDigestParams(`realm="My Realm", algorithm=MD5, qop=auth, nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"`)
|
|
if params["realm"] != "My Realm" {
|
|
t.Fatalf("expected realm 'My Realm', got %q", params["realm"])
|
|
}
|
|
if params["algorithm"] != "MD5" {
|
|
t.Fatalf("expected algorithm 'MD5', got %q", params["algorithm"])
|
|
}
|
|
if params["qop"] != "auth" {
|
|
t.Fatalf("expected qop 'auth', got %q", params["qop"])
|
|
}
|
|
if params["nonce"] != "dcd98b7102dd2f0e8b11d0f600bfb0c093" {
|
|
t.Fatalf("unexpected nonce: %q", params["nonce"])
|
|
}
|
|
})
|
|
|
|
t.Run("empty header", func(t *testing.T) {
|
|
params := parseDigestParams("")
|
|
if len(params) != 0 {
|
|
t.Fatalf("expected empty map, got %v", params)
|
|
}
|
|
})
|
|
|
|
t.Run("whitespace handling", func(t *testing.T) {
|
|
params := parseDigestParams(` realm = "spacey realm" , nonce = "abc" `)
|
|
if params["realm"] != "spacey realm" {
|
|
t.Fatalf("expected realm 'spacey realm', got %q", params["realm"])
|
|
}
|
|
if params["nonce"] != "abc" {
|
|
t.Fatalf("expected nonce 'abc', got %q", params["nonce"])
|
|
}
|
|
})
|
|
|
|
t.Run("opaque parameter", func(t *testing.T) {
|
|
params := parseDigestParams(`realm="r", nonce="n", opaque="abc123"`)
|
|
if params["opaque"] != "abc123" {
|
|
t.Fatalf("expected opaque 'abc123', got %q", params["opaque"])
|
|
}
|
|
})
|
|
|
|
t.Run("stale parameter", func(t *testing.T) {
|
|
params := parseDigestParams(`realm="r", nonce="n", stale=TRUE`)
|
|
if params["stale"] != "TRUE" {
|
|
t.Fatalf("expected stale 'TRUE', got %q", params["stale"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNewDigestAuthHandler(t *testing.T) {
|
|
h := NewDigestAuthHandler("testuser", "testpass")
|
|
if h == nil {
|
|
t.Fatal("expected non-nil handler")
|
|
}
|
|
if h.username != "testuser" {
|
|
t.Fatalf("expected username 'testuser', got %q", h.username)
|
|
}
|
|
if h.password != "testpass" {
|
|
t.Fatalf("expected password 'testpass', got %q", h.password)
|
|
}
|
|
}
|
|
|
|
func TestDigestHandleResponse(t *testing.T) {
|
|
t.Run("successful digest auth response with qop=auth", func(t *testing.T) {
|
|
handler := NewDigestAuthHandler("Mufasa", "Circle Of Life")
|
|
|
|
req, _ := http.NewRequest("GET", "/dir/index.html", nil)
|
|
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate",
|
|
`Digest realm="testrealm@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", algorithm=MD5, qop=auth`)
|
|
|
|
// We need to let HandleResponse use its own GenerateCnonce, but we need
|
|
// to manually set up the params to include nc and cnonce. However, the
|
|
// code reads nc and cnonce from the parsed params, which come from the
|
|
// WWW-Authenticate header. In real usage these are client-generated.
|
|
// For this test, we'll include them in the header to exercise the qop path.
|
|
resp.Header.Set("WWW-Authenticate",
|
|
`Digest realm="testrealm@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", algorithm=MD5, qop=auth, nc=00000001, cnonce="0a4f113b"`)
|
|
|
|
newReq, err := handler.HandleResponse(req, resp)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
authHeader := newReq.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
t.Fatal("expected Authorization header")
|
|
}
|
|
if !strings.HasPrefix(authHeader, "Digest ") {
|
|
t.Fatalf("expected Digest prefix, got %q", authHeader)
|
|
}
|
|
|
|
// Verify key components are present
|
|
if !strings.Contains(authHeader, `username="Mufasa"`) {
|
|
t.Fatalf("expected username in auth header: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `realm="testrealm@host.com"`) {
|
|
t.Fatalf("expected realm in auth header: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"`) {
|
|
t.Fatalf("expected nonce in auth header: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `uri="/dir/index.html"`) {
|
|
t.Fatalf("expected uri in auth header: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `response="`) {
|
|
t.Fatalf("expected response in auth header: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `qop=auth`) {
|
|
t.Fatalf("expected qop=auth in auth header: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `nc=00000001`) {
|
|
t.Fatalf("expected nc in auth header: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `cnonce="0a4f113b"`) {
|
|
t.Fatalf("expected cnonce in auth header: %s", authHeader)
|
|
}
|
|
|
|
// Verify original request was not mutated
|
|
if req.Header.Get("Authorization") != "" {
|
|
t.Fatal("original request should not be modified")
|
|
}
|
|
})
|
|
|
|
t.Run("no qop (RFC 2617 style)", func(t *testing.T) {
|
|
handler := NewDigestAuthHandler("Mufasa", "Circle Of Life")
|
|
|
|
req, _ := http.NewRequest("GET", "/dir/index.html", nil)
|
|
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate",
|
|
`Digest realm="testrealm@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", algorithm=MD5`)
|
|
|
|
newReq, err := handler.HandleResponse(req, resp)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
authHeader := newReq.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
t.Fatal("expected Authorization header")
|
|
}
|
|
|
|
// Should NOT contain qop, nc, or cnonce parameters
|
|
if strings.Contains(authHeader, `qop=`) && strings.Contains(authHeader, `nc=`) {
|
|
t.Fatalf("no-qop mode should not include qop/nc/cnonce, got: %s", authHeader)
|
|
}
|
|
})
|
|
|
|
t.Run("not a digest auth challenge", func(t *testing.T) {
|
|
handler := NewDigestAuthHandler("user", "pass")
|
|
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate", "Basic realm=\"test\"")
|
|
|
|
_, err := handler.HandleResponse(req, resp)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-digest challenge")
|
|
}
|
|
if !strings.Contains(err.Error(), "not a digest auth challenge") {
|
|
t.Fatalf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing required parameters returns error", func(t *testing.T) {
|
|
handler := NewDigestAuthHandler("user", "pass")
|
|
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate", `Digest algorithm=MD5`)
|
|
|
|
_, err := handler.HandleResponse(req, resp)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing required params")
|
|
}
|
|
})
|
|
|
|
t.Run("no WWW-Authenticate header", func(t *testing.T) {
|
|
handler := NewDigestAuthHandler("user", "pass")
|
|
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
// No WWW-Authenticate header at all
|
|
|
|
_, err := handler.HandleResponse(req, resp)
|
|
if err == nil {
|
|
t.Fatal("expected error when no WWW-Authenticate header")
|
|
}
|
|
})
|
|
|
|
t.Run("with opaque parameter", func(t *testing.T) {
|
|
handler := NewDigestAuthHandler("user", "pass")
|
|
|
|
req, _ := http.NewRequest("GET", "/resource", nil)
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate",
|
|
`Digest realm="r", nonce="n", opaque="abc123xyz"`)
|
|
|
|
newReq, err := handler.HandleResponse(req, resp)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
authHeader := newReq.Header.Get("Authorization")
|
|
if !strings.Contains(authHeader, `opaque="abc123xyz"`) {
|
|
t.Fatalf("expected opaque in auth header: %s", authHeader)
|
|
}
|
|
})
|
|
|
|
t.Run("qop=auth-int with cnonce and nc", func(t *testing.T) {
|
|
handler := NewDigestAuthHandler("user", "pass")
|
|
|
|
req, _ := http.NewRequest("POST", "/submit", nil)
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate",
|
|
`Digest realm="test", nonce="abc123", algorithm=MD5, qop=auth-int, nc=00000002, cnonce="deadbeef"`)
|
|
|
|
newReq, err := handler.HandleResponse(req, resp)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
authHeader := newReq.Header.Get("Authorization")
|
|
if !strings.Contains(authHeader, `qop=auth-int`) {
|
|
t.Fatalf("expected qop=auth-int, got: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `nc=00000002`) {
|
|
t.Fatalf("expected nc=00000002, got: %s", authHeader)
|
|
}
|
|
if !strings.Contains(authHeader, `cnonce="deadbeef"`) {
|
|
t.Fatalf("expected cnonce, got: %s", authHeader)
|
|
}
|
|
})
|
|
|
|
t.Run("uri includes query string (RFC 7616 request-uri)", func(t *testing.T) {
|
|
// RFC 7616 §3.4 (and RFC 2617 §3.2.2.3) require the digest uri to
|
|
// be the request-uri (path + query), not just the path. Prior to
|
|
// the fix, HandleResponse used req.URL.Path which strips the
|
|
// query string and caused digest auth to fail for any URL that
|
|
// carried a token in the query.
|
|
handler := NewDigestAuthHandler("user", "pass")
|
|
|
|
req, _ := http.NewRequest("GET", "/api/resource?token=abc&id=42", nil)
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate",
|
|
`Digest realm="r", nonce="n", qop=auth, nc=00000001, cnonce="0a4f"`)
|
|
// Inject a cnonce so the test is deterministic; HandleResponse
|
|
// will use the one from the challenge header when present.
|
|
// (The handler's own cnonce is generated only if the challenge
|
|
// omits it.)
|
|
|
|
newReq, err := handler.HandleResponse(req, resp)
|
|
if err != nil {
|
|
t.Fatalf("HandleResponse: %v", err)
|
|
}
|
|
|
|
auth := newReq.Header.Get("Authorization")
|
|
wantURI := `uri="/api/resource?token=abc&id=42"`
|
|
if !strings.Contains(auth, wantURI) {
|
|
t.Errorf("expected Authorization to contain %s, got: %s", wantURI, auth)
|
|
}
|
|
// And the bare path must NOT appear without the query.
|
|
if strings.Contains(auth, `uri="/api/resource"`) && !strings.Contains(auth, wantURI) {
|
|
t.Errorf("Authorization contains bare path instead of request-uri: %s", auth)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGenerateCnonce(t *testing.T) {
|
|
cnonce := GenerateCnonce()
|
|
if cnonce == "" {
|
|
t.Fatal("expected non-empty cnonce")
|
|
}
|
|
// Should be a hex string (each byte -> 2 hex chars, 16 bytes -> 32)
|
|
if len(cnonce) != 32 {
|
|
t.Fatalf("expected cnonce length 32, got %d: %q", len(cnonce), cnonce)
|
|
}
|
|
// Should only contain hex characters
|
|
for _, r := range cnonce {
|
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
|
t.Fatalf("cnonce contains non-hex character %c in %q", r, cnonce)
|
|
}
|
|
}
|
|
// Should produce different values on each call (cryptographically random)
|
|
cnonce2 := GenerateCnonce()
|
|
if cnonce == cnonce2 {
|
|
t.Fatal("expected two calls to produce different cnonces")
|
|
}
|
|
}
|
|
|
|
func TestMd5Sum(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"", "d41d8cd98f00b204e9800998ecf8427e"},
|
|
{"hello", "5d41402abc4b2a76b9719d911017c592"},
|
|
{"Mufasa:testrealm@host.com:Circle Of Life", "939e7578ed9e3c518a452acee763bce9"},
|
|
{"abc", "900150983cd24fb0d6963f7d28e17f72"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := md5Sum(tt.input)
|
|
if got != tt.expected {
|
|
t.Fatalf("md5Sum(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDigestParamsGet(t *testing.T) {
|
|
p := digestParams{
|
|
"realm": "test",
|
|
"qop": "auth",
|
|
}
|
|
|
|
t.Run("existing key", func(t *testing.T) {
|
|
if v := p.get("realm"); v != "test" {
|
|
t.Fatalf("expected 'test', got %q", v)
|
|
}
|
|
})
|
|
|
|
t.Run("missing key returns empty string", func(t *testing.T) {
|
|
if v := p.get("nonexistent"); v != "" {
|
|
t.Fatalf("expected empty string, got %q", v)
|
|
}
|
|
})
|
|
|
|
t.Run("empty map returns empty string", func(t *testing.T) {
|
|
empty := digestParams{}
|
|
if v := empty.get("anything"); v != "" {
|
|
t.Fatalf("expected empty string, got %q", v)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// oauth.go tests
|
|
// ============================================================================
|
|
|
|
func TestNewOAuthClient(t *testing.T) {
|
|
t.Run("defaults to Bearer token type", func(t *testing.T) {
|
|
cfg := &OAuthConfig{
|
|
ClientID: "myclient",
|
|
TokenURL: "https://example.com/token",
|
|
}
|
|
client := NewOAuthClient(cfg)
|
|
if client == nil {
|
|
t.Fatal("expected non-nil client")
|
|
}
|
|
if client.config.TokenType != "Bearer" {
|
|
t.Fatalf("expected TokenType 'Bearer', got %q", client.config.TokenType)
|
|
}
|
|
})
|
|
|
|
t.Run("preserves custom token type", func(t *testing.T) {
|
|
cfg := &OAuthConfig{
|
|
ClientID: "myclient",
|
|
TokenURL: "https://example.com/token",
|
|
TokenType: "MAC",
|
|
}
|
|
client := NewOAuthClient(cfg)
|
|
if client.config.TokenType != "MAC" {
|
|
t.Fatalf("expected TokenType 'MAC', got %q", client.config.TokenType)
|
|
}
|
|
})
|
|
|
|
t.Run("uses 30-second timeout", func(t *testing.T) {
|
|
cfg := &OAuthConfig{}
|
|
client := NewOAuthClient(cfg)
|
|
if client.client == nil {
|
|
t.Fatal("expected non-nil http client")
|
|
}
|
|
if client.client.Timeout != 30*time.Second {
|
|
t.Fatalf("expected timeout 30s, got %v", client.client.Timeout)
|
|
}
|
|
})
|
|
|
|
t.Run("empty config still creates valid client", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{})
|
|
if client == nil {
|
|
t.Fatal("expected non-nil client")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIsTokenValid(t *testing.T) {
|
|
t.Run("no access token", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{})
|
|
if client.IsTokenValid() {
|
|
t.Fatal("expected false when no access token")
|
|
}
|
|
})
|
|
|
|
t.Run("zero expiry means no expiry (always valid)", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
AccessToken: "sometoken",
|
|
})
|
|
if !client.IsTokenValid() {
|
|
t.Fatal("expected true when token has no expiry")
|
|
}
|
|
})
|
|
|
|
t.Run("token with future expiry is valid", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
AccessToken: "sometoken",
|
|
Expiry: time.Now().Add(1 * time.Hour),
|
|
})
|
|
if !client.IsTokenValid() {
|
|
t.Fatal("expected true for token expiring in 1 hour")
|
|
}
|
|
})
|
|
|
|
t.Run("token expiring within 30 seconds is invalid", func(t *testing.T) {
|
|
// The function checks: now + 30s < expiry → valid
|
|
// So if expiry is 15s from now, then now+30s > now+15s → invalid
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
AccessToken: "sometoken",
|
|
Expiry: time.Now().Add(15 * time.Second),
|
|
})
|
|
if client.IsTokenValid() {
|
|
t.Fatal("expected false for token expiring in 15 seconds")
|
|
}
|
|
})
|
|
|
|
t.Run("expired token is invalid", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
AccessToken: "sometoken",
|
|
Expiry: time.Now().Add(-1 * time.Hour),
|
|
})
|
|
if client.IsTokenValid() {
|
|
t.Fatal("expected false for expired token")
|
|
}
|
|
})
|
|
|
|
t.Run("token exactly at 30 second boundary is valid", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
AccessToken: "sometoken",
|
|
Expiry: time.Now().Add(31 * time.Second),
|
|
})
|
|
if !client.IsTokenValid() {
|
|
t.Fatal("expected true for token at 31 seconds")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetAuthorizationURL(t *testing.T) {
|
|
t.Run("basic authorization URL", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "client123",
|
|
AuthURL: "https://provider.com/oauth/authorize",
|
|
RedirectURI: "https://myapp.com/callback",
|
|
})
|
|
|
|
authURL := client.GetAuthorizationURL("state123")
|
|
parsed, err := url.Parse(authURL)
|
|
if err != nil {
|
|
t.Fatalf("invalid URL: %v", err)
|
|
}
|
|
|
|
q := parsed.Query()
|
|
if q.Get("client_id") != "client123" {
|
|
t.Fatalf("expected client_id 'client123', got %q", q.Get("client_id"))
|
|
}
|
|
if q.Get("redirect_uri") != "https://myapp.com/callback" {
|
|
t.Fatalf("expected redirect_uri 'https://myapp.com/callback', got %q", q.Get("redirect_uri"))
|
|
}
|
|
if q.Get("response_type") != "code" {
|
|
t.Fatalf("expected response_type 'code', got %q", q.Get("response_type"))
|
|
}
|
|
if q.Get("state") != "state123" {
|
|
t.Fatalf("expected state 'state123', got %q", q.Get("state"))
|
|
}
|
|
// Scope should not be present when empty
|
|
if q.Has("scope") {
|
|
t.Fatal("expected no scope when scopes are empty")
|
|
}
|
|
})
|
|
|
|
t.Run("with scopes", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "client123",
|
|
AuthURL: "https://provider.com/oauth/authorize",
|
|
RedirectURI: "https://myapp.com/callback",
|
|
Scopes: []string{"read", "write"},
|
|
})
|
|
|
|
authURL := client.GetAuthorizationURL("xyz")
|
|
parsed, _ := url.Parse(authURL)
|
|
q := parsed.Query()
|
|
if q.Get("scope") != "read write" {
|
|
t.Fatalf("expected scope 'read write', got %q", q.Get("scope"))
|
|
}
|
|
})
|
|
|
|
t.Run("empty AuthURL returns empty string", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "client123",
|
|
})
|
|
if authURL := client.GetAuthorizationURL("state"); authURL != "" {
|
|
t.Fatalf("expected empty URL, got %q", authURL)
|
|
}
|
|
})
|
|
|
|
t.Run("AuthURL with existing query parameters uses &", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "client123",
|
|
AuthURL: "https://provider.com/oauth/authorize?existing=param",
|
|
RedirectURI: "https://myapp.com/callback",
|
|
})
|
|
|
|
authURL := client.GetAuthorizationURL("state456")
|
|
if !strings.Contains(authURL, "&") {
|
|
t.Fatalf("expected & to append params when URL already has query: %s", authURL)
|
|
}
|
|
if !strings.Contains(authURL, "existing=param") {
|
|
t.Fatalf("expected existing params preserved: %s", authURL)
|
|
}
|
|
})
|
|
|
|
t.Run("AuthURL without query uses ?", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "client123",
|
|
AuthURL: "https://provider.com/oauth/authorize",
|
|
RedirectURI: "https://myapp.com/callback",
|
|
})
|
|
|
|
authURL := client.GetAuthorizationURL("state789")
|
|
if !strings.Contains(authURL, "?") {
|
|
t.Fatalf("expected ? in URL: %s", authURL)
|
|
}
|
|
// After the first param it's still ? not &
|
|
parts := strings.SplitN(authURL, "?", 2)
|
|
if len(parts) == 2 && strings.Contains(parts[1], "?") {
|
|
t.Fatalf("URL should have only one ?: %s", authURL)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUpdateToken(t *testing.T) {
|
|
t.Run("full update", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{})
|
|
before := time.Now()
|
|
|
|
resp := &TokenResponse{
|
|
AccessToken: "new_access_token",
|
|
TokenType: "Bearer",
|
|
RefreshToken: "new_refresh_token",
|
|
ExpiresIn: 3600,
|
|
}
|
|
client.UpdateToken(resp)
|
|
|
|
if client.config.AccessToken != "new_access_token" {
|
|
t.Fatalf("expected access_token 'new_access_token', got %q", client.config.AccessToken)
|
|
}
|
|
if client.config.TokenType != "Bearer" {
|
|
t.Fatalf("expected token_type 'Bearer', got %q", client.config.TokenType)
|
|
}
|
|
if client.config.RefreshToken != "new_refresh_token" {
|
|
t.Fatalf("expected refresh_token 'new_refresh_token', got %q", client.config.RefreshToken)
|
|
}
|
|
// Expiry should be ~3600s from now
|
|
expectedExpiry := before.Add(3600 * time.Second)
|
|
if client.config.Expiry.Before(expectedExpiry.Add(-2*time.Second)) ||
|
|
client.config.Expiry.After(expectedExpiry.Add(2*time.Second)) {
|
|
t.Fatalf("expected expiry around %v, got %v", expectedExpiry, client.config.Expiry)
|
|
}
|
|
})
|
|
|
|
t.Run("empty refresh token does not overwrite existing", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
RefreshToken: "existing_refresh",
|
|
AccessToken: "old_token",
|
|
})
|
|
|
|
resp := &TokenResponse{
|
|
AccessToken: "new_token",
|
|
TokenType: "Bearer",
|
|
// RefreshToken is empty
|
|
}
|
|
client.UpdateToken(resp)
|
|
|
|
if client.config.RefreshToken != "existing_refresh" {
|
|
t.Fatalf("expected refresh_token to remain 'existing_refresh', got %q", client.config.RefreshToken)
|
|
}
|
|
})
|
|
|
|
t.Run("zero ExpiresIn does not change expiry", func(t *testing.T) {
|
|
originalExpiry := time.Now().Add(30 * time.Minute)
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
Expiry: originalExpiry,
|
|
})
|
|
|
|
resp := &TokenResponse{
|
|
AccessToken: "new_token",
|
|
TokenType: "Bearer",
|
|
ExpiresIn: 0,
|
|
}
|
|
client.UpdateToken(resp)
|
|
|
|
if !client.config.Expiry.Equal(originalExpiry) {
|
|
t.Fatalf("expected expiry unchanged, got %v vs %v", client.config.Expiry, originalExpiry)
|
|
}
|
|
})
|
|
|
|
t.Run("negative ExpiresIn does not change expiry", func(t *testing.T) {
|
|
originalExpiry := time.Now().Add(30 * time.Minute)
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
Expiry: originalExpiry,
|
|
})
|
|
|
|
resp := &TokenResponse{
|
|
AccessToken: "new_token",
|
|
TokenType: "Bearer",
|
|
ExpiresIn: -1,
|
|
}
|
|
client.UpdateToken(resp)
|
|
|
|
if !client.config.Expiry.Equal(originalExpiry) {
|
|
t.Fatalf("expected expiry unchanged for negative ExpiresIn")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplyToken(t *testing.T) {
|
|
t.Run("sets Authorization header", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
AccessToken: "my_access_token",
|
|
TokenType: "Bearer",
|
|
})
|
|
|
|
req, _ := http.NewRequest("GET", "https://api.example.com/resource", nil)
|
|
client.ApplyToken(req)
|
|
|
|
auth := req.Header.Get("Authorization")
|
|
if auth != "Bearer my_access_token" {
|
|
t.Fatalf("expected 'Bearer my_access_token', got %q", auth)
|
|
}
|
|
})
|
|
|
|
t.Run("uses custom token type", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
AccessToken: "token123",
|
|
TokenType: "MAC",
|
|
})
|
|
|
|
req, _ := http.NewRequest("GET", "https://api.example.com/resource", nil)
|
|
client.ApplyToken(req)
|
|
|
|
auth := req.Header.Get("Authorization")
|
|
if auth != "MAC token123" {
|
|
t.Fatalf("expected 'MAC token123', got %q", auth)
|
|
}
|
|
})
|
|
|
|
t.Run("empty access token does nothing", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
TokenType: "Bearer",
|
|
})
|
|
|
|
req, _ := http.NewRequest("GET", "https://api.example.com/resource", nil)
|
|
client.ApplyToken(req)
|
|
|
|
if h := req.Header.Get("Authorization"); h != "" {
|
|
t.Fatalf("expected no Authorization header, got %q", h)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRequestTokenClientCredentials(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Fatalf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
|
t.Fatalf("expected form content type, got %s", r.Header.Get("Content-Type"))
|
|
}
|
|
if r.Header.Get("Accept") != "application/json" {
|
|
t.Fatalf("expected Accept application/json, got %s", r.Header.Get("Accept"))
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatalf("failed to parse form: %v", err)
|
|
}
|
|
if r.Form.Get("grant_type") != "client_credentials" {
|
|
t.Fatalf("expected grant_type 'client_credentials', got %q", r.Form.Get("grant_type"))
|
|
}
|
|
if r.Form.Get("client_id") != "test_client" {
|
|
t.Fatalf("expected client_id 'test_client', got %q", r.Form.Get("client_id"))
|
|
}
|
|
if r.Form.Get("client_secret") != "test_secret" {
|
|
t.Fatalf("expected client_secret 'test_secret', got %q", r.Form.Get("client_secret"))
|
|
}
|
|
if r.Form.Get("scope") != "read write" {
|
|
t.Fatalf("expected scope 'read write', got %q", r.Form.Get("scope"))
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"access_token":"new_access_token","token_type":"Bearer","expires_in":3600}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
ClientSecret: "test_secret",
|
|
TokenURL: server.URL,
|
|
Scopes: []string{"read", "write"},
|
|
GrantType: "client_credentials",
|
|
})
|
|
|
|
tokenResp, err := client.RequestToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if tokenResp.AccessToken != "new_access_token" {
|
|
t.Fatalf("expected access_token 'new_access_token', got %q", tokenResp.AccessToken)
|
|
}
|
|
if tokenResp.TokenType != "Bearer" {
|
|
t.Fatalf("expected token_type 'Bearer', got %q", tokenResp.TokenType)
|
|
}
|
|
if tokenResp.ExpiresIn != 3600 {
|
|
t.Fatalf("expected expires_in 3600, got %d", tokenResp.ExpiresIn)
|
|
}
|
|
}
|
|
|
|
func TestRequestTokenRefreshToken(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatalf("parse form: %v", err)
|
|
}
|
|
if r.Form.Get("grant_type") != "refresh_token" {
|
|
t.Fatalf("expected refresh_token grant type, got %q", r.Form.Get("grant_type"))
|
|
}
|
|
if r.Form.Get("refresh_token") != "my_refresh_token" {
|
|
t.Fatalf("expected refresh_token 'my_refresh_token', got %q", r.Form.Get("refresh_token"))
|
|
}
|
|
if r.Form.Get("client_id") != "test_client" {
|
|
t.Fatalf("expected client_id 'test_client'")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"access_token":"refreshed_token","token_type":"Bearer","expires_in":7200,"refresh_token":"new_refresh_token"}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
ClientSecret: "test_secret",
|
|
TokenURL: server.URL,
|
|
RefreshToken: "my_refresh_token",
|
|
GrantType: "refresh_token",
|
|
})
|
|
|
|
tokenResp, err := client.RequestToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if tokenResp.AccessToken != "refreshed_token" {
|
|
t.Fatalf("expected 'refreshed_token', got %q", tokenResp.AccessToken)
|
|
}
|
|
if tokenResp.RefreshToken != "new_refresh_token" {
|
|
t.Fatalf("expected 'new_refresh_token', got %q", tokenResp.RefreshToken)
|
|
}
|
|
}
|
|
|
|
func TestRequestTokenErrors(t *testing.T) {
|
|
t.Run("server error", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprint(w, `{"error":"invalid_request","error_description":"Missing parameter"}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
TokenURL: server.URL,
|
|
GrantType: "client_credentials",
|
|
})
|
|
|
|
_, err := client.RequestToken(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for bad request")
|
|
}
|
|
if !strings.Contains(err.Error(), "400") {
|
|
t.Fatalf("expected status code in error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("oauth error in response body", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"error":"invalid_grant","error_description":"Token expired"}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
TokenURL: server.URL,
|
|
GrantType: "client_credentials",
|
|
})
|
|
|
|
_, err := client.RequestToken(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for OAuth error in body")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid_grant") {
|
|
t.Fatalf("expected error message containing 'invalid_grant', got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid response JSON", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `this is not json`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
TokenURL: server.URL,
|
|
GrantType: "client_credentials",
|
|
})
|
|
|
|
_, err := client.RequestToken(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
})
|
|
|
|
t.Run("sensitive fields outside the structured error are not leaked", func(t *testing.T) {
|
|
// Some providers echo the client_secret (or other secrets) back
|
|
// in the JSON body alongside the structured error fields. The
|
|
// error string must only contain the structured fields, never the
|
|
// raw body.
|
|
const leakedSecret = "SECRET_TOKEN_DO_NOT_LEAK"
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintf(w, `{"error":"invalid_request","error_description":"missing parameter","client_secret":"%s"}`, leakedSecret)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
ClientSecret: leakedSecret,
|
|
TokenURL: server.URL,
|
|
GrantType: "client_credentials",
|
|
})
|
|
|
|
_, err := client.RequestToken(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if strings.Contains(err.Error(), leakedSecret) {
|
|
t.Errorf("error message leaked secret: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("long unstructured body is truncated", func(t *testing.T) {
|
|
// Generate a body longer than the 512-byte truncation threshold
|
|
// and assert that the error string does not contain the full body.
|
|
longBody := strings.Repeat("x", 2048)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprint(w, longBody)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
TokenURL: server.URL,
|
|
GrantType: "client_credentials",
|
|
})
|
|
|
|
_, err := client.RequestToken(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if strings.Contains(err.Error(), longBody) {
|
|
t.Errorf("error message contains the full unstructured body (no truncation): len=%d", len(err.Error()))
|
|
}
|
|
if !strings.Contains(err.Error(), "(truncated)") {
|
|
t.Errorf("error message should mark the body as truncated, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRefreshToken(t *testing.T) {
|
|
t.Run("successful refresh", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatalf("parse form: %v", err)
|
|
}
|
|
if r.Form.Get("grant_type") != "refresh_token" {
|
|
t.Fatalf("expected grant_type 'refresh_token', got %q", r.Form.Get("grant_type"))
|
|
}
|
|
if r.Form.Get("refresh_token") != "my_rt" {
|
|
t.Fatalf("expected refresh_token 'my_rt', got %q", r.Form.Get("refresh_token"))
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"access_token":"new_at","token_type":"Bearer","expires_in":1800}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
ClientSecret: "test_secret",
|
|
TokenURL: server.URL,
|
|
RefreshToken: "my_rt",
|
|
GrantType: "client_credentials", // should be overridden
|
|
})
|
|
|
|
err := client.RefreshToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if client.config.AccessToken != "new_at" {
|
|
t.Fatalf("expected access_token 'new_at', got %q", client.config.AccessToken)
|
|
}
|
|
// Original grant type should be restored
|
|
if client.config.GrantType != "client_credentials" {
|
|
t.Fatalf("expected original grant_type restored, got %q", client.config.GrantType)
|
|
}
|
|
})
|
|
|
|
t.Run("no refresh token available", func(t *testing.T) {
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
TokenURL: "https://example.com/token",
|
|
})
|
|
|
|
err := client.RefreshToken(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error when no refresh token")
|
|
}
|
|
if !strings.Contains(err.Error(), "no refresh token available") {
|
|
t.Fatalf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExchangeCode(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Fatalf("expected POST, got %s", r.Method)
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatalf("parse form: %v", err)
|
|
}
|
|
if r.Form.Get("grant_type") != "authorization_code" {
|
|
t.Fatalf("expected grant_type 'authorization_code', got %q", r.Form.Get("grant_type"))
|
|
}
|
|
if r.Form.Get("code") != "auth_code_123" {
|
|
t.Fatalf("expected code 'auth_code_123', got %q", r.Form.Get("code"))
|
|
}
|
|
if r.Form.Get("redirect_uri") != "https://myapp.com/callback" {
|
|
t.Fatalf("unexpected redirect_uri: %q", r.Form.Get("redirect_uri"))
|
|
}
|
|
if r.Form.Get("client_id") != "test_client" {
|
|
t.Fatalf("unexpected client_id: %q", r.Form.Get("client_id"))
|
|
}
|
|
if r.Form.Get("client_secret") != "test_secret" {
|
|
t.Fatalf("unexpected client_secret: %q", r.Form.Get("client_secret"))
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"access_token":"exchanged_token","token_type":"Bearer","expires_in":3600,"refresh_token":"rt_from_exchange"}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
ClientSecret: "test_secret",
|
|
TokenURL: server.URL,
|
|
RedirectURI: "https://myapp.com/callback",
|
|
})
|
|
|
|
tokenResp, err := client.ExchangeCode(context.Background(), "auth_code_123")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if tokenResp.AccessToken != "exchanged_token" {
|
|
t.Fatalf("expected 'exchanged_token', got %q", tokenResp.AccessToken)
|
|
}
|
|
if tokenResp.RefreshToken != "rt_from_exchange" {
|
|
t.Fatalf("expected 'rt_from_exchange', got %q", tokenResp.RefreshToken)
|
|
}
|
|
}
|
|
|
|
func TestExchangeCodeErrors(t *testing.T) {
|
|
t.Run("server returns error", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprint(w, `invalid request`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
TokenURL: server.URL,
|
|
})
|
|
|
|
_, err := client.ExchangeCode(context.Background(), "code")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
})
|
|
|
|
t.Run("OAuth error in body", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"error":"invalid_grant","error_description":"Code expired"}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
TokenURL: server.URL,
|
|
})
|
|
|
|
_, err := client.ExchangeCode(context.Background(), "code")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid_grant") {
|
|
t.Fatalf("expected 'invalid_grant' in error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("sensitive fields outside the structured error are not leaked", func(t *testing.T) {
|
|
// Mirror of the RequestToken test: a code exchange failure that
|
|
// returns the client_secret in the body must not surface that
|
|
// secret in the error string.
|
|
const leakedSecret = "EXCHANGE_SECRET_DO_NOT_LEAK"
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"bad code","client_secret":"%s"}`, leakedSecret)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "test_client",
|
|
ClientSecret: leakedSecret,
|
|
TokenURL: server.URL,
|
|
})
|
|
|
|
_, err := client.ExchangeCode(context.Background(), "code")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if strings.Contains(err.Error(), leakedSecret) {
|
|
t.Errorf("error message leaked secret: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration-style test: full OAuth client_credentials flow
|
|
// ============================================================================
|
|
|
|
func TestOAuthFullClientCredentialsFlow(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"access_token":"integ_token","token_type":"Bearer","expires_in":3600}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewOAuthClient(&OAuthConfig{
|
|
ClientID: "integ_client",
|
|
ClientSecret: "integ_secret",
|
|
TokenURL: server.URL,
|
|
GrantType: "client_credentials",
|
|
})
|
|
|
|
// 1. Initially no token
|
|
if client.IsTokenValid() {
|
|
t.Fatal("should be invalid before request")
|
|
}
|
|
|
|
// 2. Request token
|
|
tokenResp, err := client.RequestToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("RequestToken failed: %v", err)
|
|
}
|
|
|
|
// 3. Update client
|
|
client.UpdateToken(tokenResp)
|
|
|
|
// 4. Now token should be valid
|
|
if !client.IsTokenValid() {
|
|
t.Fatal("token should be valid after update")
|
|
}
|
|
|
|
// 5. Apply token to a request
|
|
req, _ := http.NewRequest("GET", "https://api.example.com/data", nil)
|
|
client.ApplyToken(req)
|
|
|
|
auth := req.Header.Get("Authorization")
|
|
if auth != "Bearer integ_token" {
|
|
t.Fatalf("expected 'Bearer integ_token', got %q", auth)
|
|
}
|
|
|
|
// 6. Verify config values are stored
|
|
if client.config.AccessToken != "integ_token" {
|
|
t.Fatalf("expected access_token 'integ_token', got %q", client.config.AccessToken)
|
|
}
|
|
if client.config.TokenType != "Bearer" {
|
|
t.Fatalf("expected TokenType 'Bearer', got %q", client.config.TokenType)
|
|
}
|
|
if client.config.Expiry.IsZero() {
|
|
t.Fatal("expected non-zero expiry")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Digest auth: verify the response hash calculation via HandleResponse
|
|
// Using known test vectors from RFC 2617
|
|
// ============================================================================
|
|
|
|
func TestDigestAuthRFC2617KnownValues(t *testing.T) {
|
|
// RFC 2617 example:
|
|
// username="Mufasa",
|
|
// realm="testrealm@host.com",
|
|
// password="Circle Of Life"
|
|
// nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"
|
|
// method="GET"
|
|
// uri="/dir/index.html"
|
|
// qop=auth
|
|
// nc=00000001
|
|
// cnonce="0a4f113b"
|
|
//
|
|
// HA1 = MD5("Mufasa:testrealm@host.com:Circle Of Life")
|
|
// = "dcd98b7102dd2f0e8b11d0f600bfb0c093"
|
|
// HA2 = MD5("GET:/dir/index.html")
|
|
// = "7d4b6a4c1f7c7e1a9a3e1a6c5b6f9d8e" (not from RFC, computed separately)
|
|
// response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
|
|
|
|
handler := NewDigestAuthHandler("Mufasa", "Circle Of Life")
|
|
|
|
req, _ := http.NewRequest("GET", "/dir/index.html", nil)
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate",
|
|
`Digest realm="testrealm@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", algorithm=MD5, qop=auth, nc=00000001, cnonce="0a4f113b"`)
|
|
|
|
newReq, err := handler.HandleResponse(req, resp)
|
|
if err != nil {
|
|
t.Fatalf("HandleResponse error: %v", err)
|
|
}
|
|
|
|
authHeader := newReq.Header.Get("Authorization")
|
|
|
|
// Verify RFC 2617 known values
|
|
if !strings.Contains(authHeader, `username="Mufasa"`) {
|
|
t.Errorf("missing username")
|
|
}
|
|
if !strings.Contains(authHeader, `realm="testrealm@host.com"`) {
|
|
t.Errorf("missing realm")
|
|
}
|
|
if !strings.Contains(authHeader, `nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"`) {
|
|
t.Errorf("missing nonce")
|
|
}
|
|
if !strings.Contains(authHeader, `uri="/dir/index.html"`) {
|
|
t.Errorf("missing uri")
|
|
}
|
|
if !strings.Contains(authHeader, `qop=auth`) {
|
|
t.Errorf("missing qop")
|
|
}
|
|
if !strings.Contains(authHeader, `nc=00000001`) {
|
|
t.Errorf("missing nc")
|
|
}
|
|
if !strings.Contains(authHeader, `cnonce="0a4f113b"`) {
|
|
t.Errorf("missing cnonce")
|
|
}
|
|
if !strings.Contains(authHeader, `response="`) {
|
|
t.Errorf("missing response")
|
|
}
|
|
}
|
|
|
|
func TestDigestAuthBuildAuthHeader(t *testing.T) {
|
|
handler := NewDigestAuthHandler("user", "pass")
|
|
|
|
// Test the private buildAuthHeader method via HandleResponse
|
|
req, _ := http.NewRequest("POST", "/api/data", nil)
|
|
resp := &http.Response{
|
|
StatusCode: 401,
|
|
Header: make(http.Header),
|
|
}
|
|
resp.Header.Set("WWW-Authenticate",
|
|
`Digest realm="api@example.com", nonce="abc123", algorithm=MD5, qop=auth, nc=00000005, cnonce="cnonce123"`)
|
|
|
|
newReq, err := handler.HandleResponse(req, resp)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
authHeader := newReq.Header.Get("Authorization")
|
|
|
|
// Check the response format
|
|
if !strings.HasPrefix(authHeader, "Digest ") {
|
|
t.Fatalf("should start with 'Digest '")
|
|
}
|
|
|
|
// Each parameter should be comma-separated
|
|
parts := strings.Split(authHeader[len("Digest "):], ", ")
|
|
if len(parts) < 6 {
|
|
t.Fatalf("unexpected number of params: %d in %s", len(parts), authHeader)
|
|
}
|
|
}
|