[codex] show plugin OAuth providers on login (#32)
* fix(auth): show oauth login providers * fix(auth): hide oauth providers when routes are unavailable
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof import("@/api/client")>("@/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(<AuthProvider>{children}</AuthProvider>);
|
||||
}
|
||||
|
||||
function ProviderProbe() {
|
||||
const { providers, setupLoading } = useAuth();
|
||||
|
||||
return (
|
||||
<div data-testid="providers">
|
||||
{setupLoading ? "loading" : providers.map((entry) => `${entry.id}:${entry.mode}`).join(",")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<SetupStatusResponse | AuthProviderOption[]> => {
|
||||
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(<ProviderProbe />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("providers")).toHaveTextContent("local:credentials");
|
||||
expect(screen.getByTestId("providers")).toHaveTextContent("plugin:41:oidc:oauth");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user