Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
650 changes: 650 additions & 0 deletions claude-notes/plans/2026-05-20-auth-provider-interface.md

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions hub-client/src/auth/AuthProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Tests for the AuthProvider React context plumbing.
*
* @vitest-environment jsdom
*/

import { describe, it, expect, afterEach } from 'vitest';
import { renderHook, cleanup } from '@testing-library/react';
import type { ReactNode } from 'react';

import {
AuthProviderRoot,
noopAuthProvider,
useAuthProvider,
type AuthProvider,
} from './AuthProvider';

afterEach(cleanup);

function makeStubProvider(): AuthProvider {
return {
SignInButton: () => null,
useSilentRenewal: () => {},
signOut: () => {},
};
}

describe('useAuthProvider', () => {
it('returns noopAuthProvider when no AuthProviderRoot is mounted', () => {
const { result } = renderHook(() => useAuthProvider());
expect(result.current).toBe(noopAuthProvider);
});

it('returns the provided value inside AuthProviderRoot', () => {
const provider = makeStubProvider();
const wrapper = ({ children }: { children: ReactNode }) => (
<AuthProviderRoot provider={provider}>{children}</AuthProviderRoot>
);
const { result } = renderHook(() => useAuthProvider(), { wrapper });
expect(result.current).toBe(provider);
});
});
103 changes: 103 additions & 0 deletions hub-client/src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* AuthProvider interface — mediates all IdP-specific calls.
*
* Implementations wrap a specific IdP integration (today: Google
* Identity Services via `GoogleAuthProvider`). Consumers depend on
* this interface via React context (`useAuthProvider`), never on a
* concrete IdP SDK directly.
*
* `noopAuthProvider` is the no-op implementation used when auth is
* disabled (no IdP configured). Consumers never need to branch — they
* always receive a valid `AuthProvider`.
*
* See `claude-notes/plans/2026-05-20-auth-provider-interface.md`
* for the design rationale.
*/

import {
createContext,
useContext,
type ComponentType,
type ReactNode,
} from 'react';

export interface AuthProvider {
/**
* React component that renders the interactive sign-in affordance
* (today: the GIS "Sign in with Google" button in redirect mode).
*
* The component owns the entire sign-in initiation — including the
* redirect to the IdP. Success is observed by the SPA via the
* existing `GET /auth/me` polling on the next page load.
*/
readonly SignInButton: ComponentType<SignInButtonProps>;

/**
* Hook that enables/disables silent credential renewal.
*
* When `enabled` becomes true, the provider attempts to obtain a
* fresh JWT without user interaction and invokes `onCredential`
* with the JWT. If renewal fails or no IdP session exists,
* `onError` is invoked.
*
* Providers without a silent-renewal capability MUST still
* implement the hook as a no-op: enabling it never invokes either
* callback. Consumers detect that scenario via timeout, not via a
* return value — keeps the interface symmetric across capabilities.
*/
useSilentRenewal(opts: SilentRenewalOpts): void;

/**
* Best-effort IdP-side sign-out. For Google this calls
* `googleLogout()` which revokes the GIS session (no network round
* trip). Synchronous on purpose — callers should not block UI on it.
*/
signOut(): void;
}

export interface SignInButtonProps {
/**
* Server endpoint the IdP credential should land at. For GIS-redirect
* mode this is wired into `<GoogleLogin login_uri={...} />`; for a
* future Code+PKCE provider it would be the redirect URI the SPA
* registers with the IdP.
*/
loginUri: string;
}

export interface SilentRenewalOpts {
enabled: boolean;
onCredential: (jwt: string) => void;
onError: () => void;
}

/**
* No-op provider used when auth is disabled (no `VITE_GOOGLE_CLIENT_ID`).
* `SignInButton` renders nothing; the hook and `signOut` do nothing.
*/
export const noopAuthProvider: AuthProvider = {
SignInButton: () => null,
useSilentRenewal: () => {},
signOut: () => {},
};

const AuthProviderContext = createContext<AuthProvider>(noopAuthProvider);

export function AuthProviderRoot({
provider,
children,
}: {
provider: AuthProvider;
children: ReactNode;
}) {
return (
<AuthProviderContext.Provider value={provider}>
{children}
</AuthProviderContext.Provider>
);
}

/** Returns the active provider, or `noopAuthProvider` when none is mounted. */
export function useAuthProvider(): AuthProvider {
return useContext(AuthProviderContext);
}
122 changes: 122 additions & 0 deletions hub-client/src/auth/GoogleAuthProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Tests for GoogleAuthProvider — the AuthProvider implementation that
* wraps Google Identity Services.
*
* @vitest-environment jsdom
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, render, cleanup } from '@testing-library/react';

import type { SilentRenewalOpts } from './AuthProvider';

// Capture mock state at module scope so each test can inspect / drive it.
let lastGoogleLoginProps: {
ux_mode?: string;
login_uri?: string;
} | null = null;

