rotate-key-dialog.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import { ArrowRight, ExternalLink, Info, Key, Timer } from 'lucide-react'
  2. import { useState } from 'react'
  3. import { toast } from 'sonner'
  4. import {
  5. Badge,
  6. Button,
  7. Checkbox,
  8. cn,
  9. DialogDescription,
  10. DialogFooter,
  11. DialogHeader,
  12. DialogSection,
  13. DialogSectionSeparator,
  14. DialogTitle,
  15. Label,
  16. Skeleton,
  17. } from 'ui'
  18. import { algorithmLabels } from '../algorithm-details'
  19. import { statusColors } from '../jwt.constants'
  20. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  21. import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
  22. import { useJWTSigningKeyUpdateMutation } from '@/data/jwt-signing-keys/jwt-signing-key-update-mutation'
  23. import { JWTSigningKey } from '@/data/jwt-signing-keys/jwt-signing-keys-query'
  24. export function RotateKeyDialog({
  25. projectRef,
  26. standbyKey,
  27. inUseKey,
  28. onClose,
  29. }: {
  30. projectRef: string
  31. standbyKey: JWTSigningKey
  32. inUseKey: JWTSigningKey
  33. onClose: () => void
  34. }) {
  35. const [isStandbyUnderstood, setStandbyUnderstood] = useState(false)
  36. const [isPreviouslyUsedUnderstood, setPreviouslyUsedUnderstood] = useState(false)
  37. const [isEdgeFunctionsVerifyJWTUnderstood, setEdgeFunctionsVerifyJWTUnderstood] = useState(false)
  38. const { data: edgeFunctions, isPending: isLoadingEdgeFunctions } = useEdgeFunctionsQuery({
  39. projectRef,
  40. })
  41. const { mutate, isPending: isPendingMutation } = useJWTSigningKeyUpdateMutation({
  42. onSuccess: () => {
  43. toast.success('Signing key rotated successfully')
  44. onClose()
  45. },
  46. onError: (error) => {
  47. toast.error(`Failed to rotate signing key: ${error.message}`)
  48. },
  49. })
  50. const verifyJWTEdgeFunctions = edgeFunctions?.filter(({ verify_jwt }) => verify_jwt) ?? []
  51. return (
  52. <>
  53. <DialogHeader>
  54. <DialogTitle>Rotate JWT signing key</DialogTitle>
  55. <DialogDescription>
  56. Change the key used by Briven Auth to create new JSON Web Tokens. Non-expired tokens
  57. remain <span className="text-brand">valid and accepted</span>!
  58. </DialogDescription>
  59. </DialogHeader>
  60. <DialogSectionSeparator />
  61. <DialogSection className="bg">
  62. <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2">
  63. <Badge
  64. className={cn(
  65. statusColors['standby'],
  66. 'px-4 py-1 gap-2 flex flex-row items-center uppercase'
  67. )}
  68. >
  69. <Timer size={14} />
  70. Standby key
  71. </Badge>
  72. <div>
  73. <ArrowRight className="size-4 text-foreground-light" />
  74. </div>
  75. <div>
  76. <Badge
  77. className={cn(
  78. statusColors['in_use'],
  79. 'px-4 py-1 gap-2 flex flex-row items-center uppercase'
  80. )}
  81. >
  82. <Key size={14} />
  83. Current key
  84. </Badge>
  85. </div>
  86. <div className="text-xs text-foreground-light font-mono text-center">
  87. {algorithmLabels[standbyKey.algorithm]}
  88. </div>
  89. <div />
  90. <div />
  91. <div className="col-span-3" />
  92. <Badge
  93. className={cn(
  94. statusColors['in_use'],
  95. 'px-4 py-1 gap-2 flex flex-row items-center uppercase'
  96. )}
  97. >
  98. <Key size={14} />
  99. Current key
  100. </Badge>
  101. <div>
  102. <ArrowRight className="h-4 w-4 text-foreground-light" />
  103. </div>
  104. <Badge
  105. className={cn(
  106. statusColors['previously_used'],
  107. 'px-4 py-1 gap-2 flex flex-row items-center uppercase'
  108. )}
  109. >
  110. <Timer size={14} />
  111. Previous key
  112. </Badge>
  113. <div />
  114. <div />
  115. <div className="text-xs text-foreground-light font-mono text-center">
  116. {algorithmLabels[inUseKey.algorithm]}
  117. </div>
  118. </div>
  119. </DialogSection>
  120. <DialogSectionSeparator />
  121. <DialogSection className="flex flex-col gap-4">
  122. {isLoadingEdgeFunctions ? (
  123. <>
  124. <Skeleton className="h-4 w-full" />
  125. <Skeleton className="h-4 w-full" />
  126. <Skeleton className="h-4 w-full" />
  127. <Skeleton className="h-4 w-full" />
  128. </>
  129. ) : (
  130. <>
  131. <div className="text-sm">To proceed please confirm:</div>
  132. <Label
  133. htmlFor="understands-standby"
  134. className="flex items-top gap-4 text-sm leading-none"
  135. >
  136. <Checkbox
  137. id="understands-standby"
  138. className="mt-0.5"
  139. checked={isStandbyUnderstood}
  140. onCheckedChange={(value) => setStandbyUnderstood(!!value)}
  141. />
  142. <p className="text-sm text-foreground-light">
  143. All of my application's components have picked up the standby key.
  144. </p>
  145. <ButtonTooltip
  146. type="default"
  147. icon={<Info />}
  148. className="px-1.5 py-2 mt-0.5"
  149. tooltip={{
  150. content: {
  151. className: 'max-w-[320px] p-4',
  152. text: (
  153. <p>
  154. If your application verifies JWTs on its own in backend servers, functions,
  155. lambdas or other such components, ensure that they've picked up and are
  156. verifying tokens against the standby key.
  157. <br />
  158. <br />
  159. Recommendation: Periodically fetch the public keys from the project's{' '}
  160. <code>jwks.json</code> endpoint.
  161. </p>
  162. ),
  163. },
  164. }}
  165. />
  166. </Label>
  167. <Label
  168. htmlFor="understands-previously-used"
  169. className="flex items-top gap-4 text-sm leading-none"
  170. >
  171. <Checkbox
  172. className="mt-0.5"
  173. id="understands-previously-used"
  174. checked={isPreviouslyUsedUnderstood}
  175. onCheckedChange={(value) => setPreviouslyUsedUnderstood(!!value)}
  176. />
  177. <p className="text-sm text-foreground-light">
  178. To invalidate non-expired JWTs I need to explicitly revoke the currently used key.
  179. </p>
  180. <ButtonTooltip
  181. type="default"
  182. icon={<Info />}
  183. className="px-1.5 py-2 mt-0.5"
  184. tooltip={{
  185. content: {
  186. className: 'max-w-[320px] p-4',
  187. text: (
  188. <p>
  189. Rotating the signing key only changes what key is used by Briven Auth to
  190. issue <em className="text-brand not-italic">new tokens</em>
  191. .<br />
  192. <br />
  193. To prevent users from being prematurely signed out, you have to manually
  194. revoke the current in use key after rotation.
  195. <br />
  196. <br />
  197. Recommendation: If your JWT expiry time is 1 hour, wait at least 1 hour and
  198. 15 minutes before revoking the key.
  199. </p>
  200. ),
  201. },
  202. }}
  203. />
  204. </Label>
  205. {verifyJWTEdgeFunctions.length > 0 && (
  206. <Label htmlFor="edge-functions-verify-jwt" className="flex gap-4 text-sm">
  207. <Checkbox
  208. id="edge-functions-verify-jwt"
  209. className="mt-0.5"
  210. checked={isEdgeFunctionsVerifyJWTUnderstood}
  211. onCheckedChange={(value) => setEdgeFunctionsVerifyJWTUnderstood(!!value)}
  212. />
  213. <p className="text-sm text-foreground-light">
  214. The following Edge Functions may stop functioning for signed-in users as they
  215. verify the legacy JWT secret:{' '}
  216. {verifyJWTEdgeFunctions
  217. .map(({ name }) => (
  218. <a
  219. key={name}
  220. className=""
  221. href={`../../functions/${name}/details`}
  222. target="_blank"
  223. title={name}
  224. >
  225. <ExternalLink className="size-3 inline-block" /> <code>{name}</code>
  226. </a>
  227. ))
  228. .reduce<React.ReactNode[]>(
  229. (arr, v) => (arr.length > 0 ? [...arr, ', ', v] : [v]),
  230. []
  231. )}
  232. </p>
  233. <ButtonTooltip
  234. type="default"
  235. icon={<Info />}
  236. className="px-1.5 py-2 mt-0.5"
  237. tooltip={{
  238. content: {
  239. className: 'max-w-[320px] p-4',
  240. text: (
  241. <p>
  242. Some of your Edge Functions are set up to require a JWT in the{' '}
  243. <code>Authorization</code> header signed with the{' '}
  244. <em className="text-brand not-italic">legacy JWT secret</em>. Rotation
  245. causes{' '}
  246. <em className="text-brand not-italic">invocations by signed-in users</em>{' '}
  247. to fail with HTTP 401 Unauthorized, as the JWT no longer meets this
  248. requirement.
  249. <br />
  250. <br />
  251. Recommendation: Change all of your Edge Functions to no longer verify JWT
  252. and implement the verification logic in the function's code yourself by
  253. using the Briven client library or any other library for working with
  254. JWT.
  255. </p>
  256. ),
  257. },
  258. }}
  259. />
  260. </Label>
  261. )}
  262. </>
  263. )}
  264. </DialogSection>
  265. <DialogFooter>
  266. <Button
  267. onClick={() => mutate({ projectRef, keyId: standbyKey.id, status: 'in_use' })}
  268. disabled={
  269. isLoadingEdgeFunctions ||
  270. !isPreviouslyUsedUnderstood ||
  271. !isStandbyUnderstood ||
  272. (!!verifyJWTEdgeFunctions.length && !isEdgeFunctionsVerifyJWTUnderstood)
  273. }
  274. loading={isPendingMutation}
  275. >
  276. Rotate signing key
  277. </Button>
  278. </DialogFooter>
  279. </>
  280. )
  281. }