diff --git a/scripts/bootstrap.mjs b/scripts/bootstrap.mjs index 3f289f0..526e8ad 100644 --- a/scripts/bootstrap.mjs +++ b/scripts/bootstrap.mjs @@ -2,6 +2,8 @@ import { applyDashboardClientChanges, applyMyAccountClientGrantChanges, + DEFAULT_CLIENT_NAME, + setClientName, } from "./utils/clients.mjs" import { applyDatabaseConnectionChanges } from "./utils/connections.mjs" import { @@ -11,7 +13,7 @@ import { } from "./utils/discovery.mjs" import { writeAuth0Plist } from "./utils/plist-writer.mjs" import { writeInfoPlistUrlScheme } from "./utils/info-plist-writer.mjs" -import { confirmWithUser } from "./utils/helpers.mjs" +import { confirmWithUser, promptWithUser } from "./utils/helpers.mjs" import { getManualActions } from "./utils/manual-actions.mjs" import { applyMyAccountResourceServerChanges, @@ -47,13 +49,29 @@ async function main() { const args = process.argv.slice(2) if (args.includes("--help") || args.includes("-h")) { - console.log("Usage: npm run auth0:bootstrap [--yes]") + console.log( + "Usage: npm run auth0:bootstrap [--client-name=] [--yes]" + ) console.log("\nArguments:") console.log( " tenant-domain Required. The Auth0 tenant domain to configure." ) console.log(" Must match your Auth0 CLI active tenant.") console.log("\nOptions:") + console.log(" --client-name=") + console.log( + " Name of the native Auth0 application. Interactive runs" + ) + console.log( + ` are prompted for it; the default is "${DEFAULT_CLIENT_NAME}".` + ) + console.log(" Env: AUTH0_CLIENT_NAME") + console.log( + " The name is the lookup key on re-runs: reusing a name" + ) + console.log( + " updates that application, a new name creates a new one." + ) console.log( " --yes, -y Skip the confirmation prompt and apply changes." ) @@ -125,6 +143,13 @@ async function main() { const scheme = iosConfig.bundleIdentifier iosConfig.scheme = scheme + // Resolve the native application's name before discovery: it is the key used + // to find an existing app (exact name + app_type "native"), so the change plan + // depends on it. Held in module state via setClientName so every check/apply + // reads the same name. + setClientName(await resolveClientName(flags)) + console.log("") + // Step 2: Discovery step("๐Ÿ”", "Resource Discovery") const resources = await discoverExistingResources(domain) @@ -317,6 +342,80 @@ function reportManualActions() { ) } +/** + * Reject client names the Management API would reject, before spending a + * round-trip on them. Auth0 requires a non-empty name without `<` or `>`. + * + * @param {string} name - Candidate client name (already trimmed) + * @returns {string | null} An error message, or null when the name is valid + */ +function validateClientName(name) { + if (!name) return "Name cannot be empty." + if (/[<>]/.test(name)) return "Name cannot contain < or >." + return null +} + +/** + * Decide what to name the native Auth0 application for this run. + * + * Precedence: `--client-name=` โ†’ `AUTH0_CLIENT_NAME` โ†’ interactive prompt + * (defaulting to DEFAULT_CLIENT_NAME) โ†’ the default itself when stdin is not a + * TTY (headless/CI), so a non-interactive run never hangs on input. + * + * The interactive path loops until the user accepts a valid name, so a typo does + * not silently create a stray application in the tenant. + * + * @param {string[]} flags - CLI flags from argv + * @returns {Promise} The client name to use + */ +async function resolveClientName(flags) { + const prefix = "--client-name=" + const flagValue = flags + .find((f) => f.startsWith(prefix)) + ?.slice(prefix.length) + .trim() + const preset = flagValue || process.env.AUTH0_CLIENT_NAME?.trim() + + if (preset) { + const error = validateClientName(preset) + if (error) { + throw new Error( + `Invalid client name "${preset}" (--client-name / AUTH0_CLIENT_NAME): ${error}` + ) + } + console.log(`โœ… Native application name: ${preset}`) + return preset + } + + // No TTY means there is nobody to answer the prompt, so take the default + // rather than looping on empty input. + if (!process.stdin.isTTY) { + console.log(`โœ… Native application name: ${DEFAULT_CLIENT_NAME} (default)`) + return DEFAULT_CLIENT_NAME + } + + console.log( + "\n๐Ÿท๏ธ Name of the native Auth0 application to create or update.\n" + + " Reusing a name updates that application; a new name creates a new one.\n" + + " Press Enter to accept the default." + ) + + for (;;) { + const answer = await promptWithUser(" Application name", DEFAULT_CLIENT_NAME) + + const error = validateClientName(answer) + if (error) { + console.log(` โš ๏ธ ${error}`) + continue + } + + if (await confirmWithUser(` Use "${answer}" as the application name?`)) { + return answer + } + console.log(" No problem โ€” enter a different name.") + } +} + // Run the main function main().catch((error) => { console.error("\nโŒ Bootstrap failed:", error.message) diff --git a/scripts/utils/clients.mjs b/scripts/utils/clients.mjs index 02696bf..8793145 100644 --- a/scripts/utils/clients.mjs +++ b/scripts/utils/clients.mjs @@ -9,8 +9,35 @@ import { recordManualAction, } from "./manual-actions.mjs" -// Constants -export const CLIENT_NAME = "iOS UI Components Demo" +// The default name for the native Auth0 application. The name actually used for +// a run is resolved once at startup (CLI flag / env / interactive prompt) and +// stored in module state via setClientName. The name is the lookup key โ€” re-runs +// find the app by exact name + app_type "native" โ€” so every check and apply +// below reads it through getClientName() rather than a hardcoded constant. +export const DEFAULT_CLIENT_NAME = "iOS UI Components Demo" + +let _clientName = DEFAULT_CLIENT_NAME + +/** + * Set the native application's name for this run. Called once from bootstrap + * before discovery, because the change plan (find-or-create the app by exact + * name match) depends on it. + * + * @param {string} name - The resolved application name + */ +export function setClientName(name) { + _clientName = name +} + +/** + * The native application's name for this run (defaults to DEFAULT_CLIENT_NAME + * until setClientName is called). + * + * @returns {string} + */ +export function getClientName() { + return _clientName +} /** * Build the allowed callback / logout URLs for the native iOS client. @@ -81,6 +108,7 @@ export async function checkDashboardClientChanges( myAccountApiScopes ) { const { bundleIdentifier } = iosConfig + const clientName = getClientName() // Auth0.swift's WebAuthentication builds its redirect URL from the bundle // identifier, not an arbitrary scheme. The two supported forms are the @@ -89,14 +117,16 @@ export async function checkDashboardClientChanges( // whether or not associated domains are configured. const redirectUrls = buildRedirectUrls(domain, bundleIdentifier) + // Re-runs find the app by exact name + app_type "native": the same name + // updates it, a different name creates a second application (ยง3.7). const existingClient = existingClients.find( - (c) => c.name === CLIENT_NAME && c.app_type === "native" + (c) => c.name === clientName && c.app_type === "native" ) if (!existingClient) { return createChangeItem(ChangeAction.CREATE, { resource: "Native Client", - name: CLIENT_NAME, + name: clientName, redirectUrls, }) } @@ -157,7 +187,7 @@ export async function checkDashboardClientChanges( return createChangeItem(ChangeAction.UPDATE, { resource: "Native Client", - name: CLIENT_NAME, + name: clientName, existing: existingClient, redirectUrls, updates, @@ -167,7 +197,7 @@ export async function checkDashboardClientChanges( return createChangeItem(ChangeAction.SKIP, { resource: "Native Client", - name: CLIENT_NAME, + name: clientName, existing: existingClient, }) } @@ -181,6 +211,8 @@ export async function applyDashboardClientChanges( domain, myAccountApiScopes ) { + const clientName = getClientName() + if (changePlan.action === ChangeAction.SKIP) { const spinner = ora({ text: `Native Client is up to date: ${changePlan.name}`, @@ -191,12 +223,12 @@ export async function applyDashboardClientChanges( if (changePlan.action === ChangeAction.CREATE) { const spinner = ora({ - text: `Creating Native Client: ${CLIENT_NAME}`, + text: `Creating Native Client: ${clientName}`, }).start() try { const clientData = { - name: CLIENT_NAME, + name: clientName, description: "Native client for Auth0 iOS UI Components sample app", app_type: "native", @@ -235,7 +267,7 @@ export async function applyDashboardClientChanges( const { stdout } = await $`auth0 ${createClientArgs}` const client = JSON.parse(stdout) - spinner.succeed(`Created Native Client: ${CLIENT_NAME}`) + spinner.succeed(`Created Native Client: ${clientName}`) return client } catch (e) { // The native client is the anchor for everything downstream (client @@ -246,7 +278,7 @@ export async function applyDashboardClientChanges( const scope = extractMissingScope(e) || "create:clients" spinner.fail(`Cannot create Native Client โ€” M2M app lacks scope: ${scope}`) recordManualAction({ - resource: `Native Client: ${CLIENT_NAME}`, + resource: `Native Client: ${clientName}`, scope, reason: "The native client is required for the sample app to authenticate; the rest of the bootstrap depends on it.", @@ -262,7 +294,7 @@ export async function applyDashboardClientChanges( if (changePlan.action === ChangeAction.UPDATE) { const spinner = ora({ - text: `Updating Native Client: ${CLIENT_NAME}`, + text: `Updating Native Client: ${clientName}`, }).start() try { @@ -326,7 +358,7 @@ export async function applyDashboardClientChanges( const { stdout } = await $`auth0 ${getArgs}` const client = JSON.parse(stdout) - spinner.succeed(`Updated Native Client: ${CLIENT_NAME}`) + spinner.succeed(`Updated Native Client: ${clientName}`) return client } catch (e) { // A missing scope (e.g. update:clients on the M2M app) should not abort @@ -338,7 +370,7 @@ export async function applyDashboardClientChanges( `Skipped updating Native Client โ€” M2M app lacks scope: ${scope}` ) recordManualAction({ - resource: `Native Client: ${CLIENT_NAME}`, + resource: `Native Client: ${clientName}`, scope, reason: "The native client's callback/logout URLs (and My Account refresh-token policy) must be set for the app's login/logout redirects to resolve.", @@ -406,6 +438,8 @@ export async function applyMyAccountClientGrantChanges( domain, clientId ) { + const clientName = getClientName() + if (changePlan.action === ChangeAction.SKIP) { const spinner = ora({ text: `My Account API Client Grant is up to date`, @@ -416,7 +450,7 @@ export async function applyMyAccountClientGrantChanges( if (changePlan.action === ChangeAction.CREATE) { const spinner = ora({ - text: `Creating ${CLIENT_NAME} client grants for My Account API`, + text: `Creating ${clientName} client grants for My Account API`, }).start() try { @@ -435,7 +469,7 @@ export async function applyMyAccountClientGrantChanges( spinner.succeed(`Created My Account API Client Grant`) } catch (e) { spinner.fail( - `Failed to create the ${CLIENT_NAME} client grants for My Account API` + `Failed to create the ${clientName} client grants for My Account API` ) throw e } @@ -464,5 +498,3 @@ export async function applyMyAccountClientGrantChanges( } } } - - diff --git a/scripts/utils/connections.mjs b/scripts/utils/connections.mjs index 90f5287..d62c7db 100644 --- a/scripts/utils/connections.mjs +++ b/scripts/utils/connections.mjs @@ -28,11 +28,87 @@ const PASSKEY_CONNECTION_OPTIONS = { }, } + +/** + * Read every client enabled on a connection, following the opaque checkpoint + * cursor (`next` is omitted on the last page). + * + * @param {string} connectionId + * @returns {Promise|null>} The + * enabled-client entries, or `null` when the read is unauthorized (missing + * scope). `null` means "unknown" โ€” NOT "none enabled" โ€” so callers must not + * treat it as an empty list. + */ +async function getConnectionEnabledClients(connectionId) { + const all = [] + let from + + // Bounded loop: a safety cap so a malformed/looping cursor can never hang. + for (let page = 0; page < 100; page++) { + const params = new URLSearchParams({ take: "100" }) + if (from) params.set("from", from) + + const result = await auth0ApiCall( + "get", + `connections/${connectionId}/clients?${params}` + ) + + // Missing scope โ†’ unknown, not empty. + if (result === null) return null + + // Newer API returns { clients, next }; tolerate a bare array too. + if (Array.isArray(result)) { + all.push(...result) + break + } + all.push(...(result.clients || [])) + from = result.next + if (!from) break + } + + return all +} + +/** + * Enable a client on a connection via the additive connections/{id}/clients + * sub-resource, then verify by re-reading (the PATCH returns 204 with no body, + * so there is nothing to inspect). Additive: other enabled clients keep their + * state. Records a manual action and returns false when the change cannot be + * confirmed rather than reporting a false success. + * + * @param {string} connectionId + * @param {string} clientId + * @returns {Promise} Whether the client is confirmed enabled + */ +async function ensureClientEnabledOnConnection(connectionId, clientId) { + await auth0ApiCall("patch", `connections/${connectionId}/clients`, [ + { client_id: clientId, status: true }, + ]) + + const enabledClients = await getConnectionEnabledClients(connectionId) + const applied = + enabledClients !== null && + enabledClients.some((c) => c.client_id === clientId && c.status !== false) + + if (!applied) { + recordManualAction({ + resource: `Connection: ${DEFAULT_CONNECTION_NAME} (enable app)`, + scope: "update:connections", + reason: + "The native app must be an enabled client of this database connection for username/password login to work.", + manualStep: + "Dashboard โ†’ Authentication โ†’ Database โ†’ Applications โ†’ enable the app, OR grant update:connections and re-run.", + }) + } + + return applied +} + // ============================================================================ // CHECK FUNCTIONS // ============================================================================ -export function checkDatabaseConnectionChanges( +export async function checkDatabaseConnectionChanges( existingConnections, dashboardClientId ) { @@ -40,21 +116,33 @@ export function checkDatabaseConnectionChanges( (c) => c.name === DEFAULT_CONNECTION_NAME ) - const desiredEnabledClients = [dashboardClientId] - if (!existing) { return createChangeItem(ChangeAction.CREATE, { resource: "Database Connection", name: DEFAULT_CONNECTION_NAME, - enabledClients: desiredEnabledClients, + enabledClients: [dashboardClientId], }) } - // Check if we need to add any missing enabled clients - const existingEnabledClients = existing.enabled_clients || [] - const missingClients = desiredEnabledClients.filter( - (clientId) => !existingEnabledClients.includes(clientId) - ) + // Whether the native app is enabled on the connection is read from the + // connections/{id}/clients sub-resource, NOT the deprecated `enabled_clients` + // field (see ยง3.2). When the native client does not exist yet the id is the + // "TO_BE_CREATED" placeholder, so its enablement verdict here is meaningless: + // force the connection into the apply path and let apply re-resolve it with + // the real client id (ยง3.3). + let clientEnabled + if (dashboardClientId === "TO_BE_CREATED") { + clientEnabled = false + } else { + const enabledClients = await getConnectionEnabledClients(existing.id) + // `null` means "unknown" (missing scope), not "none enabled" โ€” don't plan a + // change we can neither justify nor verify. + clientEnabled = + enabledClients === null || + enabledClients.some( + (c) => c.client_id === dashboardClientId && c.status !== false + ) + } // Check whether passkeys are enabled on the connection. If not, the sample // app's Passkeys UI component has nothing to surface in Universal Login. @@ -62,8 +150,8 @@ export function checkDatabaseConnectionChanges( existing.options?.authentication_methods?.passkey?.enabled === true const changes = [] - if (missingClients.length > 0) { - changes.push(`Add ${missingClients.length} enabled client(s)`) + if (!clientEnabled) { + changes.push("Enable native app on connection") } if (!passkeyEnabled) { changes.push("Enable passkey authentication method") @@ -75,7 +163,7 @@ export function checkDatabaseConnectionChanges( name: DEFAULT_CONNECTION_NAME, existing, updates: { - missingClients, + enableClient: !clientEnabled, enablePasskey: !passkeyEnabled, }, summary: changes.join(", "), @@ -111,11 +199,13 @@ export async function applyDatabaseConnectionChanges( }).start() try { + // `enabled_clients` in the body is deprecated and rejected by the API + // (400 invalid_body). Create the connection, then enable the app through + // the connections/{id}/clients sub-resource below (ยง3.2). const connectionData = { strategy: "auth0", name: DEFAULT_CONNECTION_NAME, display_name: "Universal-Components", - enabled_clients: [dashboardClientId], options: PASSKEY_CONNECTION_OPTIONS, } @@ -131,6 +221,11 @@ export async function applyDatabaseConnectionChanges( const connection = JSON.parse(stdout) spinner.succeed(`Created Database Connection: ${DEFAULT_CONNECTION_NAME}`) + + // Enable the native app on the freshly created connection. A failure here + // is recorded as a manual action (not fatal) โ€” the connection exists. + await ensureClientEnabledOnConnection(connection.id, dashboardClientId) + return connection } catch (e) { spinner.fail(`Failed to create Database Connection`) @@ -145,81 +240,67 @@ export async function applyDatabaseConnectionChanges( try { const { existing, updates } = changePlan - const existingEnabledClients = existing.enabled_clients || [] - - // Use the actual client IDs instead of the ones from the change plan - const clientsToAdd = [] - if (!existingEnabledClients.includes(dashboardClientId)) { - clientsToAdd.push(dashboardClientId) - } - - // Build the patch: enable the client and/or turn on the passkey method. - const patchData = {} - if (clientsToAdd.length > 0) { - patchData.enabled_clients = [...existingEnabledClients, ...clientsToAdd] + let clientApplied = true + let passkeyApplied = true + let updated = existing + const applied = [] + + // Enable the native app via the additive sub-resource โ€” never the + // deprecated enabled_clients body field, which the API rejects with 400 + // invalid_body (ยง3.2). A failed enable records its own manual action. + if (updates?.enableClient) { + clientApplied = await ensureClientEnabledOnConnection( + existing.id, + dashboardClientId + ) + if (clientApplied) applied.push("app enabled") } + // Turn on the passkey method by merging into the connection's existing + // options so we don't clobber password policy, attributes, or MFA config. if (updates?.enablePasskey) { - // Merge with existing options so we don't clobber other settings. const existingOptions = existing.options || {} - patchData.options = { - ...existingOptions, - authentication_methods: { - ...(existingOptions.authentication_methods || {}), - password: { enabled: true }, - passkey: { enabled: true }, - }, - passkey_options: { - ...PASSKEY_CONNECTION_OPTIONS.passkey_options, - ...(existingOptions.passkey_options || {}), + const patchData = { + options: { + ...existingOptions, + authentication_methods: { + ...(existingOptions.authentication_methods || {}), + password: { enabled: true }, + passkey: { enabled: true }, + }, + passkey_options: { + ...PASSKEY_CONNECTION_OPTIONS.passkey_options, + ...(existingOptions.passkey_options || {}), + }, }, } - } - if (Object.keys(patchData).length === 0) { - spinner.succeed(`${DEFAULT_CONNECTION_NAME} connection is already up to date`) - return existing + await auth0ApiCall("patch", `connections/${existing.id}`, patchData) + + // auth0ApiCall swallows missing-scope errors (returns null instead of + // throwing), so a "success" here is not proof the change landed. Re-read + // the connection and verify the method actually applied; if not, treat + // it as a manual action rather than reporting a false success. + updated = + (await auth0ApiCall("get", `connections/${existing.id}`)) || existing + passkeyApplied = + updated.options?.authentication_methods?.passkey?.enabled === true + if (passkeyApplied) applied.push("passkey method") } - await auth0ApiCall("patch", `connections/${existing.id}`, patchData) - - // auth0ApiCall swallows missing-scope errors (returns null instead of - // throwing), so a "success" here is not proof the change landed. Re-read - // the connection and verify the intended state actually applied; if not, - // treat it as a manual action rather than reporting a false success. - const updated = - (await auth0ApiCall("get", `connections/${existing.id}`)) || existing - - const clientApplied = - !patchData.enabled_clients || - (updated.enabled_clients || []).includes(dashboardClientId) - const passkeyApplied = - !patchData.options || - updated.options?.authentication_methods?.passkey?.enabled === true - if (clientApplied && passkeyApplied) { - const applied = [] - if (patchData.enabled_clients) applied.push(`${clientsToAdd.length} client(s)`) - if (patchData.options) applied.push("passkey method") spinner.succeed( - `Updated ${DEFAULT_CONNECTION_NAME} connection (${applied.join(", ")})` + `Updated ${DEFAULT_CONNECTION_NAME} connection${applied.length ? ` (${applied.join(", ")})` : "" + }` ) return updated } spinner.warn(`Could not fully update ${DEFAULT_CONNECTION_NAME} connection`) - if (!clientApplied) { - recordManualAction({ - resource: `Connection: ${DEFAULT_CONNECTION_NAME} (enable app)`, - scope: "update:connections", - reason: - "The native app must be an enabled client of this database connection for username/password login to work.", - manualStep: - "Dashboard โ†’ Authentication โ†’ Database โ†’ Applications โ†’ enable the app, OR grant update:connections and re-run.", - }) - } + // The client manual action (if any) was recorded inside + // ensureClientEnabledOnConnection; only the passkey one is handled here. if (!passkeyApplied) { // Writing the connection `options` object (which is where the passkey // authentication method lives) requires update:connections_options โ€” diff --git a/scripts/utils/discovery.mjs b/scripts/utils/discovery.mjs index 7a10b5f..bf4dc13 100644 --- a/scripts/utils/discovery.mjs +++ b/scripts/utils/discovery.mjs @@ -140,7 +140,7 @@ export async function buildChangePlan(resources, domain, iosConfig) { ) // Connection - plan.connection = checkDatabaseConnectionChanges( + plan.connection = await checkDatabaseConnectionChanges( resources.connections, dashboardClientId ) diff --git a/scripts/utils/helpers.mjs b/scripts/utils/helpers.mjs index 6ffea4e..1950665 100644 --- a/scripts/utils/helpers.mjs +++ b/scripts/utils/helpers.mjs @@ -14,3 +14,30 @@ export async function confirmWithUser(message) { return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes" } + +/** + * Prompt for a free-text value, using `defaultValue` when the user just presses + * Enter. In a non-interactive context (stdin is not a TTY, e.g. CI or a piped + * run) there is nobody to answer, so the default is returned immediately rather + * than blocking on input. + * + * @param {string} message - Prompt label (without the trailing default hint) + * @param {string} [defaultValue=""] - Value used on empty input or when no TTY + * @returns {Promise} The trimmed answer, or the default + */ +export async function promptWithUser(message, defaultValue = "") { + if (!process.stdin.isTTY) { + return defaultValue + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + const suffix = defaultValue ? ` [${defaultValue}]` : "" + const answer = await rl.question(`${message}${suffix}: `) + rl.close() + + return answer.trim() || defaultValue +}