rotate-key-dialog.tsx292 lines · main
| 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 | |
| 19 | import { algorithmLabels } from '../algorithm-details' |
| 20 | import { statusColors } from '../jwt.constants' |
| 21 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 22 | import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' |
| 23 | import { useJWTSigningKeyUpdateMutation } from '@/data/jwt-signing-keys/jwt-signing-key-update-mutation' |
| 24 | import { JWTSigningKey } from '@/data/jwt-signing-keys/jwt-signing-keys-query' |
| 25 | |
| 26 | export 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 | } |