From b4b7ff6c6f418f61c1b8787439d12abf53c344b7 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 10:53:00 -0600 Subject: [PATCH 01/16] added oauth 2.1 check --- src/commands/check-oauth-2-1.ts | 790 ++++++++++++++++++++++++++++++++ src/commands/index.ts | 1 + 2 files changed, 791 insertions(+) create mode 100644 src/commands/check-oauth-2-1.ts diff --git a/src/commands/check-oauth-2-1.ts b/src/commands/check-oauth-2-1.ts new file mode 100644 index 0000000..b9b4ff7 --- /dev/null +++ b/src/commands/check-oauth-2-1.ts @@ -0,0 +1,790 @@ +import {Command, Option} from "@commander-js/extra-typings"; +import { + Application, + FusionAuthClient, + GrantType, + Oauth2AuthorizedURLValidationPolicy, + ProofKeyForCodeExchangePolicy, + RefreshTokenUsagePolicy, + Tenant, +} from '@fusionauth/typescript-client'; +import chalk from "chalk"; +import {errorAndExit} from '../utils.js'; +import {apiKeyOption, hostOption} from "../options.js"; + +// -- Types ------------------------------------------------------------------- + +type CheckSeverity = 'required' | 'warning'; + +interface CheckResult { + name: string; + passed: boolean; + severity: CheckSeverity; + message: string; + details?: string[]; + specSection?: string; + specUrl?: string; +} + +interface AppCheckContext { + app: Application; + appName: string; + appId: string; +} + +interface JsonOutput { + compliant: boolean; + tenantsChecked: number; + applicationsChecked: number; + applicationsSkipped: number; + filters: { + tenantId: string | null; + applicationId: string | null; + }; + checks: Record; + criticalIssues: string[]; + warnings: string[]; + educationalLinks: Record; +} + +// -- Options ----------------------------------------------------------------- + +const applicationIdOption = new Option( + '--application-id ', + 'Check a specific application only' +); + +const tenantIdOption = new Option( + '--tenant-id ', + 'Check all applications in a specific tenant' +); + +const strictOption = new Option( + '--strict', + 'Fail if deprecated grants (Implicit or Password) are enabled' +).default(false); + +const jsonOption = new Option( + '--json', + 'Output results as JSON' +).default(false); + +const verboseOption = new Option( + '--verbose', + 'Show detailed per-application breakdown' +).default(false); + +// -- Helpers ----------------------------------------------------------------- + +const SPEC_BASE = 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15'; + +/** Well-known fixed ID of the FusionAuth admin UI application. */ +const FUSIONAUTH_APP_ID = '3c219e58-ed0e-4b18-ad48-f4f92793ae32'; + +/** Immutable role ID for the Tenant Manager 'admin' role. */ +const TENANT_MANAGER_ADMIN_ROLE_ID = '631ecd9d-8d40-4c13-8277-80cedb823714'; + +function specUrl(section: string): string { + return `${SPEC_BASE}#section-${section}`; +} + +function isLocalhostUri(uri: string): boolean { + try { + const url = new URL(uri); + return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1'; + } catch { + return false; + } +} + +/** + * Detect built-in FusionAuth applications that are not under the developer's + * control and should be excluded from all OAuth 2.1 checks. + * + * - FusionAuth admin UI: identified by its fixed well-known application ID. + * - Tenant Manager: identified by the combination of being a universal + * application, having a single redirect of "/tenant-manager", and containing + * a role with the immutable ID 631ecd9d-8d40-4c13-8277-80cedb823714 named + * "admin". + */ +function isBuiltInApplication(app: Application): boolean { + // FusionAuth admin UI + if (app.id === FUSIONAUTH_APP_ID) { + return true; + } + + // Tenant Manager — universalConfiguration is not in the v1.47.0 client + // types (added in v1.58.0), so access it dynamically. + const universal = (app as Record).universalConfiguration as Record | undefined; + if (universal?.universal !== true) { + return false; + } + + const redirects = app.oauthConfiguration?.authorizedRedirectURLs || []; + if (redirects.length !== 1 || redirects[0] !== '/tenant-manager') { + return false; + } + + const hasAdminRole = app.roles?.some( + r => r.id === TENANT_MANAGER_ADMIN_ROLE_ID && r.name === 'admin' + ) ?? false; + + return hasAdminRole; +} + +function shouldCheckApplication(app: Application): boolean { + if (isBuiltInApplication(app)) { + return false; + } + const grants = app.oauthConfiguration?.enabledGrants || []; + return grants.includes(GrantType.authorization_code) && grants.includes(GrantType.refresh_token); +} + +function getAppName(app: Application): string { + return app.name || 'Unnamed Application'; +} + +function getAppId(app: Application): string { + return app.id || 'unknown'; +} + +// -- Individual Checks ------------------------------------------------------- + +function checkPkce(ctx: AppCheckContext): CheckResult | null { + const policy = ctx.app.oauthConfiguration?.proofKeyForCodeExchangePolicy; + if (policy === ProofKeyForCodeExchangePolicy.Required) { + return null; // pass + } + return { + name: 'pkce', + passed: false, + severity: 'required', + message: `Application "${ctx.appName}" (${ctx.appId}): PKCE policy is "${policy || 'not set'}" (must be "Required")`, + specSection: '7.5', + specUrl: specUrl('7.5'), + }; +} + +function checkRedirectUriValidation(ctx: AppCheckContext): CheckResult | null { + const policy = ctx.app.oauthConfiguration?.authorizedURLValidationPolicy; + if (policy === Oauth2AuthorizedURLValidationPolicy.ExactMatch) { + return null; // pass + } + return { + name: 'redirectUriValidation', + passed: false, + severity: 'required', + message: `Application "${ctx.appName}" (${ctx.appId}): Redirect URI validation is "${policy || 'not set'}" (must be "ExactMatch")`, + specSection: '4.1.3', + specUrl: specUrl('4.1.3'), + }; +} + +function checkHttpsEnforcement(ctx: AppCheckContext): CheckResult[] { + const redirectUris = ctx.app.oauthConfiguration?.authorizedRedirectURLs || []; + const failures: CheckResult[] = []; + + for (const uri of redirectUris) { + if (uri.startsWith('https://')) { + continue; + } + if (isLocalhostUri(uri)) { + continue; + } + failures.push({ + name: 'httpsEnforcement', + passed: false, + severity: 'required', + message: `Application "${ctx.appName}" (${ctx.appId}): Non-HTTPS redirect URI: ${uri}`, + specSection: '1.5', + specUrl: specUrl('1.5'), + }); + } + return failures; +} + +function checkRefreshTokenRotation(ctx: AppCheckContext): CheckResult | null { + const policy = ctx.app.jwtConfiguration?.refreshTokenUsagePolicy; + if (policy === RefreshTokenUsagePolicy.OneTimeUse) { + return null; // pass + } + return { + name: 'refreshTokenRotation', + passed: false, + severity: 'required', + message: `Application "${ctx.appName}" (${ctx.appId}): Refresh token usage policy is "${policy || 'not set'}" (must be "OneTimeUse")`, + specSection: '4.3', + specUrl: specUrl('4.3'), + }; +} + +function checkDeprecatedGrants(ctx: AppCheckContext, strict: boolean): CheckResult[] { + const grants = ctx.app.oauthConfiguration?.enabledGrants || []; + const failures: CheckResult[] = []; + const severity: CheckSeverity = strict ? 'required' : 'warning'; + + if (grants.includes(GrantType.implicit)) { + failures.push({ + name: 'deprecatedGrants', + passed: false, + severity, + message: `Application "${ctx.appName}" (${ctx.appId}): Implicit grant is enabled (removed in OAuth 2.1)`, + specSection: '10.1', + specUrl: specUrl('10.1'), + }); + } + if (grants.includes(GrantType.password)) { + failures.push({ + name: 'deprecatedGrants', + passed: false, + severity, + message: `Application "${ctx.appName}" (${ctx.appId}): Password grant is enabled (removed in OAuth 2.1)`, + specSection: '10', + specUrl: specUrl('10'), + }); + } + return failures; +} + +function checkDpop(ctx: AppCheckContext, hasLicense: boolean): CheckResult | null { + if (!hasLicense) { + return { + name: 'dpop', + passed: false, + severity: 'warning', + message: `Application "${ctx.appName}" (${ctx.appId}): DPoP unavailable (Enterprise license required)`, + specSection: '1.4.3', + specUrl: specUrl('1.4.3'), + }; + } + + // The TypeScript client v1.47.0 does not include the DPoP field. + // Access it dynamically from the JWT configuration. + const jwtConfig = ctx.app.jwtConfiguration as Record | undefined; + const dpopEnabled = jwtConfig?.['enabledDemonstratingProofOfPossession'] === true; + + if (!dpopEnabled) { + return { + name: 'dpop', + passed: false, + severity: 'warning', + message: `Application "${ctx.appName}" (${ctx.appId}): DPoP not enabled (recommended for sender-constrained tokens)`, + specSection: '1.4.3', + specUrl: specUrl('1.4.3'), + }; + } + return null; // pass +} + +function checkTenantIssuer(tenant: Tenant): CheckResult | null { + const issuer = tenant.issuer || ''; + const tenantName = tenant.name || 'Unnamed Tenant'; + const tenantId = tenant.id || 'unknown'; + + if (issuer && issuer !== 'acme.com') { + return null; // pass + } + + const reason = issuer === 'acme.com' + ? 'still set to default "acme.com"' + : 'not configured'; + + return { + name: 'tenantIssuer', + passed: false, + severity: 'required', + message: `Tenant "${tenantName}" (${tenantId}): Issuer ${reason} (must be set to your domain)`, + }; +} + +function checkRefreshTokenRevocationOnReuse(tenant: Tenant): CheckResult | null { + const tenantName = tenant.name || 'Unnamed Tenant'; + const tenantId = tenant.id || 'unknown'; + + // The TypeScript client v1.47.0 does not include onOneTimeTokenReuse. + // Access it dynamically from the revocation policy. + const revocationPolicy = tenant.jwtConfiguration?.refreshTokenRevocationPolicy as Record | undefined; + const onReuse = revocationPolicy?.['onOneTimeTokenReuse'] === true; + + if (onReuse) { + return null; // pass + } + + return { + name: 'refreshTokenRevocationOnReuse', + passed: false, + severity: 'required', + message: `Tenant "${tenantName}" (${tenantId}): Refresh token revocation on one-time token reuse is not enabled`, + details: [ + 'Set tenant.jwtConfiguration.refreshTokenRevocationPolicy.onOneTimeTokenReuse = true', + 'This detects token theft when a one-time use refresh token is replayed.', + ], + specSection: '4.3', + specUrl: specUrl('4.3'), + }; +} + +function checkAuthCodeLifetime(tenant: Tenant): CheckResult | null { + const tenantName = tenant.name || 'Unnamed Tenant'; + const tenantId = tenant.id || 'unknown'; + const ttl = tenant.externalIdentifierConfiguration?.authorizationGrantIdTimeToLiveInSeconds; + + if (ttl === undefined || ttl === null) { + return null; // can't check, skip + } + + if (ttl <= 600) { + return null; // pass + } + + return { + name: 'authCodeLifetime', + passed: false, + severity: 'warning', + message: `Tenant "${tenantName}" (${tenantId}): Authorization code lifetime is ${ttl} seconds (recommend <= 600)`, + specSection: '7.5', + specUrl: specUrl('7.5'), + }; +} + +// -- Main Action ------------------------------------------------------------- + +const action = async function (options: { + key: string; + host: string; + applicationId?: string; + tenantId?: string; + strict: boolean; + json: boolean; + verbose: boolean; +}) { + const {key: apiKey, host, applicationId, tenantId, strict, json: jsonOutput, verbose} = options; + + if (!jsonOutput) { + console.log(chalk.blue(`Checking OAuth 2.1 compliance on ${host}...`)); + console.log(chalk.blue(`Reference: draft-ietf-oauth-v2-1-15\n`)); + } + + const results: CheckResult[] = []; + + try { + const client = new FusionAuthClient(apiKey, host); + + // -- Fetch data ------------------------------------------------------ + + // License status (for DPoP check) + let hasLicense = false; + try { + const reactorResponse = await client.retrieveReactorStatus(); + if (reactorResponse.wasSuccessful()) { + hasLicense = reactorResponse.response.status?.licensed === true; + } + } catch { + // If we can't check reactor status, assume no license + } + + // Tenants + const tenantResponse = await client.retrieveTenants(); + if (!tenantResponse.wasSuccessful() || !tenantResponse.response.tenants) { + errorAndExit('Failed to retrieve tenants.'); + return; + } + + let tenants = tenantResponse.response.tenants; + if (tenantId) { + tenants = tenants.filter(t => t.id === tenantId); + if (tenants.length === 0) { + errorAndExit(`Tenant with ID "${tenantId}" not found.`); + return; + } + } + + // Applications + let allApps: Application[] = []; + if (applicationId) { + const appResponse = await client.retrieveApplication(applicationId); + if (!appResponse.wasSuccessful() || !appResponse.response.application) { + errorAndExit(`Application with ID "${applicationId}" not found.`); + return; + } + allApps = [appResponse.response.application]; + } else { + const appsResponse = await client.retrieveApplications(); + if (!appsResponse.wasSuccessful() || !appsResponse.response.applications) { + errorAndExit('Failed to retrieve applications.'); + return; + } + allApps = appsResponse.response.applications; + } + + // Filter applications by tenant if needed + if (tenantId && !applicationId) { + allApps = allApps.filter(app => app.tenantId === tenantId); + } + + // Separate into checked and skipped + const appsToCheck = allApps.filter(shouldCheckApplication); + const skippedApps = allApps.filter(app => !shouldCheckApplication(app)); + + if (!jsonOutput) { + console.log(chalk.cyan(`Tenants checked: ${tenants.length}`)); + console.log(chalk.cyan(`Applications checked: ${appsToCheck.length}${tenantId ? ' (in selected tenant)' : applicationId ? '' : ' (across all tenants)'}`)); + console.log(chalk.cyan(`Applications skipped: ${skippedApps.length} (built-in FusionAuth apps or not using authorization_code + refresh_token grants)\n`)); + } + + if (appsToCheck.length === 0 && !applicationId) { + if (!jsonOutput) { + console.log(chalk.yellow('No applications found using both authorization_code and refresh_token grants.')); + console.log(chalk.yellow('Nothing to check for OAuth 2.1 compliance.')); + } + } + + // -- Tenant-level checks --------------------------------------------- + + for (const tenant of tenants) { + const issuerResult = checkTenantIssuer(tenant); + if (issuerResult) results.push(issuerResult); + + const revocationResult = checkRefreshTokenRevocationOnReuse(tenant); + if (revocationResult) results.push(revocationResult); + + const authCodeResult = checkAuthCodeLifetime(tenant); + if (authCodeResult) results.push(authCodeResult); + } + + // -- Application-level checks ---------------------------------------- + + for (const app of appsToCheck) { + const ctx: AppCheckContext = { + app, + appName: getAppName(app), + appId: getAppId(app), + }; + + if (verbose && !jsonOutput) { + console.log(chalk.cyan(`\nApplication: "${ctx.appName}" (${ctx.appId})`)); + } + + // Required checks + const pkceResult = checkPkce(ctx); + if (pkceResult) results.push(pkceResult); + if (verbose && !jsonOutput) { + console.log(pkceResult + ? chalk.red(` ✗ PKCE: ${ctx.app.oauthConfiguration?.proofKeyForCodeExchangePolicy || 'not set'}`) + : chalk.green(` ✓ PKCE: Required`)); + } + + const redirectResult = checkRedirectUriValidation(ctx); + if (redirectResult) results.push(redirectResult); + if (verbose && !jsonOutput) { + console.log(redirectResult + ? chalk.red(` ✗ Redirect URI validation: ${ctx.app.oauthConfiguration?.authorizedURLValidationPolicy || 'not set'}`) + : chalk.green(` ✓ Redirect URI validation: ExactMatch`)); + } + + const httpsResults = checkHttpsEnforcement(ctx); + results.push(...httpsResults); + if (verbose && !jsonOutput) { + if (httpsResults.length > 0) { + for (const r of httpsResults) { + console.log(chalk.red(` ✗ ${r.message}`)); + } + } else { + console.log(chalk.green(` ✓ HTTPS enforcement: All redirect URIs valid`)); + } + } + + const rotationResult = checkRefreshTokenRotation(ctx); + if (rotationResult) results.push(rotationResult); + if (verbose && !jsonOutput) { + console.log(rotationResult + ? chalk.red(` ✗ Refresh token rotation: ${ctx.app.jwtConfiguration?.refreshTokenUsagePolicy || 'not set'}`) + : chalk.green(` ✓ Refresh token rotation: OneTimeUse`)); + } + + // Warning checks + const dpopResult = checkDpop(ctx, hasLicense); + if (dpopResult) results.push(dpopResult); + if (verbose && !jsonOutput) { + if (dpopResult) { + console.log(chalk.yellow(` ⚠ ${dpopResult.message}`)); + } else { + console.log(chalk.green(` ✓ DPoP: Enabled`)); + } + } + + const deprecatedResults = checkDeprecatedGrants(ctx, strict); + results.push(...deprecatedResults); + if (verbose && !jsonOutput) { + if (deprecatedResults.length > 0) { + for (const r of deprecatedResults) { + const icon = strict ? '✗' : '⚠'; + const color = strict ? chalk.red : chalk.yellow; + console.log(color(` ${icon} ${r.message}`)); + } + } else { + console.log(chalk.green(` ✓ No deprecated grants enabled`)); + } + } + } + + // -- Aggregate results ----------------------------------------------- + + const criticalFailures = results.filter(r => r.severity === 'required' && !r.passed); + const warnings = results.filter(r => r.severity === 'warning' && !r.passed); + const allRequiredPassed = criticalFailures.length === 0; + + // -- Summary by check name ------------------------------------------- + + const pkceTotal = appsToCheck.length; + const pkcePass = pkceTotal - results.filter(r => r.name === 'pkce').length; + + const redirectTotal = appsToCheck.length; + const redirectPass = redirectTotal - results.filter(r => r.name === 'redirectUriValidation').length; + + const httpsFailCount = results.filter(r => r.name === 'httpsEnforcement').length; + + const rotationTotal = appsToCheck.length; + const rotationPass = rotationTotal - results.filter(r => r.name === 'refreshTokenRotation').length; + + const revocationFailCount = results.filter(r => r.name === 'refreshTokenRevocationOnReuse').length; + + const issuerFailCount = results.filter(r => r.name === 'tenantIssuer').length; + + const dpopFailCount = results.filter(r => r.name === 'dpop').length; + + const authCodeFailCount = results.filter(r => r.name === 'authCodeLifetime').length; + + const deprecatedFailCount = results.filter(r => r.name === 'deprecatedGrants').length; + + // -- Output ---------------------------------------------------------- + + if (jsonOutput) { + const output: JsonOutput = { + compliant: allRequiredPassed, + tenantsChecked: tenants.length, + applicationsChecked: appsToCheck.length, + applicationsSkipped: skippedApps.length, + filters: { + tenantId: tenantId || null, + applicationId: applicationId || null, + }, + checks: {}, + criticalIssues: criticalFailures.map(r => r.message), + warnings: warnings.map(r => r.message), + educationalLinks: { + 'oauth21Spec': SPEC_BASE, + 'pkce': specUrl('7.5'), + 'redirectUri': specUrl('4.1.3'), + 'https': specUrl('1.5'), + 'senderConstrainedTokens': specUrl('1.4.3'), + 'refreshTokenSecurity': specUrl('4.3'), + 'deprecatedGrants': specUrl('10'), + 'fusionAuthOAuthConfig': 'https://fusionauth.io/docs/apis/applications', + 'fusionAuthTenantConfig': 'https://fusionauth.io/docs/apis/tenants', + 'fusionAuthDPoP': 'https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/endpoints#demonstrating-proof-of-possession-dpop-', + }, + }; + + // Add individual check results + output.checks['pkce'] = { + severity: 'required', + passed: pkcePass === pkceTotal, + message: pkcePass === pkceTotal + ? `All applications require PKCE (${pkcePass}/${pkceTotal})` + : `${pkceTotal - pkcePass} application(s) do not require PKCE (${pkcePass}/${pkceTotal} compliant)`, + specSection: '7.5', + specUrl: specUrl('7.5'), + }; + output.checks['redirectUriValidation'] = { + severity: 'required', + passed: redirectPass === redirectTotal, + message: redirectPass === redirectTotal + ? `All applications use exact match redirect URI validation (${redirectPass}/${redirectTotal})` + : `${redirectTotal - redirectPass} application(s) allow wildcard redirect URIs (${redirectPass}/${redirectTotal} compliant)`, + specSection: '4.1.3', + specUrl: specUrl('4.1.3'), + }; + output.checks['httpsEnforcement'] = { + severity: 'required', + passed: httpsFailCount === 0, + message: httpsFailCount === 0 + ? 'All redirect URIs use HTTPS (or localhost)' + : `${httpsFailCount} non-HTTPS redirect URI(s) found`, + details: results.filter(r => r.name === 'httpsEnforcement').map(r => r.message), + specSection: '1.5', + specUrl: specUrl('1.5'), + }; + output.checks['refreshTokenRotation'] = { + severity: 'required', + passed: rotationPass === rotationTotal, + message: rotationPass === rotationTotal + ? `All applications use one-time use refresh tokens (${rotationPass}/${rotationTotal})` + : `${rotationTotal - rotationPass} application(s) do not use one-time use refresh tokens (${rotationPass}/${rotationTotal} compliant)`, + specSection: '4.3', + specUrl: specUrl('4.3'), + }; + output.checks['refreshTokenRevocationOnReuse'] = { + severity: 'required', + passed: revocationFailCount === 0, + message: revocationFailCount === 0 + ? 'All tenants have refresh token revocation on reuse enabled' + : `${revocationFailCount} tenant(s) do not have refresh token revocation on reuse enabled`, + specSection: '4.3', + specUrl: specUrl('4.3'), + }; + output.checks['tenantIssuer'] = { + severity: 'required', + passed: issuerFailCount === 0, + message: issuerFailCount === 0 + ? `All tenant issuers properly configured (${tenants.length}/${tenants.length})` + : `${issuerFailCount} tenant(s) have improperly configured issuers`, + }; + output.checks['dpop'] = { + severity: 'warning', + passed: dpopFailCount === 0, + message: dpopFailCount === 0 + ? 'DPoP enabled on all applications' + : `${dpopFailCount} application(s) do not have DPoP enabled`, + specSection: '1.4.3', + specUrl: specUrl('1.4.3'), + }; + output.checks['authCodeLifetime'] = { + severity: 'warning', + passed: authCodeFailCount === 0, + message: authCodeFailCount === 0 + ? 'Authorization code lifetime within recommended range' + : `${authCodeFailCount} tenant(s) have authorization code lifetime exceeding 600 seconds`, + specSection: '7.5', + specUrl: specUrl('7.5'), + }; + output.checks['deprecatedGrants'] = { + severity: strict ? 'required' : 'warning', + passed: deprecatedFailCount === 0, + message: deprecatedFailCount === 0 + ? 'No deprecated grants enabled' + : `${deprecatedFailCount} deprecated grant(s) found`, + details: results.filter(r => r.name === 'deprecatedGrants').map(r => r.message), + specSection: '10', + specUrl: specUrl('10'), + }; + + console.log(JSON.stringify(output, null, 2)); + } else { + // Human-readable output + if (!verbose) { + // Summary lines + console.log(pkcePass === pkceTotal + ? chalk.green(`✓ PKCE enforcement: Required (${pkcePass}/${pkceTotal} applications)`) + : chalk.red(`✗ PKCE enforcement: ${pkceTotal - pkcePass}/${pkceTotal} applications not compliant`)); + + console.log(redirectPass === redirectTotal + ? chalk.green(`✓ Redirect URI validation: ExactMatch (${redirectPass}/${redirectTotal} applications)`) + : chalk.red(`✗ Redirect URI validation: ${redirectTotal - redirectPass}/${redirectTotal} applications using wildcards`)); + + console.log(httpsFailCount === 0 + ? chalk.green(`✓ HTTPS enforcement: All redirect URIs valid`) + : chalk.red(`✗ HTTPS enforcement: ${httpsFailCount} non-HTTPS redirect URI(s) found`)); + + console.log(rotationPass === rotationTotal + ? chalk.green(`✓ Refresh token rotation: OneTimeUse (${rotationPass}/${rotationTotal} applications)`) + : chalk.red(`✗ Refresh token rotation: ${rotationTotal - rotationPass}/${rotationTotal} applications not using OneTimeUse`)); + + console.log(revocationFailCount === 0 + ? chalk.green(`✓ Refresh token revocation on reuse: Enabled (${tenants.length}/${tenants.length} tenants)`) + : chalk.red(`✗ Refresh token revocation on reuse: Disabled on ${revocationFailCount} tenant(s)`)); + + console.log(issuerFailCount === 0 + ? chalk.green(`✓ Tenant issuer: Properly configured (${tenants.length}/${tenants.length} tenants)`) + : chalk.red(`✗ Tenant issuer: ${issuerFailCount} tenant(s) not properly configured`)); + + console.log(dpopFailCount === 0 + ? chalk.green(`✓ DPoP (sender-constrained tokens): Enabled (${appsToCheck.length}/${appsToCheck.length} applications)`) + : chalk.yellow(`⚠ DPoP (sender-constrained tokens): Not enabled on ${dpopFailCount} application(s) (recommended)`)); + + console.log(authCodeFailCount === 0 + ? chalk.green(`✓ Authorization code lifetime: Within recommended range`) + : chalk.yellow(`⚠ Authorization code lifetime: ${authCodeFailCount} tenant(s) exceed 600 seconds`)); + + const deprecatedIcon = strict ? '✗' : '⚠'; + const deprecatedColor = strict ? chalk.red : chalk.yellow; + console.log(deprecatedFailCount === 0 + ? chalk.green(`✓ Deprecated grants: None enabled`) + : deprecatedColor(`${deprecatedIcon} Deprecated grants: ${deprecatedFailCount} deprecated grant(s) found${strict ? '' : ' (use --strict to fail)'}`)); + } + + // Final summary + console.log(chalk.blue('\n=== OAuth 2.1 Compliance Summary ===\n')); + + if (allRequiredPassed) { + console.log(chalk.green.bold('SUCCESS: Your FusionAuth instance meets OAuth 2.1 requirements.')); + if (warnings.length > 0) { + console.log(chalk.yellow(`\nWarnings (RECOMMENDED):`)); + for (const w of warnings) { + console.log(chalk.yellow(` - ${w.message}`)); + } + } + } else { + console.log(chalk.red.bold('FAILED: Your FusionAuth instance is NOT OAuth 2.1 compliant.\n')); + + console.log(chalk.red('Critical issues (MUST FIX):')); + for (const f of criticalFailures) { + console.log(chalk.red(` - ${f.message}`)); + if (f.details) { + for (const d of f.details) { + console.log(chalk.red(` ${d}`)); + } + } + } + + if (warnings.length > 0) { + console.log(chalk.yellow('\nWarnings (RECOMMENDED):')); + for (const w of warnings) { + console.log(chalk.yellow(` - ${w.message}`)); + } + } + } + + // Educational links + console.log(chalk.blue('\nFor more information:')); + console.log(chalk.cyan(` - OAuth 2.1 Specification: ${SPEC_BASE}`)); + console.log(chalk.cyan(` - PKCE (Section 7.5): ${specUrl('7.5')}`)); + console.log(chalk.cyan(` - Redirect URI (Section 4.1.3): ${specUrl('4.1.3')}`)); + console.log(chalk.cyan(` - HTTPS (Section 1.5): ${specUrl('1.5')}`)); + console.log(chalk.cyan(` - Sender-Constrained Tokens (Section 1.4.3): ${specUrl('1.4.3')}`)); + console.log(chalk.cyan(` - Refresh Token Security (Section 4.3): ${specUrl('4.3')}`)); + console.log(chalk.cyan(` - Deprecated Grants (Section 10): ${specUrl('10')}`)); + console.log(chalk.cyan(` - FusionAuth OAuth Configuration: https://fusionauth.io/docs/apis/applications`)); + console.log(chalk.cyan(` - FusionAuth Tenant Configuration: https://fusionauth.io/docs/apis/tenants`)); + console.log(chalk.cyan(` - FusionAuth DPoP: https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/endpoints#demonstrating-proof-of-possession-dpop-`)); + } + + if (!allRequiredPassed) { + process.exit(1); + } + + } catch (e: unknown) { + errorAndExit('OAuth 2.1 compliance check error:', e); + } +} + +// -- Command ----------------------------------------------------------------- + +// noinspection JSUnusedGlobalSymbols +export const checkOAuth21 = new Command('check:oauth-2-1') + .description('Checks FusionAuth configuration for OAuth 2.1 compliance (draft-ietf-oauth-v2-1-15)') + .addOption(apiKeyOption) + .addOption(hostOption) + .addOption(applicationIdOption) + .addOption(tenantIdOption) + .addOption(strictOption) + .addOption(jsonOption) + .addOption(verboseOption) + .action(action); diff --git a/src/commands/index.ts b/src/commands/index.ts index d074749..cd22129 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,5 @@ export * from './check-common-config.js'; +export * from './check-oauth-2-1.js'; export * from './email-create.js'; export * from './email-download.js'; export * from './email-duplicate.js'; From 706534a79729c4603f720efba56cb21de00e52b6 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 10:53:45 -0600 Subject: [PATCH 02/16] added readme for oauth 2.1 check --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/README.md b/README.md index 3f65561..77b71ef 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ fusionauth --help; Currently, the CLI supports the following commands: - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. +- OAuth 2.1 compliance check + - `fusionauth check:oauth-2-1` - Checks FusionAuth configuration for OAuth 2.1 compliance. - Emails - `fusionauth email:download` - Download a specific template or all email templates from a FusionAuth server. - `fusionauth email:duplicate` - Duplicate an email template locally. @@ -97,6 +99,67 @@ To see examples of use: https://fusionauth.io/docs/extend/code/lambdas/testing If you run this multiple times in a row against a local instance, the number of admin users may be incorrect until you re-index. See [this issue for more](https://github.com/FusionAuth/fusionauth-issues/issues/3271). +## OAuth 2.1 Compliance + +The `check:oauth-2-1` command validates your FusionAuth instance against OAuth 2.1 specification requirements ([draft-ietf-oauth-v2-1-15](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15)). + +### Usage + +```bash +# Check all applications across all tenants +fusionauth check:oauth-2-1 --key --host + +# Check a specific application +fusionauth check:oauth-2-1 --key --host --application-id + +# Check all applications in a specific tenant +fusionauth check:oauth-2-1 --key --host --tenant-id + +# Enforce strict mode (fail on deprecated grants) +fusionauth check:oauth-2-1 --key --host --strict + +# Output as JSON +fusionauth check:oauth-2-1 --key --host --json + +# Show verbose per-application breakdown +fusionauth check:oauth-2-1 --key --host --verbose +``` + +### What It Checks + +Only applications with both the `authorization_code` and `refresh_token` grants enabled are checked. + +**REQUIRED (causes exit 1 if failed):** +- PKCE enforcement set to "Required" on all applications (§7.5) +- Redirect URI validation set to "ExactMatch" — no wildcards (§4.1.3) +- HTTPS enforcement for all redirect URIs except localhost (§1.5) +- Refresh token rotation enabled via "OneTimeUse" usage policy (§4.3) +- Refresh token revocation on one-time token reuse enabled at the tenant level (§4.3) +- Tenant issuer properly configured — not default "acme.com" + +**WARNINGS (informational, does not cause exit 1):** +- DPoP (sender-constrained tokens) enabled on applications (§1.4.3) +- Authorization code lifetime ≤ 600 seconds (§7.5) +- No deprecated grants enabled — Implicit, Password (§10); use `--strict` to make this a failure + +### Known FusionAuth OAuth 2.1 Limitations + +FusionAuth does not fully implement all OAuth 2.1 security requirements. The following gaps exist: + +1. **Incomplete refresh token chain revocation on reuse detection** ([§4.3.3](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-4.3.3)) + Refresh token chain revocation on reuse detection is incomplete in FusionAuth — a replayed one-time-use token doesn't revoke the new token the attacker already obtained. See [fusionauth-issues#1619](https://github.com/FusionAuth/fusionauth-issues/issues/1619) and the overall [OAuth 2.1 compatibility tracking issue #942](https://github.com/FusionAuth/fusionauth-issues/issues/942). + +2. **Sender-constrained token support is limited** ([§1.4.3](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-1.4.3)) + DPoP is Enterprise-only, and mTLS isn't confirmed as supported. OAuth 2.1 §1.4.3 recommends sender-constrained tokens. See [fusionauth-issues#1025 (mTLS)](https://github.com/FusionAuth/fusionauth-issues/issues/1025) and [fusionauth-issues#1679 (DPoP)](https://github.com/FusionAuth/fusionauth-issues/issues/1679). + +3. **Missing `iss` authorization response parameter** ([§7.14](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-7.14)) + The `iss` authorization response parameter for mix-up mitigation doesn't appear to be supported — use distinct redirect URIs per authorization server as a workaround (§7.14.2). See [fusionauth-issues#1383](https://github.com/FusionAuth/fusionauth-issues/issues/1383). + +For more information: +- [OAuth 2.1 Specification (draft-ietf-oauth-v2-1-15)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15) +- [FusionAuth OAuth Configuration](https://fusionauth.io/docs/apis/applications) +- [FusionAuth Tenant Configuration](https://fusionauth.io/docs/apis/tenants) + ## License This code is available as open source under the terms of the [Apache v2.0 License](https://opensource.org/licenses/Apache-2.0). From ce233bdd13a32b015221def83cda85656521b6bf Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 11:15:03 -0600 Subject: [PATCH 03/16] upgrade typescript client version to grab new features --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c4fcaa5..0dc8fee 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dependencies": { "@commander-js/extra-typings": "11.0.0", "@faker-js/faker": "^8.4.1", - "@fusionauth/typescript-client": "1.47.0", + "@fusionauth/typescript-client": "^1.64.0", "chalk": "5.3.0", "chokidar": "3.5.3", "commander": "11.0.0", From 7b6a8ca41dbe06cae0596c4eb214e33992ed2519 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 11:15:40 -0600 Subject: [PATCH 04/16] accurate handling of dpop --- src/commands/check-oauth-2-1.ts | 93 +++++++++++++++------------------ 1 file changed, 41 insertions(+), 52 deletions(-) diff --git a/src/commands/check-oauth-2-1.ts b/src/commands/check-oauth-2-1.ts index b9b4ff7..e77b1db 100644 --- a/src/commands/check-oauth-2-1.ts +++ b/src/commands/check-oauth-2-1.ts @@ -5,6 +5,7 @@ import { GrantType, Oauth2AuthorizedURLValidationPolicy, ProofKeyForCodeExchangePolicy, + ReactorFeatureStatus, RefreshTokenUsagePolicy, Tenant, } from '@fusionauth/typescript-client'; @@ -120,10 +121,8 @@ function isBuiltInApplication(app: Application): boolean { return true; } - // Tenant Manager — universalConfiguration is not in the v1.47.0 client - // types (added in v1.58.0), so access it dynamically. - const universal = (app as Record).universalConfiguration as Record | undefined; - if (universal?.universal !== true) { + // Tenant Manager — universal application with specific redirect and role + if (app.universalConfiguration?.universal !== true) { return false; } @@ -253,34 +252,31 @@ function checkDeprecatedGrants(ctx: AppCheckContext, strict: boolean): CheckResu return failures; } -function checkDpop(ctx: AppCheckContext, hasLicense: boolean): CheckResult | null { - if (!hasLicense) { - return { - name: 'dpop', - passed: false, - severity: 'warning', - message: `Application "${ctx.appName}" (${ctx.appId}): DPoP unavailable (Enterprise license required)`, - specSection: '1.4.3', - specUrl: specUrl('1.4.3'), - }; - } - - // The TypeScript client v1.47.0 does not include the DPoP field. - // Access it dynamically from the JWT configuration. - const jwtConfig = ctx.app.jwtConfiguration as Record | undefined; - const dpopEnabled = jwtConfig?.['enabledDemonstratingProofOfPossession'] === true; - - if (!dpopEnabled) { +/** + * DPoP is an instance-level capability, not a per-application setting. + * FusionAuth automatically handles DPoP when the client initiates a DPoP flow + * — there is no server-side toggle to enable it. The only prerequisite is an + * Enterprise license. + * + * See: https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/dpop + */ +function checkDpop(dpopFeatureActive: boolean): CheckResult | null { + if (!dpopFeatureActive) { return { name: 'dpop', passed: false, severity: 'warning', - message: `Application "${ctx.appName}" (${ctx.appId}): DPoP not enabled (recommended for sender-constrained tokens)`, + message: 'DPoP unavailable: Enterprise license required. DPoP sender-constrains tokens to the client that requested them (§1.4.3).', + details: [ + 'DPoP requires no server-side configuration — FusionAuth handles it automatically when the client initiates a DPoP flow.', + 'However, an Enterprise license is required for this feature.', + 'See: https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/dpop', + ], specSection: '1.4.3', specUrl: specUrl('1.4.3'), }; } - return null; // pass + return null; // pass — DPoP is available, clients can use it at will } function checkTenantIssuer(tenant: Tenant): CheckResult | null { @@ -308,10 +304,8 @@ function checkRefreshTokenRevocationOnReuse(tenant: Tenant): CheckResult | null const tenantName = tenant.name || 'Unnamed Tenant'; const tenantId = tenant.id || 'unknown'; - // The TypeScript client v1.47.0 does not include onOneTimeTokenReuse. - // Access it dynamically from the revocation policy. - const revocationPolicy = tenant.jwtConfiguration?.refreshTokenRevocationPolicy as Record | undefined; - const onReuse = revocationPolicy?.['onOneTimeTokenReuse'] === true; + // onOneTimeTokenReuse is now properly typed in the v1.64.0 client. + const onReuse = tenant.jwtConfiguration?.refreshTokenRevocationPolicy?.onOneTimeTokenReuse === true; if (onReuse) { return null; // pass @@ -379,15 +373,15 @@ const action = async function (options: { // -- Fetch data ------------------------------------------------------ - // License status (for DPoP check) - let hasLicense = false; + // Reactor status (for DPoP check — DPoP requires Enterprise license) + let dpopFeatureActive = false; try { const reactorResponse = await client.retrieveReactorStatus(); if (reactorResponse.wasSuccessful()) { - hasLicense = reactorResponse.response.status?.licensed === true; + dpopFeatureActive = reactorResponse.response.status?.dPoP === ReactorFeatureStatus.ACTIVE; } } catch { - // If we can't check reactor status, assume no license + // If we can't check reactor status, assume DPoP is unavailable } // Tenants @@ -459,6 +453,11 @@ const action = async function (options: { if (authCodeResult) results.push(authCodeResult); } + // -- Instance-level checks ------------------------------------------- + + const dpopResult = checkDpop(dpopFeatureActive); + if (dpopResult) results.push(dpopResult); + // -- Application-level checks ---------------------------------------- for (const app of appsToCheck) { @@ -510,16 +509,6 @@ const action = async function (options: { } // Warning checks - const dpopResult = checkDpop(ctx, hasLicense); - if (dpopResult) results.push(dpopResult); - if (verbose && !jsonOutput) { - if (dpopResult) { - console.log(chalk.yellow(` ⚠ ${dpopResult.message}`)); - } else { - console.log(chalk.green(` ✓ DPoP: Enabled`)); - } - } - const deprecatedResults = checkDeprecatedGrants(ctx, strict); results.push(...deprecatedResults); if (verbose && !jsonOutput) { @@ -558,7 +547,7 @@ const action = async function (options: { const issuerFailCount = results.filter(r => r.name === 'tenantIssuer').length; - const dpopFailCount = results.filter(r => r.name === 'dpop').length; + const dpopAvailable = dpopResult === null; const authCodeFailCount = results.filter(r => r.name === 'authCodeLifetime').length; @@ -589,7 +578,7 @@ const action = async function (options: { 'deprecatedGrants': specUrl('10'), 'fusionAuthOAuthConfig': 'https://fusionauth.io/docs/apis/applications', 'fusionAuthTenantConfig': 'https://fusionauth.io/docs/apis/tenants', - 'fusionAuthDPoP': 'https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/endpoints#demonstrating-proof-of-possession-dpop-', + 'fusionAuthDPoP': 'https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/dpop', }, }; @@ -649,10 +638,10 @@ const action = async function (options: { }; output.checks['dpop'] = { severity: 'warning', - passed: dpopFailCount === 0, - message: dpopFailCount === 0 - ? 'DPoP enabled on all applications' - : `${dpopFailCount} application(s) do not have DPoP enabled`, + passed: dpopAvailable, + message: dpopAvailable + ? 'DPoP available (Enterprise license active). Clients can initiate DPoP flows — no server-side configuration needed.' + : 'DPoP unavailable (Enterprise license required). Sender-constrained tokens are recommended by OAuth 2.1 §1.4.3.', specSection: '1.4.3', specUrl: specUrl('1.4.3'), }; @@ -705,9 +694,9 @@ const action = async function (options: { ? chalk.green(`✓ Tenant issuer: Properly configured (${tenants.length}/${tenants.length} tenants)`) : chalk.red(`✗ Tenant issuer: ${issuerFailCount} tenant(s) not properly configured`)); - console.log(dpopFailCount === 0 - ? chalk.green(`✓ DPoP (sender-constrained tokens): Enabled (${appsToCheck.length}/${appsToCheck.length} applications)`) - : chalk.yellow(`⚠ DPoP (sender-constrained tokens): Not enabled on ${dpopFailCount} application(s) (recommended)`)); + console.log(dpopAvailable + ? chalk.green(`✓ DPoP (sender-constrained tokens): Available (Enterprise license active)`) + : chalk.yellow(`⚠ DPoP (sender-constrained tokens): Unavailable (Enterprise license required)`)); console.log(authCodeFailCount === 0 ? chalk.green(`✓ Authorization code lifetime: Within recommended range`) @@ -763,7 +752,7 @@ const action = async function (options: { console.log(chalk.cyan(` - Deprecated Grants (Section 10): ${specUrl('10')}`)); console.log(chalk.cyan(` - FusionAuth OAuth Configuration: https://fusionauth.io/docs/apis/applications`)); console.log(chalk.cyan(` - FusionAuth Tenant Configuration: https://fusionauth.io/docs/apis/tenants`)); - console.log(chalk.cyan(` - FusionAuth DPoP: https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/endpoints#demonstrating-proof-of-possession-dpop-`)); + console.log(chalk.cyan(` - FusionAuth DPoP: https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/dpop`)); } if (!allRequiredPassed) { From 3412438fc6a5fdf52628fe3379fb8c5429ad9161 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 11:19:12 -0600 Subject: [PATCH 05/16] upgrade typescript client version to grab new features --- package-lock.json | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6a714c..13f296c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@commander-js/extra-typings": "11.0.0", "@faker-js/faker": "^8.4.1", - "@fusionauth/typescript-client": "1.47.0", + "@fusionauth/typescript-client": "^1.64.0", "chalk": "5.3.0", "chokidar": "3.5.3", "commander": "11.0.0", @@ -75,9 +75,10 @@ } }, "node_modules/@fusionauth/typescript-client": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/@fusionauth/typescript-client/-/typescript-client-1.47.0.tgz", - "integrity": "sha512-XXBy5BnoTED5HScxtxgbHEO3neIkTocEo9AUVDprk3JER49BbZbaNoMHrbMioJNAM5LWs3AN8AHIot18oq96nA==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@fusionauth/typescript-client/-/typescript-client-1.64.0.tgz", + "integrity": "sha512-n5U0SWf5v6CWaPhHcYyF5lwGaS9bSGSv2GHHweSw9nWFtLWcb2ASX3s0wGi5xBiovX8VZWQvgLKVmHs2Swu6RQ==", + "license": "Apache-2.0", "dependencies": { "node-fetch": "^2.6.1" } @@ -178,8 +179,7 @@ "version": "20.4.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/uuid": { "version": "9.0.2", @@ -348,7 +348,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", - "peer": true, "engines": { "node": ">=16" } @@ -927,7 +926,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From 07ef7f3eb731f352c23a2f55a81bbff6858f020e Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 11:20:06 -0600 Subject: [PATCH 06/16] fixing audit warnings --- package-lock.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 13f296c..2109d08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -367,10 +367,11 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -739,9 +740,10 @@ } }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", "engines": { "node": ">=8.6" }, From b2fe23c061bbb76d734ab560c766b0a9c1f033f9 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 11:27:08 -0600 Subject: [PATCH 07/16] updated to 1.64 for realz --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 545050e..777e61c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@comandeer/cli-spinner": "^1.0.2", "@commander-js/extra-typings": "11.0.0", "@faker-js/faker": "^8.4.1", - "@fusionauth/typescript-client": "1.47.0", + "@fusionauth/typescript-client": "^1.64.0", "bcryptjs": "^3.0.3", "boxen": "^8.0.1", "chalk": "5.3.0", @@ -108,9 +108,9 @@ } }, "node_modules/@fusionauth/typescript-client": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/@fusionauth/typescript-client/-/typescript-client-1.47.0.tgz", - "integrity": "sha512-XXBy5BnoTED5HScxtxgbHEO3neIkTocEo9AUVDprk3JER49BbZbaNoMHrbMioJNAM5LWs3AN8AHIot18oq96nA==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@fusionauth/typescript-client/-/typescript-client-1.64.0.tgz", + "integrity": "sha512-n5U0SWf5v6CWaPhHcYyF5lwGaS9bSGSv2GHHweSw9nWFtLWcb2ASX3s0wGi5xBiovX8VZWQvgLKVmHs2Swu6RQ==", "license": "Apache-2.0", "dependencies": { "node-fetch": "^2.6.1" diff --git a/package.json b/package.json index f5feacc..adb43c8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@comandeer/cli-spinner": "^1.0.2", "@commander-js/extra-typings": "11.0.0", "@faker-js/faker": "^8.4.1", - "@fusionauth/typescript-client": "1.47.0", + "@fusionauth/typescript-client": "^1.64.0", "bcryptjs": "^3.0.3", "boxen": "^8.0.1", "chalk": "5.3.0", From 0ddd263951781a42fe4e8faf7bcfdb6ad9c34443 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Mon, 6 Apr 2026 21:09:57 -0600 Subject: [PATCH 08/16] handle single application better --- src/commands/check-oauth-2-1.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/commands/check-oauth-2-1.ts b/src/commands/check-oauth-2-1.ts index e77b1db..1216e48 100644 --- a/src/commands/check-oauth-2-1.ts +++ b/src/commands/check-oauth-2-1.ts @@ -433,10 +433,26 @@ const action = async function (options: { console.log(chalk.cyan(`Applications skipped: ${skippedApps.length} (built-in FusionAuth apps or not using authorization_code + refresh_token grants)\n`)); } - if (appsToCheck.length === 0 && !applicationId) { - if (!jsonOutput) { - console.log(chalk.yellow('No applications found using both authorization_code and refresh_token grants.')); - console.log(chalk.yellow('Nothing to check for OAuth 2.1 compliance.')); + if (appsToCheck.length === 0) { + if (applicationId) { + // The user explicitly requested this app but it was filtered out + const app = allApps[0]; + if (app && isBuiltInApplication(app)) { + if (!jsonOutput) { + console.log(chalk.yellow(`Application "${app.name || applicationId}" is a built-in FusionAuth application and is excluded from OAuth 2.1 checks.`)); + } + } else { + const grants = app?.oauthConfiguration?.enabledGrants || []; + if (!jsonOutput) { + console.log(chalk.yellow(`Application "${app?.name || applicationId}" does not use both authorization_code and refresh_token grants.`)); + console.log(chalk.yellow(`Enabled grants: ${grants.length > 0 ? grants.join(', ') : 'none'}`)); + } + } + } else { + if (!jsonOutput) { + console.log(chalk.yellow('No applications found using both authorization_code and refresh_token grants.')); + console.log(chalk.yellow('Nothing to check for OAuth 2.1 compliance.')); + } } } From baea395c2cf614e705709df65d3deab4efe22289 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Mon, 6 Apr 2026 21:18:35 -0600 Subject: [PATCH 09/16] remove internal import --- src/utils.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 45b32f2..d20c506 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,15 +1,22 @@ -import ClientResponse from '@fusionauth/typescript-client/build/src/ClientResponse.js'; import {Errors} from '@fusionauth/typescript-client'; import fs from 'node:fs' import chalk from 'chalk'; import boxen from 'boxen'; import { execSync } from 'node:child_process'; + +/** Shape of a FusionAuth ClientResponse — used for duck-type checking without importing internals. */ +interface ClientResponseLike { + wasSuccessful: () => boolean; + response: unknown; + exception?: Error; +} + /** * Checks if the response is a client response * @param response */ -export const isClientResponse = (response: any): response is ClientResponse.default => { +export const isClientResponse = (response: any): response is ClientResponseLike => { return response.wasSuccessful !== undefined; } From d07520a6c4a572d79332c91c8c8aafc1d78c7fd4 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Mon, 6 Apr 2026 21:18:49 -0600 Subject: [PATCH 10/16] more doco --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 4bee6b6..505f672 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,14 @@ npm run build; npx fusionauth -h; ``` +To run commands directly from source during development (without installing globally): +```bash +npm run build && node dist/index.js [options] + +# Example: +node dist/index.js check:oauth-2-1 --key --host http://localhost:9011 +``` + To see examples of use: https://fusionauth.io/docs/extend/code/lambdas/testing ## Troubleshooting From 318259b367473b4ec471cb429b34c596e1f137b5 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Tue, 7 Apr 2026 16:12:01 -0600 Subject: [PATCH 11/16] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/check-oauth-2-1.ts | 18 +++++++++++++++--- src/utils.ts | 4 +++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/commands/check-oauth-2-1.ts b/src/commands/check-oauth-2-1.ts index 1216e48..1ca1dd9 100644 --- a/src/commands/check-oauth-2-1.ts +++ b/src/commands/check-oauth-2-1.ts @@ -374,16 +374,28 @@ const action = async function (options: { // -- Fetch data ------------------------------------------------------ // Reactor status (for DPoP check — DPoP requires Enterprise license) - let dpopFeatureActive = false; + let dpopFeatureActive: boolean | undefined; + let dpopStatusError: unknown; try { const reactorResponse = await client.retrieveReactorStatus(); if (reactorResponse.wasSuccessful()) { dpopFeatureActive = reactorResponse.response.status?.dPoP === ReactorFeatureStatus.ACTIVE; + } else { + dpopFeatureActive = undefined; } - } catch { - // If we can't check reactor status, assume DPoP is unavailable + } catch (e: unknown) { + dpopFeatureActive = undefined; + dpopStatusError = e; } + if (dpopFeatureActive === undefined && verbose && !jsonOutput) { + const errorDetail = dpopStatusError instanceof Error + ? `: ${dpopStatusError.message}` + : dpopStatusError + ? `: ${String(dpopStatusError)}` + : ''; + console.warn(chalk.yellow(`Warning: Unable to determine DPoP Reactor status${errorDetail}. DPoP availability is unknown.`)); + } // Tenants const tenantResponse = await client.retrieveTenants(); if (!tenantResponse.wasSuccessful() || !tenantResponse.response.tenants) { diff --git a/src/utils.ts b/src/utils.ts index d20c506..bb8401e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,7 +17,9 @@ interface ClientResponseLike { * @param response */ export const isClientResponse = (response: any): response is ClientResponseLike => { - return response.wasSuccessful !== undefined; + return response !== null + && typeof response === 'object' + && typeof response.wasSuccessful === 'function'; } /** From 18d2609df6e4f48e5d8045224eba7d534aa1f986 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Tue, 7 Apr 2026 16:15:37 -0600 Subject: [PATCH 12/16] make sure that an application belongs to a tenant --- src/commands/check-oauth-2-1.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/commands/check-oauth-2-1.ts b/src/commands/check-oauth-2-1.ts index 1ca1dd9..4f32327 100644 --- a/src/commands/check-oauth-2-1.ts +++ b/src/commands/check-oauth-2-1.ts @@ -421,6 +421,12 @@ const action = async function (options: { return; } allApps = [appResponse.response.application]; + + // Validate the application belongs to the specified tenant + if (tenantId && allApps[0].tenantId !== tenantId) { + errorAndExit(`Application "${allApps[0].name || applicationId}" belongs to tenant "${allApps[0].tenantId}", not the specified tenant "${tenantId}".`); + return; + } } else { const appsResponse = await client.retrieveApplications(); if (!appsResponse.wasSuccessful() || !appsResponse.response.applications) { @@ -483,7 +489,7 @@ const action = async function (options: { // -- Instance-level checks ------------------------------------------- - const dpopResult = checkDpop(dpopFeatureActive); + const dpopResult = checkDpop(dpopFeatureActive ?? false); if (dpopResult) results.push(dpopResult); // -- Application-level checks ---------------------------------------- From f249ced1dc572edb08d2071493f025415d7260c6 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 17 Apr 2026 09:26:48 -0600 Subject: [PATCH 13/16] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/commands/check-oauth-2-1.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands/check-oauth-2-1.ts b/src/commands/check-oauth-2-1.ts index 4f32327..f244395 100644 --- a/src/commands/check-oauth-2-1.ts +++ b/src/commands/check-oauth-2-1.ts @@ -441,9 +441,16 @@ const action = async function (options: { allApps = allApps.filter(app => app.tenantId === tenantId); } - // Separate into checked and skipped - const appsToCheck = allApps.filter(shouldCheckApplication); - const skippedApps = allApps.filter(app => !shouldCheckApplication(app)); + // Separate into checked and skipped in a single pass + const appsToCheck: Application[] = []; + const skippedApps: Application[] = []; + for (const app of allApps) { + if (shouldCheckApplication(app)) { + appsToCheck.push(app); + } else { + skippedApps.push(app); + } + } if (!jsonOutput) { console.log(chalk.cyan(`Tenants checked: ${tenants.length}`)); From 6fc7dca4b8612167dd3495bb6619d20a8fe26934 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 17 Apr 2026 10:29:27 -0600 Subject: [PATCH 14/16] updated dpop reference --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 505f672..3b51195 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Only applications with both the `authorization_code` and `refresh_token` grants - Tenant issuer properly configured — not default "acme.com" **WARNINGS (informational, does not cause exit 1):** -- DPoP (sender-constrained tokens) enabled on applications (§1.4.3) +- DPoP (sender-constrained tokens) available for applications (§1.4.3) - Authorization code lifetime ≤ 600 seconds (§7.5) - No deprecated grants enabled — Implicit, Password (§10); use `--strict` to make this a failure From ee24980c7f28c0a14b5921d48df2b1aa16ec5ec3 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 17 Apr 2026 13:38:38 -0600 Subject: [PATCH 15/16] updated the limitations to reflect reality --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index 3b51195..7dbff51 100644 --- a/README.md +++ b/README.md @@ -159,20 +159,10 @@ Only applications with both the `authorization_code` and `refresh_token` grants FusionAuth does not fully implement all OAuth 2.1 security requirements. The following gaps exist: -1. **Incomplete refresh token chain revocation on reuse detection** ([§4.3.3](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-4.3.3)) - Refresh token chain revocation on reuse detection is incomplete in FusionAuth — a replayed one-time-use token doesn't revoke the new token the attacker already obtained. See [fusionauth-issues#1619](https://github.com/FusionAuth/fusionauth-issues/issues/1619) and the overall [OAuth 2.1 compatibility tracking issue #942](https://github.com/FusionAuth/fusionauth-issues/issues/942). - -2. **Sender-constrained token support is limited** ([§1.4.3](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-1.4.3)) - DPoP is Enterprise-only, and mTLS isn't confirmed as supported. OAuth 2.1 §1.4.3 recommends sender-constrained tokens. See [fusionauth-issues#1025 (mTLS)](https://github.com/FusionAuth/fusionauth-issues/issues/1025) and [fusionauth-issues#1679 (DPoP)](https://github.com/FusionAuth/fusionauth-issues/issues/1679). - -3. **Missing `iss` authorization response parameter** ([§7.14](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-7.14)) +1. **Missing `iss` authorization response parameter** ([§7.14](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15#section-7.14)) The `iss` authorization response parameter for mix-up mitigation doesn't appear to be supported — use distinct redirect URIs per authorization server as a workaround (§7.14.2). See [fusionauth-issues#1383](https://github.com/FusionAuth/fusionauth-issues/issues/1383). For more information: - [OAuth 2.1 Specification (draft-ietf-oauth-v2-1-15)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15) - [FusionAuth OAuth Configuration](https://fusionauth.io/docs/apis/applications) - [FusionAuth Tenant Configuration](https://fusionauth.io/docs/apis/tenants) - -## License - -This code is available as open source under the terms of the [Apache v2.0 License](https://opensource.org/licenses/Apache-2.0). From afd9a53f48b98324f043bd43ed25c31eaca2bb60 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 17 Apr 2026 13:38:58 -0600 Subject: [PATCH 16/16] limit checks to the containing tenant if the application id is provided --- src/commands/check-oauth-2-1.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/check-oauth-2-1.ts b/src/commands/check-oauth-2-1.ts index f244395..b09c067 100644 --- a/src/commands/check-oauth-2-1.ts +++ b/src/commands/check-oauth-2-1.ts @@ -427,6 +427,11 @@ const action = async function (options: { errorAndExit(`Application "${allApps[0].name || applicationId}" belongs to tenant "${allApps[0].tenantId}", not the specified tenant "${tenantId}".`); return; } + + // Narrow tenant-level checks to just this application's tenant + if (!tenantId) { + tenants = tenants.filter(t => t.id === allApps[0].tenantId); + } } else { const appsResponse = await client.retrieveApplications(); if (!appsResponse.wasSuccessful() || !appsResponse.response.applications) {