-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: custom tRPC link to handle dexcom refresh tokens
- Loading branch information
1 parent
f378976
commit 194962e
Showing
12 changed files
with
432 additions
and
138 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}); | ||
}); | ||
}; | ||
}; |
Oops, something went wrong.