create-key-dialog.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import dayjs from 'dayjs'
  2. import { useMemo, useState } from 'react'
  3. import { toast } from 'sonner'
  4. import {
  5. Badge,
  6. Button,
  7. Checkbox,
  8. DialogFooter,
  9. DialogHeader,
  10. DialogSection,
  11. DialogSectionSeparator,
  12. DialogTitle,
  13. Label,
  14. Select,
  15. SelectContent,
  16. SelectItem,
  17. SelectTrigger,
  18. SelectValue,
  19. Textarea,
  20. } from 'ui'
  21. import { useJWTSigningKeyCreateMutation } from '@/data/jwt-signing-keys/jwt-signing-key-create-mutation'
  22. import { JWTAlgorithm } from '@/data/jwt-signing-keys/jwt-signing-keys-query'
  23. import { stringToBase64URL } from '@/lib/base64url'
  24. const RSA_JWK_REQUIRED_PROPERTIES = ['kty', 'n', 'e', 'p', 'q', 'd', 'dq', 'dp', 'qi']
  25. const EC_JWK_REQUIRED_PROPERTIES = ['kty', 'crv', 'x', 'y', 'd']
  26. export const CreateKeyDialog = ({
  27. projectRef,
  28. onClose,
  29. }: {
  30. projectRef: string
  31. onClose: () => void
  32. }) => {
  33. const [newKeyAlgorithm, setNewKeyAlgorithm] = useState<JWTAlgorithm>('ES256')
  34. const [isBYOK, setBYOK] = useState(false)
  35. const [privateKey, setPrivateKey] = useState('')
  36. const [isBase64, setBase64] = useState(false)
  37. const privateKeyMessage = useMemo(() => {
  38. const plain = privateKey.replace(/\s+/g, '')
  39. if (!plain) {
  40. return null
  41. }
  42. if (newKeyAlgorithm === 'HS256') {
  43. if (privateKey.length < 16) {
  44. return 'Secret must be at least 16 letters long'
  45. }
  46. return null
  47. }
  48. let jwk
  49. try {
  50. jwk = JSON.parse(privateKey)
  51. } catch (e: any) {
  52. return 'Private key is not valid JSON'
  53. }
  54. if (typeof jwk !== 'object' || !jwk) {
  55. return 'Private key must be a JSON object'
  56. }
  57. if (typeof jwk.kty !== 'string' || !jwk.kty) {
  58. return 'Private key must have a kty property'
  59. }
  60. if (newKeyAlgorithm === 'RS256') {
  61. if (jwk.kty !== 'RSA') {
  62. return 'Private key must be of RSA type'
  63. }
  64. if (jwk.e !== 'AQAB') {
  65. return 'RSA private keys must use the 65537 (AQAB) public exponent'
  66. }
  67. for (let prop of RSA_JWK_REQUIRED_PROPERTIES) {
  68. if (typeof jwk[prop] !== 'string' || !jwk[prop]) {
  69. return `Incomplete RSA private key, required properties are: ${RSA_JWK_REQUIRED_PROPERTIES.join(', ')}`
  70. }
  71. }
  72. } else if (newKeyAlgorithm === 'ES256') {
  73. if (jwk.kty !== 'EC') {
  74. return 'Private key must be of EC type'
  75. }
  76. if (jwk.crv !== 'P-256') {
  77. return 'EC private keys must use P-256 curve'
  78. }
  79. for (let prop of EC_JWK_REQUIRED_PROPERTIES) {
  80. if (typeof jwk[prop] !== 'string' || !jwk[prop]) {
  81. return `Incomplete EC private key, required properties are: ${EC_JWK_REQUIRED_PROPERTIES.join(', ')}`
  82. }
  83. }
  84. }
  85. return null
  86. }, [privateKey, newKeyAlgorithm])
  87. const { mutate, isPending: isPendingMutation } = useJWTSigningKeyCreateMutation({
  88. onSuccess: () => {
  89. toast.success('Standby key created successfully')
  90. onClose()
  91. },
  92. onError: (error) => {
  93. let errorMessage = error.message
  94. if (errorMessage.includes('Please wait until')) {
  95. const dateString = errorMessage
  96. .replace('Please wait until ', '')
  97. .replace('before attempting this request again.', '')
  98. .trim()
  99. const date = dayjs(dateString)
  100. if (date.isValid()) {
  101. errorMessage = `Please wait for ${date.fromNow(true)} before attempting this request again.`
  102. }
  103. }
  104. toast.error(`Failed to add new standby key: ${errorMessage}`)
  105. },
  106. })
  107. const handleAddNewStandbyKey = async () => {
  108. mutate({
  109. projectRef: projectRef!,
  110. algorithm: newKeyAlgorithm,
  111. status: 'standby',
  112. private_jwk: isBYOK
  113. ? newKeyAlgorithm === 'HS256'
  114. ? {
  115. kty: 'oct',
  116. k: isBase64
  117. ? privateKey
  118. .replace(/\s+/g, '')
  119. .replace(/\+/g, '-')
  120. .replace(/\//g, '_')
  121. .replace(/=/g, '')
  122. : stringToBase64URL(privateKey),
  123. }
  124. : JSON.parse(privateKey)
  125. : null,
  126. })
  127. }
  128. return (
  129. <>
  130. <DialogHeader>
  131. <DialogTitle>Create a new Standby Key</DialogTitle>
  132. </DialogHeader>
  133. <DialogSectionSeparator />
  134. <DialogSection className="space-y-4">
  135. <p className="text-sm text-foreground-light">
  136. Adds a new JSON Web Token signing key. Once all of your application's components have
  137. picked it up you can rotate the current key with it.
  138. <br />
  139. <br />
  140. This action does not invalidate existing tokens, so your users remain signed in.
  141. </p>
  142. </DialogSection>
  143. <DialogSectionSeparator />
  144. <DialogSection className="flex flex-col gap-4">
  145. <div className="flex flex-col gap-4">
  146. <Label htmlFor="algorithm">Choose signing algorithm:</Label>
  147. <Select
  148. name="algorithm"
  149. value={newKeyAlgorithm}
  150. onValueChange={(value: JWTAlgorithm) => setNewKeyAlgorithm(value)}
  151. >
  152. <SelectTrigger id="algorithm">
  153. <SelectValue placeholder="Select algorithm" />
  154. </SelectTrigger>
  155. <SelectContent>
  156. <SelectItem value="ES256">
  157. <span>ES256 (ECC)</span>
  158. <Badge variant="success" className="ml-2">
  159. Recommended
  160. </Badge>
  161. </SelectItem>
  162. <SelectItem value="RS256">RS256 (RSA)</SelectItem>
  163. <SelectItem value="HS256">HS256 (Shared Secret)</SelectItem>
  164. </SelectContent>
  165. </Select>
  166. </div>
  167. <div className="flex flex-col gap-4">
  168. <Label htmlFor="byok" className="flex items-center gap-x-2">
  169. <Checkbox id="byok" checked={isBYOK} onCheckedChange={(value) => setBYOK(!!value)} />
  170. {newKeyAlgorithm === 'HS256'
  171. ? 'Import an existing secret'
  172. : 'Import an existing private key'}
  173. </Label>
  174. {isBYOK && (
  175. <div className="flex flex-col gap-2">
  176. <Textarea
  177. className="font-mono"
  178. placeholder={
  179. newKeyAlgorithm === 'HS256'
  180. ? 'Type in your JWT secret'
  181. : 'Add a private key in JWK (JSON Web Key) format'
  182. }
  183. value={privateKey}
  184. onChange={(e: any) => {
  185. setPrivateKey(e.target.value)
  186. }}
  187. autoComplete="off"
  188. autoCorrect="off"
  189. autoCapitalize="off"
  190. spellCheck="false"
  191. />
  192. {privateKeyMessage && <p className="text-red-900 text-sm">{privateKeyMessage}</p>}
  193. </div>
  194. )}
  195. {isBYOK && newKeyAlgorithm === 'HS256' && (
  196. <>
  197. <Label htmlFor="base64" className="flex items-center gap-x-2">
  198. <Checkbox
  199. id="base64"
  200. checked={isBase64}
  201. onCheckedChange={(value) => setBase64(!!value)}
  202. />
  203. Secret is already Base64 encoded
  204. </Label>
  205. </>
  206. )}
  207. </div>
  208. </DialogSection>
  209. <DialogFooter>
  210. <Button
  211. onClick={() => handleAddNewStandbyKey()}
  212. disabled={isPendingMutation || !!privateKeyMessage}
  213. loading={isPendingMutation}
  214. >
  215. Create standby key
  216. </Button>
  217. </DialogFooter>
  218. </>
  219. )
  220. }