diff --git a/app/scripts/.gitignore b/app/scripts/.gitignore new file mode 100644 index 0000000..f7c4136 --- /dev/null +++ b/app/scripts/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +.env.local diff --git a/app/scripts/bootstrap.mjs b/app/scripts/bootstrap.mjs index cd84423..8eea3e1 100644 --- a/app/scripts/bootstrap.mjs +++ b/app/scripts/bootstrap.mjs @@ -11,6 +11,7 @@ import { } from "./utils/discovery.mjs" import { writeStringsFile } from "./utils/strings-writer.mjs" import { confirmWithUser } from "./utils/helpers.mjs" +import { getManualActions } from "./utils/manual-actions.mjs" import { applyConnectionProfileChanges, applyUserAttributeProfileChanges, @@ -24,12 +25,18 @@ import { applyPromptSettingsChanges, applyTenantSettingsChanges, } from "./utils/tenant-config.mjs" +import { applyGuardianFactorChanges } from "./utils/guardian-factors.mjs" import { checkAuth0CLI, checkNodeVersion, + hasMachineCredentials, + printScopeUsageDetails, validateAndroidProject, + validateAuth0Session, + validateMyAccountScopes, validateTenant, } from "./utils/validation.mjs" +import { ChangeAction } from "./utils/change-plan.mjs" // ============================================================================ // Main Bootstrap Flow @@ -42,61 +49,112 @@ async function main() { const args = process.argv.slice(2) if (args.includes("--help") || args.includes("-h")) { - console.log("Usage: npm run auth0:bootstrap ") + console.log("Usage: npm run auth0:bootstrap [--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( + " --yes, -y Skip the confirmation prompt and apply changes." + ) + console.log( + " Auto-enabled when M2M credentials are set and stdin is" + ) + console.log(" not a TTY (headless/CI). Env: AUTH0_BOOTSTRAP_YES=1") console.log("\nExample:") console.log(" npm run auth0:bootstrap my-tenant.us.auth0.com") console.log("\nPrerequisites:") console.log(" 1. Install Auth0 CLI: https://github.com/auth0/auth0-cli") - console.log(" 2. Login to Auth0 CLI: auth0 login") - console.log(" 3. Select your tenant: auth0 tenants use ") + console.log( + "\nNote: The script checks your Auth0 CLI session and, if needed, logs you" + ) + console.log( + " in and switches to the requested tenant automatically." + ) + console.log("\nNon-interactive (standalone / CI) login:") + console.log( + " Set these environment variables for a Management-API M2M app and the" + ) + console.log( + " script authenticates via client credentials — no browser required:" + ) + console.log(" AUTH0_CLIENT_ID Client ID of the M2M application") + console.log(" AUTH0_CLIENT_SECRET Client secret of the M2M application") + console.log( + " AUTH0_DOMAIN Tenant domain (optional; defaults to the arg)" + ) console.log( "\nNote: Tenant name is required as a safety measure to prevent accidentally" ) console.log(" configuring the wrong tenant.") + // Expand the full per-scope rationale for users who want to know why each + // Management API permission is requested at login. + printScopeUsageDetails() process.exit(0) } - const tenantName = args[0] + // Flags: --yes/-y (or AUTH0_BOOTSTRAP_YES) skip the confirm prompt. The first + // non-flag argument is the tenant domain. + const flags = args.filter((a) => a.startsWith("-")) + const tenantName = args.find((a) => !a.startsWith("-")) + const yesFlag = + flags.includes("--yes") || + flags.includes("-y") || + process.env.AUTH0_BOOTSTRAP_YES === "1" + + // Consistent step numbering across the run. TOTAL is the count of top-level + // steps below; bump it if you add/remove one. + const TOTAL_STEPS = 5 + let stepNo = 0 + const step = (emoji, title) => + console.log(`\n${emoji} Step ${++stepNo}/${TOTAL_STEPS}: ${title}`) // Step 1: Validation - console.log("šŸ“‹ Step 1: Pre-flight Checks") + step("šŸ“‹", "Pre-flight Checks") checkNodeVersion() await checkAuth0CLI() + await validateAuth0Session(tenantName) const domain = await validateTenant(tenantName) const androidConfig = validateAndroidProject() - const scheme = "demo" // Default scheme for Android sample app + // Auth0.Android's WebAuthProvider uses the configured scheme (mirrored by the + // auth0Scheme manifest placeholder) for the custom-scheme callback. The sample + // app's MainActivity calls .withScheme("demo"), so the registered redirect and + // the strings.xml value must both be "demo" for the OAuth redirect to reach it. + const scheme = "demo" androidConfig.scheme = scheme - console.log("") // Step 2: Discovery - console.log("šŸ” Step 2: Resource Discovery") + step("šŸ”", "Resource Discovery") const resources = await discoverExistingResources(domain) - console.log("") + validateMyAccountScopes(resources, domain) // Step 3: Build Change Plan - console.log("šŸ“ Step 3: Analyzing Changes") + step("šŸ“", "Analyzing Changes") const plan = await buildChangePlan(resources, domain, androidConfig) console.log("") // Step 4: Display Plan displayChangePlan(plan) - // Check if there are any changes to apply - const hasChanges = - plan.clients.dashboard.action !== "skip" || - plan.clientGrants.myAccount.action !== "skip" || - plan.connection.action !== "skip" || - plan.connectionProfile.action !== "skip" || - plan.userAttributeProfile.action !== "skip" || - plan.resourceServer.action !== "skip" || - plan.roles.admin.action !== "skip" || - plan.tenantConfig.settings.action !== "skip" || - plan.tenantConfig.prompts.action !== "skip" + // Flatten the plan into a single list so change detection and the end-of-run + // summary work off the same source of truth. + const planItems = [ + plan.tenantConfig.settings, + plan.tenantConfig.prompts, + plan.connectionProfile, + plan.userAttributeProfile, + plan.resourceServer, + plan.clients.dashboard, + plan.clientGrants.myAccount, + plan.connection, + plan.roles.admin, + plan.guardianFactors, + ] + const countByAction = (action) => + planItems.filter((i) => i.action === action).length + const hasChanges = planItems.some((i) => i.action !== ChangeAction.SKIP) if (!hasChanges) { console.log( @@ -115,29 +173,46 @@ async function main() { console.log("\nāœ… strings.xml updated!\n") } + // Even when nothing changed on the tenant, still show how to build and run + // the sample app — this is the path taken on every re-run of a + // fully-configured tenant, so the build/run guidance must not be skipped. + printNextSteps() process.exit(0) } - // Step 5: User Confirmation - const confirmed = await confirmWithUser( - "Do you want to proceed with these changes? " - ) - if (!confirmed) { - console.log("\nāŒ Bootstrap cancelled by user.\n") - process.exit(0) + // User Confirmation. Skip the prompt when --yes is set, or automatically in a + // non-interactive M2M run (no TTY) so the "standalone/CI" path doesn't hang. + const autoConfirm = + yesFlag || (hasMachineCredentials(tenantName) && !process.stdin.isTTY) + + if (autoConfirm) { + console.log( + `\nā–¶ļø Proceeding with ${countByAction(ChangeAction.CREATE)} create, ` + + `${countByAction(ChangeAction.UPDATE)} update ` + + `(auto-confirmed${yesFlag ? " via --yes" : " — non-interactive M2M run"}).` + ) + } else { + const confirmed = await confirmWithUser( + "Do you want to proceed with these changes? " + ) + if (!confirmed) { + console.log("\nāŒ Bootstrap cancelled by user.\n") + process.exit(0) + } } console.log("") - // Step 6: Apply Changes - console.log("āš™ļø Step 4: Applying Changes\n") + // Step 4: Apply Changes + step("āš™ļø ", "Applying Changes") + console.log("") - // 6a. Tenant Configuration + // 4a. Tenant Configuration console.log("Configuring Tenant...") await applyTenantSettingsChanges(plan.tenantConfig.settings) await applyPromptSettingsChanges(plan.tenantConfig.prompts) console.log("") - // 6b. Profiles + // 4b. Profiles console.log("Configuring Profiles...") const connectionProfile = await applyConnectionProfileChanges( plan.connectionProfile @@ -147,12 +222,12 @@ async function main() { ) console.log("") - // 6c. Resource Server (My Account API) + // 4c. Resource Server (My Account API) console.log("Configuring My Account API...") await applyMyAccountResourceServerChanges(plan.resourceServer, domain) console.log("") - // 6d. Native Client + // 4d. Native Client console.log("Configuring Native Client...") const dashboardClient = await applyDashboardClientChanges( plan.clients.dashboard, @@ -163,7 +238,7 @@ async function main() { ) console.log("") - // 6e. Client Grants + // 4e. Client Grants console.log("Configuring Client Grants...") await applyMyAccountClientGrantChanges( plan.clientGrants.myAccount, @@ -172,7 +247,7 @@ async function main() { ) console.log("") - // 6f. Database Connection + // 4f. Database Connection console.log("Configuring Database Connection...") const connection = await applyDatabaseConnectionChanges( plan.connection, @@ -180,13 +255,21 @@ async function main() { ) console.log("") - // 6g. Roles + // 4g. Roles console.log("Configuring Roles...") await applyAdminRoleChanges(plan.roles.admin) console.log("") - // Step 7: Generate strings.xml - console.log("šŸ“ Step 5: Generating strings.xml\n") + // 4h. MFA Factors (WebAuthn / Passkey) + console.log("Configuring MFA Factors...") + await applyGuardianFactorChanges(plan.guardianFactors) + console.log("") + + // Step 5: Generate strings.xml (domain + client id + callback scheme). On + // Android this single file wires both the SDK config and the RedirectActivity + // (via the auth0Domain / auth0Scheme manifest placeholders). + step("šŸ“", "Generating strings.xml") + console.log("") await writeStringsFile( domain, dashboardClient.client_id, @@ -196,13 +279,87 @@ async function main() { // Done! console.log("\nāœ… Bootstrap complete!\n") + + // Summary of what the plan intended, plus anything that needs manual follow-up. + const manualCount = getManualActions().length + console.log( + `Summary: ${countByAction(ChangeAction.CREATE)} created, ` + + `${countByAction(ChangeAction.UPDATE)} updated, ` + + `${countByAction(ChangeAction.SKIP)} already up to date` + + (manualCount > 0 ? `, ${manualCount} need manual attention` : "") + + ".\n" + ) + + reportManualActions() + + printNextSteps() +} + +/** + * Print the build-and-run guidance shown at the end of the bootstrap. Kept as a + * shared helper so it is printed on BOTH exit paths — after a full apply run and + * after the "already configured" short-circuit — rather than only when changes + * were applied. + */ +function printNextSteps() { console.log("Next steps:") - console.log(" 1. Review the updated strings.xml file") - console.log(" 2. Build the sample app:") - console.log(" ./gradlew :app:assembleDebug") - console.log(" 3. Run the sample app:") - console.log(" ./gradlew :app:installDebug") - console.log(" 4. Login and explore the MFA components\n") + console.log( + " 1. Review the updated app/src/main/res/values/strings.xml (domain," + ) + console.log( + " client id, and callback scheme have already been configured for you)." + ) + console.log( + " 2. From the project root, build the debug APK of the sample app:" + ) + console.log(" ./gradlew :app:assembleDebug") + console.log( + " 3. With an emulator running or a device connected (adb devices), install" + ) + console.log(" and launch the sample app:") + console.log(" ./gradlew :app:installDebug") + console.log( + " adb shell am start -n com.auth0.android.sample/.MainActivity" + ) + console.log(" 4. Log in and explore the Passkey + MFA components.\n") + console.log( + " āš ļø If the Gradle build fails (e.g. it cannot download dependencies or" + ) + console.log( + " times out resolving artifacts), turn off your VPN if connected and" + ) + console.log(" try the build again.\n") +} + +/** + * Print a consolidated list of operations that were skipped because the + * authenticated identity lacked the required Management API scope. Each entry + * names the scope to grant (or the dashboard step to perform) so the tenant + * can be finished without re-running everything blindly. + */ +function reportManualActions() { + const actions = getManualActions() + if (actions.length === 0) return + + console.log( + "āš ļø Some steps need manual attention (the login identity lacked the scope):\n" + ) + actions.forEach((a, i) => { + console.log(` ${i + 1}. ${a.resource}`) + if (a.scope) console.log(` Missing scope: ${a.scope}`) + if (a.reason) console.log(` Why it matters: ${a.reason}`) + if (a.manualStep) console.log(` Fix: ${a.manualStep}`) + console.log("") + }) + console.log( + " Tip: grant the scope(s) above to your M2M app (Dashboard → Applications →" + ) + console.log( + " APIs → Auth0 Management API → Machine to Machine Applications), then re-run" + ) + console.log( + " the bootstrap. It is idempotent — completed resources will be skipped.\n" + ) } // Run the main function diff --git a/app/scripts/utils/auth0-api.mjs b/app/scripts/utils/auth0-api.mjs index 9d9a8de..0cdc4a2 100644 --- a/app/scripts/utils/auth0-api.mjs +++ b/app/scripts/utils/auth0-api.mjs @@ -1,20 +1,129 @@ import { $ } from "execa" +// Default timeout for API calls (30 seconds) +const DEFAULT_API_TIMEOUT = 30000 + +/** + * Check if an error indicates an authentication/authorization issue + * @param {Error} e - The error to check + * @returns {boolean} True if it's an auth error + */ +function isAuthError(e) { + const stderr = e.stderr?.toLowerCase() || "" + + // Check for clear authentication failures + if (stderr.includes("unauthorized") || stderr.includes("401")) { + return true + } + + // Check for token expiration messages + if (stderr.includes("token") && (stderr.includes("expired") || stderr.includes("invalid"))) { + return true + } + + // Check for "please login" type messages (but not "during login" which is scope advice) + if (stderr.includes("please login") || stderr.includes("not logged in")) { + return true + } + + return false +} + /** * Make a generic API call using auth0 CLI + * @param {string} method - HTTP method (get, post, patch, delete) + * @param {string} endpoint - API endpoint + * @param {object} data - Optional data payload + * @param {number} timeout - Optional timeout in ms (default 30s) */ -export async function auth0ApiCall(method, endpoint, data = null) { - const args = ["api", method, endpoint] +export async function auth0ApiCall(method, endpoint, data = null, timeout = DEFAULT_API_TIMEOUT) { + const args = ["api", method, endpoint, "--no-input"] if (data) { args.push("--data", JSON.stringify(data)) } try { - const { stdout } = await $`auth0 ${args}` - return stdout ? JSON.parse(stdout) : null + const { stdout } = await $({ timeout })`auth0 ${args}` + const result = stdout ? JSON.parse(stdout) : null + + // The Auth0 CLI exits 0 even when the Management API returns an HTTP error + // (e.g. 400/403), printing the error body as JSON on stdout. Detect that + // shape and surface it as a real failure instead of a silent success. + if ( + result && + typeof result === "object" && + !Array.isArray(result) && + typeof result.statusCode === "number" && + result.statusCode >= 400 + ) { + const detail = result.message || result.error || "Unknown error" + throw new Error( + `Auth0 API ${method.toUpperCase()} ${endpoint} failed (${result.statusCode}): ${detail}` + ) + } + + return result } catch (e) { + // Check if it's a timeout error + if (e.timedOut) { + throw new Error( + `API call timed out after ${timeout}ms. Your Auth0 session may have expired.` + ) + } + // Check for authentication errors + if (isAuthError(e)) { + throw new Error(`Authentication failed. Your Auth0 session may have expired.`) + } + // For scope errors, return null gracefully (the feature may not be available) + if (e.stderr?.includes("lacks scope") || e.stderr?.includes("insufficient_scope")) { + console.warn( + `āš ļø Warning: Missing required scope for ${endpoint}. Some features may not be available.` + ) + return null + } console.warn(`āš ļø Warning: API Call failed: ${e.message}`) throw e } } + +/** + * Check if the Auth0 CLI session is valid by making a simple API call + * @param {number} timeout - Timeout in ms (default 10s for quick check) + * @returns {Promise} True if session is valid + */ +export async function isSessionValid(timeout = 10000) { + try { + // Probe with an endpoint whose scope is part of the bootstrap set + // (read:client_grants). Using `get users` would require read:users, which + // the bootstrap never requests — so a correctly-scoped M2M app would look + // "invalid" here even though its session is fine. + await $({ timeout })`auth0 api get client-grants --no-input` + return true + } catch (e) { + return false + } +} + +/** + * Detect the Auth0 CLI "corrupted token" state. The CLI can persist a malformed + * token (a known issue) that no refresh can fix — it must be cleared with + * `auth0 logout` before logging in again. A plain expiry does NOT report this, + * so we treat it distinctly to trigger an automatic logout+relogin. + * @param {number} timeout + * @returns {Promise} True if the stored token is corrupted + */ +export async function isTokenCorrupted(timeout = 10000) { + try { + // Same in-set probe as isSessionValid (read:client_grants) — see note there. + await $({ timeout })`auth0 api get client-grants --no-input` + return false + } catch (e) { + const text = `${e.stderr || ""} ${e.stdout || ""} ${e.message || ""}`.toLowerCase() + return ( + text.includes("token is corrupted") || + text.includes("malformed token") || + text.includes("auth0 logout") + ) + } +} diff --git a/app/scripts/utils/clients.mjs b/app/scripts/utils/clients.mjs index 24aa535..4bcf256 100644 --- a/app/scripts/utils/clients.mjs +++ b/app/scripts/utils/clients.mjs @@ -3,10 +3,79 @@ import ora from "ora" import { auth0ApiCall } from "./auth0-api.mjs" import { ChangeAction, createChangeItem } from "./change-plan.mjs" +import { + extractMissingScope, + isPermissionError, + recordManualAction, +} from "./manual-actions.mjs" // Constants export const CLIENT_NAME = "Android Universal Components Demo" +/** + * Build the allowed callback / logout URLs for the native Android client. + * + * Auth0.Android's WebAuthProvider builds its redirect URL from the app package + * name and the configured scheme (`.withScheme(...)`, mirrored by the + * `auth0Scheme` manifest placeholder that drives RedirectActivity). Both forms + * below are registered so login/logout resolve whether the app uses the custom + * scheme (the default for the sample app) or Android App Links over HTTPS: + * - HTTPS App Link: https://{domain}/android/{packageName}/callback + * - Custom scheme: {scheme}://{domain}/android/{packageName}/callback + * + * @param {string} domain - Auth0 tenant domain + * @param {string} packageName - Android application id (package name) + * @param {string} scheme - Custom callback scheme (the sample app uses "demo") + * @returns {string[]} Redirect URLs (used for both callbacks and logout URLs) + */ +export function buildRedirectUrls(domain, packageName, scheme) { + return [ + `https://${domain}/android/${packageName}/callback`, + `${scheme}://${domain}/android/${packageName}/callback`, + ] +} + +/** + * Detect a stale Android callback URL: any + * `://{domain}/android/{packageName}/callback` whose scheme is neither + * `https` nor the configured scheme. These are leftovers from earlier runs + * (e.g. an old scheme) and should be removed so the client ends up with exactly + * the two correct redirect URLs. + * + * @param {string} url - An existing callback/logout URL + * @param {string} domain - Auth0 tenant domain + * @param {string} packageName - Android application id (package name) + * @param {string} scheme - The configured callback scheme + * @returns {boolean} True if the URL is a stale Android callback for this app + */ +function isStaleAndroidCallback(url, domain, packageName, scheme) { + const suffix = `://${domain}/android/${packageName}/callback` + if (!url.endsWith(suffix)) return false + const urlScheme = url.slice(0, url.length - suffix.length) + return urlScheme !== "https" && urlScheme !== scheme +} + +/** + * Reconcile an existing URL list to the desired end state: drop any stale + * Android callback URLs for this app, preserve everything else, and ensure both + * desired redirect URLs are present (de-duplicated, order-stable). + * + * @param {string[]} existing - Current callback/logout URLs on the client + * @param {string[]} desired - The two correct redirect URLs + * @param {string} domain - Auth0 tenant domain + * @param {string} packageName - Android application id (package name) + * @param {string} scheme - The configured callback scheme + * @returns {string[]} The reconciled URL list + */ +function reconcileRedirectUrls(existing, desired, domain, packageName, scheme) { + const kept = existing.filter( + (url) => + !isStaleAndroidCallback(url, domain, packageName, scheme) && + !desired.includes(url) + ) + return [...kept, ...desired] +} + // ============================================================================ // CHECK FUNCTIONS // ============================================================================ @@ -18,7 +87,13 @@ export async function checkDashboardClientChanges( myAccountApiScopes ) { const { packageName, scheme } = androidConfig - const callbackUrl = `${scheme}://${domain}/android/${packageName}/callback` + + // Auth0.Android's WebAuthProvider builds its redirect URL from the app package + // name and the configured scheme. The two supported forms are the + // custom-scheme callback ({scheme}://…) and the HTTPS App-Link callback. + // Register BOTH as allowed callback and logout URLs so login works whether or + // not Android App Links are configured. + const redirectUrls = buildRedirectUrls(domain, packageName, scheme) const existingClient = existingClients.find( (c) => c.name === CLIENT_NAME && c.app_type === "native" @@ -28,13 +103,36 @@ export async function checkDashboardClientChanges( return createChangeItem(ChangeAction.CREATE, { resource: "Native Client", name: CLIENT_NAME, - callbackUrl, + redirectUrls, }) } - // Check if callback URL needs updating + // Reconcile callback and logout URLs to the desired end state: add the two + // correct redirects and strip stale Android callbacks (e.g. an old scheme), + // while preserving any unrelated URLs already on the client. const existingCallbacks = existingClient.callbacks || [] - const hasCorrectCallback = existingCallbacks.includes(callbackUrl) + const existingLogoutUrls = existingClient.allowed_logout_urls || [] + const desiredCallbacks = reconcileRedirectUrls( + existingCallbacks, + redirectUrls, + domain, + packageName, + scheme + ) + const desiredLogoutUrls = reconcileRedirectUrls( + existingLogoutUrls, + redirectUrls, + domain, + packageName, + scheme + ) + + // Order-independent comparison so both additions and removals are detected. + const sameSet = (a, b) => + a.length === b.length && + a.slice().sort().toString() === b.slice().sort().toString() + const callbacksNeedUpdate = !sameSet(existingCallbacks, desiredCallbacks) + const logoutUrlsNeedUpdate = !sameSet(existingLogoutUrls, desiredLogoutUrls) // Check if My Account API refresh token policy exists with correct scopes const hasMyAccountPolicy = existingClient.refresh_token?.policies?.some( @@ -46,22 +144,30 @@ export async function checkDashboardClientChanges( const refreshTokenPoliciesNeedUpdate = !hasMyAccountPolicy - if (!hasCorrectCallback || refreshTokenPoliciesNeedUpdate) { + if ( + callbacksNeedUpdate || + logoutUrlsNeedUpdate || + refreshTokenPoliciesNeedUpdate + ) { const updates = {} - if (!hasCorrectCallback) { - updates.callbacks = [...existingCallbacks, callbackUrl] + if (callbacksNeedUpdate) { + updates.callbacks = desiredCallbacks + } + if (logoutUrlsNeedUpdate) { + updates.allowedLogoutUrls = desiredLogoutUrls } updates.refreshTokenNeedsUpdate = refreshTokenPoliciesNeedUpdate const changes = [] - if (!hasCorrectCallback) changes.push("Update callback URL") + if (callbacksNeedUpdate) changes.push("Update callback URLs") + if (logoutUrlsNeedUpdate) changes.push("Update logout URLs") if (refreshTokenPoliciesNeedUpdate) changes.push("Update refresh token policies") return createChangeItem(ChangeAction.UPDATE, { resource: "Native Client", name: CLIENT_NAME, existing: existingClient, - callbackUrl, + redirectUrls, updates, summary: changes.join(", "), }) @@ -106,8 +212,8 @@ export async function applyDashboardClientChanges( app_type: "native", oidc_conformant: true, is_first_party: true, - callbacks: [changePlan.callbackUrl], - allowed_logout_urls: [changePlan.callbackUrl], + callbacks: changePlan.redirectUrls, + allowed_logout_urls: changePlan.redirectUrls, grant_types: ["authorization_code", "refresh_token"], token_endpoint_auth_method: "none", jwt_configuration: { @@ -142,7 +248,24 @@ export async function applyDashboardClientChanges( spinner.succeed(`Created Native Client: ${CLIENT_NAME}`) return client } catch (e) { - spinner.fail(`Failed to create Native Client`) + // The native client is the anchor for everything downstream (client + // grant, connection enablement, strings.xml). If we lack create:clients + // we cannot continue meaningfully, so surface a clear manual action and + // re-throw rather than pretending success. + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "create:clients" + spinner.fail(`Cannot create Native Client — M2M app lacks scope: ${scope}`) + recordManualAction({ + resource: `Native Client: ${CLIENT_NAME}`, + scope, + reason: + "The native client is required for the sample app to authenticate; the rest of the bootstrap depends on it.", + manualStep: + "Grant create:clients to the M2M app and re-run, OR create a Native application manually in the Dashboard.", + }) + } else { + spinner.fail(`Failed to create Native Client`) + } throw e } } @@ -160,6 +283,10 @@ export async function applyDashboardClientChanges( updateData.callbacks = updates.callbacks } + if (updates.allowedLogoutUrls) { + updateData.allowed_logout_urls = updates.allowedLogoutUrls + } + if (updates.refreshTokenNeedsUpdate) { const desiredMyAccountPolicy = { audience: `https://${domain}/me/`, @@ -212,6 +339,24 @@ export async function applyDashboardClientChanges( spinner.succeed(`Updated Native Client: ${CLIENT_NAME}`) return client } catch (e) { + // A missing scope (e.g. update:clients on the M2M app) should not abort + // the whole bootstrap. Record it as a manual action and continue with the + // existing client so the rest of the setup still runs. + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:clients" + spinner.warn( + `Skipped updating Native Client — M2M app lacks scope: ${scope}` + ) + recordManualAction({ + resource: `Native Client: ${CLIENT_NAME}`, + 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.", + manualStep: + "Grant update:clients to the M2M app and re-run, OR Dashboard → Applications → → Settings → set Allowed Callback URLs and Allowed Logout URLs to the two Android redirect URLs.", + }) + return changePlan.existing + } spinner.fail(`Failed to update Native Client`) throw e } @@ -329,5 +474,3 @@ export async function applyMyAccountClientGrantChanges( } } } - - diff --git a/app/scripts/utils/connections.mjs b/app/scripts/utils/connections.mjs index cfe7eba..90f5287 100644 --- a/app/scripts/utils/connections.mjs +++ b/app/scripts/utils/connections.mjs @@ -3,10 +3,31 @@ import ora from "ora" import { auth0ApiCall } from "./auth0-api.mjs" import { ChangeAction, createChangeItem } from "./change-plan.mjs" +import { + extractMissingScope, + isPermissionError, + recordManualAction, +} from "./manual-actions.mjs" // Constants export const DEFAULT_CONNECTION_NAME = "Username-Password-Authentication" +// Passkeys are a first-class authentication method for the sample app's MFA / +// Passkeys UI components. Enabling them on the database connection is what makes +// the "Passkey" option appear in Universal Login (alongside identifier-first). +// `passkey_options` mirror Auth0's recommended progressive-enrollment defaults. +const PASSKEY_CONNECTION_OPTIONS = { + authentication_methods: { + password: { enabled: true }, + passkey: { enabled: true }, + }, + passkey_options: { + challenge_ui: "both", + local_enrollment_enabled: true, + progressive_enrollment_enabled: true, + }, +} + // ============================================================================ // CHECK FUNCTIONS // ============================================================================ @@ -35,15 +56,29 @@ export function checkDatabaseConnectionChanges( (clientId) => !existingEnabledClients.includes(clientId) ) + // Check whether passkeys are enabled on the connection. If not, the sample + // app's Passkeys UI component has nothing to surface in Universal Login. + const passkeyEnabled = + existing.options?.authentication_methods?.passkey?.enabled === true + + const changes = [] if (missingClients.length > 0) { + changes.push(`Add ${missingClients.length} enabled client(s)`) + } + if (!passkeyEnabled) { + changes.push("Enable passkey authentication method") + } + + if (changes.length > 0) { return createChangeItem(ChangeAction.UPDATE, { resource: "Database Connection", name: DEFAULT_CONNECTION_NAME, existing, updates: { missingClients, + enablePasskey: !passkeyEnabled, }, - summary: `Add ${missingClients.length} enabled client(s)`, + summary: changes.join(", "), }) } @@ -81,6 +116,7 @@ export async function applyDatabaseConnectionChanges( name: DEFAULT_CONNECTION_NAME, display_name: "Universal-Components", enabled_clients: [dashboardClientId], + options: PASSKEY_CONNECTION_OPTIONS, } const createArgs = [ @@ -104,11 +140,11 @@ export async function applyDatabaseConnectionChanges( if (changePlan.action === ChangeAction.UPDATE) { const spinner = ora({ - text: `Adding missing enabled clients to ${DEFAULT_CONNECTION_NAME} connection`, + text: `Updating ${DEFAULT_CONNECTION_NAME} connection`, }).start() try { - const { existing } = changePlan + const { existing, updates } = changePlan const existingEnabledClients = existing.enabled_clients || [] // Use the actual client IDs instead of the ones from the change plan @@ -117,26 +153,105 @@ export async function applyDatabaseConnectionChanges( clientsToAdd.push(dashboardClientId) } - if (clientsToAdd.length === 0) { - spinner.succeed( - `${DEFAULT_CONNECTION_NAME} connection already has all clients enabled` - ) + // Build the patch: enable the client and/or turn on the passkey method. + const patchData = {} + + if (clientsToAdd.length > 0) { + patchData.enabled_clients = [...existingEnabledClients, ...clientsToAdd] + } + + 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 || {}), + }, + } + } + + if (Object.keys(patchData).length === 0) { + spinner.succeed(`${DEFAULT_CONNECTION_NAME} connection is already up to date`) return existing } - const updatedClients = [...existingEnabledClients, ...clientsToAdd] + 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 - await auth0ApiCall("patch", `connections/${existing.id}`, { - enabled_clients: updatedClients, - }) - spinner.succeed( - `Updated ${DEFAULT_CONNECTION_NAME} connection with ${clientsToAdd.length} new enabled client(s)` - ) + 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(", ")})` + ) + return updated + } - // Fetch updated connection - const updated = await auth0ApiCall("get", `connections/${existing.id}`) - return updated || existing + 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.", + }) + } + if (!passkeyApplied) { + // Writing the connection `options` object (which is where the passkey + // authentication method lives) requires update:connections_options — + // a scope distinct from update:connections. When it is missing the CLI + // reports an empty "lacks scope: ." because it cannot render the newer + // scope name, so we name it explicitly here. + recordManualAction({ + resource: `Connection: ${DEFAULT_CONNECTION_NAME} (enable passkeys)`, + scope: "update:connections_options", + reason: + "Passkey must be enabled on the connection for the Passkey login option to appear in Universal Login. Writing connection options requires update:connections_options (separate from update:connections).", + manualStep: + "Grant update:connections_options to the M2M app and re-run, OR Dashboard → Authentication → Database → → Authentication Methods → toggle Passkey on.", + }) + } + return updated } catch (e) { + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:connections" + spinner.warn( + `Skipped updating ${DEFAULT_CONNECTION_NAME} — M2M app lacks scope: ${scope}` + ) + recordManualAction({ + resource: `Connection: ${DEFAULT_CONNECTION_NAME}`, + scope, + reason: + "The native app must be an enabled client of this connection and passkeys enabled for the full login experience.", + manualStep: + "Dashboard → Authentication → Database → enable the app + toggle Passkey, OR grant the scope above and re-run.", + }) + return changePlan.existing + } spinner.fail(`Failed to update ${DEFAULT_CONNECTION_NAME} connection`) throw e } diff --git a/app/scripts/utils/discovery.mjs b/app/scripts/utils/discovery.mjs index 638e9fc..411ebc3 100644 --- a/app/scripts/utils/discovery.mjs +++ b/app/scripts/utils/discovery.mjs @@ -20,8 +20,12 @@ import { checkTenantSettingsChanges, checkPromptSettingsChanges, } from "./tenant-config.mjs" +import { checkGuardianFactorChanges } from "./guardian-factors.mjs" import { ChangeAction } from "./change-plan.mjs" +// Timeout for direct Auth0 CLI commands (30 seconds) +const CLI_TIMEOUT = 30000 + // ============================================================================ // Resource Discovery // ============================================================================ @@ -36,7 +40,7 @@ export async function discoverExistingResources(domain) { let clients = [] try { const clientsArgs = ["apps", "list", "--json", "--no-input"] - const { stdout } = await $`auth0 ${clientsArgs}` + const { stdout } = await $({ timeout: CLI_TIMEOUT })`auth0 ${clientsArgs}` clients = stdout ? JSON.parse(stdout) : [] } catch { clients = [] @@ -45,7 +49,7 @@ export async function discoverExistingResources(domain) { let roles = [] try { const rolesArgs = ["roles", "list", "--json", "--no-input"] - const { stdout } = await $`auth0 ${rolesArgs}` + const { stdout } = await $({ timeout: CLI_TIMEOUT })`auth0 ${rolesArgs}` roles = stdout ? JSON.parse(stdout) : [] } catch { roles = [] @@ -61,7 +65,7 @@ export async function discoverExistingResources(domain) { let resourceServers = [] try { const rsArgs = ["apis", "list", "--json", "--no-input"] - const { stdout } = await $`auth0 ${rsArgs}` + const { stdout } = await $({ timeout: CLI_TIMEOUT })`auth0 ${rsArgs}` resourceServers = stdout ? JSON.parse(stdout) : [] } catch { resourceServers = [] @@ -90,6 +94,13 @@ export async function discoverExistingResources(domain) { userAttributeProfiles = [] } + let guardianFactors = [] + try { + guardianFactors = (await auth0ApiCall("get", "guardian/factors")) || [] + } catch { + guardianFactors = [] + } + spinner.succeed("Discovered existing resources") return { @@ -100,6 +111,7 @@ export async function discoverExistingResources(domain) { clientGrants, connectionProfiles, userAttributeProfiles, + guardianFactors, } } catch (e) { spinner.fail("Failed to discover existing resources") @@ -124,6 +136,7 @@ export async function buildChangePlan(resources, domain, androidConfig) { settings: null, prompts: null, }, + guardianFactors: null, } // Profiles @@ -164,17 +177,16 @@ export async function buildChangePlan(resources, domain, androidConfig) { dashboardClientId ) - // Roles - plan.roles.admin = await checkAdminRoleChanges( - resources.roles, - domain, - MY_ACCOUNT_API_SCOPES - ) + // Roles (always SKIP in the My-Account-only flow — see roles.mjs) + plan.roles.admin = await checkAdminRoleChanges(resources.roles) // Tenant Config plan.tenantConfig.settings = await checkTenantSettingsChanges() plan.tenantConfig.prompts = await checkPromptSettingsChanges() + // MFA Factors (WebAuthn / Passkey) + plan.guardianFactors = checkGuardianFactorChanges(resources.guardianFactors) + return plan } @@ -195,6 +207,7 @@ export function displayChangePlan(plan) { { name: "Client Grant (My Account)", ...plan.clientGrants.myAccount }, { name: "Database Connection", ...plan.connection }, { name: "Admin Role", ...plan.roles.admin }, + { name: "MFA Factors (WebAuthn/Passkey)", ...plan.guardianFactors }, ] for (const item of items) { @@ -214,8 +227,8 @@ export function displayChangePlan(plan) { let detail = "" if (item.summary) { detail = ` (${item.summary})` - } else if (item.callbackUrl) { - detail = ` (callback: ${item.callbackUrl})` + } else if (item.redirectUrls?.length) { + detail = ` (callbacks: ${item.redirectUrls.join(", ")})` } console.log(` ${icon} [${label}] ${item.name || item.resource}${detail}`) diff --git a/app/scripts/utils/guardian-factors.mjs b/app/scripts/utils/guardian-factors.mjs new file mode 100644 index 0000000..333df53 --- /dev/null +++ b/app/scripts/utils/guardian-factors.mjs @@ -0,0 +1,113 @@ +import ora from "ora" + +import { auth0ApiCall } from "./auth0-api.mjs" +import { ChangeAction, createChangeItem } from "./change-plan.mjs" +import { + extractMissingScope, + isPermissionError, + recordManualAction, +} from "./manual-actions.mjs" + +// The WebAuthn Guardian factors back the Passkeys / device-biometric MFA +// components in the sample app. `webauthn-platform` = device-bound passkeys +// (Face ID / Touch ID), `webauthn-roaming` = security keys. Enabling them makes +// those MFA options selectable during step-up / enrollment in Universal Login. +const DESIRED_FACTORS = ["webauthn-platform", "webauthn-roaming"] + +// ============================================================================ +// CHECK +// ============================================================================ + +/** + * Determine which WebAuthn MFA factors still need to be enabled. + * @param {Array<{name:string,enabled:boolean}>} existingFactors + * @returns {object} change item + */ +export function checkGuardianFactorChanges(existingFactors) { + const byName = new Map( + (existingFactors || []).map((f) => [f.name, f]) + ) + + const toEnable = DESIRED_FACTORS.filter((name) => { + const factor = byName.get(name) + // If the factor isn't present at all we still attempt to enable it; the API + // will tell us if it is unavailable on this tenant. + return !factor || factor.enabled !== true + }) + + if (toEnable.length === 0) { + return createChangeItem(ChangeAction.SKIP, { + resource: "MFA Factors (WebAuthn/Passkey)", + }) + } + + return createChangeItem(ChangeAction.UPDATE, { + resource: "MFA Factors (WebAuthn/Passkey)", + updates: { toEnable }, + summary: `Enable ${toEnable.join(", ")}`, + }) +} + +// ============================================================================ +// APPLY +// ============================================================================ + +/** + * Enable the WebAuthn MFA factors. Each factor is toggled independently so a + * missing entitlement on one does not block the other. Missing-scope failures + * are recorded as manual actions rather than aborting the bootstrap. + * @param {object} changePlan + */ +export async function applyGuardianFactorChanges(changePlan) { + if (changePlan.action === ChangeAction.SKIP) { + const spinner = ora({ text: `MFA factors are up to date` }).start() + spinner.succeed() + return + } + + for (const factor of changePlan.updates.toEnable) { + const spinner = ora({ text: `Enabling MFA factor: ${factor}` }).start() + try { + await auth0ApiCall("put", `guardian/factors/${factor}`, { + enabled: true, + }) + + // auth0ApiCall swallows missing-scope errors (returns null), so verify. + // NOTE: the per-factor GET (guardian/factors/:name) is not supported and + // returns 404 — only the list endpoint reflects state, so re-list here. + const factors = (await auth0ApiCall("get", "guardian/factors")) || [] + const current = factors.find((f) => f.name === factor) + if (current?.enabled === true) { + spinner.succeed(`Enabled MFA factor: ${factor}`) + } else { + spinner.warn( + `Could not enable ${factor} — likely missing scope: update:guardian_factors` + ) + recordManualAction({ + resource: `MFA Factor: ${factor}`, + scope: "update:guardian_factors", + reason: + "Enables the WebAuthn/Passkey MFA option in Universal Login step-up and enrollment.", + manualStep: + "Dashboard → Security → Multi-factor Auth → enable WebAuthn, OR grant update:guardian_factors and re-run.", + }) + } + } catch (e) { + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:guardian_factors" + spinner.warn(`Skipped ${factor} — M2M app lacks scope: ${scope}`) + recordManualAction({ + resource: `MFA Factor: ${factor}`, + scope, + reason: + "Enables the WebAuthn/Passkey MFA option in Universal Login step-up and enrollment.", + manualStep: + "Dashboard → Security → Multi-factor Auth → enable WebAuthn, OR grant the scope above and re-run.", + }) + continue + } + spinner.fail(`Failed to enable MFA factor: ${factor}`) + throw e + } + } +} diff --git a/app/scripts/utils/helpers.mjs b/app/scripts/utils/helpers.mjs index 06b53b9..2af0b5d 100644 --- a/app/scripts/utils/helpers.mjs +++ b/app/scripts/utils/helpers.mjs @@ -1,6 +1,9 @@ import readline from "node:readline/promises" import { select } from "@inquirer/prompts" +/** + * Wait for user confirmation before proceeding + */ export async function confirmWithUser(message) { const rl = readline.createInterface({ input: process.stdin, @@ -13,6 +16,9 @@ export async function confirmWithUser(message) { return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes" } +/** + * Wait for user input + */ export async function getInputFromUser(message) { const rl = readline.createInterface({ input: process.stdin, @@ -25,6 +31,9 @@ export async function getInputFromUser(message) { return answer.toLowerCase() } +/** + * Prompt the user to select one option from a list + */ export async function selectOptionFromList(message, options) { const answer = await select({ message: message, choices: options }) return answer diff --git a/app/scripts/utils/manual-actions.mjs b/app/scripts/utils/manual-actions.mjs new file mode 100644 index 0000000..60d5a13 --- /dev/null +++ b/app/scripts/utils/manual-actions.mjs @@ -0,0 +1,60 @@ +// ============================================================================ +// Manual-action tracking + permission-error detection +// +// The bootstrap can authenticate with an M2M app that is missing some +// Management API scopes (e.g. `update:tenant_settings`). Rather than aborting +// the whole run when a single privileged operation is denied, the affected +// apply step records a "manual action" here, warns, and continues. At the end +// of the run the bootstrap prints a consolidated list of what still needs to +// be done by hand (or by granting the missing scope and re-running). +// ============================================================================ + +const pendingManualActions = [] + +/** + * Record something the caller could not complete automatically. + * @param {{ resource: string, reason: string, scope?: string, manualStep?: string }} action + */ +export function recordManualAction(action) { + pendingManualActions.push(action) +} + +/** + * @returns {Array<{resource:string,reason:string,scope?:string,manualStep?:string}>} + */ +export function getManualActions() { + return pendingManualActions +} + +/** + * Detect whether an execa/CLI error was caused by a missing Management API + * scope or an authorization denial (as opposed to a genuine failure we should + * surface). The Auth0 CLI reports these as + * "Request failed because access token lacks scope: ". + * @param {Error & {stderr?: string, stdout?: string}} e + * @returns {boolean} + */ +export function isPermissionError(e) { + const text = `${e?.stderr || ""} ${e?.stdout || ""} ${e?.message || ""}`.toLowerCase() + + return ( + text.includes("lacks scope") || + text.includes("insufficient_scope") || + text.includes("insufficient scope") || + text.includes("forbidden") || + text.includes("access_denied") || + text.includes("403") + ) +} + +/** + * Try to pull the specific scope name out of a "lacks scope: " message + * so the manual-action summary can name exactly what to grant. + * @param {Error & {stderr?: string}} e + * @returns {string | null} + */ +export function extractMissingScope(e) { + const text = `${e?.stderr || ""} ${e?.message || ""}` + const match = text.match(/lacks scope:\s*([a-z0-9_:*-]+)/i) + return match ? match[1] : null +} diff --git a/app/scripts/utils/roles.mjs b/app/scripts/utils/roles.mjs index f697d00..42e584e 100644 --- a/app/scripts/utils/roles.mjs +++ b/app/scripts/utils/roles.mjs @@ -1,60 +1,35 @@ -import { $ } from "execa" import ora from "ora" -import { auth0ApiCall } from "./auth0-api.mjs" import { ChangeAction, createChangeItem } from "./change-plan.mjs" // ============================================================================ // CHECK FUNCTIONS // ============================================================================ -export async function checkAdminRoleChanges( - existingRoles, - domain, - myAccountApiScopes -) { +/** + * In the Android (My Account only) flow the admin role is always a no-op. + * + * My Account is a System API (identifier `https://{domain}/me/`, scopes like + * `read:me:factors`). The Management API refuses to attach System-API scopes to + * a role — `POST roles/:id/permissions` returns + * `400 operation_not_supported: "System APIs may not be used"`. Since every + * scope this bootstrap manages is a `:me:` System-API scope, there is nothing + * assignable to a role, and the sample app never uses one (My Account access is + * granted via the client grant, not a role). + * + * The check therefore always returns SKIP. It is kept in the plan so the change + * summary and idempotency logic remain uniform across resources; the earlier + * CREATE/UPDATE code paths were dead and have been removed. + */ +export async function checkAdminRoleChanges(existingRoles) { const existingRole = existingRoles.find((r) => r.name === "admin") - if (!existingRole) { - return createChangeItem(ChangeAction.CREATE, { - resource: "Admin Role", - name: "admin", - permissions: myAccountApiScopes, - domain, - }) - } - - // Check if permissions are correct - try { - const permissions = await auth0ApiCall( - "get", - `roles/${existingRole.id}/permissions` - ) - const existingPermissions = (permissions || []).map( - (p) => p.permission_name - ) - const missingPermissions = myAccountApiScopes.filter( - (s) => !existingPermissions.includes(s) - ) - - if (missingPermissions.length > 0) { - return createChangeItem(ChangeAction.UPDATE, { - resource: "Admin Role", - name: "admin", - existing: existingRole, - permissions: missingPermissions, - domain, - summary: `Add ${missingPermissions.length} missing permissions`, - }) - } - } catch { - // If we can't check permissions, skip - } - return createChangeItem(ChangeAction.SKIP, { resource: "Admin Role", name: "admin", existing: existingRole, + reason: + "My Account is a System API; its scopes cannot be assigned to a role", }) } @@ -63,76 +38,8 @@ export async function checkAdminRoleChanges( // ============================================================================ export async function applyAdminRoleChanges(changePlan) { - if (changePlan.action === ChangeAction.SKIP) { - const spinner = ora({ - text: `Admin Role is up to date`, - }).start() - spinner.succeed() - return changePlan.existing - } - - if (changePlan.action === ChangeAction.CREATE) { - const spinner = ora({ - text: `Creating admin role`, - }).start() - - try { - const createRoleArgs = [ - "roles", - "create", - "--name", - "admin", - "--description", - "Manage the tenant configuration.", - "--json", - "--no-input", - ] - - const { stdout } = await $`auth0 ${createRoleArgs}` - const role = JSON.parse(stdout) - - // Add permissions - const permissionsData = { - permissions: changePlan.permissions.map((scope) => ({ - permission_name: scope, - resource_server_identifier: `https://${changePlan.domain}/me/`, - })), - } - - await auth0ApiCall("post", `roles/${role.id}/permissions`, permissionsData) - - spinner.succeed(`Created admin role`) - return role - } catch (e) { - spinner.fail(`Failed to create the admin role`) - throw e - } - } - - if (changePlan.action === ChangeAction.UPDATE) { - const spinner = ora({ - text: `Updating admin role permissions`, - }).start() - - try { - const permissionsData = { - permissions: changePlan.permissions.map((scope) => ({ - permission_name: scope, - resource_server_identifier: `https://${changePlan.domain}/me/`, - })), - } - - await auth0ApiCall( - "post", - `roles/${changePlan.existing.id}/permissions`, - permissionsData - ) - - spinner.succeed(`Updated admin role permissions`) - return changePlan.existing - } catch (e) { - spinner.fail(`Failed to update admin role permissions`) - throw e - } - } + // Only SKIP is ever produced by checkAdminRoleChanges (see note above). + const spinner = ora({ text: `Admin Role is up to date` }).start() + spinner.succeed() + return changePlan.existing } diff --git a/app/scripts/utils/strings-writer.mjs b/app/scripts/utils/strings-writer.mjs index 02f4913..7520979 100644 --- a/app/scripts/utils/strings-writer.mjs +++ b/app/scripts/utils/strings-writer.mjs @@ -3,48 +3,111 @@ import path from "node:path" import ora from "ora" /** - * Write Auth0 configuration to Android strings.xml + * On Android, strings.xml is the single wiring point for Auth0 configuration: + * - `com_auth0_domain` / `com_auth0_client_id` are read by the Auth0.Android + * SDK at launch, and + * - `com_auth0_scheme` is consumed by the `auth0Scheme` manifest placeholder + * that drives RedirectActivity (so the OAuth callback reaches the app). * - * Reads the existing strings.xml, updates Auth0-related entries, - * and preserves any other custom string resources. + * Because a single file gates everything, the writer is defensive: it is + * idempotent, verifies the write by re-reading, never clobbers unrelated string + * resources, and falls back to printed manual instructions if the file shape is + * unexpected rather than corrupting it. + */ + +const AUTH0_KEYS = ["com_auth0_client_id", "com_auth0_domain", "com_auth0_scheme"] + +/** + * Escape a value for inclusion in an Android XML string resource body. + */ +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") +} + +/** + * Print manual fallback instructions for wiring the config by hand. + */ +function printManualInstructions(domain, clientId, scheme, stringsXmlPath) { + const rel = path.relative(process.cwd(), stringsXmlPath) + console.log(`\n Add/update the following in ${rel} manually:\n`) + console.log(` ${clientId}`) + console.log(` ${domain}`) + console.log(` ${scheme}\n`) +} + +/** + * Parse the value entries out of a strings.xml body. + * @returns {Map} + */ +function parseStrings(content) { + const strings = new Map() + const stringRegex = /([\s\S]*?)<\/string>/g + let match + while ((match = stringRegex.exec(content)) !== null) { + strings.set(match[1], match[2]) + } + return strings +} + +/** + * Write Auth0 configuration to Android strings.xml. + * + * Reads the existing strings.xml, updates only the Auth0-related entries, + * preserves any other custom string resources, then re-reads the file to verify + * the three Auth0 values actually landed before reporting success. + * + * Safe to run repeatedly: if the values are already correct it still rewrites + * them to the same content (a no-op in practice) and the second run of the + * bootstrap shows all resources as SKIP. + * + * @param {string} domain - Auth0 tenant domain + * @param {string} clientId - Native client id created/updated by the bootstrap + * @param {string} scheme - Callback scheme (the sample app uses "demo") + * @param {string} stringsXmlPath - Absolute path to app strings.xml */ export async function writeStringsFile(domain, clientId, scheme, stringsXmlPath) { const spinner = ora({ text: "Generating strings.xml", }).start() + // The file must exist — validateAndroidProject checks this, but guard anyway + // and fall back to manual instructions rather than creating one blindly. + if (!fs.existsSync(stringsXmlPath)) { + spinner.warn(`Could not find strings.xml at ${stringsXmlPath}`) + printManualInstructions(domain, clientId, scheme, stringsXmlPath) + return + } + try { - // Read existing file to preserve non-Auth0 strings - let existingContent = "" - if (fs.existsSync(stringsXmlPath)) { - existingContent = fs.readFileSync(stringsXmlPath, "utf-8") - } + const existingContent = fs.readFileSync(stringsXmlPath, "utf-8") - // Parse existing strings (simple regex-based parser for Android strings.xml) - const existingStrings = new Map() - const stringRegex = - /([\s\S]*?)<\/string>/g - let match - while ((match = stringRegex.exec(existingContent)) !== null) { - existingStrings.set(match[1], match[2]) + // Parse existing strings so we preserve every non-Auth0 resource verbatim. + const existingStrings = parseStrings(existingContent) + + const desired = { + com_auth0_client_id: escapeXml(clientId), + com_auth0_domain: escapeXml(domain), + com_auth0_scheme: escapeXml(scheme), } - // Update Auth0 values - existingStrings.set("com_auth0_client_id", clientId) - existingStrings.set("com_auth0_domain", domain) - existingStrings.set("com_auth0_scheme", scheme) + // Update only the Auth0 values; leave everything else untouched. + for (const [key, value] of Object.entries(desired)) { + existingStrings.set(key, value) + } - // Ensure app_name exists + // Ensure app_name exists (the manifest references it for the label). if (!existingStrings.has("app_name")) { existingStrings.set("app_name", "universal_components_android") } - // Build XML output - const lines = [''] - - // Write strings in a consistent order: app_name first, then Auth0 config, then others - const orderedKeys = ["app_name", "com_auth0_client_id", "com_auth0_domain", "com_auth0_scheme"] + // Build XML in a stable order: app_name, then the Auth0 config, then any + // remaining custom strings in their original discovery order. + const orderedKeys = ["app_name", ...AUTH0_KEYS] const writtenKeys = new Set() + const lines = [""] for (const key of orderedKeys) { if (existingStrings.has(key)) { @@ -52,25 +115,34 @@ export async function writeStringsFile(domain, clientId, scheme, stringsXmlPath) writtenKeys.add(key) } } - - // Write remaining strings for (const [key, value] of existingStrings) { if (!writtenKeys.has(key)) { lines.push(` ${value}`) } } + lines.push("") + lines.push("") // trailing newline - lines.push('') - lines.push('') // trailing newline + fs.writeFileSync(stringsXmlPath, lines.join("\n"), "utf-8") - const xmlContent = lines.join("\n") + // Verify: re-read the file and confirm the three Auth0 values are present + // with the expected content before reporting success — never trust a silent + // success. + const verifyStrings = parseStrings(fs.readFileSync(stringsXmlPath, "utf-8")) + const notApplied = Object.entries(desired).filter( + ([key, value]) => verifyStrings.get(key) !== value + ) - // Write file - fs.writeFileSync(stringsXmlPath, xmlContent, "utf-8") + if (notApplied.length > 0) { + spinner.warn("strings.xml did not verify after writing") + printManualInstructions(domain, clientId, scheme, stringsXmlPath) + return + } spinner.succeed(`Updated ${path.relative(process.cwd(), stringsXmlPath)}`) } catch (e) { spinner.fail("Failed to generate strings.xml") - throw e + console.warn(` ${e.message}`) + printManualInstructions(domain, clientId, scheme, stringsXmlPath) } } diff --git a/app/scripts/utils/tenant-config.mjs b/app/scripts/utils/tenant-config.mjs index dced943..27e63a8 100644 --- a/app/scripts/utils/tenant-config.mjs +++ b/app/scripts/utils/tenant-config.mjs @@ -3,6 +3,11 @@ import ora from "ora" import { auth0ApiCall } from "./auth0-api.mjs" import { ChangeAction, createChangeItem } from "./change-plan.mjs" +import { + extractMissingScope, + isPermissionError, + recordManualAction, +} from "./manual-actions.mjs" // ============================================================================ // CHECK FUNCTIONS - Determine what changes are needed @@ -116,6 +121,25 @@ export async function applyTenantSettingsChanges(changePlan) { await $`auth0 ${tenantSettingsArgs}` spinner.succeed("Updated tenant settings") } catch (e) { + // Missing update:tenant_settings should not abort the whole bootstrap — + // record it as a manual step and continue. The MFA-customization flag in + // particular is required for the MFA/Passkeys components in the demo, so + // we surface it clearly at the end. + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:tenant_settings" + spinner.warn( + `Skipped tenant settings — M2M app lacks scope: ${scope}` + ) + recordManualAction({ + resource: "Tenant Settings", + scope, + reason: + "Enables MFA customization in the post-login action, disables client connections, and sets the friendly name/logo.", + manualStep: + "Dashboard → Settings → enable required flags, OR grant the scope above to the M2M app and re-run the bootstrap.", + }) + return + } spinner.fail(`Failed to configure tenant settings`) throw e } @@ -151,6 +175,19 @@ export async function applyPromptSettingsChanges(changePlan) { await $`auth0 ${promptSettingsArgs}` spinner.succeed("Updated prompt settings") } catch (e) { + if (isPermissionError(e)) { + const scope = extractMissingScope(e) || "update:prompts" + spinner.warn(`Skipped prompt settings — M2M app lacks scope: ${scope}`) + recordManualAction({ + resource: "Prompt Settings", + scope, + reason: + "Enables the identifier-first login experience for Universal Login.", + manualStep: + "Dashboard → Authentication → Login → enable Identifier First, OR grant the scope above and re-run.", + }) + return + } spinner.fail(`Failed to configure prompt settings`) throw e } diff --git a/app/scripts/utils/validation.mjs b/app/scripts/utils/validation.mjs index 08d9450..a14f4d8 100644 --- a/app/scripts/utils/validation.mjs +++ b/app/scripts/utils/validation.mjs @@ -1,8 +1,126 @@ -import { $ } from "execa" +import { $, execaSync } from "execa" import ora from "ora" import fs from "node:fs" import path from "node:path" +import { auth0ApiCall, isSessionValid, isTokenCorrupted } from "./auth0-api.mjs" +import { confirmWithUser } from "./helpers.mjs" +import { MY_ACCOUNT_API_SCOPES } from "./resource-servers.mjs" + +// Timeout for CLI commands (15 seconds) +const CLI_TIMEOUT = 15000 + +// All scopes needed for the Android bootstrap operations. +// +// Each entry carries a one-line `reason` (surfaced in `--help` usage details) +// and an `important` flag. "Important" scopes are the write permissions that +// gate a user-visible feature or the self-grant capability — the ones most +// likely to be missing on an M2M app and to silently block setup. They are +// highlighted in the pre-login summary so you know why they are requested. +// +// NOTE: Organization-only scopes (create:organization_*) are intentionally +// omitted — the Android sample app configures the My Account feature only. +const BOOTSTRAP_SCOPE_METADATA = [ + { scope: "read:connection_profiles", reason: "Read connection profiles" }, + { scope: "create:connection_profiles", reason: "Create the connection profile" }, + { scope: "read:user_attribute_profiles", reason: "Read user attribute profiles" }, + { scope: "create:user_attribute_profiles", reason: "Create the user attribute profile" }, + { scope: "read:client_grants", reason: "Read existing client grants" }, + { scope: "create:client_grants", reason: "Grant the app access to the My Account API" }, + { + scope: "update:client_grants", + reason: + "Lets the M2M app grant itself any future scopes — without it, scope changes need the Dashboard (chicken-and-egg).", + important: true, + }, + { scope: "read:connections", reason: "Read database connections" }, + { scope: "create:connections", reason: "Create the database connection" }, + { + scope: "update:connections", + reason: "Enable the native app as a client of the connection (username/password login).", + important: true, + }, + { scope: "read:connections_options", reason: "Read connection options (auth methods)" }, + { + scope: "update:connections_options", + reason: "Enable passkeys on the connection so the Passkey option shows in Universal Login.", + important: true, + }, + { scope: "read:clients", reason: "Read existing applications" }, + { + scope: "create:clients", + reason: "Create the native Android application the sample app authenticates with.", + important: true, + }, + { + scope: "update:clients", + reason: "Set the app's callback + logout URLs so login/logout redirects resolve.", + important: true, + }, + { scope: "read:resource_servers", reason: "Read existing APIs" }, + { scope: "create:resource_servers", reason: "Register the My Account API resource server" }, + { scope: "update:resource_servers", reason: "Keep the My Account API scopes in sync" }, + { + scope: "update:tenant_settings", + reason: "Enable MFA customization in the post-login action for the MFA components.", + important: true, + }, + { + scope: "update:prompts", + reason: "Turn on identifier-first login, required for the Passkey prompt.", + important: true, + }, + { scope: "read:guardian_factors", reason: "Read enabled MFA factors" }, + { + scope: "update:guardian_factors", + reason: "Enable WebAuthn MFA factors so the MFA components have something to enroll.", + important: true, + }, +] + +// Flat scope-name list for `auth0 login --scopes` and any join operations. +const BOOTSTRAP_SCOPES = BOOTSTRAP_SCOPE_METADATA.map((s) => s.scope) + +/** + * Print the full per-scope rationale. Used by `--help` so a user who wants to + * know why each permission is requested can expand the usage details. Important + * scopes are marked with a star. + */ +export function printScopeUsageDetails() { + console.log( + `\nManagement API scopes requested at login (${BOOTSTRAP_SCOPES.length} total, ā˜… = key permission):\n` + ) + for (const { scope, reason, important } of BOOTSTRAP_SCOPE_METADATA) { + const marker = important ? "ā˜…" : " " + console.log(` ${marker} ${scope.padEnd(30)} ${reason}`) + } + console.log("") +} + +/** + * Print a summary of the scopes the bootstrap will request, shown right before + * an interactive login prompts for consent. Important scopes are flagged with a + * one-line reason so you know why elevated permissions are being requested; the + * full per-scope rationale is available via `npm run auth0:bootstrap --help`. + */ +function printScopeSummary() { + const important = BOOTSTRAP_SCOPE_METADATA.filter((s) => s.important) + + console.log( + `\nšŸ“‹ This login requests ${BOOTSTRAP_SCOPES.length} Management API scopes to configure your tenant.` + ) + console.log( + ` ${important.length} are key permissions that gate a user-visible feature or self-service setup:\n` + ) + for (const { scope, reason } of important) { + console.log(` • ${scope}`) + console.log(` ↳ ${reason}`) + } + console.log( + "\n Run with --help to see the reason for every requested scope.\n" + ) +} + /** * Check Node.js version */ @@ -24,7 +142,7 @@ export async function checkAuth0CLI() { }).start() try { - await $`auth0 --version` + await $({ timeout: CLI_TIMEOUT })`auth0 --version` cliCheck.succeed() } catch { cliCheck.fail( @@ -35,7 +153,444 @@ export async function checkAuth0CLI() { } /** - * Validate tenant configuration + * Read machine-to-machine (client-credentials) login parameters from the + * environment. When all three are present the script can authenticate the CLI + * non-interactively — no browser, no device code — which makes the bootstrap + * fully standalone (works in CI / headless shells) and sidesteps any tenant + * post-login Actions that only run on interactive logins. + * + * @param {string} domain - The tenant domain being configured (fallback for AUTH0_DOMAIN) + * @returns {{ domain: string, clientId: string, clientSecret: string } | null} + */ +/** + * Whether M2M client-credentials are configured (env or .env). Used by the + * bootstrap to decide if it can safely auto-confirm in a non-interactive run. + * @param {string} domain - The tenant domain being configured + * @returns {boolean} + */ +export function hasMachineCredentials(domain = null) { + return readMachineCredentials(domain) !== null +} + +function readMachineCredentials(domain = null) { + // Merge process env with an optional .env file in the scripts directory. + // Shell `export`s often do not survive into `npm run` child processes, so a + // local .env is the reliable channel for non-interactive credentials. + const fileEnv = readDotEnvFile() + + const clientId = (process.env.AUTH0_CLIENT_ID || fileEnv.AUTH0_CLIENT_ID)?.trim() + const clientSecret = ( + process.env.AUTH0_CLIENT_SECRET || fileEnv.AUTH0_CLIENT_SECRET + )?.trim() + const envDomain = + (process.env.AUTH0_DOMAIN || fileEnv.AUTH0_DOMAIN)?.trim() || domain + + if (clientId && clientSecret && envDomain) { + return { domain: envDomain, clientId, clientSecret } + } + + return null +} + +/** + * Read a minimal KEY=VALUE .env file from the scripts directory, if present. + * Only used to source M2M credentials; values already in process.env win. + * Supports optional surrounding quotes and ignores comments/blank lines. + * @returns {Record} + */ +function readDotEnvFile() { + try { + const envPath = path.resolve(process.cwd(), ".env") + if (!fs.existsSync(envPath)) return {} + + const out = {} + for (const rawLine of fs.readFileSync(envPath, "utf-8").split("\n")) { + const line = rawLine.trim() + if (!line || line.startsWith("#")) continue + const eq = line.indexOf("=") + if (eq === -1) continue + const key = line.slice(0, eq).trim() + let value = line.slice(eq + 1).trim() + // Strip a single pair of surrounding quotes. + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + out[key] = value + } + return out + } catch { + return {} + } +} + +/** + * Authenticate the Auth0 CLI using client credentials (machine-to-machine). + * This is non-interactive: it runs `auth0 login --no-input --domain ... + * --client-id ... --client-secret ...`. Requires an M2M application on the + * tenant that is authorized for the Auth0 Management API with the bootstrap + * scopes. + * + * @param {{ domain: string, clientId: string, clientSecret: string }} creds + * @returns {Promise} True if login was successful + */ +async function runAuth0MachineLogin(creds) { + const spinner = ora({ + text: `Authenticating with client credentials (${creds.domain})`, + }).start() + + try { + const args = [ + "login", + "--no-input", + "--domain", + creds.domain, + "--client-id", + creds.clientId, + "--client-secret", + creds.clientSecret, + ] + + // Machine login is a quick token exchange; give it a generous timeout but + // it should return in a second or two. stdio is captured (not inherited) + // so the client secret is never echoed to the terminal. + await execaSync("auth0", args, { timeout: 30000 }) + spinner.succeed(`Authenticated with client credentials (${creds.domain})`) + return true + } catch (e) { + spinner.fail("Client-credentials login failed") + if (e.timedOut) { + console.error("\nāŒ Machine login timed out. Please try again.") + } else { + // The CLI prints the useful detail (bad client-id/secret/domain) on + // stderr; surface it without leaking the secret we passed in. + const detail = (e.stderr || e.shortMessage || e.message || "") + .split("\n") + .filter((l) => !l.includes(creds.clientSecret)) + .join("\n") + .trim() + console.error(`\nāŒ Login failed: ${detail}`) + console.error( + " Verify AUTH0_CLIENT_ID / AUTH0_CLIENT_SECRET / AUTH0_DOMAIN belong" + ) + console.error( + " to an M2M app authorized for the Management API on this tenant.\n" + ) + } + return false + } +} + +/** + * Ensure the M2M app's Management API client grant holds every bootstrap scope, + * self-granting the missing ones when possible. + * + * The chicken-and-egg: an M2M app can only add scopes to a client grant (its + * own included) if its token already carries `update:client_grants`. Once that + * one scope is granted in the Dashboard, the app can grant itself all the + * others — so this closes the gap automatically on every subsequent run and no + * further Dashboard visits are needed. + * + * Because the CLI's cached access token predates the PATCH, the caller must + * re-authenticate afterwards for the new scopes to take effect — this function + * only performs the grant and reports whether one happened. + * + * @param {{ domain: string, clientId: string }} creds - M2M credentials + * @returns {Promise} True if scopes were added (a re-login is needed) + */ +async function ensureManagementScopes(creds) { + const audience = `https://${creds.domain}/api/v2/` + + // Find this app's Management API client grant. + let grants + try { + grants = await auth0ApiCall( + "get", + `client-grants?client_id=${encodeURIComponent(creds.clientId)}` + ) + } catch { + // Reading grants itself needs read:client_grants; if we can't, stay silent + // and let the individual apply steps report their own missing scopes. + return false + } + + const list = Array.isArray(grants) ? grants : grants?.client_grants || [] + const grant = list + .filter((g) => g.audience === audience) + .sort((a, b) => (b.scope?.length || 0) - (a.scope?.length || 0))[0] + + if (!grant) return false + + const current = new Set(grant.scope || []) + const missing = BOOTSTRAP_SCOPES.filter((s) => !current.has(s)) + if (missing.length === 0) return false + + // We can only patch the grant if the token can write client grants. + if (!current.has("update:client_grants")) { + const spinner = ora({ + text: "Checking M2M Management API scopes", + }).start() + spinner.warn( + `M2M app is missing ${missing.length} scope(s), including the self-grant ` + + `permission (update:client_grants) needed to add them automatically.` + ) + console.log( + "\n Grant update:client_grants once in the Dashboard and the script will\n" + + " self-grant the rest on the next run. Missing scopes:\n" + ) + for (const s of missing) console.log(` • ${s}`) + console.log("") + return false + } + + const spinner = ora({ + text: `Self-granting ${missing.length} missing Management API scope(s)`, + }).start() + + try { + const updatedScopes = [...(grant.scope || []), ...missing] + await auth0ApiCall("patch", `client-grants/${grant.id}`, { + scope: updatedScopes, + }) + + // Verify the grant actually holds the new scopes before claiming success. + const verify = await auth0ApiCall("get", `client-grants/${grant.id}`) + const now = new Set(verify?.scope || []) + const stillMissing = missing.filter((s) => !now.has(s)) + + if (stillMissing.length > 0) { + spinner.warn( + `Could not self-grant: ${stillMissing.join(", ")} — grant them manually.` + ) + return false + } + + spinner.succeed( + `Self-granted ${missing.length} scope(s): ${missing.join(", ")}` + ) + return true + } catch (e) { + spinner.warn(`Could not self-grant missing scopes: ${e.message}`) + return false + } +} + +/** + * Run Auth0 CLI login interactively with the required scopes + * @param {string} domain - Optional tenant domain to login to + * @returns {Promise} True if login was successful + */ +async function runAuth0Login(domain = null) { + // Explain what is being requested before the browser consent screen appears. + printScopeSummary() + + console.log("šŸ” Starting Auth0 CLI login...\n") + console.log(" A browser window will open for authentication.") + console.log(" Please complete the login process.\n") + + try { + // Build login args with required scopes + const scopesArg = BOOTSTRAP_SCOPES.join(",") + const args = ["login", "--scopes", scopesArg] + + // Add domain if specified + if (domain) { + args.push("--domain", domain) + } + + // Run login in interactive mode (no --no-input flag). + // Use stdio: 'inherit' to allow interactive browser-based login. + execaSync("auth0", args, { + stdio: "inherit", + timeout: 120000, // 2 minute timeout for login process + }) + return true + } catch (e) { + if (e.timedOut) { + console.error("\nāŒ Login timed out. Please try again.") + } else { + console.error(`\nāŒ Login failed: ${e.message}`) + } + return false + } +} + +/** + * Clear a corrupted CLI token by running `auth0 logout`. A malformed token can + * only be fixed by logging out first; a subsequent login then succeeds. + * @param {string} domain - Tenant to log out of (falls back to a plain logout) + * @returns {Promise} + */ +async function clearCorruptedToken(domain = null) { + const spinner = ora({ + text: `Clearing corrupted Auth0 CLI token`, + }).start() + try { + const args = domain ? ["logout", domain] : ["logout"] + execaSync("auth0", args, { timeout: CLI_TIMEOUT }) + spinner.succeed("Cleared corrupted token — a fresh login is required") + } catch { + // A logout failure is non-fatal; the subsequent login attempt may still fix it. + spinner.warn("Could not run 'auth0 logout' automatically — continuing") + } +} + +/** + * Validate the Auth0 CLI session and, if it is expired, restore it. + * + * Login strategy (in priority order): + * 1. If a valid session already exists, do nothing. + * 2. If the stored token is corrupted, clear it with `auth0 logout` first. + * 3. If M2M credentials are present (env or scripts/.env), authenticate + * non-interactively via client credentials. This keeps the bootstrap + * standalone (headless / CI) and avoids interactive-only post-login + * Actions on the tenant. + * 4. Otherwise fall back to an interactive browser login (device code). + * + * @param {string} domain - Optional tenant domain (fallback for AUTH0_DOMAIN) + * @returns {Promise} + */ +export async function validateAuth0Session(domain = null) { + const spinner = ora({ + text: `Validating Auth0 CLI session`, + }).start() + + // When M2M credentials are available for the requested domain, authenticate + // that specific tenant non-interactively regardless of whatever session may + // currently be active. A valid session for a *different* tenant must not let + // the run proceed against the wrong tenant, and re-authenticating is cheap. + const machineCreds = readMachineCredentials(domain) + if (machineCreds) { + spinner.info("Using M2M client-credentials login for the requested tenant") + + // A corrupted token blocks even a fresh login until it is cleared. + if (await isTokenCorrupted()) { + console.log( + "\nāš ļø The stored Auth0 CLI token is corrupted; clearing it before re-login.\n" + ) + await clearCorruptedToken(machineCreds.domain) + } + + console.log( + "\nšŸ” Authenticating with M2M client credentials (no browser required).\n" + ) + const loginSuccess = await runAuth0MachineLogin(machineCreds) + + if (loginSuccess) { + // A fresh login (or a prior logout) can leave a different tenant active. + // Make the requested tenant active so discovery targets the right one. + await switchToTenant(machineCreds.domain) + + const postLoginValid = await isSessionValid() + if (postLoginValid) { + // If the app can self-grant, top up any missing bootstrap scopes now. + // The freshly issued token predates the grant change, so re-authenticate + // (and re-select the tenant) for the new scopes to take effect. + const grantedMore = await ensureManagementScopes(machineCreds) + if (grantedMore) { + console.log( + "\nšŸ”„ Re-authenticating so the newly granted scopes take effect.\n" + ) + if (await runAuth0MachineLogin(machineCreds)) { + await switchToTenant(machineCreds.domain) + } + } + console.log("\nāœ… Successfully authenticated the Auth0 CLI\n") + return + } + console.error("\nāŒ Session validation failed after machine login.") + console.error( + " The M2M app may lack the required Management API scopes.\n" + ) + process.exit(1) + } + + // Machine login was attempted but failed — do not silently fall back to an + // interactive prompt in what is meant to be a non-interactive environment. + console.error( + "\nāŒ Client-credentials login failed. Fix the credentials/scopes and retry," + ) + console.error( + " or unset AUTH0_CLIENT_ID/SECRET to use interactive browser login.\n" + ) + process.exit(1) + } + + // No M2M credentials available — fall back to the interactive path. If a + // session is already valid, nothing to do (tenant matching is verified later + // in validateTenant). + const sessionValid = await isSessionValid() + if (sessionValid) { + spinner.succeed("Auth0 CLI session is valid") + return + } + + spinner.warn("Auth0 CLI session appears to be expired or invalid") + + // A corrupted token cannot be refreshed — clear it before an interactive login. + if (await isTokenCorrupted()) { + console.log( + "\nāš ļø The stored Auth0 CLI token is corrupted; clearing it before re-login.\n" + ) + await clearCorruptedToken(domain) + } + + const shouldLogin = await confirmWithUser( + "Would you like to login to Auth0 CLI now?" + ) + + if (!shouldLogin) { + console.error("\nāŒ Cannot proceed without a valid Auth0 CLI session.") + console.error(" Please run 'auth0 login' manually and try again.\n") + process.exit(1) + } + + const loginSuccess = await runAuth0Login(domain) + + if (!loginSuccess) { + console.error("\nāŒ Login was not successful. Please try again.\n") + process.exit(1) + } + + // Verify the session is now valid + const postLoginValid = await isSessionValid() + if (!postLoginValid) { + console.error("\nāŒ Session validation failed after login.") + console.error( + " Please check your Auth0 CLI configuration and try again.\n" + ) + process.exit(1) + } + + console.log("\nāœ… Successfully logged in to Auth0 CLI\n") +} + +/** + * Switch to a different tenant using `auth0 tenants use` + * @param {string} tenantName - Tenant domain to switch to + * @returns {Promise} True if the switch was successful + */ +async function switchToTenant(tenantName) { + const spinner = ora({ + text: `Switching to tenant: ${tenantName}`, + }).start() + + try { + await $({ timeout: CLI_TIMEOUT })`auth0 tenants use ${tenantName} --no-input` + spinner.succeed(`Switched to tenant: ${tenantName}`) + return true + } catch { + spinner.fail(`Failed to switch to tenant: ${tenantName}`) + return false + } +} + +/** + * Validate tenant configuration. If the requested tenant does not match the + * active CLI tenant, the script offers to switch to it (or login to it) and + * then retries — instead of hard-failing. * @param {string} tenantName - Required tenant name from command line argument */ export async function validateTenant(tenantName) { @@ -55,33 +610,92 @@ export async function validateTenant(tenantName) { }).start() try { - const tenantSettingsArgs = ["tenants", "list", "--csv"] - const { stdout } = await $`auth0 ${tenantSettingsArgs}` + // Get current tenant from CLI + // NOTE: we output CSV here due to a bug in the Auth0 CLI that doesn't + // respect the --json flag: https://github.com/auth0/auth0-cli/pull/1002 + const tenantSettingsArgs = ["tenants", "list", "--csv", "--no-input"] + const { stdout } = await $({ timeout: CLI_TIMEOUT })`auth0 ${tenantSettingsArgs}` - const cliDomain = stdout + // Parse all available tenants and find the active one + const tenantLines = stdout .split("\n") .slice(1) + .filter((line) => line.trim()) + const availableTenants = tenantLines + .map((line) => line.split(",")[1]?.trim()) + .filter(Boolean) + + // Get the active tenant (marked with →) + const cliDomain = tenantLines .find((line) => line.includes("→")) ?.split(",")[1] ?.trim() if (!cliDomain) { spinner.fail("No active tenant found in Auth0 CLI") - console.error("\nāŒ Please login to Auth0 CLI first:") - console.error(" 1. Run: auth0 login") - console.error( - " 2. If you have multiple tenants, run: auth0 tenants use " + console.error("\nāŒ No active tenant configured.") + + const shouldLogin = await confirmWithUser( + `Would you like to login to ${tenantName}?` ) + + if (shouldLogin) { + const loginSuccess = await runAuth0Login(tenantName) + if (loginSuccess) { + // Retry tenant validation after login + return validateTenant(tenantName) + } + } + + console.error("\nāŒ Cannot proceed without an active tenant.") + console.error(" Please run 'auth0 login' and try again.\n") process.exit(1) } + // Verify the provided tenant name matches the CLI active tenant if (tenantName !== cliDomain) { spinner.fail("Tenant mismatch detected") console.error(`\nāŒ Tenant mismatch:`) - console.error(` Requested tenant: ${tenantName}`) - console.error(` CLI is using: ${cliDomain}`) - console.error("\nPlease ensure you're using the correct tenant:") - console.error(` Run: auth0 tenants use ${tenantName}`) + console.error(` Requested tenant: ${tenantName}`) + console.error(` CLI is using: ${cliDomain}`) + + // Check if the requested tenant is in the list of available tenants + const tenantAvailable = availableTenants.includes(tenantName) + + if (tenantAvailable) { + // Tenant exists, offer to switch + console.error(`\n The tenant "${tenantName}" is available in your CLI.`) + const shouldSwitch = await confirmWithUser( + `Would you like to switch to ${tenantName}?` + ) + + if (shouldSwitch) { + const switchSuccess = await switchToTenant(tenantName) + if (switchSuccess) { + // Retry tenant validation after switching + return validateTenant(tenantName) + } + } + } else { + // Tenant not in list, offer to login + console.error( + `\n The tenant "${tenantName}" is not in your CLI's tenant list.` + ) + console.error(` You may need to login to this tenant.`) + const shouldLogin = await confirmWithUser( + `Would you like to login to ${tenantName}?` + ) + + if (shouldLogin) { + const loginSuccess = await runAuth0Login(tenantName) + if (loginSuccess) { + // Retry tenant validation after login + return validateTenant(tenantName) + } + } + } + + console.error("\nāŒ Cannot proceed with mismatched tenant.") console.error( "\nThis is a safety measure to prevent accidentally configuring the wrong tenant." ) @@ -91,6 +705,29 @@ export async function validateTenant(tenantName) { spinner.succeed(`Validated tenant: ${cliDomain}`) return cliDomain } catch (e) { + // Handle timeout errors specifically + if (e.timedOut) { + spinner.fail("Auth0 CLI command timed out") + console.error("\nāŒ The Auth0 CLI is not responding.") + console.error(" This usually means your session has expired.\n") + + const shouldLogin = await confirmWithUser( + `Would you like to login to ${tenantName}?` + ) + + if (shouldLogin) { + const loginSuccess = await runAuth0Login(tenantName) + if (loginSuccess) { + // Retry tenant validation after login + return validateTenant(tenantName) + } + } + + console.error("\nāŒ Cannot proceed without a valid session.") + console.error(" Please run 'auth0 login' and try again.\n") + process.exit(1) + } + spinner.fail("Failed to validate tenant") console.error(e) process.exit(1) @@ -98,7 +735,62 @@ export async function validateTenant(tenantName) { } /** - * Validate Android project structure and extract configuration + * Warn (softly) if the tenant's My Account API is missing MFA scopes required + * by the sample app. This is informational only — the bootstrap can still + * create/enable the My Account API, and the missing scopes typically require + * Auth0 support to enable on the tenant. + * @param {object} resources - Discovered resources from the tenant + * @param {string} domain - The tenant domain + */ +export function validateMyAccountScopes(resources, domain) { + const spinner = ora({ + text: `Validating My Account API scopes`, + }).start() + + const myAccountApi = resources.resourceServers.find( + (rs) => rs.identifier === `https://${domain}/me/` + ) + + // If the API doesn't exist yet, the bootstrap will create it — nothing to warn about. + if (!myAccountApi) { + spinner.info( + "My Account API not found — it will be created during bootstrap" + ) + return + } + + const availableScopes = myAccountApi.scopes?.map((s) => s.value) || [] + const missingScopes = MY_ACCOUNT_API_SCOPES.filter( + (scope) => !availableScopes.includes(scope) + ) + + if (missingScopes.length > 0) { + spinner.warn("Some My Account API scopes are not available on this tenant") + console.log("") + console.log("āš ļø My Account API") + console.log(` Missing scope(s):`) + missingScopes.forEach((scope) => console.log(` - ${scope}`)) + console.log( + " Suggestion: Contact Auth0 support to enable these scopes on your tenant." + ) + console.log( + " The bootstrap will continue with the scopes that are available.\n" + ) + return + } + + spinner.succeed("My Account API scopes are available") +} + +/** + * Validate Android project structure and extract configuration. + * + * The generated Auth0 config (domain, client id, callback scheme) is written + * into the runnable sample app module's strings.xml, which is where the + * Auth0.Android SDK reads `com_auth0_domain` / `com_auth0_client_id` at launch + * and where the `auth0Domain` / `auth0Scheme` manifest placeholders resolve + * from (driving RedirectActivity). + * * @returns {{ packageName: string, stringsXmlPath: string }} */ export function validateAndroidProject() { @@ -133,21 +825,22 @@ export function validateAndroidProject() { process.exit(1) } - // Extract applicationId from build.gradle + // Extract applicationId (falls back to namespace) from build.gradle. This is + // the package name Auth0.Android embeds in its redirect URL. const buildGradleContent = fs.readFileSync(buildGradlePath, "utf-8") - const appIdMatch = buildGradleContent.match( - /applicationId\s*[=:]\s*["']([^"']+)["']/ - ) + const appIdMatch = + buildGradleContent.match(/applicationId\s*[=:]\s*["']([^"']+)["']/) || + buildGradleContent.match(/namespace\s*[=:]\s*["']([^"']+)["']/) if (!appIdMatch) { - spinner.fail("Could not extract applicationId from app/build.gradle") + spinner.fail( + "Could not extract applicationId/namespace from app/build.gradle" + ) process.exit(1) } const packageName = appIdMatch[1] - spinner.succeed( - `Validated Android project (package: ${packageName})` - ) + spinner.succeed(`Validated Android project (package: ${packageName})`) return { packageName, stringsXmlPath } }