Skip to content

Instantly share code, notes, and snippets.

@agallio
Created January 18, 2025 06:01
Show Gist options
  • Save agallio/a460ba9ca92144908e9c3c8ea56a756e to your computer and use it in GitHub Desktop.
Save agallio/a460ba9ca92144908e9c3c8ea56a756e to your computer and use it in GitHub Desktop.
Apple Secret Generator component from agallio.xyz
// 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