[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:
Quick
2026-05-31 17:34:48 -04:00
committed by GitHub
parent 9b82535190
commit bc921cac0e
5 changed files with 204 additions and 4 deletions
+14 -3
View File
@@ -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)
}
}
+1
View File
@@ -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 {
+91
View File
@@ -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");
});
});
});
+1 -1
View File
@@ -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);