Compare commits

..
Author SHA1 Message Date
Stavros 8e65e35706 New translations en.json (Czech)
[ci skip]
2026-08-05 20:56:11 +03:00
Stavros b61ca4e7a0 New translations en.json (Czech)
[ci skip]
2026-08-04 14:35:41 +03:00
Stavros 3d9c69cd27 New translations en.json (Czech)
[ci skip]
2026-08-04 13:04:05 +03:00
Stavros 43f49bf571 New translations en.json (Japanese)
[ci skip]
2026-07-30 12:26:30 +03:00
Stavros 4911d92b0e New translations en.json (Japanese)
[ci skip]
2026-07-21 05:56:26 +03:00
Stavros 610abacf49 New translations en.json (Chinese Simplified)
[ci skip]
2026-07-11 13:11:06 +03:00
Stavros 71703e4b23 New translations en.json (Serbian (Cyrillic))
[ci skip]
2026-07-11 13:11:04 +03:00
Stavros 615940f26d New translations en.json (Polish)
[ci skip]
2026-07-11 13:11:02 +03:00
Stavros 70da5f90c9 New translations en.json (Dutch)
[ci skip]
2026-07-11 13:11:00 +03:00
Stavros 9a430cd299 New translations en.json (Chinese Simplified)
[ci skip]
2026-07-11 13:05:36 +03:00
Stavros 98348ca31e New translations en.json (Serbian (Cyrillic))
[ci skip]
2026-07-11 13:05:33 +03:00
Stavros 9194b62fbb New translations en.json (Polish)
[ci skip]
2026-07-11 13:05:30 +03:00
Stavros c41e99eeef New translations en.json (Dutch)
[ci skip]
2026-07-11 13:05:29 +03:00
64 changed files with 1087 additions and 2473 deletions
+4 -9
View File
@@ -4,10 +4,6 @@
# The base URL where the app is hosted.
TINYAUTH_APPURL=
# Path to config file.
TINYAUTH_CONFIGFILE=
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
TINYAUTH_LABELPROVIDER="auto"
# database config
@@ -101,6 +97,8 @@ TINYAUTH_AUTH_SESSIONMAXLIFETIME=0
TINYAUTH_AUTH_LOGINTIMEOUT=300
# Maximum login retries.
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.
TINYAUTH_AUTH_TRUSTEDPROXIES=
# ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow.
@@ -223,11 +221,6 @@ TINYAUTH_LDAP_AUTHKEY=
# Cache duration for LDAP group membership in seconds.
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
# Enable Tailscale integration.
@@ -240,6 +233,8 @@ TINYAUTH_TAILSCALE_APITOKENFILE=
TINYAUTH_TAILSCALE_TAILNET=
# Cache duration for Tailscale device and user lists in seconds.
TINYAUTH_TAILSCALE_CACHEDURATION=300
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
TINYAUTH_LABELPROVIDER="auto"
# log config
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
package_json_file: ./frontend/package.json
- name: Setup go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "^1.26.4"
+4 -4
View File
@@ -23,7 +23,7 @@ jobs:
REPO: ${{ github.event.repository.name }}
- name: Create release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with:
prerelease: true
tag_name: nightly
@@ -68,7 +68,7 @@ jobs:
package_json_file: ./frontend/package.json
- name: Install go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "^1.26.4"
@@ -117,7 +117,7 @@ jobs:
package_json_file: ./frontend/package.json
- name: Install go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "^1.26.4"
@@ -476,7 +476,7 @@ jobs:
merge-multiple: true
- name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with:
files: binaries/*
tag_name: nightly
+3 -3
View File
@@ -46,7 +46,7 @@ jobs:
package_json_file: ./frontend/package.json
- name: Install go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "^1.26.4"
@@ -92,7 +92,7 @@ jobs:
package_json_file: ./frontend/package.json
- name: Install go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "^1.26.4"
@@ -449,6 +449,6 @@ jobs:
merge-multiple: true
- name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with:
files: binaries/*
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
sarif_file: results.sarif
-1
View File
@@ -39,7 +39,6 @@ RUN go mod download
COPY ./cmd ./cmd
COPY ./internal ./internal
COPY ./pkg ./pkg
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \
-1
View File
@@ -12,7 +12,6 @@ RUN go install github.com/go-delve/delve/cmd/dlv@v1.26.3
COPY ./cmd ./cmd
COPY ./internal ./internal
COPY ./pkg ./pkg
COPY ./air.toml ./
EXPOSE 3000
-1
View File
@@ -39,7 +39,6 @@ RUN go mod download
COPY ./cmd ./cmd/
COPY ./internal ./internal
COPY ./pkg ./pkg
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \
+15 -11
View File
@@ -62,19 +62,18 @@ 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.
## 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
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
A big thank you to the following people for providing me with more coffee:
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/axjab"><img src="https:&#x2F;&#x2F;github.com&#x2F;axjab.png" width="64px" alt="User avatar: axjab" /></a>&nbsp;&nbsp;<a href="https://github.com/stegratech"><img src="https:&#x2F;&#x2F;github.com&#x2F;stegratech.png" width="64px" alt="User avatar: stegratech" /></a>&nbsp;&nbsp;<a href="https://github.com/apearson"><img src="https:&#x2F;&#x2F;github.com&#x2F;apearson.png" width="64px" alt="User avatar: apearson" /></a>&nbsp;&nbsp;<a href="https://github.com/Micky5991"><img src="https:&#x2F;&#x2F;github.com&#x2F;Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a>&nbsp;&nbsp;<!-- sponsors -->
@@ -82,4 +81,9 @@ for their support for Tinyauth.
- **Freepik** for providing the police hat and badge.
- **Renee French** for the original gopher logo.
- **Coderabbit AI** for providing free AI code reviews.
- **Syrhu** for providing the background image of the app.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=tinyauthapp/tinyauth&type=Date)](https://www.star-history.com/#tinyauthapp/tinyauth&Date)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

+2 -2
View File
@@ -116,11 +116,11 @@ func generateTotpCmd() *cli.Command {
userStr := fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)
fmt.Print("\nOr add the following TOTP secret to your authenticator app: ")
fmt.Print(colors.blue.Render(secret))
fmt.Print(colors.green.Render(secret))
fmt.Print("\n\n")
fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username)
fmt.Print(colors.blue.Render(userStr))
fmt.Print(colors.green.Render(userStr))
fmt.Print("\n")
return nil
-1
View File
@@ -32,7 +32,6 @@ func main() {
Configuration: tConfig,
Resources: loaders,
Run: func(_ []string) error {
// enable this on experimental features
if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) {
colors := getColors()
fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.")
+6 -6
View File
@@ -23,16 +23,16 @@
"axios": "^1.18.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.6",
"i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1",
"lucide-react": "^1.24.0",
"lucide-react": "^1.23.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-i18next": "^17.0.10",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"react-router": "^8.2.0",
"sonner": "^2.0.7",
@@ -47,14 +47,14 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.7.0",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"rollup-plugin-visualizer": "^7.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.64.0",
"vite": "^8.1.5"
"typescript-eslint": "^8.63.0",
"vite": "^8.1.4"
}
}
+135 -135
View File
@@ -28,7 +28,7 @@ importers:
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@tailwindcss/vite':
specifier: ^4.3.2
version: 4.3.2(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))
version: 4.3.2(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))
'@tanstack/react-query':
specifier: ^5.101.2
version: 5.101.2(react@19.2.7)
@@ -42,8 +42,8 @@ importers:
specifier: ^2.1.1
version: 2.1.1
i18next:
specifier: ^26.3.6
version: 26.3.6(typescript@6.0.3)
specifier: ^26.3.4
version: 26.3.4(typescript@6.0.3)
i18next-browser-languagedetector:
specifier: ^8.2.1
version: 8.2.1
@@ -51,8 +51,8 @@ importers:
specifier: ^1.2.1
version: 1.2.1
lucide-react:
specifier: ^1.24.0
version: 1.24.0(react@19.2.7)
specifier: ^1.23.0
version: 1.23.0(react@19.2.7)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -69,8 +69,8 @@ importers:
specifier: ^7.81.0
version: 7.81.0(react@19.2.7)
react-i18next:
specifier: ^17.0.10
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)
specifier: ^17.0.8
version: 17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
@@ -92,10 +92,10 @@ importers:
devDependencies:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.7.0(jiti@2.7.0))
version: 10.0.1(eslint@10.6.0(jiti@2.7.0))
'@tanstack/eslint-plugin-query':
specifier: ^5.101.2
version: 5.101.2(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
version: 5.101.2(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@types/node':
specifier: ^26.1.1
version: 26.1.1
@@ -107,16 +107,16 @@ importers:
version: 19.2.3(@types/react@19.2.17)
'@vitejs/plugin-react':
specifier: ^6.0.3
version: 6.0.3(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))
version: 6.0.3(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))
eslint:
specifier: ^10.7.0
version: 10.7.0(jiti@2.7.0)
specifier: ^10.6.0
version: 10.6.0(jiti@2.7.0)
eslint-plugin-react-hooks:
specifier: ^7.0.1
version: 7.1.1(eslint@10.7.0(jiti@2.7.0))
version: 7.1.1(eslint@10.6.0(jiti@2.7.0))
eslint-plugin-react-refresh:
specifier: ^0.5.3
version: 0.5.3(eslint@10.7.0(jiti@2.7.0))
version: 0.5.3(eslint@10.6.0(jiti@2.7.0))
globals:
specifier: ^17.7.0
version: 17.7.0
@@ -130,11 +130,11 @@ importers:
specifier: ~6.0.2
version: 6.0.3
typescript-eslint:
specifier: ^8.64.0
version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
specifier: ^8.63.0
version: 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
vite:
specifier: ^8.1.5
version: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)
specifier: ^8.1.4
version: 8.1.4(@types/node@26.1.1)(jiti@2.7.0)
packages:
@@ -1272,16 +1272,16 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@typescript-eslint/eslint-plugin@8.64.0':
resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==}
'@typescript-eslint/eslint-plugin@8.63.0':
resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.64.0
'@typescript-eslint/parser': ^8.63.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.64.0':
resolution: {integrity: sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==}
'@typescript-eslint/parser@8.63.0':
resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1293,8 +1293,8 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.64.0':
resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==}
'@typescript-eslint/project-service@8.63.0':
resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
@@ -1303,8 +1303,8 @@ packages:
resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.64.0':
resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==}
'@typescript-eslint/scope-manager@8.63.0':
resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.62.1':
@@ -1313,14 +1313,14 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/tsconfig-utils@8.64.0':
resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==}
'@typescript-eslint/tsconfig-utils@8.63.0':
resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.64.0':
resolution: {integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==}
'@typescript-eslint/type-utils@8.63.0':
resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1330,8 +1330,8 @@ packages:
resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.64.0':
resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==}
'@typescript-eslint/types@8.63.0':
resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.62.1':
@@ -1340,8 +1340,8 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/typescript-estree@8.64.0':
resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==}
'@typescript-eslint/typescript-estree@8.63.0':
resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
@@ -1353,8 +1353,8 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.64.0':
resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==}
'@typescript-eslint/utils@8.63.0':
resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1364,8 +1364,8 @@ packages:
resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.64.0':
resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==}
'@typescript-eslint/visitor-keys@8.63.0':
resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.1':
@@ -1603,8 +1603,8 @@ packages:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.7.0:
resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==}
eslint@10.6.0:
resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
@@ -1772,10 +1772,10 @@ packages:
i18next-resources-to-backend@1.2.1:
resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==}
i18next@26.3.6:
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
i18next@26.3.4:
resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==}
peerDependencies:
typescript: ^5 || ^6 || ^7
typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
@@ -1784,8 +1784,8 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.6:
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
imurmurhash@0.1.4:
@@ -1957,8 +1957,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.24.0:
resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
lucide-react@1.23.0:
resolution: {integrity: sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -2071,8 +2071,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
nanoid@3.3.15:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -2126,8 +2126,8 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
postcss@8.5.19:
resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==}
postcss@8.5.16:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
@@ -2173,14 +2173,14 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.0.10:
resolution: {integrity: sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==}
react-i18next@17.0.8:
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6 || ^7
typescript: ^5 || ^6
peerDependenciesMeta:
react-dom:
optional: true
@@ -2357,8 +2357,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
typescript-eslint@8.64.0:
resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==}
typescript-eslint@8.63.0:
resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -2430,8 +2430,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@8.1.5:
resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==}
vite@8.1.4:
resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -2647,9 +2647,9 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.7.0(jiti@2.7.0))':
'@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))':
dependencies:
eslint: 10.7.0(jiti@2.7.0)
eslint: 10.6.0(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -2670,9 +2670,9 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/js@10.0.1(eslint@10.7.0(jiti@2.7.0))':
'@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0))':
optionalDependencies:
eslint: 10.7.0(jiti@2.7.0)
eslint: 10.6.0(jiti@2.7.0)
'@eslint/object-schema@3.0.5': {}
@@ -3607,17 +3607,17 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.2
'@tailwindcss/oxide-win32-x64-msvc': 4.3.2
'@tailwindcss/vite@4.3.2(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))':
'@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))':
dependencies:
'@tailwindcss/node': 4.3.2
'@tailwindcss/oxide': 4.3.2
tailwindcss: 4.3.2
vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)
vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0)
'@tanstack/eslint-plugin-query@5.101.2(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
'@tanstack/eslint-plugin-query@5.101.2(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/utils': 8.62.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
eslint: 10.7.0(jiti@2.7.0)
'@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
eslint: 10.6.0(jiti@2.7.0)
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
@@ -3675,30 +3675,30 @@ snapshots:
'@types/unist@3.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/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)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/type-utils': 8.64.0(eslint@10.7.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.64.0
eslint: 10.7.0(jiti@2.7.0)
ignore: 7.0.6
'@typescript-eslint/parser': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/type-utils': 8.63.0(eslint@10.6.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/visitor-keys': 8.63.0
eslint: 10.6.0(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.64.0(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)':
dependencies:
'@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.64.0
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.63.0
debug: 4.4.3
eslint: 10.7.0(jiti@2.7.0)
eslint: 10.6.0(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -3712,10 +3712,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.64.0(typescript@6.0.3)':
'@typescript-eslint/project-service@8.63.0(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.64.0(typescript@6.0.3)
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3)
'@typescript-eslint/types': 8.63.0
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
@@ -3726,26 +3726,26 @@ snapshots:
'@typescript-eslint/types': 8.62.1
'@typescript-eslint/visitor-keys': 8.62.1
'@typescript-eslint/scope-manager@8.64.0':
'@typescript-eslint/scope-manager@8.63.0':
dependencies:
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/visitor-keys': 8.64.0
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/visitor-keys': 8.63.0
'@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/tsconfig-utils@8.64.0(typescript@6.0.3)':
'@typescript-eslint/tsconfig-utils@8.63.0(typescript@6.0.3)':
dependencies:
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/type-utils@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.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/types': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3)
'@typescript-eslint/utils': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
debug: 4.4.3
eslint: 10.7.0(jiti@2.7.0)
eslint: 10.6.0(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
@@ -3753,7 +3753,7 @@ snapshots:
'@typescript-eslint/types@8.62.1': {}
'@typescript-eslint/types@8.64.0': {}
'@typescript-eslint/types@8.63.0': {}
'@typescript-eslint/typescript-estree@8.62.1(typescript@6.0.3)':
dependencies:
@@ -3770,12 +3770,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.64.0(typescript@6.0.3)':
'@typescript-eslint/typescript-estree@8.63.0(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.64.0(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.64.0(typescript@6.0.3)
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/visitor-keys': 8.64.0
'@typescript-eslint/project-service': 8.63.0(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3)
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/visitor-keys': 8.63.0
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.5
@@ -3785,24 +3785,24 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.62.1(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)':
'@typescript-eslint/utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.62.1
'@typescript-eslint/types': 8.62.1
'@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3)
eslint: 10.7.0(jiti@2.7.0)
eslint: 10.6.0(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/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)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3)
eslint: 10.7.0(jiti@2.7.0)
'@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3)
eslint: 10.6.0(jiti@2.7.0)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -3812,17 +3812,17 @@ snapshots:
'@typescript-eslint/types': 8.62.1
eslint-visitor-keys: 5.0.1
'@typescript-eslint/visitor-keys@8.64.0':
'@typescript-eslint/visitor-keys@8.63.0':
dependencies:
'@typescript-eslint/types': 8.64.0
'@typescript-eslint/types': 8.63.0
eslint-visitor-keys: 5.0.1
'@ungap/structured-clone@1.3.1': {}
'@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))':
'@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)
vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0)
acorn-jsx@5.3.2(acorn@8.17.0):
dependencies:
@@ -3997,20 +3997,20 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-plugin-react-hooks@7.1.1(eslint@10.7.0(jiti@2.7.0)):
eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.3
eslint: 10.7.0(jiti@2.7.0)
eslint: 10.6.0(jiti@2.7.0)
hermes-parser: 0.25.1
zod: 4.4.3
zod-validation-error: 4.0.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
eslint-plugin-react-refresh@0.5.3(eslint@10.7.0(jiti@2.7.0)):
eslint-plugin-react-refresh@0.5.3(eslint@10.6.0(jiti@2.7.0)):
dependencies:
eslint: 10.7.0(jiti@2.7.0)
eslint: 10.6.0(jiti@2.7.0)
eslint-scope@9.1.2:
dependencies:
@@ -4023,9 +4023,9 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@10.7.0(jiti@2.7.0):
eslint@10.6.0(jiti@2.7.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5
'@eslint/config-helpers': 0.6.0
@@ -4220,13 +4220,13 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.2
i18next@26.3.6(typescript@6.0.3):
i18next@26.3.4(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
ignore@5.3.2: {}
ignore@7.0.6: {}
ignore@7.0.5: {}
imurmurhash@0.1.4: {}
@@ -4347,7 +4347,7 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.24.0(react@19.2.7):
lucide-react@1.23.0(react@19.2.7):
dependencies:
react: 19.2.7
@@ -4591,7 +4591,7 @@ snapshots:
ms@2.1.3: {}
nanoid@3.3.16: {}
nanoid@3.3.15: {}
natural-compare@1.4.0: {}
@@ -4648,9 +4648,9 @@ snapshots:
picomatch@4.0.5: {}
postcss@8.5.19:
postcss@8.5.16:
dependencies:
nanoid: 3.3.16
nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -4736,11 +4736,11 @@ snapshots:
dependencies:
react: 19.2.7
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):
react-i18next@17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1
i18next: 26.3.6(typescript@6.0.3)
i18next: 26.3.4(typescript@6.0.3)
react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
@@ -4923,13 +4923,13 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
typescript-eslint@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3):
typescript-eslint@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3):
dependencies:
'@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.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.64.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.7.0(jiti@2.7.0)
'@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/parser': 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3)
'@typescript-eslint/utils': 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
transitivePeerDependencies:
- supports-color
@@ -5010,11 +5010,11 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0):
vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.5
postcss: 8.5.19
postcss: 8.5.16
rolldown: 1.1.5
tinyglobby: 0.2.17
optionalDependencies:
@@ -32,8 +32,8 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import { useRef } from "react";
import {
searchParamsFromObject,
useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
@@ -70,13 +70,7 @@ export const QuickActions = () => {
const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const compiledParams = recompileScreenParams(screenParams);
const [isOpen, setIsOpen] = useState(false);
+2 -6
View File
@@ -1,11 +1,10 @@
type UseLoginForProps = {
login_for?: "oidc" | "app";
params: URLSearchParams;
compiledParams: string;
};
export const useLoginFor = (props: UseLoginForProps): string => {
const { login_for, params } = props;
const compiledParams = params.toString() ? "?" + params.toString() : "";
const { login_for, compiledParams } = props;
switch (login_for) {
case "oidc":
@@ -13,9 +12,6 @@ export const useLoginFor = (props: UseLoginForProps): string => {
case "app":
return "/continue" + compiledParams;
default:
if (params.get("redirect_uri")) {
return "/continue" + compiledParams
}
return "/logout";
}
};
+19 -19
View File
@@ -75,21 +75,18 @@ export const useRedirectUri = (
};
};
// https://www.geeksforgeeks.org/javascript/how-to-check-if-a-string-is-a-valid-ip-address-format-in-javascript
const isIP = (str: string): boolean => {
const ipv4 =
/^(\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("[");
}
const trimPeriod = (str: string): string => {
if(str.lastIndexOf('.') === (str.length - 1)){
str = str.substring(0, str.length - 1);
// ported from internal/controller/oauth_controller.go
const getEffectivePort = (url: URL): string => {
if (url.port) {
return url.port;
}
return str
}
if (url.protocol == "https:") {
return "443";
}
return "80";
};
export const isTrustedDomain = (
url: URL,
@@ -97,15 +94,15 @@ export const isTrustedDomain = (
cookieDomain: string,
subdomainsEnabled: boolean,
): boolean => {
if (isIP(url.hostname)) {
if (url.protocol != appUrl.protocol) {
return false;
}
if (url.port != appUrl.port) {
if (getEffectivePort(url) != getEffectivePort(appUrl)) {
return false;
}
if (trimPeriod(url.hostname) == trimPeriod(appUrl.hostname)) {
if (url.hostname == appUrl.hostname) {
return true;
}
@@ -113,6 +110,9 @@ export const isTrustedDomain = (
return false;
}
return trimPeriod(url.hostname).endsWith("." + cookieDomain.toLowerCase())
|| trimPeriod(url.hostname) == cookieDomain.toLowerCase();
if (url.hostname.endsWith("." + cookieDomain.toLowerCase())) {
return true;
}
return false;
};
+10 -4
View File
@@ -27,10 +27,16 @@ export function useScreenParams(params: URLSearchParams): ScreenParams {
return parsed.data;
}
export function searchParamsFromObject(obj: object): URLSearchParams {
return new URLSearchParams(
export function recompileScreenParams(params: ScreenParams): string {
const p = new URLSearchParams(
Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined),
Object.entries(params).filter(([, v]) => v !== undefined),
) as Record<string, string>,
);
).toString();
if (p.length > 0) {
return "?" + p;
}
return "";
}
+44 -44
View File
@@ -15,16 +15,16 @@
"loginOauthSuccessTitle": "Přesměrování",
"loginOauthSuccessSubtitle": "Přesměrování k poskytovateli OAuth",
"loginOauthAutoRedirectTitle": "Automatické přesměrování OAuth",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now",
"loginOauthAutoRedirectSubtitle": "Pro ověření budete automaticky přesměrování na svého poskytovatele OAuth.",
"loginOauthAutoRedirectButton": "Přesměrovat nyní",
"continueTitle": "Pokračovat",
"continueRedirectingTitle": "Přesměrování...",
"continueRedirectingSubtitle": "Brzy budete přesměrováni do aplikace",
"continueRedirectManually": "Redirect me manually",
"continueRedirectManually": "Přesměrovat ručně",
"continueInsecureRedirectTitle": "Nezabezpečené přesměrování",
"continueInsecureRedirectSubtitle": "Pokoušíte se přesměrovat z <code>https</code> na <code>http</code>, které není bezpečné. Opravdu chcete pokračovat?",
"continueUntrustedRedirectTitle": "Untrusted redirect",
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
"continueUntrustedRedirectTitle": "Nedůvěryhodné přesměrování",
"continueUntrustedRedirectSubtitle": "Pokoušíte se přesměrovat na doménu, která neodpovídá vaší nakonfigurované doméně (<code>{{cookieDomain}}</code>). Opravdu chcete pokračovat?",
"logoutFailTitle": "Odhlášení se nezdařilo",
"logoutFailSubtitle": "Zkuste to prosím znovu",
"logoutSuccessTitle": "Odhlášen",
@@ -51,53 +51,53 @@
"forgotPasswordTitle": "Zapomněli jste heslo?",
"failedToFetchProvidersTitle": "Nepodařilo se načíst poskytovatele ověřování. Zkontrolujte prosím konfiguraci.",
"errorTitle": "Došlo k chybě",
"errorSubtitleInfo": "The following error occurred while processing your request:",
"errorSubtitleInfo": "Při zpracování požadavku došlo k následující chybě:",
"errorSubtitle": "Nastala chyba při pokusu o provedení této akce. Pro více informací prosím zkontrolujte konzolu.",
"forgotPasswordMessage": "Heslo můžete obnovit změnou proměnné `USERS`.",
"fieldRequired": "Toto pole je povinné",
"invalidInput": "Neplatný údaj",
"domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
"domainWarningCurrent": "Current:",
"domainWarningExpected": "Expected:",
"ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Go to correct domain",
"authorizeTitle": "Authorize",
"authorizeCardTitle": "Continue to {{app}}?",
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
"authorizeLoadingTitle": "Loading...",
"domainWarningTitle": "Neplatná doména",
"domainWarningSubtitle": "Pro přístup k této instanci používáte špatnou doménu. Při pokračování mohou nastat problémy s autentizací.",
"domainWarningCurrent": "Aktuální:",
"domainWarningExpected": "Vyžadovaná:",
"ignoreTitle": "Ignorovat",
"goToCorrectDomainTitle": "Přejít na správnou doménu",
"authorizeTitle": "Autorizovat",
"authorizeCardTitle": "Pokračovat do {{app}}?",
"authorizeSubtitle": "Chcete pokračovat do této aplikace? Pečlivě si zkontrolujte jí vyžadovaná oprávnění, prosím.",
"authorizeSubtitleOAuth": "Chcete pokračovat do této aplikace?",
"authorizeLoadingTitle": "Načítání...",
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
"authorizeSuccessTitle": "Authorized",
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
"authorizeErrorInvalidParams": "The request is missing required parameters or has invalid parameters. Please check the URL and try again.",
"authorizeSuccessTitle": "Autorizováno",
"authorizeSuccessSubtitle": "Během několika vteřin budete přesměrováni do aplikace.",
"authorizeErrorClientInfo": "Při načítání klientských informací došlo k chybě. Zkuste to prosím později.",
"authorizeErrorInvalidParams": "Parametry požadavku jsou chybné/neplatné. Prosím, zkontrolujte URL a zkuste znovu.",
"openidScopeName": "OpenID Connect",
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
"emailScopeName": "Email",
"emailScopeDescription": "Allows the app to access your email address.",
"profileScopeName": "Profile",
"profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access your group information.",
"backToLoginButton": "Back to login",
"phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Continue with Tailscale",
"openidScopeDescription": "Povolí aplikaci přístup k Vaším informacím OpenID Connect.",
"emailScopeName": "E-mail",
"emailScopeDescription": "Povolí aplikaci přístup k Vaší e-mailové adrese.",
"profileScopeName": "Profil",
"profileScopeDescription": "Povolí aplikaci přístup k informacím o Vašem profilu.",
"groupsScopeName": "Skupiny",
"groupsScopeDescription": "Povolí aplikaci přístup k informacím o Vaší skupině.",
"backToLoginButton": "Zpět na přihlášení",
"phoneScopeName": "Telefon",
"phoneScopeDescription": "Povolí aplikaci přístup k Vašemu telefonnímu číslu.",
"addressScopeName": "Adresa",
"addressScopeDescription": "Povolí aplikaci přístup k Vaší adrese.",
"loginTailscaleTitle": "Pokračovat přes Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"loginTailscaleDeviceName": "Název zařízení:",
"loginTailscaleOtherMethod": "Přihlásit jinou metodou",
"loginTailscaleSuccess": "Úspěšně přihlášeno přes Tailscale.",
"loginTailscaleFail": "Chyba při přihlášení přes Tailscale. Zkuste znovu, případně se přihlašte jiným způsobem, prosím.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.",
"quickActionsLanguage": "Language",
"quickActionsTheme": "Theme",
"quickActionsThemeLight": "Light",
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsLanguage": "Jazyk",
"quickActionsTheme": "Motiv",
"quickActionsThemeLight": "Světlý",
"quickActionsThemeDark": "Tmavý",
"quickActionsThemeSystem": "Systémový",
"quickActionsLogout": "Odhlásit se",
"quickActionsTitle": "Quick Actions",
"quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP",
+2 -8
View File
@@ -22,7 +22,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
searchParamsFromObject,
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useEffect } from "react";
@@ -89,13 +89,7 @@ export const AuthorizePage = () => {
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const isOidc = screenParams.login_for === "oidc";
const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const compiledParams = recompileScreenParams(screenParams);
// TODO: maybe a better way to do this
const shouldAutoAuthorize =
+4 -10
View File
@@ -13,7 +13,7 @@ import { Navigate, useLocation, useNavigate } from "react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRedirectUri } from "@/lib/hooks/redirect-uri";
import {
searchParamsFromObject,
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
@@ -31,14 +31,8 @@ export const ContinuePage = () => {
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const redirectUri = screenParams.redirect_uri;
const isAppLogin = screenParams.login_for === "app" || !screenParams.login_for;
const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const isAppLogin = screenParams.login_for === "app";
const recompiledParams = recompileScreenParams(screenParams);
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
redirectUri,
@@ -95,7 +89,7 @@ export const ContinuePage = () => {
}, [shouldAutoRedirect, redirectToTarget]);
if (!auth.authenticated) {
return <Navigate to={`/login${compiledParams}`} replace />;
return <Navigate to={`/login${recompiledParams}`} replace />;
}
if (!hasValidRedirect || !isAppLogin) {
+3 -9
View File
@@ -12,8 +12,8 @@ import { useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import { useLocation } from "react-router";
import {
searchParamsFromObject,
useScreenParams,
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
export const ForgotPasswordPage = () => {
@@ -22,13 +22,7 @@ export const ForgotPasswordPage = () => {
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const compiledParams = recompileScreenParams(screenParams);
return (
<Card>
+10 -12
View File
@@ -26,7 +26,7 @@ import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router";
import { toast } from "sonner";
import {
searchParamsFromObject,
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for";
@@ -63,21 +63,18 @@ export const LoginPage = () => {
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const compiledParams = recompileScreenParams({
...screenParams,
oidc_prompt: undefined,
});
const loginForUrl = useLoginFor({
login_for: screenParams.login_for,
params: searchParamsFromObject({ ...screenParams, oidc_prompt: undefined}),
compiledParams,
});
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
providers.find((provider) => provider.id === oauth.autoRedirect) !==
undefined && (screenParams.redirect_uri || screenParams.oidc_ticket),
undefined && screenParams.redirect_uri !== undefined,
);
const oauthProviders = providers.filter(
@@ -174,7 +171,8 @@ export const LoginPage = () => {
!auth.authenticated &&
isOauthAutoRedirect &&
!hasAutoRedirectedRef.current &&
(screenParams.redirect_uri || screenParams.oidc_ticket)
screenParams.redirect_uri &&
screenParams.login_for
) {
hasAutoRedirectedRef.current = true;
oauthMutate(oauth.autoRedirect);
@@ -185,8 +183,8 @@ export const LoginPage = () => {
hasAutoRedirectedRef,
oauth.autoRedirect,
isOauthAutoRedirect,
screenParams.login_for,
screenParams.redirect_uri,
screenParams.oidc_ticket
]);
useEffect(() => {
+2 -8
View File
@@ -17,8 +17,8 @@ import { type UseMutationResult } from "@tanstack/react-query";
import { type AxiosResponse } from "axios";
import { useLocation } from "react-router";
import {
searchParamsFromObject,
useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params";
export const LogoutPage = () => {
@@ -29,13 +29,7 @@ export const LogoutPage = () => {
const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const compiledParams = recompileScreenParams(screenParams);
const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/user/logout"),
+3 -9
View File
@@ -17,7 +17,7 @@ import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router";
import { toast } from "sonner";
import {
searchParamsFromObject,
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for";
@@ -32,16 +32,10 @@ export const TotpPage = () => {
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = (() => {
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const compiledParams = recompileScreenParams(screenParams);
const loginForUrl = useLoginFor({
login_for: screenParams.login_for,
params: searchParamsFromObject(screenParams),
compiledParams,
});
const totpMutation = useMutation({
+1 -4
View File
@@ -46,14 +46,11 @@ func generateExampleEnv() {
func buildEnvEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]EnvEntry) {
desc := child.Tag.Get("description")
tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" {
if tag == "-" {
return
}
tag = strings.TrimSuffix(tag, ",omitempty")
value := childValue.Interface()
entry := EnvEntry{
+19 -63
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"errors"
"fmt"
"io/fs"
@@ -12,13 +13,9 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model"
)
type ConfigOptions struct {
Env string
Flag string
YAML string
}
type MarkdownEntry struct {
Options ConfigOptions
Env string
Flag string
Description string
Default any
}
@@ -50,27 +47,16 @@ func generateMarkdown() {
func buildMdEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]MarkdownEntry) {
desc := child.Tag.Get("description")
tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" {
if tag == "-" {
return
}
tag = strings.TrimSuffix(tag, ",omitempty")
value := childValue.Interface()
configOptions := ConfigOptions{
Env: strings.ToUpper(strings.ReplaceAll(parentPath, ".", "_")) + strings.ToUpper(child.Name),
Flag: fmt.Sprintf("--%s%s", strings.TrimPrefix(parentPath, "tinyauth."), strings.ToLower(child.Name)),
}
if tag != "-" && tag != "" {
configOptions.YAML = strings.TrimPrefix(parentPath, "tinyauth.") + tag
}
entry := MarkdownEntry{
Options: configOptions,
Env: strings.ToUpper(strings.ReplaceAll(parentPath, ".", "_")) + strings.ToUpper(child.Name),
Flag: fmt.Sprintf("--%s%s", strings.TrimPrefix(parentPath, "tinyauth."), strings.ToLower(child.Name)),
Description: desc,
}
@@ -97,14 +83,11 @@ func buildMdMapEntry(child reflect.StructField, parentPath string, entries *[]Ma
}
tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" {
if tag == "-" {
return
}
tag = strings.TrimSuffix(tag, ",omitempty")
mapPath := parentPath + tag + ".[name]."
valueType := fieldType.Elem()
@@ -119,54 +102,27 @@ func buildMdChildPath(parent string, child string) string {
}
func compileMd(entries []MarkdownEntry) []byte {
buffer := strings.Builder{}
buffer := bytes.Buffer{}
buffer.WriteString("{/* This part is automatically generated by gen/gen_md.go in the main repository. DO NOT EDIT. */}\n\n")
buffer.WriteString("import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n")
buffer.WriteString("<Tabs>\n")
buffer.WriteString("<!--- This file is automatically generated by gen/gen_md.go. Do not edit manually. --->\n\n")
buffer.WriteString("# Tinyauth configuration reference\n\n")
buffer.WriteString("| Environment | Flag | Description | Default |\n")
buffer.WriteString("| - | - | - | - |\n")
renderTabItem(&buffer, entries, "Environment")
renderTabItem(&buffer, entries, "Flags")
renderTabItem(&buffer, entries, "YAML")
buffer.WriteString("</Tabs>\n")
return []byte(buffer.String())
}
func renderTabItem(buffer *strings.Builder, entries []MarkdownEntry, section string) {
buffer.WriteString(fmt.Sprintf(" <TabItem label=\"%s\">\n", section))
buffer.WriteString(" ### main\n\n")
buffer.WriteString(" | Option | Description | Default |\n")
buffer.WriteString(" | - | - | - |\n")
configType := strings.ToLower(section)
previousSection := ""
for _, entry := range entries {
section := strings.Split(entry.Options.YAML, ".")[0]
var option string
switch configType {
case "yaml":
option = entry.Options.YAML
case "flags":
option = entry.Options.Flag
case "environment":
option = entry.Options.Env
}
if option == "" {
continue
}
if strings.Count(entry.Options.YAML, ".") >= 1 {
if strings.Count(entry.Env, "_") > 1 {
section := strings.Split(strings.TrimPrefix(entry.Env, "TINYAUTH_"), "_")[0]
if section != previousSection {
buffer.WriteString("\n ### " + strings.ToLower(section) + "\n\n")
buffer.WriteString(" | Option | Description | Default |\n")
buffer.WriteString(" | - | - | - |\n")
buffer.WriteString("\n## " + strings.ToLower(section) + "\n\n")
buffer.WriteString("| Environment | Flag | Description | Default |\n")
buffer.WriteString("| - | - | - | - |\n")
previousSection = section
}
}
fmt.Fprintf(buffer, " | `%s` | %s | %s |\n", option, entry.Description, entry.Default)
fmt.Fprintf(&buffer, "| `%s` | `%s` | %s | %s |\n", entry.Env, entry.Flag, entry.Description, entry.Default)
}
buffer.WriteString(" </TabItem>\n")
return buffer.Bytes()
}
+5 -5
View File
@@ -9,7 +9,7 @@ require (
github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.12.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-ldap/ldap/v3 v3.4.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/go-querystring v1.2.0
@@ -24,13 +24,12 @@ require (
github.com/weppos/publicsuffix-go v0.50.3
go.uber.org/dig v1.19.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/tools v0.48.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.36.2
k8s.io/client-go v0.36.2
modernc.org/sqlite v1.54.0
modernc.org/sqlite v1.53.0
)
require (
@@ -73,7 +72,7 @@ require (
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonreference v0.20.4 // indirect
@@ -136,6 +135,7 @@ require (
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // 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/sys v0.47.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/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect
+14 -14
View File
@@ -113,12 +113,12 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
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-0.20250403174932-29230038a667/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/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
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/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/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
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/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
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/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+7 -9
View File
@@ -88,13 +88,17 @@ func (app *BootstrapApp) Setup() error {
app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version)
// get app url
appURL, err := utils.SafeParseAppURL(app.config.AppURL)
if 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 {
return fmt.Errorf("failed to parse app url: %w", err)
}
app.runtime.AppURL = appURL
app.runtime.AppURL = strings.ToLower(appUrl.Scheme + "://" + appUrl.Host)
// validate session config
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
@@ -168,13 +172,7 @@ func (app *BootstrapApp) Setup() error {
app.runtime.CookieDomain = cookieDomain
// cookie names
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())
app.runtime.UUID = utils.GenerateUUID(appUrl.Hostname())
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
-10
View File
@@ -30,16 +30,6 @@ func (app *BootstrapApp) setupRouter() error {
if err != nil {
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{
+4 -8
View File
@@ -85,8 +85,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
err := app.dig.Provide(service.NewKubernetesService)
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to provide kubernetes service")
return nil, nil
return nil, fmt.Errorf("failed to provide kubernetes service: %w", err)
}
err = app.dig.Invoke(func(k *service.KubernetesService) error {
@@ -95,8 +94,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
})
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to invoke kubernetes service")
return nil, nil
return nil, fmt.Errorf("failed to invoke kubernetes service: %w", err)
}
// Kubernetes will fail to initialize with an error if it cannot connect to the cluster
@@ -116,8 +114,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
err := app.dig.Provide(service.NewDockerService)
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to provide docker service")
return nil, nil
return nil, fmt.Errorf("failed to provide docker service: %w", err)
}
err = app.dig.Invoke(func(d *service.DockerService) error {
@@ -126,8 +123,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
})
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to invoke docker service")
return nil, nil
return nil, fmt.Errorf("failed to invoke docker service: %w", err)
}
if app.services.dockerService == nil {
+63 -91
View File
@@ -1,9 +1,9 @@
package controller
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -12,7 +12,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"
"github.com/gin-gonic/gin"
@@ -220,16 +219,35 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return
}
oauthUserInfo := controller.createOAuthUserInfo(oauthUserInfo{
Username: user.PreferredUsername,
Email: user.Email,
Name: user.Name,
})
var name string
if strings.TrimSpace(user.Name) != "" {
controller.log.App.Debug().Msg("Using name from OAuth provider")
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{
Username: oauthUserInfo.Username,
Name: oauthUserInfo.Name,
Email: oauthUserInfo.Email,
Username: username,
Name: name,
Email: user.Email,
Provider: svc.ID(),
OAuthGroups: utils.CoalesceToString(user.Groups),
OAuthName: svc.Name(),
@@ -293,102 +311,56 @@ func (controller *OAuthController) getCookieDomain() string {
}
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{
WithPort: true,
})
_, err := v.SafeHostname(controller.runtime.AppURL)
u, err := url.Parse(redirectURI)
if err != nil {
controller.log.App.Error().Err(err).Msg("App URL is invalid, cannot validate redirect URI")
controller.log.App.Error().Err(err).Msg("Failed to parse redirect URI")
return false
}
err = v.Validate(redirectURI, controller.runtime.AppURL)
if u.Scheme == "" || u.Host == "" {
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
return false
}
if err == nil {
au, err := url.Parse(controller.runtime.AppURL)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
return false
}
if u.Scheme != au.Scheme {
controller.log.App.Warn().Msg("Redirect URI scheme does not match app URL scheme")
return false
}
getEffectivePort := func(u *url.URL) string {
if u.Port() != "" {
return u.Port()
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
if getEffectivePort(u) != getEffectivePort(au) {
controller.log.App.Warn().Msg("Redirect URI port does not match app URL port")
return false
}
if strings.EqualFold(u.Hostname(), au.Hostname()) {
return true
}
controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI")
if !errors.Is(err, validators.ErrHostnameMismatch) {
return false
}
if !controller.config.Auth.SubdomainsEnabled {
return false
}
v = validators.NewDomainValidator(validators.DomainValidatorOptions{})
hostname, err := v.SafeHostname(redirectURI)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get safe hostname from redirect URI")
return false
}
if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) ||
hostname == controller.runtime.CookieDomain {
if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
return true
}
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
}
+25 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
func TestOAuthController_isRedirectSafe(t *testing.T) {
func TestOAuthControllerIsRedirectSafe(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
@@ -81,6 +81,22 @@ func TestOAuthController_isRedirectSafe(t *testing.T) {
redirectURI: "https://sub.example.com",
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",
appURL: "https://tinyauth.example.com",
@@ -105,6 +121,14 @@ func TestOAuthController_isRedirectSafe(t *testing.T) {
redirectURI: "https:/malicious",
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",
appURL: "https://tinyauth.example.com",
+29 -34
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
@@ -344,31 +343,27 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
// Create the authorization code
code := controller.oidc.CreateCode(*authorizeReq, *userContext)
cu, err := url.Parse(authorizeReq.RedirectURI)
queries, err := query.Values(AuthorizeCallback{
Code: code,
State: authorizeReq.State,
})
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to parse redirect URI",
reasonPublic: "Failed to parse redirect URI",
json: true,
err: err,
reason: "Failed to build query",
reasonPublic: "Failed to build query",
callback: authorizeReq.RedirectURI,
callbackError: "server_error",
state: authorizeReq.State,
json: true,
})
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{
"status": 200,
"redirect_uri": cu.String(),
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()),
})
}
@@ -644,37 +639,37 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error")
if params.callback != "" {
cu, err := url.Parse(params.callback)
errorQueries := CallbackError{
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 {
controller.log.App.Error().Err(err).Msg("Failed to parse callback URL")
controller.log.App.Error().Err(err).Msg("Failed to build callback error query")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
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()
redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode())
if params.json {
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": cu.String(),
"redirect_uri": redirectUrl,
})
return
}
c.Redirect(http.StatusFound, cu.String())
c.Redirect(http.StatusFound, redirectUrl)
return
}
+9 -34
View File
@@ -9,14 +9,14 @@ import (
"regexp"
"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/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
"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
@@ -111,10 +111,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP()
aclsCtx := &service.ACLContext{
ACLs: acls,
IP: net.ParseIP(clientIP),
Path: proxyCtx.Path,
TrustedProxiesConfigured: controller.runtime.TrustedProxiesConfigured,
ACLs: acls,
IP: net.ParseIP(clientIP),
Path: proxyCtx.Path,
}
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
@@ -345,19 +344,6 @@ func (controller *ProxyController) getHeader(c *gin.Context, header string) (str
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 {
// If it's nginx we need non-browser response
if proxyCtx.ProxyType == Nginx {
@@ -403,11 +389,6 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC
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")
if !ok {
@@ -421,7 +402,7 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC
return ProxyContext{
Host: host,
Proto: proto,
Path: path,
Path: uri,
Method: method,
Type: ForwardAuth,
}, nil
@@ -453,9 +434,6 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC
}
path := url.Path
if path == "" {
path = "/"
}
method := c.Request.Method
return ProxyContext{
@@ -483,10 +461,7 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
}
// We get the path from the query string
path, err := getRequestPath(c.Query("path"))
if err != nil {
return ProxyContext{}, fmt.Errorf("invalid path: %w", err)
}
path := c.Query("path")
// For envoy we need to support every method
method := c.Request.Method
+1 -84
View File
@@ -287,76 +287,6 @@ func TestProxyController(t *testing.T) {
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",
middlewares: []gin.HandlerFunc{},
@@ -367,17 +297,6 @@ func TestProxyController(t *testing.T) {
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",
middlewares: []gin.HandlerFunc{},
@@ -789,7 +708,7 @@ func TestProxyController(t *testing.T) {
Log: log,
})
authService, err := service.NewAuthService(service.AuthServiceInput{
authService := service.NewAuthService(service.AuthServiceInput{
Log: log,
Config: &cfg,
Runtime: &runtime,
@@ -802,8 +721,6 @@ func TestProxyController(t *testing.T) {
PolicyEngine: policyEngine,
})
require.NoError(t, err)
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
router := gin.Default()
+25 -17
View File
@@ -72,12 +72,26 @@ func (controller *UserController) loginHandler(c *gin.Context) {
controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt")
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
if isLocked {
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")
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.JSON(429, gin.H{
"status": 429,
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remaining),
})
return
}
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.auth.RecordLoginAttempt(req.Username, false)
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
c.JSON(401, gin.H{
"status": 401,
@@ -93,24 +107,14 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return
}
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
if isLocked {
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
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-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{
"status": 429,
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remaining),
})
return
}
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.auth.RecordLoginAttempt(req.Username, false)
controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "invalid password")
if search.Type == model.UserLocal {
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{
"status": 401,
"message": "Unauthorized",
@@ -211,7 +215,11 @@ func (controller *UserController) loginHandler(c *gin.Context) {
controller.log.App.Info().Str("username", req.Username).Msg("Login successful")
controller.log.AuditLoginSuccess(req.Username, search.Type.String(), c.ClientIP())
if search.Type == model.UserLocal {
controller.log.AuditLoginSuccess(req.Username, "local", c.ClientIP())
} else {
controller.log.AuditLoginSuccess(req.Username, "ldap", c.ClientIP())
}
controller.auth.RecordLoginAttempt(req.Username, true)
+1 -4
View File
@@ -542,8 +542,7 @@ func TestUserController(t *testing.T) {
Runtime: &runtime,
Ctx: ctx,
})
authService, err := service.NewAuthService(service.AuthServiceInput{
authService := service.NewAuthService(service.AuthServiceInput{
Log: log,
Config: &cfg,
Runtime: &runtime,
@@ -556,8 +555,6 @@ func TestUserController(t *testing.T) {
PolicyEngine: policyEngine,
})
require.NoError(t, err)
beforeEach := func() {
// Clear failed login attempts before each test
authService.ClearLoginAttempts()
+6 -16
View File
@@ -2,7 +2,6 @@ package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
@@ -40,7 +39,6 @@ var (
type ContextMiddleware struct {
log *logger.Logger
runtime *model.RuntimeConfig
config *model.Config
auth *service.AuthService
broker *service.OAuthBrokerService
tailscale *service.TailscaleService
@@ -51,7 +49,6 @@ type ContextMiddlewareInput struct {
Log *logger.Logger
RuntimeConfig *model.RuntimeConfig
StaticConfig *model.Config
AuthService *service.AuthService
BrokerService *service.OAuthBrokerService
TailscaleService *service.TailscaleService
@@ -61,7 +58,6 @@ func NewContextMiddleware(i ContextMiddlewareInput) *ContextMiddleware {
return &ContextMiddleware{
log: i.Log,
runtime: i.RuntimeConfig,
config: i.StaticConfig,
auth: i.AuthService,
broker: i.BrokerService,
tailscale: i.TailscaleService,
@@ -248,9 +244,6 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
search, err := m.auth.SearchUser(username)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
m.auth.DummyPasswordCheck(password)
}
return nil, nil, fmt.Errorf("error searching for user: %w", err)
}
@@ -285,7 +278,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
}
userContext.Provider = model.ProviderLocal
case model.UserLDAP:
user, err := m.auth.GetLDAPUser(search.Username)
user, err := m.auth.GetLDAPUser(username)
if err != nil {
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
@@ -335,19 +328,16 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext,
return nil, nil
}
username := strings.Replace(whois.LoginName, "@", "_", 1)
uctx := model.TailscaleContext{
BaseContext: model.BaseContext{
Email: whois.LoginName,
Name: whois.DisplayName,
Username: username,
Email: whois.LoginName,
Name: whois.DisplayName,
},
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
}
@@ -264,8 +264,7 @@ func TestContextMiddleware(t *testing.T) {
Runtime: &runtime,
Ctx: ctx,
})
authService, err := service.NewAuthService(service.AuthServiceInput{
authService := service.NewAuthService(service.AuthServiceInput{
Log: log,
Config: &cfg,
Runtime: &runtime,
@@ -278,8 +277,6 @@ func TestContextMiddleware(t *testing.T) {
PolicyEngine: policyEngine,
})
require.NoError(t, err)
contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{
Log: log,
RuntimeConfig: &runtime,
+19 -20
View File
@@ -49,6 +49,7 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
ACLs: ACLsConfig{
Policy: "allow",
},
LockdownEnabled: true,
},
UI: UIConfig{
Title: "Tinyauth",
@@ -101,23 +102,22 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
}
type Config struct {
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"`
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"`
Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"`
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
// enable the cli warning on experimental features
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"`
Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"`
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"`
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
ConfigFile string `description:"Path to config file." yaml:"-"`
}
type DatabaseConfig struct {
@@ -151,6 +151,7 @@ type AuthConfig struct {
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,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"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"`
}
@@ -238,9 +239,7 @@ type LogStreamConfig struct {
Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"`
}
type ExperimentalConfig struct {
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
}
type ExperimentalConfig struct{}
type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"`
+11 -12
View File
@@ -1,18 +1,17 @@
package model
type RuntimeConfig struct {
AppURL string
UUID string
CookieDomain string
SessionCookieName string
CSRFCookieName string
RedirectCookieName string
OAuthSessionCookieName string
LocalUsers []LocalUser
OAuthProviders map[string]OAuthServiceConfig
OAuthWhitelist []string
ConfiguredProviders []Provider
TrustedProxiesConfigured bool
AppURL string
UUID string
CookieDomain string
SessionCookieName string
CSRFCookieName string
RedirectCookieName string
OAuthSessionCookieName string
LocalUsers []LocalUser
OAuthProviders map[string]OAuthServiceConfig
OAuthWhitelist []string
ConfiguredProviders []Provider
}
type Provider struct {
-10
View File
@@ -7,16 +7,6 @@ const (
UserLDAP
)
func (t UserSearchType) String() string {
switch t {
case UserLocal:
return "local"
case UserLDAP:
return "ldap"
}
return "unknown"
}
type LDAPUser struct {
DN string
Groups []string
+9 -51
View File
@@ -1,8 +1,6 @@
package service
import (
"errors"
"fmt"
"regexp"
"strings"
@@ -45,10 +43,6 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
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)
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")
return EffectDeny
}
@@ -78,7 +72,7 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
if err != nil {
if errors.Is(err, utils.ErrFilterEmpty) {
if err == utils.ErrFilterEmpty {
return EffectAbstain
}
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
@@ -181,61 +175,33 @@ type AuthEnabledRule struct {
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 {
if ctx.ACLs == nil {
return EffectDeny
}
if ctx.ACLs.Path.Block != "" {
match, err := matchPathRule(ctx.ACLs.Path.Block, ctx.Path)
regex, err := regexp.Compile(ctx.ACLs.Path.Block)
if err != nil {
rule.Log.App.Warn().Err(err).Msg("Invalid path block rule")
rule.Log.App.Error().Err(err).Msg("Failed to compile block regex")
return EffectDeny
}
if !match {
if !regex.MatchString(ctx.Path) {
return EffectAllow
}
}
if ctx.ACLs.Path.Allow != "" {
match, err := matchPathRule(ctx.ACLs.Path.Allow, ctx.Path)
regex, err := regexp.Compile(ctx.ACLs.Path.Allow)
if err != nil {
rule.Log.App.Warn().Err(err).Msg("Invalid path allow rule")
rule.Log.App.Error().Err(err).Msg("Failed to compile allow regex")
return EffectDeny
}
if match {
if regex.MatchString(ctx.Path) {
return EffectAllow
}
}
@@ -249,10 +215,6 @@ type IPAllowedRule struct {
}
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
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
@@ -301,10 +263,6 @@ type IPBypassedRule struct {
}
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
if !ctx.TrustedProxiesConfigured {
return EffectDeny
}
// merge global and per-app bypass lists
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
if ctx.ACLs != nil {
+52 -123
View File
@@ -44,21 +44,6 @@ func TestUserAllowedRule(t *testing.T) {
},
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",
ctx: &ACLContext{
@@ -527,82 +512,52 @@ func TestAuthEnabledRule(t *testing.T) {
expected: EffectDeny,
},
{
name: "allows when path starts with allow path",
name: "allows when path does not match block regex",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "/public"},
Path: model.AppPath{Block: "^/admin"},
},
Path: "/publicity",
Path: "/public",
},
expected: EffectAllow,
},
{
name: "allows when path matches a comma-separated allow path",
name: "denies when path matches block regex and no allow regex",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "/bar,/foo/bar,/hello"},
Path: model.AppPath{Block: "^/admin"},
},
Path: "/foo/bar/baz",
Path: "/admin/users",
},
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,
expected: EffectDeny,
},
{
name: "allows when path matches allow regex",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "/^/public-[0-9]+$/"},
Path: model.AppPath{Allow: "^/public"},
},
Path: "/public-42",
Path: "/public/index",
},
expected: EffectAllow,
},
{
name: "denies when comma-separated allow paths do not match",
name: "denies when path does not match allow regex",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "/bar,/foo/bar,/hello"},
Path: model.AppPath{Allow: "^/public"},
},
Path: "/private",
},
expected: EffectDeny,
},
{
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",
name: "allows when blocked path is also explicitly allowed",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{
Block: "/admin",
Allow: "/admin/public",
Block: "^/admin",
Allow: "^/admin/public",
},
},
Path: "/admin/public/page",
@@ -610,10 +565,20 @@ func TestAuthEnabledRule(t *testing.T) {
expected: EffectAllow,
},
{
name: "denies when root is blocked",
name: "denies when block regex fails to compile",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Block: "/"},
Path: model.AppPath{Block: "[invalid"},
},
Path: "/anything",
},
expected: EffectDeny,
},
{
name: "denies when allow regex fails to compile",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "[invalid"},
},
Path: "/anything",
},
@@ -646,20 +611,11 @@ func TestIPAllowedRule(t *testing.T) {
ctx *ACLContext
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",
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
@@ -669,8 +625,7 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{
IP: model.AppIP{Block: []string{"10.0.0.1"}},
},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
@@ -682,9 +637,8 @@ func TestIPAllowedRule(t *testing.T) {
},
},
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectDeny,
},
@@ -694,8 +648,7 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
},
IP: net.ParseIP("192.168.1.10"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("192.168.1.10"),
},
expected: EffectAllow,
},
@@ -707,9 +660,8 @@ func TestIPAllowedRule(t *testing.T) {
},
},
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("192.168.1.10"),
TrustedProxiesConfigured: true,
ACLs: &model.App{},
IP: net.ParseIP("192.168.1.10"),
},
expected: EffectAllow,
},
@@ -719,17 +671,15 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "allows when no block or allow lists are configured",
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
@@ -742,8 +692,7 @@ func TestIPAllowedRule(t *testing.T) {
Allow: []string{"10.0.0.1"},
},
},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
@@ -756,8 +705,7 @@ func TestIPAllowedRule(t *testing.T) {
Allow: []string{"10.0.0.1"},
},
},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
@@ -787,23 +735,12 @@ func TestIPBypassedRule(t *testing.T) {
ctx *ACLContext
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",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
@@ -811,9 +748,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "allows when ACLs are nil but IP matches global bypass",
rule: globBypassIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
ACLs: nil,
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
@@ -821,9 +757,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "denies when ACLs are nil and IP does not match global bypass",
rule: globBypassIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("192.168.1.1"),
TrustedProxiesConfigured: true,
ACLs: nil,
IP: net.ParseIP("192.168.1.1"),
},
expected: EffectDeny,
},
@@ -834,8 +769,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
},
IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
@@ -846,8 +780,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
},
IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
@@ -858,8 +791,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
},
IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
@@ -870,8 +802,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
},
IP: net.ParseIP("192.168.1.1"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("192.168.1.1"),
},
expected: EffectDeny,
},
@@ -879,9 +810,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "denies when bypass list is empty",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
@@ -892,8 +822,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
+14 -63
View File
@@ -1,17 +1,15 @@
package service
import (
"errors"
"strings"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"
)
type LabelProvider interface {
Lookup(locator func(name string, app *model.App) bool) error
GetLabels(appDomain string) (*model.App, error)
}
type AccessControlsService struct {
@@ -37,74 +35,27 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
}
}
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
var domainMatch *model.App
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
var nameMatch *model.App
var nameMatchedApps []string
locatorFunc := func(name string, app *model.App) bool {
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")
}
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
for app, config := range service.config.Apps {
if config.Config.Domain == domain {
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
return &config
}
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
nameMatch = app
nameMatchedApps = append(nameMatchedApps, name)
if strings.SplitN(domain, ".", 2)[0] == app {
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = &config
}
return false
}
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
})
return nameMatch
}
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config
app, err := service.lookupStaticACLs(domain)
// Will never return an error here, but we need to check it
if err != nil {
return nil, err
}
app := service.lookupStaticACLs(domain)
if app != nil {
service.log.App.Debug().Msg("Using static ACLs for app")
@@ -113,9 +64,9 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A
// If we have a label provider configured, try to get ACLs from it
if service.labelProvider != nil {
return service.getACLs(domain, service.labelProvider.Lookup)
return service.labelProvider.GetLabels(domain)
}
// No labels
// no labels
return nil, nil
}
+170 -130
View File
@@ -4,184 +4,224 @@ import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
type mockProvider struct {
acls map[string]model.App
shouldError bool
type mockLabelProvider struct {
getLabelsFn func(appDomain string) (*model.App, error)
calledWith string
callCount int
}
func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider {
return &mockProvider{acls: acls, shouldError: shouldError}
}
func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error {
if m.shouldError {
return errors.New("mock error")
func (m *mockLabelProvider) GetLabels(appDomain string) (*model.App, error) {
m.calledWith = appDomain
m.callCount++
if m.getLabelsFn != nil {
return m.getLabelsFn(appDomain)
}
for name, app := range m.acls {
if ok := locator(name, &app); ok {
return nil
}
}
return nil
return nil, nil
}
func TestAccessControlsService(t *testing.T) {
func TestLookupStaticACLs(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
tests := []struct {
name string
domain string
acls map[string]model.App
want *model.App
name string
apps map[string]model.App
domain string
expectNil bool
expectedDomain string
}{
{
name: "returns ACLs for domain",
domain: "example.com",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
name: "returns nil when no apps are configured",
apps: nil,
domain: "foo.example.com",
expectNil: true,
},
{
name: "returns ACLs for domain with port",
domain: "example.com:8080",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "example.com"}},
name: "returns nil when no app matches",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
domain: "bar.example.com",
expectNil: true,
},
{
name: "returns ACLs for domain with trailing dot",
domain: "example.com.",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "example.com"}},
name: "matches by exact domain",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
domain: "foo.example.com",
expectedDomain: "foo.example.com",
},
{
name: "returns ACLs for non-ascii domain",
domain: "bücher.example.com",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "bücher.example.com"}},
name: "matches by app name when domain does not match any app",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "bücher.example.com"}},
domain: "foo.example.com",
expectedDomain: "configured.example.com",
},
{
name: "returns ACLs for punycode domain and non-ascii config",
domain: "bücher.example.com",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
name: "matches by app name for nested subdomains",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
domain: "foo.sub.example.com",
expectedDomain: "configured.example.com",
},
{
name: "returns ACLs with case-insensitive matching",
domain: "Example.com",
acls: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "example.com"}},
name: "selects the app matching by domain among multiple apps",
apps: map[string]model.App{
"unrelated": {Config: model.AppConfig{Domain: "other.example.com"}},
"target": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
want: &model.App{Config: model.AppConfig{Domain: "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,
domain: "foo.example.com",
expectedDomain: "foo.example.com",
},
}
// run once for a mock provider
for _, test := range tests {
t.Run(test.name+"(getACLs)", func(t *testing.T) {
mock := newMockProvider(test.acls, false)
acls := NewAccessControlsService(AccessControlServiceInput{
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
LabelProvider: mock,
Config: &model.Config{Apps: tt.apps},
LabelProvider: nil,
})
app, err := acls.getACLs(test.domain, mock.Lookup)
require.NoError(t, err)
require.Equal(t, test.want, app)
got := svc.lookupStaticACLs(tt.domain)
if tt.expectNil {
assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, tt.expectedDomain, got.Config.Domain)
})
}
}
// run again for static acls
for _, test := range tests {
t.Run(test.name+"(staticACLs)", func(t *testing.T) {
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{
Apps: test.acls,
func TestGetAccessControls(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
t.Run("returns static ACLs when domain matches", func(t *testing.T) {
config := model.Config{
Apps: map[string]model.App{
"foo": {
Config: model.AppConfig{Domain: "foo.example.com"},
Users: model.AppUsers{Allow: "alice"},
},
})
app, err := acls.lookupStaticACLs(test.domain)
require.NoError(t, err)
require.Equal(t, test.want, app)
},
}
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &config,
LabelProvider: nil,
})
}
// get acls should return an error when the provider fails
mock := newMockProvider(map[string]model.App{}, true)
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
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, "alice", got.Users.Allow)
})
_, err := acls.getACLs("example.com", mock.Lookup)
require.Error(t, err)
// get access controls should get acls from
// static when static acls are configured
acls = NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{
t.Run("returns nil when no static match and no label provider", func(t *testing.T) {
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
LabelProvider: nil,
})
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{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
},
})
app, err := acls.GetAccessControls("foo.example.com")
require.NoError(t, err)
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app)
}
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &config,
LabelProvider: provider,
})
// should return nil for no apps
app, err = acls.GetAccessControls("bar.example.com")
require.NoError(t, err)
require.Nil(t, app)
got, err := svc.GetAccessControls("foo.example.com")
// Should use label provider if available
mock = newMockProvider(map[string]model.App{
"bar": {
Config: model.AppConfig{Domain: "bar.example.com"},
},
}, false)
acls = NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
LabelProvider: mock,
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
assert.Equal(t, 0, mock.callCount)
})
t.Run("propagates label provider errors", func(t *testing.T) {
providerErr := errors.New("provider boom")
mock := &mockLabelProvider{
getLabelsFn: func(appDomain string) (*model.App, error) {
return nil, providerErr
},
}
var provider LabelProvider = mock
svc := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Config: &model.Config{},
LabelProvider: provider,
})
got, err := svc.GetAccessControls("dynamic.example.com")
assert.Nil(t, got)
assert.ErrorIs(t, err, providerErr)
assert.Equal(t, 1, mock.callCount)
})
app, err = acls.GetAccessControls("bar.example.com")
require.NoError(t, err)
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app)
}
+95 -41
View File
@@ -8,6 +8,7 @@ import (
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/steveiliop56/ding"
@@ -15,7 +16,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig"
"github.com/google/uuid"
@@ -69,13 +69,21 @@ type AuthService struct {
tailscale *TailscaleService
policyEngine *PolicyEngine
dummyHash string
lockdown struct {
active bool
until time.Time
ctx context.Context
cancelFunc context.CancelFunc
mu sync.RWMutex
}
caches struct {
login *cache.CacheStore[LoginAttempt]
oauth *cache.CacheStore[OAuthPendingSession]
ldap *cache.CacheStore[[]string]
login *CacheStore[LoginAttempt]
oauth *CacheStore[OAuthPendingSession]
ldap *CacheStore[[]string]
}
maxLoginLimits int
}
type AuthServiceInput struct {
@@ -93,7 +101,7 @@ type AuthServiceInput struct {
PolicyEngine *PolicyEngine
}
func NewAuthService(i AuthServiceInput) (*AuthService, error) {
func NewAuthService(i AuthServiceInput) *AuthService {
service := &AuthService{
log: i.Log,
runtime: i.Runtime,
@@ -106,19 +114,19 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) {
policyEngine: i.PolicyEngine,
}
// dummy hash
dummyHash, err := bcrypt.GenerateFromPassword([]byte(utils.GenerateString(8)), bcrypt.DefaultCost)
// get the max login limits based on the number of users and the configured max retries
service.maxLoginLimits = service.calculateLockdownLimit()
if err != nil {
return nil, fmt.Errorf("failed to generate dummy hash: %w", err)
loginCacheSize := 0
if !service.config.Auth.LockdownEnabled {
loginCacheSize = service.maxLoginLimits
}
service.dummyHash = string(dummyHash)
// caches setup
oauthCache := cache.NewCacheStore[OAuthPendingSession](256)
loginCache := cache.NewCacheStore[LoginAttempt](service.calculateLockdownLimit())
ldapCache := cache.NewCacheStore[[]string](1024)
oauthCache := NewCacheStore[OAuthPendingSession](256)
loginCache := NewCacheStore[LoginAttempt](loginCacheSize)
ldapCache := NewCacheStore[[]string](1024)
service.caches.oauth = oauthCache
service.caches.login = loginCache
@@ -140,28 +148,7 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) {
}
}, ding.RingMinor)
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))
return service
}
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
@@ -258,6 +245,10 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
}
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 {
return false, 0
}
@@ -280,7 +271,15 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
return
}
auth.caches.login.WithLock(func(actions cache.CacheStoreActions[LoginAttempt]) {
if !success && auth.config.Auth.LockdownEnabled && auth.caches.login.Size() >= auth.maxLoginLimits {
if locked, _ := auth.IsInLockdown(); locked {
return
}
go auth.lockdownMode()
return
}
auth.caches.login.WithLock(func(actions CacheStoreActions[LoginAttempt]) {
entry, ok := actions.Get(identifier)
if !ok {
@@ -345,7 +344,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")
}
u, err := uuid.NewRandom()
uuid, err := uuid.NewRandom()
if err != nil {
return nil, fmt.Errorf("failed to generate session uuid: %w", err)
@@ -362,7 +361,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
session := repository.CreateSessionParams{
UUID: u.String(),
UUID: uuid.String(),
Username: data.Username,
Email: data.Email,
Name: data.Name,
@@ -617,7 +616,62 @@ func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPending
return &session, nil
}
// ClearLoginAttempts is a testing function, not useful for anything else
func (auth *AuthService) lockdownMode() {
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() {
auth.caches.login.Clear()
}
@@ -1,4 +1,4 @@
package cache
package service
import (
"slices"
@@ -33,8 +33,8 @@ func NewCacheStore[T any](maxSize int) *CacheStore[T] {
}
}
// WithLock allows performing multiple operations on a single lock.
// The provided mutate function receives a set of actions (Set, Get, Delete, Update) that
// With lock allows performing multiple operations on the cache store atomically.
// The provided mutate function receives a set of actions (Set, Get, Delete) that
// can be used to manipulate the cache store within the locked context.
func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) {
cs.mu.Lock()
@@ -195,19 +195,3 @@ func (cs *CacheStore[T]) Clear() {
cs.cache = make(map[string]cacheEntry[T])
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 cache
package service
import (
"strconv"
@@ -316,21 +316,6 @@ func TestCacheStoreSizeAndClear(t *testing.T) {
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) {
cs := NewCacheStore[int](0)
cs.Set("counter", 1, 0)
+24 -12
View File
@@ -2,7 +2,7 @@ package service
import (
"context"
"fmt"
"strings"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
@@ -31,6 +31,7 @@ type DockerServiceInput struct {
}
func NewDockerService(i DockerServiceInput) (*DockerService, error) {
client, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return nil, err
@@ -67,38 +68,49 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
return docker.client.ContainerInspect(docker.context, containerId)
}
func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error {
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
if !docker.isConnected {
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
return nil
return nil, nil
}
containers, err := docker.getContainers()
if err != nil {
return fmt.Errorf("failed to get containers: %w", err)
return nil, err
}
for _, ctr := range containers {
inspect, err := docker.inspectContainer(ctr.ID)
if err != nil {
docker.log.App.Error().Err(err).Msgf("Failed to inspect container %s", ctr.ID)
continue
return nil, err
}
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
if err != nil {
docker.log.App.Warn().Err(err).Msgf("Failed to decode labels for container %s", ctr.ID)
continue
return nil, err
}
for app, config := range labels.Apps {
if ok := locator(app, &config); ok {
return nil
var nameMatch *model.App
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
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
}
}
return nil
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain")
return nil, nil
}
func (docker *DockerService) watchAndClose(ctx context.Context) {
+118 -85
View File
@@ -12,7 +12,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -23,23 +22,31 @@ import (
"k8s.io/client-go/rest"
)
type ingressEntry struct {
name string
app model.App
}
type ingressKey struct {
namespace string
name string
}
type ingressAppKey struct {
ingressKey
appName string
}
type ingressApp struct {
domain string
appName string
app model.App
}
type KubernetesService struct {
log *logger.Logger
client dynamic.Interface
connected bool
mu sync.RWMutex
ingressEntries map[ingressKey][]ingressEntry
client dynamic.Interface
started bool
mu sync.RWMutex
ingressApps map[ingressKey][]ingressApp
domainIndex map[string]ingressAppKey
appNameIndex map[string]ingressAppKey
}
type KubernetesServiceInput struct {
@@ -79,45 +86,90 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
service := &KubernetesService{
log: i.Log,
client: client,
ingressEntries: make(map[ingressKey][]ingressEntry),
log: i.Log,
client: client,
ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
}
i.Ding.Go(func(ctx context.Context) {
service.watchGVR(gvr, ctx)
}, ding.RingMajor)
service.connected = true
service.started = true
i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
return service, nil
}
func (k *KubernetesService) addIngressEntries(key ingressKey, entries []ingressEntry) {
func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) {
k.mu.Lock()
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(key ingressKey) {
func (k *KubernetesService) removeIngress(namespace, name string) {
k.mu.Lock()
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) getEntry(locator func(name string, app *model.App) bool) {
func (k *KubernetesService) getByDomain(domain string) *model.App {
k.mu.RLock()
defer k.mu.RUnlock()
// O(n^2) is not great but the number of ingress entries is expected to be small
for _, entries := range k.ingressEntries {
for _, entry := range entries {
if ok := locator(entry.name, &entry.app); ok {
return
if appKey, ok := k.domainIndex[domain]; ok {
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
for i := range apps {
app := &apps[i]
if app.domain == domain && app.appName == appKey.appName {
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) {
@@ -167,8 +219,7 @@ func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]str
}
paths, err := k.extractPaths(rule)
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")
continue
}
@@ -184,71 +235,44 @@ func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]str
}
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
key := ingressKey{
namespace: item.GetNamespace(),
name: item.GetName(),
}
namespace := item.GetNamespace()
name := item.GetName()
annotations := item.GetAnnotations()
if annotations == nil {
k.removeIngress(key)
k.removeIngress(namespace, name)
return
}
hosts, err := k.extractHosts(item)
if err != nil {
k.removeIngress(key)
k.removeIngress(namespace, name)
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")
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode ingress labels, skipping")
k.removeIngress(key)
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
k.removeIngress(namespace, name)
return
}
var entries []ingressEntry
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
}
var apps []ingressApp
for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == "" {
continue
}
for _, host := range hosts {
if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) {
entries = append(entries, ingressEntry{
name: name,
app: config,
})
break
}
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")
continue
}
apps = append(apps, ingressApp{
domain: appLabels.Config.Domain,
appName: appName,
app: appLabels,
})
}
if len(entries) == 0 {
k.removeIngress(key)
return
if len(apps) == 0 {
k.removeIngress(namespace, name)
} else {
k.addIngressApps(namespace, name, apps)
}
k.addIngressEntries(key, entries)
}
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
@@ -291,10 +315,7 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
case watch.Added, watch.Modified:
k.updateFromItem(item)
case watch.Deleted:
k.removeIngress(ingressKey{
namespace: item.GetNamespace(),
name: item.GetName(),
})
k.removeIngress(item.GetNamespace(), item.GetName())
}
case <-resyncTicker.C:
if err := k.resyncGVR(gvr, ctx); err != nil {
@@ -341,13 +362,25 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx contex
}
}
func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error {
if !k.connected {
k.log.App.Debug().Msg("Kubernetes label provider not started, skipping")
return nil
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
if !k.started {
k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping")
return nil, nil
}
k.getEntry(locator)
// First check cache
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
}
return nil
k.log.App.Debug().Str("domain", appDomain).Msg("No labels found for domain")
return nil, nil
}
+59 -547
View File
@@ -1,7 +1,6 @@
package service
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -26,66 +25,47 @@ func TestKubernetesService(t *testing.T) {
description: "Cache by domain returns app and misses unknown domain",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "foo.example.com", appName: "foo", app: app},
})
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
})
got := svc.getByDomain("foo.example.com")
require.NotNil(t, got)
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",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
app := model.App{Config: model.AppConfig{Domain: "baz.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "baz.example.com", appName: "baz", app: app},
})
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)
svc.removeIngress("default", "my-ingress")
got = nil
svc.removeIngress(ingressKey{
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
})
got := svc.getByDomain("baz.example.com")
assert.Nil(t, got)
got = svc.getByAppName("baz")
assert.Nil(t, got)
},
},
@@ -93,130 +73,67 @@ func TestKubernetesService(t *testing.T) {
description: "AddIngressApps replaces stale entries for the same ingress",
run: func(t *testing.T, svc *KubernetesService) {
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: old,
name: "foo",
},
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "old.example.com", appName: "old", app: old},
})
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: updated,
name: "foo",
},
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "new.example.com", appName: "new", app: updated},
})
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
})
got := svc.getByDomain("old.example.com")
assert.Nil(t, got)
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "new.example.com" {
got = app
return true
}
return false
})
got = svc.getByDomain("new.example.com")
require.NotNil(t, got)
assert.Equal(t, "new.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns app from cache when connected",
description: "GetLabels returns app from cache when started",
run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true
svc.started = true
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
svc.addIngressApps("default", "ing", []ingressApp{
{domain: "hit.example.com", appName: "hit", app: app},
})
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
})
got, err := svc.GetLabels("hit.example.com")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "hit.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns empty app on cache miss when started",
run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true
svc.started = true
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
})
got, err := svc.GetLabels("notfound.example.com")
require.NoError(t, err)
require.Nil(t, got)
assert.Nil(t, got)
},
},
{
description: "GetLabels resolves app by app name",
run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true
svc.started = true
app := model.App{Path: model.AppPath{Allow: "/foo"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
app := model.App{Config: model.AppConfig{Domain: "myapp.internal.example.com"}}
svc.addIngressApps("default", "ing", []ingressApp{
{domain: "myapp.internal.example.com", appName: "myapp", app: app},
})
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
})
got, err := svc.GetLabels("myapp.internal.example.com")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "/foo", got.Path.Allow)
assert.Equal(t, "myapp.internal.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns empty app when service not yet started",
run: func(t *testing.T, svc *KubernetesService) {
var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
return false
})
got, err := svc.GetLabels("anything.example.com")
require.NoError(t, err)
assert.Nil(t, got)
},
@@ -231,437 +148,30 @@ func TestKubernetesService(t *testing.T) {
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.allow": "alice",
})
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
})
got := svc.getByDomain("myapp.example.com")
require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain)
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",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
svc.addIngressApps("default", "test-ingress", []ingressApp{
{domain: "todelete.example.com", appName: "todelete", app: app},
})
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("my-ingress")
item.SetName("test-ingress")
svc.updateFromItem(&item)
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
})
got := svc.getByDomain("todelete.example.com")
assert.Nil(t, got)
},
},
@@ -670,8 +180,10 @@ func TestKubernetesService(t *testing.T) {
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
svc := &KubernetesService{
ingressEntries: make(map[ingressKey][]ingressEntry),
log: log,
ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
log: log,
}
test.run(t, svc)
})
-1
View File
@@ -24,7 +24,6 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: config.Insecure,
MinVersion: tls.VersionTLS12,
+8 -9
View File
@@ -27,7 +27,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig"
)
@@ -159,9 +158,9 @@ type OIDCService struct {
issuer string
caches struct {
code *cache.CacheStore[AuthorizeCodeEntry]
usedCode *cache.CacheStore[UsedCodeEntry]
authorize *cache.CacheStore[AuthorizeRequest]
code *CacheStore[AuthorizeCodeEntry]
usedCode *CacheStore[UsedCodeEntry]
authorize *CacheStore[AuthorizeRequest]
}
}
@@ -340,11 +339,11 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) {
i.Ding.Go(service.cleanupRoutine, ding.RingMinor)
// Create caches
codeCache := cache.NewCacheStore[AuthorizeCodeEntry](256)
usedCode := cache.NewCacheStore[UsedCodeEntry](256)
authorize := cache.NewCacheStore[AuthorizeRequest](256)
codeCash := NewCacheStore[AuthorizeCodeEntry](256)
usedCode := NewCacheStore[UsedCodeEntry](256)
authorize := NewCacheStore[AuthorizeRequest](256)
service.caches.code = codeCache
service.caches.code = codeCash
service.caches.usedCode = usedCode
service.caches.authorize = authorize
@@ -504,7 +503,7 @@ func (service *OIDCService) GetCodeEntry(codeHash string, clientId string) (*Aut
var entry AuthorizeCodeEntry
var ok bool
service.caches.code.WithLock(func(actions cache.CacheStoreActions[AuthorizeCodeEntry]) {
service.caches.code.WithLock(func(actions CacheStoreActions[AuthorizeCodeEntry]) {
entry, ok = actions.Get(codeHash)
if !ok {
+4 -5
View File
@@ -29,11 +29,10 @@ type Rule interface {
}
type ACLContext struct {
ACLs *model.App
UserContext *model.UserContext
IP net.IP
Path string
TrustedProxiesConfigured bool
ACLs *model.App
UserContext *model.UserContext
IP net.IP
Path string
}
type PolicyEngine struct {
+4 -5
View File
@@ -10,7 +10,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig"
)
@@ -60,8 +59,8 @@ type TailscaleService struct {
apiToken string
caches struct {
devices *cache.CacheStore[tailscaleAPIDevices]
users *cache.CacheStore[tailscaleAPIUsers]
devices *CacheStore[tailscaleAPIDevices]
users *CacheStore[tailscaleAPIUsers]
}
urls struct {
@@ -101,8 +100,8 @@ func NewTailscaleService(i TailscaleServiceInput) (*TailscaleService, error) {
apiToken: apiToken,
}
devicesCache := cache.NewCacheStore[tailscaleAPIDevices](0)
usersCache := cache.NewCacheStore[tailscaleAPIUsers](0)
devicesCache := NewCacheStore[tailscaleAPIDevices](0)
usersCache := NewCacheStore[tailscaleAPIUsers](0)
s.caches.devices = devicesCache
s.caches.users = usersCache
+3 -12
View File
@@ -61,14 +61,6 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
Allow: "/allowed",
},
},
"app_path_block": {
Config: model.AppConfig{
Domain: "path-block.example.com",
},
Path: model.AppPath{
Block: "/blocked",
},
},
"app_user_allow": {
Config: model.AppConfig{
Domain: "user-allow.example.com",
@@ -171,10 +163,9 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
},
},
},
CookieDomain: "example.com",
AppURL: "https://tinyauth.example.com",
SessionCookieName: "tinyauth-session",
TrustedProxiesConfigured: true,
CookieDomain: "example.com",
AppURL: "https://tinyauth.example.com",
SessionCookieName: "tinyauth-session",
}
return config, runtime
+5 -46
View File
@@ -7,55 +7,10 @@ import (
"strings"
"github.com/weppos/publicsuffix-go/publicsuffix"
"golang.org/x/net/idna"
)
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.
// 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
// 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.
func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
u, err := url.Parse(appUrl)
@@ -66,6 +21,10 @@ func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
hostname := strings.ToLower(u.Hostname())
if netIP := net.ParseIP(hostname); netIP != nil {
return "", fmt.Errorf("ip addresses not allowed")
}
parts := strings.Split(hostname, ".")
if len(parts) < 2 {
+6 -85
View File
@@ -7,86 +7,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils"
)
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) {
func TestGetRootDomain(t *testing.T) {
// Normal case
domain := "http://sub.tinyauth.app"
expected := "tinyauth.app"
@@ -106,6 +27,11 @@ func TestGetCookieDomain(t *testing.T) {
_, err = utils.GetCookieDomain(domain, true)
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
domain = "http://[::1]:namedport"
_, err = utils.GetCookieDomain(domain, true)
@@ -130,11 +56,6 @@ func TestGetCookieDomain(t *testing.T) {
_, err = utils.GetCookieDomain(domain, true)
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 = "http://tinyauth.app"
expected = "tinyauth.app"
-10
View File
@@ -1,10 +0,0 @@
# 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.
-190
View File
@@ -1,190 +0,0 @@
// Package validators provides validators for various types of data.
//
// Domain validator is a simple utility that ensures two domains are exact
// matches while ensuring that techniques used to bypass such checks do
// not impact the validation.
package validators
import (
"fmt"
"net"
"net/url"
"strings"
"golang.org/x/net/idna"
)
// Errors
var (
ErrSchemeMismatch = fmt.Errorf("scheme mismatch")
ErrPortMismatch = fmt.Errorf("port mismatch")
ErrHostnameMismatch = fmt.Errorf("hostname mismatch")
)
// DomainValidatorOptions is a set of options for DomainValidator.
type DomainValidatorOptions struct {
// Ensure domains have the same scheme.
WithScheme bool
// Ensure domains have the same port.
WithPort bool
// Specify a list of allowed schemes if WithScheme is set to true.
AllowedSchemes []string
}
// DomainValidator is a simple utility that ensures two domains are exact
// matches while ensuring that techniques used to bypass such checks do
// not impact the validation.
type DomainValidator struct {
opts DomainValidatorOptions
}
// NewDomainValidator creates a new DomainValidator.
func NewDomainValidator(opts DomainValidatorOptions) *DomainValidator {
return &DomainValidator{
opts: opts,
}
}
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) {
if i == "" {
return nil, fmt.Errorf("url cannot be empty")
}
if v.opts.WithScheme {
err := v.checkScheme(i)
if err != nil {
return nil, fmt.Errorf("invalid scheme: %w", err)
}
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
}
func (v *DomainValidator) getHostname(hostname string) (string, error) {
hostname = strings.ToLower(hostname)
hostname = strings.TrimSuffix(hostname, ".")
if net.ParseIP(hostname) != nil {
return "", fmt.Errorf("ip addresses are not supported")
}
hostname, err := idna.Lookup.ToASCII(hostname)
if err != nil {
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
}
return hostname, nil
}
// Validate ensures that two domains are exact matches with the
// options defined in the DomainValidatorOptions. It ensures that the
// inputs are proper URLs and contain a host. It lowercases the hostnames
// and removes the trailing dot. Finally, it checks that the hostnames are
// equal unless WithScheme or WithPort is set to true where it also
// validates the scheme and port respectively.
func (v *DomainValidator) Validate(expected, actual string) error {
eu, err := v.getURL(expected)
if err != nil {
return err
}
au, err := v.getURL(actual)
if err != nil {
return err
}
if v.opts.WithScheme {
if eu.Scheme != au.Scheme {
return ErrSchemeMismatch
}
}
if v.opts.WithPort {
if eu.Port() != au.Port() {
return ErrPortMismatch
}
}
euf, err := v.getHostname(eu.Hostname())
if err != nil {
return err
}
auf, err := v.getHostname(au.Hostname())
if err != nil {
return err
}
if euf != auf {
return ErrHostnameMismatch
}
return nil
}
// 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
// is present and that the hostname is lowercased and without a trailing dot.
func (v *DomainValidator) SafeHostname(input string) (string, error) {
u, err := v.getURL(input)
if err != nil {
return "", err
}
return v.getHostname(u.Hostname())
}
-270
View File
@@ -1,270 +0,0 @@
package validators
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDomainValidator_SafeHostname(t *testing.T) {
type testCase struct {
description string
options DomainValidatorOptions
input string
expected string
errorFunc func(t *testing.T, e error)
}
tests := []testCase{
{
description: "Empty url fails",
errorFunc: func(t *testing.T, e error) {
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",
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) {
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",
input: "example.com",
expected: "example.com",
},
{
description: "Domain without scheme should not parse if scheme is enabled",
options: DomainValidatorOptions{WithScheme: true},
input: "example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "invalid scheme")
},
},
{
description: "Domain with scheme and disallowed scheme should fail",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
input: "foo://example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "invalid scheme")
},
},
{
description: "Domain with scheme and allowed scheme should pass",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
input: "https://example.com",
expected: "example.com",
},
{
description: "Domain should get lowercased",
input: "EXAMPLE.COM",
expected: "example.com",
},
{
description: "DNS dot should be removed",
input: "example.com.",
expected: "example.com",
},
{
description: "IPv4 address should fail",
input: "127.0.0.1",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "ip addresses are not supported")
},
},
{
description: "IPv6 address should fail",
input: "[::1]",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "ip addresses are not supported")
},
},
{
description: "Domains with unicode characters should be allowed",
input: "bücher.example.com",
expected: "xn--bcher-kva.example.com",
},
{
description: "Invalid IDNA domain should fail",
input: "ab--cd.example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "invalid label")
},
},
{
description: "With port enabled without any port should work",
options: DomainValidatorOptions{WithPort: true},
input: "example.com",
expected: "example.com",
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
v := NewDomainValidator(test.options)
res, err := v.SafeHostname(test.input)
if test.errorFunc != nil {
test.errorFunc(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, test.expected, res)
})
}
}
func TestDomainValidator_Validate(t *testing.T) {
type testCase struct {
description string
options DomainValidatorOptions
expected string
actual string
errorFunc func(t *testing.T, e error)
}
tests := []testCase{
{
description: "Invalid expected domain fails checks",
expected: "foo:foo",
actual: "bar.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse host:")
},
},
{
description: "Invalid check domain fails checks",
expected: "example.com",
actual: "foo:foo",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse host:")
},
},
{
description: "Valid domains with non-matching schemes should fail",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
expected: "https://example.com",
actual: "http://example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorIs(t, e, ErrSchemeMismatch)
},
},
{
description: "Valid domains with matching schemes should pass",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
expected: "https://example.com",
actual: "https://example.com",
},
{
description: "Port validation with ports enabled and empty ports should work",
options: DomainValidatorOptions{WithPort: true},
expected: "example.com",
actual: "example.com",
},
{
description: "Port validation should pass with same port",
options: DomainValidatorOptions{WithPort: true},
expected: "example.com:8080",
actual: "example.com:8080",
},
{
description: "Port enabled with scheme and matching port should pass",
options: DomainValidatorOptions{WithPort: true},
expected: "https://example.com:443",
actual: "https://example.com:443",
},
{
description: "Failure to format expected domain should fail",
expected: "ab--cd.example.com",
actual: "example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "idna: invalid label")
},
},
{
description: "Failure to format check domain should fail",
expected: "example.com",
actual: "ab--cd.example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "idna: invalid label")
},
},
{
description: "Valid domains with matching schemes and ports should pass",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}, WithPort: true},
expected: "https://example.com:8080",
actual: "https://example.com:8080",
},
{
description: "Valid domains with matching schemes should pass",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
expected: "https://example.com",
actual: "https://example.com",
},
{
description: "Valid domains with non matching ports should fail",
options: DomainValidatorOptions{WithPort: true},
expected: "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",
actual: "example.com",
expected: "example.com",
},
{
description: "Unicode valid domains should pass",
expected: "xn--bcher-kva.example.com",
actual: "bücher.example.com",
},
{
description: "Unicode valid domains should pass (reverse)",
expected: "bücher.example.com",
actual: "xn--bcher-kva.example.com",
},
{
description: "Non matching hostnames should fail",
expected: "example.com",
actual: "foo.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorIs(t, e, ErrHostnameMismatch)
},
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
v := NewDomainValidator(test.options)
err := v.Validate(test.expected, test.actual)
if test.errorFunc != nil {
test.errorFunc(t, err)
return
}
require.NoError(t, err)
})
}
}