Files
goget/internal/auth/auth_test.go
T

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)
}
}