mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
1 parent
46d3de728f
commit
c54842b8f9
3 files changed
+426
-12
No files matched your search
+159
-12
@@ -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),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user