From bc921cac0e14ca33e76494ff82aa02a740bc40a5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 31 May 2026 17:34:48 -0400 Subject: [PATCH] [codex] show plugin OAuth providers on login (#32) * fix(auth): show oauth login providers * fix(auth): hide oauth providers when routes are unavailable --- internal/api/handlers/auth.go | 17 +++- internal/api/handlers/auth_providers_test.go | 97 ++++++++++++++++++++ internal/api/router.go | 1 + web/src/hooks/AuthProvider.test.tsx | 91 ++++++++++++++++++ web/src/hooks/useAuth.tsx | 2 +- 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 internal/api/handlers/auth_providers_test.go create mode 100644 web/src/hooks/AuthProvider.test.tsx diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 78b985ad..9b3ce6cd 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -17,9 +17,10 @@ import ( // AuthHandler handles authentication-related HTTP endpoints. type AuthHandler struct { - service *auth.Service - jwt *auth.JWTService - device *auth.DeviceLoginService + service *auth.Service + jwt *auth.JWTService + device *auth.DeviceLoginService + oauthRoutesAvailable bool } // NewAuthHandler creates a new AuthHandler backed by the given auth, JWT, @@ -32,6 +33,13 @@ func NewAuthHandler(service *auth.Service, jwt *auth.JWTService, device *auth.De } } +// SetOAuthRoutesAvailable controls whether OAuth login providers are +// advertised by /auth/providers. The router only mounts OAuth routes when the +// server has enough configuration to complete the flow. +func (h *AuthHandler) SetOAuthRoutesAvailable(available bool) { + h.oauthRoutesAvailable = available +} + // --- Request/Response types --- // loginRequest represents the JSON body of a POST /auth/login request. @@ -181,6 +189,9 @@ func (h *AuthHandler) HandleProviders(w http.ResponseWriter, r *http.Request) { providers := h.service.ListProviders() response := make([]authProviderResponse, 0, len(providers)) for _, provider := range providers { + if provider.Mode == "oauth" && !h.oauthRoutesAvailable { + continue + } response = append(response, authProviderResponse{ ID: provider.ID, DisplayName: provider.DisplayName, diff --git a/internal/api/handlers/auth_providers_test.go b/internal/api/handlers/auth_providers_test.go new file mode 100644 index 00000000..24c1c802 --- /dev/null +++ b/internal/api/handlers/auth_providers_test.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +) + +type stubLoginProvider struct{} + +func (stubLoginProvider) Authenticate(context.Context, auth.Credentials) (*models.User, error) { + return nil, auth.ErrInvalidCredentials +} + +func (stubLoginProvider) ValidateSession(context.Context, string) (bool, error) { + return false, nil +} + +func newAuthProviderHandlerForTest(oauthRoutesAvailable bool) *AuthHandler { + service := auth.NewService(nil, nil, nil, nil, nil, nil, nil) + provider := stubLoginProvider{} + + service.RegisterProvider(auth.LoginProviderInfo{ + ID: "local", + DisplayName: "Local", + Mode: "credentials", + Default: true, + }, provider) + service.RegisterProvider(auth.LoginProviderInfo{ + ID: "plugin:41:oidc", + DisplayName: "OIDC", + Mode: "oauth", + InstallationID: 41, + }, provider) + + handler := NewAuthHandler(service, nil, nil) + handler.SetOAuthRoutesAvailable(oauthRoutesAvailable) + return handler +} + +func readProviderResponse(t *testing.T, handler *AuthHandler) []authProviderResponse { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/auth/providers", nil) + handler.HandleProviders(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var providers []authProviderResponse + if err := json.NewDecoder(rec.Body).Decode(&providers); err != nil { + t.Fatalf("decode response: %v", err) + } + return providers +} + +func TestAuthProvidersHideOAuthWhenOAuthRoutesUnavailable(t *testing.T) { + providers := readProviderResponse(t, newAuthProviderHandlerForTest(false)) + + if len(providers) != 1 { + t.Fatalf("provider count = %d, want 1: %#v", len(providers), providers) + } + if providers[0].ID != "local" { + t.Fatalf("provider ID = %q, want local", providers[0].ID) + } +} + +func TestAuthProvidersIncludeOAuthWhenOAuthRoutesAvailable(t *testing.T) { + providers := readProviderResponse(t, newAuthProviderHandlerForTest(true)) + + if len(providers) != 2 { + t.Fatalf("provider count = %d, want 2: %#v", len(providers), providers) + } + + var foundOAuth bool + for _, provider := range providers { + if provider.ID == "plugin:41:oidc" { + foundOAuth = true + if provider.Mode != "oauth" { + t.Fatalf("OAuth provider mode = %q, want oauth", provider.Mode) + } + if provider.InstallationID != 41 { + t.Fatalf("OAuth provider installation ID = %d, want 41", provider.InstallationID) + } + } + } + if !foundOAuth { + t.Fatalf("OAuth provider missing from response: %#v", providers) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index aac5be6a..b5927215 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1161,6 +1161,7 @@ func NewRouter(deps Dependencies) chi.Router { StateTTL: 10 * time.Minute, }) } + authHandler.SetOAuthRoutesAvailable(oauthHandler != nil) r.Route("/auth", func(r chi.Router) { if deps.RateLimitMW != nil { diff --git a/web/src/hooks/AuthProvider.test.tsx b/web/src/hooks/AuthProvider.test.tsx new file mode 100644 index 00000000..9a2fcc6b --- /dev/null +++ b/web/src/hooks/AuthProvider.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AuthProviderOption, SetupStatusResponse } from "@/api/types"; +import { storage } from "@/utils/storage"; +import { AuthProvider, useAuth } from "./useAuth"; + +const apiMock = vi.hoisted(() => vi.fn()); +const bootstrapAccessTokenMock = vi.hoisted(() => vi.fn()); +const getAccessTokenMock = vi.hoisted(() => vi.fn()); +const onProfileUnverifiedMock = vi.hoisted(() => vi.fn()); +const restoreUserSessionMock = vi.hoisted(() => vi.fn()); +const setAccessTokenMock = vi.hoisted(() => vi.fn()); +const setProfileIdMock = vi.hoisted(() => vi.fn()); +const setProfileTokenMock = vi.hoisted(() => vi.fn()); +const setRefreshTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/client", async () => { + const actual = await vi.importActual("@/api/client"); + + return { + ...actual, + api: apiMock, + bootstrapAccessToken: bootstrapAccessTokenMock, + getAccessToken: getAccessTokenMock, + onProfileUnverified: onProfileUnverifiedMock, + restoreUserSession: restoreUserSessionMock, + setAccessToken: setAccessTokenMock, + setProfileId: setProfileIdMock, + setProfileToken: setProfileTokenMock, + setRefreshToken: setRefreshTokenMock, + }; +}); + +function renderWithAuthProvider(children: ReactNode) { + return render({children}); +} + +function ProviderProbe() { + const { providers, setupLoading } = useAuth(); + + return ( +
+ {setupLoading ? "loading" : providers.map((entry) => `${entry.id}:${entry.mode}`).join(",")} +
+ ); +} + +describe("AuthProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.values(storage.KEYS).forEach((key) => storage.remove(key)); + + bootstrapAccessTokenMock.mockResolvedValue(false); + getAccessTokenMock.mockReturnValue(null); + restoreUserSessionMock.mockResolvedValue(null); + }); + + it("preserves OAuth login providers returned by the auth providers endpoint", async () => { + const providers: AuthProviderOption[] = [ + { id: "local", display_name: "Local", mode: "credentials", default: true }, + { + id: "plugin:41:oidc", + display_name: "OIDC", + mode: "oauth", + default: false, + installation_id: 41, + }, + ]; + + apiMock.mockImplementation( + (path: string): Promise => { + if (path === "/auth/setup") { + return Promise.resolve({ needs_setup: false }); + } + if (path === "/auth/providers") { + return Promise.resolve(providers); + } + return Promise.reject(new Error(`unexpected API call: ${path}`)); + }, + ); + + renderWithAuthProvider(); + + await waitFor(() => { + expect(screen.getByTestId("providers")).toHaveTextContent("local:credentials"); + expect(screen.getByTestId("providers")).toHaveTextContent("plugin:41:oidc:oauth"); + }); + }); +}); diff --git a/web/src/hooks/useAuth.tsx b/web/src/hooks/useAuth.tsx index af1c16c7..206e684d 100644 --- a/web/src/hooks/useAuth.tsx +++ b/web/src/hooks/useAuth.tsx @@ -358,7 +358,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return; } setSetupRequired(status.needs_setup); - setProviders((availableProviders ?? []).filter((entry) => entry.mode === "credentials")); + setProviders(availableProviders ?? []); } catch { if (!cancelled) { setSetupRequired(false);