mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-08 12:27:44 +00:00
docs(feat): added apple sign in JWT generation in docs (#2453)
* docs: Add guide for Sign In with Apple * docs-feat: add apple JWT generator * fix-lint: ran lint:fix to fix CI test * chore: refactor to remove jose * update docs * chore: lock file * fix test --------- Co-authored-by: Bereket Engida <Bekacru@gmail.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
|||||||
import { contents } from "@/components/sidebar-content";
|
import { contents } from "@/components/sidebar-content";
|
||||||
import { Endpoint } from "@/components/endpoint";
|
import { Endpoint } from "@/components/endpoint";
|
||||||
import { DividerText } from "@/components/divider-text";
|
import { DividerText } from "@/components/divider-text";
|
||||||
|
import { GenerateAppleJwt } from "@/components/generate-apple-jwt";
|
||||||
|
|
||||||
const { AutoTypeTable } = createTypeTable();
|
const { AutoTypeTable } = createTypeTable();
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export default async function Page({
|
|||||||
Tabs,
|
Tabs,
|
||||||
AutoTypeTable,
|
AutoTypeTable,
|
||||||
GenerateSecret,
|
GenerateSecret,
|
||||||
|
GenerateAppleJwt,
|
||||||
AnimatePresence,
|
AnimatePresence,
|
||||||
TypeTable,
|
TypeTable,
|
||||||
Features,
|
Features,
|
||||||
|
|||||||
232
docs/components/generate-apple-jwt.tsx
Normal file
232
docs/components/generate-apple-jwt.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { KJUR } from "jsrsasign";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
// Zod schema for validation
|
||||||
|
const appleJwtSchema = z.object({
|
||||||
|
teamId: z.string().min(1, { message: "Team ID is required." }),
|
||||||
|
clientId: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Client ID (Service ID) is required." }),
|
||||||
|
keyId: z.string().min(1, { message: "Key ID is required." }),
|
||||||
|
privateKey: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Private Key content is required." })
|
||||||
|
.refine(
|
||||||
|
(key) => key.startsWith("-----BEGIN PRIVATE KEY-----"),
|
||||||
|
"Private key must be in PKCS#8 PEM format (starting with -----BEGIN PRIVATE KEY-----)",
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(key) => key.includes("-----END PRIVATE KEY-----"),
|
||||||
|
"Private key must be in PKCS#8 PEM format (ending with -----END PRIVATE KEY-----)",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AppleJwtFormValues = z.infer<typeof appleJwtSchema>;
|
||||||
|
|
||||||
|
export const GenerateAppleJwt = () => {
|
||||||
|
const [generatedJwt, setGeneratedJwt] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<AppleJwtFormValues>({
|
||||||
|
resolver: zodResolver(appleJwtSchema),
|
||||||
|
defaultValues: {
|
||||||
|
teamId: "",
|
||||||
|
clientId: "",
|
||||||
|
keyId: "",
|
||||||
|
privateKey: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: AppleJwtFormValues) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setGeneratedJwt(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
//normalize the private key by replacing \r\n with \n and trimming whitespace just incase lol
|
||||||
|
const normalizedKey = data.privateKey.replace(/\r\n/g, "\n").trim();
|
||||||
|
|
||||||
|
//since jose is not working with safari, we are using jsrsasign
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
alg: "ES256",
|
||||||
|
kid: data.keyId,
|
||||||
|
typ: "JWT",
|
||||||
|
};
|
||||||
|
|
||||||
|
const issuedAtSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const expirationSeconds = issuedAtSeconds + 180 * 24 * 60 * 60; // 180 days. Should we let the user choose this ? MAX is 6 months
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
iss: data.teamId, // Issuer (Team ID)
|
||||||
|
aud: "https://appleid.apple.com", // Audience
|
||||||
|
sub: data.clientId, // Subject (Client ID -> Service ID)
|
||||||
|
iat: issuedAtSeconds, // Issued At timestamp
|
||||||
|
exp: expirationSeconds, // Expiration timestamp
|
||||||
|
};
|
||||||
|
|
||||||
|
const sHeader = JSON.stringify(header);
|
||||||
|
const sPayload = JSON.stringify(payload);
|
||||||
|
|
||||||
|
const jwt = KJUR.jws.JWS.sign("ES256", sHeader, sPayload, normalizedKey);
|
||||||
|
setGeneratedJwt(jwt);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("JWT Generation Error:", err);
|
||||||
|
setError(
|
||||||
|
`Failed to generate JWT: ${
|
||||||
|
err.message || "Unknown error"
|
||||||
|
}. Check key format and details.`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
if (generatedJwt) {
|
||||||
|
navigator.clipboard.writeText(generatedJwt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4 space-y-6">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="teamId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Apple Team ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g., A1B2C3D4E5" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="clientId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Client ID (Service ID)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g., com.yourdomain.app" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The identifier for the service you created in Apple Developer.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keyId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Key ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g., F6G7H8I9J0" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The ID associated with your private key (.p8 file).
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="privateKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Private Key Content (.p8 file content)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||||
|
className="min-h-[150px] font-mono text-sm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Paste the entire content of your .p8 private key file here.
|
||||||
|
Ensure it's in PKCS#8 format.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "Generating..." : "Generate Apple Client Secret (JWT)"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 rounded-md border border-red-400 bg-red-50 p-3 text-red-700">
|
||||||
|
<p className="font-semibold">Error:</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{generatedJwt && (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Generated Client Secret:</h3>
|
||||||
|
<div className="relative rounded-md bg-muted p-4 font-mono text-sm">
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
<code>{generatedJwt}</code>
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-2 h-7 w-7"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{/* I used gpt for this lol. Should we change to another icon or is this ok ? */}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="lucide lucide-copy"
|
||||||
|
>
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This is the client secret (JWT) required for 'Sign in with Apple'.
|
||||||
|
It expires in 180 days.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -54,10 +54,9 @@ description: Apple provider setup and usage.
|
|||||||
6. **Generate the Client Secret (JWT):**
|
6. **Generate the Client Secret (JWT):**
|
||||||
Apple requires a JSON Web Token (JWT) to be generated dynamically using the downloaded `.p8` key, the Key ID, and your Team ID. This JWT serves as your `clientSecret`.
|
Apple requires a JSON Web Token (JWT) to be generated dynamically using the downloaded `.p8` key, the Key ID, and your Team ID. This JWT serves as your `clientSecret`.
|
||||||
|
|
||||||
You can use the guide below from Apple's documentation to understand how to generate this client secret:
|
You can use the guide below from [Apple's documentation](https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret) to understand how to generate this client secret. You can also use our built in generator [below](#generate-apple-client-secret-jwt) to generate the client secret JWT required for 'Sign in with Apple'.
|
||||||
<Link href="https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret">
|
|
||||||
Creating a client secret
|
|
||||||
</Link>
|
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
### Configure the provider
|
### Configure the provider
|
||||||
@@ -126,3 +125,7 @@ await authClient.signIn.social({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Generate Apple Client Secret (JWT)
|
||||||
|
|
||||||
|
<GenerateAppleJwt />
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"fumadocs-ui": "15.0.15",
|
"fumadocs-ui": "15.0.15",
|
||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
"jotai": "^2.12.1",
|
"jotai": "^2.12.1",
|
||||||
|
"jsrsasign": "^11.1.0",
|
||||||
"lucide-react": "^0.477.0",
|
"lucide-react": "^0.477.0",
|
||||||
"motion": "^12.4.10",
|
"motion": "^12.4.10",
|
||||||
"next": "15.2.3",
|
"next": "15.2.3",
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.9",
|
"@tailwindcss/postcss": "^4.0.9",
|
||||||
|
"@types/jsrsasign": "^10.5.15",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "22.13.8",
|
"@types/node": "22.13.8",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const otp2fa = (options?: OTPOptions) => {
|
|||||||
const hashedCode = await storeOTP(ctx, code);
|
const hashedCode = await storeOTP(ctx, code);
|
||||||
await ctx.context.internalAdapter.createVerificationValue(
|
await ctx.context.internalAdapter.createVerificationValue(
|
||||||
{
|
{
|
||||||
value: `${hashedCode}!0`,
|
value: `${hashedCode}:0`,
|
||||||
identifier: `2fa-otp-${key}`,
|
identifier: `2fa-otp-${key}`,
|
||||||
expiresAt: new Date(Date.now() + opts.period),
|
expiresAt: new Date(Date.now() + opts.period),
|
||||||
},
|
},
|
||||||
@@ -311,7 +311,7 @@ export const otp2fa = (options?: OTPOptions) => {
|
|||||||
await ctx.context.internalAdapter.findVerificationValue(
|
await ctx.context.internalAdapter.findVerificationValue(
|
||||||
`2fa-otp-${key}`,
|
`2fa-otp-${key}`,
|
||||||
);
|
);
|
||||||
const [otp, counter] = toCheckOtp?.value?.split("!") ?? [];
|
const [otp, counter] = toCheckOtp?.value?.split(":") ?? [];
|
||||||
const decryptedOtp = await decryptOTP(ctx, otp);
|
const decryptedOtp = await decryptOTP(ctx, otp);
|
||||||
if (!toCheckOtp || toCheckOtp.expiresAt < new Date()) {
|
if (!toCheckOtp || toCheckOtp.expiresAt < new Date()) {
|
||||||
if (toCheckOtp) {
|
if (toCheckOtp) {
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -513,6 +513,9 @@ importers:
|
|||||||
jotai:
|
jotai:
|
||||||
specifier: ^2.12.1
|
specifier: ^2.12.1
|
||||||
version: 2.12.5(@types/react@19.1.6)(react@19.1.0)
|
version: 2.12.5(@types/react@19.1.6)(react@19.1.0)
|
||||||
|
jsrsasign:
|
||||||
|
specifier: ^11.1.0
|
||||||
|
version: 11.1.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.477.0
|
specifier: ^0.477.0
|
||||||
version: 0.477.0(react@19.1.0)
|
version: 0.477.0(react@19.1.0)
|
||||||
@@ -571,6 +574,9 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.0.9
|
specifier: ^4.0.9
|
||||||
version: 4.1.8
|
version: 4.1.8
|
||||||
|
'@types/jsrsasign':
|
||||||
|
specifier: ^10.5.15
|
||||||
|
version: 10.5.15
|
||||||
'@types/mdx':
|
'@types/mdx':
|
||||||
specifier: ^2.0.13
|
specifier: ^2.0.13
|
||||||
version: 2.0.13
|
version: 2.0.13
|
||||||
@@ -8784,6 +8790,9 @@ packages:
|
|||||||
'@types/jsonfile@6.1.4':
|
'@types/jsonfile@6.1.4':
|
||||||
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
|
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
|
||||||
|
|
||||||
|
'@types/jsrsasign@10.5.15':
|
||||||
|
resolution: {integrity: sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==}
|
||||||
|
|
||||||
'@types/leaflet@1.7.6':
|
'@types/leaflet@1.7.6':
|
||||||
resolution: {integrity: sha512-Emkz3V08QnlelSbpT46OEAx+TBZYTOX2r1yM7W+hWg5+djHtQ1GbEXBDRLaqQDOYcDI51Ss0ayoqoKD4CtLUDA==}
|
resolution: {integrity: sha512-Emkz3V08QnlelSbpT46OEAx+TBZYTOX2r1yM7W+hWg5+djHtQ1GbEXBDRLaqQDOYcDI51Ss0ayoqoKD4CtLUDA==}
|
||||||
|
|
||||||
@@ -14088,6 +14097,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
||||||
engines: {node: '>=12', npm: '>=6'}
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
|
jsrsasign@11.1.0:
|
||||||
|
resolution: {integrity: sha512-Ov74K9GihaK9/9WncTe1mPmvrO7Py665TUfUKvraXBpu+xcTWitrtuOwcjf4KMU9maPaYn0OuaWy0HOzy/GBXg==}
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -29785,6 +29797,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.17.57
|
'@types/node': 20.17.57
|
||||||
|
|
||||||
|
'@types/jsrsasign@10.5.15': {}
|
||||||
|
|
||||||
'@types/leaflet@1.7.6':
|
'@types/leaflet@1.7.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/geojson': 7946.0.16
|
'@types/geojson': 7946.0.16
|
||||||
@@ -36768,6 +36782,8 @@ snapshots:
|
|||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
semver: 7.7.2
|
semver: 7.7.2
|
||||||
|
|
||||||
|
jsrsasign@11.1.0: {}
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-includes: 3.1.8
|
array-includes: 3.1.8
|
||||||
|
|||||||
Reference in New Issue
Block a user