Skip to content
Open
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
20 changes: 18 additions & 2 deletions packages/zudoku/src/lib/authentication/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,31 @@ export type UseAuthReturn = ReturnType<typeof useAuth>;
*/
export const useRefreshUserProfile = ({
refetchOnWindowFocus,
refetchOnMount,
}: {
refetchOnWindowFocus?: boolean | "always";
refetchOnMount?: boolean | "always";
} = {}) => {
const { authentication } = useZudoku();
const profile = useAuthState((s) => s.profile);
const profileFetchedAt = useAuthState((s) => s.profileFetchedAt);
const isAuthEnabled = typeof authentication !== "undefined";

return useQuery({
refetchOnWindowFocus,
refetchOnMount,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
queryKey: ["refresh-user-profile"],
enabled:
isAuthEnabled && typeof authentication?.refreshUserProfile === "function",
queryFn: () => authentication?.refreshUserProfile?.(),
queryFn: async () => {
const result = await authentication?.refreshUserProfile?.();
useAuthState.setState({ profileFetchedAt: Date.now() });
return result;
},
initialData: profile ? true : undefined,
initialDataUpdatedAt: profileFetchedAt ?? undefined,
});
};

Expand All @@ -34,8 +47,11 @@ export const useVerifiedEmail = () => {
const navigate = useNavigate();
const isAuthEnabled = typeof authentication !== "undefined";

const isUnverified = authState.profile?.emailVerified === false;

const { refetch: refreshUserProfile } = useRefreshUserProfile({
refetchOnWindowFocus: "always",
refetchOnWindowFocus: isUnverified ? "always" : true,
refetchOnMount: isUnverified ? "always" : undefined,
});

return {
Expand Down
1 change: 1 addition & 0 deletions packages/zudoku/src/lib/authentication/providers/clerk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const clerkAuth: AuthenticationProviderInitializer<
isAuthenticated: true,
isPending: false,
profile,
profileFetchedAt: Date.now(),
providerData: {
type: "clerk",
user: clerk.session?.user,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ class FirebaseAuthenticationProvider
isAuthenticated: false,
isPending: false,
profile: undefined,
profileFetchedAt: null,
providerData: undefined,
});

Expand Down
61 changes: 61 additions & 0 deletions packages/zudoku/src/lib/authentication/providers/openid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,67 @@ describe("OpenIDAuthenticationProvider emailVerified", () => {
});
});

describe("discovery caching", () => {
const setupAuthenticated = () => {
useAuthState.setState({
isAuthenticated: true,
isPending: false,
profile: {
sub: "user-1",
email: "user@example.com",
emailVerified: false,
name: "Test",
pictureUrl: undefined,
},
providerData: {
type: "openid",
accessToken: FAKE_ACCESS_TOKEN,
expiresOn: new Date(Date.now() + 3600_000),
tokenType: "bearer",
claims: undefined,
} satisfies OpenIdProviderData,
});

vi.mocked(oauth.userInfoRequest).mockImplementation(() =>
Promise.resolve(
Response.json({ sub: "user-1", email: "user@example.com" }),
),
);
};

test("retries discovery after a failed request", async () => {
vi.mocked(oauth.discoveryRequest)
.mockReset()
.mockRejectedValueOnce(new Error("network down"))
.mockImplementation(() => Promise.resolve(new Response()));

const provider = createProvider();
setupAuthenticated();

await expect(provider.refreshUserProfile()).rejects.toThrow(
"network down",
);
await expect(provider.refreshUserProfile()).resolves.toBe(true);

expect(oauth.discoveryRequest).toHaveBeenCalledTimes(2);
});

test("deduplicates concurrent discovery requests", async () => {
vi.mocked(oauth.discoveryRequest).mockClear();

const provider = createProvider();
setupAuthenticated();

await Promise.all([
provider.refreshUserProfile(),
provider.refreshUserProfile(),
provider.refreshUserProfile(),
]);

expect(oauth.discoveryRequest).toHaveBeenCalledTimes(1);
});
});

test("self heals providerData when providerData.type is undefined", async () => {
const provider = createProvider();

Expand Down
23 changes: 14 additions & 9 deletions packages/zudoku/src/lib/authentication/providers/openid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class OpenIDAuthenticationProvider
{
protected client: oauth.Client;
protected issuer: string;
protected authorizationServer: oauth.AuthorizationServer | undefined;
protected authorizationServer: Promise<oauth.AuthorizationServer> | undefined;

Comment thread
mosch marked this conversation as resolved.
protected callbackUrlPath: string;

Expand Down Expand Up @@ -105,14 +105,16 @@ export class OpenIDAuthenticationProvider
}

protected async getAuthServer() {
if (!this.authorizationServer) {
const issuerUrl = new URL(this.issuer);
const response = await oauth.discoveryRequest(issuerUrl);
this.authorizationServer = await oauth.processDiscoveryResponse(
issuerUrl,
response,
);
}
this.authorizationServer ??= (async () => {
try {
const issuerUrl = new URL(this.issuer);
const response = await oauth.discoveryRequest(issuerUrl);
return await oauth.processDiscoveryResponse(issuerUrl, response);
} catch (err) {
this.authorizationServer = undefined;
throw err;
}
})();
Comment thread
mosch marked this conversation as resolved.
return this.authorizationServer;
}

Expand Down Expand Up @@ -239,6 +241,7 @@ export class OpenIDAuthenticationProvider
isAuthenticated: true,
isPending: false,
profile,
profileFetchedAt: Date.now(),
});

return true;
Expand Down Expand Up @@ -438,6 +441,7 @@ export class OpenIDAuthenticationProvider
isAuthenticated: false,
isPending: false,
profile: null,
profileFetchedAt: null,
providerData: null,
});
return;
Expand Down Expand Up @@ -544,6 +548,7 @@ export class OpenIDAuthenticationProvider
isAuthenticated: true,
isPending: false,
profile,
profileFetchedAt: Date.now(),
});
await this.refreshUserProfile();

Expand Down
5 changes: 5 additions & 0 deletions packages/zudoku/src/lib/authentication/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface AuthState {
isAuthenticated: boolean;
isPending: boolean;
profile: UserProfile | null;
profileFetchedAt: number | null;
providerData: ProviderData | null;
setAuthenticationPending: () => void;
setLoggedOut: () => void;
Expand All @@ -40,26 +41,30 @@ export const authState = create<AuthState>()(
isAuthenticated: false,
isPending: true,
profile: null,
profileFetchedAt: null,
providerData: null,
setAuthenticationPending: () =>
set(() => ({
isAuthenticated: false,
isPending: false,
profile: null,
profileFetchedAt: null,
providerData: null,
})),
setLoggedOut: () =>
set(() => ({
isAuthenticated: false,
isPending: false,
profile: null,
profileFetchedAt: null,
providerData: null,
})),
setLoggedIn: ({ profile, providerData }) =>
set(() => ({
isAuthenticated: true,
isPending: false,
profile,
profileFetchedAt: Date.now(),
providerData,
})),
}),
Expand Down
1 change: 1 addition & 0 deletions packages/zudoku/src/lib/core/RouteGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const createWrapper = ({
isPending: false,
isAuthEnabled: false,
profile: null,
profileFetchedAt: null,
providerData: null,
setAuthenticationPending: vi.fn(),
setLoggedOut: vi.fn(),
Expand Down
Loading