Compare commits

..
Author SHA1 Message Date
Scott McKendry 3390debab7 handle edge cases 2026-07-30 07:20:51 +12:00
Scott McKendry c5bccd0b7b should be non-breaking now ;) 2026-07-30 07:06:54 +12:00
Scott McKendry cce0eaa974 rework to keep regex under specific conditions 2026-07-28 19:53:16 +12:00
Scott McKendry 3d5ab54ab8 fix(acls): auth bypass via forward auth 2026-07-27 19:43:47 +12:00
Stavros c7077a7c84 fix: do not allow empty ingresses 2026-07-21 00:37:13 +03:00
StavrosandGitHub 80bc87188e refactor: rework acl fetching for kubernetes and docker (#1028) 2026-07-20 16:39:15 +03:00
StavrosandGitHub 0e7bdf6cd5 feat: experimental option to run tinyauth in oauth bridge mode (#1027) 2026-07-20 00:03:26 +03:00
Stavros 4db1b33034 fix: allow for oauth auto redirect in oidc authorization 2026-07-19 13:34:05 +03:00
Stavros f6637c0f65 fix: don't force login_for=app in continue page 2026-07-19 13:03:55 +03:00
StavrosandGitHub f43d690320 refactor: rework scheme validation in oauth controller and frontend (#1026) 2026-07-19 00:41:28 +03:00
Stavros 50c25e4478 fix: fix domain validator with port but no scheme and https or http 2026-07-18 18:17:24 +03:00
Stavros d6087dc45e fix: allow for mismatching protocol in trusted domain check - ui warnings 2026-07-18 18:06:26 +03:00
Stavros 6efb970673 fix: fix lint errors 2026-07-18 17:52:08 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a9221ece92 chore(deps): bump actions/setup-go from 6.5.0 to 7.0.0 (#1012)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 17:10:09 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5b7e60362 chore(deps): bump the minor-patch group across 1 directory with 4 updates (#1020)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 17:09:38 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4d8cd86eec chore(deps): bump the minor-patch group with 2 updates (#1018)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 17:09:17 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
504866a432 chore(deps): bump github/codeql-action/upload-sarif from 4.37.0 to 4.37.1 (#1019)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 17:08:27 +03:00
Stavros 0c1a64d47d fix: make login_for optional 2026-07-18 17:05:52 +03:00
Stavros a79438d6ec fix: fix typo 2026-07-18 16:47:53 +03:00
Stavros 286aad2e08 test: remove obsolete test case 2026-07-18 16:37:52 +03:00
Stavros 5091fc2d7a fix: suppress irrelevant debugs logs in acls service 2026-07-18 16:30:12 +03:00
Stavros 0fc87ad58f fix: support for parent trusted domain, fixes #1021 2026-07-18 16:30:12 +03:00
StavrosandGitHub a7eba59a42 refactor: export cache package (#1017) 2026-07-17 02:04:34 +03:00
tsushanthandGitHub ac8703eadb fix(ldap): use DN instead of uid in basicAuth GetLDAPUser call (#1015) 2026-07-17 01:15:48 +03:00
Stavros 04a06ec5ff chore: use blue color for totp generate 2026-07-16 17:13:36 +03:00
Stavros 3cf3cc9090 fix: don't fail app on label provider init failure 2026-07-16 14:34:12 +03:00
Stavros 589fe22138 tests: add tests for abstain in oauth whitelist 2026-07-16 14:30:48 +03:00
1165c91fcc fix: abstain when OAuth whitelist is empty (#1010)
Co-authored-by: wwhsaber <wwhsaber@foxmail.com>
2026-07-16 14:23:07 +03:00
Stavros 84117ce109 chore: remove star history from readme 2026-07-15 17:10:38 +03:00
Stavros 9eabfe89fd chore: update screenshot 2026-07-15 16:48:16 +03:00
StavrosandGitHub dade1e2c8f refactor: rework rate limit logic (#1008) 2026-07-15 15:30:57 +03:00
StavrosandGitHub 6e095fd4f2 refactor: rework the way trusted proxies ip work (#1007) 2026-07-15 15:17:55 +03:00
Stavros 2a0d7bd37a chore: update sponsors section in readme 2026-07-15 01:59:19 +03:00
StavrosandGitHub c22925c2fb fix: use constant time in user checks (#1004) 2026-07-14 16:49:59 +03:00
StavrosandGitHub d946926c36 feat: allow existing query params in oidc redirect uri (#1003) 2026-07-14 16:36:57 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8881116360 chore(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.2 (#1001)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 16:17:36 +03:00
57 changed files with 1961 additions and 1120 deletions
+5 -2
View File
@@ -101,8 +101,6 @@ TINYAUTH_AUTH_SESSIONMAXLIFETIME=0
TINYAUTH_AUTH_LOGINTIMEOUT=300 TINYAUTH_AUTH_LOGINTIMEOUT=300
# Maximum login retries. # Maximum login retries.
TINYAUTH_AUTH_LOGINMAXRETRIES=3 TINYAUTH_AUTH_LOGINMAXRETRIES=3
# Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically.
TINYAUTH_AUTH_LOCKDOWNENABLED=true
# Comma-separated list of trusted proxy addresses. # Comma-separated list of trusted proxy addresses.
TINYAUTH_AUTH_TRUSTEDPROXIES= TINYAUTH_AUTH_TRUSTEDPROXIES=
# ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow. # ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow.
@@ -225,6 +223,11 @@ TINYAUTH_LDAP_AUTHKEY=
# Cache duration for LDAP group membership in seconds. # Cache duration for LDAP group membership in seconds.
TINYAUTH_LDAP_GROUPCACHETTL=900 TINYAUTH_LDAP_GROUPCACHETTL=900
# experimental config
# Enable the OAuth bridge, uses a new way to format OAuth user information.
TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false
# tailscale config # tailscale config
# Enable Tailscale integration. # Enable Tailscale integration.
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Setup go - name: Setup go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
+4 -4
View File
@@ -23,7 +23,7 @@ jobs:
REPO: ${{ github.event.repository.name }} REPO: ${{ github.event.repository.name }}
- name: Create release - name: Create release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
with: with:
prerelease: true prerelease: true
tag_name: nightly tag_name: nightly
@@ -68,7 +68,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
@@ -117,7 +117,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
@@ -476,7 +476,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
with: with:
files: binaries/* files: binaries/*
tag_name: nightly tag_name: nightly
+3 -3
View File
@@ -46,7 +46,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
@@ -92,7 +92,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
@@ -449,6 +449,6 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
with: with:
files: binaries/* files: binaries/*
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5 retention-days: 5
- name: Upload to code-scanning - name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+11 -15
View File
@@ -62,18 +62,19 @@ If you like, you can help translate Tinyauth into more languages by visiting the
Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file. Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file.
## Hosting Partners
If you use one of our partners, you can help support us while getting a great hosting deal.
<div>
<a title="InstaPods" target="_blank" href="https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth"><img src="https://instapods.com/deploy-button.svg"></a>
</div>
## Sponsors ## Sponsors
A big thank you to the following people for providing me with more coffee: A big thank-you to the following individuals and companies
for their support for Tinyauth.
### Companies and Organizations
- [JetBrains for Open-Source](https://jb.gg/OpenSource)
- [CodeRabbit AI](https://www.coderabbit.ai)
- [Dosu](https://dosu.dev/)
- [InstaPods](https://instapods.com) - [Deploy Tinyauth from 3$/month](https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth)
### Individuals
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/axjab"><img src="https:&#x2F;&#x2F;github.com&#x2F;axjab.png" width="64px" alt="User avatar: axjab" /></a>&nbsp;&nbsp;<a href="https://github.com/stegratech"><img src="https:&#x2F;&#x2F;github.com&#x2F;stegratech.png" width="64px" alt="User avatar: stegratech" /></a>&nbsp;&nbsp;<a href="https://github.com/apearson"><img src="https:&#x2F;&#x2F;github.com&#x2F;apearson.png" width="64px" alt="User avatar: apearson" /></a>&nbsp;&nbsp;<a href="https://github.com/Micky5991"><img src="https:&#x2F;&#x2F;github.com&#x2F;Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a>&nbsp;&nbsp;<!-- sponsors --> <!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/axjab"><img src="https:&#x2F;&#x2F;github.com&#x2F;axjab.png" width="64px" alt="User avatar: axjab" /></a>&nbsp;&nbsp;<a href="https://github.com/stegratech"><img src="https:&#x2F;&#x2F;github.com&#x2F;stegratech.png" width="64px" alt="User avatar: stegratech" /></a>&nbsp;&nbsp;<a href="https://github.com/apearson"><img src="https:&#x2F;&#x2F;github.com&#x2F;apearson.png" width="64px" alt="User avatar: apearson" /></a>&nbsp;&nbsp;<a href="https://github.com/Micky5991"><img src="https:&#x2F;&#x2F;github.com&#x2F;Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a>&nbsp;&nbsp;<!-- sponsors -->
@@ -81,9 +82,4 @@ A big thank you to the following people for providing me with more coffee:
- **Freepik** for providing the police hat and badge. - **Freepik** for providing the police hat and badge.
- **Renee French** for the original gopher logo. - **Renee French** for the original gopher logo.
- **Coderabbit AI** for providing free AI code reviews.
- **Syrhu** for providing the background image of the app. - **Syrhu** for providing the background image of the app.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=tinyauthapp/tinyauth&type=Date)](https://www.star-history.com/#tinyauthapp/tinyauth&Date)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

+2 -2
View File
@@ -116,11 +116,11 @@ func generateTotpCmd() *cli.Command {
userStr := fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret) userStr := fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)
fmt.Print("\nOr add the following TOTP secret to your authenticator app: ") fmt.Print("\nOr add the following TOTP secret to your authenticator app: ")
fmt.Print(colors.green.Render(secret)) fmt.Print(colors.blue.Render(secret))
fmt.Print("\n\n") fmt.Print("\n\n")
fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username) fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username)
fmt.Print(colors.green.Render(userStr)) fmt.Print(colors.blue.Render(userStr))
fmt.Print("\n") fmt.Print("\n")
return nil return nil
+1
View File
@@ -32,6 +32,7 @@ func main() {
Configuration: tConfig, Configuration: tConfig,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
// enable this on experimental features
if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) { if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) {
colors := getColors() colors := getColors()
fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.") fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.")
+4 -4
View File
@@ -32,7 +32,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.81.0",
"react-i18next": "^17.0.9", "react-i18next": "^17.0.10",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^8.2.0", "react-router": "^8.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -47,14 +47,14 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0", "eslint": "^10.7.0",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0", "globals": "^17.7.0",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.63.0", "typescript-eslint": "^8.64.0",
"vite": "^8.1.4" "vite": "^8.1.5"
} }
} }
+122 -122
View File
@@ -28,7 +28,7 @@ importers:
version: 1.3.0(@types/react@19.2.17)(react@19.2.7) version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.3.2 specifier: ^4.3.2
version: 4.3.2(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) version: 4.3.2(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.101.2 specifier: ^5.101.2
version: 5.101.2(react@19.2.7) version: 5.101.2(react@19.2.7)
@@ -69,8 +69,8 @@ importers:
specifier: ^7.81.0 specifier: ^7.81.0
version: 7.81.0(react@19.2.7) version: 7.81.0(react@19.2.7)
react-i18next: react-i18next:
specifier: ^17.0.9 specifier: ^17.0.10
version: 17.0.9(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) version: 17.0.10(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
react-markdown: react-markdown:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.17)(react@19.2.7) version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
@@ -92,10 +92,10 @@ importers:
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^10.0.1 specifier: ^10.0.1
version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) version: 10.0.1(eslint@10.7.0(jiti@2.7.0))
'@tanstack/eslint-plugin-query': '@tanstack/eslint-plugin-query':
specifier: ^5.101.2 specifier: ^5.101.2
version: 5.101.2(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) version: 5.101.2(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
'@types/node': '@types/node':
specifier: ^26.1.1 specifier: ^26.1.1
version: 26.1.1 version: 26.1.1
@@ -107,16 +107,16 @@ importers:
version: 19.2.3(@types/react@19.2.17) version: 19.2.3(@types/react@19.2.17)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.3(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) version: 6.0.3(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))
eslint: eslint:
specifier: ^10.6.0 specifier: ^10.7.0
version: 10.6.0(jiti@2.7.0) version: 10.7.0(jiti@2.7.0)
eslint-plugin-react-hooks: eslint-plugin-react-hooks:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.1.1(eslint@10.6.0(jiti@2.7.0)) version: 7.1.1(eslint@10.7.0(jiti@2.7.0))
eslint-plugin-react-refresh: eslint-plugin-react-refresh:
specifier: ^0.5.3 specifier: ^0.5.3
version: 0.5.3(eslint@10.6.0(jiti@2.7.0)) version: 0.5.3(eslint@10.7.0(jiti@2.7.0))
globals: globals:
specifier: ^17.7.0 specifier: ^17.7.0
version: 17.7.0 version: 17.7.0
@@ -130,11 +130,11 @@ importers:
specifier: ~6.0.2 specifier: ~6.0.2
version: 6.0.3 version: 6.0.3
typescript-eslint: typescript-eslint:
specifier: ^8.63.0 specifier: ^8.64.0
version: 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
vite: vite:
specifier: ^8.1.4 specifier: ^8.1.5
version: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) version: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)
packages: packages:
@@ -1272,16 +1272,16 @@ packages:
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@typescript-eslint/eslint-plugin@8.63.0': '@typescript-eslint/eslint-plugin@8.64.0':
resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.63.0 '@typescript-eslint/parser': ^8.64.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.63.0': '@typescript-eslint/parser@8.64.0':
resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} resolution: {integrity: sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1293,8 +1293,8 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.63.0': '@typescript-eslint/project-service@8.64.0':
resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
@@ -1303,8 +1303,8 @@ packages:
resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.63.0': '@typescript-eslint/scope-manager@8.64.0':
resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.62.1': '@typescript-eslint/tsconfig-utils@8.62.1':
@@ -1313,14 +1313,14 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/tsconfig-utils@8.63.0': '@typescript-eslint/tsconfig-utils@8.64.0':
resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.63.0': '@typescript-eslint/type-utils@8.64.0':
resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} resolution: {integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1330,8 +1330,8 @@ packages:
resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.63.0': '@typescript-eslint/types@8.64.0':
resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.62.1': '@typescript-eslint/typescript-estree@8.62.1':
@@ -1340,8 +1340,8 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/typescript-estree@8.63.0': '@typescript-eslint/typescript-estree@8.64.0':
resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
@@ -1353,8 +1353,8 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.63.0': '@typescript-eslint/utils@8.64.0':
resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1364,8 +1364,8 @@ packages:
resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.63.0': '@typescript-eslint/visitor-keys@8.64.0':
resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.1': '@ungap/structured-clone@1.3.1':
@@ -1603,8 +1603,8 @@ packages:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.6.0: eslint@10.7.0:
resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24} engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -1784,8 +1784,8 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
ignore@7.0.5: ignore@7.0.6:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
imurmurhash@0.1.4: imurmurhash@0.1.4:
@@ -2071,8 +2071,8 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nanoid@3.3.15: nanoid@3.3.16:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
@@ -2126,8 +2126,8 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'} engines: {node: '>=12'}
postcss@8.5.16: postcss@8.5.19:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0: powershell-utils@0.1.0:
@@ -2173,8 +2173,8 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.0.9: react-i18next@17.0.10:
resolution: {integrity: sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==} resolution: {integrity: sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==}
peerDependencies: peerDependencies:
i18next: '>= 26.2.0' i18next: '>= 26.2.0'
react: '>= 16.8.0' react: '>= 16.8.0'
@@ -2357,8 +2357,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
typescript-eslint@8.63.0: typescript-eslint@8.64.0:
resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -2430,8 +2430,8 @@ packages:
vfile@6.0.3: vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@8.1.4: vite@8.1.5:
resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -2647,9 +2647,9 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))': '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0(jiti@2.7.0))':
dependencies: dependencies:
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {} '@eslint-community/regexpp@4.12.2': {}
@@ -2670,9 +2670,9 @@ snapshots:
dependencies: dependencies:
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15
'@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0))': '@eslint/js@10.0.1(eslint@10.7.0(jiti@2.7.0))':
optionalDependencies: optionalDependencies:
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
'@eslint/object-schema@3.0.5': {} '@eslint/object-schema@3.0.5': {}
@@ -3607,17 +3607,17 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2
'@tailwindcss/oxide-win32-x64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2
'@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))': '@tailwindcss/vite@4.3.2(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))':
dependencies: dependencies:
'@tailwindcss/node': 4.3.2 '@tailwindcss/node': 4.3.2
'@tailwindcss/oxide': 4.3.2 '@tailwindcss/oxide': 4.3.2
tailwindcss: 4.3.2 tailwindcss: 4.3.2
vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)
'@tanstack/eslint-plugin-query@5.101.2(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': '@tanstack/eslint-plugin-query@5.101.2(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/utils': 8.62.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
optionalDependencies: optionalDependencies:
typescript: 6.0.3 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3675,30 +3675,30 @@ snapshots:
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
'@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/parser': 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.63.0 '@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/type-utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/type-utils': 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/utils': 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.63.0 '@typescript-eslint/visitor-keys': 8.64.0
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
ignore: 7.0.5 ignore: 7.0.6
natural-compare: 1.4.0 natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.3) ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': '@typescript-eslint/parser@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.63.0 '@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/types': 8.63.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.63.0 '@typescript-eslint/visitor-keys': 8.64.0
debug: 4.4.3 debug: 4.4.3
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
typescript: 6.0.3 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3712,10 +3712,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.63.0(typescript@6.0.3)': '@typescript-eslint/project-service@8.64.0(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@6.0.3)
'@typescript-eslint/types': 8.63.0 '@typescript-eslint/types': 8.64.0
debug: 4.4.3 debug: 4.4.3
typescript: 6.0.3 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3726,26 +3726,26 @@ snapshots:
'@typescript-eslint/types': 8.62.1 '@typescript-eslint/types': 8.62.1
'@typescript-eslint/visitor-keys': 8.62.1 '@typescript-eslint/visitor-keys': 8.62.1
'@typescript-eslint/scope-manager@8.63.0': '@typescript-eslint/scope-manager@8.64.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.63.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/visitor-keys': 8.63.0 '@typescript-eslint/visitor-keys': 8.64.0
'@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)': '@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)':
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
'@typescript-eslint/tsconfig-utils@8.63.0(typescript@6.0.3)': '@typescript-eslint/tsconfig-utils@8.64.0(typescript@6.0.3)':
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
'@typescript-eslint/type-utils@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': '@typescript-eslint/type-utils@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.63.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3)
'@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/utils': 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
debug: 4.4.3 debug: 4.4.3
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@6.0.3) ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3753,7 +3753,7 @@ snapshots:
'@typescript-eslint/types@8.62.1': {} '@typescript-eslint/types@8.62.1': {}
'@typescript-eslint/types@8.63.0': {} '@typescript-eslint/types@8.64.0': {}
'@typescript-eslint/typescript-estree@8.62.1(typescript@6.0.3)': '@typescript-eslint/typescript-estree@8.62.1(typescript@6.0.3)':
dependencies: dependencies:
@@ -3770,12 +3770,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/typescript-estree@8.63.0(typescript@6.0.3)': '@typescript-eslint/typescript-estree@8.64.0(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.63.0(typescript@6.0.3) '@typescript-eslint/project-service': 8.64.0(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@6.0.3)
'@typescript-eslint/types': 8.63.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/visitor-keys': 8.63.0 '@typescript-eslint/visitor-keys': 8.64.0
debug: 4.4.3 debug: 4.4.3
minimatch: 10.2.5 minimatch: 10.2.5
semver: 7.8.5 semver: 7.8.5
@@ -3785,24 +3785,24 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': '@typescript-eslint/utils@8.62.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.62.1 '@typescript-eslint/scope-manager': 8.62.1
'@typescript-eslint/types': 8.62.1 '@typescript-eslint/types': 8.62.1
'@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3)
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
typescript: 6.0.3 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': '@typescript-eslint/utils@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.63.0 '@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/types': 8.63.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3)
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
typescript: 6.0.3 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3812,17 +3812,17 @@ snapshots:
'@typescript-eslint/types': 8.62.1 '@typescript-eslint/types': 8.62.1
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
'@typescript-eslint/visitor-keys@8.63.0': '@typescript-eslint/visitor-keys@8.64.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.63.0 '@typescript-eslint/types': 8.64.0
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
'@ungap/structured-clone@1.3.1': {} '@ungap/structured-clone@1.3.1': {}
'@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))': '@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.1 '@rolldown/pluginutils': 1.0.1
vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)
acorn-jsx@5.3.2(acorn@8.17.0): acorn-jsx@5.3.2(acorn@8.17.0):
dependencies: dependencies:
@@ -3997,20 +3997,20 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)): eslint-plugin-react-hooks@7.1.1(eslint@10.7.0(jiti@2.7.0)):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/parser': 7.29.3 '@babel/parser': 7.29.3
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
hermes-parser: 0.25.1 hermes-parser: 0.25.1
zod: 4.4.3 zod: 4.4.3
zod-validation-error: 4.0.2(zod@4.4.3) zod-validation-error: 4.0.2(zod@4.4.3)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-react-refresh@0.5.3(eslint@10.6.0(jiti@2.7.0)): eslint-plugin-react-refresh@0.5.3(eslint@10.7.0(jiti@2.7.0)):
dependencies: dependencies:
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
eslint-scope@9.1.2: eslint-scope@9.1.2:
dependencies: dependencies:
@@ -4023,9 +4023,9 @@ snapshots:
eslint-visitor-keys@5.0.1: {} eslint-visitor-keys@5.0.1: {}
eslint@10.6.0(jiti@2.7.0): eslint@10.7.0(jiti@2.7.0):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5 '@eslint/config-array': 0.23.5
'@eslint/config-helpers': 0.6.0 '@eslint/config-helpers': 0.6.0
@@ -4226,7 +4226,7 @@ snapshots:
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.6: {}
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
@@ -4591,7 +4591,7 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.15: {} nanoid@3.3.16: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
@@ -4648,9 +4648,9 @@ snapshots:
picomatch@4.0.5: {} picomatch@4.0.5: {}
postcss@8.5.16: postcss@8.5.19:
dependencies: dependencies:
nanoid: 3.3.15 nanoid: 3.3.16
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
@@ -4736,7 +4736,7 @@ snapshots:
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
react-i18next@17.0.9(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): react-i18next@17.0.10(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3):
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1 html-parse-stringify: 3.0.1
@@ -4923,13 +4923,13 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): typescript-eslint@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/parser': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/parser': 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3)
'@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/utils': 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
eslint: 10.6.0(jiti@2.7.0) eslint: 10.7.0(jiti@2.7.0)
typescript: 6.0.3 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5010,11 +5010,11 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.0.3 vfile-message: 4.0.3
vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0): vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.5 picomatch: 4.0.5
postcss: 8.5.16 postcss: 8.5.19
rolldown: 1.1.5 rolldown: 1.1.5
tinyglobby: 0.2.17 tinyglobby: 0.2.17
optionalDependencies: optionalDependencies:
@@ -32,8 +32,8 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { useRef } from "react"; import { useRef } from "react";
import { import {
searchParamsFromObject,
useScreenParams, useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
@@ -70,7 +70,13 @@ export const QuickActions = () => {
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams); const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
+6 -2
View File
@@ -1,10 +1,11 @@
type UseLoginForProps = { type UseLoginForProps = {
login_for?: "oidc" | "app"; login_for?: "oidc" | "app";
compiledParams: string; params: URLSearchParams;
}; };
export const useLoginFor = (props: UseLoginForProps): string => { export const useLoginFor = (props: UseLoginForProps): string => {
const { login_for, compiledParams } = props; const { login_for, params } = props;
const compiledParams = params.toString() ? "?" + params.toString() : "";
switch (login_for) { switch (login_for) {
case "oidc": case "oidc":
@@ -12,6 +13,9 @@ export const useLoginFor = (props: UseLoginForProps): string => {
case "app": case "app":
return "/continue" + compiledParams; return "/continue" + compiledParams;
default: default:
if (params.get("redirect_uri")) {
return "/continue" + compiledParams
}
return "/logout"; return "/logout";
} }
}; };
+18 -18
View File
@@ -75,18 +75,21 @@ export const useRedirectUri = (
}; };
}; };
// ported from internal/controller/oauth_controller.go // https://www.geeksforgeeks.org/javascript/how-to-check-if-a-string-is-a-valid-ip-address-format-in-javascript
const getEffectivePort = (url: URL): string => { const isIP = (str: string): boolean => {
if (url.port) { const ipv4 =
return url.port; /^(\d{1,3}\.){3}\d{1,3}$/;
} const ipv6 =
/^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4.test(str) || ipv6.test(str) || str.startsWith("[");
}
if (url.protocol == "https:") { const trimPeriod = (str: string): string => {
return "443"; if(str.lastIndexOf('.') === (str.length - 1)){
str = str.substring(0, str.length - 1);
} }
return str
return "80"; }
};
export const isTrustedDomain = ( export const isTrustedDomain = (
url: URL, url: URL,
@@ -94,15 +97,15 @@ export const isTrustedDomain = (
cookieDomain: string, cookieDomain: string,
subdomainsEnabled: boolean, subdomainsEnabled: boolean,
): boolean => { ): boolean => {
if (url.protocol != appUrl.protocol) { if (isIP(url.hostname)) {
return false; return false;
} }
if (getEffectivePort(url) != getEffectivePort(appUrl)) { if (url.port != appUrl.port) {
return false; return false;
} }
if (url.hostname == appUrl.hostname) { if (trimPeriod(url.hostname) == trimPeriod(appUrl.hostname)) {
return true; return true;
} }
@@ -110,9 +113,6 @@ export const isTrustedDomain = (
return false; return false;
} }
if (url.hostname.endsWith("." + cookieDomain.toLowerCase())) { return trimPeriod(url.hostname).endsWith("." + cookieDomain.toLowerCase())
return true; || trimPeriod(url.hostname) == cookieDomain.toLowerCase();
}
return false;
}; };
+4 -10
View File
@@ -27,16 +27,10 @@ export function useScreenParams(params: URLSearchParams): ScreenParams {
return parsed.data; return parsed.data;
} }
export function recompileScreenParams(params: ScreenParams): string { export function searchParamsFromObject(obj: object): URLSearchParams {
const p = new URLSearchParams( return new URLSearchParams(
Object.fromEntries( Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== undefined), Object.entries(obj).filter(([, v]) => v !== undefined),
) as Record<string, string>, ) as Record<string, string>,
).toString(); );
if (p.length > 0) {
return "?" + p;
}
return "";
} }
+8 -2
View File
@@ -22,7 +22,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { import {
recompileScreenParams, searchParamsFromObject,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -89,7 +89,13 @@ export const AuthorizePage = () => {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const isOidc = screenParams.login_for === "oidc"; const isOidc = screenParams.login_for === "oidc";
const compiledParams = recompileScreenParams(screenParams); const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
// TODO: maybe a better way to do this // TODO: maybe a better way to do this
const shouldAutoAuthorize = const shouldAutoAuthorize =
+10 -4
View File
@@ -13,7 +13,7 @@ import { Navigate, useLocation, useNavigate } from "react-router";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRedirectUri } from "@/lib/hooks/redirect-uri"; import { useRedirectUri } from "@/lib/hooks/redirect-uri";
import { import {
recompileScreenParams, searchParamsFromObject,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
@@ -31,8 +31,14 @@ export const ContinuePage = () => {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const redirectUri = screenParams.redirect_uri; const redirectUri = screenParams.redirect_uri;
const isAppLogin = screenParams.login_for === "app"; const isAppLogin = screenParams.login_for === "app" || !screenParams.login_for;
const recompiledParams = recompileScreenParams(screenParams); const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri( const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
redirectUri, redirectUri,
@@ -89,7 +95,7 @@ export const ContinuePage = () => {
}, [shouldAutoRedirect, redirectToTarget]); }, [shouldAutoRedirect, redirectToTarget]);
if (!auth.authenticated) { if (!auth.authenticated) {
return <Navigate to={`/login${recompiledParams}`} replace />; return <Navigate to={`/login${compiledParams}`} replace />;
} }
if (!hasValidRedirect || !isAppLogin) { if (!hasValidRedirect || !isAppLogin) {
+9 -3
View File
@@ -12,8 +12,8 @@ import { useTranslation } from "react-i18next";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { import {
recompileScreenParams, searchParamsFromObject,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
export const ForgotPasswordPage = () => { export const ForgotPasswordPage = () => {
@@ -22,7 +22,13 @@ export const ForgotPasswordPage = () => {
const { search } = useLocation(); const { search } = useLocation();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams); const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
return ( return (
<Card> <Card>
+12 -10
View File
@@ -26,7 +26,7 @@ import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
recompileScreenParams, searchParamsFromObject,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for"; import { useLoginFor } from "@/lib/hooks/login-for";
@@ -63,18 +63,21 @@ export const LoginPage = () => {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams({ const compiledParams = (() => {
...screenParams, const params = searchParamsFromObject(screenParams).toString();
oidc_prompt: undefined, if (params.length > 0) {
}); return `?${params}`;
}
return "";
})();
const loginForUrl = useLoginFor({ const loginForUrl = useLoginFor({
login_for: screenParams.login_for, login_for: screenParams.login_for,
compiledParams, params: searchParamsFromObject({ ...screenParams, oidc_prompt: undefined}),
}); });
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState( const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
providers.find((provider) => provider.id === oauth.autoRedirect) !== providers.find((provider) => provider.id === oauth.autoRedirect) !==
undefined && screenParams.redirect_uri !== undefined, undefined && (screenParams.redirect_uri || screenParams.oidc_ticket),
); );
const oauthProviders = providers.filter( const oauthProviders = providers.filter(
@@ -171,8 +174,7 @@ export const LoginPage = () => {
!auth.authenticated && !auth.authenticated &&
isOauthAutoRedirect && isOauthAutoRedirect &&
!hasAutoRedirectedRef.current && !hasAutoRedirectedRef.current &&
screenParams.redirect_uri && (screenParams.redirect_uri || screenParams.oidc_ticket)
screenParams.login_for
) { ) {
hasAutoRedirectedRef.current = true; hasAutoRedirectedRef.current = true;
oauthMutate(oauth.autoRedirect); oauthMutate(oauth.autoRedirect);
@@ -183,8 +185,8 @@ export const LoginPage = () => {
hasAutoRedirectedRef, hasAutoRedirectedRef,
oauth.autoRedirect, oauth.autoRedirect,
isOauthAutoRedirect, isOauthAutoRedirect,
screenParams.login_for,
screenParams.redirect_uri, screenParams.redirect_uri,
screenParams.oidc_ticket
]); ]);
useEffect(() => { useEffect(() => {
+8 -2
View File
@@ -17,8 +17,8 @@ import { type UseMutationResult } from "@tanstack/react-query";
import { type AxiosResponse } from "axios"; import { type AxiosResponse } from "axios";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { import {
searchParamsFromObject,
useScreenParams, useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
export const LogoutPage = () => { export const LogoutPage = () => {
@@ -29,7 +29,13 @@ export const LogoutPage = () => {
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams); const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/user/logout"), mutationFn: () => axios.post("/api/user/logout"),
+9 -3
View File
@@ -17,7 +17,7 @@ import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
recompileScreenParams, searchParamsFromObject,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for"; import { useLoginFor } from "@/lib/hooks/login-for";
@@ -32,10 +32,16 @@ export const TotpPage = () => {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams); const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const loginForUrl = useLoginFor({ const loginForUrl = useLoginFor({
login_for: screenParams.login_for, login_for: screenParams.login_for,
compiledParams, params: searchParamsFromObject(screenParams),
}); });
const totpMutation = useMutation({ const totpMutation = useMutation({
+5 -5
View File
@@ -9,7 +9,7 @@ require (
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.14
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/go-querystring v1.2.0 github.com/google/go-querystring v1.2.0
@@ -24,12 +24,13 @@ require (
github.com/weppos/publicsuffix-go v0.50.3 github.com/weppos/publicsuffix-go v0.50.3
go.uber.org/dig v1.19.0 go.uber.org/dig v1.19.0
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/tools v0.48.0 golang.org/x/tools v0.48.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.36.2 k8s.io/apimachinery v0.36.2
k8s.io/client-go v0.36.2 k8s.io/client-go v0.36.2
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.54.0
) )
require ( require (
@@ -72,7 +73,7 @@ require (
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonreference v0.20.4 // indirect github.com/go-openapi/jsonreference v0.20.4 // indirect
@@ -135,7 +136,6 @@ require (
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.38.0 // indirect golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.45.0 // indirect
@@ -147,7 +147,7 @@ require (
k8s.io/klog/v2 v2.140.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect rsc.io/qr v0.2.0 // indirect
+14 -14
View File
@@ -113,12 +113,12 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -392,20 +392,20 @@ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hk
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -414,8 +414,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+9 -7
View File
@@ -88,17 +88,13 @@ func (app *BootstrapApp) Setup() error {
app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version) app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version)
// get app url // get app url
if app.config.AppURL == "" { appURL, err := utils.SafeParseAppURL(app.config.AppURL)
return errors.New("app url cannot be empty, perhaps config loading failed")
}
appUrl, err := url.Parse(app.config.AppURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse app url: %w", err) return fmt.Errorf("failed to parse app url: %w", err)
} }
app.runtime.AppURL = strings.ToLower(appUrl.Scheme + "://" + appUrl.Host) app.runtime.AppURL = appURL
// validate session config // validate session config
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry { if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
@@ -172,7 +168,13 @@ func (app *BootstrapApp) Setup() error {
app.runtime.CookieDomain = cookieDomain app.runtime.CookieDomain = cookieDomain
// cookie names // cookie names
app.runtime.UUID = utils.GenerateUUID(appUrl.Hostname()) u, err := url.Parse(app.runtime.AppURL)
if err != nil {
return fmt.Errorf("failed to parse app url: %w", err)
}
app.runtime.UUID = utils.GenerateUUID(u.Hostname())
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
+10
View File
@@ -30,6 +30,16 @@ func (app *BootstrapApp) setupRouter() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set trusted proxies: %w", err) return fmt.Errorf("failed to set trusted proxies: %w", err)
} }
app.runtime.TrustedProxiesConfigured = true
} else {
err := engine.SetTrustedProxies(nil)
if err != nil {
return fmt.Errorf("failed to set trusted proxies: %w", err)
}
app.log.App.Warn().Msg("Trusted proxies are not configured, IP access controls will NOT work")
} }
middlewareProvideFor := []any{ middlewareProvideFor := []any{
+8 -4
View File
@@ -85,7 +85,8 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
err := app.dig.Provide(service.NewKubernetesService) err := app.dig.Provide(service.NewKubernetesService)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to provide kubernetes service: %w", err) app.log.App.Warn().Err(err).Msg("Failed to provide kubernetes service")
return nil, nil
} }
err = app.dig.Invoke(func(k *service.KubernetesService) error { err = app.dig.Invoke(func(k *service.KubernetesService) error {
@@ -94,7 +95,8 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to invoke kubernetes service: %w", err) app.log.App.Warn().Err(err).Msg("Failed to invoke kubernetes service")
return nil, nil
} }
// Kubernetes will fail to initialize with an error if it cannot connect to the cluster // Kubernetes will fail to initialize with an error if it cannot connect to the cluster
@@ -114,7 +116,8 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
err := app.dig.Provide(service.NewDockerService) err := app.dig.Provide(service.NewDockerService)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to provide docker service: %w", err) app.log.App.Warn().Err(err).Msg("Failed to provide docker service")
return nil, nil
} }
err = app.dig.Invoke(func(d *service.DockerService) error { err = app.dig.Invoke(func(d *service.DockerService) error {
@@ -123,7 +126,8 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to invoke docker service: %w", err) app.log.App.Warn().Err(err).Msg("Failed to invoke docker service")
return nil, nil
} }
if app.services.dockerService == nil { if app.services.dockerService == nil {
+68 -33
View File
@@ -220,35 +220,16 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return return
} }
var name string oauthUserInfo := controller.createOAuthUserInfo(oauthUserInfo{
Username: user.PreferredUsername,
if strings.TrimSpace(user.Name) != "" { Email: user.Email,
controller.log.App.Debug().Msg("Using name from OAuth provider") Name: user.Name,
name = user.Name })
} else {
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
parts := strings.SplitN(user.Email, "@", 2)
if len(parts) == 2 {
name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
} else {
name = utils.Capitalize(user.Email)
}
}
var username string
if strings.TrimSpace(user.PreferredUsername) != "" {
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
username = user.PreferredUsername
} else {
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
username = strings.Replace(user.Email, "@", "_", 1)
}
sessionCookie := repository.Session{ sessionCookie := repository.Session{
Username: username, Username: oauthUserInfo.Username,
Name: name, Name: oauthUserInfo.Name,
Email: user.Email, Email: oauthUserInfo.Email,
Provider: svc.ID(), Provider: svc.ID(),
OAuthGroups: utils.CoalesceToString(user.Groups), OAuthGroups: utils.CoalesceToString(user.Groups),
OAuthName: svc.Name(), OAuthName: svc.Name(),
@@ -313,8 +294,7 @@ func (controller *OAuthController) getCookieDomain() string {
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{ v := validators.NewDomainValidator(validators.DomainValidatorOptions{
WithScheme: true, WithPort: true,
WithPort: true,
}) })
_, err := v.SafeHostname(controller.runtime.AppURL) _, err := v.SafeHostname(controller.runtime.AppURL)
@@ -332,9 +312,7 @@ func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI") controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI")
if errors.Is(err, validators.ErrInvalidURL) || if !errors.Is(err, validators.ErrHostnameMismatch) {
errors.Is(err, validators.ErrSchemeMismatch) ||
errors.Is(err, validators.ErrPortMismatch) {
return false return false
} }
@@ -351,9 +329,66 @@ func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
return false return false
} }
if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) { if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) ||
hostname == controller.runtime.CookieDomain {
return true return true
} }
return false return false
} }
type oauthUserInfo struct {
Email string
Username string
Name string
}
func (controller *OAuthController) createOAuthUserInfo(input oauthUserInfo) oauthUserInfo {
info := oauthUserInfo{
Email: input.Email,
}
if controller.config.Experimental.OAuthBridgeEnabled {
if input.Username != "" {
info.Username = input.Username
} else {
parts := strings.SplitN(input.Email, "@", 2)
if len(parts) != 2 {
controller.log.App.Error().Str("email", input.Email).Msg("Invalid email address")
} else {
info.Username = parts[0]
}
}
if input.Name != "" {
info.Name = input.Name
} else {
info.Name = utils.Capitalize(info.Username)
}
return info
}
if input.Name != "" {
controller.log.App.Debug().Msg("Using name from OAuth provider")
info.Name = input.Name
} else {
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
parts := strings.SplitN(input.Email, "@", 2)
if len(parts) != 2 {
controller.log.App.Error().Str("email", input.Email).Msg("Invalid email address")
} else {
info.Name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
}
}
if input.Username != "" {
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
info.Username = input.Username
} else {
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
info.Username = strings.Replace(info.Email, "@", "_", 1)
}
return info
}
@@ -81,22 +81,6 @@ func TestOAuthController_isRedirectSafe(t *testing.T) {
redirectURI: "https://sub.example.com", redirectURI: "https://sub.example.com",
expected: false, expected: false,
}, },
{
description: "Cookie domain itself is not a subdomain match",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://example.com",
expected: false,
},
{
description: "Different scheme returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "http://tinyauth.example.com",
expected: false,
},
{ {
description: "Different port returns false", description: "Different port returns false",
appURL: "https://tinyauth.example.com", appURL: "https://tinyauth.example.com",
@@ -121,14 +105,6 @@ func TestOAuthController_isRedirectSafe(t *testing.T) {
redirectURI: "https:/malicious", redirectURI: "https:/malicious",
expected: false, expected: false,
}, },
{
description: "Redirect URI without scheme returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "tinyauth.example.com",
expected: false,
},
{ {
description: "Relative redirect URI returns false", description: "Relative redirect URI returns false",
appURL: "https://tinyauth.example.com", appURL: "https://tinyauth.example.com",
+34 -29
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -343,27 +344,31 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
// Create the authorization code // Create the authorization code
code := controller.oidc.CreateCode(*authorizeReq, *userContext) code := controller.oidc.CreateCode(*authorizeReq, *userContext)
queries, err := query.Values(AuthorizeCallback{ cu, err := url.Parse(authorizeReq.RedirectURI)
Code: code,
State: authorizeReq.State,
})
if err != nil { if err != nil {
controller.authorizeError(c, authorizeErrorParams{ controller.authorizeError(c, authorizeErrorParams{
err: err, err: err,
reason: "Failed to build query", reason: "Failed to parse redirect URI",
reasonPublic: "Failed to build query", reasonPublic: "Failed to parse redirect URI",
callback: authorizeReq.RedirectURI, json: true,
callbackError: "server_error",
state: authorizeReq.State,
json: true,
}) })
return return
} }
q := cu.Query()
q.Set("code", code)
if authorizeReq.State != "" {
q.Set("state", authorizeReq.State)
}
cu.RawQuery = q.Encode()
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()), "redirect_uri": cu.String(),
}) })
} }
@@ -639,37 +644,37 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error") controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error")
if params.callback != "" { if params.callback != "" {
errorQueries := CallbackError{ cu, err := url.Parse(params.callback)
Error: params.callbackError,
}
if params.reasonPublic != "" {
errorQueries.ErrorDescription = params.reasonPublic
}
if params.state != "" {
errorQueries.State = params.state
}
queries, err := query.Values(errorQueries)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to build callback error query") controller.log.App.Error().Err(err).Msg("Failed to parse callback URL")
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)
return return
} }
redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode()) q := cu.Query()
q.Set("error", params.callbackError)
if params.reasonPublic != "" {
q.Set("error_description", params.reasonPublic)
}
if params.state != "" {
q.Set("state", params.state)
}
cu.RawQuery = q.Encode()
if params.json { if params.json {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": redirectUrl, "redirect_uri": cu.String(),
}) })
return return
} }
c.Redirect(http.StatusFound, redirectUrl) c.Redirect(http.StatusFound, cu.String())
return return
} }
+34 -9
View File
@@ -9,14 +9,14 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"go.uber.org/dig"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"go.uber.org/dig"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
) )
type AuthModuleType int type AuthModuleType int
@@ -111,9 +111,10 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP() clientIP := c.ClientIP()
aclsCtx := &service.ACLContext{ aclsCtx := &service.ACLContext{
ACLs: acls, ACLs: acls,
IP: net.ParseIP(clientIP), IP: net.ParseIP(clientIP),
Path: proxyCtx.Path, Path: proxyCtx.Path,
TrustedProxiesConfigured: controller.runtime.TrustedProxiesConfigured,
} }
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) { if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
@@ -344,6 +345,19 @@ func (controller *ProxyController) getHeader(c *gin.Context, header string) (str
return val, strings.TrimSpace(val) != "" return val, strings.TrimSpace(val) != ""
} }
func getRequestPath(uri string) (string, error) {
parsedURI, err := url.ParseRequestURI(uri)
if err != nil {
return "", err
}
if parsedURI.Path == "" {
return "/", nil
}
return parsedURI.Path, nil
}
func (controller *ProxyController) useBrowserResponse(proxyCtx ProxyContext) bool { func (controller *ProxyController) useBrowserResponse(proxyCtx ProxyContext) bool {
// If it's nginx we need non-browser response // If it's nginx we need non-browser response
if proxyCtx.ProxyType == Nginx { if proxyCtx.ProxyType == Nginx {
@@ -389,6 +403,11 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC
return ProxyContext{}, errors.New("x-forwarded-uri not found") return ProxyContext{}, errors.New("x-forwarded-uri not found")
} }
path, err := getRequestPath(uri)
if err != nil {
return ProxyContext{}, fmt.Errorf("invalid x-forwarded-uri: %w", err)
}
proto, ok := controller.getHeader(c, "x-forwarded-proto") proto, ok := controller.getHeader(c, "x-forwarded-proto")
if !ok { if !ok {
@@ -402,7 +421,7 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC
return ProxyContext{ return ProxyContext{
Host: host, Host: host,
Proto: proto, Proto: proto,
Path: uri, Path: path,
Method: method, Method: method,
Type: ForwardAuth, Type: ForwardAuth,
}, nil }, nil
@@ -434,6 +453,9 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC
} }
path := url.Path path := url.Path
if path == "" {
path = "/"
}
method := c.Request.Method method := c.Request.Method
return ProxyContext{ return ProxyContext{
@@ -461,7 +483,10 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
} }
// We get the path from the query string // We get the path from the query string
path := c.Query("path") path, err := getRequestPath(c.Query("path"))
if err != nil {
return ProxyContext{}, fmt.Errorf("invalid path: %w", err)
}
// For envoy we need to support every method // For envoy we need to support every method
method := c.Request.Method method := c.Request.Method
+84 -1
View File
@@ -287,6 +287,76 @@ func TestProxyController(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
}, },
}, },
{
description: "Ensure path allow ACL does not match forwarded URI query string",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-allow.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/admin?path=/allowed")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path allow ACL does not match path substrings",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-allow.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/admin/allowed")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path block ACL works on forward auth",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/blocked")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path block ACL does not match forwarded URI query string",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/admin?path=/blocked")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
},
},
{
description: "Ensure path block ACL does not match path substrings",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/admin/blocked")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
},
},
{
description: "Ensure path allow ACL ignores query strings for nginx auth request",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
req.Header.Set("x-original-url", "https://path-allow.example.com/admin?path=/allowed")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{ {
description: "Ensure path allow ACL works on nginx auth request", description: "Ensure path allow ACL works on nginx auth request",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
@@ -297,6 +367,17 @@ func TestProxyController(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
}, },
}, },
{
description: "Ensure path allow ACL ignores query strings for envoy ext authz",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/admin%3Fpath%3D/allowed", nil)
req.Host = "path-allow.example.com"
req.Header.Set("x-forwarded-proto", "https")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{ {
description: "Ensure path allow ACL works on envoy ext authz", description: "Ensure path allow ACL works on envoy ext authz",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
@@ -708,7 +789,7 @@ func TestProxyController(t *testing.T) {
Log: log, Log: log,
}) })
authService := service.NewAuthService(service.AuthServiceInput{ authService, err := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -721,6 +802,8 @@ func TestProxyController(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
router := gin.Default() router := gin.Default()
+24 -56
View File
@@ -72,11 +72,32 @@ func (controller *UserController) loginHandler(c *gin.Context) {
controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt") controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt")
search, err := controller.auth.SearchUser(req.Username)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
controller.auth.DummyPasswordCheck(req.Password)
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
isLocked, remaining := controller.auth.IsAccountLocked(req.Username) isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
if isLocked { if isLocked {
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts") controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "account locked") controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "account locked")
c.Writer.Header().Add("x-tinyauth-lock-locked", "true") c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{ c.JSON(429, gin.H{
@@ -86,44 +107,10 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return return
} }
search, err := controller.auth.SearchUser(req.Username)
if err != nil {
controller.constantTime(func() constantTimeRes {
if errors.Is(err, service.ErrUserNotFound) {
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
controller.auth.RecordLoginAttempt(req.Username, false)
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
return constantTimeRes{
Code: 401,
Res: gin.H{
"status": 401,
"message": "Unauthorized",
},
}
}
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
return constantTimeRes{
Code: 500,
Res: gin.H{
"status": 500,
"message": "Internal Server Error",
},
}
}, func(res constantTimeRes) {
c.JSON(res.Code, res.Res)
}, time.Millisecond*45)
return
}
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil { if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt") controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(req.Username, false)
if search.Type == model.UserLocal { controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "invalid password")
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "invalid password")
} else {
controller.log.AuditLoginFailure(req.Username, "ldap", c.ClientIP(), "invalid password")
}
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -224,11 +211,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
controller.log.App.Info().Str("username", req.Username).Msg("Login successful") controller.log.App.Info().Str("username", req.Username).Msg("Login successful")
if search.Type == model.UserLocal { controller.log.AuditLoginSuccess(req.Username, search.Type.String(), c.ClientIP())
controller.log.AuditLoginSuccess(req.Username, "local", c.ClientIP())
} else {
controller.log.AuditLoginSuccess(req.Username, "ldap", c.ClientIP())
}
controller.auth.RecordLoginAttempt(req.Username, true) controller.auth.RecordLoginAttempt(req.Username, true)
@@ -475,18 +458,3 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
"message": "Login successful", "message": "Login successful",
}) })
} }
type constantTimeRes struct {
Code int
Res any
}
func (controller *UserController) constantTime(f func() constantTimeRes, rf func(res constantTimeRes), targetTime time.Duration) {
tStart := time.Now()
res := f()
tEnd := time.Now()
if tEnd.Sub(tStart) < targetTime {
time.Sleep(targetTime - tEnd.Sub(tStart))
}
rf(res)
}
+4 -1
View File
@@ -542,7 +542,8 @@ func TestUserController(t *testing.T) {
Runtime: &runtime, Runtime: &runtime,
Ctx: ctx, Ctx: ctx,
}) })
authService := service.NewAuthService(service.AuthServiceInput{
authService, err := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -555,6 +556,8 @@ func TestUserController(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
beforeEach := func() { beforeEach := func() {
// Clear failed login attempts before each test // Clear failed login attempts before each test
authService.ClearLoginAttempts() authService.ClearLoginAttempts()
+16 -6
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -39,6 +40,7 @@ var (
type ContextMiddleware struct { type ContextMiddleware struct {
log *logger.Logger log *logger.Logger
runtime *model.RuntimeConfig runtime *model.RuntimeConfig
config *model.Config
auth *service.AuthService auth *service.AuthService
broker *service.OAuthBrokerService broker *service.OAuthBrokerService
tailscale *service.TailscaleService tailscale *service.TailscaleService
@@ -49,6 +51,7 @@ type ContextMiddlewareInput struct {
Log *logger.Logger Log *logger.Logger
RuntimeConfig *model.RuntimeConfig RuntimeConfig *model.RuntimeConfig
StaticConfig *model.Config
AuthService *service.AuthService AuthService *service.AuthService
BrokerService *service.OAuthBrokerService BrokerService *service.OAuthBrokerService
TailscaleService *service.TailscaleService TailscaleService *service.TailscaleService
@@ -58,6 +61,7 @@ func NewContextMiddleware(i ContextMiddlewareInput) *ContextMiddleware {
return &ContextMiddleware{ return &ContextMiddleware{
log: i.Log, log: i.Log,
runtime: i.RuntimeConfig, runtime: i.RuntimeConfig,
config: i.StaticConfig,
auth: i.AuthService, auth: i.AuthService,
broker: i.BrokerService, broker: i.BrokerService,
tailscale: i.TailscaleService, tailscale: i.TailscaleService,
@@ -244,6 +248,9 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
search, err := m.auth.SearchUser(username) search, err := m.auth.SearchUser(username)
if err != nil { if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
m.auth.DummyPasswordCheck(password)
}
return nil, nil, fmt.Errorf("error searching for user: %w", err) return nil, nil, fmt.Errorf("error searching for user: %w", err)
} }
@@ -278,7 +285,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
} }
userContext.Provider = model.ProviderLocal userContext.Provider = model.ProviderLocal
case model.UserLDAP: case model.UserLDAP:
user, err := m.auth.GetLDAPUser(username) user, err := m.auth.GetLDAPUser(search.Username)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err) return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
@@ -328,16 +335,19 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext,
return nil, nil return nil, nil
} }
username := strings.Replace(whois.LoginName, "@", "_", 1)
uctx := model.TailscaleContext{ uctx := model.TailscaleContext{
BaseContext: model.BaseContext{ BaseContext: model.BaseContext{
Username: username, Email: whois.LoginName,
Email: whois.LoginName, Name: whois.DisplayName,
Name: whois.DisplayName,
}, },
NodeName: whois.NodeName, NodeName: whois.NodeName,
} }
if m.config.Experimental.OAuthBridgeEnabled {
uctx.BaseContext.Username = strings.SplitN(whois.LoginName, "@", 2)[0]
} else {
uctx.BaseContext.Username = strings.Replace(whois.LoginName, "@", "_", 1)
}
return &uctx, nil return &uctx, nil
} }
@@ -264,7 +264,8 @@ func TestContextMiddleware(t *testing.T) {
Runtime: &runtime, Runtime: &runtime,
Ctx: ctx, Ctx: ctx,
}) })
authService := service.NewAuthService(service.AuthServiceInput{
authService, err := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -277,6 +278,8 @@ func TestContextMiddleware(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{ contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{
Log: log, Log: log,
RuntimeConfig: &runtime, RuntimeConfig: &runtime,
+20 -19
View File
@@ -49,7 +49,6 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
ACLs: ACLsConfig{ ACLs: ACLsConfig{
Policy: "allow", Policy: "allow",
}, },
LockdownEnabled: true,
}, },
UI: UIConfig{ UI: UIConfig{
Title: "Tinyauth", Title: "Tinyauth",
@@ -102,22 +101,23 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
} }
type Config struct { type Config struct {
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"` AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"`
ConfigFile string `description:"Path to config file." yaml:"-" gen:"include"` ConfigFile string `description:"Path to config file." yaml:"-" gen:"include"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"` LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"`
Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"` Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"` Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"` Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"` Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"` Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"` Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"` OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"` OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"`
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"` UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"` LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"` // enable the cli warning on experimental features
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"` Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"` Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -151,7 +151,6 @@ type AuthConfig struct {
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"` SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"`
LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled,omitempty"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"` ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"`
} }
@@ -239,7 +238,9 @@ type LogStreamConfig struct {
Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"` Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"`
} }
type ExperimentalConfig struct{} type ExperimentalConfig struct {
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
}
type TailscaleConfig struct { type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"` Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"`
+12 -11
View File
@@ -1,17 +1,18 @@
package model package model
type RuntimeConfig struct { type RuntimeConfig struct {
AppURL string AppURL string
UUID string UUID string
CookieDomain string CookieDomain string
SessionCookieName string SessionCookieName string
CSRFCookieName string CSRFCookieName string
RedirectCookieName string RedirectCookieName string
OAuthSessionCookieName string OAuthSessionCookieName string
LocalUsers []LocalUser LocalUsers []LocalUser
OAuthProviders map[string]OAuthServiceConfig OAuthProviders map[string]OAuthServiceConfig
OAuthWhitelist []string OAuthWhitelist []string
ConfiguredProviders []Provider ConfiguredProviders []Provider
TrustedProxiesConfigured bool
} }
type Provider struct { type Provider struct {
+10
View File
@@ -7,6 +7,16 @@ const (
UserLDAP UserLDAP
) )
func (t UserSearchType) String() string {
switch t {
case UserLocal:
return "local"
case UserLDAP:
return "ldap"
}
return "unknown"
}
type LDAPUser struct { type LDAPUser struct {
DN string DN string
Groups []string Groups []string
+51 -9
View File
@@ -1,6 +1,8 @@
package service package service
import ( import (
"errors"
"fmt"
"regexp" "regexp"
"strings" "strings"
@@ -43,6 +45,10 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
rule.Log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist") rule.Log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email) match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email)
if err != nil { if err != nil {
if errors.Is(err, utils.ErrFilterEmpty) {
rule.Log.App.Debug().Msg("OAuth whitelist is empty, abstaining")
return EffectAbstain
}
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist") rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist")
return EffectDeny return EffectDeny
} }
@@ -72,7 +78,7 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername()) match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
if err != nil { if err != nil {
if err == utils.ErrFilterEmpty { if errors.Is(err, utils.ErrFilterEmpty) {
return EffectAbstain return EffectAbstain
} }
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list") rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
@@ -175,33 +181,61 @@ type AuthEnabledRule struct {
Log *logger.Logger Log *logger.Logger
} }
func matchPathRule(paths, path string) (bool, error) {
paths = strings.TrimRight(strings.TrimSpace(paths), ",")
if paths == "/" {
return true, nil
}
if strings.HasPrefix(paths, "/") && strings.HasSuffix(paths, "/") {
regex, err := regexp.Compile(paths[1 : len(paths)-1])
if err != nil {
return false, fmt.Errorf("invalid path regex %q: %w", paths, err)
}
return regex.MatchString(path), nil
}
for _, configuredPath := range strings.Split(paths, ",") {
configuredPath = strings.TrimSpace(configuredPath)
if configuredPath == "" {
continue
}
if strings.HasPrefix(path, configuredPath) {
return true, nil
}
}
return false, nil
}
func (rule *AuthEnabledRule) Evaluate(ctx *ACLContext) Effect { func (rule *AuthEnabledRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil { if ctx.ACLs == nil {
return EffectDeny return EffectDeny
} }
if ctx.ACLs.Path.Block != "" { if ctx.ACLs.Path.Block != "" {
regex, err := regexp.Compile(ctx.ACLs.Path.Block) match, err := matchPathRule(ctx.ACLs.Path.Block, ctx.Path)
if err != nil { if err != nil {
rule.Log.App.Error().Err(err).Msg("Failed to compile block regex") rule.Log.App.Warn().Err(err).Msg("Invalid path block rule")
return EffectDeny return EffectDeny
} }
if !regex.MatchString(ctx.Path) { if !match {
return EffectAllow return EffectAllow
} }
} }
if ctx.ACLs.Path.Allow != "" { if ctx.ACLs.Path.Allow != "" {
regex, err := regexp.Compile(ctx.ACLs.Path.Allow) match, err := matchPathRule(ctx.ACLs.Path.Allow, ctx.Path)
if err != nil { if err != nil {
rule.Log.App.Error().Err(err).Msg("Failed to compile allow regex") rule.Log.App.Warn().Err(err).Msg("Invalid path allow rule")
return EffectDeny return EffectDeny
} }
if regex.MatchString(ctx.Path) { if match {
return EffectAllow return EffectAllow
} }
} }
@@ -215,6 +249,10 @@ type IPAllowedRule struct {
} }
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect { func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
if !ctx.TrustedProxiesConfigured {
return EffectAllow // We can't block the proxy
}
// merge global and per-app block/allow lists // merge global and per-app block/allow lists
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...) blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...) allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
@@ -263,6 +301,10 @@ type IPBypassedRule struct {
} }
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect { func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
if !ctx.TrustedProxiesConfigured {
return EffectDeny
}
// merge global and per-app bypass lists // merge global and per-app bypass lists
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...) bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
if ctx.ACLs != nil { if ctx.ACLs != nil {
+123 -52
View File
@@ -44,6 +44,21 @@ func TestUserAllowedRule(t *testing.T) {
}, },
expected: EffectAbstain, expected: EffectAbstain,
}, },
{
name: "abstains when filter is empty",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: ""},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAbstain,
},
{ {
name: "allows OAuth user when email matches whitelist", name: "allows OAuth user when email matches whitelist",
ctx: &ACLContext{ ctx: &ACLContext{
@@ -512,52 +527,82 @@ func TestAuthEnabledRule(t *testing.T) {
expected: EffectDeny, expected: EffectDeny,
}, },
{ {
name: "allows when path does not match block regex", name: "allows when path starts with allow path",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
Path: model.AppPath{Block: "^/admin"}, Path: model.AppPath{Allow: "/public"},
}, },
Path: "/public", Path: "/publicity",
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
{ {
name: "denies when path matches block regex and no allow regex", name: "allows when path matches a comma-separated allow path",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
Path: model.AppPath{Block: "^/admin"}, Path: model.AppPath{Allow: "/bar,/foo/bar,/hello"},
}, },
Path: "/admin/users", Path: "/foo/bar/baz",
}, },
expected: EffectDeny, expected: EffectAllow,
},
{
name: "allows when comma-separated allow paths have trailing whitespace and commas",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: " /bar,/foo/bar,/hello, , "},
},
Path: "/foo/bar/baz",
},
expected: EffectAllow,
}, },
{ {
name: "allows when path matches allow regex", name: "allows when path matches allow regex",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
Path: model.AppPath{Allow: "^/public"}, Path: model.AppPath{Allow: "/^/public-[0-9]+$/"},
}, },
Path: "/public/index", Path: "/public-42",
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
{ {
name: "denies when path does not match allow regex", name: "denies when comma-separated allow paths do not match",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
Path: model.AppPath{Allow: "^/public"}, Path: model.AppPath{Allow: "/bar,/foo/bar,/hello"},
}, },
Path: "/private", Path: "/private",
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
{ {
name: "allows when blocked path is also explicitly allowed", name: "denies when allow paths contain only whitespace and commas",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: " , , "},
},
Path: "/anything",
},
expected: EffectDeny,
},
{
name: "denies when path does not match allow path",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "/public"},
},
Path: "/private",
},
expected: EffectDeny,
},
{
name: "allows when blocked path is explicitly allowed",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
Path: model.AppPath{ Path: model.AppPath{
Block: "^/admin", Block: "/admin",
Allow: "^/admin/public", Allow: "/admin/public",
}, },
}, },
Path: "/admin/public/page", Path: "/admin/public/page",
@@ -565,20 +610,10 @@ func TestAuthEnabledRule(t *testing.T) {
expected: EffectAllow, expected: EffectAllow,
}, },
{ {
name: "denies when block regex fails to compile", name: "denies when root is blocked",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{ ACLs: &model.App{
Path: model.AppPath{Block: "[invalid"}, Path: model.AppPath{Block: "/"},
},
Path: "/anything",
},
expected: EffectDeny,
},
{
name: "denies when allow regex fails to compile",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "[invalid"},
}, },
Path: "/anything", Path: "/anything",
}, },
@@ -611,11 +646,20 @@ func TestIPAllowedRule(t *testing.T) {
ctx *ACLContext ctx *ACLContext
expected Effect expected Effect
}{ }{
{
name: "when trusted proxies are not configured, IP is allowed",
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
{ {
name: "allows when ACLs are nil and no global lists configured", name: "allows when ACLs are nil and no global lists configured",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -625,7 +669,8 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Block: []string{"10.0.0.1"}}, IP: model.AppIP{Block: []string{"10.0.0.1"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -637,8 +682,9 @@ func TestIPAllowedRule(t *testing.T) {
}, },
}, },
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -648,7 +694,8 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}}, IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
}, },
IP: net.ParseIP("192.168.1.10"), IP: net.ParseIP("192.168.1.10"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -660,8 +707,9 @@ func TestIPAllowedRule(t *testing.T) {
}, },
}, },
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("192.168.1.10"), IP: net.ParseIP("192.168.1.10"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -671,15 +719,17 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}}, IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
{ {
name: "allows when no block or allow lists are configured", name: "allows when no block or allow lists are configured",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -692,7 +742,8 @@ func TestIPAllowedRule(t *testing.T) {
Allow: []string{"10.0.0.1"}, Allow: []string{"10.0.0.1"},
}, },
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -705,7 +756,8 @@ func TestIPAllowedRule(t *testing.T) {
Allow: []string{"10.0.0.1"}, Allow: []string{"10.0.0.1"},
}, },
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -735,12 +787,23 @@ func TestIPBypassedRule(t *testing.T) {
ctx *ACLContext ctx *ACLContext
expected Effect expected Effect
}{ }{
{
name: "when trusted proxies are not configured, IP is not bypassed",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: false,
},
expected: EffectDeny,
},
{ {
name: "deny when ACLs are nil and no global bypass", name: "deny when ACLs are nil and no global bypass",
rule: defaultIPBR, rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -748,8 +811,9 @@ func TestIPBypassedRule(t *testing.T) {
name: "allows when ACLs are nil but IP matches global bypass", name: "allows when ACLs are nil but IP matches global bypass",
rule: globBypassIPBR, rule: globBypassIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -757,8 +821,9 @@ func TestIPBypassedRule(t *testing.T) {
name: "denies when ACLs are nil and IP does not match global bypass", name: "denies when ACLs are nil and IP does not match global bypass",
rule: globBypassIPBR, rule: globBypassIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("192.168.1.1"), IP: net.ParseIP("192.168.1.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -769,7 +834,8 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -780,7 +846,8 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}}, IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -791,7 +858,8 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -802,7 +870,8 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("192.168.1.1"), IP: net.ParseIP("192.168.1.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -810,8 +879,9 @@ func TestIPBypassedRule(t *testing.T) {
name: "denies when bypass list is empty", name: "denies when bypass list is empty",
rule: defaultIPBR, rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -822,7 +892,8 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}}, IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
+61 -20
View File
@@ -11,7 +11,7 @@ import (
) )
type LabelProvider interface { type LabelProvider interface {
GetLabels(appDomain string) (*model.App, error) Lookup(locator func(name string, app *model.App) bool) error
} }
type AccessControlsService struct { type AccessControlsService struct {
@@ -37,33 +37,74 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
} }
} }
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App { func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
var nameMatch *model.App
v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
// First try to find a matching app by domain, then fallback to matching by app name (subdomain) var domainMatch *model.App
for app, config := range service.config.Apps { var nameMatch *model.App
err := v.Validate(config.Config.Domain, domain) var nameMatchedApps []string
if err == nil {
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain") locatorFunc := func(name string, app *model.App) bool {
return &config if app.Config.Domain != "" {
err := v.Validate(app.Config.Domain, domain)
if err == nil {
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
domainMatch = app
return true
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
}
} }
if !errors.Is(err, validators.ErrHostnameMismatch) { if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
service.log.App.Debug().Str("name", app).Err(err).Msg("Domain validation failed") service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
} nameMatch = app
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(app+".")) { nameMatchedApps = append(nameMatchedApps, name)
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = &config
} }
return false
} }
return nameMatch err := lookup(locatorFunc)
if err != nil {
return nil, err
}
if domainMatch != nil {
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by domain")
return domainMatch, nil
}
if nameMatch == nil {
service.log.App.Debug().Str("domain", domain).Msg("No match found for domain, skipping")
return nil, nil
}
if len(nameMatchedApps) > 1 {
service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match")
}
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
return nameMatch, nil
}
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
for app, config := range service.config.Apps {
if ok := locator(app, &config); ok {
return nil
}
}
return nil
})
} }
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config // First check in the static config
app := service.lookupStaticACLs(domain) app, err := service.lookupStaticACLs(domain)
// Will never return an error here, but we need to check it
if err != nil {
return nil, err
}
if app != nil { if app != nil {
service.log.App.Debug().Msg("Using static ACLs for app") service.log.App.Debug().Msg("Using static ACLs for app")
@@ -72,9 +113,9 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A
// If we have a label provider configured, try to get ACLs from it // If we have a label provider configured, try to get ACLs from it
if service.labelProvider != nil { if service.labelProvider != nil {
return service.labelProvider.GetLabels(domain) return service.getACLs(domain, service.labelProvider.Lookup)
} }
// no labels // No labels
return nil, nil return nil, nil
} }
+129 -169
View File
@@ -4,224 +4,184 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
type mockLabelProvider struct { type mockProvider struct {
getLabelsFn func(appDomain string) (*model.App, error) acls map[string]model.App
calledWith string shouldError bool
callCount int
} }
func (m *mockLabelProvider) GetLabels(appDomain string) (*model.App, error) { func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider {
m.calledWith = appDomain return &mockProvider{acls: acls, shouldError: shouldError}
m.callCount++ }
if m.getLabelsFn != nil {
return m.getLabelsFn(appDomain) func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error {
if m.shouldError {
return errors.New("mock error")
} }
return nil, nil for name, app := range m.acls {
if ok := locator(name, &app); ok {
return nil
}
}
return nil
} }
func TestLookupStaticACLs(t *testing.T) { func TestAccessControlsService(t *testing.T) {
log := logger.NewLogger().WithTestConfig() log := logger.NewLogger().WithTestConfig()
log.Init() log.Init()
tests := []struct { tests := []struct {
name string name string
apps map[string]model.App domain string
domain string acls map[string]model.App
expectNil bool want *model.App
expectedDomain string
}{ }{
{ {
name: "returns nil when no apps are configured", name: "returns ACLs for domain",
apps: nil, domain: "example.com",
domain: "foo.example.com", acls: map[string]model.App{
expectNil: true, "foo": {Config: model.AppConfig{Domain: "example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
}, },
{ {
name: "returns nil when no app matches", name: "returns ACLs for domain with port",
apps: map[string]model.App{ domain: "example.com:8080",
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}}, acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "example.com"}},
}, },
domain: "bar.example.com", want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
expectNil: true,
}, },
{ {
name: "matches by exact domain", name: "returns ACLs for domain with trailing dot",
apps: map[string]model.App{ domain: "example.com.",
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}}, acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "example.com"}},
}, },
domain: "foo.example.com", want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
expectedDomain: "foo.example.com",
}, },
{ {
name: "matches by app name when domain does not match any app", name: "returns ACLs for non-ascii domain",
apps: map[string]model.App{ domain: "bücher.example.com",
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}}, acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "bücher.example.com"}},
}, },
domain: "foo.example.com", want: &model.App{Config: model.AppConfig{Domain: "bücher.example.com"}},
expectedDomain: "configured.example.com",
}, },
{ {
name: "matches by app name for nested subdomains", name: "returns ACLs for punycode domain and non-ascii config",
apps: map[string]model.App{ domain: "bücher.example.com",
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}}, acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
}, },
domain: "foo.sub.example.com", want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
expectedDomain: "configured.example.com",
}, },
{ {
name: "selects the app matching by domain among multiple apps", name: "returns ACLs with case-insensitive matching",
apps: map[string]model.App{ domain: "Example.com",
"unrelated": {Config: model.AppConfig{Domain: "other.example.com"}}, acls: map[string]model.App{
"target": {Config: model.AppConfig{Domain: "foo.example.com"}}, "foo": {Config: model.AppConfig{Domain: "example.com"}},
}, },
domain: "foo.example.com", want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
expectedDomain: "foo.example.com", },
{
name: "falls back to name matching when domain fails",
domain: "app.example.com",
acls: map[string]model.App{
"app": {Path: model.AppPath{Allow: "/foo"}},
},
want: &model.App{Path: model.AppPath{Allow: "/foo"}},
},
{
name: "name matching is case-insensitive",
domain: "aPp.example.com",
acls: map[string]model.App{
"APP": {Path: model.AppPath{Allow: "/foo"}},
},
want: &model.App{Path: model.AppPath{Allow: "/foo"}},
},
{
name: "returns nil when no ACLs are found",
domain: "example.com",
acls: map[string]model.App{},
want: nil,
}, },
} }
for _, tt := range tests { // run once for a mock provider
t.Run(tt.name, func(t *testing.T) { for _, test := range tests {
svc := NewAccessControlsService(AccessControlServiceInput{ t.Run(test.name+"(getACLs)", func(t *testing.T) {
mock := newMockProvider(test.acls, false)
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log, Log: log,
Config: &model.Config{Apps: tt.apps}, Config: &model.Config{},
LabelProvider: nil, LabelProvider: mock,
}) })
got := svc.lookupStaticACLs(tt.domain) app, err := acls.getACLs(test.domain, mock.Lookup)
if tt.expectNil { require.NoError(t, err)
assert.Nil(t, got) require.Equal(t, test.want, app)
return
}
require.NotNil(t, got)
assert.Equal(t, tt.expectedDomain, got.Config.Domain)
}) })
} }
}
func TestGetAccessControls(t *testing.T) { // run again for static acls
log := logger.NewLogger().WithTestConfig() for _, test := range tests {
log.Init() t.Run(test.name+"(staticACLs)", func(t *testing.T) {
acls := NewAccessControlsService(AccessControlServiceInput{
t.Run("returns static ACLs when domain matches", func(t *testing.T) { Log: log,
config := model.Config{ Config: &model.Config{
Apps: map[string]model.App{ Apps: test.acls,
"foo": {
Config: model.AppConfig{Domain: "foo.example.com"},
Users: model.AppUsers{Allow: "alice"},
}, },
}, })
} app, err := acls.lookupStaticACLs(test.domain)
svc := NewAccessControlsService(AccessControlServiceInput{ require.NoError(t, err)
Log: log, require.Equal(t, test.want, app)
Config: &config,
LabelProvider: nil,
}) })
}
got, err := svc.GetAccessControls("foo.example.com") // get acls should return an error when the provider fails
mock := newMockProvider(map[string]model.App{}, true)
require.NoError(t, err) acls := NewAccessControlsService(AccessControlServiceInput{
require.NotNil(t, got) Log: log,
assert.Equal(t, "foo.example.com", got.Config.Domain) Config: &model.Config{},
assert.Equal(t, "alice", got.Users.Allow)
}) })
_, err := acls.getACLs("example.com", mock.Lookup)
require.Error(t, err)
t.Run("returns nil when no static match and no label provider", func(t *testing.T) { // get access controls should get acls from
svc := NewAccessControlsService(AccessControlServiceInput{ // static when static acls are configured
Log: log, acls = NewAccessControlsService(AccessControlServiceInput{
Config: &model.Config{}, Log: log,
LabelProvider: nil, Config: &model.Config{
})
got, err := svc.GetAccessControls("unknown.example.com")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("returns nil when label provider pointer wraps a nil interface", func(t *testing.T) {
var provider LabelProvider
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
LabelProvider: provider, // nil provider
})
got, err := svc.GetAccessControls("unknown.example.com")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("falls back to label provider when no static match", func(t *testing.T) {
expected := &model.App{
Config: model.AppConfig{Domain: "dynamic.example.com"},
Users: model.AppUsers{Allow: "bob"},
}
mock := &mockLabelProvider{
getLabelsFn: func(appDomain string) (*model.App, error) {
return expected, nil
},
}
var provider LabelProvider = mock
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
LabelProvider: provider,
})
got, err := svc.GetAccessControls("dynamic.example.com")
require.NoError(t, err)
assert.Same(t, expected, got)
assert.Equal(t, "dynamic.example.com", mock.calledWith)
assert.Equal(t, 1, mock.callCount)
})
t.Run("does not call label provider when static match found", func(t *testing.T) {
mock := &mockLabelProvider{}
var provider LabelProvider = mock
config := model.Config{
Apps: map[string]model.App{ Apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}}, "foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
}, },
} },
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &config,
LabelProvider: provider,
})
got, err := svc.GetAccessControls("foo.example.com")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
assert.Equal(t, 0, mock.callCount)
}) })
app, err := acls.GetAccessControls("foo.example.com")
require.NoError(t, err)
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
t.Run("propagates label provider errors", func(t *testing.T) { // should return nil for no apps
providerErr := errors.New("provider boom") app, err = acls.GetAccessControls("bar.example.com")
mock := &mockLabelProvider{ require.NoError(t, err)
getLabelsFn: func(appDomain string) (*model.App, error) { require.Nil(t, app)
return nil, providerErr
},
}
var provider LabelProvider = mock
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
LabelProvider: provider,
})
got, err := svc.GetAccessControls("dynamic.example.com") // Should use label provider if available
mock = newMockProvider(map[string]model.App{
assert.Nil(t, got) "bar": {
assert.ErrorIs(t, err, providerErr) Config: model.AppConfig{Domain: "bar.example.com"},
assert.Equal(t, 1, mock.callCount) },
}, false)
acls = NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
LabelProvider: mock,
}) })
app, err = acls.GetAccessControls("bar.example.com")
require.NoError(t, err)
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
} }
+41 -95
View File
@@ -8,7 +8,6 @@ import (
"math/big" "math/big"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/steveiliop56/ding" "github.com/steveiliop56/ding"
@@ -16,6 +15,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig" "go.uber.org/dig"
"github.com/google/uuid" "github.com/google/uuid"
@@ -69,21 +69,13 @@ type AuthService struct {
tailscale *TailscaleService tailscale *TailscaleService
policyEngine *PolicyEngine policyEngine *PolicyEngine
lockdown struct { dummyHash string
active bool
until time.Time
ctx context.Context
cancelFunc context.CancelFunc
mu sync.RWMutex
}
caches struct { caches struct {
login *CacheStore[LoginAttempt] login *cache.CacheStore[LoginAttempt]
oauth *CacheStore[OAuthPendingSession] oauth *cache.CacheStore[OAuthPendingSession]
ldap *CacheStore[[]string] ldap *cache.CacheStore[[]string]
} }
maxLoginLimits int
} }
type AuthServiceInput struct { type AuthServiceInput struct {
@@ -101,7 +93,7 @@ type AuthServiceInput struct {
PolicyEngine *PolicyEngine PolicyEngine *PolicyEngine
} }
func NewAuthService(i AuthServiceInput) *AuthService { func NewAuthService(i AuthServiceInput) (*AuthService, error) {
service := &AuthService{ service := &AuthService{
log: i.Log, log: i.Log,
runtime: i.Runtime, runtime: i.Runtime,
@@ -114,19 +106,19 @@ func NewAuthService(i AuthServiceInput) *AuthService {
policyEngine: i.PolicyEngine, policyEngine: i.PolicyEngine,
} }
// get the max login limits based on the number of users and the configured max retries // dummy hash
service.maxLoginLimits = service.calculateLockdownLimit() dummyHash, err := bcrypt.GenerateFromPassword([]byte(utils.GenerateString(8)), bcrypt.DefaultCost)
loginCacheSize := 0 if err != nil {
return nil, fmt.Errorf("failed to generate dummy hash: %w", err)
if !service.config.Auth.LockdownEnabled {
loginCacheSize = service.maxLoginLimits
} }
service.dummyHash = string(dummyHash)
// caches setup // caches setup
oauthCache := NewCacheStore[OAuthPendingSession](256) oauthCache := cache.NewCacheStore[OAuthPendingSession](256)
loginCache := NewCacheStore[LoginAttempt](loginCacheSize) loginCache := cache.NewCacheStore[LoginAttempt](service.calculateLockdownLimit())
ldapCache := NewCacheStore[[]string](1024) ldapCache := cache.NewCacheStore[[]string](1024)
service.caches.oauth = oauthCache service.caches.oauth = oauthCache
service.caches.login = loginCache service.caches.login = loginCache
@@ -148,7 +140,28 @@ func NewAuthService(i AuthServiceInput) *AuthService {
} }
}, ding.RingMinor) }, ding.RingMinor)
return service i.Ding.Go(func(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
service.log.App.Debug().Msg("Updating login cache limits")
service.caches.login.SetMaxSize(service.calculateLockdownLimit())
service.log.App.Debug().Msg("Login cache limits updated")
case <-ctx.Done():
return
}
}
}, ding.RingMinor)
return service, nil
}
func (auth *AuthService) DummyPasswordCheck(password string) {
bcrypt.CompareHashAndPassword([]byte(auth.dummyHash), []byte(password))
} }
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) { func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
@@ -245,10 +258,6 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
} }
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
if locked, remaining := auth.IsInLockdown(); locked {
return true, remaining
}
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 { if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
return false, 0 return false, 0
} }
@@ -271,15 +280,7 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
return return
} }
if !success && auth.config.Auth.LockdownEnabled && auth.caches.login.Size() >= auth.maxLoginLimits { auth.caches.login.WithLock(func(actions cache.CacheStoreActions[LoginAttempt]) {
if locked, _ := auth.IsInLockdown(); locked {
return
}
go auth.lockdownMode()
return
}
auth.caches.login.WithLock(func(actions CacheStoreActions[LoginAttempt]) {
entry, ok := actions.Get(identifier) entry, ok := actions.Get(identifier)
if !ok { if !ok {
@@ -344,7 +345,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user") return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
} }
uuid, err := uuid.NewRandom() u, err := uuid.NewRandom()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate session uuid: %w", err) return nil, fmt.Errorf("failed to generate session uuid: %w", err)
@@ -361,7 +362,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second) expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
session := repository.CreateSessionParams{ session := repository.CreateSessionParams{
UUID: uuid.String(), UUID: u.String(),
Username: data.Username, Username: data.Username,
Email: data.Email, Email: data.Email,
Name: data.Name, Name: data.Name,
@@ -616,62 +617,7 @@ func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPending
return &session, nil return &session, nil
} }
func (auth *AuthService) lockdownMode() { // ClearLoginAttempts is a testing function, not useful for anything else
auth.lockdown.mu.Lock()
if auth.lockdown.active {
auth.lockdown.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(auth.ctx)
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
auth.lockdown.active = true
auth.lockdown.ctx = ctx
auth.lockdown.cancelFunc = cancel
d := time.Duration(auth.config.Auth.LoginTimeout) * time.Second
auth.lockdown.until = time.Now().Add(d)
timer := time.NewTimer(d)
auth.lockdown.mu.Unlock()
defer cancel()
defer timer.Stop()
select {
case <-timer.C:
// Timer expired, end lockdown
case <-ctx.Done():
// Context cancelled, end lockdown
}
auth.lockdown.mu.Lock()
auth.log.App.Info().Msg("Exiting lockdown mode")
auth.caches.login.Clear()
auth.lockdown.active = false
auth.lockdown.until = time.Time{}
auth.lockdown.ctx = nil
auth.lockdown.cancelFunc = nil
auth.lockdown.mu.Unlock()
}
func (auth *AuthService) IsInLockdown() (bool, int) {
auth.lockdown.mu.RLock()
defer auth.lockdown.mu.RUnlock()
if auth.lockdown.active {
remaining := int(time.Until(auth.lockdown.until).Seconds())
return true, remaining
}
return false, 0
}
// mostly a testing function, not useful for anything else
func (auth *AuthService) ClearLoginAttempts() { func (auth *AuthService) ClearLoginAttempts() {
auth.caches.login.Clear() auth.caches.login.Clear()
} }
+12 -24
View File
@@ -2,7 +2,7 @@ package service
import ( import (
"context" "context"
"strings" "fmt"
"github.com/steveiliop56/ding" "github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
@@ -31,7 +31,6 @@ type DockerServiceInput struct {
} }
func NewDockerService(i DockerServiceInput) (*DockerService, error) { func NewDockerService(i DockerServiceInput) (*DockerService, error) {
client, err := client.NewClientWithOpts(client.FromEnv) client, err := client.NewClientWithOpts(client.FromEnv)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -68,49 +67,38 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
return docker.client.ContainerInspect(docker.context, containerId) return docker.client.ContainerInspect(docker.context, containerId)
} }
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) { func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error {
if !docker.isConnected { if !docker.isConnected {
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels") docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
return nil, nil return nil
} }
containers, err := docker.getContainers() containers, err := docker.getContainers()
if err != nil { if err != nil {
return nil, err return fmt.Errorf("failed to get containers: %w", err)
} }
for _, ctr := range containers { for _, ctr := range containers {
inspect, err := docker.inspectContainer(ctr.ID) inspect, err := docker.inspectContainer(ctr.ID)
if err != nil { if err != nil {
return nil, err docker.log.App.Error().Err(err).Msgf("Failed to inspect container %s", ctr.ID)
continue
} }
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps") labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
if err != nil { if err != nil {
return nil, err docker.log.App.Warn().Err(err).Msgf("Failed to decode labels for container %s", ctr.ID)
continue
} }
var nameMatch *model.App for app, config := range labels.Apps {
if ok := locator(app, &config); ok {
// First try to find a matching app by domain, then fallback to matching by app name (subdomain) return nil
for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == appDomain {
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
return &appLabels, nil
} }
if strings.SplitN(appDomain, ".", 2)[0] == appName {
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
nameMatch = &appLabels
}
}
if nameMatch != nil {
return nameMatch, nil
} }
} }
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain") return nil
return nil, nil
} }
func (docker *DockerService) watchAndClose(ctx context.Context) { func (docker *DockerService) watchAndClose(ctx context.Context) {
+85 -118
View File
@@ -12,6 +12,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders" "github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig" "go.uber.org/dig"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -22,31 +23,23 @@ import (
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
) )
type ingressEntry struct {
name string
app model.App
}
type ingressKey struct { type ingressKey struct {
namespace string namespace string
name string name string
} }
type ingressAppKey struct {
ingressKey
appName string
}
type ingressApp struct {
domain string
appName string
app model.App
}
type KubernetesService struct { type KubernetesService struct {
log *logger.Logger log *logger.Logger
client dynamic.Interface client dynamic.Interface
started bool connected bool
mu sync.RWMutex mu sync.RWMutex
ingressApps map[ingressKey][]ingressApp ingressEntries map[ingressKey][]ingressEntry
domainIndex map[string]ingressAppKey
appNameIndex map[string]ingressAppKey
} }
type KubernetesServiceInput struct { type KubernetesServiceInput struct {
@@ -86,90 +79,45 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher") i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
service := &KubernetesService{ service := &KubernetesService{
log: i.Log, log: i.Log,
client: client, client: client,
ingressApps: make(map[ingressKey][]ingressApp), ingressEntries: make(map[ingressKey][]ingressEntry),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
} }
i.Ding.Go(func(ctx context.Context) { i.Ding.Go(func(ctx context.Context) {
service.watchGVR(gvr, ctx) service.watchGVR(gvr, ctx)
}, ding.RingMajor) }, ding.RingMajor)
service.started = true service.connected = true
i.Log.App.Debug().Msg("Kubernetes label provider started successfully") i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
return service, nil return service, nil
} }
func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) { func (k *KubernetesService) addIngressEntries(key ingressKey, entries []ingressEntry) {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
k.ingressEntries[key] = entries
key := ingressKey{namespace, name}
// Remove existing entries for this ingress
if existing, ok := k.ingressApps[key]; ok {
for _, app := range existing {
delete(k.domainIndex, app.domain)
delete(k.appNameIndex, app.appName)
}
}
// Add new entries
k.ingressApps[key] = apps
for _, app := range apps {
appKey := ingressAppKey{key, app.appName}
k.domainIndex[app.domain] = appKey
k.appNameIndex[app.appName] = appKey
}
} }
func (k *KubernetesService) removeIngress(namespace, name string) { func (k *KubernetesService) removeIngress(key ingressKey) {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
delete(k.ingressEntries, key)
key := ingressKey{namespace, name}
if apps, ok := k.ingressApps[key]; ok {
for _, app := range apps {
delete(k.domainIndex, app.domain)
delete(k.appNameIndex, app.appName)
}
delete(k.ingressApps, key)
}
} }
func (k *KubernetesService) getByDomain(domain string) *model.App { func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) {
k.mu.RLock() k.mu.RLock()
defer k.mu.RUnlock() defer k.mu.RUnlock()
if appKey, ok := k.domainIndex[domain]; ok { // O(n^2) is not great but the number of ingress entries is expected to be small
if apps, ok := k.ingressApps[appKey.ingressKey]; ok { for _, entries := range k.ingressEntries {
for i := range apps { for _, entry := range entries {
app := &apps[i] if ok := locator(entry.name, &entry.app); ok {
if app.domain == domain && app.appName == appKey.appName { return
return &app.app
}
} }
} }
} }
return nil
}
func (k *KubernetesService) getByAppName(appName string) *model.App {
k.mu.RLock()
defer k.mu.RUnlock()
if appKey, ok := k.appNameIndex[appName]; ok {
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
for i := range apps {
app := &apps[i]
if app.appName == appName {
return &app.app
}
}
}
}
return nil
} }
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) { func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
@@ -219,7 +167,8 @@ func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]str
} }
paths, err := k.extractPaths(rule) paths, err := k.extractPaths(rule)
if err != nil { if err != nil {
// This is purely to warn users, it doesn't affect our ability to extract hosts so we won't fail the whole operation // This is purely to warn users
// It doesn't affect our ability to extract hosts, so we won't fail the whole operation
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule") k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
continue continue
} }
@@ -235,44 +184,71 @@ func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]str
} }
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
namespace := item.GetNamespace() key := ingressKey{
name := item.GetName() namespace: item.GetNamespace(),
name: item.GetName(),
}
annotations := item.GetAnnotations() annotations := item.GetAnnotations()
if annotations == nil { if annotations == nil {
k.removeIngress(namespace, name) k.removeIngress(key)
return return
} }
hosts, err := k.extractHosts(item) hosts, err := k.extractHosts(item)
if err != nil { if err != nil {
k.removeIngress(namespace, name) k.removeIngress(key)
return return
} }
if len(hosts) == 0 {
k.log.App.Warn().Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in ingress, skipping")
k.removeIngress(key)
return
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps") labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil { if err != nil {
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping") k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode ingress labels, skipping")
k.removeIngress(namespace, name) k.removeIngress(key)
return return
} }
var apps []ingressApp
for appName, appLabels := range labels.Apps { var entries []ingressEntry
if appLabels.Config.Domain == "" {
continue v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
for name, config := range labels.Apps {
if config.Config.Domain != "" {
hostname, err := v.SafeHostname(config.Config.Domain)
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name")
} else if slices.Contains(hosts, hostname) {
entries = append(entries, ingressEntry{
name: name,
app: config,
})
continue
}
} }
if len(hosts) > 0 && !slices.Contains(hosts, appLabels.Config.Domain) {
k.log.App.Warn().Str("namespace", namespace).Str("name", name).Str("appName", appName).Str("domain", appLabels.Config.Domain).Msg("App domain does not match any hosts defined in ingress rules, skipping") for _, host := range hosts {
continue if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) {
entries = append(entries, ingressEntry{
name: name,
app: config,
})
break
}
} }
apps = append(apps, ingressApp{
domain: appLabels.Config.Domain,
appName: appName,
app: appLabels,
})
} }
if len(apps) == 0 {
k.removeIngress(namespace, name) if len(entries) == 0 {
} else { k.removeIngress(key)
k.addIngressApps(namespace, name, apps) return
} }
k.addIngressEntries(key, entries)
} }
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error { func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
@@ -315,7 +291,10 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
case watch.Added, watch.Modified: case watch.Added, watch.Modified:
k.updateFromItem(item) k.updateFromItem(item)
case watch.Deleted: case watch.Deleted:
k.removeIngress(item.GetNamespace(), item.GetName()) k.removeIngress(ingressKey{
namespace: item.GetNamespace(),
name: item.GetName(),
})
} }
case <-resyncTicker.C: case <-resyncTicker.C:
if err := k.resyncGVR(gvr, ctx); err != nil { if err := k.resyncGVR(gvr, ctx); err != nil {
@@ -362,25 +341,13 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx contex
} }
} }
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) { func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error {
if !k.started { if !k.connected {
k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping") k.log.App.Debug().Msg("Kubernetes label provider not started, skipping")
return nil, nil return nil
} }
// First check cache k.getEntry(locator)
app := k.getByDomain(appDomain)
if app != nil {
k.log.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
return app, nil
}
appName := strings.SplitN(appDomain, ".", 2)[0]
app = k.getByAppName(appName)
if app != nil {
k.log.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
return app, nil
}
k.log.App.Debug().Str("domain", appDomain).Msg("No labels found for domain") return nil
return nil, nil
} }
+547 -59
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"strings"
"testing" "testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -25,47 +26,66 @@ func TestKubernetesService(t *testing.T) {
description: "Cache by domain returns app and misses unknown domain", description: "Cache by domain returns app and misses unknown domain",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{ svc.addIngressEntries(ingressKey{
{domain: "foo.example.com", appName: "foo", app: app}, namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
got := svc.getByDomain("foo.example.com") var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got) require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain) assert.Equal(t, "foo.example.com", got.Config.Domain)
got = svc.getByDomain("notfound.example.com")
assert.Nil(t, got)
},
},
{
description: "Cache by app name returns app and misses unknown name",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "bar.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "bar.example.com", appName: "bar", app: app},
})
got := svc.getByAppName("bar")
require.NotNil(t, got)
assert.Equal(t, "bar.example.com", got.Config.Domain)
got = svc.getByAppName("notfound")
assert.Nil(t, got)
}, },
}, },
{ {
description: "RemoveIngress clears domain and app name entries", description: "RemoveIngress clears domain and app name entries",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "baz.example.com"}} app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{ svc.addIngressEntries(ingressKey{
{domain: "baz.example.com", appName: "baz", app: app}, namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
svc.removeIngress("default", "my-ingress") var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
got := svc.getByDomain("baz.example.com") got = nil
assert.Nil(t, got) svc.removeIngress(ingressKey{
got = svc.getByAppName("baz") namespace: "default",
name: "my-ingress",
})
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got) assert.Nil(t, got)
}, },
}, },
@@ -73,67 +93,130 @@ func TestKubernetesService(t *testing.T) {
description: "AddIngressApps replaces stale entries for the same ingress", description: "AddIngressApps replaces stale entries for the same ingress",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}} old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{ svc.addIngressEntries(ingressKey{
{domain: "old.example.com", appName: "old", app: old}, namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: old,
name: "foo",
},
}) })
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}} updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{ svc.addIngressEntries(ingressKey{
{domain: "new.example.com", appName: "new", app: updated}, namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: updated,
name: "foo",
},
}) })
got := svc.getByDomain("old.example.com") var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "old.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got) assert.Nil(t, got)
got = svc.getByDomain("new.example.com") svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "new.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got) require.NotNil(t, got)
assert.Equal(t, "new.example.com", got.Config.Domain) assert.Equal(t, "new.example.com", got.Config.Domain)
}, },
}, },
{ {
description: "GetLabels returns app from cache when started", description: "GetLabels returns app from cache when connected",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
svc.started = true svc.connected = true
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}} app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
svc.addIngressApps("default", "ing", []ingressApp{ svc.addIngressEntries(ingressKey{
{domain: "hit.example.com", appName: "hit", app: app}, namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
got, err := svc.GetLabels("hit.example.com") var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
if app.Config.Domain == "hit.example.com" {
got = app
return true
}
return false
})
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "hit.example.com", got.Config.Domain) assert.Equal(t, "hit.example.com", got.Config.Domain)
}, },
}, },
{ {
description: "GetLabels returns empty app on cache miss when started", description: "GetLabels returns empty app on cache miss when started",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
svc.started = true svc.connected = true
got, err := svc.GetLabels("notfound.example.com") var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
if app.Config.Domain == "notfound.example.com" {
got = app
return true
}
return false
})
require.NoError(t, err) require.NoError(t, err)
assert.Nil(t, got) require.Nil(t, got)
}, },
}, },
{ {
description: "GetLabels resolves app by app name", description: "GetLabels resolves app by app name",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
svc.started = true svc.connected = true
app := model.App{Config: model.AppConfig{Domain: "myapp.internal.example.com"}} app := model.App{Path: model.AppPath{Allow: "/foo"}}
svc.addIngressApps("default", "ing", []ingressApp{ svc.addIngressEntries(ingressKey{
{domain: "myapp.internal.example.com", appName: "myapp", app: app}, namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
got, err := svc.GetLabels("myapp.internal.example.com") var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
if strings.HasPrefix("foo.internal.example.com", "foo.") {
got = app
return true
}
return false
})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "myapp.internal.example.com", got.Config.Domain) require.NotNil(t, got)
assert.Equal(t, "/foo", got.Path.Allow)
}, },
}, },
{ {
description: "GetLabels returns empty app when service not yet started", description: "GetLabels returns empty app when service not yet started",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
got, err := svc.GetLabels("anything.example.com") var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
return false
})
require.NoError(t, err) require.NoError(t, err)
assert.Nil(t, got) assert.Nil(t, got)
}, },
@@ -148,30 +231,437 @@ func TestKubernetesService(t *testing.T) {
"tinyauth.apps.myapp.config.domain": "myapp.example.com", "tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.allow": "alice", "tinyauth.apps.myapp.users.allow": "alice",
}) })
item.Object["spec"] = map[string]any{
"rules": []any{
map[string]any{
"host": "myapp.example.com",
},
},
}
svc.updateFromItem(&item) svc.updateFromItem(&item)
got := svc.getByDomain("myapp.example.com") var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got) require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain) assert.Equal(t, "myapp.example.com", got.Config.Domain)
assert.Equal(t, "alice", got.Users.Allow) assert.Equal(t, "alice", got.Users.Allow)
}, },
}, },
{
description: "Update from item skips annotations with no hosts",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem fails when label parsing fails",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.break": "i-dont-exist",
})
item.Object["spec"] = map[string]any{
"rules": []any{
map[string]any{
"host": "myapp.example.com",
},
},
}
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
require.Nil(t, got)
},
},
{ {
description: "UpdateFromItem with no annotations removes existing cache entries", description: "UpdateFromItem with no annotations removes existing cache entries",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}} app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
svc.addIngressApps("default", "test-ingress", []ingressApp{ svc.addIngressEntries(ingressKey{
{domain: "todelete.example.com", appName: "todelete", app: app}, namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
item := unstructured.Unstructured{} item := unstructured.Unstructured{}
item.SetNamespace("default") item.SetNamespace("default")
item.SetName("test-ingress") item.SetName("my-ingress")
svc.updateFromItem(&item) svc.updateFromItem(&item)
got := svc.getByDomain("todelete.example.com") var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "todelete.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "ExtractPaths returns all non empty paths from a rule",
run: func(t *testing.T, svc *KubernetesService) {
rule := map[string]any{
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/"},
map[string]any{"path": "/api"},
map[string]any{"path": ""},
map[string]any{"pathType": "Prefix"},
"not-a-map",
},
},
}
paths, err := svc.extractPaths(rule)
require.NoError(t, err)
assert.Equal(t, []string{"/", "/api"}, paths)
},
},
{
description: "ExtractPaths returns nothing when http or paths are missing",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{})
require.NoError(t, err)
assert.Empty(t, paths)
paths, err = svc.extractPaths(map[string]any{
"http": map[string]any{},
})
require.NoError(t, err)
assert.Empty(t, paths)
},
},
{
description: "ExtractPaths errors when http is not a map",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{
"http": "invalid",
})
require.Error(t, err)
assert.Nil(t, paths)
},
},
{
description: "ExtractPaths errors when paths is not a slice",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{
"http": map[string]any{
"paths": "invalid",
},
})
require.Error(t, err)
assert.Nil(t, paths)
},
},
{
description: "ExtractHosts returns hosts from all rules",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/"},
},
},
},
map[string]any{
"host": "bar.example.com",
},
map[string]any{
"host": "",
},
"not-a-map",
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts)
},
},
{
description: "ExtractHosts still returns hosts when a rule has no catch all path",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/api"},
},
},
},
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com"}, hosts)
},
},
{
description: "ExtractHosts still returns hosts when path extraction fails",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": "invalid",
},
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com"}, hosts)
},
},
{
description: "ExtractHosts returns nothing when spec.rules is missing",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Empty(t, hosts)
},
},
{
description: "ExtractHosts errors when spec.rules is not a slice",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.Error(t, err)
assert.Nil(t, hosts)
},
},
{
description: "UpdateFromItem registers app when its domain matches an ingress host",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "myapp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain)
},
},
{
description: "UpdateFromItem registers app when its name matches an ingress host prefix",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.users.allow": "alice",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "MyApp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "alice", got.Users.Allow)
},
},
{
description: "UpdateFromItem skips apps that match neither host nor name",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "other.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem falls back to app name when the domain is invalid",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "not a domain",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "myapp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
},
},
{
description: "UpdateFromItem removes entries when host extraction fails",
run: func(t *testing.T, svc *KubernetesService) {
key := ingressKey{
namespace: "default",
name: "test-ingress",
}
svc.addIngressEntries(key, []ingressEntry{
{
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace(key.namespace)
item.SetName(key.name)
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem removes entries when annotations are not decodable",
run: func(t *testing.T, svc *KubernetesService) {
key := ingressKey{
namespace: "default",
name: "test-ingress",
}
svc.addIngressEntries(key, []ingressEntry{
{
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace(key.namespace)
item.SetName(key.name)
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.oauthWhitelist": "[",
})
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got) assert.Nil(t, got)
}, },
}, },
@@ -180,10 +670,8 @@ func TestKubernetesService(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
svc := &KubernetesService{ svc := &KubernetesService{
ingressApps: make(map[ingressKey][]ingressApp), ingressEntries: make(map[ingressKey][]ingressEntry),
domainIndex: make(map[string]ingressAppKey), log: log,
appNameIndex: make(map[string]ingressAppKey),
log: log,
} }
test.run(t, svc) test.run(t, svc)
}) })
+9 -8
View File
@@ -27,6 +27,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig" "go.uber.org/dig"
) )
@@ -158,9 +159,9 @@ type OIDCService struct {
issuer string issuer string
caches struct { caches struct {
code *CacheStore[AuthorizeCodeEntry] code *cache.CacheStore[AuthorizeCodeEntry]
usedCode *CacheStore[UsedCodeEntry] usedCode *cache.CacheStore[UsedCodeEntry]
authorize *CacheStore[AuthorizeRequest] authorize *cache.CacheStore[AuthorizeRequest]
} }
} }
@@ -339,11 +340,11 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) {
i.Ding.Go(service.cleanupRoutine, ding.RingMinor) i.Ding.Go(service.cleanupRoutine, ding.RingMinor)
// Create caches // Create caches
codeCash := NewCacheStore[AuthorizeCodeEntry](256) codeCache := cache.NewCacheStore[AuthorizeCodeEntry](256)
usedCode := NewCacheStore[UsedCodeEntry](256) usedCode := cache.NewCacheStore[UsedCodeEntry](256)
authorize := NewCacheStore[AuthorizeRequest](256) authorize := cache.NewCacheStore[AuthorizeRequest](256)
service.caches.code = codeCash service.caches.code = codeCache
service.caches.usedCode = usedCode service.caches.usedCode = usedCode
service.caches.authorize = authorize service.caches.authorize = authorize
@@ -503,7 +504,7 @@ func (service *OIDCService) GetCodeEntry(codeHash string, clientId string) (*Aut
var entry AuthorizeCodeEntry var entry AuthorizeCodeEntry
var ok bool var ok bool
service.caches.code.WithLock(func(actions CacheStoreActions[AuthorizeCodeEntry]) { service.caches.code.WithLock(func(actions cache.CacheStoreActions[AuthorizeCodeEntry]) {
entry, ok = actions.Get(codeHash) entry, ok = actions.Get(codeHash)
if !ok { if !ok {
+5 -4
View File
@@ -29,10 +29,11 @@ type Rule interface {
} }
type ACLContext struct { type ACLContext struct {
ACLs *model.App ACLs *model.App
UserContext *model.UserContext UserContext *model.UserContext
IP net.IP IP net.IP
Path string Path string
TrustedProxiesConfigured bool
} }
type PolicyEngine struct { type PolicyEngine struct {
+5 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig" "go.uber.org/dig"
) )
@@ -59,8 +60,8 @@ type TailscaleService struct {
apiToken string apiToken string
caches struct { caches struct {
devices *CacheStore[tailscaleAPIDevices] devices *cache.CacheStore[tailscaleAPIDevices]
users *CacheStore[tailscaleAPIUsers] users *cache.CacheStore[tailscaleAPIUsers]
} }
urls struct { urls struct {
@@ -100,8 +101,8 @@ func NewTailscaleService(i TailscaleServiceInput) (*TailscaleService, error) {
apiToken: apiToken, apiToken: apiToken,
} }
devicesCache := NewCacheStore[tailscaleAPIDevices](0) devicesCache := cache.NewCacheStore[tailscaleAPIDevices](0)
usersCache := NewCacheStore[tailscaleAPIUsers](0) usersCache := cache.NewCacheStore[tailscaleAPIUsers](0)
s.caches.devices = devicesCache s.caches.devices = devicesCache
s.caches.users = usersCache s.caches.users = usersCache
+12 -3
View File
@@ -61,6 +61,14 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
Allow: "/allowed", Allow: "/allowed",
}, },
}, },
"app_path_block": {
Config: model.AppConfig{
Domain: "path-block.example.com",
},
Path: model.AppPath{
Block: "/blocked",
},
},
"app_user_allow": { "app_user_allow": {
Config: model.AppConfig{ Config: model.AppConfig{
Domain: "user-allow.example.com", Domain: "user-allow.example.com",
@@ -163,9 +171,10 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
}, },
}, },
}, },
CookieDomain: "example.com", CookieDomain: "example.com",
AppURL: "https://tinyauth.example.com", AppURL: "https://tinyauth.example.com",
SessionCookieName: "tinyauth-session", SessionCookieName: "tinyauth-session",
TrustedProxiesConfigured: true,
} }
return config, runtime return config, runtime
+46 -5
View File
@@ -7,10 +7,55 @@ import (
"strings" "strings"
"github.com/weppos/publicsuffix-go/publicsuffix" "github.com/weppos/publicsuffix-go/publicsuffix"
"golang.org/x/net/idna"
) )
// GetCookieDomain parses the app url and returns the domain value to use for cookies. var (
ErrEmptyURL = fmt.Errorf("invalid url")
)
func SafeParseAppURL(str string) (string, error) {
if strings.TrimSpace(str) == "" {
return "", ErrEmptyURL
}
u, err := url.Parse(str)
if err != nil {
return "", fmt.Errorf("invalid url: %w", err)
}
if u.Host == "" ||
(u.Scheme != "http" &&
u.Scheme != "https") {
return "", fmt.Errorf("invalid url, must be in format https(s)://host")
}
hostname := strings.ToLower(u.Hostname())
hostname = strings.TrimSuffix(hostname, ".")
if netIP := net.ParseIP(hostname); netIP != nil {
return "", fmt.Errorf("ip addresses not allowed")
}
hostname, err = idna.Lookup.ToASCII(hostname)
if err != nil {
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
}
appURL := fmt.Sprintf("%s://%s", u.Scheme, hostname)
if u.Port() != "" {
appURL += ":" + u.Port()
}
return appURL, nil
}
// GetCookieDomain parses the app URL and returns the domain value to use for cookies.
// When auth for subdomains is enabled, it strips the leftmost label // When auth for subdomains is enabled, it strips the leftmost label
// GetCookieDomain assumes the app URL is first parsed with SafeParseAppURL
// (e.g. sub1.sub2.domain.com -> sub2.domain.com), otherwise it returns the full hostname. // (e.g. sub1.sub2.domain.com -> sub2.domain.com), otherwise it returns the full hostname.
func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) { func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
u, err := url.Parse(appUrl) u, err := url.Parse(appUrl)
@@ -21,10 +66,6 @@ func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
hostname := strings.ToLower(u.Hostname()) hostname := strings.ToLower(u.Hostname())
if netIP := net.ParseIP(hostname); netIP != nil {
return "", fmt.Errorf("ip addresses not allowed")
}
parts := strings.Split(hostname, ".") parts := strings.Split(hostname, ".")
if len(parts) < 2 { if len(parts) < 2 {
+85 -6
View File
@@ -7,7 +7,86 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
) )
func TestGetRootDomain(t *testing.T) { func TestSafeParseAPPURL(t *testing.T) {
// Normal app url
appURL := "http://sub.tinyauth.app"
expected := "http://sub.tinyauth.app"
result, err := utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Strip path
appURL = "http://sub.tinyauth.app/path"
expected = "http://sub.tinyauth.app"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Preserve port
appURL = "http://sub.tinyauth.app:8080"
expected = "http://sub.tinyauth.app:8080"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Remove trailing dot
appURL = "http://sub.tinyauth.app."
expected = "http://sub.tinyauth.app"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Convert to ascii
appURL = "http://bücher.example.com"
expected = "http://xn--bcher-kva.example.com"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Lowercase
appURL = "HTTP://SUb.tinyAUth.aPP"
expected = "http://sub.tinyauth.app"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Empty string
appURL = ""
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorIs(t, err, utils.ErrEmptyURL)
// Invalid URL
appURL = "invalidurl"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "invalid url")
// Non http or https URL
appURL = "ftp://sub.tinyauth.app"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "invalid url")
// Invalid punycode
appURL = "http://ab--cd.example.com"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "failed to convert hostname to ascii")
// IP address
appURL = "http://10.10.10.10"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "ip addresses not allowed")
// IPv6 address
appURL = "http://[::1]:8080"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "ip addresses not allowed")
// Invalid URL
appURL = "://"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "invalid url")
}
func TestGetCookieDomain(t *testing.T) {
// Normal case // Normal case
domain := "http://sub.tinyauth.app" domain := "http://sub.tinyauth.app"
expected := "tinyauth.app" expected := "tinyauth.app"
@@ -27,11 +106,6 @@ func TestGetRootDomain(t *testing.T) {
_, err = utils.GetCookieDomain(domain, true) _, err = utils.GetCookieDomain(domain, true)
assert.EqualError(t, err, "invalid app url, must be in format subdomain.domain.tld or domain.tld") assert.EqualError(t, err, "invalid app url, must be in format subdomain.domain.tld or domain.tld")
// IP address
domain = "http://10.10.10.10"
_, err = utils.GetCookieDomain(domain, true)
assert.ErrorContains(t, err, "ip addresses not allowed")
// Invalid URL // Invalid URL
domain = "http://[::1]:namedport" domain = "http://[::1]:namedport"
_, err = utils.GetCookieDomain(domain, true) _, err = utils.GetCookieDomain(domain, true)
@@ -56,6 +130,11 @@ func TestGetRootDomain(t *testing.T) {
_, err = utils.GetCookieDomain(domain, true) _, err = utils.GetCookieDomain(domain, true)
assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies") assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies")
// Domain managed by ICANN without subdomain
domain = "http://co.uk"
_, err = utils.GetCookieDomain(domain, true)
assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies")
// Domain without subdomain // Domain without subdomain
domain = "http://tinyauth.app" domain = "http://tinyauth.app"
expected = "tinyauth.app" expected = "tinyauth.app"
+10
View File
@@ -0,0 +1,10 @@
# Public packages
This directory contains packages that can be used by
other projects.
While we try to maintain a consistent API, no promises
can be made for non-breaking changes throughout updates
as we constantly need to make changes to comply with the
needs of Tinyauth. We advise pinning the version of the
package you wish to use.
@@ -1,4 +1,4 @@
package service package cache
import ( import (
"slices" "slices"
@@ -33,8 +33,8 @@ func NewCacheStore[T any](maxSize int) *CacheStore[T] {
} }
} }
// With lock allows performing multiple operations on the cache store atomically. // WithLock allows performing multiple operations on a single lock.
// The provided mutate function receives a set of actions (Set, Get, Delete) that // The provided mutate function receives a set of actions (Set, Get, Delete, Update) that
// can be used to manipulate the cache store within the locked context. // can be used to manipulate the cache store within the locked context.
func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) { func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) {
cs.mu.Lock() cs.mu.Lock()
@@ -195,3 +195,19 @@ func (cs *CacheStore[T]) Clear() {
cs.cache = make(map[string]cacheEntry[T]) cs.cache = make(map[string]cacheEntry[T])
cs.order = make([]string, 0) cs.order = make([]string, 0)
} }
func (cs *CacheStore[T]) SetMaxSize(maxSize int) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.maxSize = maxSize
for len(cs.cache) > maxSize {
if !cs.evictOne() {
break
}
}
}
func (cs *CacheStore[T]) GetMaxSize() int {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.maxSize
}
@@ -1,4 +1,4 @@
package service package cache
import ( import (
"strconv" "strconv"
@@ -316,6 +316,21 @@ func TestCacheStoreSizeAndClear(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
} }
func TestCacheStoreWithMaxSize(t *testing.T) {
cs := NewCacheStore[string](0)
assert.Equal(t, 0, cs.Size())
for i := 0; i < 100; i++ {
cs.Set(strconv.Itoa(i), strconv.Itoa(i), 0)
}
assert.Equal(t, 100, cs.Size())
cs.SetMaxSize(10)
assert.Equal(t, 10, cs.Size())
}
func TestCacheStoreWithLock(t *testing.T) { func TestCacheStoreWithLock(t *testing.T) {
cs := NewCacheStore[int](0) cs := NewCacheStore[int](0)
cs.Set("counter", 1, 0) cs.Set("counter", 1, 0)
+64 -53
View File
@@ -10,14 +10,13 @@ import (
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"slices"
"strings" "strings"
"golang.org/x/net/idna" "golang.org/x/net/idna"
) )
// Errors
var ( var (
ErrInvalidURL = fmt.Errorf("invalid url")
ErrSchemeMismatch = fmt.Errorf("scheme mismatch") ErrSchemeMismatch = fmt.Errorf("scheme mismatch")
ErrPortMismatch = fmt.Errorf("port mismatch") ErrPortMismatch = fmt.Errorf("port mismatch")
ErrHostnameMismatch = fmt.Errorf("hostname mismatch") ErrHostnameMismatch = fmt.Errorf("hostname mismatch")
@@ -29,8 +28,7 @@ type DomainValidatorOptions struct {
WithScheme bool WithScheme bool
// Ensure domains have the same port. // Ensure domains have the same port.
WithPort bool WithPort bool
// Specify a list of allowed schemes IF WithScheme is set to true. // Specify a list of allowed schemes if WithScheme is set to true.
// Leave empty to allow any scheme.
AllowedSchemes []string AllowedSchemes []string
} }
@@ -48,53 +46,74 @@ func NewDomainValidator(opts DomainValidatorOptions) *DomainValidator {
} }
} }
func (v *DomainValidator) checkScheme(rawURL string) error {
if !v.opts.WithScheme {
return nil
}
if len(v.opts.AllowedSchemes) == 0 {
return fmt.Errorf("allowed schemes must be specified")
}
for _, scheme := range v.opts.AllowedSchemes {
if strings.HasPrefix(strings.ToLower(rawURL), strings.ToLower(scheme)+"://") {
return nil
}
}
return fmt.Errorf("invalid scheme")
}
func (v *DomainValidator) getURL(i string) (*url.URL, error) { func (v *DomainValidator) getURL(i string) (*url.URL, error) {
u, err := url.Parse(i) if i == "" {
return nil, fmt.Errorf("url cannot be empty")
if !v.opts.WithScheme && (err != nil || u.Host == "") {
u, err = url.Parse("tinyauth://" + i)
}
if err != nil {
return nil, fmt.Errorf("failed to parse input url: %w", err)
}
if u.Host == "" {
return nil, ErrInvalidURL
}
if v.opts.WithPort && !v.opts.WithScheme && u.Port() == "" {
return nil, fmt.Errorf("port validation is enabled but port is missing in input url and schemes are not enabled")
} }
if v.opts.WithScheme { if v.opts.WithScheme {
// Empty scheme means that we parsed the url with the tinyauth:// placeholder err := v.checkScheme(i)
if u.Scheme == "tinyauth" {
return nil, fmt.Errorf("input url is missing scheme") if err != nil {
return nil, fmt.Errorf("invalid scheme: %w", err)
} }
if len(v.opts.AllowedSchemes) > 0 && !slices.Contains(v.opts.AllowedSchemes, u.Scheme) {
return nil, fmt.Errorf("scheme %s not allowed", u.Scheme) u, err := url.Parse(i)
if err != nil {
return nil, fmt.Errorf("failed to parse input url: %w", err)
} }
if u.Host == "" || u.Scheme == "" {
return nil, fmt.Errorf("missing host or scheme in url: %s", i)
}
return u, nil
}
rawURL := i
if !strings.Contains(i, "://") {
// From godoc: [scheme:][//[userinfo@]host][/]path[?query][#fragment]
// So, we can omit the colon and tell the Go URL lib that we want
// to parse the URL without the scheme. If we don't do this,
// the URL lib will parse our entire domain as the path.
rawURL = "//" + i
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("failed to parse host: %w", err)
}
if u.Host == "" {
return nil, fmt.Errorf("missing host in url: %s", i)
} }
return u, nil return u, nil
} }
func (v *DomainValidator) getEffectivePort(u *url.URL) (string, bool) { func (v *DomainValidator) getHostname(hostname string) (string, error) {
if u.Port() != "" {
return u.Port(), true
}
switch u.Scheme {
case "http":
return "80", true
case "https":
return "443", true
default:
return "", false
}
}
func (v *DomainValidator) formatHostname(hostname string) (string, error) {
hostname = strings.ToLower(hostname) hostname = strings.ToLower(hostname)
hostname = strings.TrimSuffix(hostname, ".") hostname = strings.TrimSuffix(hostname, ".")
if net.ParseIP(hostname) != nil { if net.ParseIP(hostname) != nil {
@@ -133,26 +152,18 @@ func (v *DomainValidator) Validate(expected, actual string) error {
} }
if v.opts.WithPort { if v.opts.WithPort {
eup, ok := v.getEffectivePort(eu) if eu.Port() != au.Port() {
if !ok {
return fmt.Errorf("failed to get effective port for url: %s", eu.String())
}
aup, ok := v.getEffectivePort(au)
if !ok {
return fmt.Errorf("failed to get effective port for url: %s", au.String())
}
if eup != aup {
return ErrPortMismatch return ErrPortMismatch
} }
} }
euf, err := v.formatHostname(eu.Hostname()) euf, err := v.getHostname(eu.Hostname())
if err != nil { if err != nil {
return err return err
} }
auf, err := v.formatHostname(au.Hostname()) auf, err := v.getHostname(au.Hostname())
if err != nil { if err != nil {
return err return err
@@ -165,7 +176,7 @@ func (v *DomainValidator) Validate(expected, actual string) error {
return nil return nil
} }
// SafeHostname uses the internal validation for domains that Validator uses // SafeHostname uses the internal validation for domains that the validator uses
// to parse a hostname. It ensures the input URL is a valid URL, that a host // to parse a hostname. It ensures the input URL is a valid URL, that a host
// is present and that the hostname is lowercased and without a trailing dot. // is present and that the hostname is lowercased and without a trailing dot.
func (v *DomainValidator) SafeHostname(input string) (string, error) { func (v *DomainValidator) SafeHostname(input string) (string, error) {
@@ -175,5 +186,5 @@ func (v *DomainValidator) SafeHostname(input string) (string, error) {
return "", err return "", err
} }
return v.formatHostname(u.Hostname()) return v.getHostname(u.Hostname())
} }
+39 -57
View File
@@ -20,16 +20,36 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
{ {
description: "Empty url fails", description: "Empty url fails",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorIs(t, e, ErrInvalidURL) assert.ErrorContains(t, e, "url cannot be empty")
},
},
{
description: "URL without host fails",
input: "/foo",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "missing host in url")
}, },
}, },
{ {
description: "Invalid url fails", description: "Invalid url fails",
input: "foo:foo", input: "foo:foo",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse host")
},
},
{
description: "With scheme and invalid url should fail",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
input: "https://foo:foo",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse input url") assert.ErrorContains(t, e, "failed to parse input url")
}, },
}, },
{
description: "Scheme disabled with scheme should pass",
input: "https://example.com",
expected: "example.com",
},
{ {
description: "Domain without scheme should parse if scheme is disabled", description: "Domain without scheme should parse if scheme is disabled",
input: "example.com", input: "example.com",
@@ -40,7 +60,7 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
options: DomainValidatorOptions{WithScheme: true}, options: DomainValidatorOptions{WithScheme: true},
input: "example.com", input: "example.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorIs(t, e, ErrInvalidURL) assert.ErrorContains(t, e, "invalid scheme")
}, },
}, },
{ {
@@ -48,7 +68,7 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}}, options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
input: "foo://example.com", input: "foo://example.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "foo not allowed") assert.ErrorContains(t, e, "invalid scheme")
}, },
}, },
{ {
@@ -94,15 +114,10 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
}, },
}, },
{ {
// Placeholder should not be used by users and is reserved for the validator. description: "With port enabled without any port should work",
// Using it is like not using any scheme for the validator, and thus it will fail options: DomainValidatorOptions{WithPort: true},
// with schemes enabled. input: "example.com",
description: "Placeholder scheme supplied directly should fail", expected: "example.com",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
input: "tinyauth://example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "input url is missing scheme")
},
}, },
} }
@@ -135,7 +150,7 @@ func TestDomainValidator_Validate(t *testing.T) {
expected: "foo:foo", expected: "foo:foo",
actual: "bar.com", actual: "bar.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse input url:") assert.ErrorContains(t, e, "failed to parse host:")
}, },
}, },
{ {
@@ -143,7 +158,7 @@ func TestDomainValidator_Validate(t *testing.T) {
expected: "example.com", expected: "example.com",
actual: "foo:foo", actual: "foo:foo",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse input url:") assert.ErrorContains(t, e, "failed to parse host:")
}, },
}, },
{ {
@@ -162,58 +177,22 @@ func TestDomainValidator_Validate(t *testing.T) {
actual: "https://example.com", actual: "https://example.com",
}, },
{ {
description: "Port validation without ports and schemes disabled should fail", description: "Port validation with ports enabled and empty ports should work",
options: DomainValidatorOptions{WithPort: true}, options: DomainValidatorOptions{WithPort: true},
expected: "example.com", expected: "example.com",
actual: "example.com", actual: "example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "port validation is enabled but port is missing in input url and schemes are not enabled")
},
}, },
{ {
description: "Port validation with no port and http should pass", description: "Port validation should pass with same port",
options: DomainValidatorOptions{WithPort: true, WithScheme: true, AllowedSchemes: []string{"http"}},
expected: "http://example.com",
actual: "http://example.com",
},
{
description: "Port validation with no port and https should pass",
options: DomainValidatorOptions{WithPort: true, WithScheme: true, AllowedSchemes: []string{"https"}},
expected: "https://example.com",
actual: "https://example.com",
},
{
description: "Port validation with port and no scheme should pass with same port",
options: DomainValidatorOptions{WithPort: true}, options: DomainValidatorOptions{WithPort: true},
expected: "example.com:8080", expected: "example.com:8080",
actual: "example.com:8080", actual: "example.com:8080",
}, },
{ {
description: "Domains with unknown scheme and port enabled but no port should fail", description: "Port enabled with scheme and matching port should pass",
options: DomainValidatorOptions{WithPort: true, WithScheme: true},
expected: "ssh://example.com:22",
actual: "ssh://example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to get effective port for url")
},
},
{
description: "Domains with unknown scheme and port enabled but no port should fail, reverse",
options: DomainValidatorOptions{WithPort: true, WithScheme: true},
expected: "ssh://example.com",
actual: "ssh://example.com:22",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to get effective port for url")
},
},
{
description: "Port validation with port and no scheme should fail with different port",
options: DomainValidatorOptions{WithPort: true}, options: DomainValidatorOptions{WithPort: true},
expected: "example.com:8080", expected: "https://example.com:443",
actual: "example.com:8081", actual: "https://example.com:443",
errorFunc: func(t *testing.T, e error) {
assert.ErrorIs(t, e, ErrPortMismatch)
},
}, },
{ {
description: "Failure to format expected domain should fail", description: "Failure to format expected domain should fail",
@@ -244,10 +223,13 @@ func TestDomainValidator_Validate(t *testing.T) {
actual: "https://example.com", actual: "https://example.com",
}, },
{ {
description: "Valid domains with matching ports should pass", description: "Valid domains with non matching ports should fail",
options: DomainValidatorOptions{WithPort: true}, options: DomainValidatorOptions{WithPort: true},
expected: "example.com:8080", expected: "example.com:8080",
actual: "example.com:8080", actual: "example.com:8085",
errorFunc: func(t *testing.T, e error) {
assert.ErrorIs(t, e, ErrPortMismatch)
},
}, },
{ {
description: "Valid domains without ports or schemes should pass", description: "Valid domains without ports or schemes should pass",