Skip to content

Commit

Permalink
feat: custom tRPC link to handle dexcom refresh tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
trevorpfiz committed Aug 30, 2024
1 parent f378976 commit 194962e
Show file tree
Hide file tree
Showing 12 changed files with 432 additions and 138 deletions.
2 changes: 2 additions & 0 deletions apps/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"lucide-react-native": "^0.436.0",
"nativewind": "4.0.36",
"openai": "^4.56.0",
"p-queue": "^8.0.1",
"react": "catalog:react18",
"react-dom": "catalog:react18",
"react-hook-form": "^7.52.2",
Expand Down Expand Up @@ -116,6 +117,7 @@
"tailwind-merge": "^2.5.2",
"tailwindcss": "catalog:tailwind",
"tailwindcss-animate": "catalog:tailwind",
"trpc-token-refresh-link": "^0.5.0",
"victory-native": "^41.1.0",
"zeego": "^1.10.0",
"zustand": "^4.5.5"
Expand Down
3 changes: 3 additions & 0 deletions apps/expo/src/app/(app)/(tabs)/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useUser,
} from "@supabase/auth-helpers-react";

import DexcomDevicesList from "~/components/dexcom/dexcom-devices";
import { DexcomLogin } from "~/components/dexcom/dexcom-login";
import { ThemeToggle } from "~/components/theme-toggle";
import { Button } from "~/components/ui/button";
Expand Down Expand Up @@ -40,6 +41,8 @@ export default function AccountScreen() {
{user?.id && <SignOut />}

<DexcomLogin />

<DexcomDevicesList />
</View>
);
}
27 changes: 1 addition & 26 deletions apps/expo/src/components/dexcom/dexcom-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { ActivityIndicator, Alert, ScrollView, View } from "react-native";
import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
import { api } from "~/utils/api";
import { getDexcomTokens, updateDexcomTokens } from "~/utils/dexcom-store";

