Skip to content

Commit

Permalink
feat(add): Add Apple Secret generation (#10)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
serhiyhvala and balazsorban44 authored Oct 7, 2024
1 parent 6b3ce7b commit 04e7dc1
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 32 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,6 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*


*.p8
98 changes: 66 additions & 32 deletions commands/add.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,33 @@
import * as y from "yoctocolors"
import open from "open"
import clipboard from "clipboardy"
import { select, input, password } from "@inquirer/prompts"
import { select, input, password, number } from "@inquirer/prompts"
import { requireFramework } from "../lib/detect.js"
import { updateEnvFile } from "../lib/write-env.js"
import { providers, frameworks } from "../lib/meta.js"
import { secret } from "./index.js"
import { link, markdownToAnsi } from "../lib/markdown.js"
import { appleGenSecret } from "../lib/apple-gen-secret.js"

/**
* @param {string} label
* @param {string} [defaultValue]
*/
async function promptInput(label, defaultValue) {
return input({
message: `Paste ${y.magenta(label)}:`,
validate: (value) => !!value,
default: defaultValue,
})
}

/** @param {string} label */
async function promptPassword(label) {
return password({
message: `Paste ${y.magenta(label)}:`,
mask: true,
validate: (value) => !!value,
})
}

const choices = Object.entries(providers)
.filter(([, { setupUrl }]) => !!setupUrl)
Expand All @@ -17,19 +38,19 @@ const choices = Object.entries(providers)
/** @param {string | undefined} providerId */
export async function action(providerId) {
try {
if (!providerId) {
providerId = await select({
const pid =
providerId ??
(await select({
message: "What provider do you want to set up?",
choices: choices,
})
}
}))

const provider = providers[providerId]
const provider = providers[pid]
if (!provider?.setupUrl) {
console.error(
y.red(
`Missing instructions for ${
provider?.name ?? providerId
provider?.name ?? pid
}.\nInstructions are available for: ${y.bold(
choices.map((choice) => choice.name).join(", ")
)}`
Expand Down Expand Up @@ -78,35 +99,48 @@ ${y.bold("Callback URL (copied to clipboard)")}: ${url}`

await open(provider.setupUrl)

const clientId = await input({
message: `Paste ${y.magenta("Client ID")}:`,
validate: (value) => !!value,
})
const clientSecret = await password({
message: `Paste ${y.magenta("Client secret")}:`,
mask: true,
validate: (value) => !!value,
})
if (providerId === "apple") {
const clientId = await promptInput("Client ID")
const keyId = await promptInput("Key ID")
const teamId = await promptInput("Team ID")
const privateKey = await input({
message: "Path to Private Key",
validate: (value) => !!value,
default: "./private-key.p8",
})

console.log(y.dim(`Updating environment variable file...`))
const expiresInDays =
(await number({
message: "Expires in days (default: 180)",
required: false,
default: 180,
})) ?? 180

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

await updateEnvFile({
[`${varPrefix}_ID`]: clientId,
[`${varPrefix}_SECRET`]: clientSecret,
})
await updateEnvFile({ AUTH_APPLE_ID: clientId })

console.log(
y.dim(
`\nEnsuring that ${link(
"AUTH_SECRET",
"https://authjs.dev/getting-started/installation#setup-environment"
)} is set...`
)
)
const secret = await appleGenSecret({
teamId,
clientId,
keyId,
privateKey,
expiresInDays,
})

await updateEnvFile({ AUTH_APPLE_SECRET: secret })
} else {
const clientId = await promptInput("Client ID")
const clientSecret = await promptPassword("Client Secret")

await secret.action({})
console.log(y.dim("Updating environment variable file..."))

const varPrefix = `AUTH_${pid.toUpperCase()}`
await updateEnvFile({
[`${varPrefix}_ID`]: clientId,
[`${varPrefix}_SECRET`]: clientSecret,
})
}

console.log("\n🎉 Done! You can now use this provider in your app.")
} catch (error) {
Expand Down
101 changes: 101 additions & 0 deletions lib/apple-gen-secret.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import fs from "node:fs"
import path from "node:path"
import * as y from "yoctocolors"

/**
* Generates an Apple client secret.
*
* @param {object} options
* @param {string} options.teamId - Apple Team ID.
* @param {string} options.clientId - Apple Client ID.
* @param {string} options.keyId - Apple Key ID.
* @param {string} options.privateKey - Apple Private Key.
* @param {number} options.expiresInDays - Days until the secret expires.
*
* @see https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
*/
export async function appleGenSecret({
teamId: iss,
clientId: sub,
keyId: kid,
privateKey,
expiresInDays,
}) {
const expiresIn = 86400 * expiresInDays
const exp = Math.ceil(Date.now() / 1000) + expiresIn

const secret = await signJWT(sub, iss, kid, privateKey, exp)

console.log(
y.green(
`Apple client secret generated. Valid until: ${new Date(exp * 1000)}`
)
)

return secret
}

/**
*
* @param {string} sub - Apple client ID.
* @param {string} iss - Apple team ID.
* @param {string} kid - Apple key ID.
* @param {string} privateKeyPath - Apple private key.
* @param {Date} exp - Expiry date.
*/
async function signJWT(sub, iss, kid, privateKeyPath, exp) {
const header = { alg: "ES256", kid }

const payload = {
iss,
iat: Date.now() / 1000,
exp,
aud: "https://appleid.apple.com",
sub,
}

const parts = [
toBase64Url(encoder.encode(JSON.stringify(header))),
toBase64Url(encoder.encode(JSON.stringify(payload))),
]

const privateKey = fs.readFileSync(path.resolve(privateKeyPath), "utf8")

const signature = await sign(parts.join("."), privateKey)

parts.push(toBase64Url(signature))
return parts.join(".")
}

const encoder = new TextEncoder()
function toBase64Url(data) {
return btoa(String.fromCharCode(...new Uint8Array(data)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
}

async function sign(data, private_key) {
const pem = private_key.replace(
/-----BEGIN PRIVATE KEY-----|\n|-----END PRIVATE KEY-----/g,
""
)
const binaryDerString = atob(pem)
const binaryDer = new Uint8Array(
[...binaryDerString].map((char) => char.charCodeAt(0))
)

const privateKey = await globalThis.crypto.subtle.importKey(
"pkcs8",
binaryDer.buffer,
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign"]
)

return await globalThis.crypto.subtle.sign(
{ name: "ECDSA", hash: { name: "SHA-256" } },
privateKey,
encoder.encode(data)
)
}

0 comments on commit 04e7dc1

Please sign in to comment.