Skip to content

Commit ace38a0

Browse files
authored
refactor(hub-client): isolate GIS coupling behind AuthProvider interface (#219)
Introduce an `AuthProvider` interface (sign-in button, silent renewal, signout). `LoginScreen`, `useAuth`, `authService`, and `main.tsx` depend on it via React context; only `auth/GoogleAuthProvider.tsx` and `main.tsx` import `@react-oauth/google` directly. Auth-disabled mode uses a `noopAuthProvider` so consumers never branch on null. No behaviour change. Plan: `claude-notes/plans/2026-05-20-auth-provider-interface.md`.
1 parent b0253b7 commit ace38a0

13 files changed

Lines changed: 1247 additions & 136 deletions

claude-notes/plans/2026-05-20-auth-provider-interface.md

Lines changed: 650 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Tests for the AuthProvider React context plumbing.
3+
*
4+
* @vitest-environment jsdom
5+
*/
6+
7+
import { describe, it, expect, afterEach } from 'vitest';
8+
import { renderHook, cleanup } from '@testing-library/react';
9+
import type { ReactNode } from 'react';
10+
11+
import {
12+
AuthProviderRoot,
13+
noopAuthProvider,
14+
useAuthProvider,
15+
type AuthProvider,
16+
} from './AuthProvider';
17+
18+
afterEach(cleanup);
19+
20+
function makeStubProvider(): AuthProvider {
21+
return {
22+
SignInButton: () => null,
23+
useSilentRenewal: () => {},
24+
signOut: () => {},
25+
};
26+
}
27+
28+
describe('useAuthProvider', () => {
29+
it('returns noopAuthProvider when no AuthProviderRoot is mounted', () => {
30+
const { result } = renderHook(() => useAuthProvider());
31+
expect(result.current).toBe(noopAuthProvider);
32+
});
33+
34+
it('returns the provided value inside AuthProviderRoot', () => {
35+
const provider = makeStubProvider();
36+
const wrapper = ({ children }: { children: ReactNode }) => (
37+
<AuthProviderRoot provider={provider}>{children}</AuthProviderRoot>
38+
);
39+
const { result } = renderHook(() => useAuthProvider(), { wrapper });
40+
expect(result.current).toBe(provider);
41+
});
42+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* AuthProvider interface — mediates all IdP-specific calls.
3+
*
4+
* Implementations wrap a specific IdP integration (today: Google
5+
* Identity Services via `GoogleAuthProvider`). Consumers depend on
6+
* this interface via React context (`useAuthProvider`), never on a
7+
* concrete IdP SDK directly.
8+
*
9+
* `noopAuthProvider` is the no-op implementation used when auth is
10+
* disabled (no IdP configured). Consumers never need to branch — they
11+
* always receive a valid `AuthProvider`.
12+
*
13+
* See `claude-notes/plans/2026-05-20-auth-provider-interface.md`
14+
* for the design rationale.
15+
*/
16+
17+
import {
18+
createContext,
19+
useContext,
20+
type ComponentType,
21+
type ReactNode,
22+
} from 'react';
23+
24+
export interface AuthProvider {
25+
/**
26+
* React component that renders the interactive sign-in affordance
27+
* (today: the GIS "Sign in with Google" button in redirect mode).
28+
*
29+
* The component owns the entire sign-in initiation — including the
30+
* redirect to the IdP. Success is observed by the SPA via the
31+
* existing `GET /auth/me` polling on the next page load.
32+
*/
33+
readonly SignInButton: ComponentType<SignInButtonProps>;
34+
35+
/**
36+
* Hook that enables/disables silent credential renewal.
37+
*
38+
* When `enabled` becomes true, the provider attempts to obtain a
39+
* fresh JWT without user interaction and invokes `onCredential`
40+
* with the JWT. If renewal fails or no IdP session exists,
41+
* `onError` is invoked.
42+
*
43+
* Providers without a silent-renewal capability MUST still
44+
* implement the hook as a no-op: enabling it never invokes either
45+
* callback. Consumers detect that scenario via timeout, not via a
46+
* return value — keeps the interface symmetric across capabilities.
47+
*/
48+
useSilentRenewal(opts: SilentRenewalOpts): void;
49+
50+
/**
51+
* Best-effort IdP-side sign-out. For Google this calls
52+
* `googleLogout()` which revokes the GIS session (no network round
53+
* trip). Synchronous on purpose — callers should not block UI on it.
54+
*/
55+
signOut(): void;
56+
}
57+
58+
export interface SignInButtonProps {
59+
/**
60+
* Server endpoint the IdP credential should land at. For GIS-redirect
61+
* mode this is wired into `<GoogleLogin login_uri={...} />`; for a
62+
* future Code+PKCE provider it would be the redirect URI the SPA
63+
* registers with the IdP.
64+
*/
65+
loginUri: string;
66+
}
67+
68+
export interface SilentRenewalOpts {
69+
enabled: boolean;
70+
onCredential: (jwt: string) => void;
71+
onError: () => void;
72+
}
73+
74+
/**
75+
* No-op provider used when auth is disabled (no `VITE_GOOGLE_CLIENT_ID`).
76+
* `SignInButton` renders nothing; the hook and `signOut` do nothing.
77+
*/
78+
export const noopAuthProvider: AuthProvider = {
79+
SignInButton: () => null,
80+
useSilentRenewal: () => {},
81+
signOut: () => {},
82+
};
83+
84+
const AuthProviderContext = createContext<AuthProvider>(noopAuthProvider);
85+
86+
export function AuthProviderRoot({
87+
provider,
88+
children,
89+
}: {
90+
provider: AuthProvider;
91+
children: ReactNode;
92+
}) {
93+
return (
94+
<AuthProviderContext.Provider value={provider}>
95+
{children}
96+
</AuthProviderContext.Provider>
97+
);
98+
}
99+
100+
/** Returns the active provider, or `noopAuthProvider` when none is mounted. */
101+
export function useAuthProvider(): AuthProvider {
102+
return useContext(AuthProviderContext);
103+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Tests for GoogleAuthProvider — the AuthProvider implementation that
3+
* wraps Google Identity Services.
4+
*
5+
* @vitest-environment jsdom
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9+
import { renderHook, render, cleanup } from '@testing-library/react';
10+
11+
import type { SilentRenewalOpts } from './AuthProvider';
12+
13+
// Capture mock state at module scope so each test can inspect / drive it.
14+
let lastGoogleLoginProps: {
15+
ux_mode?: string;
16+
login_uri?: string;
17+
} | null = null;
18+
19+
let lastOneTapOpts: {
20+
onSuccess?: (response: { credential?: string }) => void;
21+
onError?: () => void;
22+
disabled?: boolean;
23+
auto_select?: boolean;
24+
} | null = null;
25+
26+
const mockGoogleLogout = vi.fn();
27+
28+
vi.mock('@react-oauth/google', () => ({
29+
GoogleLogin: (props: typeof lastGoogleLoginProps) => {
30+
lastGoogleLoginProps = props;
31+
return null;
32+
},
33+
useGoogleOneTapLogin: (opts: typeof lastOneTapOpts) => {
34+
lastOneTapOpts = opts;
35+
},
36+
googleLogout: () => mockGoogleLogout(),
37+
}));
38+
39+
import { googleAuthProvider } from './GoogleAuthProvider';
40+
41+
beforeEach(() => {
42+
lastGoogleLoginProps = null;
43+
lastOneTapOpts = null;
44+
mockGoogleLogout.mockClear();
45+
});
46+
47+
afterEach(cleanup);
48+
49+
describe('GoogleAuthProvider.SignInButton', () => {
50+
it('renders GoogleLogin in redirect mode with the given loginUri', () => {
51+
render(<googleAuthProvider.SignInButton loginUri="/auth/callback" />);
52+
53+
expect(lastGoogleLoginProps).not.toBeNull();
54+
expect(lastGoogleLoginProps?.ux_mode).toBe('redirect');
55+
expect(lastGoogleLoginProps?.login_uri).toBe('/auth/callback');
56+
});
57+
});
58+
59+
describe('GoogleAuthProvider.useSilentRenewal', () => {
60+
function renderProviderHook(opts: SilentRenewalOpts) {
61+
return renderHook(() => googleAuthProvider.useSilentRenewal(opts));
62+
}
63+
64+
it('calls useGoogleOneTapLogin with auto_select:true and disabled:false when enabled', () => {
65+
renderProviderHook({
66+
enabled: true,
67+
onCredential: vi.fn(),
68+
onError: vi.fn(),
69+
});
70+
71+
expect(lastOneTapOpts).not.toBeNull();
72+
expect(lastOneTapOpts?.auto_select).toBe(true);
73+
expect(lastOneTapOpts?.disabled).toBe(false);
74+
});
75+
76+
it('calls useGoogleOneTapLogin with disabled:true when not enabled', () => {
77+
renderProviderHook({
78+
enabled: false,
79+
onCredential: vi.fn(),
80+
onError: vi.fn(),
81+
});
82+
83+
expect(lastOneTapOpts?.disabled).toBe(true);
84+
});
85+
86+
it('forwards onCredential when one-tap success carries a credential', () => {
87+
const onCredential = vi.fn();
88+
const onError = vi.fn();
89+
renderProviderHook({ enabled: true, onCredential, onError });
90+
91+
lastOneTapOpts?.onSuccess?.({ credential: 'jwt-token' });
92+
expect(onCredential).toHaveBeenCalledExactlyOnceWith('jwt-token');
93+
expect(onError).not.toHaveBeenCalled();
94+
});
95+
96+
it('forwards onError when one-tap success carries no credential', () => {
97+
const onCredential = vi.fn();
98+
const onError = vi.fn();
99+
renderProviderHook({ enabled: true, onCredential, onError });
100+
101+
lastOneTapOpts?.onSuccess?.({});
102+
expect(onError).toHaveBeenCalledTimes(1);
103+
expect(onCredential).not.toHaveBeenCalled();
104+
});
105+
106+
it('forwards onError on one-tap error', () => {
107+
const onCredential = vi.fn();
108+
const onError = vi.fn();
109+
renderProviderHook({ enabled: true, onCredential, onError });
110+
111+
lastOneTapOpts?.onError?.();
112+
expect(onError).toHaveBeenCalledTimes(1);
113+
expect(onCredential).not.toHaveBeenCalled();
114+
});
115+
});
116+
117+
describe('GoogleAuthProvider.signOut', () => {
118+
it('calls googleLogout()', () => {
119+
googleAuthProvider.signOut();
120+
expect(mockGoogleLogout).toHaveBeenCalledTimes(1);
121+
});
122+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* GoogleAuthProvider — AuthProvider implementation wrapping Google
3+
* Identity Services via `@react-oauth/google`.
4+
*
5+
* Requires `<GoogleOAuthProvider clientId={...}>` to be mounted above
6+
* any tree that uses this provider's `SignInButton` or
7+
* `useSilentRenewal`. The GIS provider wrap stays in `main.tsx` (see
8+
* Phase 3 of `claude-notes/plans/2026-05-20-auth-provider-interface.md`);
9+
* this module does not produce its own provider scope.
10+
*/
11+
12+
import {
13+
GoogleLogin,
14+
googleLogout,
15+
useGoogleOneTapLogin,
16+
} from '@react-oauth/google';
17+
18+
import type {
19+
AuthProvider,
20+
SignInButtonProps,
21+
SilentRenewalOpts,
22+
} from './AuthProvider';
23+
24+
function SignInButton({ loginUri }: SignInButtonProps) {
25+
return (
26+
<GoogleLogin
27+
ux_mode="redirect"
28+
login_uri={loginUri}
29+
onSuccess={() => {
30+
// Not called in redirect mode — credential arrives via HttpOnly
31+
// cookie set by the server-side redirect callback.
32+
}}
33+
/>
34+
);
35+
}
36+
37+
function useSilentRenewal(opts: SilentRenewalOpts) {
38+
useGoogleOneTapLogin({
39+
onSuccess: (response) => {
40+
if (response.credential) {
41+
opts.onCredential(response.credential);
42+
} else {
43+
// Success without a credential — semantically equivalent to a
44+
// failed renewal from the consumer's perspective.
45+
opts.onError();
46+
}
47+
},
48+
onError: () => {
49+
opts.onError();
50+
},
51+
auto_select: true,
52+
disabled: !opts.enabled,
53+
});
54+
}
55+
56+
export const googleAuthProvider: AuthProvider = {
57+
SignInButton,
58+
useSilentRenewal,
59+
signOut: () => googleLogout(),
60+
};

0 commit comments

Comments
 (0)