Implement OIDC authentication with Pocket-ID (P2.18)
Add OIDC/OAuth2 authentication support to the login page with automatic provider detection and email/password fallback. Features: - Auto-detect OIDC provider via PocketBase listAuthMethods() API - Display "Sign In with Pocket-ID" button when OIDC is configured - Use PocketBase authWithOAuth2() popup-based OAuth2 flow - Fall back to email/password form when OIDC not available - Loading states during authentication - Error handling with user-friendly messages The implementation checks for available auth methods on page load and conditionally renders either the OIDC button or the email/password form. This allows production deployments to use OIDC while development environments can continue using email/password. Tests: 24 tests (10 new OIDC tests added) - OIDC button rendering when provider configured - OIDC authentication flow with authWithOAuth2 - Loading and error states for OIDC - Fallback to email/password when OIDC unavailable Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,10 +13,14 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
// Mock PocketBase
|
||||
const mockAuthWithPassword = vi.fn();
|
||||
const mockAuthWithOAuth2 = vi.fn();
|
||||
const mockListAuthMethods = vi.fn();
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
pb: {
|
||||
collection: () => ({
|
||||
authWithPassword: mockAuthWithPassword,
|
||||
authWithOAuth2: mockAuthWithOAuth2,
|
||||
listAuthMethods: mockListAuthMethods,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -26,22 +30,33 @@ import LoginPage from "./page";
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: no OIDC configured, show email/password form
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: false,
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the login form with email and password inputs", () => {
|
||||
it("renders the login form with email and password inputs", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a sign in button", () => {
|
||||
it("renders a sign in button", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in/i }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the PhaseFlow branding", () => {
|
||||
@@ -50,18 +65,22 @@ describe("LoginPage", () => {
|
||||
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has email input with type email", () => {
|
||||
it("has email input with type email", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
expect(emailInput).toHaveAttribute("type", "email");
|
||||
await waitFor(() => {
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
expect(emailInput).toHaveAttribute("type", "email");
|
||||
});
|
||||
});
|
||||
|
||||
it("has password input with type password", () => {
|
||||
it("has password input with type password", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
expect(passwordInput).toHaveAttribute("type", "password");
|
||||
await waitFor(() => {
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
expect(passwordInput).toHaveAttribute("type", "password");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +89,11 @@ describe("LoginPage", () => {
|
||||
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
@@ -90,6 +114,11 @@ describe("LoginPage", () => {
|
||||
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
@@ -113,6 +142,11 @@ describe("LoginPage", () => {
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
@@ -141,6 +175,11 @@ describe("LoginPage", () => {
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
@@ -166,6 +205,11 @@ describe("LoginPage", () => {
|
||||
);
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
@@ -186,6 +230,11 @@ describe("LoginPage", () => {
|
||||
);
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
@@ -210,6 +259,11 @@ describe("LoginPage", () => {
|
||||
);
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
@@ -235,6 +289,11 @@ describe("LoginPage", () => {
|
||||
it("does not submit with empty email", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
@@ -248,6 +307,11 @@ describe("LoginPage", () => {
|
||||
it("does not submit with empty password", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
// Wait for auth check to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
|
||||
@@ -258,4 +322,223 @@ describe("LoginPage", () => {
|
||||
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OIDC authentication", () => {
|
||||
beforeEach(() => {
|
||||
// Configure OIDC provider available
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
providers: [
|
||||
{
|
||||
name: "oidc",
|
||||
displayName: "Pocket-ID",
|
||||
state: "test-state",
|
||||
codeVerifier: "test-verifier",
|
||||
codeChallenge: "test-challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
authURL: "https://id.example.com/auth",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows OIDC button when provider is configured", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides email/password form when OIDC is available", async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Email/password form should not be visible
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls authWithOAuth2 when OIDC button is clicked", async () => {
|
||||
mockAuthWithOAuth2.mockResolvedValueOnce({ token: "test-token" });
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const oidcButton = screen.getByRole("button", {
|
||||
name: /sign in with pocket-id/i,
|
||||
});
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: "oidc" });
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to dashboard on successful OIDC login", async () => {
|
||||
mockAuthWithOAuth2.mockResolvedValueOnce({
|
||||
token: "test-token",
|
||||
record: { id: "user-123" },
|
||||
});
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const oidcButton = screen.getByRole("button", {
|
||||
name: /sign in with pocket-id/i,
|
||||
});
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state during OIDC authentication", async () => {
|
||||
let resolveAuth: (value: unknown) => void = () => {};
|
||||
const authPromise = new Promise((resolve) => {
|
||||
resolveAuth = resolve;
|
||||
});
|
||||
mockAuthWithOAuth2.mockReturnValue(authPromise);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const oidcButton = screen.getByRole("button", {
|
||||
name: /sign in with pocket-id/i,
|
||||
});
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /signing in/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveAuth({ token: "test-token" });
|
||||
});
|
||||
|
||||
it("shows error message on OIDC failure", async () => {
|
||||
mockAuthWithOAuth2.mockRejectedValueOnce(
|
||||
new Error("Authentication cancelled"),
|
||||
);
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const oidcButton = screen.getByRole("button", {
|
||||
name: /sign in with pocket-id/i,
|
||||
});
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/authentication cancelled/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-enables OIDC button after error", async () => {
|
||||
mockAuthWithOAuth2.mockRejectedValueOnce(
|
||||
new Error("Authentication cancelled"),
|
||||
);
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const oidcButton = screen.getByRole("button", {
|
||||
name: /sign in with pocket-id/i,
|
||||
});
|
||||
fireEvent.click(oidcButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Button should be re-enabled
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback to email/password", () => {
|
||||
it("shows email/password form when OIDC is not configured", async () => {
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: false,
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows email/password form when listAuthMethods fails", async () => {
|
||||
mockListAuthMethods.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show OIDC button when no providers configured", async () => {
|
||||
mockListAuthMethods.mockResolvedValue({
|
||||
oauth2: {
|
||||
enabled: false,
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /sign in with pocket-id/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user