diff --git a/README.md b/README.md index 7df8398..7dbff51 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. @@ -96,12 +98,71 @@ 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 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). -## License +## 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) 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 + +### Known FusionAuth OAuth 2.1 Limitations + +FusionAuth does not fully implement all OAuth 2.1 security requirements. The following gaps exist: + +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). -This code is available as open source under the terms of the [Apache v2.0 License](https://opensource.org/licenses/Apache-2.0). +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) diff --git a/package-lock.json b/package-lock.json index 4213275..777e61c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@fusionauth/cli", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fusionauth/cli", - "version": "1.6.0", + "version": "1.7.0", "license": "Apache-2.0", "dependencies": { "@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,32 +108,33 @@ } }, "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" } }, "node_modules/@inquirer/ansi": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", - "integrity": "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.4.tgz", + "integrity": "sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/checkbox": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.0.tgz", - "integrity": "sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.2.tgz", + "integrity": "sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.4", + "@inquirer/core": "^11.1.7", + "@inquirer/figures": "^2.0.4", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -148,13 +149,13 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.8.tgz", - "integrity": "sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw==", + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.10.tgz", + "integrity": "sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.7", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -169,14 +170,14 @@ } }, "node_modules/@inquirer/core": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.5.tgz", - "integrity": "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==", + "version": "11.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.7.tgz", + "integrity": "sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3", + "@inquirer/ansi": "^2.0.4", + "@inquirer/figures": "^2.0.4", + "@inquirer/type": "^4.0.4", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -207,14 +208,14 @@ } }, "node_modules/@inquirer/editor": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.8.tgz", - "integrity": "sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.10.tgz", + "integrity": "sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/external-editor": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.7", + "@inquirer/external-editor": "^2.0.4", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -229,13 +230,13 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.8.tgz", - "integrity": "sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.10.tgz", + "integrity": "sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.7", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -250,9 +251,9 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.3.tgz", - "integrity": "sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.4.tgz", + "integrity": "sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA==", "license": "MIT", "dependencies": { "chardet": "^2.1.1", @@ -271,22 +272,22 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", - "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.4.tgz", + "integrity": "sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/input": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.8.tgz", - "integrity": "sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.10.tgz", + "integrity": "sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.7", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -301,13 +302,13 @@ } }, "node_modules/@inquirer/number": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.8.tgz", - "integrity": "sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ==", + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.10.tgz", + "integrity": "sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.7", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -322,14 +323,14 @@ } }, "node_modules/@inquirer/password": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.8.tgz", - "integrity": "sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.10.tgz", + "integrity": "sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.4", + "@inquirer/core": "^11.1.7", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -344,21 +345,21 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.0.tgz", - "integrity": "sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.2.tgz", + "integrity": "sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w==", "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.1.0", - "@inquirer/confirm": "^6.0.8", - "@inquirer/editor": "^5.0.8", - "@inquirer/expand": "^5.0.8", - "@inquirer/input": "^5.0.8", - "@inquirer/number": "^4.0.8", - "@inquirer/password": "^5.0.8", - "@inquirer/rawlist": "^5.2.4", - "@inquirer/search": "^4.1.4", - "@inquirer/select": "^5.1.0" + "@inquirer/checkbox": "^5.1.2", + "@inquirer/confirm": "^6.0.10", + "@inquirer/editor": "^5.0.10", + "@inquirer/expand": "^5.0.10", + "@inquirer/input": "^5.0.10", + "@inquirer/number": "^4.0.10", + "@inquirer/password": "^5.0.10", + "@inquirer/rawlist": "^5.2.6", + "@inquirer/search": "^4.1.6", + "@inquirer/select": "^5.1.2" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -373,13 +374,13 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.4.tgz", - "integrity": "sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.6.tgz", + "integrity": "sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.7", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -394,14 +395,14 @@ } }, "node_modules/@inquirer/search": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.4.tgz", - "integrity": "sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.6.tgz", + "integrity": "sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/core": "^11.1.7", + "@inquirer/figures": "^2.0.4", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -416,15 +417,15 @@ } }, "node_modules/@inquirer/select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.0.tgz", - "integrity": "sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.2.tgz", + "integrity": "sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" + "@inquirer/ansi": "^2.0.4", + "@inquirer/core": "^11.1.7", + "@inquirer/figures": "^2.0.4", + "@inquirer/type": "^4.0.4" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -439,9 +440,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.3.tgz", - "integrity": "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.4.tgz", + "integrity": "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -926,10 +927,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" } @@ -1169,15 +1171,15 @@ } }, "node_modules/inquirer": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.3.0.tgz", - "integrity": "sha512-APTrZe9IhrsshL0u2PgmEMLP3CXDBjZ99xh5dR2+sryOt5R+JGL0KNuaTTT2lW54B9eNQDMutPR05UYTL7Xb1Q==", + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.3.2.tgz", + "integrity": "sha512-bh/OjBGxNR9qvfQj1n5bxtIF58mbOTp2InN5dKuwUK03dXcDGFsjlDinQRuXMZ4EGiJaFieUWHCAaxH2p7iUBw==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/prompts": "^8.3.0", - "@inquirer/type": "^4.0.3", + "@inquirer/ansi": "^2.0.4", + "@inquirer/core": "^11.1.7", + "@inquirer/prompts": "^8.3.2", + "@inquirer/type": "^4.0.4", "mute-stream": "^3.0.0", "run-async": "^4.0.6", "rxjs": "^7.8.2" @@ -1409,9 +1411,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" }, 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", diff --git a/src/commands/check-oauth-2-1.ts b/src/commands/check-oauth-2-1.ts new file mode 100644 index 0000000..b09c067 --- /dev/null +++ b/src/commands/check-oauth-2-1.ts @@ -0,0 +1,825 @@ +import {Command, Option} from "@commander-js/extra-typings"; +import { + Application, + FusionAuthClient, + GrantType, + Oauth2AuthorizedURLValidationPolicy, + ProofKeyForCodeExchangePolicy, + ReactorFeatureStatus, + 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 — universal application with specific redirect and role + if (app.universalConfiguration?.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; +} + +/** + * 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: '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 — DPoP is available, clients can use it at will +} + +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'; + + // onOneTimeTokenReuse is now properly typed in the v1.64.0 client. + const onReuse = tenant.jwtConfiguration?.refreshTokenRevocationPolicy?.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 ------------------------------------------------------ + + // Reactor status (for DPoP check — DPoP requires Enterprise license) + 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 (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) { + 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]; + + // 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; + } + + // 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) { + 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 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}`)); + 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) { + 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.')); + } + } + } + + // -- 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); + } + + // -- Instance-level checks ------------------------------------------- + + const dpopResult = checkDpop(dpopFeatureActive ?? false); + if (dpopResult) results.push(dpopResult); + + // -- 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 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 dpopAvailable = dpopResult === null; + + 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/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: 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'), + }; + 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(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`) + : 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/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 0bd1da9..24b817a 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'; diff --git a/src/utils.ts b/src/utils.ts index 45b32f2..bb8401e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,16 +1,25 @@ -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 => { - return response.wasSuccessful !== undefined; +export const isClientResponse = (response: any): response is ClientResponseLike => { + return response !== null + && typeof response === 'object' + && typeof response.wasSuccessful === 'function'; } /**