const DexcomCGMData: React.FC = () => {
const [isFetching, setIsFetching] = useState(false);
Expand All @@ -26,31 +25,7 @@ const DexcomCGMData: React.FC = () => {
const handleFetchData = async () => {
setIsFetching(true);
try {
const tokens = getDexcomTokens();
if (!tokens) {
Alert.alert(
"Error",
"No Dexcom tokens found. Please connect to Dexcom first.",
);
return;
}

const result = await fetchAndStoreEGVsMutation.mutateAsync({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenExpiresAt: tokens.expiresAt,
startDate,
endDate,
});

if (result.newTokens) {
updateDexcomTokens(
result.newTokens.accessToken,
result.newTokens.refreshToken,
result.newTokens.expiresAt,
);
}

await fetchAndStoreEGVsMutation.mutateAsync(queryInput);
await refetchStoredData();
Alert.alert("Success", "CGM data fetched and stored successfully.");
} catch (error) {
Expand Down
60 changes: 60 additions & 0 deletions apps/expo/src/components/dexcom/dexcom-devices.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ActivityIndicator, ScrollView, View } from "react-native";

import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
import { api } from "~/utils/api";

const DexcomDevicesList = () => {
const {
data: devicesData,
isLoading,
isError,
error,
refetch,
} = api.dexcom.fetchDevices.useQuery();

if (isLoading) {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" />
<Text>Loading Dexcom devices...</Text>
</View>
);
}

if (isError) {
return (
<View className="flex-1 items-center justify-center">
<Text className="text-red-500">Error: {error.message}</Text>
<Button onPress={() => refetch()} className="mt-4">
<Text>Retry</Text>
</Button>
</View>
);
}

return (
<ScrollView className="p-4">
<Text className="mb-4 text-2xl font-bold">Dexcom Devices</Text>
<Button onPress={() => refetch()} className="mb-4">
<Text>Refresh Devices</Text>
</Button>
{devicesData?.devices.map((device, index) => (
<View key={index} className="mb-6 rounded-lg p-4">
<Text className="font-semibold">Device {index + 1}</Text>
<Text>Display Device: {device.displayDevice}</Text>
<Text>Transmitter Generation: {device.transmitterGeneration}</Text>
<Text>
Last Upload: {new Date(device.lastUploadDate).toLocaleString()}
</Text>
{device.transmitterId && (
<Text>Transmitter ID: {device.transmitterId}</Text>
)}
{device.displayApp && <Text>Display App: {device.displayApp}</Text>}
</View>
))}
</ScrollView>
);
};

export default DexcomDevicesList;
14 changes: 14 additions & 0 deletions apps/expo/src/utils/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import superjson from "superjson";
import type { AppRouter } from "@hyper/api";

import { getBaseUrl } from "~/utils/base-url";
import { getDexcomTokens } from "~/utils/dexcom-store";
import { tokenRefreshLink } from "~/utils/token-refresh";

/**
* A set of typesafe hooks for consuming your API.
Expand All @@ -32,6 +34,7 @@ export const TRPCProvider = (props: { children: React.ReactNode }) => {
(opts.direction === "down" && opts.result instanceof Error),
colorMode: "ansi",
}),
tokenRefreshLink,
httpBatchLink({
transformer: superjson,
url: `${getBaseUrl()}/api/trpc`,
Expand All @@ -43,6 +46,17 @@ export const TRPCProvider = (props: { children: React.ReactNode }) => {
const token = data.session?.access_token;
if (token) headers.set("Authorization", token);

// Add Dexcom tokens to headers
const dexcomTokens = getDexcomTokens();
if (dexcomTokens) {
headers.set("X-Dexcom-Access-Token", dexcomTokens.accessToken);
headers.set("X-Dexcom-Refresh-Token", dexcomTokens.refreshToken);
headers.set(
"X-Dexcom-Expires-At",
dexcomTokens.expiresAt.toString(),
);
}

return Object.fromEntries(headers);
},
}),
Expand Down
89 changes: 89 additions & 0 deletions apps/expo/src/utils/dexcom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";

import type { OAuthTokenResponse } from "@hyper/validators/dexcom";
import {
OAuthErrorResponseSchema,
OAuthTokenResponseSchema,
} from "@hyper/validators/dexcom";

export const TokenDataSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
expiresAt: z.number(),
});
export type TokenData = z.infer<typeof TokenDataSchema>;

export const DEXCOM_SANDBOX_BASE_URL = "https://sandbox-api.dexcom.com";

export async function refreshAccessToken(refreshToken: string) {
return exchangeToken({
client_id: process.env.NEXT_PUBLIC_DEXCOM_CLIENT_ID ?? "",
client_secret: process.env.DEXCOM_CLIENT_SECRET ?? "",
refresh_token: refreshToken,
grant_type: "refresh_token",
});
}

export async function exchangeToken(params: {
client_id: string;
client_secret: string;
code?: string;
refresh_token?: string;
grant_type: "authorization_code" | "refresh_token";
redirect_uri?: string;
}): Promise<OAuthTokenResponse> {
const response = await fetch(`${DEXCOM_SANDBOX_BASE_URL}/v2/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(params).toString(),
});

const data: unknown = await response.json();

if (!response.ok) {
const errorResult = OAuthErrorResponseSchema.safeParse(data);
if (errorResult.success) {
const errorData = errorResult.data;
throw new TRPCError({
code: response.status === 401 ? "UNAUTHORIZED" : "BAD_REQUEST",
message:
errorData.error_description ?? `OAuth error: ${errorData.error}`,
});
} else {
// If the error doesn't match the OAuth error schema, fall back to a generic error
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to exchange token",
});
}
}

const result = OAuthTokenResponseSchema.safeParse(data);
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to parse OAuth token response",
});
}

return result.data;
}

export async function refreshTokenIfNeeded(
tokens: TokenData,
): Promise<TokenData | null> {
const now = Date.now();
if (tokens.expiresAt - now < 300000) {
// 5 minutes
const refreshedData = await refreshAccessToken(tokens.refreshToken);
return {
accessToken: refreshedData.access_token,
refreshToken: refreshedData.refresh_token,
expiresAt: now + refreshedData.expires_in * 1000,
};
}
return null;
}
62 changes: 62 additions & 0 deletions apps/expo/src/utils/token-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { TRPCLink } from "@trpc/client";
import { observable } from "@trpc/server/observable";
import PQueue from "p-queue";

import type { AppRouter } from "@hyper/api";

import type { DexcomTokens } from "~/utils/dexcom-store";
import { refreshTokenIfNeeded } from "~/utils/dexcom";
import {
deleteDexcomTokens,
getDexcomTokens,
setDexcomTokens,
} from "~/utils/dexcom-store";

export const RENEW_MS_BEFORE_EXPIRATION = 5000; // 5 seconds

// Create a queue with concurrency 1 to ensure sequential token refreshes
const queue = new PQueue({ concurrency: 1 });

export const tokenRefreshLink: TRPCLink<AppRouter> = () => {
return ({ next, op }) => {
return observable((observer) => {
void queue.add(async () => {
if (op.path.startsWith("dexcom.")) {
const tokens = getDexcomTokens();
if (
tokens &&
Date.now() >= tokens.expiresAt - RENEW_MS_BEFORE_EXPIRATION
) {
try {
const newTokens = await refreshTokenIfNeeded(tokens);
if (newTokens !== null) {
const updatedTokens: DexcomTokens = {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
expiresAt: newTokens.expiresAt,
refreshTokenCreated: Date.now(), // Set current time as refresh token creation time
};
setDexcomTokens(updatedTokens);
// Update the operation context with new tokens
op.context.dexcomTokens = updatedTokens;
}
} catch (error) {
console.error("Error refreshing Dexcom token:", error);
await deleteDexcomTokens();
throw error; // Propagate the error
}
}
}

// Call the next link in the chain
const unsubscribe = next(op).subscribe({
next: (result) => observer.next(result),
error: (error) => observer.error(error),
complete: () => observer.complete(),
});

return unsubscribe;
});
});
};
};
Loading

0 comments on commit 194962e

Please sign in to comment.