Compare commits

..
Author SHA1 Message Date
Stavros 250e6fb8a1 New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-07-09 19:01:11 +03:00
Stavros 056116bf1e New translations en.json (Vietnamese)
[ci skip]
2026-07-09 19:01:10 +03:00
Stavros 2460528500 New translations en.json (Chinese Traditional)
[ci skip]
2026-07-09 19:01:08 +03:00
Stavros a8777f24da New translations en.json (Chinese Simplified)
[ci skip]
2026-07-09 19:01:07 +03:00
Stavros 63ab2c9e3f New translations en.json (Ukrainian)
[ci skip]
2026-07-09 19:01:06 +03:00
Stavros 941b882fc9 New translations en.json (Turkish)
[ci skip]
2026-07-09 19:01:05 +03:00
Stavros 0e7e281429 New translations en.json (Swedish)
[ci skip]
2026-07-09 19:01:03 +03:00
Stavros 181a6cf58c New translations en.json (Serbian (Cyrillic))
[ci skip]
2026-07-09 19:01:02 +03:00
Stavros 928f9b3133 New translations en.json (Russian)
[ci skip]
2026-07-09 19:01:00 +03:00
Stavros 105de0a608 New translations en.json (Portuguese)
[ci skip]
2026-07-09 19:00:59 +03:00
Stavros 9bb1e88492 New translations en.json (Polish)
[ci skip]
2026-07-09 19:00:58 +03:00
Stavros aa86e179bb New translations en.json (Norwegian)
[ci skip]
2026-07-09 19:00:57 +03:00
Stavros 3a435de109 New translations en.json (Dutch)
[ci skip]
2026-07-09 19:00:55 +03:00
Stavros e0b8151f66 New translations en.json (Korean)
[ci skip]
2026-07-09 19:00:54 +03:00
Stavros 0d94c16bff New translations en.json (Japanese)
[ci skip]
2026-07-09 19:00:53 +03:00
Stavros 097efe9d42 New translations en.json (Italian)
[ci skip]
2026-07-09 19:00:52 +03:00
Stavros 8d84b3e6af New translations en.json (Hungarian)
[ci skip]
2026-07-09 19:00:51 +03:00
Stavros c7edf79a6d New translations en.json (Hebrew)
[ci skip]
2026-07-09 19:00:49 +03:00
Stavros f6188f6a8d New translations en.json (Finnish)
[ci skip]
2026-07-09 19:00:48 +03:00
Stavros 7235855061 New translations en.json (Greek)
[ci skip]
2026-07-09 19:00:47 +03:00
Stavros 768eb29292 New translations en.json (German)
[ci skip]
2026-07-09 19:00:45 +03:00
Stavros 9743fe71cc New translations en.json (Danish)
[ci skip]
2026-07-09 19:00:44 +03:00
Stavros 93e2a6a7fa New translations en.json (Czech)
[ci skip]
2026-07-09 19:00:43 +03:00
Stavros 1649a96cd6 New translations en.json (Catalan)
[ci skip]
2026-07-09 19:00:42 +03:00
Stavros 2cb606e5aa New translations en.json (Arabic)
[ci skip]
2026-07-09 19:00:40 +03:00
Stavros 229a5ae3c3 New translations en.json (Afrikaans)
[ci skip]
2026-07-09 19:00:39 +03:00
Stavros 122e02811a New translations en.json (Spanish)
[ci skip]
2026-07-09 19:00:38 +03:00
Stavros cf97a7a251 New translations en.json (French)
[ci skip]
2026-07-09 19:00:37 +03:00
Stavros 82189617b8 New translations en.json (Romanian)
[ci skip]
2026-07-09 19:00:35 +03:00
69 changed files with 1370 additions and 2625 deletions
+4 -9
View File
@@ -4,10 +4,6 @@
# The base URL where the app is hosted. # The base URL where the app is hosted.
TINYAUTH_APPURL= 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 # database config
@@ -101,6 +97,8 @@ TINYAUTH_AUTH_SESSIONMAXLIFETIME=0
TINYAUTH_AUTH_LOGINTIMEOUT=300 TINYAUTH_AUTH_LOGINTIMEOUT=300
# Maximum login retries. # Maximum login retries.
TINYAUTH_AUTH_LOGINMAXRETRIES=3 TINYAUTH_AUTH_LOGINMAXRETRIES=3
# Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically.
TINYAUTH_AUTH_LOCKDOWNENABLED=true
# Comma-separated list of trusted proxy addresses. # Comma-separated list of trusted proxy addresses.
TINYAUTH_AUTH_TRUSTEDPROXIES= TINYAUTH_AUTH_TRUSTEDPROXIES=
# ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow. # ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow.
@@ -223,11 +221,6 @@ TINYAUTH_LDAP_AUTHKEY=
# Cache duration for LDAP group membership in seconds. # Cache duration for LDAP group membership in seconds.
TINYAUTH_LDAP_GROUPCACHETTL=900 TINYAUTH_LDAP_GROUPCACHETTL=900
# experimental config
# Enable the OAuth bridge, uses a new way to format OAuth user information.
TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false
# tailscale config # tailscale config
# Enable Tailscale integration. # Enable Tailscale integration.
@@ -240,6 +233,8 @@ TINYAUTH_TAILSCALE_APITOKENFILE=
TINYAUTH_TAILSCALE_TAILNET= TINYAUTH_TAILSCALE_TAILNET=
# Cache duration for Tailscale device and user lists in seconds. # Cache duration for Tailscale device and user lists in seconds.
TINYAUTH_TAILSCALE_CACHEDURATION=300 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 # log config
-8
View File
@@ -9,8 +9,6 @@ updates:
- "minor" - "minor"
schedule: schedule:
interval: "daily" interval: "daily"
cooldown:
default-days: 1
- package-ecosystem: "gomod" - package-ecosystem: "gomod"
directory: "/" directory: "/"
@@ -21,19 +19,13 @@ updates:
- "minor" - "minor"
schedule: schedule:
interval: "daily" interval: "daily"
cooldown:
default-days: 1
- package-ecosystem: "docker" - package-ecosystem: "docker"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
cooldown:
default-days: 1
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
cooldown:
default-days: 1
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Setup go - name: Setup go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
+10 -10
View File
@@ -23,7 +23,7 @@ jobs:
REPO: ${{ github.event.repository.name }} REPO: ${{ github.event.repository.name }}
- name: Create release - name: Create release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with: with:
prerelease: true prerelease: true
tag_name: nightly tag_name: nightly
@@ -68,7 +68,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
@@ -117,7 +117,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
@@ -167,7 +167,7 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -226,7 +226,7 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -285,7 +285,7 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -344,7 +344,7 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -399,7 +399,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -438,7 +438,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -476,7 +476,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with: with:
files: binaries/* files: binaries/*
tag_name: nightly tag_name: nightly
+9 -9
View File
@@ -46,7 +46,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
@@ -92,7 +92,7 @@ jobs:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.4"
@@ -139,7 +139,7 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -196,7 +196,7 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -253,7 +253,7 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -310,7 +310,7 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -366,7 +366,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -407,7 +407,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -449,6 +449,6 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with: with:
files: binaries/* files: binaries/*
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5 retention-days: 5
- name: Upload to code-scanning - name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4 uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+1 -2
View File
@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM node:26.5-alpine3.23 AS frontend-builder FROM node:26.4-alpine3.23 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -39,7 +39,6 @@ RUN go mod download
COPY ./cmd ./cmd COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
COPY ./pkg ./pkg
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \ 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 ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
COPY ./pkg ./pkg
COPY ./air.toml ./ COPY ./air.toml ./
EXPOSE 3000 EXPOSE 3000
+1 -2
View File
@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM node:26.5-alpine3.23 AS frontend-builder FROM node:26.4-alpine3.23 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -39,7 +39,6 @@ RUN go mod download
COPY ./cmd ./cmd/ COPY ./cmd ./cmd/
COPY ./internal ./internal COPY ./internal ./internal
COPY ./pkg ./pkg
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \ 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. Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file.
## Hosting Partners
If you use one of our partners, you can help support us while getting a great hosting deal.
<div>
<a title="InstaPods" target="_blank" href="https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth"><img src="https://instapods.com/deploy-button.svg"></a>
</div>
## Sponsors ## Sponsors
A big thank-you to the following individuals and companies A big thank you to the following people for providing me with more coffee:
for their support for Tinyauth.
### Companies and Organizations
- [JetBrains for Open-Source](https://jb.gg/OpenSource)
- [CodeRabbit AI](https://www.coderabbit.ai)
- [Dosu](https://dosu.dev/)
- [InstaPods](https://instapods.com) - [Deploy Tinyauth from 3$/month](https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth)
### Individuals
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/axjab"><img src="https:&#x2F;&#x2F;github.com&#x2F;axjab.png" width="64px" alt="User avatar: axjab" /></a>&nbsp;&nbsp;<a href="https://github.com/stegratech"><img src="https:&#x2F;&#x2F;github.com&#x2F;stegratech.png" width="64px" alt="User avatar: stegratech" /></a>&nbsp;&nbsp;<a href="https://github.com/apearson"><img src="https:&#x2F;&#x2F;github.com&#x2F;apearson.png" width="64px" alt="User avatar: apearson" /></a>&nbsp;&nbsp;<a href="https://github.com/Micky5991"><img src="https:&#x2F;&#x2F;github.com&#x2F;Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a>&nbsp;&nbsp;<!-- sponsors --> <!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/axjab"><img src="https:&#x2F;&#x2F;github.com&#x2F;axjab.png" width="64px" alt="User avatar: axjab" /></a>&nbsp;&nbsp;<a href="https://github.com/stegratech"><img src="https:&#x2F;&#x2F;github.com&#x2F;stegratech.png" width="64px" alt="User avatar: stegratech" /></a>&nbsp;&nbsp;<a href="https://github.com/apearson"><img src="https:&#x2F;&#x2F;github.com&#x2F;apearson.png" width="64px" alt="User avatar: apearson" /></a>&nbsp;&nbsp;<a href="https://github.com/Micky5991"><img src="https:&#x2F;&#x2F;github.com&#x2F;Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a>&nbsp;&nbsp;<!-- sponsors -->
@@ -82,4 +81,9 @@ for their support for Tinyauth.
- **Freepik** for providing the police hat and badge. - **Freepik** for providing the police hat and badge.
- **Renee French** for the original gopher logo. - **Renee French** for the original gopher logo.
- **Coderabbit AI** for providing free AI code reviews.
- **Syrhu** for providing the background image of the app. - **Syrhu** for providing the background image of the app.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=tinyauthapp/tinyauth&type=Date)](https://www.star-history.com/#tinyauthapp/tinyauth&Date)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.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) userStr := fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)
fmt.Print("\nOr add the following TOTP secret to your authenticator app: ") fmt.Print("\nOr add the following TOTP secret to your authenticator app: ")
fmt.Print(colors.blue.Render(secret)) fmt.Print(colors.green.Render(secret))
fmt.Print("\n\n") fmt.Print("\n\n")
fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username) fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username)
fmt.Print(colors.blue.Render(userStr)) fmt.Print(colors.green.Render(userStr))
fmt.Print("\n") fmt.Print("\n")
return nil return nil
-1
View File
@@ -32,7 +32,6 @@ func main() {
Configuration: tConfig, Configuration: tConfig,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
// enable this on experimental features
if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) { if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) {
colors := getColors() colors := getColors()
fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.") fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.")
+11 -5
View File
@@ -1,6 +1,12 @@
base_path: . "base_path": "."
base_url: 'https://api.crowdin.com' "base_url": "https://api.crowdin.com"
preserve_hierarchy: 1
"preserve_hierarchy": true
files: files:
- source: /frontend/src/lib/i18n/locales/en.json [
translation: /frontend/src/lib/i18n/locales/%locale%.json {
"source": "/frontend/src/lib/i18n/locales/en.json",
"translation": "/frontend/src/lib/i18n/locales/%locale%.json",
},
]
+11 -11
View File
@@ -13,9 +13,9 @@
"packageManager": "pnpm@11.1.2", "packageManager": "pnpm@11.1.2",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dropdown-menu": "^2.1.20", "@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-label": "^2.1.11", "@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-select": "^2.3.3", "@radix-ui/react-select": "^2.3.2",
"@radix-ui/react-separator": "^1.1.11", "@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-slot": "^1.3.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
@@ -23,18 +23,18 @@
"axios": "^1.18.1", "axios": "^1.18.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^26.3.6", "i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"lucide-react": "^1.24.0", "lucide-react": "^1.23.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.2", "radix-ui": "^1.6.1",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.81.0",
"react-i18next": "^17.0.10", "react-i18next": "^17.0.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^8.2.0", "react-router": "^8.1.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
@@ -43,18 +43,18 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tanstack/eslint-plugin-query": "^5.101.2", "@tanstack/eslint-plugin-query": "^5.101.2",
"@types/node": "^26.1.1", "@types/node": "^26.1.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.7.0", "eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0", "globals": "^17.7.0",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.64.0", "typescript-eslint": "^8.62.1",
"vite": "^8.1.5" "vite": "^8.1.3"
} }
} }
+446 -536
View File
File diff suppressed because it is too large Load Diff
@@ -32,8 +32,8 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { useRef } from "react"; import { useRef } from "react";
import { import {
searchParamsFromObject,
useScreenParams, useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
@@ -70,13 +70,7 @@ export const QuickActions = () => {
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = (() => { const compiledParams = recompileScreenParams(screenParams);
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
+2 -6
View File
@@ -1,11 +1,10 @@
type UseLoginForProps = { type UseLoginForProps = {
login_for?: "oidc" | "app"; login_for?: "oidc" | "app";
params: URLSearchParams; compiledParams: string;
}; };
export const useLoginFor = (props: UseLoginForProps): string => { export const useLoginFor = (props: UseLoginForProps): string => {
const { login_for, params } = props; const { login_for, compiledParams } = props;
const compiledParams = params.toString() ? "?" + params.toString() : "";
switch (login_for) { switch (login_for) {
case "oidc": case "oidc":
@@ -13,9 +12,6 @@ export const useLoginFor = (props: UseLoginForProps): string => {
case "app": case "app":
return "/continue" + compiledParams; return "/continue" + compiledParams;
default: default:
if (params.get("redirect_uri")) {
return "/continue" + compiledParams
}
return "/logout"; return "/logout";
} }
}; };
+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 // ported from internal/controller/oauth_controller.go
const isIP = (str: string): boolean => { const getEffectivePort = (url: URL): string => {
const ipv4 = if (url.port) {
/^(\d{1,3}\.){3}\d{1,3}$/; return url.port;
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);
} }
return str
} if (url.protocol == "https:") {
return "443";
}
return "80";
};
export const isTrustedDomain = ( export const isTrustedDomain = (
url: URL, url: URL,
@@ -97,15 +94,15 @@ export const isTrustedDomain = (
cookieDomain: string, cookieDomain: string,
subdomainsEnabled: boolean, subdomainsEnabled: boolean,
): boolean => { ): boolean => {
if (isIP(url.hostname)) { if (url.protocol != appUrl.protocol) {
return false; return false;
} }
if (url.port != appUrl.port) { if (getEffectivePort(url) != getEffectivePort(appUrl)) {
return false; return false;
} }
if (trimPeriod(url.hostname) == trimPeriod(appUrl.hostname)) { if (url.hostname == appUrl.hostname) {
return true; return true;
} }
@@ -113,6 +110,9 @@ export const isTrustedDomain = (
return false; return false;
} }
return trimPeriod(url.hostname).endsWith("." + cookieDomain.toLowerCase()) if (url.hostname.endsWith("." + cookieDomain.toLowerCase())) {
|| trimPeriod(url.hostname) == cookieDomain.toLowerCase(); return true;
}
return false;
}; };
+10 -4
View File
@@ -27,10 +27,16 @@ export function useScreenParams(params: URLSearchParams): ScreenParams {
return parsed.data; return parsed.data;
} }
export function searchParamsFromObject(obj: object): URLSearchParams { export function recompileScreenParams(params: ScreenParams): string {
return new URLSearchParams( const p = new URLSearchParams(
Object.fromEntries( Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined), Object.entries(params).filter(([, v]) => v !== undefined),
) as Record<string, string>, ) as Record<string, string>,
); ).toString();
if (p.length > 0) {
return "?" + p;
}
return "";
} }
+1 -1
View File
@@ -86,7 +86,7 @@
"addressScopeName": "Adres", "addressScopeName": "Adres",
"addressScopeDescription": "Geeft de app toegang tot je adres.", "addressScopeDescription": "Geeft de app toegang tot je adres.",
"loginTailscaleTitle": "Doorgaan met Tailscale", "loginTailscaleTitle": "Doorgaan met Tailscale",
"loginTailscaleDescription": "Je lijkt toegang te hebben tot Tinyauth vanaf een geautoriseerd Tailscale-apparaat. Wil je doorgaan met je Tailscale-verbinding?", "loginTailscaleDescription": "Je lijkt toegang te hebben tot Tinyauth vanaf een geautoriseerd Tailscale-apparaat. Wil je doorgaan met jeTailscale-verbinding?",
"loginTailscaleDeviceName": "Apparaatnaam:", "loginTailscaleDeviceName": "Apparaatnaam:",
"loginTailscaleOtherMethod": "Op een andere manier inloggen", "loginTailscaleOtherMethod": "Op een andere manier inloggen",
"loginTailscaleSuccess": "Succesvol geauthenticeerd met Tailscale.", "loginTailscaleSuccess": "Succesvol geauthenticeerd met Tailscale.",
+1 -1
View File
@@ -77,7 +77,7 @@
"emailScopeName": "E-mail", "emailScopeName": "E-mail",
"emailScopeDescription": "Zezwala aplikacji na dostęp do adresów e-mail.", "emailScopeDescription": "Zezwala aplikacji na dostęp do adresów e-mail.",
"profileScopeName": "Profil", "profileScopeName": "Profil",
"profileScopeDescription": "Zezwala aplikacji na dostęp do informacji o profilu.", "profileScopeDescription": "Zezwala aplikacji na dostęp do informacji o porfilu.",
"groupsScopeName": "Grupy", "groupsScopeName": "Grupy",
"groupsScopeDescription": "Zezwala aplikacji na dostęp do informacji o grupie.", "groupsScopeDescription": "Zezwala aplikacji na dostęp do informacji o grupie.",
"backToLoginButton": "Wróć do logowania", "backToLoginButton": "Wróć do logowania",
+1 -1
View File
@@ -99,7 +99,7 @@
"quickActionsThemeSystem": "Систем", "quickActionsThemeSystem": "Систем",
"quickActionsLogout": "Одјава", "quickActionsLogout": "Одјава",
"quickActionsTitle": "Брзе радње", "quickActionsTitle": "Брзе радње",
"quickActionsProviderLocal": "Локално", "quickActionsProviderLocal": "Lokalno",
"quickActionsProviderLDAP": "LDAP", "quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth" "quickActionsProviderOAuth": "{{provider}} OAuth"
} }
+1 -1
View File
@@ -98,7 +98,7 @@
"quickActionsThemeDark": "暗色主题", "quickActionsThemeDark": "暗色主题",
"quickActionsThemeSystem": "跟随系统", "quickActionsThemeSystem": "跟随系统",
"quickActionsLogout": "登出", "quickActionsLogout": "登出",
"quickActionsTitle": "快速操作", "quickActionsTitle": "快速行動",
"quickActionsProviderLocal": "本地", "quickActionsProviderLocal": "本地",
"quickActionsProviderLDAP": "LDAP", "quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth" "quickActionsProviderOAuth": "{{provider}} OAuth"
+2 -8
View File
@@ -22,7 +22,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { import {
searchParamsFromObject, recompileScreenParams,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -89,13 +89,7 @@ export const AuthorizePage = () => {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const isOidc = screenParams.login_for === "oidc"; const isOidc = screenParams.login_for === "oidc";
const compiledParams = (() => { const compiledParams = recompileScreenParams(screenParams);
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
// TODO: maybe a better way to do this // TODO: maybe a better way to do this
const shouldAutoAuthorize = const shouldAutoAuthorize =
+4 -10
View File
@@ -13,7 +13,7 @@ import { Navigate, useLocation, useNavigate } from "react-router";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRedirectUri } from "@/lib/hooks/redirect-uri"; import { useRedirectUri } from "@/lib/hooks/redirect-uri";
import { import {
searchParamsFromObject, recompileScreenParams,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
@@ -31,14 +31,8 @@ export const ContinuePage = () => {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const redirectUri = screenParams.redirect_uri; const redirectUri = screenParams.redirect_uri;
const isAppLogin = screenParams.login_for === "app" || !screenParams.login_for; const isAppLogin = screenParams.login_for === "app";
const compiledParams = (() => { const recompiledParams = recompileScreenParams(screenParams);
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri( const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
redirectUri, redirectUri,
@@ -95,7 +89,7 @@ export const ContinuePage = () => {
}, [shouldAutoRedirect, redirectToTarget]); }, [shouldAutoRedirect, redirectToTarget]);
if (!auth.authenticated) { if (!auth.authenticated) {
return <Navigate to={`/login${compiledParams}`} replace />; return <Navigate to={`/login${recompiledParams}`} replace />;
} }
if (!hasValidRedirect || !isAppLogin) { if (!hasValidRedirect || !isAppLogin) {
+3 -9
View File
@@ -12,8 +12,8 @@ import { useTranslation } from "react-i18next";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { import {
searchParamsFromObject, recompileScreenParams,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
export const ForgotPasswordPage = () => { export const ForgotPasswordPage = () => {
@@ -22,13 +22,7 @@ export const ForgotPasswordPage = () => {
const { search } = useLocation(); const { search } = useLocation();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = (() => { const compiledParams = recompileScreenParams(screenParams);
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
return ( return (
<Card> <Card>
+10 -12
View File
@@ -26,7 +26,7 @@ import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
searchParamsFromObject, recompileScreenParams,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for"; import { useLoginFor } from "@/lib/hooks/login-for";
@@ -63,21 +63,18 @@ export const LoginPage = () => {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = (() => { const compiledParams = recompileScreenParams({
const params = searchParamsFromObject(screenParams).toString(); ...screenParams,
if (params.length > 0) { oidc_prompt: undefined,
return `?${params}`; });
}
return "";
})();
const loginForUrl = useLoginFor({ const loginForUrl = useLoginFor({
login_for: screenParams.login_for, login_for: screenParams.login_for,
params: searchParamsFromObject({ ...screenParams, oidc_prompt: undefined}), compiledParams,
}); });
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState( const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
providers.find((provider) => provider.id === oauth.autoRedirect) !== providers.find((provider) => provider.id === oauth.autoRedirect) !==
undefined && (screenParams.redirect_uri || screenParams.oidc_ticket), undefined && screenParams.redirect_uri !== undefined,
); );
const oauthProviders = providers.filter( const oauthProviders = providers.filter(
@@ -174,7 +171,8 @@ export const LoginPage = () => {
!auth.authenticated && !auth.authenticated &&
isOauthAutoRedirect && isOauthAutoRedirect &&
!hasAutoRedirectedRef.current && !hasAutoRedirectedRef.current &&
(screenParams.redirect_uri || screenParams.oidc_ticket) screenParams.redirect_uri &&
screenParams.login_for
) { ) {
hasAutoRedirectedRef.current = true; hasAutoRedirectedRef.current = true;
oauthMutate(oauth.autoRedirect); oauthMutate(oauth.autoRedirect);
@@ -185,8 +183,8 @@ export const LoginPage = () => {
hasAutoRedirectedRef, hasAutoRedirectedRef,
oauth.autoRedirect, oauth.autoRedirect,
isOauthAutoRedirect, isOauthAutoRedirect,
screenParams.login_for,
screenParams.redirect_uri, screenParams.redirect_uri,
screenParams.oidc_ticket
]); ]);
useEffect(() => { useEffect(() => {
+2 -8
View File
@@ -17,8 +17,8 @@ import { type UseMutationResult } from "@tanstack/react-query";
import { type AxiosResponse } from "axios"; import { type AxiosResponse } from "axios";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { import {
searchParamsFromObject,
useScreenParams, useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
export const LogoutPage = () => { export const LogoutPage = () => {
@@ -29,13 +29,7 @@ export const LogoutPage = () => {
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = (() => { const compiledParams = recompileScreenParams(screenParams);
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/user/logout"), mutationFn: () => axios.post("/api/user/logout"),
+3 -9
View File
@@ -17,7 +17,7 @@ import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
searchParamsFromObject, recompileScreenParams,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for"; import { useLoginFor } from "@/lib/hooks/login-for";
@@ -32,16 +32,10 @@ export const TotpPage = () => {
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const screenParams = useScreenParams(searchParams);
const compiledParams = (() => { const compiledParams = recompileScreenParams(screenParams);
const params = searchParamsFromObject(screenParams).toString();
if (params.length > 0) {
return `?${params}`;
}
return "";
})();
const loginForUrl = useLoginFor({ const loginForUrl = useLoginFor({
login_for: screenParams.login_for, login_for: screenParams.login_for,
params: searchParamsFromObject(screenParams), compiledParams,
}); });
const totpMutation = useMutation({ 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) { func buildEnvEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]EnvEntry) {
desc := child.Tag.Get("description") desc := child.Tag.Get("description")
tag := child.Tag.Get("yaml") tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" { if tag == "-" {
return return
} }
tag = strings.TrimSuffix(tag, ",omitempty")
value := childValue.Interface() value := childValue.Interface()
entry := EnvEntry{ entry := EnvEntry{
+19 -63
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
@@ -12,13 +13,9 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
) )
type ConfigOptions struct {
Env string
Flag string
YAML string
}
type MarkdownEntry struct { type MarkdownEntry struct {
Options ConfigOptions Env string
Flag string
Description string Description string
Default any Default any
} }
@@ -50,27 +47,16 @@ func generateMarkdown() {
func buildMdEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]MarkdownEntry) { func buildMdEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]MarkdownEntry) {
desc := child.Tag.Get("description") desc := child.Tag.Get("description")
tag := child.Tag.Get("yaml") tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" { if tag == "-" {
return return
} }
tag = strings.TrimSuffix(tag, ",omitempty")
value := childValue.Interface() 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{ 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, Description: desc,
} }
@@ -97,14 +83,11 @@ func buildMdMapEntry(child reflect.StructField, parentPath string, entries *[]Ma
} }
tag := child.Tag.Get("yaml") tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" { if tag == "-" {
return return
} }
tag = strings.TrimSuffix(tag, ",omitempty")
mapPath := parentPath + tag + ".[name]." mapPath := parentPath + tag + ".[name]."
valueType := fieldType.Elem() valueType := fieldType.Elem()
@@ -119,54 +102,27 @@ func buildMdChildPath(parent string, child string) string {
} }
func compileMd(entries []MarkdownEntry) []byte { 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("<!--- This file is automatically generated by gen/gen_md.go. Do not edit manually. --->\n\n")
buffer.WriteString("import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n") buffer.WriteString("# Tinyauth configuration reference\n\n")
buffer.WriteString("<Tabs>\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 := "" previousSection := ""
for _, entry := range entries { for _, entry := range entries {
section := strings.Split(entry.Options.YAML, ".")[0] if strings.Count(entry.Env, "_") > 1 {
var option string section := strings.Split(strings.TrimPrefix(entry.Env, "TINYAUTH_"), "_")[0]
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 section != previousSection { if section != previousSection {
buffer.WriteString("\n ### " + strings.ToLower(section) + "\n\n") buffer.WriteString("\n## " + strings.ToLower(section) + "\n\n")
buffer.WriteString(" | Option | Description | Default |\n") buffer.WriteString("| Environment | Flag | Description | Default |\n")
buffer.WriteString(" | - | - | - |\n") buffer.WriteString("| - | - | - | - |\n")
previousSection = section 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()
} }
+12 -12
View File
@@ -9,7 +9,7 @@ require (
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.14 github.com/go-ldap/ldap/v3 v3.4.13
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/go-querystring v1.2.0 github.com/google/go-querystring v1.2.0
@@ -23,14 +23,13 @@ require (
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298 github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
github.com/weppos/publicsuffix-go v0.50.3 github.com/weppos/publicsuffix-go v0.50.3
go.uber.org/dig v1.19.0 go.uber.org/dig v1.19.0
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.53.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/tools v0.48.0 golang.org/x/tools v0.47.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.36.2 k8s.io/apimachinery v0.36.2
k8s.io/client-go v0.36.2 k8s.io/client-go v0.36.2
modernc.org/sqlite v1.54.0 modernc.org/sqlite v1.53.0
) )
require ( require (
@@ -73,7 +72,7 @@ require (
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // 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/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonreference v0.20.4 // indirect github.com/go-openapi/jsonreference v0.20.4 // indirect
@@ -135,11 +134,12 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.38.0 // indirect golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
@@ -147,7 +147,7 @@ require (
k8s.io/klog/v2 v2.140.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
modernc.org/libc v1.74.1 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect rsc.io/qr v0.2.0 // indirect
+30 -30
View File
@@ -113,12 +113,12 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= 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/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= 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 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= 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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -336,29 +336,29 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
@@ -392,20 +392,20 @@ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hk
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= 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 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -414,8 +414,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+7 -9
View File
@@ -88,13 +88,17 @@ func (app *BootstrapApp) Setup() error {
app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version) app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version)
// get app url // get app url
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 { if err != nil {
return fmt.Errorf("failed to parse app url: %w", err) return fmt.Errorf("failed to parse app url: %w", err)
} }
app.runtime.AppURL = appURL app.runtime.AppURL = strings.ToLower(appUrl.Scheme + "://" + appUrl.Host)
// validate session config // validate session config
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry { if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
@@ -168,13 +172,7 @@ func (app *BootstrapApp) Setup() error {
app.runtime.CookieDomain = cookieDomain app.runtime.CookieDomain = cookieDomain
// cookie names // cookie names
u, err := url.Parse(app.runtime.AppURL) app.runtime.UUID = utils.GenerateUUID(appUrl.Hostname())
if err != nil {
return fmt.Errorf("failed to parse app url: %w", err)
}
app.runtime.UUID = utils.GenerateUUID(u.Hostname())
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
-10
View File
@@ -30,16 +30,6 @@ func (app *BootstrapApp) setupRouter() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set trusted proxies: %w", err) return fmt.Errorf("failed to set trusted proxies: %w", err)
} }
app.runtime.TrustedProxiesConfigured = true
} else {
err := engine.SetTrustedProxies(nil)
if err != nil {
return fmt.Errorf("failed to set trusted proxies: %w", err)
}
app.log.App.Warn().Msg("Trusted proxies are not configured, IP access controls will NOT work")
} }
middlewareProvideFor := []any{ middlewareProvideFor := []any{
+4 -8
View File
@@ -85,8 +85,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
err := app.dig.Provide(service.NewKubernetesService) err := app.dig.Provide(service.NewKubernetesService)
if err != nil { if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to provide kubernetes service") return nil, fmt.Errorf("failed to provide kubernetes service: %w", err)
return nil, nil
} }
err = app.dig.Invoke(func(k *service.KubernetesService) error { err = app.dig.Invoke(func(k *service.KubernetesService) error {
@@ -95,8 +94,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
}) })
if err != nil { if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to invoke kubernetes service") return nil, fmt.Errorf("failed to invoke kubernetes service: %w", err)
return nil, nil
} }
// Kubernetes will fail to initialize with an error if it cannot connect to the cluster // Kubernetes will fail to initialize with an error if it cannot connect to the cluster
@@ -116,8 +114,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
err := app.dig.Provide(service.NewDockerService) err := app.dig.Provide(service.NewDockerService)
if err != nil { if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to provide docker service") return nil, fmt.Errorf("failed to provide docker service: %w", err)
return nil, nil
} }
err = app.dig.Invoke(func(d *service.DockerService) error { err = app.dig.Invoke(func(d *service.DockerService) error {
@@ -126,8 +123,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
}) })
if err != nil { if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to invoke docker service") return nil, fmt.Errorf("failed to invoke docker service: %w", err)
return nil, nil
} }
if app.services.dockerService == nil { if app.services.dockerService == nil {
+63 -91
View File
@@ -1,9 +1,9 @@
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -12,7 +12,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig" "go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -220,16 +219,35 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return return
} }
oauthUserInfo := controller.createOAuthUserInfo(oauthUserInfo{ var name string
Username: user.PreferredUsername,
Email: user.Email, if strings.TrimSpace(user.Name) != "" {
Name: 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{ sessionCookie := repository.Session{
Username: oauthUserInfo.Username, Username: username,
Name: oauthUserInfo.Name, Name: name,
Email: oauthUserInfo.Email, Email: user.Email,
Provider: svc.ID(), Provider: svc.ID(),
OAuthGroups: utils.CoalesceToString(user.Groups), OAuthGroups: utils.CoalesceToString(user.Groups),
OAuthName: svc.Name(), OAuthName: svc.Name(),
@@ -293,102 +311,56 @@ func (controller *OAuthController) getCookieDomain() string {
} }
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{ u, err := url.Parse(redirectURI)
WithPort: true,
})
_, err := v.SafeHostname(controller.runtime.AppURL)
if err != nil { 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 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 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 { if !controller.config.Auth.SubdomainsEnabled {
return false return false
} }
v = validators.NewDomainValidator(validators.DomainValidatorOptions{}) if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
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 {
return true return true
} }
return false return false
} }
type oauthUserInfo struct {
Email string
Username string
Name string
}
func (controller *OAuthController) createOAuthUserInfo(input oauthUserInfo) oauthUserInfo {
info := oauthUserInfo{
Email: input.Email,
}
if controller.config.Experimental.OAuthBridgeEnabled {
if input.Username != "" {
info.Username = input.Username
} else {
parts := strings.SplitN(input.Email, "@", 2)
if len(parts) != 2 {
controller.log.App.Error().Str("email", input.Email).Msg("Invalid email address")
} else {
info.Username = parts[0]
}
}
if input.Name != "" {
info.Name = input.Name
} else {
info.Name = utils.Capitalize(info.Username)
}
return info
}
if input.Name != "" {
controller.log.App.Debug().Msg("Using name from OAuth provider")
info.Name = input.Name
} else {
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
parts := strings.SplitN(input.Email, "@", 2)
if len(parts) != 2 {
controller.log.App.Error().Str("email", input.Email).Msg("Invalid email address")
} else {
info.Name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
}
}
if input.Username != "" {
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
info.Username = input.Username
} else {
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
info.Username = strings.Replace(info.Email, "@", "_", 1)
}
return info
}
+25 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestOAuthController_isRedirectSafe(t *testing.T) { func TestOAuthControllerIsRedirectSafe(t *testing.T) {
log := logger.NewLogger().WithTestConfig() log := logger.NewLogger().WithTestConfig()
log.Init() log.Init()
@@ -81,6 +81,22 @@ func TestOAuthController_isRedirectSafe(t *testing.T) {
redirectURI: "https://sub.example.com", redirectURI: "https://sub.example.com",
expected: false, expected: false,
}, },
{
description: "Cookie domain itself is not a subdomain match",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://example.com",
expected: false,
},
{
description: "Different scheme returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "http://tinyauth.example.com",
expected: false,
},
{ {
description: "Different port returns false", description: "Different port returns false",
appURL: "https://tinyauth.example.com", appURL: "https://tinyauth.example.com",
@@ -105,6 +121,14 @@ func TestOAuthController_isRedirectSafe(t *testing.T) {
redirectURI: "https:/malicious", redirectURI: "https:/malicious",
expected: false, expected: false,
}, },
{
description: "Redirect URI without scheme returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "tinyauth.example.com",
expected: false,
},
{ {
description: "Relative redirect URI returns false", description: "Relative redirect URI returns false",
appURL: "https://tinyauth.example.com", appURL: "https://tinyauth.example.com",
+29 -34
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -344,31 +343,27 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
// Create the authorization code // Create the authorization code
code := controller.oidc.CreateCode(*authorizeReq, *userContext) code := controller.oidc.CreateCode(*authorizeReq, *userContext)
cu, err := url.Parse(authorizeReq.RedirectURI) queries, err := query.Values(AuthorizeCallback{
Code: code,
State: authorizeReq.State,
})
if err != nil { if err != nil {
controller.authorizeError(c, authorizeErrorParams{ controller.authorizeError(c, authorizeErrorParams{
err: err, err: err,
reason: "Failed to parse redirect URI", reason: "Failed to build query",
reasonPublic: "Failed to parse redirect URI", reasonPublic: "Failed to build query",
json: true, callback: authorizeReq.RedirectURI,
callbackError: "server_error",
state: authorizeReq.State,
json: true,
}) })
return return
} }
q := cu.Query()
q.Set("code", code)
if authorizeReq.State != "" {
q.Set("state", authorizeReq.State)
}
cu.RawQuery = q.Encode()
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": 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") controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error")
if params.callback != "" { 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 { 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) c.AbortWithStatus(http.StatusInternalServerError)
return return
} }
q := cu.Query() redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode())
q.Set("error", params.callbackError)
if params.reasonPublic != "" {
q.Set("error_description", params.reasonPublic)
}
if params.state != "" {
q.Set("state", params.state)
}
cu.RawQuery = q.Encode()
if params.json { if params.json {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": cu.String(), "redirect_uri": redirectUrl,
}) })
return return
} }
c.Redirect(http.StatusFound, cu.String()) c.Redirect(http.StatusFound, redirectUrl)
return return
} }
+3 -4
View File
@@ -111,10 +111,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP() clientIP := c.ClientIP()
aclsCtx := &service.ACLContext{ aclsCtx := &service.ACLContext{
ACLs: acls, ACLs: acls,
IP: net.ParseIP(clientIP), IP: net.ParseIP(clientIP),
Path: proxyCtx.Path, Path: proxyCtx.Path,
TrustedProxiesConfigured: controller.runtime.TrustedProxiesConfigured,
} }
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) { if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
+1 -3
View File
@@ -708,7 +708,7 @@ func TestProxyController(t *testing.T) {
Log: log, Log: log,
}) })
authService, err := service.NewAuthService(service.AuthServiceInput{ authService := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -721,8 +721,6 @@ func TestProxyController(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
router := gin.Default() router := gin.Default()
+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") 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) search, err := controller.auth.SearchUser(req.Username)
if err != nil { if err != nil {
if errors.Is(err, service.ErrUserNotFound) { if errors.Is(err, service.ErrUserNotFound) {
controller.auth.DummyPasswordCheck(req.Password)
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt") controller.log.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") controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
@@ -93,24 +107,14 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return 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 { if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt") controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(req.Username, false)
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{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "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.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) controller.auth.RecordLoginAttempt(req.Username, true)
+1 -4
View File
@@ -542,8 +542,7 @@ func TestUserController(t *testing.T) {
Runtime: &runtime, Runtime: &runtime,
Ctx: ctx, Ctx: ctx,
}) })
authService := service.NewAuthService(service.AuthServiceInput{
authService, err := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -556,8 +555,6 @@ func TestUserController(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
beforeEach := func() { beforeEach := func() {
// Clear failed login attempts before each test // Clear failed login attempts before each test
authService.ClearLoginAttempts() authService.ClearLoginAttempts()
+6 -16
View File
@@ -2,7 +2,6 @@ package middleware
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -40,7 +39,6 @@ var (
type ContextMiddleware struct { type ContextMiddleware struct {
log *logger.Logger log *logger.Logger
runtime *model.RuntimeConfig runtime *model.RuntimeConfig
config *model.Config
auth *service.AuthService auth *service.AuthService
broker *service.OAuthBrokerService broker *service.OAuthBrokerService
tailscale *service.TailscaleService tailscale *service.TailscaleService
@@ -51,7 +49,6 @@ type ContextMiddlewareInput struct {
Log *logger.Logger Log *logger.Logger
RuntimeConfig *model.RuntimeConfig RuntimeConfig *model.RuntimeConfig
StaticConfig *model.Config
AuthService *service.AuthService AuthService *service.AuthService
BrokerService *service.OAuthBrokerService BrokerService *service.OAuthBrokerService
TailscaleService *service.TailscaleService TailscaleService *service.TailscaleService
@@ -61,7 +58,6 @@ func NewContextMiddleware(i ContextMiddlewareInput) *ContextMiddleware {
return &ContextMiddleware{ return &ContextMiddleware{
log: i.Log, log: i.Log,
runtime: i.RuntimeConfig, runtime: i.RuntimeConfig,
config: i.StaticConfig,
auth: i.AuthService, auth: i.AuthService,
broker: i.BrokerService, broker: i.BrokerService,
tailscale: i.TailscaleService, tailscale: i.TailscaleService,
@@ -248,9 +244,6 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
search, err := m.auth.SearchUser(username) search, err := m.auth.SearchUser(username)
if err != nil { if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
m.auth.DummyPasswordCheck(password)
}
return nil, nil, fmt.Errorf("error searching for user: %w", err) return nil, nil, fmt.Errorf("error searching for user: %w", err)
} }
@@ -285,7 +278,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
} }
userContext.Provider = model.ProviderLocal userContext.Provider = model.ProviderLocal
case model.UserLDAP: case model.UserLDAP:
user, err := m.auth.GetLDAPUser(search.Username) user, err := m.auth.GetLDAPUser(username)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err) return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
@@ -335,19 +328,16 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext,
return nil, nil return nil, nil
} }
username := strings.Replace(whois.LoginName, "@", "_", 1)
uctx := model.TailscaleContext{ uctx := model.TailscaleContext{
BaseContext: model.BaseContext{ BaseContext: model.BaseContext{
Email: whois.LoginName, Username: username,
Name: whois.DisplayName, Email: whois.LoginName,
Name: whois.DisplayName,
}, },
NodeName: whois.NodeName, NodeName: whois.NodeName,
} }
if m.config.Experimental.OAuthBridgeEnabled {
uctx.BaseContext.Username = strings.SplitN(whois.LoginName, "@", 2)[0]
} else {
uctx.BaseContext.Username = strings.Replace(whois.LoginName, "@", "_", 1)
}
return &uctx, nil return &uctx, nil
} }
@@ -264,8 +264,7 @@ func TestContextMiddleware(t *testing.T) {
Runtime: &runtime, Runtime: &runtime,
Ctx: ctx, Ctx: ctx,
}) })
authService := service.NewAuthService(service.AuthServiceInput{
authService, err := service.NewAuthService(service.AuthServiceInput{
Log: log, Log: log,
Config: &cfg, Config: &cfg,
Runtime: &runtime, Runtime: &runtime,
@@ -278,8 +277,6 @@ func TestContextMiddleware(t *testing.T) {
PolicyEngine: policyEngine, PolicyEngine: policyEngine,
}) })
require.NoError(t, err)
contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{ contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{
Log: log, Log: log,
RuntimeConfig: &runtime, RuntimeConfig: &runtime,
+19 -20
View File
@@ -49,6 +49,7 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
ACLs: ACLsConfig{ ACLs: ACLsConfig{
Policy: "allow", Policy: "allow",
}, },
LockdownEnabled: true,
}, },
UI: UIConfig{ UI: UIConfig{
Title: "Tinyauth", Title: "Tinyauth",
@@ -101,23 +102,22 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
} }
type Config struct { type Config struct {
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"` AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"`
ConfigFile string `description:"Path to config file." yaml:"-" gen:"include"` Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"` Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"` Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"` Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"` Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"`
Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"` Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"` OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"` OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"` UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"` LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"` Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"` Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
// enable the cli warning on experimental features LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"` Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"` ConfigFile string `description:"Path to config file." yaml:"-"`
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -151,6 +151,7 @@ type AuthConfig struct {
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"` SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"`
LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled,omitempty"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"` ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"`
} }
@@ -238,9 +239,7 @@ type LogStreamConfig struct {
Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"` Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"`
} }
type ExperimentalConfig struct { type ExperimentalConfig struct{}
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
}
type TailscaleConfig struct { type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"` Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"`
+11 -12
View File
@@ -1,18 +1,17 @@
package model package model
type RuntimeConfig struct { type RuntimeConfig struct {
AppURL string AppURL string
UUID string UUID string
CookieDomain string CookieDomain string
SessionCookieName string SessionCookieName string
CSRFCookieName string CSRFCookieName string
RedirectCookieName string RedirectCookieName string
OAuthSessionCookieName string OAuthSessionCookieName string
LocalUsers []LocalUser LocalUsers []LocalUser
OAuthProviders map[string]OAuthServiceConfig OAuthProviders map[string]OAuthServiceConfig
OAuthWhitelist []string OAuthWhitelist []string
ConfiguredProviders []Provider ConfiguredProviders []Provider
TrustedProxiesConfigured bool
} }
type Provider struct { type Provider struct {
-10
View File
@@ -7,16 +7,6 @@ const (
UserLDAP UserLDAP
) )
func (t UserSearchType) String() string {
switch t {
case UserLocal:
return "local"
case UserLDAP:
return "ldap"
}
return "unknown"
}
type LDAPUser struct { type LDAPUser struct {
DN string DN string
Groups []string Groups []string
+1 -14
View File
@@ -1,7 +1,6 @@
package service package service
import ( import (
"errors"
"regexp" "regexp"
"strings" "strings"
@@ -44,10 +43,6 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
rule.Log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist") rule.Log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email) match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email)
if err != nil { if err != nil {
if errors.Is(err, utils.ErrFilterEmpty) {
rule.Log.App.Debug().Msg("OAuth whitelist is empty, abstaining")
return EffectAbstain
}
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist") rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist")
return EffectDeny return EffectDeny
} }
@@ -77,7 +72,7 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername()) match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
if err != nil { if err != nil {
if errors.Is(err, utils.ErrFilterEmpty) { if err == utils.ErrFilterEmpty {
return EffectAbstain return EffectAbstain
} }
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list") rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
@@ -220,10 +215,6 @@ type IPAllowedRule struct {
} }
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect { func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
if !ctx.TrustedProxiesConfigured {
return EffectAllow // We can't block the proxy
}
// merge global and per-app block/allow lists // merge global and per-app block/allow lists
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...) blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...) allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
@@ -272,10 +263,6 @@ type IPBypassedRule struct {
} }
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect { func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
if !ctx.TrustedProxiesConfigured {
return EffectDeny
}
// merge global and per-app bypass lists // merge global and per-app bypass lists
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...) bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
if ctx.ACLs != nil { if ctx.ACLs != nil {
+26 -77
View File
@@ -44,21 +44,6 @@ func TestUserAllowedRule(t *testing.T) {
}, },
expected: EffectAbstain, expected: EffectAbstain,
}, },
{
name: "abstains when filter is empty",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: ""},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAbstain,
},
{ {
name: "allows OAuth user when email matches whitelist", name: "allows OAuth user when email matches whitelist",
ctx: &ACLContext{ ctx: &ACLContext{
@@ -626,20 +611,11 @@ func TestIPAllowedRule(t *testing.T) {
ctx *ACLContext ctx *ACLContext
expected Effect expected Effect
}{ }{
{
name: "when trusted proxies are not configured, IP is allowed",
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
{ {
name: "allows when ACLs are nil and no global lists configured", name: "allows when ACLs are nil and no global lists configured",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -649,8 +625,7 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Block: []string{"10.0.0.1"}}, IP: model.AppIP{Block: []string{"10.0.0.1"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -662,9 +637,8 @@ func TestIPAllowedRule(t *testing.T) {
}, },
}, },
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -674,8 +648,7 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}}, IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
}, },
IP: net.ParseIP("192.168.1.10"), IP: net.ParseIP("192.168.1.10"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -687,9 +660,8 @@ func TestIPAllowedRule(t *testing.T) {
}, },
}, },
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("192.168.1.10"), IP: net.ParseIP("192.168.1.10"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -699,17 +671,15 @@ func TestIPAllowedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}}, IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
{ {
name: "allows when no block or allow lists are configured", name: "allows when no block or allow lists are configured",
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -722,8 +692,7 @@ func TestIPAllowedRule(t *testing.T) {
Allow: []string{"10.0.0.1"}, Allow: []string{"10.0.0.1"},
}, },
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -736,8 +705,7 @@ func TestIPAllowedRule(t *testing.T) {
Allow: []string{"10.0.0.1"}, Allow: []string{"10.0.0.1"},
}, },
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -767,23 +735,12 @@ func TestIPBypassedRule(t *testing.T) {
ctx *ACLContext ctx *ACLContext
expected Effect expected Effect
}{ }{
{
name: "when trusted proxies are not configured, IP is not bypassed",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: false,
},
expected: EffectDeny,
},
{ {
name: "deny when ACLs are nil and no global bypass", name: "deny when ACLs are nil and no global bypass",
rule: defaultIPBR, rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -791,9 +748,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "allows when ACLs are nil but IP matches global bypass", name: "allows when ACLs are nil but IP matches global bypass",
rule: globBypassIPBR, rule: globBypassIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -801,9 +757,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "denies when ACLs are nil and IP does not match global bypass", name: "denies when ACLs are nil and IP does not match global bypass",
rule: globBypassIPBR, rule: globBypassIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: nil, ACLs: nil,
IP: net.ParseIP("192.168.1.1"), IP: net.ParseIP("192.168.1.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -814,8 +769,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -826,8 +780,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}}, IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -838,8 +791,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("10.0.0.5"), IP: net.ParseIP("10.0.0.5"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
@@ -850,8 +802,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}}, IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
}, },
IP: net.ParseIP("192.168.1.1"), IP: net.ParseIP("192.168.1.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -859,9 +810,8 @@ func TestIPBypassedRule(t *testing.T) {
name: "denies when bypass list is empty", name: "denies when bypass list is empty",
rule: defaultIPBR, rule: defaultIPBR,
ctx: &ACLContext{ ctx: &ACLContext{
ACLs: &model.App{}, ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectDeny, expected: EffectDeny,
}, },
@@ -872,8 +822,7 @@ func TestIPBypassedRule(t *testing.T) {
ACLs: &model.App{ ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}}, IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
}, },
IP: net.ParseIP("10.0.0.1"), IP: net.ParseIP("10.0.0.1"),
TrustedProxiesConfigured: true,
}, },
expected: EffectAllow, expected: EffectAllow,
}, },
+14 -63
View File
@@ -1,17 +1,15 @@
package service package service
import ( import (
"errors"
"strings" "strings"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig" "go.uber.org/dig"
) )
type LabelProvider interface { type LabelProvider interface {
Lookup(locator func(name string, app *model.App) bool) error GetLabels(appDomain string) (*model.App, error)
} }
type AccessControlsService struct { 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) { func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
var domainMatch *model.App
var nameMatch *model.App var nameMatch *model.App
var nameMatchedApps []string
locatorFunc := func(name string, app *model.App) bool { // First try to find a matching app by domain, then fallback to matching by app name (subdomain)
if app.Config.Domain != "" { for app, config := range service.config.Apps {
err := v.Validate(app.Config.Domain, domain) if config.Config.Domain == domain {
if err == nil { service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain") return &config
domainMatch = app
return true
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
}
} }
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) { if strings.SplitN(domain, ".", 2)[0] == app {
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name") service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = app nameMatch = &config
nameMatchedApps = append(nameMatchedApps, name)
} }
return false
} }
err := lookup(locatorFunc) return nameMatch
if err != nil {
return nil, err
}
if domainMatch != nil {
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by domain")
return domainMatch, nil
}
if nameMatch == nil {
service.log.App.Debug().Str("domain", domain).Msg("No match found for domain, skipping")
return nil, nil
}
if len(nameMatchedApps) > 1 {
service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match")
}
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
return nameMatch, nil
}
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
for app, config := range service.config.Apps {
if ok := locator(app, &config); ok {
return nil
}
}
return nil
})
} }
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config // First check in the static config
app, err := service.lookupStaticACLs(domain) app := service.lookupStaticACLs(domain)
// Will never return an error here, but we need to check it
if err != nil {
return nil, err
}
if app != nil { if app != nil {
service.log.App.Debug().Msg("Using static ACLs for app") service.log.App.Debug().Msg("Using static ACLs for app")
@@ -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 we have a label provider configured, try to get ACLs from it
if service.labelProvider != nil { if service.labelProvider != nil {
return service.getACLs(domain, service.labelProvider.Lookup) return service.labelProvider.GetLabels(domain)
} }
// No labels // no labels
return nil, nil return nil, nil
} }
+170 -130
View File
@@ -4,184 +4,224 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
type mockProvider struct { type mockLabelProvider struct {
acls map[string]model.App getLabelsFn func(appDomain string) (*model.App, error)
shouldError bool calledWith string
callCount int
} }
func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider { func (m *mockLabelProvider) GetLabels(appDomain string) (*model.App, error) {
return &mockProvider{acls: acls, shouldError: shouldError} m.calledWith = appDomain
} m.callCount++
if m.getLabelsFn != nil {
func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error { return m.getLabelsFn(appDomain)
if m.shouldError {
return errors.New("mock error")
} }
for name, app := range m.acls { return nil, nil
if ok := locator(name, &app); ok {
return nil
}
}
return nil
} }
func TestAccessControlsService(t *testing.T) { func TestLookupStaticACLs(t *testing.T) {
log := logger.NewLogger().WithTestConfig() log := logger.NewLogger().WithTestConfig()
log.Init() log.Init()
tests := []struct { tests := []struct {
name string name string
domain string apps map[string]model.App
acls map[string]model.App domain string
want *model.App expectNil bool
expectedDomain string
}{ }{
{ {
name: "returns ACLs for domain", name: "returns nil when no apps are configured",
domain: "example.com", apps: nil,
acls: map[string]model.App{ domain: "foo.example.com",
"foo": {Config: model.AppConfig{Domain: "example.com"}}, expectNil: true,
},
want: &model.App{Config: model.AppConfig{Domain: "example.com"}},
}, },
{ {
name: "returns ACLs for domain with port", name: "returns nil when no app matches",
domain: "example.com:8080", apps: map[string]model.App{
acls: map[string]model.App{ "foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
"foo": {Config: model.AppConfig{Domain: "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", name: "matches by exact domain",
domain: "example.com.", apps: map[string]model.App{
acls: map[string]model.App{ "foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
"foo": {Config: model.AppConfig{Domain: "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", name: "matches by app name when domain does not match any app",
domain: "bücher.example.com", apps: map[string]model.App{
acls: map[string]model.App{ "foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
"foo": {Config: model.AppConfig{Domain: "bücher.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", name: "matches by app name for nested subdomains",
domain: "bücher.example.com", apps: map[string]model.App{
acls: map[string]model.App{ "foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
"foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.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", name: "selects the app matching by domain among multiple apps",
domain: "Example.com", apps: map[string]model.App{
acls: map[string]model.App{ "unrelated": {Config: model.AppConfig{Domain: "other.example.com"}},
"foo": {Config: model.AppConfig{Domain: "example.com"}}, "target": {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: "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,
}, },
} }
// run once for a mock provider for _, tt := range tests {
for _, test := range tests { t.Run(tt.name, func(t *testing.T) {
t.Run(test.name+"(getACLs)", func(t *testing.T) { svc := NewAccessControlsService(AccessControlServiceInput{
mock := newMockProvider(test.acls, false)
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log, Log: log,
Config: &model.Config{}, Config: &model.Config{Apps: tt.apps},
LabelProvider: mock, LabelProvider: nil,
}) })
app, err := acls.getACLs(test.domain, mock.Lookup) got := svc.lookupStaticACLs(tt.domain)
require.NoError(t, err) if tt.expectNil {
require.Equal(t, test.want, app) assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, tt.expectedDomain, got.Config.Domain)
}) })
} }
}
// run again for static acls func TestGetAccessControls(t *testing.T) {
for _, test := range tests { log := logger.NewLogger().WithTestConfig()
t.Run(test.name+"(staticACLs)", func(t *testing.T) { log.Init()
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log, t.Run("returns static ACLs when domain matches", func(t *testing.T) {
Config: &model.Config{ config := model.Config{
Apps: test.acls, 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) svc := NewAccessControlsService(AccessControlServiceInput{
require.Equal(t, test.want, app) Log: log,
Config: &config,
LabelProvider: nil,
}) })
}
// get acls should return an error when the provider fails got, err := svc.GetAccessControls("foo.example.com")
mock := newMockProvider(map[string]model.App{}, true)
acls := NewAccessControlsService(AccessControlServiceInput{ require.NoError(t, err)
Log: log, require.NotNil(t, got)
Config: &model.Config{}, 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 t.Run("returns nil when no static match and no label provider", func(t *testing.T) {
// static when static acls are configured svc := NewAccessControlsService(AccessControlServiceInput{
acls = NewAccessControlsService(AccessControlServiceInput{ Log: log,
Log: log, Config: &model.Config{},
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{ Apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}}, "foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
}, },
}, }
}) svc := NewAccessControlsService(AccessControlServiceInput{
app, err := acls.GetAccessControls("foo.example.com") Log: log,
require.NoError(t, err) Config: &config,
require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app) LabelProvider: provider,
})
// should return nil for no apps got, err := svc.GetAccessControls("foo.example.com")
app, err = acls.GetAccessControls("bar.example.com")
require.NoError(t, err)
require.Nil(t, app)
// Should use label provider if available require.NoError(t, err)
mock = newMockProvider(map[string]model.App{ require.NotNil(t, got)
"bar": { assert.Equal(t, "foo.example.com", got.Config.Domain)
Config: model.AppConfig{Domain: "bar.example.com"}, assert.Equal(t, 0, mock.callCount)
}, })
}, false)
acls = NewAccessControlsService(AccessControlServiceInput{ t.Run("propagates label provider errors", func(t *testing.T) {
Log: log, providerErr := errors.New("provider boom")
Config: &model.Config{}, mock := &mockLabelProvider{
LabelProvider: mock, 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" "math/big"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/steveiliop56/ding" "github.com/steveiliop56/ding"
@@ -15,7 +16,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig" "go.uber.org/dig"
"github.com/google/uuid" "github.com/google/uuid"
@@ -69,13 +69,21 @@ type AuthService struct {
tailscale *TailscaleService tailscale *TailscaleService
policyEngine *PolicyEngine policyEngine *PolicyEngine
dummyHash string lockdown struct {
active bool
until time.Time
ctx context.Context
cancelFunc context.CancelFunc
mu sync.RWMutex
}
caches struct { caches struct {
login *cache.CacheStore[LoginAttempt] login *CacheStore[LoginAttempt]
oauth *cache.CacheStore[OAuthPendingSession] oauth *CacheStore[OAuthPendingSession]
ldap *cache.CacheStore[[]string] ldap *CacheStore[[]string]
} }
maxLoginLimits int
} }
type AuthServiceInput struct { type AuthServiceInput struct {
@@ -93,7 +101,7 @@ type AuthServiceInput struct {
PolicyEngine *PolicyEngine PolicyEngine *PolicyEngine
} }
func NewAuthService(i AuthServiceInput) (*AuthService, error) { func NewAuthService(i AuthServiceInput) *AuthService {
service := &AuthService{ service := &AuthService{
log: i.Log, log: i.Log,
runtime: i.Runtime, runtime: i.Runtime,
@@ -106,19 +114,19 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) {
policyEngine: i.PolicyEngine, policyEngine: i.PolicyEngine,
} }
// dummy hash // get the max login limits based on the number of users and the configured max retries
dummyHash, err := bcrypt.GenerateFromPassword([]byte(utils.GenerateString(8)), bcrypt.DefaultCost) service.maxLoginLimits = service.calculateLockdownLimit()
if err != nil { loginCacheSize := 0
return nil, fmt.Errorf("failed to generate dummy hash: %w", err)
if !service.config.Auth.LockdownEnabled {
loginCacheSize = service.maxLoginLimits
} }
service.dummyHash = string(dummyHash)
// caches setup // caches setup
oauthCache := cache.NewCacheStore[OAuthPendingSession](256) oauthCache := NewCacheStore[OAuthPendingSession](256)
loginCache := cache.NewCacheStore[LoginAttempt](service.calculateLockdownLimit()) loginCache := NewCacheStore[LoginAttempt](loginCacheSize)
ldapCache := cache.NewCacheStore[[]string](1024) ldapCache := NewCacheStore[[]string](1024)
service.caches.oauth = oauthCache service.caches.oauth = oauthCache
service.caches.login = loginCache service.caches.login = loginCache
@@ -140,28 +148,7 @@ func NewAuthService(i AuthServiceInput) (*AuthService, error) {
} }
}, ding.RingMinor) }, ding.RingMinor)
i.Ding.Go(func(ctx context.Context) { return service
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
service.log.App.Debug().Msg("Updating login cache limits")
service.caches.login.SetMaxSize(service.calculateLockdownLimit())
service.log.App.Debug().Msg("Login cache limits updated")
case <-ctx.Done():
return
}
}
}, ding.RingMinor)
return service, nil
}
func (auth *AuthService) DummyPasswordCheck(password string) {
bcrypt.CompareHashAndPassword([]byte(auth.dummyHash), []byte(password))
} }
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) { func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
@@ -258,6 +245,10 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
} }
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
if locked, remaining := auth.IsInLockdown(); locked {
return true, remaining
}
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 { if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
return false, 0 return false, 0
} }
@@ -280,7 +271,15 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
return 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) entry, ok := actions.Get(identifier)
if !ok { 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") 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 { if err != nil {
return nil, fmt.Errorf("failed to generate session uuid: %w", err) return nil, fmt.Errorf("failed to generate session uuid: %w", err)
@@ -362,7 +361,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second) expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
session := repository.CreateSessionParams{ session := repository.CreateSessionParams{
UUID: u.String(), UUID: uuid.String(),
Username: data.Username, Username: data.Username,
Email: data.Email, Email: data.Email,
Name: data.Name, Name: data.Name,
@@ -617,7 +616,62 @@ func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPending
return &session, nil 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() { func (auth *AuthService) ClearLoginAttempts() {
auth.caches.login.Clear() auth.caches.login.Clear()
} }
@@ -1,4 +1,4 @@
package cache package service
import ( import (
"slices" "slices"
@@ -33,8 +33,8 @@ func NewCacheStore[T any](maxSize int) *CacheStore[T] {
} }
} }
// WithLock allows performing multiple operations on a single lock. // With lock allows performing multiple operations on the cache store atomically.
// The provided mutate function receives a set of actions (Set, Get, Delete, Update) that // 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. // can be used to manipulate the cache store within the locked context.
func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) { func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) {
cs.mu.Lock() cs.mu.Lock()
@@ -195,19 +195,3 @@ func (cs *CacheStore[T]) Clear() {
cs.cache = make(map[string]cacheEntry[T]) cs.cache = make(map[string]cacheEntry[T])
cs.order = make([]string, 0) cs.order = make([]string, 0)
} }
func (cs *CacheStore[T]) SetMaxSize(maxSize int) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.maxSize = maxSize
for len(cs.cache) > maxSize {
if !cs.evictOne() {
break
}
}
}
func (cs *CacheStore[T]) GetMaxSize() int {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.maxSize
}
@@ -1,4 +1,4 @@
package cache package service
import ( import (
"strconv" "strconv"
@@ -316,21 +316,6 @@ func TestCacheStoreSizeAndClear(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
} }
func TestCacheStoreWithMaxSize(t *testing.T) {
cs := NewCacheStore[string](0)
assert.Equal(t, 0, cs.Size())
for i := 0; i < 100; i++ {
cs.Set(strconv.Itoa(i), strconv.Itoa(i), 0)
}
assert.Equal(t, 100, cs.Size())
cs.SetMaxSize(10)
assert.Equal(t, 10, cs.Size())
}
func TestCacheStoreWithLock(t *testing.T) { func TestCacheStoreWithLock(t *testing.T) {
cs := NewCacheStore[int](0) cs := NewCacheStore[int](0)
cs.Set("counter", 1, 0) cs.Set("counter", 1, 0)
+24 -12
View File
@@ -2,7 +2,7 @@ package service
import ( import (
"context" "context"
"fmt" "strings"
"github.com/steveiliop56/ding" "github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
@@ -31,6 +31,7 @@ type DockerServiceInput struct {
} }
func NewDockerService(i DockerServiceInput) (*DockerService, error) { func NewDockerService(i DockerServiceInput) (*DockerService, error) {
client, err := client.NewClientWithOpts(client.FromEnv) client, err := client.NewClientWithOpts(client.FromEnv)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -67,38 +68,49 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
return docker.client.ContainerInspect(docker.context, containerId) return docker.client.ContainerInspect(docker.context, containerId)
} }
func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error { func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
if !docker.isConnected { if !docker.isConnected {
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels") docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
return nil return nil, nil
} }
containers, err := docker.getContainers() containers, err := docker.getContainers()
if err != nil { if err != nil {
return fmt.Errorf("failed to get containers: %w", err) return nil, err
} }
for _, ctr := range containers { for _, ctr := range containers {
inspect, err := docker.inspectContainer(ctr.ID) inspect, err := docker.inspectContainer(ctr.ID)
if err != nil { if err != nil {
docker.log.App.Error().Err(err).Msgf("Failed to inspect container %s", ctr.ID) return nil, err
continue
} }
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps") labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
if err != nil { if err != nil {
docker.log.App.Warn().Err(err).Msgf("Failed to decode labels for container %s", ctr.ID) return nil, err
continue
} }
for app, config := range labels.Apps { var nameMatch *model.App
if ok := locator(app, &config); ok {
return nil // 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) { func (docker *DockerService) watchAndClose(ctx context.Context) {
+117 -85
View File
@@ -12,7 +12,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders" "github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig" "go.uber.org/dig"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -23,23 +22,31 @@ import (
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
) )
type ingressEntry struct {
name string
app model.App
}
type ingressKey struct { type ingressKey struct {
namespace string namespace string
name string name string
} }
type ingressAppKey struct {
ingressKey
appName string
}
type ingressApp struct {
domain string
appName string
app model.App
}
type KubernetesService struct { type KubernetesService struct {
log *logger.Logger log *logger.Logger
client dynamic.Interface client dynamic.Interface
connected bool started bool
mu sync.RWMutex mu sync.RWMutex
ingressEntries map[ingressKey][]ingressEntry ingressApps map[ingressKey][]ingressApp
domainIndex map[string]ingressAppKey
appNameIndex map[string]ingressAppKey
} }
type KubernetesServiceInput struct { 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") i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
service := &KubernetesService{ service := &KubernetesService{
log: i.Log, log: i.Log,
client: client, client: client,
ingressEntries: make(map[ingressKey][]ingressEntry), ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
} }
i.Ding.Go(func(ctx context.Context) { i.Ding.Go(func(ctx context.Context) {
service.watchGVR(gvr, ctx) service.watchGVR(gvr, ctx)
}, ding.RingMajor) }, ding.RingMajor)
service.connected = true service.started = true
i.Log.App.Debug().Msg("Kubernetes label provider started successfully") i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
return service, nil return service, nil
} }
func (k *KubernetesService) addIngressEntries(key ingressKey, entries []ingressEntry) { func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
k.ingressEntries[key] = entries
key := ingressKey{namespace, name}
// Remove existing entries for this ingress
if existing, ok := k.ingressApps[key]; ok {
for _, app := range existing {
delete(k.domainIndex, app.domain)
delete(k.appNameIndex, app.appName)
}
}
// Add new entries
k.ingressApps[key] = apps
for _, app := range apps {
appKey := ingressAppKey{key, app.appName}
k.domainIndex[app.domain] = appKey
k.appNameIndex[app.appName] = appKey
}
} }
func (k *KubernetesService) removeIngress(key ingressKey) { func (k *KubernetesService) removeIngress(namespace, name string) {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
delete(k.ingressEntries, key)
key := ingressKey{namespace, name}
if apps, ok := k.ingressApps[key]; ok {
for _, app := range apps {
delete(k.domainIndex, app.domain)
delete(k.appNameIndex, app.appName)
}
delete(k.ingressApps, key)
}
} }
func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) { func (k *KubernetesService) getByDomain(domain string) *model.App {
k.mu.RLock() k.mu.RLock()
defer k.mu.RUnlock() defer k.mu.RUnlock()
// O(n^2) is not great but the number of ingress entries is expected to be small if appKey, ok := k.domainIndex[domain]; ok {
for _, entries := range k.ingressEntries { if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
for _, entry := range entries { for i := range apps {
if ok := locator(entry.name, &entry.app); ok { app := &apps[i]
return 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) { 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) paths, err := k.extractPaths(rule)
if err != nil { if err != nil {
// This is purely to warn users // This is purely to warn users, it doesn't affect our ability to extract hosts so we won't fail the whole operation
// It doesn't affect our ability to extract hosts, so we won't fail the whole operation
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule") k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
continue continue
} }
@@ -184,72 +235,44 @@ func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]str
} }
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
key := ingressKey{ namespace := item.GetNamespace()
namespace: item.GetNamespace(), name := item.GetName()
name: item.GetName(),
}
annotations := item.GetAnnotations() annotations := item.GetAnnotations()
if annotations == nil { if annotations == nil {
k.removeIngress(key) k.removeIngress(namespace, name)
return return
} }
hosts, err := k.extractHosts(item) hosts, err := k.extractHosts(item)
if err != nil { if err != nil {
k.removeIngress(key) k.removeIngress(namespace, name)
return return
} }
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps") labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil { if err != nil {
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode ingress labels, skipping") k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
k.removeIngress(key) k.removeIngress(namespace, name)
return return
} }
var apps []ingressApp
var entries []ingressEntry for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == "" {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
for name, config := range labels.Apps {
registerApp := len(hosts) == 0
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) {
registerApp = true
}
}
if !registerApp {
for _, host := range hosts {
if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) {
registerApp = true
break
}
}
}
if !registerApp {
k.log.App.Warn().Str("namespace", key.namespace).Str("name", name).Str("appName", name).Msg("App name or domain does not match with ingress")
continue continue
} }
if len(hosts) > 0 && !slices.Contains(hosts, appLabels.Config.Domain) {
entries = append(entries, ingressEntry{ 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")
name: name, continue
app: config, }
apps = append(apps, ingressApp{
domain: appLabels.Config.Domain,
appName: appName,
app: appLabels,
}) })
} }
if len(apps) == 0 {
if len(entries) == 0 { k.removeIngress(namespace, name)
k.removeIngress(key) } else {
return k.addIngressApps(namespace, name, apps)
} }
k.addIngressEntries(key, entries)
} }
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error { func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
@@ -292,10 +315,7 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
case watch.Added, watch.Modified: case watch.Added, watch.Modified:
k.updateFromItem(item) k.updateFromItem(item)
case watch.Deleted: case watch.Deleted:
k.removeIngress(ingressKey{ k.removeIngress(item.GetNamespace(), item.GetName())
namespace: item.GetNamespace(),
name: item.GetName(),
})
} }
case <-resyncTicker.C: case <-resyncTicker.C:
if err := k.resyncGVR(gvr, ctx); err != nil { if err := k.resyncGVR(gvr, ctx); err != nil {
@@ -342,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 { func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
if !k.connected { if !k.started {
k.log.App.Debug().Msg("Kubernetes label provider not started, skipping") k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping")
return nil 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 -485
View File
@@ -1,7 +1,6 @@
package service package service
import ( import (
"strings"
"testing" "testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -26,66 +25,47 @@ func TestKubernetesService(t *testing.T) {
description: "Cache by domain returns app and misses unknown domain", description: "Cache by domain returns app and misses unknown domain",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
svc.addIngressEntries(ingressKey{ svc.addIngressApps("default", "my-ingress", []ingressApp{
namespace: "default", {domain: "foo.example.com", appName: "foo", app: app},
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
var got *model.App got := svc.getByDomain("foo.example.com")
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got) require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain) assert.Equal(t, "foo.example.com", got.Config.Domain)
got = svc.getByDomain("notfound.example.com")
assert.Nil(t, got)
},
},
{
description: "Cache by app name returns app and misses unknown name",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "bar.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
{domain: "bar.example.com", appName: "bar", app: app},
})
got := svc.getByAppName("bar")
require.NotNil(t, got)
assert.Equal(t, "bar.example.com", got.Config.Domain)
got = svc.getByAppName("notfound")
assert.Nil(t, got)
}, },
}, },
{ {
description: "RemoveIngress clears domain and app name entries", description: "RemoveIngress clears domain and app name entries",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} app := model.App{Config: model.AppConfig{Domain: "baz.example.com"}}
svc.addIngressEntries(ingressKey{ svc.addIngressApps("default", "my-ingress", []ingressApp{
namespace: "default", {domain: "baz.example.com", appName: "baz", app: app},
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
var got *model.App svc.removeIngress("default", "my-ingress")
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
got = nil got := svc.getByDomain("baz.example.com")
svc.removeIngress(ingressKey{ assert.Nil(t, got)
namespace: "default", got = svc.getByAppName("baz")
name: "my-ingress",
})
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got) assert.Nil(t, got)
}, },
}, },
@@ -93,130 +73,67 @@ func TestKubernetesService(t *testing.T) {
description: "AddIngressApps replaces stale entries for the same ingress", description: "AddIngressApps replaces stale entries for the same ingress",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}} old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
svc.addIngressEntries(ingressKey{ svc.addIngressApps("default", "my-ingress", []ingressApp{
namespace: "default", {domain: "old.example.com", appName: "old", app: old},
name: "my-ingress",
}, []ingressEntry{
{
app: old,
name: "foo",
},
}) })
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}} updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
svc.addIngressEntries(ingressKey{ svc.addIngressApps("default", "my-ingress", []ingressApp{
namespace: "default", {domain: "new.example.com", appName: "new", app: updated},
name: "my-ingress",
}, []ingressEntry{
{
app: updated,
name: "foo",
},
}) })
var got *model.App got := svc.getByDomain("old.example.com")
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "old.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got) assert.Nil(t, got)
svc.getEntry(func(name string, app *model.App) bool { got = svc.getByDomain("new.example.com")
if app.Config.Domain == "new.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got) require.NotNil(t, got)
assert.Equal(t, "new.example.com", got.Config.Domain) assert.Equal(t, "new.example.com", got.Config.Domain)
}, },
}, },
{ {
description: "GetLabels returns app from cache when connected", description: "GetLabels returns app from cache when started",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true svc.started = true
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}} app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
svc.addIngressEntries(ingressKey{ svc.addIngressApps("default", "ing", []ingressApp{
namespace: "default", {domain: "hit.example.com", appName: "hit", app: app},
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
var got *model.App got, err := svc.GetLabels("hit.example.com")
err := svc.Lookup(func(name string, app *model.App) bool {
if app.Config.Domain == "hit.example.com" {
got = app
return true
}
return false
})
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "hit.example.com", got.Config.Domain) assert.Equal(t, "hit.example.com", got.Config.Domain)
}, },
}, },
{ {
description: "GetLabels returns empty app on cache miss when started", description: "GetLabels returns empty app on cache miss when started",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true svc.started = true
var got *model.App got, err := svc.GetLabels("notfound.example.com")
err := svc.Lookup(func(name string, app *model.App) bool {
if app.Config.Domain == "notfound.example.com" {
got = app
return true
}
return false
})
require.NoError(t, err) require.NoError(t, err)
require.Nil(t, got) assert.Nil(t, got)
}, },
}, },
{ {
description: "GetLabels resolves app by app name", description: "GetLabels resolves app by app name",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true svc.started = true
app := model.App{Path: model.AppPath{Allow: "/foo"}} app := model.App{Config: model.AppConfig{Domain: "myapp.internal.example.com"}}
svc.addIngressEntries(ingressKey{ svc.addIngressApps("default", "ing", []ingressApp{
namespace: "default", {domain: "myapp.internal.example.com", appName: "myapp", app: app},
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
var got *model.App got, err := svc.GetLabels("myapp.internal.example.com")
err := svc.Lookup(func(name string, app *model.App) bool {
if strings.HasPrefix("foo.internal.example.com", "foo.") {
got = app
return true
}
return false
})
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, got) assert.Equal(t, "myapp.internal.example.com", got.Config.Domain)
assert.Equal(t, "/foo", got.Path.Allow)
}, },
}, },
{ {
description: "GetLabels returns empty app when service not yet started", description: "GetLabels returns empty app when service not yet started",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
var got *model.App got, err := svc.GetLabels("anything.example.com")
err := svc.Lookup(func(name string, app *model.App) bool {
return false
})
require.NoError(t, err) require.NoError(t, err)
assert.Nil(t, got) assert.Nil(t, got)
}, },
@@ -234,15 +151,7 @@ func TestKubernetesService(t *testing.T) {
svc.updateFromItem(&item) svc.updateFromItem(&item)
var got *model.App got := svc.getByDomain("myapp.example.com")
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got) require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain) assert.Equal(t, "myapp.example.com", got.Config.Domain)
assert.Equal(t, "alice", got.Users.Allow) assert.Equal(t, "alice", got.Users.Allow)
@@ -252,354 +161,17 @@ func TestKubernetesService(t *testing.T) {
description: "UpdateFromItem with no annotations removes existing cache entries", description: "UpdateFromItem with no annotations removes existing cache entries",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}} app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
svc.addIngressEntries(ingressKey{ svc.addIngressApps("default", "test-ingress", []ingressApp{
namespace: "default", {domain: "todelete.example.com", appName: "todelete", app: app},
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
}) })
item := unstructured.Unstructured{} item := unstructured.Unstructured{}
item.SetNamespace("default") item.SetNamespace("default")
item.SetName("my-ingress") item.SetName("test-ingress")
svc.updateFromItem(&item) svc.updateFromItem(&item)
var got *model.App got := svc.getByDomain("todelete.example.com")
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "todelete.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "ExtractPaths returns all non empty paths from a rule",
run: func(t *testing.T, svc *KubernetesService) {
rule := map[string]any{
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/"},
map[string]any{"path": "/api"},
map[string]any{"path": ""},
map[string]any{"pathType": "Prefix"},
"not-a-map",
},
},
}
paths, err := svc.extractPaths(rule)
require.NoError(t, err)
assert.Equal(t, []string{"/", "/api"}, paths)
},
},
{
description: "ExtractPaths returns nothing when http or paths are missing",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{})
require.NoError(t, err)
assert.Empty(t, paths)
paths, err = svc.extractPaths(map[string]any{
"http": map[string]any{},
})
require.NoError(t, err)
assert.Empty(t, paths)
},
},
{
description: "ExtractPaths errors when http is not a map",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{
"http": "invalid",
})
require.Error(t, err)
assert.Nil(t, paths)
},
},
{
description: "ExtractPaths errors when paths is not a slice",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{
"http": map[string]any{
"paths": "invalid",
},
})
require.Error(t, err)
assert.Nil(t, paths)
},
},
{
description: "ExtractHosts returns hosts from all rules",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/"},
},
},
},
map[string]any{
"host": "bar.example.com",
},
map[string]any{
"host": "",
},
"not-a-map",
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts)
},
},
{
description: "ExtractHosts still returns hosts when a rule has no catch all path",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/api"},
},
},
},
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com"}, hosts)
},
},
{
description: "ExtractHosts still returns hosts when path extraction fails",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": "invalid",
},
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com"}, hosts)
},
},
{
description: "ExtractHosts returns nothing when spec.rules is missing",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Empty(t, hosts)
},
},
{
description: "ExtractHosts errors when spec.rules is not a slice",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.Error(t, err)
assert.Nil(t, hosts)
},
},
{
description: "UpdateFromItem registers app when its domain matches an ingress host",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "myapp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain)
},
},
{
description: "UpdateFromItem registers app when its name matches an ingress host prefix",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.users.allow": "alice",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "MyApp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "alice", got.Users.Allow)
},
},
{
description: "UpdateFromItem skips apps that match neither host nor name",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "other.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem falls back to app name when the domain is invalid",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "not a domain",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "myapp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
},
},
{
description: "UpdateFromItem removes entries when host extraction fails",
run: func(t *testing.T, svc *KubernetesService) {
key := ingressKey{
namespace: "default",
name: "test-ingress",
}
svc.addIngressEntries(key, []ingressEntry{
{
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace(key.namespace)
item.SetName(key.name)
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem removes entries when annotations are not decodable",
run: func(t *testing.T, svc *KubernetesService) {
key := ingressKey{
namespace: "default",
name: "test-ingress",
}
svc.addIngressEntries(key, []ingressEntry{
{
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace(key.namespace)
item.SetName(key.name)
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.oauthWhitelist": "[",
})
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got) assert.Nil(t, got)
}, },
}, },
@@ -608,8 +180,10 @@ func TestKubernetesService(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
svc := &KubernetesService{ svc := &KubernetesService{
ingressEntries: make(map[ingressKey][]ingressEntry), ingressApps: make(map[ingressKey][]ingressApp),
log: log, domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
log: log,
} }
test.run(t, svc) test.run(t, svc)
}) })
-1
View File
@@ -24,7 +24,6 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
httpClient := &http.Client{ httpClient := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
InsecureSkipVerify: config.Insecure, InsecureSkipVerify: config.Insecure,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
+8 -9
View File
@@ -27,7 +27,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig" "go.uber.org/dig"
) )
@@ -159,9 +158,9 @@ type OIDCService struct {
issuer string issuer string
caches struct { caches struct {
code *cache.CacheStore[AuthorizeCodeEntry] code *CacheStore[AuthorizeCodeEntry]
usedCode *cache.CacheStore[UsedCodeEntry] usedCode *CacheStore[UsedCodeEntry]
authorize *cache.CacheStore[AuthorizeRequest] authorize *CacheStore[AuthorizeRequest]
} }
} }
@@ -340,11 +339,11 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) {
i.Ding.Go(service.cleanupRoutine, ding.RingMinor) i.Ding.Go(service.cleanupRoutine, ding.RingMinor)
// Create caches // Create caches
codeCache := cache.NewCacheStore[AuthorizeCodeEntry](256) codeCash := NewCacheStore[AuthorizeCodeEntry](256)
usedCode := cache.NewCacheStore[UsedCodeEntry](256) usedCode := NewCacheStore[UsedCodeEntry](256)
authorize := cache.NewCacheStore[AuthorizeRequest](256) authorize := NewCacheStore[AuthorizeRequest](256)
service.caches.code = codeCache service.caches.code = codeCash
service.caches.usedCode = usedCode service.caches.usedCode = usedCode
service.caches.authorize = authorize service.caches.authorize = authorize
@@ -504,7 +503,7 @@ func (service *OIDCService) GetCodeEntry(codeHash string, clientId string) (*Aut
var entry AuthorizeCodeEntry var entry AuthorizeCodeEntry
var ok bool var ok bool
service.caches.code.WithLock(func(actions cache.CacheStoreActions[AuthorizeCodeEntry]) { service.caches.code.WithLock(func(actions CacheStoreActions[AuthorizeCodeEntry]) {
entry, ok = actions.Get(codeHash) entry, ok = actions.Get(codeHash)
if !ok { if !ok {
+4 -5
View File
@@ -29,11 +29,10 @@ type Rule interface {
} }
type ACLContext struct { type ACLContext struct {
ACLs *model.App ACLs *model.App
UserContext *model.UserContext UserContext *model.UserContext
IP net.IP IP net.IP
Path string Path string
TrustedProxiesConfigured bool
} }
type PolicyEngine struct { type PolicyEngine struct {
+4 -5
View File
@@ -10,7 +10,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/cache"
"go.uber.org/dig" "go.uber.org/dig"
) )
@@ -60,8 +59,8 @@ type TailscaleService struct {
apiToken string apiToken string
caches struct { caches struct {
devices *cache.CacheStore[tailscaleAPIDevices] devices *CacheStore[tailscaleAPIDevices]
users *cache.CacheStore[tailscaleAPIUsers] users *CacheStore[tailscaleAPIUsers]
} }
urls struct { urls struct {
@@ -101,8 +100,8 @@ func NewTailscaleService(i TailscaleServiceInput) (*TailscaleService, error) {
apiToken: apiToken, apiToken: apiToken,
} }
devicesCache := cache.NewCacheStore[tailscaleAPIDevices](0) devicesCache := NewCacheStore[tailscaleAPIDevices](0)
usersCache := cache.NewCacheStore[tailscaleAPIUsers](0) usersCache := NewCacheStore[tailscaleAPIUsers](0)
s.caches.devices = devicesCache s.caches.devices = devicesCache
s.caches.users = usersCache s.caches.users = usersCache
+3 -4
View File
@@ -163,10 +163,9 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
}, },
}, },
}, },
CookieDomain: "example.com", CookieDomain: "example.com",
AppURL: "https://tinyauth.example.com", AppURL: "https://tinyauth.example.com",
SessionCookieName: "tinyauth-session", SessionCookieName: "tinyauth-session",
TrustedProxiesConfigured: true,
} }
return config, runtime return config, runtime
+5 -46
View File
@@ -7,55 +7,10 @@ import (
"strings" "strings"
"github.com/weppos/publicsuffix-go/publicsuffix" "github.com/weppos/publicsuffix-go/publicsuffix"
"golang.org/x/net/idna"
) )
var ( // GetCookieDomain parses the app url and returns the domain value to use for cookies.
ErrEmptyURL = fmt.Errorf("invalid url")
)
func SafeParseAppURL(str string) (string, error) {
if strings.TrimSpace(str) == "" {
return "", ErrEmptyURL
}
u, err := url.Parse(str)
if err != nil {
return "", fmt.Errorf("invalid url: %w", err)
}
if u.Host == "" ||
(u.Scheme != "http" &&
u.Scheme != "https") {
return "", fmt.Errorf("invalid url, must be in format https(s)://host")
}
hostname := strings.ToLower(u.Hostname())
hostname = strings.TrimSuffix(hostname, ".")
if netIP := net.ParseIP(hostname); netIP != nil {
return "", fmt.Errorf("ip addresses not allowed")
}
hostname, err = idna.Lookup.ToASCII(hostname)
if err != nil {
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
}
appURL := fmt.Sprintf("%s://%s", u.Scheme, hostname)
if u.Port() != "" {
appURL += ":" + u.Port()
}
return appURL, nil
}
// GetCookieDomain parses the app URL and returns the domain value to use for cookies.
// When auth for subdomains is enabled, it strips the leftmost label // When auth for subdomains is enabled, it strips the leftmost label
// GetCookieDomain assumes the app URL is first parsed with SafeParseAppURL
// (e.g. sub1.sub2.domain.com -> sub2.domain.com), otherwise it returns the full hostname. // (e.g. sub1.sub2.domain.com -> sub2.domain.com), otherwise it returns the full hostname.
func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) { func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
u, err := url.Parse(appUrl) u, err := url.Parse(appUrl)
@@ -66,6 +21,10 @@ func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
hostname := strings.ToLower(u.Hostname()) hostname := strings.ToLower(u.Hostname())
if netIP := net.ParseIP(hostname); netIP != nil {
return "", fmt.Errorf("ip addresses not allowed")
}
parts := strings.Split(hostname, ".") parts := strings.Split(hostname, ".")
if len(parts) < 2 { if len(parts) < 2 {
+6 -85
View File
@@ -7,86 +7,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
) )
func TestSafeParseAPPURL(t *testing.T) { func TestGetRootDomain(t *testing.T) {
// Normal app url
appURL := "http://sub.tinyauth.app"
expected := "http://sub.tinyauth.app"
result, err := utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Strip path
appURL = "http://sub.tinyauth.app/path"
expected = "http://sub.tinyauth.app"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Preserve port
appURL = "http://sub.tinyauth.app:8080"
expected = "http://sub.tinyauth.app:8080"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Remove trailing dot
appURL = "http://sub.tinyauth.app."
expected = "http://sub.tinyauth.app"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Convert to ascii
appURL = "http://bücher.example.com"
expected = "http://xn--bcher-kva.example.com"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Lowercase
appURL = "HTTP://SUb.tinyAUth.aPP"
expected = "http://sub.tinyauth.app"
result, err = utils.SafeParseAppURL(appURL)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// Empty string
appURL = ""
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorIs(t, err, utils.ErrEmptyURL)
// Invalid URL
appURL = "invalidurl"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "invalid url")
// Non http or https URL
appURL = "ftp://sub.tinyauth.app"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "invalid url")
// Invalid punycode
appURL = "http://ab--cd.example.com"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "failed to convert hostname to ascii")
// IP address
appURL = "http://10.10.10.10"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "ip addresses not allowed")
// IPv6 address
appURL = "http://[::1]:8080"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "ip addresses not allowed")
// Invalid URL
appURL = "://"
_, err = utils.SafeParseAppURL(appURL)
assert.ErrorContains(t, err, "invalid url")
}
func TestGetCookieDomain(t *testing.T) {
// Normal case // Normal case
domain := "http://sub.tinyauth.app" domain := "http://sub.tinyauth.app"
expected := "tinyauth.app" expected := "tinyauth.app"
@@ -106,6 +27,11 @@ func TestGetCookieDomain(t *testing.T) {
_, err = utils.GetCookieDomain(domain, true) _, err = utils.GetCookieDomain(domain, true)
assert.EqualError(t, err, "invalid app url, must be in format subdomain.domain.tld or domain.tld") assert.EqualError(t, err, "invalid app url, must be in format subdomain.domain.tld or domain.tld")
// IP address
domain = "http://10.10.10.10"
_, err = utils.GetCookieDomain(domain, true)
assert.ErrorContains(t, err, "ip addresses not allowed")
// Invalid URL // Invalid URL
domain = "http://[::1]:namedport" domain = "http://[::1]:namedport"
_, err = utils.GetCookieDomain(domain, true) _, err = utils.GetCookieDomain(domain, true)
@@ -130,11 +56,6 @@ func TestGetCookieDomain(t *testing.T) {
_, err = utils.GetCookieDomain(domain, true) _, err = utils.GetCookieDomain(domain, true)
assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies") assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies")
// Domain managed by ICANN without subdomain
domain = "http://co.uk"
_, err = utils.GetCookieDomain(domain, true)
assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies")
// Domain without subdomain // Domain without subdomain
domain = "http://tinyauth.app" domain = "http://tinyauth.app"
expected = "tinyauth.app" expected = "tinyauth.app"
-10
View File
@@ -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)
})
}
}