mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
09289371a2
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
268 lines
8.0 KiB
Go
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")
|
|
}
|
|
}
|