feat: jwt signing key dialog supports bring your own key (#36730)

* feat: jwt signing key dialog supports bring your own key

* Minor structure fixes.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
Stojan DimitrovskiandIvan Vasilov authored and GitHub committed 2025-07-03 12:41:11 +02:00
1 parent 46d3de728f
commit c54842b8f9
3 files changed
+426 -12

No files matched your search

@@ -1,12 +1,15 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/jwt-signing-key-create-mutation'
import { JWTAlgorithm } from 'data/jwt-signing-keys/jwt-signing-keys-query'
import { stringToBase64URL } from 'lib/base64url'
import {
Badge,
Button,
Checkbox_Shadcn_,
DialogFooter,
DialogHeader,
DialogSection,
@@ -18,11 +21,18 @@ import {
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Select_Shadcn_,
Textarea,
} from 'ui'
import { algorithmDescriptions } from '../algorithm-details'
dayjs.extend(relativeTime)
const RSA_JWK_REQUIRED_PROPERTIES = ['kty', 'n', 'e', 'p', 'q', 'd', 'dq', 'dp', 'qi']
const EC_JWK_REQUIRED_PROPERTIES = ['kty', 'crv', 'x', 'y', 'd']
const ALLOWED_JWK_PROPERTIES = new Set([
...RSA_JWK_REQUIRED_PROPERTIES,
...EC_JWK_REQUIRED_PROPERTIES,
])
export const CreateKeyDialog = ({
projectRef,
onClose,
@@ -30,7 +40,73 @@ export const CreateKeyDialog = ({
projectRef: string
onClose: () => void
}) => {
const [newKeyAlgorithm, setNewKeyAlgorithm] = useState<JWTAlgorithm>('RS256')
const [newKeyAlgorithm, setNewKeyAlgorithm] = useState<JWTAlgorithm>('ES256')
const [isBYOK, setBYOK] = useState(false)
const [privateKey, setPrivateKey] = useState('')
const [isBase64, setBase64] = useState(false)
const privateKeyMessage = useMemo(() => {
const plain = privateKey.replace(/\s+/g, '')
if (!plain) {
return null
}
if (newKeyAlgorithm === 'HS256') {
if (privateKey.length < 16) {
return 'Secret must be at least 16 letters long'
}
return null
}
let jwk
try {
jwk = JSON.parse(privateKey)
} catch (e: any) {
return 'Private key is not valid JSON'
}
if (typeof jwk !== 'object' || !jwk) {
return 'Private key must be a JSON object'
}
if (typeof jwk.kty !== 'string' || !jwk.kty) {
return 'Private key must have a kty property'
}
if (newKeyAlgorithm === 'RS256') {
if (jwk.kty !== 'RSA') {
return 'Private key must be of RSA type'
}
if (jwk.e !== 'AQAB') {
return 'RSA private keys must use the 65537 (AQAB) public exponent'
}
for (let prop of RSA_JWK_REQUIRED_PROPERTIES) {
if (typeof jwk[prop] !== 'string' || !jwk[prop]) {
return `Incomplete RSA private key, required properties are: ${RSA_JWK_REQUIRED_PROPERTIES.join(', ')}`
}
}
} else if (newKeyAlgorithm === 'ES256') {
if (jwk.kty !== 'EC') {
return 'Private key must be of EC type'
}
if (jwk.crv !== 'P-256') {
return 'EC private keys must use P-256 curve'
}
for (let prop of EC_JWK_REQUIRED_PROPERTIES) {
if (typeof jwk[prop] !== 'string' || !jwk[prop]) {
return `Incomplete EC private key, required properties are: ${EC_JWK_REQUIRED_PROPERTIES.join(', ')}`
}
}
}
return null
}, [privateKey, newKeyAlgorithm])
const { mutate, isLoading: isLoadingMutation } = useJWTSigningKeyCreateMutation({
onSuccess: () => {
@@ -60,6 +136,24 @@ export const CreateKeyDialog = ({
projectRef: projectRef!,
algorithm: newKeyAlgorithm,
status: 'standby',
private_jwk: isBYOK
? newKeyAlgorithm === 'HS256'
? {
kty: 'oct',
k: isBase64
? privateKey
.replace(/\s+/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
: stringToBase64URL(privateKey),
}
: Object.fromEntries(
Object.entries(JSON.parse(privateKey)).filter(([prop]) =>
ALLOWED_JWK_PROPERTIES.has(prop)
)
)
: null,
})
}
@@ -70,9 +164,18 @@ export const CreateKeyDialog = ({
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="space-y-4">
<div>
<Label_Shadcn_ htmlFor="algorithm">Choose the key type to use:</Label_Shadcn_>
<p className="text-sm text-foreground-light">
This will create a new JWT signing key in standby mode. The key will be available for your
application to use, but won't be used for signing new JWTs until you activate it. Once
you've updated your application to trust this key, you can switch to it for new JWTs.
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<Label_Shadcn_ htmlFor="algorithm">Choose signing algorithm:</Label_Shadcn_>
<Select_Shadcn_
name="algorithm"
value={newKeyAlgorithm}
onValueChange={(value: JWTAlgorithm) => setNewKeyAlgorithm(value)}
>
@@ -82,21 +185,65 @@ export const CreateKeyDialog = ({
<SelectContent_Shadcn_>
<SelectItem_Shadcn_ value="HS256">HS256 (Symmetric)</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="RS256">RS256 (RSA)</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="ES256">ES256 (ECC)</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="EdDSA" disabled>
EdDSA (Ed25519)
<SelectItem_Shadcn_ value="ES256">
<span>ES256 (ECC)</span>
<Badge variant="brand" className="ml-2">
Recommended
</Badge>
</SelectItem_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
<p className="text-sm text-muted-foreground mt-1">
{algorithmDescriptions[newKeyAlgorithm]}
</p>
</div>
<div className="flex flex-col gap-4">
<Label_Shadcn_ htmlFor="byok" className="flex items-center gap-x-2">
<Checkbox_Shadcn_
id="byok"
checked={isBYOK}
onCheckedChange={(value) => setBYOK(!!value)}
/>
{newKeyAlgorithm === 'HS256'
? 'Import an existing secret'
: 'Import an existing private key'}
</Label_Shadcn_>
{isBYOK && (
<div className="flex flex-col gap-2">
<Textarea
className="font-mono"
placeholder={
newKeyAlgorithm === 'HS256'
? 'Type in your JWT secret'
: 'Add a private key in JWK (JSON Web Key) format'
}
value={privateKey}
onChange={(e: any) => {
setPrivateKey(e.target.value)
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{privateKeyMessage && <p className="text-red-900 text-sm">{privateKeyMessage}</p>}
</div>
)}
{isBYOK && newKeyAlgorithm === 'HS256' && (
<>
<Label_Shadcn_ htmlFor="base64" className="flex items-center gap-x-2">
<Checkbox_Shadcn_
id="base64"
checked={isBase64}
onCheckedChange={(value) => setBase64(!!value)}
/>
Secret is already Base64 encoded
</Label_Shadcn_>
</>
)}
</div>
</DialogSection>
<DialogFooter>
<Button
onClick={() => handleAddNewStandbyKey()}
disabled={isLoadingMutation}
disabled={isLoadingMutation || !!privateKeyMessage}
loading={isLoadingMutation}
>
Create standby key
@@ -10,6 +10,7 @@ interface JWTSigningKeyCreateVariables {
projectRef?: string
algorithm: JWTAlgorithm
status: 'in_use' | 'standby'
private_jwk: any
}
export async function createJWTSigningKey(payload: JWTSigningKeyCreateVariables) {
@@ -22,6 +23,7 @@ export async function createJWTSigningKey(payload: JWTSigningKeyCreateVariables)
body: {
algorithm: payload.algorithm,
status: payload.status,
...(payload.private_jwk ? { private_jwk: payload.private_jwk } : null),
},
})
+265
View File
@@ -0,0 +1,265 @@
/**
* Avoid modifying this file. It's part of
* https://github.com/supabase-community/base64url-js. Submit all fixes on
* that repo!
*/
/**
* An array of characters that encode 6 bits into a Base64-URL alphabet
* character.
*/
const TO_BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split('')
/**
* An array of characters that can appear in a Base64-URL encoded string but
* should be ignored.
*/
const IGNORE_BASE64URL = ' \t\n\r='.split('')
/**
* An array of 128 numbers that map a Base64-URL character to 6 bits, or if -2
* used to skip the character, or if -1 used to error out.
*/
const FROM_BASE64URL = (() => {
const charMap: number[] = new Array(128)
for (let i = 0; i < charMap.length; i += 1) {
charMap[i] = -1
}
for (let i = 0; i < IGNORE_BASE64URL.length; i += 1) {
charMap[IGNORE_BASE64URL[i].charCodeAt(0)] = -2
}
for (let i = 0; i < TO_BASE64URL.length; i += 1) {
charMap[TO_BASE64URL[i].charCodeAt(0)] = i
}
return charMap
})()
/**
* Converts a byte to a Base64-URL string.
*
* @param byte The byte to convert, or null to flush at the end of the byte sequence.
* @param state The Base64 conversion state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`.
* @param emit A function called with the next Base64 character when ready.
*/
export function byteToBase64URL(
byte: number | null,
state: { queue: number; queuedBits: number },
emit: (char: string) => void
) {
if (byte !== null) {
state.queue = (state.queue << 8) | byte
state.queuedBits += 8
while (state.queuedBits >= 6) {
const pos = (state.queue >> (state.queuedBits - 6)) & 63
emit(TO_BASE64URL[pos])
state.queuedBits -= 6
}
} else if (state.queuedBits > 0) {
state.queue = state.queue << (6 - state.queuedBits)
state.queuedBits = 6
while (state.queuedBits >= 6) {
const pos = (state.queue >> (state.queuedBits - 6)) & 63
emit(TO_BASE64URL[pos])
state.queuedBits -= 6
}
}
}
/**
* Converts a String char code (extracted using `string.charCodeAt(position)`) to a sequence of Base64-URL characters.
*
* @param charCode The char code of the JavaScript string.
* @param state The Base64 state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`.
* @param emit A function called with the next byte.
*/
export function byteFromBase64URL(
charCode: number,
state: { queue: number; queuedBits: number },
emit: (byte: number) => void
) {
const bits = FROM_BASE64URL[charCode]
if (bits > -1) {
// valid Base64-URL character
state.queue = (state.queue << 6) | bits
state.queuedBits += 6
while (state.queuedBits >= 8) {
emit((state.queue >> (state.queuedBits - 8)) & 0xff)
state.queuedBits -= 8
}
} else if (bits === -2) {
// ignore spaces, tabs, newlines, =
return
} else {
throw new Error(`Invalid Base64-URL character "${String.fromCharCode(charCode)}"`)
}
}
/**
* Converts a JavaScript string (which may include any valid character) into a
* Base64-URL encoded string. The string is first encoded in UTF-8 which is
* then encoded as Base64-URL.
*
* @param str The string to convert.
*/
export function stringToBase64URL(str: string) {
const base64: string[] = []
const emitter = (char: string) => {
base64.push(char)
}
const state = { queue: 0, queuedBits: 0 }
stringToUTF8(str, (byte: number) => {
byteToBase64URL(byte, state, emitter)
})
byteToBase64URL(null, state, emitter)
return base64.join('')
}
/**
* Converts a Base64-URL encoded string into a JavaScript string. It is assumed
* that the underlying string has been encoded as UTF-8.
*
* @param str The Base64-URL encoded string.
*/
export function stringFromBase64URL(str: string) {
const conv: string[] = []
const utf8Emit = (codepoint: number) => {
conv.push(String.fromCodePoint(codepoint))
}
const utf8State = {
utf8seq: 0,
codepoint: 0,
}
const b64State = { queue: 0, queuedBits: 0 }
const byteEmit = (byte: number) => {
stringFromUTF8(byte, utf8State, utf8Emit)
}
for (let i = 0; i < str.length; i += 1) {
byteFromBase64URL(str.charCodeAt(i), b64State, byteEmit)
}
return conv.join('')
}
/**
* Converts a Unicode codepoint to a multi-byte UTF-8 sequence.
*
* @param codepoint The Unicode codepoint.
* @param emit Function which will be called for each UTF-8 byte that represents the codepoint.
*/
export function codepointToUTF8(codepoint: number, emit: (byte: number) => void) {
if (codepoint <= 0x7f) {
emit(codepoint)
return
} else if (codepoint <= 0x7ff) {
emit(0xc0 | (codepoint >> 6))
emit(0x80 | (codepoint & 0x3f))
return
} else if (codepoint <= 0xffff) {
emit(0xe0 | (codepoint >> 12))
emit(0x80 | ((codepoint >> 6) & 0x3f))
emit(0x80 | (codepoint & 0x3f))
return
} else if (codepoint <= 0x10ffff) {
emit(0xf0 | (codepoint >> 18))
emit(0x80 | ((codepoint >> 12) & 0x3f))
emit(0x80 | ((codepoint >> 6) & 0x3f))
emit(0x80 | (codepoint & 0x3f))
return
}
throw new Error(`Unrecognized Unicode codepoint: ${codepoint.toString(16)}`)
}
/**
* Converts a JavaScript string to a sequence of UTF-8 bytes.
*
* @param str The string to convert to UTF-8.
* @param emit Function which will be called for each UTF-8 byte of the string.
*/
export function stringToUTF8(str: string, emit: (byte: number) => void) {
for (let i = 0; i < str.length; i += 1) {
let codepoint = str.charCodeAt(i)
if (codepoint > 0xd7ff && codepoint <= 0xdbff) {
// most UTF-16 codepoints are Unicode codepoints, except values in this
// range where the next UTF-16 codepoint needs to be combined with the
// current one to get the Unicode codepoint
const highSurrogate = ((codepoint - 0xd800) * 0x400) & 0xffff
const lowSurrogate = (str.charCodeAt(i + 1) - 0xdc00) & 0xffff
codepoint = (lowSurrogate | highSurrogate) + 0x10000
i += 1
}
codepointToUTF8(codepoint, emit)
}
}
/**
* Converts a UTF-8 byte to a Unicode codepoint.
*
* @param byte The UTF-8 byte next in the sequence.
* @param state The shared state between consecutive UTF-8 bytes in the
* sequence, an object with the shape `{ utf8seq: 0, codepoint: 0 }`.
* @param emit Function which will be called for each codepoint.
*/
export function stringFromUTF8(
byte: number,
state: { utf8seq: number; codepoint: number },
emit: (codepoint: number) => void
) {
if (state.utf8seq === 0) {
if (byte <= 0x7f) {
emit(byte)
return
}
// count the number of 1 leading bits until you reach 0
for (let leadingBit = 1; leadingBit < 6; leadingBit += 1) {
if (((byte >> (7 - leadingBit)) & 1) === 0) {
state.utf8seq = leadingBit
break
}
}
if (state.utf8seq === 2) {
state.codepoint = byte & 31
} else if (state.utf8seq === 3) {
state.codepoint = byte & 15
} else if (state.utf8seq === 4) {
state.codepoint = byte & 7
} else {
throw new Error('Invalid UTF-8 sequence')
}
state.utf8seq -= 1
} else if (state.utf8seq > 0) {
if (byte <= 0x7f) {
throw new Error('Invalid UTF-8 sequence')
}
state.codepoint = (state.codepoint << 6) | (byte & 63)
state.utf8seq -= 1
if (state.utf8seq === 0) {
emit(state.codepoint)
}
}
}