-
-
Save agallio/a460ba9ca92144908e9c3c8ea56a756e to your computer and use it in GitHub Desktop.
Apple Secret Generator component from agallio.xyz
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Inspired by: | |
// https://github.com/supabase/supabase/blob/master/apps/docs/components/AppleSecretGenerator/AppleSecretGenerator.tsx | |
'use client' | |
import { useEffect, useMemo, useState } from 'react' | |
// Components | |
import CustomLink from '@/components/mdx/link' | |
import customDayJs from '@/utils/dayjs' | |
function base64URL(value: string) { | |
return globalThis | |
.btoa(value) | |
.replace(/[=]/g, '') | |
.replace(/[+]/g, '-') | |
.replace(/[\/]/g, '_') | |
} | |
/* | |
Convert a string into an ArrayBuffer | |
from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String | |
*/ | |
function stringToArrayBuffer(value: string) { | |
const buf = new ArrayBuffer(value.length) | |
const bufView = new Uint8Array(buf) | |
for (let i = 0; i < value.length; i++) { | |
bufView[i] = value.charCodeAt(i) | |
} | |
return buf | |
} | |
function arrayBufferToString(buf: ArrayBuffer): string { | |
// @ts-expect-error: Test | |
return String.fromCharCode.apply(null, new Uint8Array(buf)) | |
} | |
function useBrowserDetection() { | |
const [browser, setBrowser] = useState('unknown') | |
useEffect(() => { | |
const userAgent = window.navigator.userAgent.toLowerCase() | |
if (userAgent.indexOf('chrome') > -1) { | |
setBrowser('chrome') | |
} else if (userAgent.indexOf('firefox') > -1) { | |
setBrowser('firefox') | |
} else if ( | |
userAgent.indexOf('safari') > -1 && | |
userAgent.indexOf('chrome') === -1 | |
) { | |
setBrowser('safari') | |
} | |
}, []) | |
return browser | |
} | |
const generateAppleSecretKey = async ( | |
kid: string, | |
iss: string, | |
sub: string, | |
file: File, | |
): Promise<{ kid: string; jwt: string; exp: number }> => { | |
if (!kid) { | |
const match = file.name.match(/AuthKey_([^.]+)[.].*$/i) | |
if (match && match[1]) { | |
kid = match[1] | |
} | |
} | |
if (!kid) { | |
throw new Error( | |
`No Keys ID provided. The file "${file.name}" does not follow the AuthKey_XXXXXXXXXX.p8 pattern. Please provide a Keys ID manually.`, | |
) | |
} | |
const contents = await file.text() | |
if ( | |
!contents.match(/^\s*-+BEGIN PRIVATE KEY-+[^-]+-+END PRIVATE KEY-+\s*$/i) | |
) { | |
throw new Error( | |
`Chosen file does not appear to be a PEM encoded PKCS8 private key file.`, | |
) | |
} | |
// remove PEM headers and spaces | |
const pkcs8 = stringToArrayBuffer( | |
globalThis.atob(contents.replace(/-+[^-]+-+/g, '').replace(/\s+/g, '')), | |
) | |
const privateKey = await globalThis.crypto.subtle.importKey( | |
'pkcs8', | |
pkcs8, | |
{ | |
name: 'ECDSA', | |
namedCurve: 'P-256', | |
}, | |
true, | |
['sign'], | |
) | |
const iat = Math.floor(Date.now() / 1000) | |
const exp = iat + 180 * 24 * 60 * 60 | |
const jwt = [ | |
base64URL(JSON.stringify({ typ: 'JWT', kid, alg: 'ES256' })), | |
base64URL( | |
JSON.stringify({ | |
iss, | |
sub, | |
iat, | |
exp, | |
aud: 'https://appleid.apple.com', | |
}), | |
), | |
] | |
const signature = await globalThis.crypto.subtle.sign( | |
{ | |
name: 'ECDSA', | |
hash: 'SHA-256', | |
}, | |
privateKey, | |
stringToArrayBuffer(jwt.join('.')), | |
) | |
jwt.push(base64URL(arrayBufferToString(signature))) | |
return { kid, jwt: jwt.join('.'), exp } | |
} | |
const languagesText = { | |
warning: { | |
en: 'This tool only works in Chrome/Firefox browser.', | |
id: 'Hanya dapat digunakan pada browser Chrome/Firefox.', | |
}, | |
inputPlaceholder: { | |
en: 'Input your', | |
id: 'Masukan', | |
}, | |
accountIdHelper: { | |
en: 'Your Apple Developer Account ID, 10 alphanumeric characters', | |
id: 'ID Akun Apple Developer Anda, 10 karakter alfanumerik', | |
}, | |
servicesIdHelper: { | |
en: 'Your recently created Services ID', | |
id: 'Services ID yang baru saja Anda buat', | |
}, | |
keysIdFileHelper: { | |
en: 'The filename format is usually like this: AuthKey_XX123456XX.p8', | |
id: 'Format nama file nya biasanya seperti ini: AuthKey_XX123456XX.p8', | |
}, | |
keysIdHelper: { | |
en: 'Get it from the last 10 alphanumeric characters from the Keys ID filename above', | |
id: 'Ambil 10 karakter alfanumerik terakhir dalam nama file Keys ID diatas', | |
}, | |
seeHere: { | |
en: 'See here', | |
id: 'Lihat disini', | |
}, | |
generatedPlaceholder: { | |
en: 'Click "Generate" button above', | |
id: 'Klik tombol "Generate" diatas', | |
}, | |
clientOnlyHelper: { | |
en: `Privacy-first: this tool runs entirely in your browser, no server involved!`, | |
id: `Tool ini mengutamakan privasi: 100% berjalan di browser, tanpa server!`, | |
}, | |
clientOnlyHelperLink: { | |
en: 'See the code here', | |
id: 'Lihat kodenya disini', | |
}, | |
invalidRequestError: { | |
en: 'Invalid request. Team ID, Service ID, and Keys ID must be 10 characters.', | |
id: 'Request salah. Team ID, Service ID, dan Keys ID harus 10 karakter.', | |
}, | |
expiryHelper: { | |
en: `Don't forget to generate new key before expiry date!`, | |
id: 'Jangan lupa generate key baru sebelum tanggal expire!', | |
}, | |
} | |
export default function AppleSecretGenerator({ | |
language = 'en', | |
}: { | |
language?: 'en' | 'id' | |
}) { | |
const browser = useBrowserDetection() | |
const [teamID, setTeamID] = useState('') | |
const [serviceID, setServiceID] = useState('') | |
const [file, setFile] = useState({ file: null as File | null }) | |
const [keyID, setKeyID] = useState('') | |
// Results | |
const [secretKey, setSecretKey] = useState('') | |
const [expiresAt, setExpiresAt] = useState('') | |
const [error, setError] = useState('') | |
const [copyLabel, setCopyLabel] = useState('Copy') | |
// Constants | |
const isSafari = browser === 'safari' | |
// Memoized Values | |
const isGenerateButtonDisabled = useMemo(() => { | |
if (isSafari) return true | |
const isTeamIDValid = teamID.length === 10 | |
const isServiceIDValid = serviceID.length > 0 | |
const isKeyIDValid = keyID.length === 10 | |
const isFileValid = file.file !== null | |
return !(isTeamIDValid && isServiceIDValid && isKeyIDValid && isFileValid) | |
}, [teamID, serviceID, keyID, file.file, isSafari]) | |
// Methods | |
const handleGenerate = async () => { | |
setError('') | |
const isTeamIDValid = teamID.length === 10 | |
const isServiceIDValid = serviceID.length > 0 | |
const isKeyIDValid = keyID.length === 10 | |
if (!isTeamIDValid || !isServiceIDValid || !isKeyIDValid || !file.file) { | |
setError(languagesText.invalidRequestError[language]) | |
return | |
} | |
try { | |
const { jwt, exp } = await generateAppleSecretKey( | |
keyID, | |
teamID, | |
serviceID, | |
file.file, | |
) | |
setSecretKey(jwt) | |
setExpiresAt( | |
customDayJs(new Date(exp * 1000)) | |
.locale(language) | |
.format('DD-MM-YYYY HH:mm:ss [UTC]Z'), | |
) | |
} catch (e) { | |
console.log(e) | |
setError((e as { message: string }).message) | |
} | |
} | |
const handleCopy = () => { | |
if (!secretKey) return | |
navigator.clipboard.writeText(secretKey).then( | |
() => { | |
/* clipboard successfully set */ | |
setCopyLabel('Copied') | |
setTimeout(() => { | |
setCopyLabel('Copy') | |
}, 3000) | |
}, | |
() => { | |
/* clipboard write failed */ | |
setCopyLabel('Failed to copy') | |
}, | |
) | |
} | |
return ( | |
<div className="not-prose relative mx-auto my-8 max-w-[500px]"> | |
<div className="flex flex-row gap-2 rounded-t-[20px] border border-yellow-300 bg-yellow-100 px-5 pb-7 pt-4 dark:border-yellow-700 dark:bg-yellow-800"> | |
<p className="mt-1 shrink-0 text-2xl leading-none min-[480px]:mt-0"> | |
⚠️ | |
</p> | |
<p className="leading-snug text-yellow-900 dark:text-yellow-100"> | |
{languagesText.warning[language]} | |
</p> | |
</div> | |
<div className="relative -mt-[1.1rem] flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-5 dark:border-neutral-700 dark:bg-neutral-800"> | |
<div className="flex flex-col gap-1"> | |
<label>Team Account ID</label> | |
<input | |
type="text" | |
maxLength={10} | |
value={teamID} | |
onChange={(e) => setTeamID(e.target.value)} | |
placeholder={`${languagesText.inputPlaceholder[language]} Team Account ID (XX123456XX)`} | |
disabled={isSafari} | |
className="block w-full rounded-md border-neutral-200 shadow-sm placeholder:text-neutral-400 focus:border-green-600 focus:ring focus:ring-green-200 focus:ring-opacity-50 disabled:bg-neutral-100 dark:border-neutral-600 dark:bg-neutral-700 dark:shadow-none dark:focus:border-green-400 dark:focus:ring-green-300 dark:focus:ring-opacity-50 dark:disabled:bg-neutral-700 dark:disabled:placeholder:text-neutral-500" | |
/> | |
<p className="mt-1 text-sm leading-tight text-neutral-500 dark:text-neutral-400"> | |
{languagesText.accountIdHelper[language]}. | |
</p> | |
</div> | |
<div className="flex flex-col gap-1"> | |
<label>Services ID</label> | |
<input | |
type="text" | |
value={serviceID} | |
onChange={(e) => setServiceID(e.target.value)} | |
placeholder={`${languagesText.inputPlaceholder[language]} Services ID (com.example.auth)`} | |
disabled={isSafari} | |
className="block w-full rounded-md border-neutral-200 shadow-sm placeholder:text-neutral-400 focus:border-green-600 focus:ring focus:ring-green-200 focus:ring-opacity-50 disabled:bg-neutral-100 dark:border-neutral-600 dark:bg-neutral-700 dark:shadow-none dark:focus:border-green-400 dark:focus:ring-green-300 dark:focus:ring-opacity-50 dark:disabled:bg-neutral-700 dark:disabled:placeholder:text-neutral-500" | |
/> | |
<p className="mt-1 text-sm leading-tight text-neutral-500 dark:text-neutral-400"> | |
{languagesText.servicesIdHelper[language]}.{' '} | |
<CustomLink | |
href={ | |
language === 'en' | |
? '#creating-a-services-id' | |
: '#membuat-services-id' | |
} | |
className="dark:text-white" | |
> | |
{languagesText.seeHere[language]} | |
</CustomLink> | |
. | |
</p> | |
</div> | |
<div className="flex flex-col gap-1"> | |
<label>Keys ID File</label> | |
<input | |
type="file" | |
accept=".p8" | |
disabled={isSafari} | |
onChange={(e) => { | |
if (e.target.files) { | |
const { name } = e.target.files[0] | |
const keyID = name.split('_')[1].split('.')[0] | |
setFile({ file: e.target.files[0] }) | |
setKeyID(keyID) | |
} | |
}} | |
/> | |
<p className="mt-1 text-sm leading-tight text-neutral-500 dark:text-neutral-400"> | |
{languagesText.keysIdFileHelper[language]}.{' '} | |
<CustomLink | |
href={ | |
language === 'en' ? '#creating-a-keys-id' : '#membuat-keys-id' | |
} | |
className="dark:text-white" | |
> | |
{languagesText.seeHere[language]} | |
</CustomLink> | |
. | |
</p> | |
</div> | |
<div className="flex flex-col gap-1"> | |
<label>Keys ID</label> | |
<input | |
type="text" | |
maxLength={10} | |
value={keyID} | |
onChange={(e) => setKeyID(e.target.value)} | |
placeholder={`${languagesText.inputPlaceholder[language]} Keys ID (XX123456XX)`} | |
disabled={isSafari} | |
className="block w-full rounded-md border-neutral-200 shadow-sm placeholder:text-neutral-400 focus:border-green-600 focus:ring focus:ring-green-200 focus:ring-opacity-50 disabled:bg-neutral-100 dark:border-neutral-600 dark:bg-neutral-700 dark:shadow-none dark:focus:border-green-400 dark:focus:ring-green-300 dark:focus:ring-opacity-50 dark:disabled:bg-neutral-700 dark:disabled:placeholder:text-neutral-500" | |
/> | |
<p className="mt-1 text-sm leading-tight text-neutral-500 dark:text-neutral-400"> | |
{languagesText.keysIdHelper[language]}.{' '} | |
<CustomLink | |
href={ | |
language === 'en' | |
? '#creating-your-client-secret' | |
: '#membuat-client-secret' | |
} | |
className="dark:text-white" | |
> | |
{languagesText.seeHere[language]} | |
</CustomLink> | |
. | |
</p> | |
</div> | |
<button | |
disabled={isGenerateButtonDisabled} | |
className="rounded-lg bg-neutral-200 p-2 font-semibold text-black transition-colors hover:bg-neutral-300 disabled:bg-neutral-100 disabled:text-neutral-400 dark:disabled:bg-neutral-950/40 dark:disabled:text-neutral-600" | |
onClick={handleGenerate} | |
> | |
Generate Client Secret | |
</button> | |
{error ? ( | |
<p className="leading-tight text-red-500">Error: {error}</p> | |
) : null} | |
<div> | |
<label>Generated Client Secret</label> | |
<div className="flex items-center"> | |
<input | |
type="text" | |
readOnly | |
value={secretKey} | |
placeholder={languagesText.generatedPlaceholder[language]} | |
disabled={isSafari} | |
className="disabled block w-full rounded-l-md border-neutral-200 bg-neutral-100 shadow-sm placeholder:text-neutral-400 focus:border-green-600 focus:ring focus:ring-green-200 focus:ring-opacity-50 dark:border-neutral-600 dark:bg-neutral-700 dark:focus:border-green-400 dark:focus:ring-green-300 dark:focus:ring-opacity-50" | |
/> | |
<button | |
disabled={!secretKey} | |
className="rounded-r-md border-b border-r border-t border-neutral-200 px-2 py-1.5 focus:border focus:border-green-600 focus:ring focus:ring-green-200 focus:ring-opacity-50 disabled:bg-neutral-200 disabled:text-neutral-400 dark:border-neutral-600 dark:bg-neutral-700 dark:focus:border-green-400 dark:focus:ring-green-300 dark:focus:ring-opacity-50 dark:disabled:bg-neutral-600 dark:disabled:text-neutral-400" | |
onClick={handleCopy} | |
> | |
{copyLabel} | |
</button> | |
</div> | |
<p className="mt-2 text-sm leading-tight text-neutral-500 dark:text-neutral-400"> | |
Expire: {expiresAt || '-'} | |
</p> | |
<p className="mt-1.5 text-sm leading-tight text-neutral-500 dark:text-neutral-400"> | |
{languagesText.expiryHelper[language]} | |
</p> | |
</div> | |
</div> | |
<div className="-mt-[1.1rem] flex flex-row gap-2 rounded-b-[20px] border border-green-300 bg-green-100 px-5 pb-4 pt-8 dark:border-green-800 dark:bg-green-950"> | |
<p className="mt-1 shrink-0 text-2xl leading-none">🔒</p> | |
<p className="leading-snug text-green-900 dark:text-green-100"> | |
{languagesText.clientOnlyHelper[language]}{' '} | |
{languagesText.clientOnlyHelperLink[language]}. | |
</p> | |
</div> | |
</div> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment