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