Skip to content

Commit 04e7dc1

Browse files
feat(add): Add Apple Secret generation (#10)
* chore: update dependencies * feat: create function to generate apple client secret * feat: update add.js cli command * docs: update apple-gen-secret function * refactor: add.js command * improvements * whoopsie * revert lock file * drop auth secret check * refactor * Update add.js * Update add.js * gitignore .p8 files --------- Co-authored-by: Balázs Orbán <[email protected]>
1 parent 6b3ce7b commit 04e7dc1

File tree

3 files changed

+170
-32
lines changed

3 files changed

+170
-32
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,6 @@ dist
128128
.yarn/build-state.yml
129129
.yarn/install-state.gz
130130
.pnp.*
131+
132+
133+
*.p8

commands/add.js

Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,33 @@
33
import * as y from "yoctocolors"
44
import open from "open"
55
import clipboard from "clipboardy"
6-
import { select, input, password } from "@inquirer/prompts"
6+
import { select, input, password, number } from "@inquirer/prompts"
77
import { requireFramework } from "../lib/detect.js"
88
import { updateEnvFile } from "../lib/write-env.js"
99
import { providers, frameworks } from "../lib/meta.js"
10-
import { secret } from "./index.js"
1110
import { link, markdownToAnsi } from "../lib/markdown.js"
11+
import { appleGenSecret } from "../lib/apple-gen-secret.js"
12+
13+
/**
14+
* @param {string} label
15+
* @param {string} [defaultValue]
16+
*/
17+
async function promptInput(label, defaultValue) {
18+
return input({
19+
message: `Paste ${y.magenta(label)}:`,
20+
validate: (value) => !!value,
21+
default: defaultValue,
22+
})
23+
}
24+
25+
/** @param {string} label */
26+
async function promptPassword(label) {
27+
return password({
28+
message: `Paste ${y.magenta(label)}:`,
29+
mask: true,
30+
validate: (value) => !!value,
31+
})
32+
}
1233

1334
const choices = Object.entries(providers)
1435
.filter(([, { setupUrl }]) => !!setupUrl)
@@ -17,19 +38,19 @@ const choices = Object.entries(providers)
1738
/** @param {string | undefined} providerId */
1839
export async function action(providerId) {
1940
try {
20-
if (!providerId) {
21-
providerId = await select({
41+
const pid =
42+
providerId ??
43+
(await select({
2244
message: "What provider do you want to set up?",
2345
choices: choices,
24-
})
25-
}
46+
}))
2647

27-
const provider = providers[providerId]
48+
const provider = providers[pid]
2849
if (!provider?.setupUrl) {
2950
console.error(
3051
y.red(
3152
`Missing instructions for ${
32-
provider?.name ?? providerId
53+
provider?.name ?? pid
3354
}.\nInstructions are available for: ${y.bold(
3455
choices.map((choice) => choice.name).join(", ")
3556
)}`
@@ -78,35 +99,48 @@ ${y.bold("Callback URL (copied to clipboard)")}: ${url}`
7899

79100
await open(provider.setupUrl)
80101

81-
const clientId = await input({
82-
message: `Paste ${y.magenta("Client ID")}:`,
83-
validate: (value) => !!value,
84-
})
85-
const clientSecret = await password({
86-
message: `Paste ${y.magenta("Client secret")}:`,
87-
mask: true,
88-
validate: (value) => !!value,
89-
})
102+
if (providerId === "apple") {
103+
const clientId = await promptInput("Client ID")
104+
const keyId = await promptInput("Key ID")
105+
const teamId = await promptInput("Team ID")
106+
const privateKey = await input({
107+
message: "Path to Private Key",
108+
validate: (value) => !!value,
109+
default: "./private-key.p8",
110+
})
90111

91-
console.log(y.dim(`Updating environment variable file...`))
112+
const expiresInDays =
113+
(await number({
114+
message: "Expires in days (default: 180)",
115+
required: false,
116+
default: 180,
117+
})) ?? 180
92118

93-
const varPrefix = `AUTH_${providerId.toUpperCase()}`
119+
console.log(y.dim("Updating environment variable file..."))
94120

95-
await updateEnvFile({
96-
[`${varPrefix}_ID`]: clientId,
97-
[`${varPrefix}_SECRET`]: clientSecret,
98-
})
121+
await updateEnvFile({ AUTH_APPLE_ID: clientId })
99122

100-
console.log(
101-
y.dim(
102-
`\nEnsuring that ${link(
103-
"AUTH_SECRET",
104-
"https://authjs.dev/getting-started/installation#setup-environment"
105-
)} is set...`
106-
)
107-
)
123+
const secret = await appleGenSecret({
124+
teamId,
125+
clientId,
126+
keyId,
127+
privateKey,
128+
expiresInDays,
129+
})
130+
131+
await updateEnvFile({ AUTH_APPLE_SECRET: secret })
132+
} else {
133+
const clientId = await promptInput("Client ID")
134+
const clientSecret = await promptPassword("Client Secret")
108135

109-
await secret.action({})
136+
console.log(y.dim("Updating environment variable file..."))
137+
138+
const varPrefix = `AUTH_${pid.toUpperCase()}`
139+
await updateEnvFile({
140+
[`${varPrefix}_ID`]: clientId,
141+
[`${varPrefix}_SECRET`]: clientSecret,
142+
})
143+
}
110144

111145
console.log("\n🎉 Done! You can now use this provider in your app.")
112146
} catch (error) {

lib/apple-gen-secret.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import fs from "node:fs"
2+
import path from "node:path"
3+
import * as y from "yoctocolors"
4+
5+
/**
6+
* Generates an Apple client secret.
7+
*
8+
* @param {object} options
9+
* @param {string} options.teamId - Apple Team ID.
10+
* @param {string} options.clientId - Apple Client ID.
11+
* @param {string} options.keyId - Apple Key ID.
12+
* @param {string} options.privateKey - Apple Private Key.
13+
* @param {number} options.expiresInDays - Days until the secret expires.
14+
*
15+
* @see https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
16+
*/
17+
export async function appleGenSecret({
18+
teamId: iss,
19+
clientId: sub,
20+
keyId: kid,
21+
privateKey,
22+
expiresInDays,
23+
}) {
24+
const expiresIn = 86400 * expiresInDays
25+
const exp = Math.ceil(Date.now() / 1000) + expiresIn
26+
27+
const secret = await signJWT(sub, iss, kid, privateKey, exp)
28+
29+
console.log(
30+
y.green(
31+
`Apple client secret generated. Valid until: ${new Date(exp * 1000)}`
32+
)
33+
)
34+
35+
return secret
36+
}
37+
38+
/**
39+
*
40+
* @param {string} sub - Apple client ID.
41+
* @param {string} iss - Apple team ID.
42+
* @param {string} kid - Apple key ID.
43+
* @param {string} privateKeyPath - Apple private key.
44+
* @param {Date} exp - Expiry date.
45+
*/
46+
async function signJWT(sub, iss, kid, privateKeyPath, exp) {
47+
const header = { alg: "ES256", kid }
48+
49+
const payload = {
50+
iss,
51+
iat: Date.now() / 1000,
52+
exp,
53+
aud: "https://appleid.apple.com",
54+
sub,
55+
}
56+
57+
const parts = [
58+
toBase64Url(encoder.encode(JSON.stringify(header))),
59+
toBase64Url(encoder.encode(JSON.stringify(payload))),
60+
]
61+
62+
const privateKey = fs.readFileSync(path.resolve(privateKeyPath), "utf8")
63+
64+
const signature = await sign(parts.join("."), privateKey)
65+
66+
parts.push(toBase64Url(signature))
67+
return parts.join(".")
68+
}
69+
70+
const encoder = new TextEncoder()
71+
function toBase64Url(data) {
72+
return btoa(String.fromCharCode(...new Uint8Array(data)))
73+
.replace(/\+/g, "-")
74+
.replace(/\//g, "_")
75+
.replace(/=+$/, "")
76+
}
77+
78+
async function sign(data, private_key) {
79+
const pem = private_key.replace(
80+
/-----BEGIN PRIVATE KEY-----|\n|-----END PRIVATE KEY-----/g,
81+
""
82+
)
83+
const binaryDerString = atob(pem)
84+
const binaryDer = new Uint8Array(
85+
[...binaryDerString].map((char) => char.charCodeAt(0))
86+
)
87+
88+
const privateKey = await globalThis.crypto.subtle.importKey(
89+
"pkcs8",
90+
binaryDer.buffer,
91+
{ name: "ECDSA", namedCurve: "P-256" },
92+
true,
93+
["sign"]
94+
)
95+
96+
return await globalThis.crypto.subtle.sign(
97+
{ name: "ECDSA", hash: { name: "SHA-256" } },
98+
privateKey,
99+
encoder.encode(data)
100+
)
101+
}

0 commit comments

Comments
 (0)