mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-08-26 07:13:32 +08:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3390debab7 | ||
|
|
c5bccd0b7b | ||
|
|
cce0eaa974 | ||
|
|
3d5ab54ab8 | ||
|
|
c7077a7c84 | ||
|
|
80bc87188e | ||
|
|
0e7bdf6cd5 | ||
|
|
4db1b33034 | ||
|
|
f6637c0f65 | ||
|
|
f43d690320 | ||
|
|
50c25e4478 | ||
|
|
d6087dc45e | ||
|
|
6efb970673 | ||
|
|
a9221ece92 | ||
|
|
d5b7e60362 | ||
|
|
4d8cd86eec | ||
|
|
504866a432 | ||
|
|
0c1a64d47d | ||
|
|
a79438d6ec | ||
|
|
286aad2e08 | ||
|
|
5091fc2d7a | ||
|
|
0fc87ad58f | ||
|
|
a7eba59a42 | ||
|
|
ac8703eadb | ||
|
|
04a06ec5ff | ||
|
|
3cf3cc9090 | ||
|
|
589fe22138 | ||
|
|
1165c91fcc | ||
|
|
84117ce109 | ||
|
|
9eabfe89fd | ||
|
|
dade1e2c8f | ||
|
|
6e095fd4f2 | ||
|
|
2a0d7bd37a | ||
|
|
c22925c2fb | ||
|
|
d946926c36 | ||
|
|
8881116360 |
+5
-2
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/axjab"><img src="https://github.com/axjab.png" width="64px" alt="User avatar: axjab" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <a href="https://github.com/apearson"><img src="https://github.com/apearson.png" width="64px" alt="User avatar: apearson" /></a> <a href="https://github.com/Micky5991"><img src="https://github.com/Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a> <!-- sponsors -->
|
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/axjab"><img src="https://github.com/axjab.png" width="64px" alt="User avatar: axjab" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <a href="https://github.com/apearson"><img src="https://github.com/apearson.png" width="64px" alt="User avatar: apearson" /></a> <a href="https://github.com/Micky5991"><img src="https://github.com/Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a> <!-- 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
|
|
||||||
|
|
||||||
[](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 |
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+122
-122
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 "";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
+19
-3
@@ -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)
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user