let lastOneTapOpts: {
onSuccess?: (response: { credential?: string }) => void;
onError?: () => void;
disabled?: boolean;
auto_select?: boolean;
} | null = null;

const mockGoogleLogout = vi.fn();

vi.mock('@react-oauth/google', () => ({
GoogleLogin: (props: typeof lastGoogleLoginProps) => {
lastGoogleLoginProps = props;
return null;
},
useGoogleOneTapLogin: (opts: typeof lastOneTapOpts) => {
lastOneTapOpts = opts;
},
googleLogout: () => mockGoogleLogout(),
}));

import { googleAuthProvider } from './GoogleAuthProvider';

beforeEach(() => {
lastGoogleLoginProps = null;
lastOneTapOpts = null;
mockGoogleLogout.mockClear();
});

afterEach(cleanup);

describe('GoogleAuthProvider.SignInButton', () => {
it('renders GoogleLogin in redirect mode with the given loginUri', () => {
render(<googleAuthProvider.SignInButton loginUri="/auth/callback" />);

expect(lastGoogleLoginProps).not.toBeNull();
expect(lastGoogleLoginProps?.ux_mode).toBe('redirect');
expect(lastGoogleLoginProps?.login_uri).toBe('/auth/callback');
});
});

describe('GoogleAuthProvider.useSilentRenewal', () => {
function renderProviderHook(opts: SilentRenewalOpts) {
return renderHook(() => googleAuthProvider.useSilentRenewal(opts));
}

it('calls useGoogleOneTapLogin with auto_select:true and disabled:false when enabled', () => {
renderProviderHook({
enabled: true,
onCredential: vi.fn(),
onError: vi.fn(),
});

expect(lastOneTapOpts).not.toBeNull();
expect(lastOneTapOpts?.auto_select).toBe(true);
expect(lastOneTapOpts?.disabled).toBe(false);
});

it('calls useGoogleOneTapLogin with disabled:true when not enabled', () => {
renderProviderHook({
enabled: false,
onCredential: vi.fn(),
onError: vi.fn(),
});

expect(lastOneTapOpts?.disabled).toBe(true);
});

it('forwards onCredential when one-tap success carries a credential', () => {
const onCredential = vi.fn();
const onError = vi.fn();
renderProviderHook({ enabled: true, onCredential, onError });

lastOneTapOpts?.onSuccess?.({ credential: 'jwt-token' });
expect(onCredential).toHaveBeenCalledExactlyOnceWith('jwt-token');
expect(onError).not.toHaveBeenCalled();
});

it('forwards onError when one-tap success carries no credential', () => {
const onCredential = vi.fn();
const onError = vi.fn();
renderProviderHook({ enabled: true, onCredential, onError });

lastOneTapOpts?.onSuccess?.({});
expect(onError).toHaveBeenCalledTimes(1);
expect(onCredential).not.toHaveBeenCalled();
});

it('forwards onError on one-tap error', () => {
const onCredential = vi.fn();
const onError = vi.fn();
renderProviderHook({ enabled: true, onCredential, onError });

lastOneTapOpts?.onError?.();
expect(onError).toHaveBeenCalledTimes(1);
expect(onCredential).not.toHaveBeenCalled();
});
});

describe('GoogleAuthProvider.signOut', () => {
it('calls googleLogout()', () => {
googleAuthProvider.signOut();
expect(mockGoogleLogout).toHaveBeenCalledTimes(1);
});
});
60 changes: 60 additions & 0 deletions hub-client/src/auth/GoogleAuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* GoogleAuthProvider — AuthProvider implementation wrapping Google
* Identity Services via `@react-oauth/google`.
*
* Requires `<GoogleOAuthProvider clientId={...}>` to be mounted above
* any tree that uses this provider's `SignInButton` or
* `useSilentRenewal`. The GIS provider wrap stays in `main.tsx` (see
* Phase 3 of `claude-notes/plans/2026-05-20-auth-provider-interface.md`);
* this module does not produce its own provider scope.
*/

import {
GoogleLogin,
googleLogout,
useGoogleOneTapLogin,
} from '@react-oauth/google';

import type {
AuthProvider,
SignInButtonProps,
SilentRenewalOpts,
} from './AuthProvider';

function SignInButton({ loginUri }: SignInButtonProps) {
return (
<GoogleLogin
ux_mode="redirect"
login_uri={loginUri}
onSuccess={() => {
// Not called in redirect mode — credential arrives via HttpOnly
// cookie set by the server-side redirect callback.
}}
/>
);
}

function useSilentRenewal(opts: SilentRenewalOpts) {
useGoogleOneTapLogin({
onSuccess: (response) => {
if (response.credential) {
opts.onCredential(response.credential);
} else {
// Success without a credential — semantically equivalent to a
// failed renewal from the consumer's perspective.
opts.onError();
}
},
onError: () => {
opts.onError();
},
auto_select: true,
disabled: !opts.enabled,
});
}

export const googleAuthProvider: AuthProvider = {
SignInButton,
useSilentRenewal,
signOut: () => googleLogout(),
};
Loading
Loading