Compare commits

..
Author SHA1 Message Date
Stavros 5c2cb08a7a fix: use constant time in user lookups 2026-07-14 15:14:26 +03:00
77 changed files with 2434 additions and 4301 deletions
+4 -7
View File
@@ -101,6 +101,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.
@@ -132,9 +134,9 @@ TINYAUTH_APPS_name_RESPONSE_BASICAUTH_USERNAME=
TINYAUTH_APPS_name_RESPONSE_BASICAUTH_PASSWORD= TINYAUTH_APPS_name_RESPONSE_BASICAUTH_PASSWORD=
# Path to the file containing the basic auth password. # Path to the file containing the basic auth password.
TINYAUTH_APPS_name_RESPONSE_BASICAUTH_PASSWORDFILE= TINYAUTH_APPS_name_RESPONSE_BASICAUTH_PASSWORDFILE=
# Disable authentication for only paths that match the regex string. # Comma-separated list of allowed paths.
TINYAUTH_APPS_name_PATH_ALLOW= TINYAUTH_APPS_name_PATH_ALLOW=
# Enable authentication for only paths that match the regex string. # Comma-separated list of blocked paths.
TINYAUTH_APPS_name_PATH_BLOCK= TINYAUTH_APPS_name_PATH_BLOCK=
# Comma-separated list of required LDAP groups. # Comma-separated list of required LDAP groups.
TINYAUTH_APPS_name_LDAP_GROUPS= TINYAUTH_APPS_name_LDAP_GROUPS=
@@ -223,11 +225,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.
+9 -1
View File
@@ -9,6 +9,8 @@ updates:
- "minor" - "minor"
schedule: schedule:
interval: "daily" interval: "daily"
cooldown:
default-days: 1
- package-ecosystem: "gomod" - package-ecosystem: "gomod"
directory: "/" directory: "/"
@@ -19,13 +21,19 @@ 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
+3 -3
View File
@@ -13,15 +13,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
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"
-43
View File
@@ -1,43 +0,0 @@
name: Run e2e tests
on:
workflow_dispatch:
pull_request:
branches:
- main
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # ratchet:pnpm/action-setup@v6
with:
package_json_file: ./e2e/package.json
- name: Set up Docker
uses: docker/setup-docker-action@6d7cfa65f60a9dda7b46e5513fa982536f3c9877 # ratchet:docker/setup-docker-action@v5
- name: Install dependencies
run: pnpm ci
working-directory: e2e
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
working-directory: e2e
- name: Run Playwright tests
run: pnpm test
working-directory: e2e
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: e2e/playwright-report/
retention-days: 5
+20 -20
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Delete old release - name: Delete old release
run: gh release delete --cleanup-tag --yes nightly || echo release not found run: gh release delete --cleanup-tag --yes nightly || echo release not found
@@ -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
@@ -38,7 +38,7 @@ jobs:
BUILD_TAGS: ${{ steps.metadata.outputs.BUILD_TAGS }} BUILD_TAGS: ${{ steps.metadata.outputs.BUILD_TAGS }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: nightly ref: nightly
@@ -58,17 +58,17 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: nightly ref: nightly
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
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"
@@ -107,17 +107,17 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: nightly ref: nightly
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
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"
@@ -156,7 +156,7 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: nightly ref: nightly
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -215,7 +215,7 @@ jobs:
- image-build - image-build
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: nightly ref: nightly
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -274,7 +274,7 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: nightly ref: nightly
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -333,7 +333,7 @@ jobs:
- image-build-arm - image-build-arm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: nightly ref: nightly
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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
+18 -18
View File
@@ -19,7 +19,7 @@ jobs:
BUILD_TAGS: ${{ steps.metadata.outputs.BUILD_TAGS }} BUILD_TAGS: ${{ steps.metadata.outputs.BUILD_TAGS }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Generate metadata - name: Generate metadata
id: metadata id: metadata
@@ -38,15 +38,15 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
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"
@@ -84,15 +84,15 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with: with:
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"
@@ -130,7 +130,7 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Docker meta - name: Docker meta
id: meta id: meta
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -187,7 +187,7 @@ jobs:
- image-build - image-build
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Docker meta - name: Docker meta
id: meta id: meta
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -244,7 +244,7 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Docker meta - name: Docker meta
id: meta id: meta
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -301,7 +301,7 @@ jobs:
- image-build-arm - image-build-arm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Docker meta - name: Docker meta
id: meta id: meta
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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/*
+3 -3
View File
@@ -19,12 +19,12 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with: with:
persist-credentials: false persist-credentials: false
- name: Run analysis - name: Run analysis
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a
with: with:
results_file: results.sarif results_file: results.sarif
results_format: sarif results_format: sarif
@@ -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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Generate Sponsors - name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1 uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
with: with:
token: ${{ secrets.SPONSORS_GENERATOR_PAT }} token: ${{ secrets.SPONSORS_GENERATOR_PAT }}
active-only: false active-only: false
+1 -4
View File
@@ -50,10 +50,7 @@ __debug_*
config.certify.yml config.certify.yml
# deepsec # deepsec
/.deepsec/ /.deepsec
# jetbrains # jetbrains
/.idea/ /.idea/
# claude stuff
/.claude/
-151
View File
@@ -1,151 +0,0 @@
# Agents
*This file is written by Humans for Agents.*
## Overview
Tinyauth is a lightweight and open-source authentication server written in Go and TypeScript (React). It acts as either an authentication middleware (forward_auth, ext_authz or auth_request) to protect applications using proxy authentication or as an OpenID Connect provider to offer SSO (Single-Sign-On) to your self-hosted apps. It supports local users with optional 2FA (via TOTP), LDAP, SSO users via OAuth, and access controls (ACLs). Tinyauth can be deployed with Docker, Kubernetes or bare-metal with a binary.
## Considerations
- The repository we are working at is `https://github.com/tinyauthapp/tinyauth`.
- ALWAYS follow the instructions for committing and creating a pull request as mentioned below.
## Philosophy
Tinyauth is designed to run with simplicity in mind. This is why we try to avoid adding unnecessary persistent storage and configuration options.
Tinyauth can run without persistent storage, and the SQLite database is only used for storing normal or OpenID Connect sessions. You MUST never store data in the database that is required for Tinyauth function.
As for the configuration, we support environment variables, CLI flags and a YAML configuration file. We try to keep the required configuration at a minimal with sane defaults so users can spend the least amount of time configuring Tinyauth.
We NEVER create a breaking change unless absolutely necessary and only if non-breaking changes have been discussed and deemed not ideal.
## Technical Overview
Tinyauth is designed to be as modular as possible. We use a repository-service-controller structure where each service/controller/middleware defines its dependencies in a Dig input struct, and then the main bootstrap entrypoint dynamically injects the dependencies to each method.
All methods share one global static config struct, which contains the user configuration as is, and a runtime config struct that contains dynamically generated values on startup. If a method needs a modified version, it MUST never modify the global configuration struct but rather create a local copy.
We write database migrations by hand. Migrations go in the respective database directory inside the `assets/migrations` directory and follow the `000001_migration_name_in_snake_case.sql` format where the 6-digit number is incremented on each new migration. The repository is automatically generated from SQL queries, SQLC and our own custom generator that unifies each SQLC package into one repository interface. Always ensure that migrations and queries exist for all available database drivers, else our store generation will fail. After adding your migrations and queries, run the SQLC code-gen with `make sql` and update the store code-gen with `make generate`. DO NOT EDIT the automatically generated files from SQLC or our store generator, they are marked.
When updating translations, you should only update the `frontend/src/lib/i18n/locales/en.json` and `frontend/src/lib/i18n/locales/en-US.json` files (they should be exactly the same). Crowdin will handle the generation of the keys for the rest of the available locales. NEVER hard-code plain English in the frontend, instead use the available `i18next` library and the respective translations.
For the REST framework we use Gin. However, functions or methods should avoid using the Gin Context (`gin.Context`) and default to stdlib arguments and outputs. The Gin Context exposes stdlib-compatible structs such as `http.Request` and `http.ResponseWriter` and is compatible with the `context` package.
When you need to log in the backend, use the injected logger, NOT the global zerolog struct.
In case you need toolchain versions, you can find the Node + Go version in the `Dockerfile` and the PNPM version in the `package.json` file inside the `frontend` directory.
Tinyauth uses Semantic Versioning (SemVer) for versions.
## File structure
Tinyauth is composed of two parts, the React frontend and the Go backend.
A high level of the backend is as follows:
```text
internal
├── assets # Contains the embedded assets
│ ├── dist # Dist is the compiled frontend
│ └── migrations # Migrations in SQL for all supported databases
│ ├── postgres
│ └── sqlite
├── bootstrap # The main entrypoint that bootstraps and starts Tinyauth, called by the CLI
├── controller # All of the HTTP controllers
├── middleware # The HTTP middlewares
├── model # Configuration schemas
├── repository # Repository holds all of the queries used by the services, each child-repository implements the store interface
│ ├── memory
│ ├── postgres
│ └── sqlite
├── service # The services that handle the underlying logic for the controllers
├── test # Creates any necessary package-wide configurations and helpers used by tests
└── utils # Small helpers and utils used by the app
├── decoders # Wrappers around paerser decoders such as the label decoder
├── loaders # The env, cli and YAML wrappers around the paerser loaders
└── logger # A wrapper around the zerolog logging library
```
Same for the frontend:
```text
frontend/src
├── components # Different components used by the pages
│ ├── auth # Forms used for authentication
│ ├── domain-warning # Domain warning when configured domain and actual domain don't match
│ ├── icons # Hardcoded SVG icons for OAuth providers
│ ├── layout # Main frontend layout
│ ├── providers # Different state providers such as theme
│ ├── quick-actions # The top right quick settings menu
│ └── ui # ShadCN based UI components
├── context # Holds and provides the app and user context
├── lib # Helpers used by the pages
│ ├── hooks # Hooks around the query parameters
│ └── i18n # Holds translation logic
│ └── locales # The raw JSON locales provided by Crowdin
├── pages # The actual app pages
└── schemas # Different schemas, mostly used for fetching data from the backend
```
## Make recipes
Tinyauth uses a Makefile for simplifying development. A reference of the available recipes can be found below:
- `deps` - Install the frontend and backend dependencies.
- `clean-data` - Clean any data created by running Tinyauth.
- `clean-webui` - Clean frontend build output.
- `webui` - Compile the WebUI.
- `binary` - Compile the binary for the current system.
- `binary-linux-amd64` - Compile the binary for Linux amd64.
- `binary-linux-arm64` - Compile the binary for Linux arm64.
- `test` - Test the Go backend.
- `vet` - Vet the Go backend.
- `test-race` - Test the Go backend with the race detector enabled.
- `dev` - Start the Docker-based development server.
- `prod` - Start the Docker-based production deployment (used for testing pre-releases).
- `sql` - Generate the SQLC repositories.
- `generate` - Update Go code-gen.
- `docker` - Build the Docker image for the current system.
- `docker-distroless` - Build the distroless Docker image for the current system.
- `lint-webui` - Lint the frontend with ESLint.
- `fmt` - Format the Go code with the Go `fmt` tool.
## Development lifecycle
Development of Tinyauth happens inside two Docker containers. The backend is built automatically by air using a template build output for the frontend. The frontend is run with PNPM, and then backend requests are routed with the help of Vite's proxy.
When developing, you should default to the `make dev` command to start everything in Docker and avoid platform-specific issues. If you need to test the CLI, use the `make binary` command.
After finishing with the development, test and vet the backend with `make test` and `make vet` respectively. If you believe you need to test for race conditions, use `make test-race`. You can also test specific parts of the code using the normal `go test` command, for example to run the `TestHealthController` test, you can use `go test ./internal/controller/ -run TestHealthController -v`. Finally, format the Go code with `make fmt`.
If you made any changes to the frontend, make sure to lint with `make lint-webui`.
NEVER run any destructive commands like `make clean-data` or delete any configurations without the user's approval.
## Creating a pull request
When committing, you MUST use the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) standard for your commit messages. You can add a commit description if you like. You MUST also use your standard no-reply Co-Author trailer (`Co-Authored-By:`).
You should work in separate branches unless it's clearly specified to work in the main branch. When working in a separate branch, follow the naming convention below:
```text
[feat/refactor/fix/tests/etc]/[small-change-description-in-kebab-case]
```
For example, if your change was to add OAuth to Tinyauth, the branch would look as follows:
```text
feat/oauth
```
Or:
```text
feat/add-oauth-support
```
Shorter branch names that still describe the general change are preferred.
Finally, when creating the actual pull request and if you have access to the internet/a GitHub tool, you should look if it resolves any open issues and if it does, reference them.
+1 -1
View File
@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM node:26.7-alpine3.23 AS frontend-builder FROM node:26.5-alpine3.23 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
+1 -1
View File
@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM node:26.7-alpine3.23 AS frontend-builder FROM node:26.5-alpine3.23 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
+8 -8
View File
@@ -78,10 +78,18 @@ test-race:
dev: dev:
docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build
# Development - Infisical
dev-infisical:
infisical run --env=dev -- docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build
# Production # Production
prod: prod:
docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans
# Production - Infisical
prod-infisical:
infisical run --env=dev -- docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans
# SQL # SQL
sql: sql:
sqlc generate sqlc generate
@@ -107,11 +115,3 @@ docker-distroless:
--build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) \ --build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) \
--build-arg=BUILD_TAGS=$(BUILD_TAGS) \ --build-arg=BUILD_TAGS=$(BUILD_TAGS) \
-f Dockerfile.distroless . -f Dockerfile.distroless .
# Lint the frontend
lint-webui:
cd frontend && pnpm lint
# Format the code
fmt:
go fmt ./...
+15 -17
View File
@@ -62,30 +62,28 @@ 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.
### Individuals
A huge thank you to [selfh.st](https://selfh.st) for their generous donation to Tinyauth.
<img alt="selfh.st" width="128" src="https://selfh.st/content/images/size/w640/format/webp/2025/04/selfh-st-logo-white-1.svg">
---
<!-- 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 -->
### 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)
## Acknowledgements ## Acknowledgements
- **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.")
-7
View File
@@ -1,7 +0,0 @@
# playwright files
/node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
-15
View File
@@ -1,15 +0,0 @@
{
auto_https off
}
http://whoami.127.0.0.1.sslip.io {
forward_auth tinyauth:3000 {
uri /api/auth/caddy
copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
}
reverse_proxy whoami:80
}
http://tinyauth.127.0.0.1.sslip.io {
reverse_proxy tinyauth:3000
}
-22
View File
@@ -1,22 +0,0 @@
appUrl: http://tinyauth.127.0.0.1.sslip.io
log:
level: debug
auth:
users:
# user1:password,user2:password,user3:password:token
- user1:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e
- user2:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e
- user3:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e:MVR4JQWNXYKNM6HHJEYEFP2O74QIIEJE
# disable rate limits for multiple workers to work
loginMaxRetries: 0
apps:
whoami:
config:
domain: whoami.127.0.0.1.sslip.io
path:
allow: /foo
users:
allow: user1
-24
View File
@@ -1,24 +0,0 @@
services:
caddy:
image: caddy:2.11.4
pull_policy: missing
ports:
- 80:80
volumes:
- ./conf:/etc/caddy
whoami:
image: traefik/whoami:v1.11.0
pull_policy: missing
tinyauth:
build:
context: ../
dockerfile: Dockerfile
args:
- VERSION=e2e
- BUILD_TAGS=nomsgpack
- LDFLAGS=-s -w
command: ["--configfile", "/app/config.yaml"]
volumes:
- ./config.e2e.yaml:/app/config.yaml:ro
-47
View File
@@ -1,47 +0,0 @@
import {expect, Page} from '@playwright/test';
import { OTP } from 'otplib';
export class LoginFixture {
constructor(public readonly page: Page) {}
async run(username: string, password: string) {
await expect(this.page.getByText('Welcome back, please login')).toBeVisible();
await this.page.getByLabel('Username').fill(username);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Login' }).click();
}
async expectSuccess(username: string) {
await expect(this.page.getByText(`You are currently logged in as ${username}.`)).toBeVisible()
}
}
export class LogoutFixture {
constructor(public readonly page: Page) {}
async run() {
await expect(this.page.getByText('Click the button below to logout.')).toBeVisible();
await this.page.getByRole('button', { name: 'Logout' }).click();
}
async expectSuccess() {
await expect(this.page.getByText('Welcome back, please login')).toBeVisible();
}
}
export class TOTPFixture {
constructor(public readonly page: Page) {}
async run(secret: string) {
await expect(this.page.getByText('Enter your TOTP code')).toBeVisible();
const otp = new OTP();
const token = await otp.generate({ secret });
await this.page.getByPlaceholder('XXXXXX').fill(token);
// we shouldn't need to click continue, it will auto submit
// await this.page.getByRole('button', { name: 'Continue' }).click();
}
async expectSuccess(username: string) {
await expect(this.page.getByText(`You are currently logged in as ${username}.`)).toBeVisible()
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"name": "e2e",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"down": "docker compose -f docker-compose.e2e.yml down",
"up": "docker compose -f docker-compose.e2e.yml up --build --force-recreate --remove-orphans",
"test": "playwright test",
"report": "playwright show-report"
},
"keywords": [],
"author": "",
"license": "ISC",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^11.1.2",
"onFail": "download"
}
},
"type": "module",
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.1"
},
"dependencies": {
"otplib": "^13.4.1"
}
}
-49
View File
@@ -1,49 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './specs',
fullyParallel: true,
forbidOnly: false,
retries: 0,
workers: 4,
reporter: 'html',
use: {
trace: 'on-first-retry',
video: 'on',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'pnpm run up',
url: 'http://tinyauth.127.0.0.1.sslip.io/api/healthz',
reuseExistingServer: true,
timeout: 5 * 60 * 1000,
gracefulShutdown: {
signal: 'SIGINT',
timeout: 1000,
},
stderr: 'pipe',
stdout: 'pipe',
},
});
-338
View File
@@ -1,338 +0,0 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: ^11.1.2
version: 11.13.1
pnpm:
specifier: ^11.1.2
version: 11.13.1
packages:
'@pnpm/exe@11.13.1':
resolution: {integrity: sha512-P4euEK6lOFnd5oTHEc5M/HhvyF4XUhTnVsklEcM6rmY0QJxPD6xbT+u1+gskEIBp4nSRorz20IJQtAU1Nerggg==}
hasBin: true
'@pnpm/linux-arm64@11.13.1':
resolution: {integrity: sha512-wB8zloqrYrudPyuA5qbuTCnJGe4eETPwqOjoPjoyyyvA4zFI5XfLpxgqOOcaY5UJBoqzckcGpRVDwhSRfsQ/6A==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.13.1':
resolution: {integrity: sha512-A+wnEvzfWEvanXiwww3tnOPmtjPSrrf5tOP6vk8+K0BRFEe/Df0oPytm2nWgGcn5iwPnqtr1Btkof913McnSPA==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.13.1':
resolution: {integrity: sha512-k4t65VeqRX4COMFe45TF58CVmCpmAsKZShaR1HobmUeleo98mWTctggKolrA2MHcVUeSS+12yB5Urb3uDazhmw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.13.1':
resolution: {integrity: sha512-A65GqPzwCl0bAMk3kRWfbjSRBm5RRaqR2oMxV/9AYZrwO0X9yEfngbLBISCPHjt6/Qe4nH7DFemyhy6yODYwEw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.13.1':
resolution: {integrity: sha512-MJvOtyGOWSfBoqdVEfAH8ljmHs13mt82k/UxN4f+q7koDxJRR2n4Nie6Og6RwbnbaubCz0Fh2bTeL1+MxDSFpA==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.13.1':
resolution: {integrity: sha512-kl/g1cCKOJPe4HntspyrAJW0LRco0UHnVfxHSspezo4Zj4AanJAZ8WzLqfa6/w3lBSKHTEN4x0pb3m4J7B7Vpw==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.13.1':
resolution: {integrity: sha512-Bcb14NeBlbHS2Gq1qr8VnCiAz5eC1lYzXOls7zH0bnV0Taaj4/xyfm0HVO4dn9R2TQtVtz1qnBZHHL9PDFumqQ==}
cpu: [x64]
os: [win32]
'@reflink/reflink-darwin-arm64@0.1.19':
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@reflink/reflink-darwin-x64@0.1.19':
resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@reflink/reflink-linux-arm64-gnu@0.1.19':
resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@reflink/reflink-linux-arm64-musl@0.1.19':
resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@reflink/reflink-linux-x64-gnu@0.1.19':
resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@reflink/reflink-linux-x64-musl@0.1.19':
resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@reflink/reflink-win32-arm64-msvc@0.1.19':
resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@reflink/reflink-win32-x64-msvc@0.1.19':
resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@reflink/reflink@0.1.19':
resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
engines: {node: '>= 10'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.13.1:
resolution: {integrity: sha512-svx2g7imUlQU59E+G6KMqt3elr9m7FQL+ut+cCuB8+C+TR8pXt9/n+A5Z0Co3ORQnFgt33mJH0VD/qMtN2RfJQ==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.13.1':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.13.1
'@pnpm/linux-x64': 11.13.1
'@pnpm/linuxstatic-arm64': 11.13.1
'@pnpm/linuxstatic-x64': 11.13.1
'@pnpm/macos-arm64': 11.13.1
'@pnpm/win-arm64': 11.13.1
'@pnpm/win-x64': 11.13.1
'@pnpm/linux-arm64@11.13.1':
optional: true
'@pnpm/linux-x64@11.13.1':
optional: true
'@pnpm/linuxstatic-arm64@11.13.1':
optional: true
'@pnpm/linuxstatic-x64@11.13.1':
optional: true
'@pnpm/macos-arm64@11.13.1':
optional: true
'@pnpm/win-arm64@11.13.1':
optional: true
'@pnpm/win-x64@11.13.1':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
optional: true
'@reflink/reflink-darwin-x64@0.1.19':
optional: true
'@reflink/reflink-linux-arm64-gnu@0.1.19':
optional: true
'@reflink/reflink-linux-arm64-musl@0.1.19':
optional: true
'@reflink/reflink-linux-x64-gnu@0.1.19':
optional: true
'@reflink/reflink-linux-x64-musl@0.1.19':
optional: true
'@reflink/reflink-win32-arm64-msvc@0.1.19':
optional: true
'@reflink/reflink-win32-x64-msvc@0.1.19':
optional: true
'@reflink/reflink@0.1.19':
optionalDependencies:
'@reflink/reflink-darwin-arm64': 0.1.19
'@reflink/reflink-darwin-x64': 0.1.19
'@reflink/reflink-linux-arm64-gnu': 0.1.19
'@reflink/reflink-linux-arm64-musl': 0.1.19
'@reflink/reflink-linux-x64-gnu': 0.1.19
'@reflink/reflink-linux-x64-musl': 0.1.19
'@reflink/reflink-win32-arm64-msvc': 0.1.19
'@reflink/reflink-win32-x64-msvc': 0.1.19
detect-libc@2.1.2: {}
pnpm@11.13.1: {}
---
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
otplib:
specifier: ^13.4.1
version: 13.4.1
devDependencies:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
'@types/node':
specifier: ^26.1.1
version: 26.1.1
packages:
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@otplib/core@13.4.1':
resolution: {integrity: sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==}
'@otplib/hotp@13.4.1':
resolution: {integrity: sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==}
'@otplib/plugin-base32-scure@13.4.1':
resolution: {integrity: sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==}
'@otplib/plugin-crypto-noble@13.4.1':
resolution: {integrity: sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==}
'@otplib/totp@13.4.1':
resolution: {integrity: sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==}
'@otplib/uri@13.4.1':
resolution: {integrity: sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@scure/base@2.2.0':
resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
otplib@13.4.1:
resolution: {integrity: sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
snapshots:
'@noble/hashes@2.2.0': {}
'@otplib/core@13.4.1': {}
'@otplib/hotp@13.4.1':
dependencies:
'@otplib/core': 13.4.1
'@otplib/uri': 13.4.1
'@otplib/plugin-base32-scure@13.4.1':
dependencies:
'@otplib/core': 13.4.1
'@scure/base': 2.2.0
'@otplib/plugin-crypto-noble@13.4.1':
dependencies:
'@noble/hashes': 2.2.0
'@otplib/core': 13.4.1
'@otplib/totp@13.4.1':
dependencies:
'@otplib/core': 13.4.1
'@otplib/hotp': 13.4.1
'@otplib/uri': 13.4.1
'@otplib/uri@13.4.1':
dependencies:
'@otplib/core': 13.4.1
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@scure/base@2.2.0': {}
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
fsevents@2.3.2:
optional: true
otplib@13.4.1:
dependencies:
'@otplib/core': 13.4.1
'@otplib/hotp': 13.4.1
'@otplib/plugin-base32-scure': 13.4.1
'@otplib/plugin-crypto-noble': 13.4.1
'@otplib/totp': 13.4.1
'@otplib/uri': 13.4.1
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
undici-types@8.3.0: {}
-28
View File
@@ -1,28 +0,0 @@
import {expect, test} from '@playwright/test';
import {LoginFixture} from "../fixtures/auth.fixtures";
test('should be able to login to app with forward-auth', async ({ page }) => {
const loginFixture = new LoginFixture(page);
await page.goto('http://whoami.127.0.0.1.sslip.io');
// redirect to tinyauth
await expect(page.getByText('Welcome back, please login')).toBeVisible()
await loginFixture.run('user1', 'password')
// redirect to app
await expect(page.getByText('whoami.127.0.0.1.sslip.io')).toBeVisible()
});
test('non authorized user should not be able to access app', async ({ page }) => {
const loginFixture = new LoginFixture(page);
await page.goto('http://whoami.127.0.0.1.sslip.io');
// redirect to tinyauth
await expect(page.getByText('Welcome back, please login')).toBeVisible()
// user2 is not authorized to access app
await loginFixture.run('user2', 'password')
// redirect to app
await expect(page.getByText('The user with username user2 is not authorized to access the resource whoami.')).toBeVisible()
})
test('allowed path should skip authentication', async ({ page }) => {
await page.goto('http://whoami.127.0.0.1.sslip.io/foo');
await expect(page.getByText('whoami.127.0.0.1.sslip.io')).toBeVisible()
})
-48
View File
@@ -1,48 +0,0 @@
import { test, expect } from '@playwright/test';
import {LoginFixture, LogoutFixture, TOTPFixture} from "../fixtures/auth.fixtures";
test('should be able to login', async ({ page }) => {
const loginFixture = new LoginFixture(page);
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
await loginFixture.run('user1', 'password')
await loginFixture.expectSuccess('user1')
});
test('should fail to login with wrong credentials', async ({ page }) => {
const loginFixture = new LoginFixture(page);
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
await loginFixture.run('user27267', 'password')
const toast = page.locator('[data-sonner-toast]').first();
await expect(toast).toBeVisible();
await expect(toast).toContainText('Failed to log in');
});
test('should be able to logout', async ({ page }) => {
const loginFixture = new LoginFixture(page);
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
await loginFixture.run('user1', 'password')
await loginFixture.expectSuccess('user1')
const logoutFixture = new LogoutFixture(page);
await logoutFixture.run()
})
test('should be able to login with totp', async ({ page }) => {
const loginFixture = new LoginFixture(page);
const totpFixture = new TOTPFixture(page);
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
await loginFixture.run('user3', 'password')
await totpFixture.run('MVR4JQWNXYKNM6HHJEYEFP2O74QIIEJE')
await loginFixture.expectSuccess('user3');
});
test('should fail to login with wrong totp', async ({ page }) => {
const loginFixture = new LoginFixture(page);
const totpFixture = new TOTPFixture(page);
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
await loginFixture.run('user3', 'password')
await totpFixture.run('VZVMOMQCBN24DJ5VRFAL5TJAZGBHXMN3')
const toast = page.locator('[data-sonner-toast]').first();
await expect(toast).toBeVisible();
await expect(toast).toContainText('Failed to verify code');
});
+28 -28
View File
@@ -12,49 +12,49 @@
}, },
"packageManager": "pnpm@11.1.2", "packageManager": "pnpm@11.1.2",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.7.1", "@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-label": "^2.1.15", "@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-select": "^2.3.7", "@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.2",
"axios": "^1.19.0", "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.6",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.3", "i18next-resources-to-backend": "^1.2.1",
"lucide-react": "^1.31.0", "lucide-react": "^1.24.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.2",
"react": "^19.2.8", "react": "^19.2.7",
"react-dom": "^19.2.8", "react-dom": "^19.2.7",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.81.0",
"react-i18next": "^17.0.11", "react-i18next": "^17.0.9",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^8.3.0", "react-router": "^8.2.0",
"sonner": "^2.0.8", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.2",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tanstack/eslint-plugin-query": "^5.101.4", "@tanstack/eslint-plugin-query": "^5.101.2",
"@types/node": "^26.2.0", "@types/node": "^26.1.1",
"@types/react": "^19.2.18", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.8.1", "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.9.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.66.0", "typescript-eslint": "^8.63.0",
"vite": "^8.2.1" "vite": "^8.1.4"
} }
} }
+1302 -1440
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 "";
} }
+44 -44
View File
@@ -15,16 +15,16 @@
"loginOauthSuccessTitle": "Přesměrování", "loginOauthSuccessTitle": "Přesměrování",
"loginOauthSuccessSubtitle": "Přesměrování k poskytovateli OAuth", "loginOauthSuccessSubtitle": "Přesměrování k poskytovateli OAuth",
"loginOauthAutoRedirectTitle": "Automatické přesměrování OAuth", "loginOauthAutoRedirectTitle": "Automatické přesměrování OAuth",
"loginOauthAutoRedirectSubtitle": "Pro ověření budete automaticky přesměrování na svého poskytovatele OAuth.", "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Přesměrovat nyní", "loginOauthAutoRedirectButton": "Redirect now",
"continueTitle": "Pokračovat", "continueTitle": "Pokračovat",
"continueRedirectingTitle": "Přesměrování...", "continueRedirectingTitle": "Přesměrování...",
"continueRedirectingSubtitle": "Brzy budete přesměrováni do aplikace", "continueRedirectingSubtitle": "Brzy budete přesměrováni do aplikace",
"continueRedirectManually": "Přesměrovat ručně", "continueRedirectManually": "Redirect me manually",
"continueInsecureRedirectTitle": "Nezabezpečené přesměrování", "continueInsecureRedirectTitle": "Nezabezpečené přesměrování",
"continueInsecureRedirectSubtitle": "Pokoušíte se přesměrovat z <code>https</code> na <code>http</code>, které není bezpečné. Opravdu chcete pokračovat?", "continueInsecureRedirectSubtitle": "Pokoušíte se přesměrovat z <code>https</code> na <code>http</code>, které není bezpečné. Opravdu chcete pokračovat?",
"continueUntrustedRedirectTitle": "Nedůvěryhodné přesměrování", "continueUntrustedRedirectTitle": "Untrusted redirect",
"continueUntrustedRedirectSubtitle": "Pokoušíte se přesměrovat na doménu, která neodpovídá vaší nakonfigurované doméně (<code>{{cookieDomain}}</code>). Opravdu chcete pokračovat?", "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?",
"logoutFailTitle": "Odhlášení se nezdařilo", "logoutFailTitle": "Odhlášení se nezdařilo",
"logoutFailSubtitle": "Zkuste to prosím znovu", "logoutFailSubtitle": "Zkuste to prosím znovu",
"logoutSuccessTitle": "Odhlášen", "logoutSuccessTitle": "Odhlášen",
@@ -51,53 +51,53 @@
"forgotPasswordTitle": "Zapomněli jste heslo?", "forgotPasswordTitle": "Zapomněli jste heslo?",
"failedToFetchProvidersTitle": "Nepodařilo se načíst poskytovatele ověřování. Zkontrolujte prosím konfiguraci.", "failedToFetchProvidersTitle": "Nepodařilo se načíst poskytovatele ověřování. Zkontrolujte prosím konfiguraci.",
"errorTitle": "Došlo k chybě", "errorTitle": "Došlo k chybě",
"errorSubtitleInfo": "Při zpracování požadavku došlo k následující chybě:", "errorSubtitleInfo": "The following error occurred while processing your request:",
"errorSubtitle": "Nastala chyba při pokusu o provedení této akce. Pro více informací prosím zkontrolujte konzolu.", "errorSubtitle": "Nastala chyba při pokusu o provedení této akce. Pro více informací prosím zkontrolujte konzolu.",
"forgotPasswordMessage": "Heslo můžete obnovit změnou proměnné `USERS`.", "forgotPasswordMessage": "Heslo můžete obnovit změnou proměnné `USERS`.",
"fieldRequired": "Toto pole je povinné", "fieldRequired": "Toto pole je povinné",
"invalidInput": "Neplatný údaj", "invalidInput": "Neplatný údaj",
"domainWarningTitle": "Neplatná doména", "domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "Pro přístup k této instanci používáte špatnou doménu. Při pokračování mohou nastat problémy s autentizací.", "domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
"domainWarningCurrent": "Aktuální:", "domainWarningCurrent": "Current:",
"domainWarningExpected": "Vyžadovaná:", "domainWarningExpected": "Expected:",
"ignoreTitle": "Ignorovat", "ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Přejít na správnou doménu", "goToCorrectDomainTitle": "Go to correct domain",
"authorizeTitle": "Autorizovat", "authorizeTitle": "Authorize",
"authorizeCardTitle": "Pokračovat do {{app}}?", "authorizeCardTitle": "Continue to {{app}}?",
"authorizeSubtitle": "Chcete pokračovat do této aplikace? Pečlivě si zkontrolujte jí vyžadovaná oprávnění, prosím.", "authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
"authorizeSubtitleOAuth": "Chcete pokračovat do této aplikace?", "authorizeSubtitleOAuth": "Would you like to continue to this app?",
"authorizeLoadingTitle": "Načítání...", "authorizeLoadingTitle": "Loading...",
"authorizeLoadingSubtitle": "Please wait while we load the client information.", "authorizeLoadingSubtitle": "Please wait while we load the client information.",
"authorizeSuccessTitle": "Autorizováno", "authorizeSuccessTitle": "Authorized",
"authorizeSuccessSubtitle": "Během několika vteřin budete přesměrováni do aplikace.", "authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
"authorizeErrorClientInfo": "Při načítání klientských informací došlo k chybě. Zkuste to prosím později.", "authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
"authorizeErrorInvalidParams": "Parametry požadavku jsou chybné/neplatné. Prosím, zkontrolujte URL a zkuste znovu.", "authorizeErrorInvalidParams": "The request is missing required parameters or has invalid parameters. Please check the URL and try again.",
"openidScopeName": "OpenID Connect", "openidScopeName": "OpenID Connect",
"openidScopeDescription": "Povolí aplikaci přístup k Vaším informacím OpenID Connect.", "openidScopeDescription": "Allows the app to access your OpenID Connect information.",
"emailScopeName": "E-mail", "emailScopeName": "Email",
"emailScopeDescription": "Povolí aplikaci přístup k Vaší e-mailové adrese.", "emailScopeDescription": "Allows the app to access your email address.",
"profileScopeName": "Profil", "profileScopeName": "Profile",
"profileScopeDescription": "Povolí aplikaci přístup k informacím o Vašem profilu.", "profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Skupiny", "groupsScopeName": "Groups",
"groupsScopeDescription": "Povolí aplikaci přístup k informacím o Vaší skupině.", "groupsScopeDescription": "Allows the app to access your group information.",
"backToLoginButton": "Zpět na přihlášení", "backToLoginButton": "Back to login",
"phoneScopeName": "Telefon", "phoneScopeName": "Phone",
"phoneScopeDescription": "Povolí aplikaci přístup k Vašemu telefonnímu číslu.", "phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Adresa", "addressScopeName": "Address",
"addressScopeDescription": "Povolí aplikaci přístup k Vaší adrese.", "addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Pokračovat přes Tailscale", "loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?", "loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Název zařízení:", "loginTailscaleDeviceName": "Device name:",
"loginTailscaleOtherMethod": "Přihlásit jinou metodou", "loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Úspěšně přihlášeno přes Tailscale.", "loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Chyba při přihlášení přes Tailscale. Zkuste znovu, případně se přihlašte jiným způsobem, prosím.", "loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.", "logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.",
"quickActionsLanguage": "Jazyk", "quickActionsLanguage": "Language",
"quickActionsTheme": "Motiv", "quickActionsTheme": "Theme",
"quickActionsThemeLight": "Světlý", "quickActionsThemeLight": "Light",
"quickActionsThemeDark": "Tmavý", "quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "Systémový", "quickActionsThemeSystem": "System",
"quickActionsLogout": "Odhlásit se", "quickActionsLogout": "Logout",
"quickActionsTitle": "Quick Actions", "quickActionsTitle": "Quick Actions",
"quickActionsProviderLocal": "Local", "quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP", "quickActionsProviderLDAP": "LDAP",
+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({
+9 -9
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
@@ -24,13 +24,12 @@ require (
github.com/weppos/publicsuffix-go v0.50.3 github.com/weppos/publicsuffix-go v0.50.3
go.uber.org/dig v1.19.0 go.uber.org/dig v1.19.0
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/tools v0.48.0 golang.org/x/tools v0.48.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.36.3 k8s.io/apimachinery v0.36.2
k8s.io/client-go v0.36.3 k8s.io/client-go v0.36.2
modernc.org/sqlite v1.56.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
@@ -92,7 +91,7 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
@@ -136,6 +135,7 @@ require (
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.38.0 // indirect golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.45.0 // indirect
@@ -147,12 +147,12 @@ 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.4 // 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
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )
+27 -26
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=
@@ -154,8 +154,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
@@ -208,8 +208,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
@@ -348,6 +348,7 @@ 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
@@ -379,32 +380,32 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
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.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.29.1/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.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= 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=
@@ -413,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.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= 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=
@@ -425,7 +426,7 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+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 {
+33 -68
View File
@@ -220,16 +220,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(),
@@ -294,7 +313,8 @@ func (controller *OAuthController) getCookieDomain() string {
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{ v := validators.NewDomainValidator(validators.DomainValidatorOptions{
WithPort: true, WithScheme: true,
WithPort: true,
}) })
_, err := v.SafeHostname(controller.runtime.AppURL) _, err := v.SafeHostname(controller.runtime.AppURL)
@@ -312,7 +332,9 @@ func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI") controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI")
if !errors.Is(err, validators.ErrHostnameMismatch) { if errors.Is(err, validators.ErrInvalidURL) ||
errors.Is(err, validators.ErrSchemeMismatch) ||
errors.Is(err, validators.ErrPortMismatch) {
return false return false
} }
@@ -329,66 +351,9 @@ func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
return false return false
} }
if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) || if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) {
hostname == controller.runtime.CookieDomain {
return true return true
} }
return false return false
} }
type oauthUserInfo struct {
Email string
Username string
Name string
}
func (controller *OAuthController) createOAuthUserInfo(input oauthUserInfo) oauthUserInfo {
info := oauthUserInfo{
Email: input.Email,
}
if controller.config.Experimental.OAuthBridgeEnabled {
if input.Username != "" {
info.Username = input.Username
} else {
parts := strings.SplitN(input.Email, "@", 2)
if len(parts) != 2 {
controller.log.App.Error().Str("email", input.Email).Msg("Invalid email address")
} else {
info.Username = parts[0]
}
}
if input.Name != "" {
info.Name = input.Name
} else {
info.Name = utils.Capitalize(info.Username)
}
return info
}
if input.Name != "" {
controller.log.App.Debug().Msg("Using name from OAuth provider")
info.Name = input.Name
} else {
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
parts := strings.SplitN(input.Email, "@", 2)
if len(parts) != 2 {
controller.log.App.Error().Str("email", input.Email).Msg("Invalid email address")
} else {
info.Name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
}
}
if input.Username != "" {
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
info.Username = input.Username
} else {
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
info.Username = strings.Replace(info.Email, "@", "_", 1)
}
return info
}
@@ -81,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
} }
+20 -35
View File
@@ -6,7 +6,6 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"path"
"regexp" "regexp"
"strings" "strings"
@@ -47,7 +46,6 @@ type ProxyContext struct {
Host string Host string
Proto string Proto string
Path string Path string
PathRaw string
Method string Method string
Type AuthModuleType Type AuthModuleType
IsBrowser bool IsBrowser bool
@@ -113,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) {
@@ -282,7 +279,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
} }
queries, err := query.Values(RedirectQuery{ queries, err := query.Values(RedirectQuery{
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.PathRaw), RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
LoginFor: FrontendLoginForApp, LoginFor: FrontendLoginForApp,
}) })
@@ -403,11 +400,11 @@ func (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyC
method := c.Request.Method method := c.Request.Method
return ProxyContext{ return ProxyContext{
Host: host, Host: host,
Proto: proto, Proto: proto,
PathRaw: uri, Path: uri,
Method: method, Method: method,
Type: ForwardAuth, Type: ForwardAuth,
}, nil }, nil
} }
@@ -436,14 +433,15 @@ func (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyC
return ProxyContext{}, errors.New("proto not found") return ProxyContext{}, errors.New("proto not found")
} }
path := url.Path
method := c.Request.Method method := c.Request.Method
return ProxyContext{ return ProxyContext{
Host: host, Host: host,
Proto: proto, Proto: proto,
PathRaw: url.RequestURI(), Path: path,
Method: method, Method: method,
Type: AuthRequest, Type: AuthRequest,
}, nil }, nil
} }
@@ -469,11 +467,11 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
method := c.Request.Method method := c.Request.Method
return ProxyContext{ return ProxyContext{
Host: host, Host: host,
Proto: proto, Proto: proto,
PathRaw: path, Path: path,
Method: method, Method: method,
Type: ExtAuthz, Type: ExtAuthz,
}, nil }, nil
} }
@@ -552,19 +550,6 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
return ProxyContext{}, err return ProxyContext{}, err
} }
// Parse the raw path to populate the cleaned path used for ACLs
upath, err := url.Parse(ctx.PathRaw)
if err != nil {
return ProxyContext{}, fmt.Errorf("failed to parse request path: %v", err)
}
if upath.Host != "" || !strings.HasPrefix(upath.Path, "/") {
return ProxyContext{}, fmt.Errorf("invalid request path")
}
ctx.Path = path.Clean(upath.Path)
// We don't care if the header is empty, we will just assume it's not a browser // We don't care if the header is empty, we will just assume it's not a browser
userAgent, _ := controller.getHeader(c, "user-agent") userAgent, _ := controller.getHeader(c, "user-agent")
isBrowser := BrowserUserAgentRegex.MatchString(userAgent) isBrowser := BrowserUserAgentRegex.MatchString(userAgent)
+1 -216
View File
@@ -95,38 +95,6 @@ func TestProxyController(t *testing.T) {
assert.Contains(t, location, "https://tinyauth.example.com/login") assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{
description: "Forward auth login redirect should preserve query parameters",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "test.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/search?foo=bar")
req.Header.Set("user-agent", browserUserAgent)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/search?foo=bar"))
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
},
},
{
description: "Auth request (nginx) login redirect should preserve query parameters",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
req.Header.Set("x-original-url", "https://test.example.com/search?foo=bar")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
location := recorder.Header().Get("x-tinyauth-location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/search?foo=bar"))
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
},
},
{ {
description: "Auth request (nginx) should be detected and used", description: "Auth request (nginx) should be detected and used",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
@@ -158,22 +126,6 @@ func TestProxyController(t *testing.T) {
assert.Contains(t, location, "https://tinyauth.example.com/login") assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{
description: "Ext authz (envoy) login redirect should preserve query parameters",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=%2Fhello%3Ffoo%3Dbar", nil)
req.Host = "test.example.com"
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("user-agent", browserUserAgent)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/hello?foo=bar"))
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
},
},
{ {
description: "Forward auth with caddy should be detected and used", description: "Forward auth with caddy should be detected and used",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
@@ -356,171 +308,6 @@ func TestProxyController(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
}, },
}, },
{
description: "Ensure path block ACL requires auth on exact match",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/admin")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path block ACL skips auth for non-matching path",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/public")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
},
},
{
description: "Ensure path block ACL cannot be bypassed with query params",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/admin?foo=bar")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path block ACL cannot be bypassed with a trailing slash",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/admin/")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path block ACL cannot be bypassed with dot segments",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/foo/../admin")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path block ACL cannot be bypassed with percent-encoded characters",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/%61dmin")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path block ACL cannot be bypassed with a percent-encoded double slash",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/%2Fadmin")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure a protocol-relative x-forwarded-uri is rejected",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "//admin")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
description: "Ensure a relative x-forwarded-uri is rejected",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "public")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
description: "Ensure path block ACL cannot be bypassed with query params on envoy ext authz",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/admin%3Ffoo=bar", nil)
req.Host = "path-block.example.com"
req.Header.Set("x-forwarded-proto", "https")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path block ACL cannot be bypassed with dot segments on nginx auth request",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
req.Header.Set("x-original-url", "https://path-block.example.com/foo/../admin")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure path allow ACL still matches when query params are present",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-allow.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/allowed?foo=bar")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
},
},
{
description: "Ensure path allow ACL cannot be extended with dot segments",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-allow.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/allowed/../secret")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{
description: "Ensure an allowed path inside the query string does not bypass auth",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", "path-allow.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/secret?next=/allowed")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
{ {
description: "Ensure ip bypass ACL works on forward auth", description: "Ensure ip bypass ACL works on forward auth",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
@@ -921,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,
@@ -934,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()
+56 -27
View File
@@ -72,32 +72,11 @@ func (controller *UserController) loginHandler(c *gin.Context) {
controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt") controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt")
search, err := controller.auth.SearchUser(req.Username)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
controller.auth.DummyPasswordCheck(req.Password)
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
isLocked, remaining := controller.auth.IsAccountLocked(req.Username) isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
if isLocked { if isLocked {
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts") controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "account locked") 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-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{ c.JSON(429, gin.H{
@@ -107,10 +86,44 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return return
} }
search, err := controller.auth.SearchUser(req.Username)
if err != nil {
controller.constantTime(func() constantTimeRes {
if errors.Is(err, service.ErrUserNotFound) {
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
controller.auth.RecordLoginAttempt(req.Username, false)
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
return constantTimeRes{
Code: 401,
Res: gin.H{
"status": 401,
"message": "Unauthorized",
},
}
}
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
return constantTimeRes{
Code: 500,
Res: gin.H{
"status": 500,
"message": "Internal Server Error",
},
}
}, func(res constantTimeRes) {
c.JSON(res.Code, res.Res)
}, time.Millisecond*45)
return
}
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil { if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt") controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(req.Username, false)
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",
@@ -194,9 +207,6 @@ func (controller *UserController) loginHandler(c *gin.Context) {
if search.Email != "" { if search.Email != "" {
sessionCookie.Email = search.Email sessionCookie.Email = search.Email
} }
if search.Name != "" {
sessionCookie.Name = search.Name
}
} }
cookie, err := controller.auth.CreateSession(c, sessionCookie) cookie, err := controller.auth.CreateSession(c, sessionCookie)
@@ -214,7 +224,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)
@@ -461,3 +475,18 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
"message": "Login successful", "message": "Login successful",
}) })
} }
type constantTimeRes struct {
Code int
Res any
}
func (controller *UserController) constantTime(f func() constantTimeRes, rf func(res constantTimeRes), targetTime time.Duration) {
tStart := time.Now()
res := f()
tEnd := time.Now()
if tEnd.Sub(tStart) < targetTime {
time.Sleep(targetTime - tEnd.Sub(tStart))
}
rf(res)
}
+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()
+8 -26
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,
@@ -204,11 +200,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
} }
userContext.LDAP.Groups = user.Groups userContext.LDAP.Groups = user.Groups
if search.Name != "" { userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
userContext.LDAP.Name = search.Name
} else {
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
}
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain) userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain)
if search.Email != "" { if search.Email != "" {
@@ -252,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)
} }
@@ -289,20 +278,16 @@ 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)
} }
name := search.Name
if name == "" {
name = utils.Capitalize(username)
}
userContext.LDAP = &model.LDAPContext{ userContext.LDAP = &model.LDAPContext{
BaseContext: model.BaseContext{ BaseContext: model.BaseContext{
Username: username, Username: username,
Name: name, Name: utils.Capitalize(username),
}, },
Groups: user.Groups, Groups: user.Groups,
} }
@@ -343,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,
+21 -22
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"` ConfigFile string `description:"Path to config file." yaml:"-" gen:"include"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"` LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"`
Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"` Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"` Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"` Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"` Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"` Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"` Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"` OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"` OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"`
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"` UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"` LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
// enable the cli warning on experimental features Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"` Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"` Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
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"`
@@ -332,6 +331,6 @@ type AppBasicAuth struct {
} }
type AppPath struct { type AppPath struct {
Allow string `description:"Disable authentication for only paths that match the regex string." yaml:"allow,omitempty"` Allow string `description:"Comma-separated list of allowed paths." yaml:"allow,omitempty"`
Block string `description:"Enable authentication for only paths that match the regex string." yaml:"block,omitempty"` Block string `description:"Comma-separated list of blocked paths." yaml:"block,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 {
-11
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
@@ -32,6 +22,5 @@ type LocalUser struct {
type UserSearch struct { type UserSearch struct {
Username string Username string
Email string // used for LDAP, we can't throw it to LDAPUser because it would need another cache or an LDAP lookup every time Email string // used for LDAP, we can't throw it to LDAPUser because it would need another cache or an LDAP lookup every time
Name string // used for LDAP cn attribute
Type UserSearchType Type UserSearchType
} }
+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,
}, },
+21 -62
View File
@@ -11,7 +11,7 @@ import (
) )
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 +37,33 @@ 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 {
var nameMatch *model.App
v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
var domainMatch *model.App // First try to find a matching app by domain, then fallback to matching by app name (subdomain)
var nameMatch *model.App for app, config := range service.config.Apps {
var nameMatchedApps []string err := v.Validate(config.Config.Domain, domain)
if err == nil {
locatorFunc := func(name string, app *model.App) bool { service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
if app.Config.Domain != "" { return &config
err := v.Validate(app.Config.Domain, domain)
if err == nil {
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
domainMatch = app
return true
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
}
} }
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) { if !errors.Is(err, validators.ErrHostnameMismatch) {
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name") service.log.App.Debug().Str("name", app).Err(err).Msg("Domain validation failed")
nameMatch = app
nameMatchedApps = append(nameMatchedApps, name)
} }
return false if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(app+".")) {
} service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = &config
err := lookup(locatorFunc)
if err != nil {
return nil, err
}
if domainMatch != nil {
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by domain")
return domainMatch, nil
}
if nameMatch == nil {
service.log.App.Debug().Str("domain", domain).Msg("No match found for domain, skipping")
return nil, nil
}
if len(nameMatchedApps) > 1 {
service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match")
}
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
return nameMatch, nil
}
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
for app, config := range service.config.Apps {
if ok := locator(app, &config); ok {
return nil
}
} }
return nil }
})
return nameMatch
} }
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { 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 +72,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)
} }
+96 -43
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) {
@@ -173,7 +160,7 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
} }
if auth.ldap != nil { if auth.ldap != nil {
userDN, email, cn, err := auth.ldap.GetUserInfo(username) userDN, email, err := auth.ldap.GetUserInfo(username)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ldap user: %w", err) return nil, fmt.Errorf("failed to get ldap user: %w", err)
@@ -182,7 +169,6 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
return &model.UserSearch{ return &model.UserSearch{
Username: userDN, Username: userDN,
Email: email, Email: email,
Name: cn,
Type: model.UserLDAP, Type: model.UserLDAP,
}, nil }, nil
} }
@@ -259,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
} }
@@ -281,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 {
@@ -346,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)
@@ -363,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,
@@ -618,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) {
+118 -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,71 +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
} }
if len(hosts) == 0 {
k.log.App.Warn().Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in ingress, skipping")
k.removeIngress(key)
return
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps") labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil { if err != nil {
k.log.App.Warn().Err(err).Str("namespace", 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{}) continue
for name, config := range labels.Apps {
if config.Config.Domain != "" {
hostname, err := v.SafeHostname(config.Config.Domain)
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name")
} else if slices.Contains(hosts, hostname) {
entries = append(entries, ingressEntry{
name: name,
app: config,
})
continue
}
} }
if len(hosts) > 0 && !slices.Contains(hosts, appLabels.Config.Domain) {
for _, host := range hosts { 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")
if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) { continue
entries = append(entries, ingressEntry{
name: name,
app: config,
})
break
}
} }
apps = append(apps, ingressApp{
domain: appLabels.Config.Domain,
appName: appName,
app: appLabels,
})
} }
if len(apps) == 0 {
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 {
@@ -291,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 {
@@ -341,13 +362,25 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx contex
} }
} }
func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error { 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 -547
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)
}, },
@@ -231,437 +148,30 @@ func TestKubernetesService(t *testing.T) {
"tinyauth.apps.myapp.config.domain": "myapp.example.com", "tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.allow": "alice", "tinyauth.apps.myapp.users.allow": "alice",
}) })
item.Object["spec"] = map[string]any{
"rules": []any{
map[string]any{
"host": "myapp.example.com",
},
},
}
svc.updateFromItem(&item) svc.updateFromItem(&item)
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)
}, },
}, },
{
description: "Update from item skips annotations with no hosts",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem fails when label parsing fails",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.break": "i-dont-exist",
})
item.Object["spec"] = map[string]any{
"rules": []any{
map[string]any{
"host": "myapp.example.com",
},
},
}
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
require.Nil(t, got)
},
},
{ {
description: "UpdateFromItem with no annotations removes existing cache entries", description: "UpdateFromItem with no annotations removes existing cache entries",
run: func(t *testing.T, svc *KubernetesService) { run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}} app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
svc.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)
}, },
}, },
@@ -670,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)
}) })
+5 -5
View File
@@ -146,7 +146,7 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
return ldap.conn, nil return ldap.conn, nil
} }
func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, cn string, err error) { func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, err error) {
escapedUsername := ldapgo.EscapeFilter(username) escapedUsername := ldapgo.EscapeFilter(username)
filter := fmt.Sprintf(ldap.config.LDAP.SearchFilter, escapedUsername) filter := fmt.Sprintf(ldap.config.LDAP.SearchFilter, escapedUsername)
@@ -154,7 +154,7 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
ldap.config.LDAP.BaseDN, ldap.config.LDAP.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false, ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
filter, filter,
[]string{"dn", "mail", "cn"}, []string{"dn", "mail"},
nil, nil,
) )
@@ -163,15 +163,15 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
searchResult, err := ldap.conn.Search(searchRequest) searchResult, err := ldap.conn.Search(searchRequest)
if err != nil { if err != nil {
return "", "", "", err return "", "", err
} }
if len(searchResult.Entries) != 1 { if len(searchResult.Entries) != 1 {
return "", "", "", fmt.Errorf("multiple or no entries found for user %s", username) return "", "", fmt.Errorf("multiple or no entries found for user %s", username)
} }
entry := searchResult.Entries[0] entry := searchResult.Entries[0]
return entry.DN, entry.GetAttributeValue("mail"), entry.GetAttributeValue("cn"), nil return entry.DN, entry.GetAttributeValue("mail"), nil
} }
func (ldap *LdapService) GetUserCount() (int, error) { func (ldap *LdapService) GetUserCount() (int, error) {
+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
+4 -13
View File
@@ -58,15 +58,7 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
Domain: "path-allow.example.com", Domain: "path-allow.example.com",
}, },
Path: model.AppPath{ Path: model.AppPath{
Allow: "^/allowed$", Allow: "/allowed",
},
},
"app_path_block": {
Config: model.AppConfig{
Domain: "path-block.example.com",
},
Path: model.AppPath{
Block: "^/admin$",
}, },
}, },
"app_user_allow": { "app_user_allow": {
@@ -171,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.
+52 -63
View File
@@ -10,13 +10,14 @@ import (
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"slices"
"strings" "strings"
"golang.org/x/net/idna" "golang.org/x/net/idna"
) )
// Errors
var ( var (
ErrInvalidURL = fmt.Errorf("invalid url")
ErrSchemeMismatch = fmt.Errorf("scheme mismatch") ErrSchemeMismatch = fmt.Errorf("scheme mismatch")
ErrPortMismatch = fmt.Errorf("port mismatch") ErrPortMismatch = fmt.Errorf("port mismatch")
ErrHostnameMismatch = fmt.Errorf("hostname mismatch") ErrHostnameMismatch = fmt.Errorf("hostname mismatch")
@@ -28,7 +29,8 @@ type DomainValidatorOptions struct {
WithScheme bool WithScheme bool
// Ensure domains have the same port. // Ensure domains have the same port.
WithPort bool WithPort bool
// Specify a list of allowed schemes if WithScheme is set to true. // Specify a list of allowed schemes IF WithScheme is set to true.
// Leave empty to allow any scheme.
AllowedSchemes []string AllowedSchemes []string
} }
@@ -46,74 +48,53 @@ func NewDomainValidator(opts DomainValidatorOptions) *DomainValidator {
} }
} }
func (v *DomainValidator) checkScheme(rawURL string) error {
if !v.opts.WithScheme {
return nil
}
if len(v.opts.AllowedSchemes) == 0 {
return fmt.Errorf("allowed schemes must be specified")
}
for _, scheme := range v.opts.AllowedSchemes {
if strings.HasPrefix(strings.ToLower(rawURL), strings.ToLower(scheme)+"://") {
return nil
}
}
return fmt.Errorf("invalid scheme")
}
func (v *DomainValidator) getURL(i string) (*url.URL, error) { func (v *DomainValidator) getURL(i string) (*url.URL, error) {
if i == "" { u, err := url.Parse(i)
return nil, fmt.Errorf("url cannot be empty")
if !v.opts.WithScheme && (err != nil || u.Host == "") {
u, err = url.Parse("tinyauth://" + i)
} }
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 { if err != nil {
return nil, fmt.Errorf("failed to parse host: %w", err) return nil, fmt.Errorf("failed to parse input url: %w", err)
} }
if u.Host == "" { if u.Host == "" {
return nil, fmt.Errorf("missing host in url: %s", i) return nil, ErrInvalidURL
}
if v.opts.WithPort && !v.opts.WithScheme && u.Port() == "" {
return nil, fmt.Errorf("port validation is enabled but port is missing in input url and schemes are not enabled")
}
if v.opts.WithScheme {
// Empty scheme means that we parsed the url with the tinyauth:// placeholder
if u.Scheme == "tinyauth" {
return nil, fmt.Errorf("input url is missing scheme")
}
if len(v.opts.AllowedSchemes) > 0 && !slices.Contains(v.opts.AllowedSchemes, u.Scheme) {
return nil, fmt.Errorf("scheme %s not allowed", u.Scheme)
}
} }
return u, nil return u, nil
} }
func (v *DomainValidator) getHostname(hostname string) (string, error) { func (v *DomainValidator) getEffectivePort(u *url.URL) (string, bool) {
if u.Port() != "" {
return u.Port(), true
}
switch u.Scheme {
case "http":
return "80", true
case "https":
return "443", true
default:
return "", false
}
}
func (v *DomainValidator) formatHostname(hostname string) (string, error) {
hostname = strings.ToLower(hostname) hostname = strings.ToLower(hostname)
hostname = strings.TrimSuffix(hostname, ".") hostname = strings.TrimSuffix(hostname, ".")
if net.ParseIP(hostname) != nil { if net.ParseIP(hostname) != nil {
@@ -152,18 +133,26 @@ func (v *DomainValidator) Validate(expected, actual string) error {
} }
if v.opts.WithPort { if v.opts.WithPort {
if eu.Port() != au.Port() { eup, ok := v.getEffectivePort(eu)
if !ok {
return fmt.Errorf("failed to get effective port for url: %s", eu.String())
}
aup, ok := v.getEffectivePort(au)
if !ok {
return fmt.Errorf("failed to get effective port for url: %s", au.String())
}
if eup != aup {
return ErrPortMismatch return ErrPortMismatch
} }
} }
euf, err := v.getHostname(eu.Hostname()) euf, err := v.formatHostname(eu.Hostname())
if err != nil { if err != nil {
return err return err
} }
auf, err := v.getHostname(au.Hostname()) auf, err := v.formatHostname(au.Hostname())
if err != nil { if err != nil {
return err return err
@@ -176,7 +165,7 @@ func (v *DomainValidator) Validate(expected, actual string) error {
return nil return nil
} }
// SafeHostname uses the internal validation for domains that the validator uses // SafeHostname uses the internal validation for domains that Validator uses
// to parse a hostname. It ensures the input URL is a valid URL, that a host // to parse a hostname. It ensures the input URL is a valid URL, that a host
// is present and that the hostname is lowercased and without a trailing dot. // is present and that the hostname is lowercased and without a trailing dot.
func (v *DomainValidator) SafeHostname(input string) (string, error) { func (v *DomainValidator) SafeHostname(input string) (string, error) {
@@ -186,5 +175,5 @@ func (v *DomainValidator) SafeHostname(input string) (string, error) {
return "", err return "", err
} }
return v.getHostname(u.Hostname()) return v.formatHostname(u.Hostname())
} }
+57 -39
View File
@@ -20,36 +20,16 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
{ {
description: "Empty url fails", description: "Empty url fails",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "url cannot be empty") assert.ErrorIs(t, e, ErrInvalidURL)
},
},
{
description: "URL without host fails",
input: "/foo",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "missing host in url")
}, },
}, },
{ {
description: "Invalid url fails", description: "Invalid url fails",
input: "foo:foo", input: "foo:foo",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse host")
},
},
{
description: "With scheme and invalid url should fail",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
input: "https://foo:foo",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse input url") assert.ErrorContains(t, e, "failed to parse input url")
}, },
}, },
{
description: "Scheme disabled with scheme should pass",
input: "https://example.com",
expected: "example.com",
},
{ {
description: "Domain without scheme should parse if scheme is disabled", description: "Domain without scheme should parse if scheme is disabled",
input: "example.com", input: "example.com",
@@ -60,7 +40,7 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
options: DomainValidatorOptions{WithScheme: true}, options: DomainValidatorOptions{WithScheme: true},
input: "example.com", input: "example.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "invalid scheme") assert.ErrorIs(t, e, ErrInvalidURL)
}, },
}, },
{ {
@@ -68,7 +48,7 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}}, options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
input: "foo://example.com", input: "foo://example.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "invalid scheme") assert.ErrorContains(t, e, "foo not allowed")
}, },
}, },
{ {
@@ -114,10 +94,15 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
}, },
}, },
{ {
description: "With port enabled without any port should work", // Placeholder should not be used by users and is reserved for the validator.
options: DomainValidatorOptions{WithPort: true}, // Using it is like not using any scheme for the validator, and thus it will fail
input: "example.com", // with schemes enabled.
expected: "example.com", description: "Placeholder scheme supplied directly should fail",
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
input: "tinyauth://example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "input url is missing scheme")
},
}, },
} }
@@ -150,7 +135,7 @@ func TestDomainValidator_Validate(t *testing.T) {
expected: "foo:foo", expected: "foo:foo",
actual: "bar.com", actual: "bar.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse host:") assert.ErrorContains(t, e, "failed to parse input url:")
}, },
}, },
{ {
@@ -158,7 +143,7 @@ func TestDomainValidator_Validate(t *testing.T) {
expected: "example.com", expected: "example.com",
actual: "foo:foo", actual: "foo:foo",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to parse host:") assert.ErrorContains(t, e, "failed to parse input url:")
}, },
}, },
{ {
@@ -177,22 +162,58 @@ func TestDomainValidator_Validate(t *testing.T) {
actual: "https://example.com", actual: "https://example.com",
}, },
{ {
description: "Port validation with ports enabled and empty ports should work", description: "Port validation without ports and schemes disabled should fail",
options: DomainValidatorOptions{WithPort: true}, options: DomainValidatorOptions{WithPort: true},
expected: "example.com", expected: "example.com",
actual: "example.com", actual: "example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "port validation is enabled but port is missing in input url and schemes are not enabled")
},
}, },
{ {
description: "Port validation should pass with same port", description: "Port validation with no port and http should pass",
options: DomainValidatorOptions{WithPort: true, WithScheme: true, AllowedSchemes: []string{"http"}},
expected: "http://example.com",
actual: "http://example.com",
},
{
description: "Port validation with no port and https should pass",
options: DomainValidatorOptions{WithPort: true, WithScheme: true, AllowedSchemes: []string{"https"}},
expected: "https://example.com",
actual: "https://example.com",
},
{
description: "Port validation with port and no scheme should pass with same port",
options: DomainValidatorOptions{WithPort: true}, options: DomainValidatorOptions{WithPort: true},
expected: "example.com:8080", expected: "example.com:8080",
actual: "example.com:8080", actual: "example.com:8080",
}, },
{ {
description: "Port enabled with scheme and matching port should pass", description: "Domains with unknown scheme and port enabled but no port should fail",
options: DomainValidatorOptions{WithPort: true, WithScheme: true},
expected: "ssh://example.com:22",
actual: "ssh://example.com",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to get effective port for url")
},
},
{
description: "Domains with unknown scheme and port enabled but no port should fail, reverse",
options: DomainValidatorOptions{WithPort: true, WithScheme: true},
expected: "ssh://example.com",
actual: "ssh://example.com:22",
errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "failed to get effective port for url")
},
},
{
description: "Port validation with port and no scheme should fail with different port",
options: DomainValidatorOptions{WithPort: true}, options: DomainValidatorOptions{WithPort: true},
expected: "https://example.com:443", expected: "example.com:8080",
actual: "https://example.com:443", actual: "example.com:8081",
errorFunc: func(t *testing.T, e error) {
assert.ErrorIs(t, e, ErrPortMismatch)
},
}, },
{ {
description: "Failure to format expected domain should fail", description: "Failure to format expected domain should fail",
@@ -223,13 +244,10 @@ func TestDomainValidator_Validate(t *testing.T) {
actual: "https://example.com", actual: "https://example.com",
}, },
{ {
description: "Valid domains with non matching ports should fail", description: "Valid domains with matching ports should pass",
options: DomainValidatorOptions{WithPort: true}, options: DomainValidatorOptions{WithPort: true},
expected: "example.com:8080", expected: "example.com:8080",
actual: "example.com:8085", actual: "example.com:8080",
errorFunc: func(t *testing.T, e error) {
assert.ErrorIs(t, e, ErrPortMismatch)
},
}, },
{ {
description: "Valid domains without ports or schemes should pass", description: "Valid domains without ports or schemes should pass",