Files
garage-ui/backend/internal/auth/auth_more_test.go
T

268 lines
8.0 KiB
Go

package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/config"
"golang.org/x/oauth2"
)
// newOIDCServerWithTokenAndUserInfo extends the minimal discovery stub with
// token and userinfo endpoints so ExchangeCode and GetUserInfo can be driven
// end-to-end without a real IdP.
func newOIDCServerWithTokenAndUserInfo(
t *testing.T,
tokenResp map[string]any,
tokenStatus int,
userInfoResp map[string]any,
userInfoStatus int,
) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
doc := map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/auth",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"id_token_signing_alg_values_supported": []string{"RS256"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"keys":[]}`))
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tokenStatus)
if tokenResp != nil {
_ = json.NewEncoder(w).Encode(tokenResp)
}
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(userInfoStatus)
if userInfoResp != nil {
_ = json.NewEncoder(w).Encode(userInfoResp)
}
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestExchangeCode_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
if _, err := svc.ExchangeCode(context.Background(), "some-code"); err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestExchangeCode_TokenEndpointErrorPropagates(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
map[string]any{"error": "invalid_grant"}, http.StatusBadRequest,
nil, http.StatusOK,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.ExchangeCode(context.Background(), "bad-code")
if err == nil {
t.Fatal("expected error for 400 from token endpoint")
}
if !strings.Contains(err.Error(), "failed to exchange code") {
t.Errorf("error = %v, want wrap 'failed to exchange code'", err)
}
}
func TestVerifyIDToken_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
if _, err := svc.VerifyIDToken(context.Background(), "tok"); err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestVerifyIDToken_GarbageTokenRejected(t *testing.T) {
// Discovery works; JWKS is empty so no signature can verify — any token
// is rejected. This exercises the verifier error path.
srv := newOIDCServerWithTokenAndUserInfo(t, nil, http.StatusOK, nil, http.StatusOK)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.VerifyIDToken(context.Background(), "not-a-real-jwt")
if err == nil {
t.Fatal("expected verifier error for garbage token")
}
}
func TestGetUserInfo_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
_, err := svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "x"})
if err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestGetUserInfo_ProviderErrorPropagates(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
nil, http.StatusOK,
map[string]any{"error": "invalid_token"}, http.StatusUnauthorized,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "bad"})
if err == nil {
t.Fatal("expected error from userinfo endpoint")
}
if !strings.Contains(err.Error(), "failed to get user info") {
t.Errorf("error = %v", err)
}
}
func TestGetUserInfo_HappyPath_ExtractsClaims(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
nil, http.StatusOK,
map[string]any{
"sub": "user-123",
"preferred_username": "alice",
"email": "alice@example.com",
"name": "Alice Example",
"resource_access": map[string]any{
"garage": map[string]any{
"roles": []any{"admin", "user"},
},
},
},
http.StatusOK,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
UsernameAttribute: "preferred_username",
EmailAttribute: "email",
NameAttribute: "name",
RoleAttributePath: "resource_access.garage.roles",
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
info, err := svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "good"})
if err != nil {
t.Fatalf("GetUserInfo: %v", err)
}
if info.Username != "alice" || info.Email != "alice@example.com" || info.Name != "Alice Example" {
t.Errorf("got %+v, want alice/alice@example.com/Alice Example", info)
}
if len(info.Roles) != 2 || info.Roles[0] != "admin" {
t.Errorf("Roles = %v, want [admin user]", info.Roles)
}
}
func TestExtractClaim(t *testing.T) {
cases := []struct {
name string
claims map[string]interface{}
key string
want string
}{
{"empty key returns empty", map[string]interface{}{"a": "b"}, "", ""},
{"missing key returns empty", map[string]interface{}{"a": "b"}, "missing", ""},
{"non-string value returns empty", map[string]interface{}{"n": 42}, "n", ""},
{"string value returned", map[string]interface{}{"email": "x@y"}, "email", "x@y"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractClaim(tc.claims, tc.key); got != tc.want {
t.Errorf("extractClaim(%v, %q) = %q, want %q", tc.claims, tc.key, got, tc.want)
}
})
}
}
func TestGenerateAndValidateStateToken_Roundtrip(t *testing.T) {
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
svc := &Service{
authConfig: &config.AuthConfig{},
serverConfig: &config.ServerConfig{},
jwtService: jwtSvc,
}
tok, err := svc.GenerateStateToken()
if err != nil {
t.Fatalf("GenerateStateToken: %v", err)
}
if tok == "" {
t.Fatal("state token is empty")
}
if !svc.ValidateAndConsumeState(tok) {
t.Fatal("ValidateAndConsumeState rejected a freshly-issued token")
}
// Double-consume must fail (CSRF single-use).
if svc.ValidateAndConsumeState(tok) {
t.Fatal("ValidateAndConsumeState accepted a re-used token")
}
}
func TestValidateAndConsumeState_RejectsGarbage(t *testing.T) {
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
svc := &Service{jwtService: jwtSvc}
if svc.ValidateAndConsumeState("definitely-not-a-token") {
t.Fatal("accepted an invalid token")
}
}