Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 03d6bea4e1 chore(deps): bump the minor-patch group across 1 directory with 5 updates
Bumps the minor-patch group with 4 updates in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto), [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery), [k8s.io/client-go](https://github.com/kubernetes/client-go) and [modernc.org/sqlite](https://gitlab.com/cznic/sqlite).


Updates `golang.org/x/crypto` from 0.50.0 to 0.51.0
- [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.51.0)

Updates `golang.org/x/tools` from 0.43.0 to 0.44.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.43.0...v0.44.0)

Updates `k8s.io/apimachinery` from 0.36.0 to 0.36.1
- [Commits](https://github.com/kubernetes/apimachinery/compare/v0.36.0...v0.36.1)

Updates `k8s.io/client-go` from 0.36.0 to 0.36.1
- [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kubernetes/client-go/compare/v0.36.0...v0.36.1)

Updates `modernc.org/sqlite` from 1.50.0 to 1.50.1
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.50.0...v1.50.1)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: golang.org/x/tools
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: k8s.io/apimachinery
  dependency-version: 0.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: k8s.io/client-go
  dependency-version: 0.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.50.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 19:35:39 +00:00
143 changed files with 6514 additions and 11691 deletions
+1 -72
View File
@@ -7,9 +7,7 @@ TINYAUTH_APPURL=
# database config # database config
# The database driver to use. Valid values: sqlite, postgres, memory. # The path to the database, including file name.
TINYAUTH_DATABASE_DRIVER="sqlite"
# The path to the SQLite database file, or connection URL when driver is postgres.
TINYAUTH_DATABASE_PATH="./tinyauth.db" TINYAUTH_DATABASE_PATH="./tinyauth.db"
# analytics config # analytics config
@@ -39,52 +37,8 @@ TINYAUTH_SERVER_SOCKETPATH=
TINYAUTH_AUTH_IP_ALLOW= TINYAUTH_AUTH_IP_ALLOW=
# List of blocked IPs or CIDR ranges. # List of blocked IPs or CIDR ranges.
TINYAUTH_AUTH_IP_BLOCK= TINYAUTH_AUTH_IP_BLOCK=
# List of IPs or CIDR ranges that bypass authentication entirely.
TINYAUTH_AUTH_IP_BYPASS=
# Comma-separated list of users (username:hashed_password). # Comma-separated list of users (username:hashed_password).
TINYAUTH_AUTH_USERS= TINYAUTH_AUTH_USERS=
# Enable subdomains support.
TINYAUTH_AUTH_SUBDOMAINSENABLED=true
# Full name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_NAME=
# Given (first) name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_GIVENNAME=
# Family (last) name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_FAMILYNAME=
# Middle name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_MIDDLENAME=
# Nickname of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_NICKNAME=
# URL of the user's profile page.
TINYAUTH_AUTH_USERATTRIBUTES_name_PROFILE=
# URL of the user's profile picture.
TINYAUTH_AUTH_USERATTRIBUTES_name_PICTURE=
# URL of the user's website.
TINYAUTH_AUTH_USERATTRIBUTES_name_WEBSITE=
# Email address of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_EMAIL=
# Gender of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_GENDER=
# Birthdate of the user (YYYY-MM-DD).
TINYAUTH_AUTH_USERATTRIBUTES_name_BIRTHDATE=
# Time zone of the user (e.g. Europe/Athens).
TINYAUTH_AUTH_USERATTRIBUTES_name_ZONEINFO=
# Locale of the user (e.g. en-US).
TINYAUTH_AUTH_USERATTRIBUTES_name_LOCALE=
# Phone number of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_PHONENUMBER=
# Full mailing address, formatted for display.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_FORMATTED=
# Street address.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_STREETADDRESS=
# City or locality.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_LOCALITY=
# State, province, or region.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_REGION=
# Zip or postal code.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_POSTALCODE=
# Country.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_COUNTRY=
# Path to the users file. # Path to the users file.
TINYAUTH_AUTH_USERSFILE= TINYAUTH_AUTH_USERSFILE=
# Enable secure cookies. # Enable secure cookies.
@@ -97,12 +51,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.
TINYAUTH_AUTH_ACLS_POLICY="allow"
# apps config # apps config
@@ -151,10 +101,6 @@ TINYAUTH_OAUTH_PROVIDERS_name_CLIENTID=
TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRET= TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRET=
# Path to the file containing the OAuth client secret. # Path to the file containing the OAuth client secret.
TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRETFILE= TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRETFILE=
# Comma-separated list of allowed OAuth domains for this provider.
TINYAUTH_OAUTH_PROVIDERS_name_WHITELIST=
# Path to the OAuth whitelist file for this provider.
TINYAUTH_OAUTH_PROVIDERS_name_WHITELISTFILE=
# OAuth scopes. # OAuth scopes.
TINYAUTH_OAUTH_PROVIDERS_name_SCOPES= TINYAUTH_OAUTH_PROVIDERS_name_SCOPES=
# OAuth redirect URL. # OAuth redirect URL.
@@ -206,8 +152,6 @@ TINYAUTH_LDAP_ADDRESS=
TINYAUTH_LDAP_BINDDN= TINYAUTH_LDAP_BINDDN=
# Bind password for LDAP authentication. # Bind password for LDAP authentication.
TINYAUTH_LDAP_BINDPASSWORD= TINYAUTH_LDAP_BINDPASSWORD=
# Path to the Bind password.
TINYAUTH_LDAP_BINDPASSWORDFILE=
# Base DN for LDAP searches. # Base DN for LDAP searches.
TINYAUTH_LDAP_BASEDN= TINYAUTH_LDAP_BASEDN=
# Allow insecure LDAP connections. # Allow insecure LDAP connections.
@@ -221,21 +165,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
# tailscale config
# Enable Tailscale integration.
TINYAUTH_TAILSCALE_ENABLED=false
# Tailscale API token.
TINYAUTH_TAILSCALE_APITOKEN=
# Path to the file containing the Tailscale API token.
TINYAUTH_TAILSCALE_APITOKENFILE=
# Tailnet name.
TINYAUTH_TAILSCALE_TAILNET=
# Cache duration for Tailscale device and user lists in seconds.
TINYAUTH_TAILSCALE_CACHEDURATION=300
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
TINYAUTH_LABELPROVIDER="auto"
# log config # log config
# Log level (trace, debug, info, warn, error). # Log level (trace, debug, info, warn, error).
+9 -9
View File
@@ -13,32 +13,32 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with: with:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Setup go - name: Setup go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.0"
- name: Go dependencies - name: Go dependencies
run: go mod download run: go mod download
- name: Setup sqlc - name: Setup sqlc
uses: sqlc-dev/setup-sqlc@v5 uses: sqlc-dev/setup-sqlc@v4
with: with:
sqlc-version: "1.31.1" sqlc-version: "1.31.1"
- name: Check codegen is up to date - name: Check codegen is up to date
run: | run: |
sqlc generate sqlc generate
go generate ./... go generate ./internal/repository/...
git diff --exit-code git diff --exit-code -- internal/repository/
git status --porcelain | grep -q . && echo "untracked files code gen files" && exit 1 || true git status --porcelain -- internal/repository/ | grep -q . && echo "untracked files in internal/repository/" && exit 1 || true
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend working-directory: ./frontend
@@ -62,6 +62,6 @@ jobs:
run: go test -coverprofile=coverage.txt -v ./... run: go test -coverprofile=coverage.txt -v ./...
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
+51 -66
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@718ea10b132b3b2eba29c1007bb80653f286566b # v3 uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with: with:
prerelease: true prerelease: true
tag_name: nightly tag_name: nightly
@@ -35,10 +35,9 @@ jobs:
VERSION: ${{ steps.metadata.outputs.VERSION }} VERSION: ${{ steps.metadata.outputs.VERSION }}
COMMIT_HASH: ${{ steps.metadata.outputs.COMMIT_HASH }} COMMIT_HASH: ${{ steps.metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
BUILD_TAGS: ${{ steps.metadata.outputs.BUILD_TAGS }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: nightly ref: nightly
@@ -48,8 +47,6 @@ jobs:
echo "VERSION=nightly" >> "$GITHUB_OUTPUT" echo "VERSION=nightly" >> "$GITHUB_OUTPUT"
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "COMMIT_HASH=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT" echo "BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT"
export GIN_BUILD_TAGS=nomsgpack
echo "BUILD_TAGS=$GIN_BUILD_TAGS" >> "$GITHUB_OUTPUT"
binary-build: binary-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -58,19 +55,19 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: nightly ref: nightly
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with: with:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend working-directory: ./frontend
@@ -86,11 +83,7 @@ jobs:
- name: Build - name: Build
run: | run: |
cp -r frontend/dist internal/assets/dist cp -r frontend/dist internal/assets/dist
go build -tags ${{ needs.generate-metadata.outputs.BUILD_TAGS }} \ go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
-ldflags "-X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" \
-o tinyauth-amd64 ./cmd/tinyauth
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0
@@ -107,19 +100,19 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: nightly ref: nightly
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with: with:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend working-directory: ./frontend
@@ -135,11 +128,7 @@ jobs:
- name: Build - name: Build
run: | run: |
cp -r frontend/dist internal/assets/dist cp -r frontend/dist internal/assets/dist
go build -tags ${{ needs.generate-metadata.outputs.BUILD_TAGS }} \ go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
-ldflags "-X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" \
-o tinyauth-arm64 ./cmd/tinyauth
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0
@@ -156,42 +145,41 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: nightly ref: nightly
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
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@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build id: build
with: with:
platforms: linux/amd64 platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true outputs: type=image,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=buildkit-amd64 cache-from: type=gha
cache-to: type=gha,mode=max,scope=buildkit-amd64 cache-to: type=gha,mode=max
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
build-args: | build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }} VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
BUILD_TAGS=${{ needs.generate-metadata.outputs.BUILD_TAGS }}
- name: Export digest - name: Export digest
run: | run: |
@@ -202,7 +190,7 @@ jobs:
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: digests-alpine-linux-amd64 name: digests-linux-amd64
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -215,28 +203,28 @@ jobs:
- image-build - image-build
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: nightly ref: nightly
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
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@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build id: build
with: with:
platforms: linux/amd64 platforms: linux/amd64
@@ -244,14 +232,13 @@ jobs:
tags: ghcr.io/${{ github.repository_owner }}/tinyauth tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true outputs: type=image,push-by-digest=true,name-canonical=true,push=true
file: Dockerfile.distroless file: Dockerfile.distroless
cache-from: type=gha,scope=buildkit-distroless-amd64 cache-from: type=gha
cache-to: type=gha,mode=max,scope=buildkit-distroless-amd64 cache-to: type=gha,mode=max
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
build-args: | build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }} VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
BUILD_TAGS=${{ needs.generate-metadata.outputs.BUILD_TAGS }}
- name: Export digest - name: Export digest
run: | run: |
@@ -274,42 +261,41 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: nightly ref: nightly
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
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@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build id: build
with: with:
platforms: linux/arm64 platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true outputs: type=image,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=buildkit-arm64 cache-from: type=gha
cache-to: type=gha,mode=max,scope=buildkit-arm64 cache-to: type=gha,mode=max
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
build-args: | build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }} VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
BUILD_TAGS=${{ needs.generate-metadata.outputs.BUILD_TAGS }}
- name: Export digest - name: Export digest
run: | run: |
@@ -320,7 +306,7 @@ jobs:
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: digests-alpine-linux-arm64 name: digests-linux-arm64
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -333,28 +319,28 @@ jobs:
- image-build-arm - image-build-arm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: nightly ref: nightly
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
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@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build id: build
with: with:
platforms: linux/arm64 platforms: linux/arm64
@@ -362,14 +348,13 @@ jobs:
tags: ghcr.io/${{ github.repository_owner }}/tinyauth tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true outputs: type=image,push-by-digest=true,name-canonical=true,push=true
file: Dockerfile.distroless file: Dockerfile.distroless
cache-from: type=gha,scope=buildkit-distroless-arm64 cache-from: type=gha
cache-to: type=gha,mode=max,scope=buildkit-distroless-arm64 cache-to: type=gha,mode=max
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
build-args: | build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }} VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
BUILD_TAGS=${{ needs.generate-metadata.outputs.BUILD_TAGS }}
- name: Export digest - name: Export digest
run: | run: |
@@ -395,22 +380,22 @@ jobs:
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-alpine-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
flavor: | flavor: |
@@ -438,18 +423,18 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
flavor: | flavor: |
@@ -476,7 +461,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with: with:
files: binaries/* files: binaries/*
tag_name: nightly tag_name: nightly
+50 -71
View File
@@ -16,21 +16,16 @@ jobs:
VERSION: ${{ steps.metadata.outputs.VERSION }} VERSION: ${{ steps.metadata.outputs.VERSION }}
COMMIT_HASH: ${{ steps.metadata.outputs.COMMIT_HASH }} COMMIT_HASH: ${{ steps.metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
BUILD_TAGS: ${{ steps.metadata.outputs.BUILD_TAGS }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate metadata - name: Generate metadata
id: metadata id: metadata
env:
REF_NAME: ${{ github.ref_name }}
run: | run: |
echo "VERSION=$REF_NAME" >> "$GITHUB_OUTPUT" echo "VERSION=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "COMMIT_HASH=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT" echo "BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT"
export GIN_BUILD_TAGS=nomsgpack
echo "BUILD_TAGS=$GIN_BUILD_TAGS" >> "$GITHUB_OUTPUT"
binary-build: binary-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -38,17 +33,17 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with: with:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend working-directory: ./frontend
@@ -64,11 +59,7 @@ jobs:
- name: Build - name: Build
run: | run: |
cp -r frontend/dist internal/assets/dist cp -r frontend/dist internal/assets/dist
go build -tags ${{ needs.generate-metadata.outputs.BUILD_TAGS }} \ go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
-ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" \
-o tinyauth-amd64 ./cmd/tinyauth
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0
@@ -84,17 +75,17 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with: with:
package_json_file: ./frontend/package.json package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version: "^1.26.4" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend working-directory: ./frontend
@@ -110,11 +101,7 @@ jobs:
- name: Build - name: Build
run: | run: |
cp -r frontend/dist internal/assets/dist cp -r frontend/dist internal/assets/dist
go build -tags ${{ needs.generate-metadata.outputs.BUILD_TAGS }} \ go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
-ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" \
-o tinyauth-arm64 ./cmd/tinyauth
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0
@@ -130,41 +117,39 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
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@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build id: build
with: with:
platforms: linux/amd64 platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true outputs: type=image,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=buildkit-amd64 cache-from: type=gha
cache-to: type=gha,mode=max,scope=buildkit-amd64 cache-to: type=gha,mode=max
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
build-args: | build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }} VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS=-s -w
BUILD_TAGS=${{ needs.generate-metadata.outputs.BUILD_TAGS }}
- name: Export digest - name: Export digest
run: | run: |
@@ -175,7 +160,7 @@ jobs:
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: digests-alpine-linux-amd64 name: digests-linux-amd64
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -187,26 +172,26 @@ jobs:
- image-build - image-build
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
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@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build id: build
with: with:
platforms: linux/amd64 platforms: linux/amd64
@@ -214,15 +199,13 @@ jobs:
tags: ghcr.io/${{ github.repository_owner }}/tinyauth tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true outputs: type=image,push-by-digest=true,name-canonical=true,push=true
file: Dockerfile.distroless file: Dockerfile.distroless
cache-from: type=gha,scope=buildkit-distroless-amd64 cache-from: type=gha
cache-to: type=gha,mode=max,scope=buildkit-distroless-amd64 cache-to: type=gha,mode=max
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
build-args: | build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }} VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS=-s -w
BUILD_TAGS=${{ needs.generate-metadata.outputs.BUILD_TAGS }}
- name: Export digest - name: Export digest
run: | run: |
@@ -244,41 +227,39 @@ jobs:
- generate-metadata - generate-metadata
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
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@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build id: build
with: with:
platforms: linux/arm64 platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true outputs: type=image,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=buildkit-arm64 cache-from: type=gha
cache-to: type=gha,mode=max,scope=buildkit-arm64 cache-to: type=gha,mode=max
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
build-args: | build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }} VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS=-s -w
BUILD_TAGS=${{ needs.generate-metadata.outputs.BUILD_TAGS }}
- name: Export digest - name: Export digest
run: | run: |
@@ -289,7 +270,7 @@ jobs:
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: digests-alpine-linux-arm64 name: digests-linux-arm64
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -301,26 +282,26 @@ jobs:
- image-build-arm - image-build-arm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
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@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build id: build
with: with:
platforms: linux/arm64 platforms: linux/arm64
@@ -328,15 +309,13 @@ jobs:
tags: ghcr.io/${{ github.repository_owner }}/tinyauth tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true outputs: type=image,push-by-digest=true,name-canonical=true,push=true
file: Dockerfile.distroless file: Dockerfile.distroless
cache-from: type=gha,scope=buildkit-distroless-arm64 cache-from: type=gha
cache-to: type=gha,mode=max,scope=buildkit-distroless-arm64 cache-to: type=gha,mode=max
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
build-args: | build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }} VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS=-s -w
BUILD_TAGS=${{ needs.generate-metadata.outputs.BUILD_TAGS }}
- name: Export digest - name: Export digest
run: | run: |
@@ -362,22 +341,22 @@ jobs:
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-alpine-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
flavor: | flavor: |
@@ -407,18 +386,18 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth images: ghcr.io/${{ github.repository_owner }}/tinyauth
flavor: | flavor: |
@@ -449,6 +428,6 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Release - name: Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with: with:
files: binaries/* files: binaries/*
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with: with:
persist-credentials: false persist-credentials: false
@@ -38,6 +38,6 @@ jobs:
retention-days: 5 retention-days: 5
- name: Upload to code-scanning - name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate Sponsors - name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1 uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
-3
View File
@@ -51,6 +51,3 @@ config.certify.yml
# deepsec # deepsec
/.deepsec /.deepsec
# jetbrains
/.idea/
+1 -1
View File
@@ -8,7 +8,7 @@ Contributing to Tinyauth is straightforward. Follow the steps below to set up a
## Requirements ## Requirements
- pnpm - pnpm
- Golang v1.26.4 or later - Golang v1.24.0 or later
- Git - Git
- Docker - Docker
- Make - Make
+9 -14
View File
@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM node:26.4-alpine3.23 AS frontend-builder FROM node:26.1-alpine3.23 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -27,8 +27,6 @@ FROM golang:1.26-alpine3.23 AS builder
ARG VERSION ARG VERSION
ARG COMMIT_HASH ARG COMMIT_HASH
ARG BUILD_TIMESTAMP ARG BUILD_TIMESTAMP
ARG LDFLAGS
ARG BUILD_TAGS
WORKDIR /tinyauth WORKDIR /tinyauth
@@ -41,30 +39,27 @@ COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \ RUN CGO_ENABLED=0 go build -ldflags "-s -w \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \ -X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \ -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" \ -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
-o tinyauth ./cmd/tinyauth
# Runner # Runner
FROM alpine:3.24 AS runner FROM alpine:3.23 AS runner
WORKDIR /tinyauth WORKDIR /tinyauth
COPY --from=builder /tinyauth/tinyauth ./ COPY --from=builder /tinyauth/tinyauth ./
EXPOSE 3000 RUN mkdir -p /data
# Make the data directory with a non-root user EXPOSE 3000
RUN addgroup tinyauth && adduser -DH tinyauth -G tinyauth
RUN mkdir -p /data/resources /data/oidc /data/tailscale
RUN chown -R tinyauth:tinyauth /data
VOLUME ["/data"] VOLUME ["/data"]
# Tell tinyauth that it's running in a container and where to find the data directory ENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db
ENV RUNTIME_ENV=docker
ENV TINYAUTH_RESOURCES_PATH=/data/resources
ENV PATH=$PATH:/tinyauth ENV PATH=$PATH:/tinyauth
+10 -15
View File
@@ -1,5 +1,5 @@
# Site builder # Site builder
FROM node:26.4-alpine3.23 AS frontend-builder FROM node:26.1-alpine3.23 AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
@@ -27,8 +27,6 @@ FROM golang:1.26-alpine3.23 AS builder
ARG VERSION ARG VERSION
ARG COMMIT_HASH ARG COMMIT_HASH
ARG BUILD_TIMESTAMP ARG BUILD_TIMESTAMP
ARG LDFLAGS
ARG BUILD_TAGS
WORKDIR /tinyauth WORKDIR /tinyauth
@@ -37,20 +35,16 @@ COPY go.sum ./
RUN go mod download RUN go mod download
COPY ./cmd ./cmd/ COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \ RUN mkdir -p data
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \ -X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \ -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" \ -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
-o tinyauth ./cmd/tinyauth
# Make the data directory with a non-root user
RUN addgroup tinyauth && adduser -DH tinyauth -G tinyauth
RUN mkdir -p /data/resources /data/oidc /data/tailscale
RUN chown -R tinyauth:tinyauth /data
# Runner # Runner
FROM gcr.io/distroless/static-debian12:latest AS runner FROM gcr.io/distroless/static-debian12:latest AS runner
@@ -60,14 +54,15 @@ WORKDIR /tinyauth
COPY --from=builder /tinyauth/tinyauth ./ COPY --from=builder /tinyauth/tinyauth ./
# Since it's distroless, we need to copy the data directory from the builder stage # Since it's distroless, we need to copy the data directory from the builder stage
COPY --from=builder /data /data COPY --from=builder /tinyauth/data /data
EXPOSE 3000 EXPOSE 3000
VOLUME ["/data"] VOLUME ["/data"]
# Tell tinyauth that it's running in a container and where to find the data directory ENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db
ENV RUNTIME_ENV=docker
ENV TINYAUTH_RESOURCES_PATH=/data/resources
ENV PATH=$PATH:/tinyauth ENV PATH=$PATH:/tinyauth
+77 -64
View File
@@ -1,5 +1,5 @@
GNU AFFERO GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@@ -7,15 +7,17 @@
Preamble Preamble
The GNU Affero General Public License is a free, copyleft license for The GNU General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure software and other kinds of works.
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free share and change all versions of a program--to make sure it remains free
software for all its users. software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you price. Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things. free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights To protect your rights, we need to prevent others from denying you
with two steps: (1) assert copyright on the software, and (2) offer these rights or asking you to surrender the rights. Therefore, you have
you this License which gives you legal permission to copy, distribute certain responsibilities if you distribute copies of the software, or if
and/or modify the software. you modify it: responsibilities to respect the freedom of others.
A secondary benefit of defending all users' freedom is that For example, if you distribute copies of such a program, whether
improvements made in alternate versions of the program, if they gratis or for a fee, you must pass on to the recipients the same
receive widespread use, become available for other developers to freedoms that you received. You must make sure that they, too, receive
incorporate. Many developers of free software are heartened and or can get the source code. And you must show them these terms so they
encouraged by the resulting cooperation. However, in the case of know their rights.
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to Developers that use the GNU GPL protect your rights with two steps:
ensure that, in such cases, the modified source code becomes available (1) assert copyright on the software, and (2) offer you this License
to the community. It requires the operator of a network server to giving you legal permission to copy, distribute and/or modify it.
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and For the developers' and authors' protection, the GPL clearly explains
published by Affero, was designed to accomplish similar goals. This is that there is no warranty for this free software. For both users' and
a different license, not a version of the Affero GPL, but Affero has authors' sake, the GPL requires that modified versions be marked as
released a new version of the Affero GPL which permits relicensing under changed, so that their problems will not be attributed erroneously to
this license. authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@@ -60,7 +72,7 @@ modification follow.
0. Definitions. 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License. "This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. works, such as semiconductor masks.
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License. 13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work, License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version but the special requirements of the GNU Affero General Public License,
3 of the GNU General Public License. section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions the GNU General Public License from time to time. Such new versions will
will be similar in spirit to the present version, but may differ in detail to be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published GNU General Public License, you may choose any version ever published
by the Free Software Foundation. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. to choose that version for the Program.
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author> Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer If the program does terminal interaction, make it output a short
network, you should also make sure that it provides a way for users to notice like this when it starts in an interactive mode:
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive <program> Copyright (C) <year> <name of author>
of the code. There are many ways you could offer source, and different This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
solutions will be better for different programs; see section 13 for the This is free software, and you are welcome to redistribute it
specific requirements. under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+5 -34
View File
@@ -8,10 +8,6 @@ TAG_NAME := $(shell git describe --abbrev=0 --exact-match 2> /dev/null || echo "
COMMIT_HASH := $(shell git rev-parse HEAD) COMMIT_HASH := $(shell git rev-parse HEAD)
BUILD_TIMESTAMP := $(shell date '+%Y-%m-%dT%H:%M:%S') BUILD_TIMESTAMP := $(shell date '+%Y-%m-%dT%H:%M:%S')
BIN_NAME := tinyauth-$(GOARCH) BIN_NAME := tinyauth-$(GOARCH)
LDFLAGS := -s -w
# Whatever 6MB serialization lib Gin is using
GIN_BUILD_TAGS := nomsgpack
BUILD_TAGS = $(GIN_BUILD_TAGS)
# Development vars # Development vars
DEV_COMPOSE := $(shell test -f "docker-compose.test.yml" && echo "docker-compose.test.yml" || echo "docker-compose.dev.yml" ) DEV_COMPOSE := $(shell test -f "docker-compose.test.yml" && echo "docker-compose.test.yml" || echo "docker-compose.dev.yml" )
@@ -19,8 +15,6 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
.DEFAULT_GOAL := binary .DEFAULT_GOAL := binary
.PHONY: deps clean-data clean-webui webui binary binary-linux-amd64 binary-linux-arm64 test vet test-race dev dev-infisical prod prod-infisical sql generate docker docker-distroless
# Deps # Deps
deps: deps:
cd frontend && pnpm ci cd frontend && pnpm ci
@@ -42,7 +36,7 @@ webui: clean-webui
# Build the binary # Build the binary
binary: webui binary: webui
CGO_ENABLED=$(CGO_ENABLED) go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \ CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "-s -w \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${TAG_NAME} \ -X github.com/tinyauthapp/tinyauth/internal/model.Version=${TAG_NAME} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \ -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" \ -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" \
@@ -63,17 +57,10 @@ binary-linux-arm64:
$(MAKE) binary $(MAKE) binary
# Go test # Go test
.PHONY: test
test: test:
go test -v ./... go test -v ./...
# Go vet
vet:
go vet ./...
# Go race
test-race:
go test -race ./...
# Development # Development
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
@@ -91,27 +78,11 @@ prod-infisical:
infisical run --env=dev -- docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans infisical run --env=dev -- docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans
# SQL # SQL
.PHONY: sql
sql: sql:
sqlc generate sqlc generate
# Go gen # Go gen
generate: generate:
go generate ./... go run ./gen
go generate ./internal/repository/...
# Docker image
docker:
docker buildx build -t tinyauthapp/tinyauth:dev \
--build-arg=VERSION=$(TAG_NAME) \
--build-arg=COMMIT_HASH=$(COMMIT_HASH) \
--build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) \
--build-arg=BUILD_TAGS=$(BUILD_TAGS) \
-f Dockerfile .
# Docker image distroless
docker-distroless:
docker buildx build -t tinyauthapp/tinyauth:dev-distroless \
--build-arg=VERSION=$(TAG_NAME) \
--build-arg=COMMIT_HASH=$(COMMIT_HASH) \
--build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) \
--build-arg=BUILD_TAGS=$(BUILD_TAGS) \
-f Dockerfile.distroless .
+5 -15
View File
@@ -1,7 +1,7 @@
<div align="center"> <div align="center">
<img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png"> <img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png">
<h1>Tinyauth</h1> <h1>Tinyauth</h1>
<p>The tiniest OpenID Certified™ authorization and authentication server you have ever seen.</p> <p>The tiniest authentication and authorization server you have ever seen.</p>
</div> </div>
<div align="center"> <div align="center">
@@ -28,9 +28,8 @@ Tinyauth is the simplest and tiniest authentication and authorization server you
> [!NOTE] > [!NOTE]
> This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag. > This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag.
As of 2026-06-25, Tinyauth v5.1.0 is OpenID Certified™ for Basic OP. You can find the certification details [here](https://openid.net/certification-old/certified-openid-providers-profiles/), test suite available [here](https://www.certification.openid.net/plan-detail.html?public=true&plan=H0qhpsOcQkxUE). > [!NOTE]
> Tinyauth is in the process of migrating to the new [tinyauthapp](https://github.com/tinyauthapp) organization. The organization **is official** and it will host all of the Tinyauth related repositories in the future.
<img alt="OpenID Certified" width="200" src="https://openid.net/wordpress-content/uploads/2016/05/oid-l-certification-mark-l-cmyk-150dpi-90mm.jpg" />
## Getting Started ## Getting Started
@@ -60,22 +59,13 @@ If you like, you can help translate Tinyauth into more languages by visiting the
## License ## License
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 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) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file.
## Hosting Partners
If you use one of our partners, you can help support us while getting a great hosting deal.
<div>
<a title="InstaPods" target="_blank" href="https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth"><img src="https://instapods.com/deploy-button.svg"></a>
</div>
## Sponsors ## Sponsors
A big thank you to the following people for providing me with more coffee: A big thank you to the following people for providing me with more coffee:
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<a href="https://github.com/afunworm"><img src="https:&#x2F;&#x2F;github.com&#x2F;afunworm.png" width="64px" alt="User avatar: afunworm" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="64px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/Lancelot-Enguerrand"><img src="https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>&nbsp;&nbsp;<a href="https://github.com/allgoewer"><img src="https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a>&nbsp;&nbsp;<a href="https://github.com/NEANC"><img src="https:&#x2F;&#x2F;github.com&#x2F;NEANC.png" width="64px" alt="User avatar: NEANC" /></a>&nbsp;&nbsp;<a href="https://github.com/axjab"><img src="https:&#x2F;&#x2F;github.com&#x2F;axjab.png" width="64px" alt="User avatar: axjab" /></a>&nbsp;&nbsp;<a href="https://github.com/stegratech"><img src="https:&#x2F;&#x2F;github.com&#x2F;stegratech.png" width="64px" alt="User avatar: stegratech" /></a>&nbsp;&nbsp;<a href="https://github.com/apearson"><img src="https:&#x2F;&#x2F;github.com&#x2F;apearson.png" width="64px" alt="User avatar: apearson" /></a>&nbsp;&nbsp;<a href="https://github.com/Micky5991"><img src="https:&#x2F;&#x2F;github.com&#x2F;Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a>&nbsp;&nbsp;<!-- sponsors --> <!-- 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/ax-mad"><img src="https:&#x2F;&#x2F;github.com&#x2F;ax-mad.png" width="64px" alt="User avatar: ax-mad" /></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;<!-- sponsors -->
## Acknowledgements ## Acknowledgements
-32
View File
@@ -1,32 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/model"
)
func configCmd(tconfig *model.Config, loaders []cli.ResourceLoader) *cli.Command {
return &cli.Command{
Name: "config",
Description: "Dump the current configuration in YAML format, useful for debugging",
Configuration: tconfig,
Resources: loaders,
Run: func(_ []string) error {
buf := strings.Builder{}
fmt.Fprint(&buf, "Your current configuration in YAML is:\n\n")
err := renderYamlToBuf(&buf, tconfig)
if err != nil {
return fmt.Errorf("failed to render yaml config: %w", err)
}
fmt.Print(buf.String())
return nil
},
}
}
+19 -64
View File
@@ -7,9 +7,8 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/paerser/cli"
) )
func createOidcClientCmd() *cli.Command { func createOidcClientCmd() *cli.Command {
@@ -32,84 +31,40 @@ func createOidcClientCmd() *cli.Command {
return errors.New("client name can only contain alphanumeric characters and hyphens") return errors.New("client name can only contain alphanumeric characters and hyphens")
} }
u := uuid.New() uuid := uuid.New()
clientId := u.String() clientId := uuid.String()
clientSecret := "ta-" + utils.GenerateString(61) clientSecret := "ta-" + utils.GenerateString(61)
uclientName := strings.ToUpper(clientName) uclientName := strings.ToUpper(clientName)
lclientName := strings.ToLower(clientName) lclientName := strings.ToLower(clientName)
buf := strings.Builder{} builder := strings.Builder{}
// header // header
fmt.Fprintf(&buf, "Created '%s' OIDC client.\n\n", clientName) fmt.Fprintf(&builder, "Created credentials for client %s\n\n", clientName)
// credentials // credentials
fmt.Fprintf(&buf, "Credentials:\n\n") fmt.Fprintf(&builder, "Client Name: %s\n", clientName)
fmt.Fprintf(&buf, "Client Name: %s\n", clientName) fmt.Fprintf(&builder, "Client ID: %s\n", clientId)
fmt.Fprintf(&buf, "Client ID: %s\n", clientId) fmt.Fprintf(&builder, "Client Secret: %s\n\n", clientSecret)
fmt.Fprintf(&buf, "Client Secret: %s\n\n", clientSecret)
// end variables // env variables
fmt.Fprintf(&buf, "Environment variables:\n\n") fmt.Fprint(&builder, "Environment variables:\n\n")
renderToBuf(&buf, []kv{ fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_CLIENTID=%s\n", uclientName, clientId)
{ fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET=%s\n", uclientName, clientSecret)
k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_CLIENTID", uclientName), fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_NAME=%s\n\n", uclientName, utils.Capitalize(lclientName))
v: clientId,
},
{
k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET", uclientName),
v: clientSecret,
},
{
k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_NAME", uclientName),
v: utils.Capitalize(lclientName),
},
}, "=")
fmt.Fprintf(&buf, "\n")
// cli flags // cli flags
fmt.Fprintf(&buf, "CLI flags:\n\n") fmt.Fprint(&builder, "CLI flags:\n\n")
renderToBuf(&buf, []kv{ fmt.Fprintf(&builder, "--oidc.clients.%s.clientid=%s\n", lclientName, clientId)
{ fmt.Fprintf(&builder, "--oidc.clients.%s.clientsecret=%s\n", lclientName, clientSecret)
k: fmt.Sprintf("--oidc.clients.%s.clientid", lclientName), fmt.Fprintf(&builder, "--oidc.clients.%s.name=%s\n\n", lclientName, utils.Capitalize(lclientName))
v: clientId,
},
{
k: fmt.Sprintf("--oidc.clients.%s.clientsecret", lclientName),
v: clientSecret,
},
{
k: fmt.Sprintf("--oidc.clients.%s.name", lclientName),
v: utils.Capitalize(lclientName),
},
}, "=")
fmt.Fprintf(&buf, "\n")
// yaml config
fmt.Fprintf(&buf, "YAML config:\n\n")
err = renderYamlToBuf(&buf, &model.OIDCConfig{
Clients: map[string]model.OIDCClientConfig{
lclientName: {
ClientID: clientId,
ClientSecret: clientSecret,
Name: utils.Capitalize(lclientName),
},
},
})
if err != nil {
return fmt.Errorf("failed to render yaml config: %w", err)
}
buf.WriteString("\n")
// footer // footer
fmt.Fprintln(&buf, "You can use any of the above options to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.") fmt.Fprintln(&builder, "You can use either option to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.")
// print // print
out := buf.String() out := builder.String()
fmt.Print(out) fmt.Print(out)
return nil return nil
}, },
+44 -90
View File
@@ -3,12 +3,11 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -35,107 +34,62 @@ func createUserCmd() *cli.Command {
&cli.FlagLoader{}, &cli.FlagLoader{},
} }
cmd := &cli.Command{ return &cli.Command{
Name: "create", Name: "create",
Description: "Create a user", Description: "Create a user",
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
} Run: func(_ []string) error {
log := logger.NewLogger().WithSimpleConfig()
log.Init()
cmd.Run = func(_ []string) error { if tCfg.Interactive {
if tCfg.Interactive { form := huh.NewForm(
form := huh.NewForm( huh.NewGroup(
huh.NewGroup( huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate(func(s string) error { if s == "" {
if s == "" { return errors.New("username cannot be empty")
return errors.New("username cannot be empty") }
} return nil
if strings.Contains(s, ":") { })),
return errors.New("username cannot contain ':'") huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
} if s == "" {
return nil return errors.New("password cannot be empty")
}), }
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate(func(s string) error { return nil
if s == "" { })),
return errors.New("password cannot be empty") huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker),
} ),
return nil )
}),
huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker),
),
)
theme := new(themeBase) theme := new(themeBase)
err := form.WithTheme(theme).Run()
err := form.WithTheme(theme).Run() if err != nil {
if err != nil { return fmt.Errorf("failed to run interactive prompt: %w", err)
return fmt.Errorf("failed to run interactive prompt: %w", err) }
} }
}
if tCfg.Username == "" || tCfg.Password == "" { if tCfg.Username == "" || tCfg.Password == "" {
cmd.PrintHelp(os.Stdout) return errors.New("username and password cannot be empty")
return errors.New("username and password cannot be empty") }
}
if strings.Contains(tCfg.Username, ":") { log.App.Info().Str("username", tCfg.Username).Msg("Creating user")
return errors.New("username cannot contain ':'")
}
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost) passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return fmt.Errorf("failed to hash password: %w", err) return fmt.Errorf("failed to hash password: %w", err)
} }
// Only the docker compose output needs $ escaped, the raw hash is correct everywhere else // If docker format is enabled, escape the dollar sign
passwdStr := string(passwd) passwdStr := string(passwd)
outputStr := passwdStr if tCfg.Docker {
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
}
if tCfg.Docker { log.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
outputStr = strings.ReplaceAll(passwdStr, "$", "$$")
}
user := fmt.Sprintf("%s:%s", tCfg.Username, passwdStr) return nil
escapedUser := fmt.Sprintf("%s:%s", tCfg.Username, outputStr) },
buf := strings.Builder{}
// header
fmt.Fprintf(&buf, "Created user '%s'.\n\n", tCfg.Username)
// environment variable
fmt.Fprint(&buf, "Environment variable:\n\n")
renderToBuf(&buf, []kv{
{"TINYAUTH_AUTH_USERS", escapedUser},
}, "=")
// cli flags
fmt.Fprint(&buf, "\nCLI flags:\n\n")
renderToBuf(&buf, []kv{
{"--auth.users", user},
}, "=")
// yaml config
fmt.Fprint(&buf, "\nYAML config:\n\n")
err = renderYamlToBuf(&buf, &model.Config{
Auth: model.AuthConfig{
Users: []string{user},
},
})
if err != nil {
return fmt.Errorf("failed to render yaml config: %w", err)
}
buf.WriteString("\n")
// footer
fmt.Fprint(&buf, "Use your config option of choice to add the user to Tinyauth and then restart.")
fmt.Println(buf.String())
return nil
} }
return cmd
} }
+58 -70
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/mdp/qrterminal/v3" "github.com/mdp/qrterminal/v3"
@@ -33,98 +34,85 @@ func generateTotpCmd() *cli.Command {
&cli.FlagLoader{}, &cli.FlagLoader{},
} }
cmd := &cli.Command{ return &cli.Command{
Name: "generate", Name: "generate",
Description: "Generate a TOTP secret", Description: "Generate a TOTP secret",
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
} Run: func(_ []string) error {
log := logger.NewLogger().WithSimpleConfig()
log.Init()
cmd.Run = func(_ []string) error { if tCfg.Interactive {
colors := getColors() form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Current user (username:hash)").Value(&tCfg.User).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
),
)
if tCfg.Interactive { theme := new(themeBase)
form := huh.NewForm( err := form.WithTheme(theme).Run()
huh.NewGroup(
huh.NewInput().Title("Current user (username:hash)").Value(&tCfg.User).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
),
)
theme := new(themeBase) if err != nil {
err := form.WithTheme(theme).Run() return fmt.Errorf("failed to run interactive prompt: %w", err)
}
}
user, err := utils.ParseUser(tCfg.User)
if err != nil { if err != nil {
return fmt.Errorf("failed to run interactive prompt: %w", err) return fmt.Errorf("failed to parse user: %w", err)
} }
}
if tCfg.User == "" { docker := false
cmd.PrintHelp(os.Stdout) if strings.Contains(tCfg.User, "$$") {
return fmt.Errorf("user is required") docker = true
} }
user, err := utils.ParseUser(tCfg.User) if user.TOTPSecret != "" {
return fmt.Errorf("user already has a TOTP secret")
}
if err != nil { key, err := totp.Generate(totp.GenerateOpts{
return fmt.Errorf("failed to parse user: %w", err) Issuer: "Tinyauth",
} AccountName: user.Username,
})
docker := false if err != nil {
if strings.Contains(tCfg.User, "$$") { return fmt.Errorf("failed to generate TOTP secret: %w", err)
docker = true }
}
if user.TOTPSecret != "" { secret := key.Secret()
return fmt.Errorf("user already has a TOTP secret")
}
key, err := totp.Generate(totp.GenerateOpts{ log.App.Info().Str("secret", secret).Msg("Generated TOTP secret")
Issuer: "Tinyauth",
AccountName: user.Username,
})
if err != nil { log.App.Info().Msg("Generated QR code")
return fmt.Errorf("failed to generate TOTP secret: %w", err)
}
secret := key.Secret() config := qrterminal.Config{
Level: qrterminal.L,
Writer: os.Stdout,
BlackChar: qrterminal.BLACK,
WhiteChar: qrterminal.WHITE,
QuietZone: 2,
}
fmt.Printf("Scan the following QR code with your authenticator app (e.g., Google Authenticator, 2fauth, Microsoft Authenticator):\n\n") qrterminal.GenerateWithConfig(key.URL(), config)
config := qrterminal.Config{ user.TOTPSecret = secret
Level: qrterminal.L,
Writer: os.Stdout,
BlackChar: qrterminal.BLACK,
WhiteChar: qrterminal.WHITE,
QuietZone: 2,
}
qrterminal.GenerateWithConfig(key.URL(), config) // If using docker escape re-escape it
if docker {
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
}
user.TOTPSecret = secret log.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
// If using docker escape re-escape it return nil
if docker { },
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
}
userStr := fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)
fmt.Print("\nOr add the following TOTP secret to your authenticator app: ")
fmt.Print(colors.green.Render(secret))
fmt.Print("\n\n")
fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username)
fmt.Print(colors.green.Render(userStr))
fmt.Print("\n")
return nil
} }
return cmd
} }
+16 -147
View File
@@ -2,23 +2,18 @@ package main
import ( import (
"fmt" "fmt"
"os"
"reflect"
"strings"
"charm.land/huh/v2" "charm.land/huh/v2"
"charm.land/lipgloss/v2"
"github.com/tinyauthapp/tinyauth/internal/bootstrap" "github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/loaders" "github.com/tinyauthapp/tinyauth/internal/utils/loaders"
"gopkg.in/yaml.v3"
"github.com/rs/zerolog/log"
"github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/cli"
) )
func main() { func main() {
env := model.DetectRuntimeEnv() tConfig := model.NewDefaultConfiguration()
tConfig := model.NewDefaultConfiguration(env)
loaders := []cli.ResourceLoader{ loaders := []cli.ResourceLoader{
&loaders.FileLoader{}, &loaders.FileLoader{},
@@ -32,114 +27,83 @@ func main() {
Configuration: tConfig, Configuration: tConfig,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) {
colors := getColors()
fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.")
}
return runCmd(*tConfig) return runCmd(*tConfig)
}, },
} }
cmdUser := &cli.Command{ cmdUser := &cli.Command{
Name: "user", Name: "user",
Description: "Manage users", Description: "Manage Tinyauth users",
} }
cmdTotp := &cli.Command{ cmdTotp := &cli.Command{
Name: "totp", Name: "totp",
Description: "Manage TOTP users", Description: "Manage Tinyauth TOTP users",
} }
cmdOidc := &cli.Command{ cmdOidc := &cli.Command{
Name: "oidc", Name: "oidc",
Description: "Manage OIDC clients", Description: "Manage Tinyauth OIDC clients",
} }
helpCmd := &cli.Command{ err := cmdTinyauth.AddCommand(versionCmd())
Name: "help",
Description: "Show the help message",
Run: func(_ []string) error {
return cmdTinyauth.PrintHelp(os.Stdout)
},
}
err := cmdTinyauth.AddCommand(helpCmd)
if err != nil { if err != nil {
fatalf(err, "Failed to add help command") log.Fatal().Err(err).Msg("Failed to add version command")
}
err = cmdTinyauth.AddCommand(versionCmd())
if err != nil {
fatalf(err, "Failed to add version command")
}
err = cmdTinyauth.AddCommand(configCmd(tConfig, loaders))
if err != nil {
fatalf(err, "Failed to add config command")
} }
err = cmdUser.AddCommand(verifyUserCmd()) err = cmdUser.AddCommand(verifyUserCmd())
if err != nil { if err != nil {
fatalf(err, "Failed to add user verify command") log.Fatal().Err(err).Msg("Failed to add verify command")
} }
err = cmdTinyauth.AddCommand(healthcheckCmd()) err = cmdTinyauth.AddCommand(healthcheckCmd())
if err != nil { if err != nil {
fatalf(err, "Failed to add healthcheck command") log.Fatal().Err(err).Msg("Failed to add healthcheck command")
} }
err = cmdTotp.AddCommand(generateTotpCmd()) err = cmdTotp.AddCommand(generateTotpCmd())
if err != nil { if err != nil {
fatalf(err, "Failed to add totp generate command") log.Fatal().Err(err).Msg("Failed to add generate command")
} }
err = cmdUser.AddCommand(createUserCmd()) err = cmdUser.AddCommand(createUserCmd())
if err != nil { if err != nil {
fatalf(err, "Failed to add create user command") log.Fatal().Err(err).Msg("Failed to add create command")
} }
err = cmdOidc.AddCommand(createOidcClientCmd()) err = cmdOidc.AddCommand(createOidcClientCmd())
if err != nil { if err != nil {
fatalf(err, "Failed to add create oidc client command") log.Fatal().Err(err).Msg("Failed to add create command")
} }
err = cmdTinyauth.AddCommand(cmdUser) err = cmdTinyauth.AddCommand(cmdUser)
if err != nil { if err != nil {
fatalf(err, "Failed to add user command") log.Fatal().Err(err).Msg("Failed to add user command")
} }
err = cmdTinyauth.AddCommand(cmdTotp) err = cmdTinyauth.AddCommand(cmdTotp)
if err != nil { if err != nil {
fatalf(err, "Failed to add totp command") log.Fatal().Err(err).Msg("Failed to add totp command")
} }
err = cmdTinyauth.AddCommand(cmdOidc) err = cmdTinyauth.AddCommand(cmdOidc)
if err != nil { if err != nil {
fatalf(err, "Failed to add oidc command") log.Fatal().Err(err).Msg("Failed to add oidc command")
} }
err = cli.Execute(cmdTinyauth) err = cli.Execute(cmdTinyauth)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "command not found") { log.Fatal().Err(err).Msg("Failed to execute command")
fmt.Println("Command not found. Use 'tinyauth help' to see available commands.")
return
}
if strings.Contains(err.Error(), "is not runnable") {
return
}
fatalf(err, "Failed to execute command")
} }
} }
@@ -160,98 +124,3 @@ type themeBase struct{}
func (t *themeBase) Theme(isDark bool) *huh.Styles { func (t *themeBase) Theme(isDark bool) *huh.Styles {
return huh.ThemeBase(isDark) return huh.ThemeBase(isDark)
} }
type colors struct {
blue lipgloss.Style
gray lipgloss.Style
lightGray lipgloss.Style
green lipgloss.Style
yellow lipgloss.Style
}
func getColors() colors {
noColor := os.Getenv("NO_COLOR")
forceColor := os.Getenv("FORCE_COLOR")
colorOut := colors{
green: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(34)),
gray: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(245)),
yellow: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(214)),
blue: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(75)),
lightGray: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(250)),
}
noColorOut := colors{
green: lipgloss.NewStyle(),
gray: lipgloss.NewStyle(),
yellow: lipgloss.NewStyle(),
blue: lipgloss.NewStyle(),
lightGray: lipgloss.NewStyle(),
}
useColors := true
if noColor == "true" || noColor == "1" {
useColors = false
}
if forceColor == "true" || forceColor == "1" {
useColors = true
}
if !useColors {
return noColorOut
}
return colorOut
}
func fatalf(err error, msg string) {
fmt.Printf("%s: %v\n", msg, err)
os.Exit(1)
}
type kv struct {
k string
v string
}
func renderToBuf(buf *strings.Builder, kv []kv, sep string) {
colors := getColors()
for _, i := range kv {
buf.WriteString(colors.blue.Render(i.k))
buf.WriteString(colors.gray.Render(sep))
buf.WriteString(colors.lightGray.Render(i.v))
buf.WriteString("\n")
}
}
func renderYamlToBuf(buf *strings.Builder, i any) error {
colors := getColors()
yout, err := yaml.Marshal(i)
if err != nil {
return fmt.Errorf("failed to marshal yaml: %w", err)
}
for l := range strings.SplitSeq(string(yout), "\n") {
if l == "" {
continue
}
if strings.HasPrefix(strings.TrimLeft(l, " "), "- ") {
buf.WriteString(colors.lightGray.Render(l))
buf.WriteString("\n")
continue
}
lp := strings.SplitN(l, ":", 2)
buf.WriteString(colors.blue.Render(lp[0]))
buf.WriteString(colors.gray.Render(":"))
if len(lp) == 2 {
buf.WriteString(colors.lightGray.Render(lp[1]))
}
buf.WriteString("\n")
}
return nil
}
+64 -70
View File
@@ -3,9 +3,9 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@@ -38,87 +38,81 @@ func verifyUserCmd() *cli.Command {
&cli.FlagLoader{}, &cli.FlagLoader{},
} }
cmd := &cli.Command{ return &cli.Command{
Name: "verify", Name: "verify",
Description: "Verify a user is set up correctly", Description: "Verify a user is set up correctly",
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
} Run: func(_ []string) error {
log := logger.NewLogger().WithSimpleConfig()
log.Init()
cmd.Run = func(_ []string) error { if tCfg.Interactive {
colors := getColors() form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("User (username:hash:totp)").Value(&tCfg.User).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
return nil
})),
huh.NewInput().Title("TOTP Code (optional)").Value(&tCfg.Totp),
),
)
if tCfg.Interactive { theme := new(themeBase)
form := huh.NewForm( err := form.WithTheme(theme).Run()
huh.NewGroup(
huh.NewInput().Title("User (username:hash:totp)").Value(&tCfg.User).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
return nil
})),
huh.NewInput().Title("TOTP Code (optional)").Value(&tCfg.Totp),
),
)
theme := new(themeBase) if err != nil {
err := form.WithTheme(theme).Run() return fmt.Errorf("failed to run interactive prompt: %w", err)
}
}
user, err := utils.ParseUser(tCfg.User)
if err != nil { if err != nil {
return fmt.Errorf("failed to run interactive prompt: %w", err) return fmt.Errorf("failed to parse user: %w", err)
} }
}
if tCfg.User == "" || tCfg.Username == "" || tCfg.Password == "" { if user.Username != tCfg.Username {
cmd.PrintHelp(os.Stdout) return fmt.Errorf("username is incorrect")
return fmt.Errorf("user, username, and password are required")
}
user, err := utils.ParseUser(tCfg.User)
if err != nil {
return fmt.Errorf("failed to parse user: %w", err)
}
if user.Username != tCfg.Username {
return fmt.Errorf("username is incorrect")
}
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password))
if err != nil {
return fmt.Errorf("password is incorrect: %w", err)
}
if user.TOTPSecret == "" {
if tCfg.Totp != "" {
fmt.Println(colors.yellow.Render("⚠") + " TOTP code provided but user does not have TOTP enabled")
} }
fmt.Println(colors.green.Render("✓") + " User verified")
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password))
if err != nil {
return fmt.Errorf("password is incorrect: %w", err)
}
if user.TOTPSecret == "" {
if tCfg.Totp != "" {
log.App.Warn().Msg("User does not have TOTP secret")
}
log.App.Info().Msg("User verified")
return nil
}
ok := totp.Validate(tCfg.Totp, user.TOTPSecret)
if !ok {
return fmt.Errorf("TOTP code incorrect")
}
log.App.Info().Msg("User verified")
return nil return nil
} },
ok := totp.Validate(tCfg.Totp, user.TOTPSecret)
if !ok {
return fmt.Errorf("TOTP code incorrect")
}
fmt.Println(colors.green.Render("✓") + " User verified")
return nil
} }
return cmd
} }
+3 -4
View File
@@ -14,10 +14,9 @@ func versionCmd() *cli.Command {
Configuration: nil, Configuration: nil,
Resources: nil, Resources: nil,
Run: func(_ []string) error { Run: func(_ []string) error {
colors := getColors() fmt.Printf("Version: %s\n", model.Version)
fmt.Printf("Version: %s\n", colors.blue.Render(model.Version)) fmt.Printf("Commit Hash: %s\n", model.CommitHash)
fmt.Printf("Commit Hash: %s\n", colors.blue.Render(model.CommitHash)) fmt.Printf("Build Timestamp: %s\n", model.BuildTimestamp)
fmt.Printf("Build Timestamp: %s\n", colors.blue.Render(model.BuildTimestamp))
return nil return nil
}, },
} }
+26 -25
View File
@@ -12,49 +12,50 @@
}, },
"packageManager": "pnpm@11.1.2", "packageManager": "pnpm@11.1.2",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dropdown-menu": "^2.1.19", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.11", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.3.2", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.11", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.99.0",
"axios": "^1.18.1", "axios": "^1.15.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^26.3.4", "i18next": "^26.0.4",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"lucide-react": "^1.23.0", "lucide-react": "^1.8.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.1", "radix-ui": "^1.4.3",
"react": "^19.2.7", "react": "^19.2.5",
"react-dom": "^19.2.7", "react-dom": "^19.2.5",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.72.1",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.2",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.14.0", "react-router": "^7.14.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.2.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.2", "@tanstack/eslint-plugin-query": "^5.99.0",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.6.0", "eslint": "^10.2.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.2",
"globals": "^17.7.0", "globals": "^17.5.0",
"prettier": "3.8.2",
"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.62.1", "typescript-eslint": "^8.58.1",
"vite": "^8.1.3" "vite": "^8.0.8"
} }
} }
+1377 -1289
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,3 +1,4 @@
dangerouslyAllowAllBuilds: false dangerouslyAllowAllBuilds: false
blockExoticSubdeps: true blockExoticSubdeps: true
minimumReleaseAge: 1440 # 1 day minimumReleaseAge: 1440 # 1 day
trustPolicy: no-downgrade
+2 -2
View File
@@ -2,9 +2,9 @@ import { Navigate } from "react-router";
import { useUserContext } from "./context/user-context"; import { useUserContext } from "./context/user-context";
export const App = () => { export const App = () => {
const { auth } = useUserContext(); const { isLoggedIn } = useUserContext();
if (auth.authenticated) { if (isLoggedIn) {
return <Navigate to="/logout" replace />; return <Navigate to="/logout" replace />;
} }
@@ -1,22 +0,0 @@
import type { SVGProps } from "react";
export function LocalAuthIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h5m3.5 3.5L15 22l-1.5-1.5m5.054-2.086a2 2 0 1 1 2.828-2.828a2 2 0 0 1-2.828 2.828M16 19l1 1"
></path>
</svg>
);
}
@@ -0,0 +1,36 @@
import { languages, SupportedLanguage } from "@/lib/i18n/locales";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { useState } from "react";
import i18n from "@/lib/i18n/i18n";
export const LanguageSelector = () => {
const [language, setLanguage] = useState<SupportedLanguage>(
i18n.language as SupportedLanguage,
);
const handleSelect = (option: string) => {
setLanguage(option as SupportedLanguage);
i18n.changeLanguage(option as SupportedLanguage);
};
return (
<Select onValueChange={handleSelect} value={language}>
<SelectTrigger aria-label="Select language">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{Object.entries(languages).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
+12 -22
View File
@@ -1,28 +1,29 @@
import { useAppContext } from "@/context/app-context"; import { useAppContext } from "@/context/app-context";
import { LanguageSelector } from "../language/language";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DomainWarning } from "../domain-warning/domain-warning"; import { DomainWarning } from "../domain-warning/domain-warning";
import { QuickActions } from "../quick-actions/quick-actions"; import { ThemeToggle } from "../theme-toggle/theme-toggle";
import { isTrustedDomain } from "@/lib/hooks/redirect-uri";
const BaseLayout = ({ children }: { children: React.ReactNode }) => { const BaseLayout = ({ children }: { children: React.ReactNode }) => {
const { ui } = useAppContext(); const { backgroundImage, title } = useAppContext();
useEffect(() => { useEffect(() => {
document.title = ui.title; document.title = title;
}, [ui.title]); }, [title]);
return ( return (
<div <div
className="flex flex-col justify-center items-center min-h-svh px-4" className="flex flex-col justify-center items-center min-h-svh px-4"
style={{ style={{
backgroundImage: `url(${ui.backgroundImage})`, backgroundImage: `url(${backgroundImage})`,
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
}} }}
> >
<div className="absolute top-4 right-4"> <div className="absolute top-4 right-4 flex flex-row gap-2">
<QuickActions /> <ThemeToggle />
<LanguageSelector />
</div> </div>
<div className="max-w-sm md:min-w-sm min-w-xs">{children}</div> <div className="max-w-sm md:min-w-sm min-w-xs">{children}</div>
</div> </div>
@@ -30,7 +31,7 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
}; };
export const Layout = () => { export const Layout = () => {
const { app, ui } = useAppContext(); const { appUrl, warningsEnabled } = useAppContext();
const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => { const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => {
return window.sessionStorage.getItem("ignoreDomainWarning") === "true"; return window.sessionStorage.getItem("ignoreDomainWarning") === "true";
}); });
@@ -41,22 +42,11 @@ export const Layout = () => {
setIgnoreDomainWarning(true); setIgnoreDomainWarning(true);
}, [setIgnoreDomainWarning]); }, [setIgnoreDomainWarning]);
const isTrusted = (() => { if (!ignoreDomainWarning && warningsEnabled && appUrl !== currentUrl) {
try {
const appUrlObj = new URL(app.appUrl);
const currentUrlObj = new URL(currentUrl);
return isTrustedDomain(currentUrlObj, appUrlObj, "", false);
} catch {
return false;
}
})();
if (!ignoreDomainWarning && ui.warningsEnabled && !isTrusted) {
return ( return (
<BaseLayout> <BaseLayout>
<DomainWarning <DomainWarning
appUrl={app.appUrl} appUrl={appUrl}
currentUrl={currentUrl} currentUrl={currentUrl}
onClick={() => handleIgnore()} onClick={() => handleIgnore()}
/> />
@@ -1,274 +0,0 @@
import { languages, SupportedLanguage } from "@/lib/i18n/locales";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { useState } from "react";
import i18n from "@/lib/i18n/i18n";
import { useUserContext } from "@/context/user-context";
import { ScrollArea } from "../ui/scroll-area";
import { useTheme } from "../providers/theme-provider";
import {
Check,
DoorOpenIcon,
Languages,
Monitor,
Moon,
Palette,
Settings,
Sun,
UserRoundKey,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import { useRef } from "react";
import {
useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { toast } from "sonner";
import { useEffect } from "react";
import { GoogleIcon } from "../icons/google";
import { GithubIcon } from "../icons/github";
import { TailscaleIcon } from "../icons/tailscale";
import { MicrosoftIcon } from "../icons/microsoft";
import { PocketIDIcon } from "../icons/pocket-id";
import { OAuthIcon } from "../icons/oauth";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
const iconStyles = "size-4";
const iconMap: Record<string, React.ReactNode> = {
google: <GoogleIcon className={iconStyles} />,
github: <GithubIcon className={iconStyles} />,
tailscale: <TailscaleIcon className={iconStyles} />,
microsoft: <MicrosoftIcon className={iconStyles} />,
pocketid: <PocketIDIcon className={iconStyles} />,
};
export const QuickActions = () => {
const { auth, oauth, tailscale } = useUserContext();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const { search } = useLocation();
const [language, setLanguage] = useState<SupportedLanguage>(
i18n.language as SupportedLanguage,
);
const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams);
const [isOpen, setIsOpen] = useState(false);
const providerDetails = (():
| { name: string; icon: React.ReactNode }
| undefined => {
if (!auth.authenticated) {
return undefined;
}
if (auth.providerId === "local" || auth.providerId === "ldap") {
return {
name: t(
auth.providerId === "ldap"
? "quickActionsProviderLDAP"
: "quickActionsProviderLocal",
),
icon: (
<UserRoundKey
strokeWidth={1.5}
size={16}
className="text-muted-foreground ml-0.5"
/>
),
};
}
if (oauth.active) {
return {
name: t("quickActionsProviderOAuth", { provider: oauth.displayName }),
icon: iconMap[auth.providerId] || <OAuthIcon className={iconStyles} />,
};
}
if (auth.providerId === "tailscale") {
return {
name: `Tailscale (${tailscale.nodeName})`,
icon: <TailscaleIcon className={iconStyles} />,
};
}
return undefined;
})();
const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/user/logout"),
mutationKey: ["logout"],
onSuccess: () => {
toast.success(t("logoutSuccessTitle"), {
description: t("logoutSuccessSubtitle"),
});
redirectTimer.current = window.setTimeout(() => {
window.location.replace(`/login${compiledParams}`);
}, 500);
},
onError: () => {
toast.error(t("logoutFailTitle"), {
description: t("logoutFailSubtitle"),
});
},
});
useEffect(() => {
return () => {
if (redirectTimer.current) {
clearTimeout(redirectTimer.current);
}
};
}, [redirectTimer]);
const initial = auth.authenticated
? (auth.name[0] || "U").toUpperCase()
: null;
const handleSelect = (option: string) => {
setLanguage(option as SupportedLanguage);
i18n.changeLanguage(option as SupportedLanguage);
};
const themes = [
{ key: "light", label: t("quickActionsThemeLight"), icon: Sun },
{ key: "dark", label: t("quickActionsThemeDark"), icon: Moon },
{ key: "system", label: t("quickActionsThemeSystem"), icon: Monitor },
] as const;
return (
<DropdownMenu onOpenChange={(open) => setIsOpen(open)} open={isOpen}>
<DropdownMenuTrigger asChild>
<button
aria-label={t("quickActionsTitle")}
className="rounded-full transition-transform duration-200 will-change-transform hover:scale-105 hover:cursor-pointer focus:ring-0 focus:outline-3 focus:outline-ring/50"
>
{auth.authenticated ? (
<div className="size-10 flex justify-center items-center p-2 rounded-full bg-card border border-border">
{isOpen ? (
<X className="size-4 text-primary rotate-0 transition-transform duration-200 starting:rotate-45" />
) : (
<span className="text-sm text-primary rotate-0 transition-transform duration-200 starting:-rotate-45">
{initial}
</span>
)}
</div>
) : (
<span className="bg-card text-primary border-border size-10 flex items-center justify-center rounded-full border shadow-lg">
<Settings
className={`size-4 transition-transform duration-200 ${
isOpen ? "rotate-45" : "rotate-0"
}`}
/>
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="rounded-xl p-1 w-3xs"
>
{auth.authenticated && (
<>
<DropdownMenuLabel className="flex items-center gap-3 p-2">
<Tooltip>
<TooltipTrigger className="size-9 rounded-full p-2 bg-muted border-border border flex items-center justify-center">
{providerDetails!.icon}
</TooltipTrigger>
<TooltipContent>{providerDetails!.name}</TooltipContent>
</Tooltip>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{auth.name}
</span>
<span className="text-muted-foreground truncate text-xs">
{auth.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="size-4" />
{t("quickActionsLanguage")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent sideOffset={8} className="rounded-xl p-1">
<ScrollArea className="h-80">
{Object.entries(languages).map(([key, value]) => (
<DropdownMenuItem
key={key}
onSelect={() => handleSelect(key)}
>
{value}
{language === key && <Check className="size-4" />}
</DropdownMenuItem>
))}
</ScrollArea>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="size-4" />
{t("quickActionsTheme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="rounded-xl p-1" sideOffset={8}>
{themes.map(({ key, label, icon: Icon }) => (
<DropdownMenuItem key={key} onClick={() => setTheme(key)}>
<span className="flex items-center gap-2">
<Icon className="size-4" />
{label}
</span>
{theme === key && <Check className="size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{auth.authenticated && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => logoutMutation.mutate()}
className="text-destructive"
>
<DoorOpenIcon className="size-4 text-destructive" />
{t("quickActionsLogout")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,40 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/providers/theme-provider";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="bg-card text-card-foreground hover:bg-card/90"
size="icon"
>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,56 +0,0 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
-17
View File
@@ -1,17 +0,0 @@
type UseLoginForProps = {
login_for?: "oidc" | "app";
compiledParams: string;
};
export const useLoginFor = (props: UseLoginForProps): string => {
const { login_for, compiledParams } = props;
switch (login_for) {
case "oidc":
return "/oidc/authorize" + compiledParams;
case "app":
return "/continue" + compiledParams;
default:
return "/logout";
}
};
+76
View File
@@ -0,0 +1,76 @@
import { z } from "zod";
export const oidcParamsSchema = z.object({
scope: z.string().min(1),
response_type: z.string().min(1),
client_id: z.string().min(1),
redirect_uri: z.string().min(1),
state: z.string().optional(),
nonce: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
});
function b64urlDecode(s: string): string {
const base64 = s.replace(/-/g, "+").replace(/_/g, "/");
return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="));
}
function decodeRequestObject(jwt: string): Record<string, string> {
try {
// Must have exactly 3 parts: header, payload, signature
const parts = jwt.split(".");
if (parts.length !== 3) return {};
// Header must specify "alg": "none" and signature must be empty string
const header = JSON.parse(b64urlDecode(parts[0]));
if (!header || typeof header !== "object" || header.alg !== "none" || parts[2] !== "") return {};
const payload = JSON.parse(b64urlDecode(parts[1]));
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(payload)) {
if (typeof v === "string") result[k] = v;
}
return result;
} catch {
return {};
}
}
export const useOIDCParams = (
params: URLSearchParams,
): {
values: z.infer<typeof oidcParamsSchema>;
issues: string[];
isOidc: boolean;
compiled: string;
} => {
const obj = Object.fromEntries(params.entries());
// RFC 9101 / OIDC Core 6.1: if `request` param present, decode JWT payload
// and merge claims over top-level params (JWT claims take precedence)
const requestJwt = params.get("request");
if (requestJwt) {
const claims = decodeRequestObject(requestJwt);
Object.assign(obj, claims);
}
const parsed = oidcParamsSchema.safeParse(obj);
if (parsed.success) {
return {
values: parsed.data,
issues: [],
isOidc: true,
compiled: new URLSearchParams(parsed.data).toString(),
};
}
return {
issues: parsed.error.issues.map((issue) => issue.path.toString()),
values: {} as z.infer<typeof oidcParamsSchema>,
isOidc: false,
compiled: "",
};
};
+5 -59
View File
@@ -7,29 +7,14 @@ type IuseRedirectUri = {
}; };
export const useRedirectUri = ( export const useRedirectUri = (
redirect_uri: string | undefined, redirect_uri: string | null,
cookieDomain: string, cookieDomain: string,
appUrl: string,
subdomainsEnabled: boolean,
): IuseRedirectUri => { ): IuseRedirectUri => {
let isValid = false; let isValid = false;
let isTrusted = false; let isTrusted = false;
let isAllowedProto = false; let isAllowedProto = false;
let isHttpsDowngrade = false; let isHttpsDowngrade = false;
let appUrlObj: URL;
try {
appUrlObj = new URL(appUrl);
} catch {
return {
valid: isValid,
trusted: isTrusted,
allowedProto: isAllowedProto,
httpsDowngrade: isHttpsDowngrade,
};
}
if (!redirect_uri) { if (!redirect_uri) {
return { return {
valid: isValid, valid: isValid,
@@ -54,7 +39,10 @@ export const useRedirectUri = (
isValid = true; isValid = true;
if (isTrustedDomain(url, appUrlObj, cookieDomain, subdomainsEnabled)) { if (
url.hostname == cookieDomain ||
url.hostname.endsWith(`.${cookieDomain}`)
) {
isTrusted = true; isTrusted = true;
} }
@@ -74,45 +62,3 @@ export const useRedirectUri = (
httpsDowngrade: isHttpsDowngrade, httpsDowngrade: isHttpsDowngrade,
}; };
}; };
// ported from internal/controller/oauth_controller.go
const getEffectivePort = (url: URL): string => {
if (url.port) {
return url.port;
}
if (url.protocol == "https:") {
return "443";
}
return "80";
};
export const isTrustedDomain = (
url: URL,
appUrl: URL,
cookieDomain: string,
subdomainsEnabled: boolean,
): boolean => {
if (url.protocol != appUrl.protocol) {
return false;
}
if (getEffectivePort(url) != getEffectivePort(appUrl)) {
return false;
}
if (url.hostname == appUrl.hostname) {
return true;
}
if (!subdomainsEnabled) {
return false;
}
if (url.hostname.endsWith("." + cookieDomain.toLowerCase())) {
return true;
}
return false;
};
-42
View File
@@ -1,42 +0,0 @@
import { z } from "zod";
type ScreenParams = {
login_for?: "oidc" | "app";
redirect_uri?: string;
oidc_ticket?: string;
oidc_scope?: string;
oidc_name?: string;
oidc_prompt?: "none" | "login";
};
const zodScreenParams = z.object({
login_for: z.enum(["oidc", "app"]).optional(),
redirect_uri: z.string().optional(),
oidc_ticket: z.string().optional(),
oidc_scope: z.string().optional(),
oidc_name: z.string().optional(),
oidc_prompt: z.enum(["none", "login"]).optional(),
});
export function useScreenParams(params: URLSearchParams): ScreenParams {
const paramsObj = Object.fromEntries(params.entries());
const parsed = zodScreenParams.safeParse(paramsObj);
if (!parsed.success) {
return {};
}
return parsed.data;
}
export function recompileScreenParams(params: ScreenParams): string {
const p = new URLSearchParams(
Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== undefined),
) as Record<string, string>,
).toString();
if (p.length > 0) {
return "?" + p;
}
return "";
}
+82 -104
View File
@@ -1,106 +1,84 @@
{ {
"loginTitle": "Welcome back, login with", "loginTitle": "Welcome back, login with",
"loginTitleSimple": "Welcome back, please login", "loginTitleSimple": "Welcome back, please login",
"loginDivider": "Or", "loginDivider": "Or",
"loginUsername": "Username", "loginUsername": "Username",
"loginPassword": "Password", "loginPassword": "Password",
"loginSubmit": "Login", "loginSubmit": "Login",
"loginFailTitle": "Failed to log in", "loginFailTitle": "Failed to log in",
"loginFailSubtitle": "Please check your username and password", "loginFailSubtitle": "Please check your username and password",
"loginFailRateLimit": "You failed to login too many times. Please try again later", "loginFailRateLimit": "You failed to login too many times. Please try again later",
"loginSuccessTitle": "Logged in", "loginSuccessTitle": "Logged in",
"loginSuccessSubtitle": "Welcome back!", "loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "An error occurred", "loginOauthFailTitle": "An error occurred",
"loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthFailSubtitle": "Failed to get OAuth URL",
"loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessTitle": "Redirecting",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect", "loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now", "loginOauthAutoRedirectButton": "Redirect now",
"continueTitle": "Continue", "continueTitle": "Continue",
"continueRedirectingTitle": "Redirecting...", "continueRedirectingTitle": "Redirecting...",
"continueRedirectingSubtitle": "You should be redirected to the app soon", "continueRedirectingSubtitle": "You should be redirected to the app soon",
"continueRedirectManually": "Redirect me manually", "continueRedirectManually": "Redirect me manually",
"continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectTitle": "Insecure redirect",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?", "continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
"continueUntrustedRedirectTitle": "Untrusted redirect", "continueUntrustedRedirectTitle": "Untrusted redirect",
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?", "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": "Failed to log out", "logoutFailTitle": "Failed to log out",
"logoutFailSubtitle": "Please try again", "logoutFailSubtitle": "Please try again",
"logoutSuccessTitle": "Logged out", "logoutSuccessTitle": "Logged out",
"logoutSuccessSubtitle": "You have been logged out", "logoutSuccessSubtitle": "You have been logged out",
"logoutTitle": "Logout", "logoutTitle": "Logout",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.", "logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.", "logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
"notFoundTitle": "Page not found", "notFoundTitle": "Page not found",
"notFoundSubtitle": "The page you are looking for does not exist.", "notFoundSubtitle": "The page you are looking for does not exist.",
"notFoundButton": "Go home", "notFoundButton": "Go home",
"totpFailTitle": "Failed to verify code", "totpFailTitle": "Failed to verify code",
"totpFailSubtitle": "Please check your code and try again", "totpFailSubtitle": "Please check your code and try again",
"totpSuccessTitle": "Verified", "totpSuccessTitle": "Verified",
"totpSuccessSubtitle": "Redirecting to your app", "totpSuccessSubtitle": "Redirecting to your app",
"totpTitle": "Enter your TOTP code", "totpTitle": "Enter your TOTP code",
"totpSubtitle": "Please enter the code from your authenticator app.", "totpSubtitle": "Please enter the code from your authenticator app.",
"unauthorizedTitle": "Unauthorized", "unauthorizedTitle": "Unauthorized",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"cancelTitle": "Cancel", "cancelTitle": "Cancel",
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitleInfo": "The following error occurred while processing your request:", "errorSubtitleInfo": "The following error occurred while processing your request:",
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.", "errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required", "fieldRequired": "This field is required",
"invalidInput": "Invalid input", "invalidInput": "Invalid input",
"domainWarningTitle": "Invalid Domain", "domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.", "domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
"domainWarningCurrent": "Current:", "domainWarningCurrent": "Current:",
"domainWarningExpected": "Expected:", "domainWarningExpected": "Expected:",
"ignoreTitle": "Ignore", "ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Go to correct domain", "goToCorrectDomainTitle": "Go to correct domain",
"authorizeTitle": "Authorize", "authorizeTitle": "Authorize",
"authorizeCardTitle": "Continue to {{app}}?", "authorizeCardTitle": "Continue to {{app}}?",
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.", "authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
"authorizeSubtitleOAuth": "Would you like to continue to this app?", "authorizeSubtitleOAuth": "Would you like to continue to this app?",
"authorizeLoadingTitle": "Loading...", "authorizeLoadingTitle": "Loading...",
"authorizeLoadingSubtitle": "Please wait while we load the client information.", "authorizeLoadingSubtitle": "Please wait while we load the client information.",
"authorizeSuccessTitle": "Authorized", "authorizeSuccessTitle": "Authorized",
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.", "authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.", "authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
"authorizeErrorInvalidParams": "The request is missing required parameters or has invalid parameters. Please check the URL and try again.", "authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
"openidScopeName": "OpenID Connect", "openidScopeName": "OpenID Connect",
"openidScopeDescription": "Allows the app to access your OpenID Connect information.", "openidScopeDescription": "Allows the app to access your OpenID Connect information.",
"emailScopeName": "Email", "emailScopeName": "Email",
"emailScopeDescription": "Allows the app to access your email address.", "emailScopeDescription": "Allows the app to access your email address.",
"profileScopeName": "Profile", "profileScopeName": "Profile",
"profileScopeDescription": "Allows the app to access your profile information.", "profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups", "groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access your group information.", "groupsScopeDescription": "Allows the app to access your group information.",
"backToLoginButton": "Back to login", "backToLoginButton": "Back to login"
"phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleSubmit": "Continue with Tailscale",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"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.",
"quickActionsLanguage": "Language",
"quickActionsTheme": "Theme",
"quickActionsThemeLight": "Light",
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsTitle": "Quick Actions",
"quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth"
} }
+86 -104
View File
@@ -1,106 +1,88 @@
{ {
"loginTitle": "Welcome back, login with", "loginTitle": "Welcome back, login with",
"loginTitleSimple": "Welcome back, please login", "loginTitleSimple": "Welcome back, please login",
"loginDivider": "Or", "loginDivider": "Or",
"loginUsername": "Username", "loginUsername": "Username",
"loginPassword": "Password", "loginPassword": "Password",
"loginSubmit": "Login", "loginSubmit": "Login",
"loginFailTitle": "Failed to log in", "loginFailTitle": "Failed to log in",
"loginFailSubtitle": "Please check your username and password", "loginFailSubtitle": "Please check your username and password",
"loginFailRateLimit": "You failed to login too many times. Please try again later", "loginFailRateLimit": "You failed to login too many times. Please try again later",
"loginSuccessTitle": "Logged in", "loginSuccessTitle": "Logged in",
"loginSuccessSubtitle": "Welcome back!", "loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "An error occurred", "loginOauthFailTitle": "An error occurred",
"loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthFailSubtitle": "Failed to get OAuth URL",
"loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessTitle": "Redirecting",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect", "loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now", "loginOauthAutoRedirectButton": "Redirect now",
"continueTitle": "Continue", "continueTitle": "Continue",
"continueRedirectingTitle": "Redirecting...", "continueRedirectingTitle": "Redirecting...",
"continueRedirectingSubtitle": "You should be redirected to the app soon", "continueRedirectingSubtitle": "You should be redirected to the app soon",
"continueRedirectManually": "Redirect me manually", "continueRedirectManually": "Redirect me manually",
"continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectTitle": "Insecure redirect",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?", "continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
"continueUntrustedRedirectTitle": "Untrusted redirect", "continueUntrustedRedirectTitle": "Untrusted redirect",
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?", "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": "Failed to log out", "logoutFailTitle": "Failed to log out",
"logoutFailSubtitle": "Please try again", "logoutFailSubtitle": "Please try again",
"logoutSuccessTitle": "Logged out", "logoutSuccessTitle": "Logged out",
"logoutSuccessSubtitle": "You have been logged out", "logoutSuccessSubtitle": "You have been logged out",
"logoutTitle": "Logout", "logoutTitle": "Logout",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.", "logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.", "logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
"notFoundTitle": "Page not found", "notFoundTitle": "Page not found",
"notFoundSubtitle": "The page you are looking for does not exist.", "notFoundSubtitle": "The page you are looking for does not exist.",
"notFoundButton": "Go home", "notFoundButton": "Go home",
"totpFailTitle": "Failed to verify code", "totpFailTitle": "Failed to verify code",
"totpFailSubtitle": "Please check your code and try again", "totpFailSubtitle": "Please check your code and try again",
"totpSuccessTitle": "Verified", "totpSuccessTitle": "Verified",
"totpSuccessSubtitle": "Redirecting to your app", "totpSuccessSubtitle": "Redirecting to your app",
"totpTitle": "Enter your TOTP code", "totpTitle": "Enter your TOTP code",
"totpSubtitle": "Please enter the code from your authenticator app.", "totpSubtitle": "Please enter the code from your authenticator app.",
"unauthorizedTitle": "Unauthorized", "unauthorizedTitle": "Unauthorized",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.", "unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.", "unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.", "unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again", "unauthorizedButton": "Try again",
"cancelTitle": "Cancel", "cancelTitle": "Cancel",
"forgotPasswordTitle": "Forgot your password?", "forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred", "errorTitle": "An error occurred",
"errorSubtitleInfo": "The following error occurred while processing your request:", "errorSubtitleInfo": "The following error occurred while processing your request:",
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.", "errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required", "fieldRequired": "This field is required",
"invalidInput": "Invalid input", "invalidInput": "Invalid input",
"domainWarningTitle": "Invalid Domain", "domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.", "domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
"domainWarningCurrent": "Current:", "domainWarningCurrent": "Current:",
"domainWarningExpected": "Expected:", "domainWarningExpected": "Expected:",
"ignoreTitle": "Ignore", "ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Go to correct domain", "goToCorrectDomainTitle": "Go to correct domain",
"authorizeTitle": "Authorize", "authorizeTitle": "Authorize",
"authorizeCardTitle": "Continue to {{app}}?", "authorizeCardTitle": "Continue to {{app}}?",
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.", "authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
"authorizeSubtitleOAuth": "Would you like to continue to this app?", "authorizeSubtitleOAuth": "Would you like to continue to this app?",
"authorizeLoadingTitle": "Loading...", "authorizeLoadingTitle": "Loading...",
"authorizeLoadingSubtitle": "Please wait while we load the client information.", "authorizeLoadingSubtitle": "Please wait while we load the client information.",
"authorizeSuccessTitle": "Authorized", "authorizeSuccessTitle": "Authorized",
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.", "authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.", "authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
"authorizeErrorInvalidParams": "The request is missing required parameters or has invalid parameters. Please check the URL and try again.", "authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
"openidScopeName": "OpenID Connect", "openidScopeName": "OpenID Connect",
"openidScopeDescription": "Allows the app to access your OpenID Connect information.", "openidScopeDescription": "Allows the app to access your OpenID Connect information.",
"emailScopeName": "Email", "emailScopeName": "Email",
"emailScopeDescription": "Allows the app to access your email address.", "emailScopeDescription": "Allows the app to access your email address.",
"profileScopeName": "Profile", "profileScopeName": "Profile",
"profileScopeDescription": "Allows the app to access your profile information.", "profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups", "groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access your group information.", "groupsScopeDescription": "Allows the app to access your group information.",
"backToLoginButton": "Back to login", "backToLoginButton": "Back to login",
"phoneScopeName": "Phone", "phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.", "phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address", "addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address.", "addressScopeDescription": "Allows the app to access your address."
"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?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleSubmit": "Continue with Tailscale",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"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.",
"quickActionsLanguage": "Language",
"quickActionsTheme": "Theme",
"quickActionsThemeLight": "Light",
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsTitle": "Quick Actions",
"quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth"
} }
+2 -2
View File
@@ -58,8 +58,8 @@
"invalidInput": "Input non valido", "invalidInput": "Input non valido",
"domainWarningTitle": "Dominio non valido", "domainWarningTitle": "Dominio non valido",
"domainWarningSubtitle": "Stai accedendo a questa istanza da un dominio errato. Scegliendo di procedere, potresti incontrare problemi con l'autenticazione.", "domainWarningSubtitle": "Stai accedendo a questa istanza da un dominio errato. Scegliendo di procedere, potresti incontrare problemi con l'autenticazione.",
"domainWarningCurrent": "Attuale:", "domainWarningCurrent": "Current:",
"domainWarningExpected": "Previsto:", "domainWarningExpected": "Expected:",
"ignoreTitle": "Ignora", "ignoreTitle": "Ignora",
"goToCorrectDomainTitle": "Vai al dominio corretto", "goToCorrectDomainTitle": "Vai al dominio corretto",
"authorizeTitle": "Autorizza", "authorizeTitle": "Autorizza",
+1 -1
View File
@@ -57,7 +57,7 @@
"fieldRequired": "Ово поље је неопходно", "fieldRequired": "Ово поље је неопходно",
"invalidInput": "Неисправан унос", "invalidInput": "Неисправан унос",
"domainWarningTitle": "Неисправан домен", "domainWarningTitle": "Неисправан домен",
"domainWarningSubtitle": "Приступате овој инстанци са неисправног домена. Ако наставите, можете наићи на проблеме са аутентификацијом.", "domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
"domainWarningCurrent": "Тренутни:", "domainWarningCurrent": "Тренутни:",
"domainWarningExpected": "Очекивани:", "domainWarningExpected": "Очекивани:",
"ignoreTitle": "Игнориши", "ignoreTitle": "Игнориши",
+1 -4
View File
@@ -35,10 +35,7 @@ createRoot(document.getElementById("root")!).render(
<Route element={<Layout />} errorElement={<ErrorPage />}> <Route element={<Layout />} errorElement={<ErrorPage />}>
<Route path="/" element={<App />} /> <Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route path="/authorize" element={<AuthorizePage />} />
path="/oidc/authorize"
element={<AuthorizePage />}
/>
<Route path="/logout" element={<LogoutPage />} /> <Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} /> <Route path="/continue" element={<ContinuePage />} />
<Route path="/totp" element={<TotpPage />} /> <Route path="/totp" element={<TotpPage />} />
+55 -39
View File
@@ -1,5 +1,5 @@
import { useUserContext } from "@/context/user-context"; import { useUserContext } from "@/context/user-context";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Navigate, useNavigate } from "react-router"; import { Navigate, useNavigate } from "react-router";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { import {
@@ -10,9 +10,11 @@ import {
CardFooter, CardFooter,
CardContent, CardContent,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { getOidcClientInfoSchema } from "@/schemas/oidc-schemas";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react"; import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react";
@@ -21,11 +23,6 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useEffect } from "react";
type Scope = { type Scope = {
id: string; id: string;
@@ -80,32 +77,34 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
}; };
export const AuthorizePage = () => { export const AuthorizePage = () => {
const { auth } = useUserContext(); const { isLoggedIn } = useUserContext();
const { search } = useLocation(); const { search } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const scopeMap = createScopeMap(t); const scopeMap = createScopeMap(t);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const oidcParams = useOIDCParams(searchParams);
const isOidc = screenParams.login_for === "oidc";
const compiledParams = recompileScreenParams(screenParams);
// TODO: maybe a better way to do this const getClientInfo = useQuery({
const shouldAutoAuthorize = queryKey: ["client", oidcParams.values.client_id],
auth.authenticated && queryFn: async () => {
isOidc && const res = await fetch(
screenParams.oidc_ticket !== undefined && `/api/oidc/clients/${encodeURIComponent(oidcParams.values.client_id)}`,
screenParams.oidc_scope !== undefined && );
screenParams.oidc_prompt === "none"; const data = await getOidcClientInfoSchema.parseAsync(await res.json());
return data;
},
enabled: oidcParams.isOidc,
});
const { mutate: authorizeMutate, isPending: authorizePending } = useMutation({ const authorizeMutation = useMutation({
mutationFn: () => { mutationFn: () => {
return axios.post("/api/oidc/authorize-complete", { return axios.post("/api/oidc/authorize", {
ticket: screenParams.oidc_ticket, ...oidcParams.values,
}); });
}, },
mutationKey: ["authorize", screenParams.oidc_ticket], mutationKey: ["authorize", oidcParams.values.client_id],
onSuccess: (data) => { onSuccess: (data) => {
toast.info(t("authorizeSuccessTitle"), { toast.info(t("authorizeSuccessTitle"), {
description: t("authorizeSuccessSubtitle"), description: t("authorizeSuccessSubtitle"),
@@ -119,38 +118,56 @@ export const AuthorizePage = () => {
}, },
}); });
useEffect(() => { if (oidcParams.issues.length > 0) {
if (shouldAutoAuthorize) {
authorizeMutate();
}
}, [shouldAutoAuthorize, authorizeMutate]);
if (!isOidc || !screenParams.oidc_ticket || !screenParams.oidc_scope) {
return ( return (
<Navigate <Navigate
to={`/error?error=${encodeURIComponent(t("authorizeErrorInvalidParams"))}`} to={`/error?error=${encodeURIComponent(t("authorizeErrorMissingParams", { missingParams: oidcParams.issues.join(", ") }))}`}
replace replace
/> />
); );
} }
if (!auth.authenticated || screenParams.oidc_prompt === "login") { if (!isLoggedIn) {
return <Navigate to={`/login${compiledParams}`} replace />; return <Navigate to={`/login?${oidcParams.compiled}`} replace />;
}
if (getClientInfo.isLoading) {
return (
<Card className="gap-0">
<CardHeader>
<CardTitle className="text-xl">
{t("authorizeLoadingTitle")}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>{t("authorizeLoadingSubtitle")}</CardDescription>
</CardContent>
</Card>
);
}
if (getClientInfo.isError) {
return (
<Navigate
to={`/error?error=${encodeURIComponent(t("authorizeErrorClientInfo"))}`}
replace
/>
);
} }
const scopes = const scopes =
screenParams.oidc_scope.split(" ").filter((s) => s.trim() !== "") || []; oidcParams.values.scope.split(" ").filter((s) => s.trim() !== "") || [];
return ( return (
<Card> <Card>
<CardHeader className="mb-2"> <CardHeader className="mb-2">
<div className="flex flex-col gap-3 items-center justify-center text-center"> <div className="flex flex-col gap-3 items-center justify-center text-center">
<div className="bg-accent-foreground box-content text-muted text-xl font-bold font-sans rounded-lg size-8 p-2 flex items-center justify-center"> <div className="bg-accent-foreground box-content text-muted text-xl font-bold font-sans rounded-lg size-8 p-2 flex items-center justify-center">
{screenParams.oidc_name ? screenParams.oidc_name.slice(0, 1) : "U"} {getClientInfo.data?.name.slice(0, 1) || "U"}
</div> </div>
<CardTitle className="text-xl"> <CardTitle className="text-xl">
{t("authorizeCardTitle", { {t("authorizeCardTitle", {
app: screenParams.oidc_name || "Unknown", app: getClientInfo.data?.name || "Unknown",
})} })}
</CardTitle> </CardTitle>
<CardDescription className="text-sm max-w-sm"> <CardDescription className="text-sm max-w-sm">
@@ -183,15 +200,14 @@ export const AuthorizePage = () => {
)} )}
<CardFooter className="flex flex-col items-stretch gap-3"> <CardFooter className="flex flex-col items-stretch gap-3">
<Button <Button
onClick={() => authorizeMutate()} onClick={() => authorizeMutation.mutate()}
loading={authorizePending} loading={authorizeMutation.isPending}
disabled={shouldAutoAuthorize}
> >
{t("authorizeTitle")} {t("authorizeTitle")}
</Button> </Button>
<Button <Button
onClick={() => navigate(`/logout${compiledParams}`)} onClick={() => navigate("/")}
disabled={authorizePending || shouldAutoAuthorize} disabled={authorizeMutation.isPending}
variant="outline" variant="outline"
> >
{t("cancelTitle")} {t("cancelTitle")}
+17 -27
View File
@@ -12,14 +12,10 @@ import { Trans, useTranslation } from "react-i18next";
import { Navigate, useLocation, useNavigate } from "react-router"; 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 {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
export const ContinuePage = () => { export const ContinuePage = () => {
const { app, ui } = useAppContext(); const { cookieDomain, warningsEnabled } = useAppContext();
const { auth } = useUserContext(); const { isLoggedIn } = useUserContext();
const { search } = useLocation(); const { search } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -29,31 +25,24 @@ export const ContinuePage = () => {
const hasRedirected = useRef(false); const hasRedirected = useRef(false);
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const redirectUri = searchParams.get("redirect_uri");
const redirectUri = screenParams.redirect_uri;
const isAppLogin = screenParams.login_for === "app";
const recompiledParams = recompileScreenParams(screenParams);
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri( const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
redirectUri, redirectUri,
app.cookieDomain, cookieDomain,
app.appUrl,
app.subdomainsEnabled,
); );
const urlHref = url?.href; const urlHref = url?.href;
const hasValidRedirect = valid && allowedProto; const hasValidRedirect = valid && allowedProto;
const showUntrustedWarning = const showUntrustedWarning = hasValidRedirect && !trusted && warningsEnabled;
hasValidRedirect && !trusted && ui.warningsEnabled;
const showInsecureWarning = const showInsecureWarning =
hasValidRedirect && httpsDowngrade && ui.warningsEnabled; hasValidRedirect && httpsDowngrade && warningsEnabled;
const shouldAutoRedirect = const shouldAutoRedirect =
auth.authenticated && isLoggedIn &&
hasValidRedirect && hasValidRedirect &&
!showUntrustedWarning && !showUntrustedWarning &&
!showInsecureWarning && !showInsecureWarning;
isAppLogin;
const redirectToTarget = useCallback(() => { const redirectToTarget = useCallback(() => {
if (!urlHref || hasRedirected.current) { if (!urlHref || hasRedirected.current) {
@@ -88,11 +77,16 @@ export const ContinuePage = () => {
}; };
}, [shouldAutoRedirect, redirectToTarget]); }, [shouldAutoRedirect, redirectToTarget]);
if (!auth.authenticated) { if (!isLoggedIn) {
return <Navigate to={`/login${recompiledParams}`} replace />; return (
<Navigate
to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
replace
/>
);
} }
if (!hasValidRedirect || !isAppLogin) { if (!hasValidRedirect) {
return <Navigate to="/logout" replace />; return <Navigate to="/logout" replace />;
} }
@@ -110,11 +104,7 @@ export const ContinuePage = () => {
components={{ components={{
code: <code />, code: <code />,
}} }}
values={{ values={{ cookieDomain }}
cookieDomain: app.subdomainsEnabled
? `.${app.cookieDomain}`
: app.cookieDomain,
}}
shouldUnescape={true} shouldUnescape={true}
/> />
</CardDescription> </CardDescription>
+1 -1
View File
@@ -11,7 +11,7 @@ export const ErrorPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation(); const { search } = useLocation();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const error = searchParams.get("error") || ""; const error = searchParams.get("error") ?? "";
return ( return (
<Card> <Card>
+7 -10
View File
@@ -11,18 +11,12 @@ import { useAppContext } from "@/context/app-context";
import { useTranslation } from "react-i18next"; 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 {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
export const ForgotPasswordPage = () => { export const ForgotPasswordPage = () => {
const { ui } = useAppContext(); const { forgotPasswordMessage } = useAppContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation(); const { search } = useLocation();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams);
return ( return (
<Card> <Card>
@@ -32,8 +26,8 @@ export const ForgotPasswordPage = () => {
<CardContent> <CardContent>
<CardDescription> <CardDescription>
<Markdown> <Markdown>
{ui.forgotPasswordMessage !== "" {forgotPasswordMessage !== ""
? ui.forgotPasswordMessage ? forgotPasswordMessage
: t("forgotPasswordMessage")} : t("forgotPasswordMessage")}
</Markdown> </Markdown>
</CardDescription> </CardDescription>
@@ -43,7 +37,10 @@ export const ForgotPasswordPage = () => {
className="w-full" className="w-full"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
window.location.replace(`/login${compiledParams}`); const eparams = searchParams.toString();
window.location.replace(
`/login${eparams.length > 0 ? `?${eparams}` : ""}`,
);
}} }}
> >
{t("backToLoginButton")} {t("backToLoginButton")}
+53 -99
View File
@@ -18,6 +18,7 @@ import { OAuthButton } from "@/components/ui/oauth-button";
import { SeperatorWithChildren } from "@/components/ui/separator"; import { SeperatorWithChildren } from "@/components/ui/separator";
import { useAppContext } from "@/context/app-context"; import { useAppContext } from "@/context/app-context";
import { useUserContext } from "@/context/user-context"; import { useUserContext } from "@/context/user-context";
import { useOIDCParams } from "@/lib/hooks/oidc";
import { LoginSchema } from "@/schemas/login-schema"; import { LoginSchema } from "@/schemas/login-schema";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
@@ -25,11 +26,6 @@ import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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 {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for";
const iconMap: Record<string, React.ReactNode> = { const iconMap: Record<string, React.ReactNode> = {
google: <GoogleIcon />, google: <GoogleIcon />,
@@ -40,19 +36,12 @@ const iconMap: Record<string, React.ReactNode> = {
}; };
export const LoginPage = () => { export const LoginPage = () => {
const { auth, tailscale } = useUserContext(); const { isLoggedIn } = useUserContext();
const { const { providers, title, oauthAutoRedirect } = useAppContext();
ui,
oauth,
auth: { providers },
} = useAppContext();
const { search } = useLocation(); const { search } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const [showRedirectButton, setShowRedirectButton] = useState(false); const [showRedirectButton, setShowRedirectButton] = useState(false);
const [useTailscale, setUseTailscale] = useState(
tailscale.nodeName !== undefined,
);
const hasAutoRedirectedRef = useRef(false); const hasAutoRedirectedRef = useRef(false);
@@ -62,25 +51,17 @@ export const LoginPage = () => {
const formId = useId(); const formId = useId();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams); const redirectUri = searchParams.get("redirect_uri") || undefined;
const compiledParams = recompileScreenParams({ const oidcParams = useOIDCParams(searchParams);
...screenParams,
oidc_prompt: undefined,
});
const loginForUrl = useLoginFor({
login_for: screenParams.login_for,
compiledParams,
});
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState( const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
providers.find((provider) => provider.id === oauth.autoRedirect) !== providers.find((provider) => provider.id === oauthAutoRedirect) !==
undefined && screenParams.redirect_uri !== undefined, undefined && redirectUri !== undefined,
); );
const oauthProviders = providers.filter( const oauthProviders = providers.filter(
(provider) => provider.id !== "local" && provider.id !== "ldap", (provider) => provider.id !== "local" && provider.id !== "ldap",
); );
const userAuthConfigured = const userAuthConfigured =
providers.find( providers.find(
(provider) => provider.id === "local" || provider.id === "ldap", (provider) => provider.id === "local" || provider.id === "ldap",
@@ -93,7 +74,16 @@ export const LoginPage = () => {
variables: oauthVariables, variables: oauthVariables,
} = useMutation({ } = useMutation({
mutationFn: (provider: string) => { mutationFn: (provider: string) => {
return axios.get(`/api/oauth/url/${provider}${compiledParams}`); const getParams = function (): string {
if (oidcParams.isOidc) {
return `?${oidcParams.compiled}`;
}
if (redirectUri) {
return `?redirect_uri=${encodeURIComponent(redirectUri)}`;
}
return "";
};
return axios.get(`/api/oauth/url/${provider}${getParams()}`);
}, },
mutationKey: ["oauth"], mutationKey: ["oauth"],
onSuccess: (data) => { onSuccess: (data) => {
@@ -124,7 +114,13 @@ export const LoginPage = () => {
mutationKey: ["login"], mutationKey: ["login"],
onSuccess: (data) => { onSuccess: (data) => {
if (data.data.totpPending) { if (data.data.totpPending) {
window.location.replace(`/totp${compiledParams}`); if (oidcParams.isOidc) {
window.location.replace(`/totp?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/totp${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
return; return;
} }
@@ -133,7 +129,13 @@ export const LoginPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.replace(loginForUrl); if (oidcParams.isOidc) {
window.location.replace(`/authorize?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
}, 500); }, 500);
}, },
onError: (error: AxiosError) => { onError: (error: AxiosError) => {
@@ -146,45 +148,23 @@ export const LoginPage = () => {
}, },
}); });
const { mutate: tailscaleMutate, isPending: tailscaleIsPending } =
useMutation({
mutationFn: () => axios.post("/api/user/tailscale"),
mutationKey: ["tailscale"],
onSuccess: () => {
toast.success(t("loginSuccessTitle"), {
description: t("loginTailscaleSuccess"),
});
redirectTimer.current = window.setTimeout(() => {
window.location.replace(loginForUrl);
}, 500);
},
onError: () => {
toast.error(t("loginFailTitle"), {
description: t("loginTailscaleFail"),
});
},
});
useEffect(() => { useEffect(() => {
if ( if (
!auth.authenticated && !isLoggedIn &&
isOauthAutoRedirect && isOauthAutoRedirect &&
!hasAutoRedirectedRef.current && !hasAutoRedirectedRef.current &&
screenParams.redirect_uri && redirectUri !== undefined
screenParams.login_for
) { ) {
hasAutoRedirectedRef.current = true; hasAutoRedirectedRef.current = true;
oauthMutate(oauth.autoRedirect); oauthMutate(oauthAutoRedirect);
} }
}, [ }, [
auth.authenticated, isLoggedIn,
oauthMutate, oauthMutate,
hasAutoRedirectedRef, hasAutoRedirectedRef,
oauth.autoRedirect, oauthAutoRedirect,
isOauthAutoRedirect, isOauthAutoRedirect,
screenParams.login_for, redirectUri,
screenParams.redirect_uri,
]); ]);
useEffect(() => { useEffect(() => {
@@ -199,8 +179,21 @@ export const LoginPage = () => {
}; };
}, [redirectTimer, redirectButtonTimer]); }, [redirectTimer, redirectButtonTimer]);
if (auth.authenticated && screenParams.oidc_prompt !== "login") { if (isLoggedIn && oidcParams.isOidc) {
return <Navigate to={loginForUrl} replace />; return <Navigate to={`/authorize?${oidcParams.compiled}`} replace />;
}
if (isLoggedIn && redirectUri !== undefined) {
return (
<Navigate
to={`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
replace
/>
);
}
if (isLoggedIn) {
return <Navigate to="/logout" replace />;
} }
if (isOauthAutoRedirect) { if (isOauthAutoRedirect) {
@@ -235,49 +228,10 @@ export const LoginPage = () => {
</Card> </Card>
); );
} }
if (useTailscale) {
return (
<Card>
<CardHeader className="gap-3">
<TailscaleIcon className="mx-auto h-8 w-8" />
<CardTitle className="text-center text-xl">
{t("loginTailscaleTitle")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="text-muted-foreground text-sm">
{t("loginTailscaleDescription")}
</div>
<div className="text-muted-foreground text-sm">
{t("loginTailscaleDeviceName")} <code>{tailscale.nodeName}</code>
</div>
</CardContent>
<CardFooter className="flex flex-col items-stretch gap-3">
<Button
className="w-full"
onClick={() => tailscaleMutate()}
loading={tailscaleIsPending}
>
{t("loginTailscaleSubmit")}
</Button>
<Button
className="w-full"
variant="outline"
onClick={() => setUseTailscale(false)}
disabled={tailscaleIsPending}
>
{t("loginTailscaleOtherMethod")}
</Button>
</CardFooter>
</Card>
);
}
return ( return (
<Card> <Card>
<CardHeader className="gap-1.5"> <CardHeader className="gap-1.5">
<CardTitle className="text-center text-xl">{ui.title}</CardTitle> <CardTitle className="text-center text-xl">{title}</CardTitle>
{providers.length > 0 && ( {providers.length > 0 && (
<CardDescription className="text-center"> <CardDescription className="text-center">
{oauthProviders.length !== 0 {oauthProviders.length !== 0
+34 -85
View File
@@ -13,23 +13,12 @@ import { useEffect, useRef } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Navigate } from "react-router"; import { Navigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { type UseMutationResult } from "@tanstack/react-query";
import { type AxiosResponse } from "axios";
import { useLocation } from "react-router";
import {
useScreenParams,
recompileScreenParams,
} from "@/lib/hooks/screen-params";
export const LogoutPage = () => { export const LogoutPage = () => {
const { auth, oauth, tailscale } = useUserContext(); const { provider, username, isLoggedIn, email, oauthName } = useUserContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation();
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams);
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/user/logout"), mutationFn: () => axios.post("/api/user/logout"),
@@ -40,7 +29,7 @@ export const LogoutPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.replace(`/login${compiledParams}`); window.location.replace("/login");
}, 500); }, 500);
}, },
onError: () => { onError: () => {
@@ -58,86 +47,46 @@ export const LogoutPage = () => {
}; };
}, [redirectTimer]); }, [redirectTimer]);
if (!auth.authenticated) { if (!isLoggedIn) {
return <Navigate to={`/login${compiledParams}`} replace />; return <Navigate to="/login" replace />;
} }
if (oauth.active) {
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutOauthSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: auth.email,
provider: oauth.displayName,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
}
if (auth.providerId === "tailscale") {
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutTailscaleSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
deviceName: tailscale.nodeName,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
}
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutUsernameSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: auth.username,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
};
interface LogoutLayoutProps {
children: React.ReactNode;
logoutMutation: UseMutationResult<
//eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-empty-object-type
AxiosResponse<any, any, {}>,
Error,
void,
unknown
>;
}
function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
const { t } = useTranslation();
return ( return (
<Card> <Card>
<CardHeader className="gap-1.5"> <CardHeader className="gap-1.5">
<CardTitle className="text-xl">{t("logoutTitle")}</CardTitle> <CardTitle className="text-xl">{t("logoutTitle")}</CardTitle>
<CardDescription>{children}</CardDescription> <CardDescription>
{provider !== "local" && provider !== "ldap" ? (
<Trans
i18nKey="logoutOauthSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: email,
provider: oauthName,
}}
shouldUnescape={true}
/>
) : (
<Trans
i18nKey="logoutUsernameSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username,
}}
shouldUnescape={true}
/>
)}
</CardDescription>
</CardHeader> </CardHeader>
<CardFooter> <CardFooter>
<Button <Button
className="w-full text-destructive" className="w-full"
variant="outline" variant="outline"
loading={logoutMutation.isPending} loading={logoutMutation.isPending}
onClick={() => logoutMutation.mutate()} onClick={() => logoutMutation.mutate()}
@@ -147,4 +96,4 @@ function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
</CardFooter> </CardFooter>
</Card> </Card>
); );
} };
+14 -18
View File
@@ -16,14 +16,10 @@ import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next"; 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 { useOIDCParams } from "@/lib/hooks/oidc";
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useLoginFor } from "@/lib/hooks/login-for";
export const TotpPage = () => { export const TotpPage = () => {
const { totp, auth } = useUserContext(); const { totpPending } = useUserContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation(); const { search } = useLocation();
const formId = useId(); const formId = useId();
@@ -31,12 +27,8 @@ export const TotpPage = () => {
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 redirectUri = searchParams.get("redirect_uri") || undefined;
const compiledParams = recompileScreenParams(screenParams); const oidcParams = useOIDCParams(searchParams);
const loginForUrl = useLoginFor({
login_for: screenParams.login_for,
compiledParams,
});
const totpMutation = useMutation({ const totpMutation = useMutation({
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values), mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
@@ -47,7 +39,14 @@ export const TotpPage = () => {
}); });
redirectTimer.current = window.setTimeout(() => { redirectTimer.current = window.setTimeout(() => {
window.location.replace(loginForUrl); if (oidcParams.isOidc) {
window.location.replace(`/authorize?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
}, 500); }, 500);
}, },
onError: () => { onError: () => {
@@ -65,11 +64,8 @@ export const TotpPage = () => {
}; };
}, [redirectTimer]); }, [redirectTimer]);
if (!totp.pending) { if (!totpPending) {
if (auth.authenticated) { return <Navigate to="/" replace />;
return <Navigate to={loginForUrl} replace />;
}
return <Navigate to={`/login${compiledParams}`} replace />;
} }
return ( return (
+4 -21
View File
@@ -6,32 +6,15 @@ export const providerSchema = z.object({
oauth: z.boolean(), oauth: z.boolean(),
}); });
const authSchema = z.object({ export const appContextSchema = z.object({
providers: z.array(providerSchema), providers: z.array(providerSchema),
});
const oauthSchema = z.object({
autoRedirect: z.string(),
});
const uiSchema = z.object({
title: z.string(), title: z.string(),
appUrl: z.string(),
cookieDomain: z.string(),
forgotPasswordMessage: z.string(), forgotPasswordMessage: z.string(),
backgroundImage: z.string(), backgroundImage: z.string(),
oauthAutoRedirect: z.string(),
warningsEnabled: z.boolean(), warningsEnabled: z.boolean(),
}); });
const appSchema = z.object({
appUrl: z.string(),
cookieDomain: z.string(),
subdomainsEnabled: z.boolean(),
});
export const appContextSchema = z.object({
auth: authSchema,
oauth: oauthSchema,
ui: uiSchema,
app: appSchema,
});
export type AppContextSchema = z.infer<typeof appContextSchema>; export type AppContextSchema = z.infer<typeof appContextSchema>;
+5
View File
@@ -0,0 +1,5 @@
import { z } from "zod";
export const getOidcClientInfoSchema = z.object({
name: z.string(),
});
+6 -23
View File
@@ -1,31 +1,14 @@
import { z } from "zod"; import { z } from "zod";
const authSchema = z.object({ export const userContextSchema = z.object({
authenticated: z.boolean(), isLoggedIn: z.boolean(),
username: z.string(), username: z.string(),
name: z.string(), name: z.string(),
email: z.string(), email: z.string(),
providerId: z.string(), provider: z.string(),
}); oauth: z.boolean(),
totpPending: z.boolean(),
const oauthSchema = z.object({ oauthName: z.string(),
active: z.boolean(),
displayName: z.string(),
});
const totpSchema = z.object({
pending: z.boolean(),
});
const tailscaleSchema = z.object({
nodeName: z.string().optional(),
});
export const userContextSchema = z.object({
auth: authSchema,
oauth: oauthSchema,
totp: totpSchema,
tailscale: tailscaleSchema,
}); });
export type UserContextSchema = z.infer<typeof userContextSchema>; export type UserContextSchema = z.infer<typeof userContextSchema>;
-5
View File
@@ -57,11 +57,6 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/robots.txt/, ""), rewrite: (path) => path.replace(/^\/robots.txt/, ""),
}, },
"/authorize": {
target: "http://tinyauth-backend:3000/authorize",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/authorize/, ""),
},
}, },
allowedHosts: true, allowedHosts: true,
}, },
View File
+1 -1
View File
@@ -20,7 +20,7 @@ type EnvEntry struct {
} }
func generateExampleEnv() { func generateExampleEnv() {
cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown) cfg := model.NewDefaultConfiguration()
entries := make([]EnvEntry, 0) entries := make([]EnvEntry, 0)
root := reflect.TypeOf(cfg).Elem() root := reflect.TypeOf(cfg).Elem()
+1 -1
View File
@@ -21,7 +21,7 @@ type MarkdownEntry struct {
} }
func generateMarkdown() { func generateMarkdown() {
cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown) cfg := model.NewDefaultConfiguration()
entries := make([]MarkdownEntry, 0) entries := make([]MarkdownEntry, 0)
root := reflect.TypeOf(cfg).Elem() root := reflect.TypeOf(cfg).Elem()
@@ -1,4 +1,4 @@
// gen/sqlc_wrapper generates store.go wrapper files for each sqlc driver package under // gen/sqlc-wrapper generates store.go wrapper files for each sqlc driver package under
// internal/repository/<driver>/. Run via: // internal/repository/<driver>/. Run via:
// //
// go generate ./internal/repository/... // go generate ./internal/repository/...
@@ -32,7 +32,7 @@ import (
var storeSrc string var storeSrc string
func main() { func main() {
fmt.Println("sqlc_wrapper: generating store.go files for sqlc driver packages...") fmt.Println("sqlc-wrapper: generating store.go files for sqlc driver packages...")
if err := run(); err != nil { if err := run(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
-3
View File
@@ -1,3 +0,0 @@
package tinyauth
//go:generate go run github.com/tinyauthapp/tinyauth/gen/docs
+24 -35
View File
@@ -1,45 +1,40 @@
module github.com/tinyauthapp/tinyauth module github.com/tinyauthapp/tinyauth
go 1.26.4 go 1.26.0
require ( require (
charm.land/huh/v2 v2.0.3 charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.5
github.com/cenkalti/backoff/v5 v5.0.3 github.com/cenkalti/backoff/v5 v5.0.3
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.13
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
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/rs/zerolog v1.35.1 github.com/rs/zerolog v1.35.1
github.com/steveiliop56/ding v0.2.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298 github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
github.com/weppos/publicsuffix-go v0.50.3 github.com/weppos/publicsuffix-go v0.50.3
go.uber.org/dig v1.19.0 golang.org/x/crypto v0.51.0
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/tools v0.47.0 golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.36.1
k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.1
k8s.io/client-go v0.36.2 modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.53.0
) )
require ( require (
charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbles/v2 v2.0.0 // indirect
charm.land/bubbletea/v2 v2.0.2 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect
charm.land/lipgloss/v2 v2.0.1 // indirect
dario.cat/mergo v1.0.1 // indirect dario.cat/mergo v1.0.1 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
@@ -49,9 +44,9 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -65,7 +60,7 @@ require (
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -75,24 +70,19 @@ require (
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // 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-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/huandu/xstrings v1.5.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
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.3.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.20 // 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.20 // 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
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
@@ -106,25 +96,23 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
@@ -134,20 +122,21 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.37.0 // indirect golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.56.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/term v0.44.0 // indirect golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.38.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
k8s.io/klog/v2 v2.140.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.72.3 // 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
+58 -77
View File
@@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
@@ -18,8 +18,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -45,12 +45,12 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
@@ -87,14 +87,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -126,8 +124,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -142,8 +140,6 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
@@ -166,16 +162,6 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -202,16 +188,16 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.20/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=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
@@ -246,8 +232,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -259,8 +245,8 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -271,21 +257,18 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/steveiliop56/ding v0.2.0 h1:m/Fj99wBpVVLHlpqb2RDJkWubOc5cWJ11ZYCHya3Sk0=
github.com/steveiliop56/ding v0.2.0/go.mod h1:bE2u2XH7CjhPzbb/0Ems+D8YZlf2Ae+eKhj00UR1iAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -308,8 +291,8 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
@@ -326,8 +309,6 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
@@ -336,29 +317,29 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
@@ -380,32 +361,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.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0=
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU=
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.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -414,8 +395,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
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=
+1 -1
View File
@@ -11,5 +11,5 @@ var FrontendAssets embed.FS
// Migrations // Migrations
// //
//go:embed migrations/sqlite/*.sql migrations/postgres/*.sql //go:embed migrations/sqlite/*.sql
var Migrations embed.FS var Migrations embed.FS
@@ -1,4 +0,0 @@
DROP TABLE IF EXISTS "oidc_tokens";
DROP TABLE IF EXISTS "oidc_userinfo";
DROP TABLE IF EXISTS "oidc_codes";
DROP TABLE IF EXISTS "sessions";
@@ -1,60 +0,0 @@
CREATE TABLE "sessions" (
"uuid" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"totp_pending" BOOLEAN NOT NULL,
"oauth_groups" TEXT NOT NULL DEFAULT '',
"expiry" BIGINT NOT NULL,
"created_at" BIGINT NOT NULL,
"oauth_name" TEXT NOT NULL DEFAULT '',
"oauth_sub" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY,
"scope" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT '',
"code_challenge" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_tokens" (
"sub" TEXT NOT NULL UNIQUE,
"access_token_hash" TEXT NOT NULL PRIMARY KEY,
"refresh_token_hash" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" BIGINT NOT NULL,
"refresh_token_expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_userinfo" (
"sub" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"preferred_username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"groups" TEXT NOT NULL,
"updated_at" BIGINT NOT NULL,
"given_name" TEXT NOT NULL,
"family_name" TEXT NOT NULL,
"middle_name" TEXT NOT NULL,
"nickname" TEXT NOT NULL,
"profile" TEXT NOT NULL,
"picture" TEXT NOT NULL,
"website" TEXT NOT NULL,
"gender" TEXT NOT NULL,
"birthdate" TEXT NOT NULL,
"zoneinfo" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"phone_number" TEXT NOT NULL,
"address" TEXT NOT NULL
);
CREATE INDEX idx_sessions_expiry ON "sessions" ("expiry");
@@ -1,46 +0,0 @@
DROP TABLE IF EXISTS "oidc_sessions";
CREATE TABLE "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY,
"scope" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT '',
"code_challenge" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_tokens" (
"sub" TEXT NOT NULL UNIQUE,
"access_token_hash" TEXT NOT NULL PRIMARY KEY,
"refresh_token_hash" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" BIGINT NOT NULL,
"refresh_token_expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_userinfo" (
"sub" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"preferred_username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"groups" TEXT NOT NULL,
"updated_at" BIGINT NOT NULL,
"given_name" TEXT NOT NULL,
"family_name" TEXT NOT NULL,
"middle_name" TEXT NOT NULL,
"nickname" TEXT NOT NULL,
"profile" TEXT NOT NULL,
"picture" TEXT NOT NULL,
"website" TEXT NOT NULL,
"gender" TEXT NOT NULL,
"birthdate" TEXT NOT NULL,
"zoneinfo" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"phone_number" TEXT NOT NULL,
"address" TEXT NOT NULL
);
@@ -1,28 +0,0 @@
/*
This migration will nuke the entire setup of OIDC sessions and merge everything
into one table.
*/
/*
Drop all the old tables. Yes, we will log out all OIDC users, but not really a big deal
*/
DROP TABLE IF EXISTS "oidc_tokens";
DROP TABLE IF EXISTS "oidc_userinfo";
DROP TABLE IF EXISTS "oidc_codes";
/*
Create a new simple OIDC sessions table that will hold tokens + userinfo.
*/
CREATE TABLE IF NOT EXISTS "oidc_sessions" (
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
"access_token_hash" TEXT NOT NULL UNIQUE,
"refresh_token_hash" TEXT NOT NULL UNIQUE,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" BIGINT NOT NULL,
"refresh_token_expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT '',
"userinfo_json" TEXT NOT NULL
);
@@ -1,46 +0,0 @@
DROP TABLE IF EXISTS "oidc_sessions";
CREATE TABLE IF NOT EXISTS "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
"scope" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"expires_at" INTEGER NOT NULL,
"nonce" TEXT DEFAULT "",
"code_challenge" TEXT DEFAULT ""
);
CREATE TABLE IF NOT EXISTS "oidc_tokens" (
"sub" TEXT NOT NULL UNIQUE,
"access_token_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
"refresh_token_hash" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" INTEGER NOT NULL,
"refresh_token_expires_at" INTEGER NOT NULL,
"nonce" TEXT DEFAULT ""
);
CREATE TABLE IF NOT EXISTS "oidc_userinfo" (
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
"name" TEXT NOT NULL,
"preferred_username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"groups" TEXT NOT NULL,
"updated_at" INTEGER NOT NULL,
"given_name" TEXT NOT NULL,
"family_name" TEXT NOT NULL,
"middle_name" TEXT NOT NULL,
"nickname" TEXT NOT NULL,
"profile" TEXT NOT NULL,
"picture" TEXT NOT NULL,
"website" TEXT NOT NULL,
"gender" TEXT NOT NULL,
"birthdate" TEXT NOT NULL,
"zoneinfo" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"phone_number" TEXT NOT NULL,
"address" TEXT NOT NULL
);
@@ -1,28 +0,0 @@
/*
This migration will nuke the entire setup of OIDC sessions and merge everything
into one table.
*/
/*
Drop all the old tables. Yes, we will log out all OIDC users, but not really a big deal
*/
DROP TABLE IF EXISTS "oidc_tokens";
DROP TABLE IF EXISTS "oidc_userinfo";
DROP TABLE IF EXISTS "oidc_codes";
/*
Create a new simple OIDC sessions table that will hold tokens + userinfo.
*/
CREATE TABLE IF NOT EXISTS "oidc_sessions" (
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
"access_token_hash" TEXT NOT NULL UNIQUE,
"refresh_token_hash" TEXT NOT NULL UNIQUE,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" INTEGER NOT NULL,
"refresh_token_expires_at" INTEGER NOT NULL,
"nonce" TEXT DEFAULT "",
"userinfo_json" TEXT NOT NULL
);
+152 -120
View File
@@ -7,19 +7,18 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"slices"
"sort" "sort"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"go.uber.org/dig"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
@@ -28,12 +27,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
// Shutdown order for go routines
// 1. Janitor routines (e.g. database cleanup, heartbeat) - ding.RingMinor
// 2. HTTP server listeners - ding.RingNormal
// 3. Networking layers, user and label providers (e.g. ailscale service, kubernetes service) - ding.RingMajor
// 4. Database connection - ding.RingCritical
type Services struct { type Services struct {
accessControlService *service.AccessControlsService accessControlService *service.AccessControlsService
authService *service.AuthService authService *service.AuthService
@@ -42,8 +35,6 @@ type Services struct {
ldapService *service.LdapService ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService oidcService *service.OIDCService
tailscaleService *service.TailscaleService
policyEngine *service.PolicyEngine
} }
type BootstrapApp struct { type BootstrapApp struct {
@@ -56,8 +47,7 @@ type BootstrapApp struct {
queries repository.Store queries repository.Store
router *gin.Engine router *gin.Engine
db *sql.DB db *sql.DB
ding *ding.Ding wg sync.WaitGroup
dig *dig.Container
} }
func NewBootstrapApp(config model.Config) *BootstrapApp { func NewBootstrapApp(config model.Config) *BootstrapApp {
@@ -72,21 +62,11 @@ func (app *BootstrapApp) Setup() error {
app.ctx = ctx app.ctx = ctx
app.cancel = cancel app.cancel = cancel
// create the dig container
c := dig.New()
app.dig = c
// create a ding instance
dg := ding.New(ctx)
app.ding = dg
// setup logger // setup logger
log := logger.NewLogger().WithConfig(app.config.Log) log := logger.NewLogger().WithConfig(app.config.Log)
log.Init() log.Init()
app.log = log app.log = log
app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version)
// get app url // get app url
if app.config.AppURL == "" { if app.config.AppURL == "" {
return errors.New("app url cannot be empty, perhaps config loading failed") return errors.New("app url cannot be empty, perhaps config loading failed")
@@ -98,7 +78,7 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to parse app url: %w", err) return fmt.Errorf("failed to parse app url: %w", err)
} }
app.runtime.AppURL = strings.ToLower(appUrl.Scheme + "://" + appUrl.Host) app.runtime.AppURL = appUrl.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 {
@@ -112,12 +92,7 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to load users: %w", err) return fmt.Errorf("failed to load users: %w", err)
} }
if users != nil { app.runtime.LocalUsers = *users
app.runtime.LocalUsers = *users
} else {
log.App.Debug().Msg("No local users found, local authentication will not be available")
app.runtime.LocalUsers = []model.LocalUser{}
}
// load oauth whitelist // load oauth whitelist
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile) oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
@@ -132,21 +107,19 @@ func (app *BootstrapApp) Setup() error {
app.runtime.OAuthProviders = app.config.OAuth.Providers app.runtime.OAuthProviders = app.config.OAuth.Providers
for id, provider := range app.runtime.OAuthProviders { for id, provider := range app.runtime.OAuthProviders {
if slices.Contains(model.ReservedProviderNames, id) {
return fmt.Errorf("provider id %s is reserved and cannot be used", id)
}
providerWhitelist, err := utils.GetStringList(provider.Whitelist, provider.WhitelistFile)
if err != nil {
return fmt.Errorf("failed to load oauth whitelist for provider %s: %w", id, err)
}
provider.Whitelist = providerWhitelist
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile) secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
provider.ClientSecret = secret provider.ClientSecret = secret
provider.ClientSecretFile = "" provider.ClientSecretFile = ""
if provider.RedirectURL == "" {
provider.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + id
}
app.runtime.OAuthProviders[id] = provider
}
// set presets for built-in providers
for id, provider := range app.runtime.OAuthProviders {
if provider.Name == "" { if provider.Name == "" {
if name, ok := model.OverrideProviders[id]; ok { if name, ok := model.OverrideProviders[id]; ok {
provider.Name = name provider.Name = name
@@ -154,16 +127,24 @@ func (app *BootstrapApp) Setup() error {
provider.Name = utils.Capitalize(id) provider.Name = utils.Capitalize(id)
} }
} }
app.runtime.OAuthProviders[id] = provider app.runtime.OAuthProviders[id] = provider
} }
// cookie domain // setup oidc clients
if !app.config.Auth.SubdomainsEnabled { for id, client := range app.config.OIDC.Clients {
app.log.App.Warn().Msg("Subdomains are disabled, cookies will be set for the current domain only") client.ID = id
app.runtime.OIDCClients = append(app.runtime.OIDCClients, client)
} }
cookieDomain, err := utils.GetCookieDomain(app.runtime.AppURL, app.config.Auth.SubdomainsEnabled) // cookie domain
cookieDomainResolver := utils.GetCookieDomain
if !app.config.Auth.SubdomainsEnabled {
app.log.App.Warn().Msg("Subdomains are disabled, using standalone cookie domain resolver which will not work with subdomains")
cookieDomainResolver = utils.GetStandaloneCookieDomain
}
cookieDomain, err := cookieDomainResolver(app.runtime.AppURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to get cookie domain: %w", err) return fmt.Errorf("failed to get cookie domain: %w", err)
@@ -188,48 +169,19 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to setup database: %w", err) return fmt.Errorf("failed to setup database: %w", err)
} }
app.ding.Go(func(ctx context.Context) { // after this point, we start initializing dependencies so it's a good time to setup a defer
<-ctx.Done() // to ensure that resources are cleaned up properly in case of an error during initialization
app.log.App.Debug().Msg("Shutting down database connection") defer func() {
if app.db == nil { app.cancel()
// using memory store, no db instance app.wg.Wait()
return if app.db != nil {
app.db.Close()
} }
if err := app.db.Close(); err != nil { }()
app.log.App.Error().Err(err).Msg("Failed to close database connection")
}
}, ding.RingCritical)
// store // store
app.queries = store app.queries = store
// provide basic utilities to container
type utilityProvider struct {
dig.Out
Log *logger.Logger
Config *model.Config
Runtime *model.RuntimeConfig
Ding *ding.Ding
Ctx context.Context
Queries repository.Store
}
err = app.dig.Provide(func() utilityProvider {
return utilityProvider{
Log: app.log,
Config: &app.config,
Runtime: &app.runtime,
Ding: app.ding,
Ctx: app.ctx,
Queries: app.queries,
}
})
if err != nil {
return fmt.Errorf("failed to provide utilities to container: %w", err)
}
// services // services
err = app.setupServices() err = app.setupServices()
@@ -278,24 +230,6 @@ func (app *BootstrapApp) Setup() error {
app.runtime.ConfiguredProviders = configuredProviders app.runtime.ConfiguredProviders = configuredProviders
// force an update of the redirect urls for all oauth providers, if they are empty
services := app.services.oauthBrokerService.GetConfiguredServices()
for _, service := range services {
oauthService, ok := app.services.oauthBrokerService.GetService(service)
if !ok {
return fmt.Errorf("failed to get oauth service for provider %s", service)
}
providerConfig := oauthService.GetConfig()
if providerConfig.RedirectURL == "" {
providerConfig.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + service
oauthService.UpdateConfig(providerConfig)
}
}
// setup router // setup router
err = app.setupRouter() err = app.setupRouter()
@@ -305,44 +239,142 @@ func (app *BootstrapApp) Setup() error {
// start db cleanup routine // start db cleanup routine
app.log.App.Debug().Msg("Starting database cleanup routine") app.log.App.Debug().Msg("Starting database cleanup routine")
app.ding.Go(app.dbCleanupRoutine, ding.RingMinor) app.wg.Go(app.dbCleanupRoutine)
// if analytics are not disabled, start heartbeat // if analytics are not disabled, start heartbeat
if app.config.Analytics.Enabled { if app.config.Analytics.Enabled {
app.log.App.Debug().Msg("Starting heartbeat routine") app.log.App.Debug().Msg("Starting heartbeat routine")
app.ding.Go(app.heartbeatRoutine, ding.RingMinor) app.wg.Go(app.heartbeatRoutine)
} }
// get listener // create err channel to listen for server errors
listenerFunc, err := app.getListenerFunc() errChanLen := 0
if err != nil { runUnix := app.config.Server.SocketPath != ""
return fmt.Errorf("failed to get listener function: %w", err) runHTTP := app.config.Server.SocketPath == "" || app.config.Server.ConcurrentListenersEnabled
if runUnix {
errChanLen++
} }
// run listener if runHTTP {
lec := make(chan error, 1) errChanLen++
}
app.ding.Go(func(ctx context.Context) { errChan := make(chan error, errChanLen)
lec <- listenerFunc(ctx)
}, ding.RingNormal) if app.config.Server.ConcurrentListenersEnabled {
app.log.App.Info().Msg("Concurrent listeners enabled, will run on all available listeners")
}
// serve unix
if runUnix {
app.wg.Go(func() {
if err := app.serveUnix(); err != nil {
errChan <- err
}
})
}
// serve to http
if runHTTP {
app.wg.Go(func() {
if err := app.serveHTTP(); err != nil {
errChan <- err
}
})
}
// monitor cancellation and server errors // monitor cancellation and server errors
for { for {
select { select {
case <-app.ctx.Done(): case <-app.ctx.Done():
app.ding.Wait()
app.log.App.Info().Msg("Oh, it's time for me to go, bye!") app.log.App.Info().Msg("Oh, it's time for me to go, bye!")
return nil return nil
case err := <-lec: case err := <-errChan:
if err != nil { if err != nil {
return fmt.Errorf("listener error: %w", err) return fmt.Errorf("server error: %w", err)
} }
} }
} }
} }
func (app *BootstrapApp) heartbeatRoutine(ctx context.Context) { func (app *BootstrapApp) serveHTTP() error {
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
app.log.App.Info().Msgf("Starting server on %s", address)
server := &http.Server{
Addr: address,
Handler: app.router.Handler(),
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msg("Shutting down http listener")
server.Shutdown(app.ctx)
}()
err := server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("failed to start http listener: %w", err)
}
return nil
}
func (app *BootstrapApp) serveUnix() error {
if app.config.Server.SocketPath == "" {
return nil
}
_, err := os.Stat(app.config.Server.SocketPath)
if err == nil {
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err)
}
}
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
listener, err := net.Listen("unix", app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to create unix socket listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
shutdown := func() {
server.Shutdown(app.ctx)
listener.Close()
os.Remove(app.config.Server.SocketPath)
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msg("Shutting down unix socket listener")
shutdown()
}()
err = server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
shutdown()
return fmt.Errorf("failed to start unix socket listener: %w", err)
}
return nil
}
func (app *BootstrapApp) heartbeatRoutine() {
ticker := time.NewTicker(time.Duration(12) * time.Hour) ticker := time.NewTicker(time.Duration(12) * time.Hour)
defer ticker.Stop() defer ticker.Stop()
@@ -395,7 +427,7 @@ func (app *BootstrapApp) heartbeatRoutine(ctx context.Context) {
if res.StatusCode != 200 && res.StatusCode != 201 { if res.StatusCode != 200 && res.StatusCode != 201 {
app.log.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status") app.log.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
} }
case <-ctx.Done(): case <-app.ctx.Done():
app.log.App.Debug().Msg("Stopping heartbeat routine") app.log.App.Debug().Msg("Stopping heartbeat routine")
ticker.Stop() ticker.Stop()
return return
@@ -403,7 +435,7 @@ func (app *BootstrapApp) heartbeatRoutine(ctx context.Context) {
} }
} }
func (app *BootstrapApp) dbCleanupRoutine(ctx context.Context) { func (app *BootstrapApp) dbCleanupRoutine() {
ticker := time.NewTicker(time.Duration(30) * time.Minute) ticker := time.NewTicker(time.Duration(30) * time.Minute)
defer ticker.Stop() defer ticker.Stop()
@@ -412,14 +444,14 @@ func (app *BootstrapApp) dbCleanupRoutine(ctx context.Context) {
case <-ticker.C: case <-ticker.C:
app.log.App.Debug().Msg("Running database cleanup") app.log.App.Debug().Msg("Running database cleanup")
err := app.queries.DeleteExpiredSessions(ctx, time.Now().Unix()) err := app.queries.DeleteExpiredSessions(app.ctx, time.Now().Unix())
if err != nil { if err != nil {
app.log.App.Error().Err(err).Msg("Failed to delete expired sessions") app.log.App.Error().Err(err).Msg("Failed to delete expired sessions")
} }
app.log.App.Debug().Msg("Database cleanup completed") app.log.App.Debug().Msg("Database cleanup completed")
case <-ctx.Done(): case <-app.ctx.Done():
app.log.App.Debug().Msg("Stopping database cleanup routine") app.log.App.Debug().Msg("Stopping database cleanup routine")
ticker.Stop() ticker.Stop()
return return
+9 -57
View File
@@ -6,18 +6,15 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/golang-migrate/migrate/v4"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"github.com/tinyauthapp/tinyauth/internal/assets" "github.com/tinyauthapp/tinyauth/internal/assets"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/repository/postgres"
"github.com/tinyauthapp/tinyauth/internal/repository/sqlite" "github.com/tinyauthapp/tinyauth/internal/repository/sqlite"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite"
) )
func (app *BootstrapApp) SetupStore() (repository.Store, error) { func (app *BootstrapApp) SetupStore() (repository.Store, error) {
@@ -26,10 +23,8 @@ func (app *BootstrapApp) SetupStore() (repository.Store, error) {
return memory.New(), nil return memory.New(), nil
case "sqlite", "": case "sqlite", "":
return app.setupSQLite(app.config.Database.Path) return app.setupSQLite(app.config.Database.Path)
case "postgres":
return app.setupPostgres(app.config.Database.Path)
default: default:
return nil, fmt.Errorf("unknown database driver %q: valid values are sqlite, postgres, memory", app.config.Database.Driver) return nil, fmt.Errorf("unknown database driver %q: valid values are sqlite, memory", app.config.Database.Driver)
} }
} }
@@ -46,9 +41,9 @@ func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, err
return nil, fmt.Errorf("failed to open database: %w", err) return nil, fmt.Errorf("failed to open database: %w", err)
} }
cleanup := true // Close the database if there is an error during migration
defer func() { defer func() {
if cleanup { if err != nil {
db.Close() db.Close()
} }
}() }()
@@ -75,54 +70,11 @@ func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, err
return nil, fmt.Errorf("failed to create migrator: %w", err) return nil, fmt.Errorf("failed to create migrator: %w", err)
} }
if err = migrator.Up(); err != nil && err != migrate.ErrNoChange { if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
return nil, fmt.Errorf("failed to migrate database: %w", err) return nil, fmt.Errorf("failed to migrate database: %w", err)
} }
cleanup = false
app.db = db app.db = db
return sqlite.NewStore(sqlite.New(db)), nil return sqlite.NewStore(sqlite.New(db)), nil
} }
func (app *BootstrapApp) setupPostgres(databaseURL string) (repository.Store, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
cleanup := true
defer func() {
if cleanup {
db.Close()
}
}()
migrations, err := iofs.New(assets.Migrations, "migrations/postgres")
if err != nil {
return nil, fmt.Errorf("failed to create migrations: %w", err)
}
target, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
if err != nil {
return nil, fmt.Errorf("failed to create postgres instance: %w", err)
}
migrator, err := migrate.NewWithInstance("iofs", migrations, "pgx", target)
if err != nil {
return nil, fmt.Errorf("failed to create migrator: %w", err)
}
if err = migrator.Up(); err != nil && err != migrate.ErrNoChange {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
cleanup = false
app.db = db
return postgres.NewStore(postgres.New(db)), nil
}
+16 -171
View File
@@ -1,18 +1,10 @@
package bootstrap package bootstrap
import ( import (
"context"
"errors"
"fmt" "fmt"
"net"
"net/http"
"os"
"time"
"github.com/tinyauthapp/tinyauth/internal/controller" "github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/middleware" "github.com/tinyauthapp/tinyauth/internal/middleware"
"github.com/tinyauthapp/tinyauth/internal/model"
"go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -32,179 +24,32 @@ func (app *BootstrapApp) setupRouter() error {
} }
} }
middlewareProvideFor := []any{ contextMiddleware := middleware.NewContextMiddleware(app.log, app.runtime, app.services.authService, app.services.oauthBrokerService)
middleware.NewContextMiddleware, engine.Use(contextMiddleware.Middleware())
middleware.NewUIMiddleware,
middleware.NewZerologMiddleware,
}
for _, provider := range middlewareProvideFor { uiMiddleware, err := middleware.NewUIMiddleware()
err := app.dig.Provide(provider)
if err != nil {
return fmt.Errorf("failed to provide middleware: %w", err)
}
}
type middlewareInput struct {
dig.In
ContextMiddleware *middleware.ContextMiddleware
UIMiddleware *middleware.UIMiddleware
ZerologMiddleware *middleware.ZerologMiddleware
}
err := app.dig.Invoke(func(mi middlewareInput) {
engine.Use(mi.ContextMiddleware.Middleware())
engine.Use(mi.UIMiddleware.Middleware())
engine.Use(mi.ZerologMiddleware.Middleware())
})
if err != nil { if err != nil {
return fmt.Errorf("failed to invoke middleware: %w", err) return fmt.Errorf("failed to initialize UI middleware: %w", err)
} }
err = app.dig.Provide(func() *gin.RouterGroup { engine.Use(uiMiddleware.Middleware())
return &engine.RouterGroup
}, dig.Name("mainRouterGroup"))
if err != nil { zerologMiddleware := middleware.NewZerologMiddleware(app.log)
return fmt.Errorf("failed to provide main router group: %w", err)
}
err = app.dig.Provide(func() *gin.RouterGroup { engine.Use(zerologMiddleware.Middleware())
return engine.Group("/api")
}, dig.Name("apiRouterGroup"))
if err != nil { apiRouter := engine.Group("/api")
return fmt.Errorf("failed to provide api router group: %w", err)
}
controllerProvideFor := []any{ controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
controller.NewContextController, controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
controller.NewOAuthController, controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter)
controller.NewOIDCController, controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService)
controller.NewProxyController, controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
controller.NewUserController, controller.NewResourcesController(app.config, &engine.RouterGroup)
controller.NewResourcesController, controller.NewHealthController(apiRouter)
controller.NewHealthController, controller.NewWellKnownController(app.services.oidcService, &engine.RouterGroup)
controller.NewWellKnownController,
}
for _, provider := range controllerProvideFor {
err := app.dig.Provide(provider)
if err != nil {
return fmt.Errorf("failed to provide controller: %w", err)
}
}
type controllerInput struct {
dig.In
ContextController *controller.ContextController
OAuthController *controller.OAuthController
OIDCController *controller.OIDCController
ProxyController *controller.ProxyController
UserController *controller.UserController
ResourcesController *controller.ResourcesController
HealthController *controller.HealthController
WellKnownController *controller.WellKnownController
}
// force dig to build all controllers and register their routes
err = app.dig.Invoke(func(ci controllerInput) error {
return nil
})
if err != nil {
return fmt.Errorf("failed to invoke controllers: %w", err)
}
app.router = engine app.router = engine
return nil return nil
} }
// Top down
// 1. Unix socket (if server.socketPath)
// 2. HTTP - default
func (app *BootstrapApp) getListenerFunc() (func(ctx context.Context) error, error) {
if app.config.Server.SocketPath != "" {
return app.serveUnix, nil
}
return app.serveHTTP, nil
}
func (app *BootstrapApp) serveHTTP(ctx context.Context) error {
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
app.log.App.Info().Msgf("Starting server on http://%s", address)
listener, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("failed to create tcp listener: %w", err)
}
server := &http.Server{
Addr: address,
Handler: app.router.Handler(),
}
return app.serve(listener, server, ctx, "http")
}
func (app *BootstrapApp) serveUnix(ctx context.Context) error {
_, err := os.Stat(app.config.Server.SocketPath)
if err == nil {
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err)
}
}
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
listener, err := net.Listen("unix", app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to create unix socket listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
return app.serve(listener, server, ctx, "unix socket")
}
func (app *BootstrapApp) serve(listener net.Listener, server *http.Server, ctx context.Context, name string) error {
shutdown := func() {
// we use a new context for the shutdown since the main one is cancelled
sctx, cancel := context.WithTimeout(context.Background(), model.GracefulShutdownTimeout*time.Second)
defer cancel()
err := server.Shutdown(sctx)
if err != nil {
app.log.App.Error().Err(err).Msgf("Failed to shutdown %s listener gracefully", name)
}
listener.Close()
}
go func() {
<-ctx.Done()
app.log.App.Debug().Msgf("Shutting down %s listener", name)
shutdown()
}()
err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("failed to start %s listener: %w", name, err)
}
return nil
}
+36 -144
View File
@@ -5,170 +5,62 @@ import (
"os" "os"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"go.uber.org/dig"
) )
func (app *BootstrapApp) setupServices() error { func (app *BootstrapApp) setupServices() error {
err := app.setupPolicyEngine() ldapService, err := service.NewLdapService(app.log, app.config, app.ctx, &app.wg)
if err != nil { if err != nil {
return fmt.Errorf("failed to setup policy engine: %w", err) app.log.App.Warn().Err(err).Msg("Failed to initialize LDAP connection, will continue without it")
} }
labelProvider, err := app.getLabelProvider() app.services.ldapService = ldapService
if err != nil { useKubernetes := app.config.LabelProvider == "kubernetes" ||
return fmt.Errorf("failed to get label provider: %w", err) (app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
}
serviceProvideFor := []any{ var labelProvider service.LabelProvider
func() service.LabelProvider {
return labelProvider
},
service.NewLdapService,
service.NewTailscaleService,
service.NewAccessControlsService,
service.NewOAuthBrokerService,
service.NewAuthService,
service.NewOIDCService,
}
for _, provider := range serviceProvideFor { if useKubernetes {
err = app.dig.Provide(provider) app.log.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
if err != nil { if err != nil {
return fmt.Errorf("failed to provide service: %w", err) return fmt.Errorf("failed to initialize kubernetes service: %w", err)
} }
app.services.kubernetesService = kubernetesService
labelProvider = kubernetesService
} else {
app.log.App.Debug().Msg("Using Docker label provider")
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
if err != nil {
return fmt.Errorf("failed to initialize docker service: %w", err)
}
app.services.dockerService = dockerService
labelProvider = dockerService
} }
type svcInput struct { accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps)
dig.In app.services.accessControlService = accessControlsService
AccessControlService *service.AccessControlsService oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
AuthService *service.AuthService app.services.oauthBrokerService = oauthBrokerService
LDAPService *service.LdapService
OAuthBrokerService *service.OAuthBrokerService
OIDCService *service.OIDCService
TailscaleService *service.TailscaleService
}
err = app.dig.Invoke(func(i svcInput) error { authService := service.NewAuthService(app.log, app.config, app.runtime, app.ctx, &app.wg, app.services.ldapService, app.queries, app.services.oauthBrokerService)
app.services.accessControlService = i.AccessControlService app.services.authService = authService
app.services.authService = i.AuthService
app.services.ldapService = i.LDAPService oidcService, err := service.NewOIDCService(app.log, app.config, app.runtime, app.queries, app.ctx, &app.wg)
app.services.oauthBrokerService = i.OAuthBrokerService
app.services.oidcService = i.OIDCService
app.services.tailscaleService = i.TailscaleService
return nil
})
if err != nil { if err != nil {
return fmt.Errorf("failed to invoke services: %w", err) return fmt.Errorf("failed to initialize oidc service: %w", err)
} }
app.services.oidcService = oidcService
return nil return nil
} }
func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
switch app.config.LabelProvider {
case "none", "docker", "kubernetes", "auto":
if app.config.LabelProvider == "none" {
return nil, nil
}
useKubernetes := app.config.LabelProvider == "kubernetes" ||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
if useKubernetes {
app.log.App.Debug().Msg("Using Kubernetes label provider")
err := app.dig.Provide(service.NewKubernetesService)
if err != nil {
return nil, fmt.Errorf("failed to provide kubernetes service: %w", err)
}
err = app.dig.Invoke(func(k *service.KubernetesService) error {
app.services.kubernetesService = k
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to invoke kubernetes service: %w", err)
}
// Kubernetes will fail to initialize with an error if it cannot connect to the cluster
// but just to be safe, we check if the service is nil and log a warning if it is
if app.services.kubernetesService == nil {
if app.config.LabelProvider == "kubernetes" {
app.log.App.Warn().Msg("Kubernetes label provider selected but Kubernetes is not available, will continue without it")
}
return nil, nil
}
return app.services.kubernetesService, nil
}
app.log.App.Debug().Msg("Using Docker label provider")
err := app.dig.Provide(service.NewDockerService)
if err != nil {
return nil, fmt.Errorf("failed to provide docker service: %w", err)
}
err = app.dig.Invoke(func(d *service.DockerService) error {
app.services.dockerService = d
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to invoke docker service: %w", err)
}
if app.services.dockerService == nil {
if app.config.LabelProvider == "docker" {
app.log.App.Warn().Msg("Docker label provider selected but Docker is not available, will continue without it")
}
return nil, nil
}
return app.services.dockerService, nil
default:
return nil, fmt.Errorf("invalid label provider: %s", app.config.LabelProvider)
}
}
func (app *BootstrapApp) setupPolicyEngine() error {
err := app.dig.Provide(service.NewPolicyEngine)
if err != nil {
return fmt.Errorf("failed to create policy engine: %w", err)
}
err = app.dig.Invoke(func(policyEngine *service.PolicyEngine) error {
policyEngine.RegisterRule(service.RuleUserAllowed, &service.UserAllowedRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleOAuthGroup, &service.OAuthGroupRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleLDAPGroup, &service.LDAPGroupRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleAuthEnabled, &service.AuthEnabledRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleIPAllowed, &service.IPAllowedRule{
Log: app.log,
Config: app.config,
})
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
Log: app.log,
Config: app.config,
})
return nil
})
return err
}
+71 -122
View File
@@ -1,106 +1,64 @@
package controller package controller
import ( import (
"errors" "fmt"
"net/url"
"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"
"go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// UCR -> User Context Response
type UCRAuth struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
ProviderID string `json:"providerId"`
}
type UCROAuth struct {
Active bool `json:"active"`
DisplayName string `json:"displayName"`
}
type UCRTOTP struct {
Pending bool `json:"pending"`
}
type UCRTailscale struct {
NodeName string `json:"nodeName,omitempty"`
}
type UserContextResponse struct { type UserContextResponse struct {
Status int `json:"status"` Status int `json:"status"`
Message string `json:"message"` Message string `json:"message"`
Auth UCRAuth `json:"auth"` IsLoggedIn bool `json:"isLoggedIn"`
OAuth UCROAuth `json:"oauth"` Username string `json:"username"`
TOTP UCRTOTP `json:"totp"` Name string `json:"name"`
Tailscale UCRTailscale `json:"tailscale"` Email string `json:"email"`
} Provider string `json:"provider"`
OAuth bool `json:"oauth"`
// ACR -> App Context Response TOTPPending bool `json:"totpPending"`
OAuthName string `json:"oauthName"`
type ACRAuth struct {
Providers []model.Provider `json:"providers"`
}
type ACROAuth struct {
AutoRedirect string `json:"autoRedirect"`
}
type ACRUI struct {
Title string `json:"title"`
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
BackgroundImage string `json:"backgroundImage"`
WarningsEnabled bool `json:"warningsEnabled"`
}
type ACRApp struct {
AppURL string `json:"appUrl"`
CookieDomain string `json:"cookieDomain"`
SubdomainsEnabled bool `json:"subdomainsEnabled"`
} }
type AppContextResponse struct { type AppContextResponse struct {
Status int `json:"status"` Status int `json:"status"`
Message string `json:"message"` Message string `json:"message"`
Auth ACRAuth `json:"auth"` Providers []model.Provider `json:"providers"`
OAuth ACROAuth `json:"oauth"` Title string `json:"title"`
UI ACRUI `json:"ui"` AppURL string `json:"appUrl"`
App ACRApp `json:"app"` CookieDomain string `json:"cookieDomain"`
} ForgotPasswordMessage string `json:"forgotPasswordMessage"`
BackgroundImage string `json:"backgroundImage"`
type ContextControllerInput struct { OAuthAutoRedirect string `json:"oauthAutoRedirect"`
dig.In WarningsEnabled bool `json:"warningsEnabled"`
Log *logger.Logger
Config *model.Config
Runtime *model.RuntimeConfig
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
} }
type ContextController struct { type ContextController struct {
log *logger.Logger log *logger.Logger
config *model.Config config model.Config
runtime *model.RuntimeConfig runtime model.RuntimeConfig
} }
func NewContextController(i ContextControllerInput) *ContextController { func NewContextController(
log *logger.Logger,
config model.Config,
runtimeConfig model.RuntimeConfig,
router *gin.RouterGroup,
) *ContextController {
controller := &ContextController{ controller := &ContextController{
log: i.Log, log: log,
config: i.Config, config: config,
runtime: i.Runtime, runtime: runtimeConfig,
} }
if !i.Config.UI.WarningsEnabled { if !config.UI.WarningsEnabled {
i.Log.App.Warn().Msg("UI warnings are disabled. This may lead to security issues if you are not careful. Make sure to enable warnings in production environments.") log.App.Warn().Msg("UI warnings are disabled. This may lead to security issues if you are not careful. Make sure to enable warnings in production environments.")
} }
contextGroup := i.RouterGroup.Group("/context") contextGroup := router.Group("/context")
contextGroup.GET("/user", controller.userContextHandler) contextGroup.GET("/user", controller.userContextHandler)
contextGroup.GET("/app", controller.appContextHandler) contextGroup.GET("/app", controller.appContextHandler)
@@ -111,62 +69,53 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
context, err := new(model.UserContext).NewFromGin(c) context, err := new(model.UserContext).NewFromGin(c)
if err != nil { if err != nil {
if !errors.Is(err, model.ErrUserContextNotFound) { controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
}
c.JSON(200, UserContextResponse{ c.JSON(200, UserContextResponse{
Status: 401, Status: 401,
Message: "Unauthorized", Message: "Unauthorized",
Auth: UCRAuth{Authenticated: false}, IsLoggedIn: false,
}) })
return return
} }
userContext := UserContextResponse{ userContext := UserContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Auth: UCRAuth{ IsLoggedIn: context.Authenticated,
Authenticated: context.Authenticated, Username: context.GetUsername(),
Username: context.GetUsername(), Name: context.GetName(),
Name: context.GetName(), Email: context.GetEmail(),
Email: context.GetEmail(), Provider: context.GetProviderID(),
ProviderID: context.GetProviderID(), OAuth: context.IsOAuth(),
}, TOTPPending: context.TOTPPending(),
OAuth: UCROAuth{ OAuthName: context.OAuthName(),
Active: context.IsOAuth(),
DisplayName: context.OAuthName(),
},
TOTP: UCRTOTP{
Pending: context.TOTPPending(),
},
Tailscale: UCRTailscale{
NodeName: context.TailscaleNodeName(),
},
} }
c.JSON(200, userContext) c.JSON(200, userContext)
} }
func (controller *ContextController) appContextHandler(c *gin.Context) { func (controller *ContextController) appContextHandler(c *gin.Context) {
appUrl, err := url.Parse(controller.runtime.AppURL)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.JSON(200, AppContextResponse{ c.JSON(200, AppContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Auth: ACRAuth{ Providers: controller.runtime.ConfiguredProviders,
Providers: controller.runtime.ConfiguredProviders, Title: controller.config.UI.Title,
}, AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
OAuth: ACROAuth{ CookieDomain: controller.runtime.CookieDomain,
AutoRedirect: controller.config.OAuth.AutoRedirect, ForgotPasswordMessage: controller.config.UI.ForgotPasswordMessage,
}, BackgroundImage: controller.config.UI.BackgroundImage,
UI: ACRUI{ OAuthAutoRedirect: controller.config.OAuth.AutoRedirect,
Title: controller.config.UI.Title, WarningsEnabled: controller.config.UI.WarningsEnabled,
ForgotPasswordMessage: controller.config.UI.ForgotPasswordMessage,
BackgroundImage: controller.config.UI.BackgroundImage,
WarningsEnabled: controller.config.UI.WarningsEnabled,
},
App: ACRApp{
AppURL: controller.runtime.AppURL,
CookieDomain: controller.runtime.CookieDomain,
SubdomainsEnabled: controller.config.Auth.SubdomainsEnabled,
},
}) })
} }
+23 -38
View File
@@ -1,4 +1,4 @@
package controller package controller_test
import ( import (
"encoding/json" "encoding/json"
@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
@@ -32,26 +33,17 @@ func TestContextController(t *testing.T) {
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
path: "/api/context/app", path: "/api/context/app",
expected: func() string { expected: func() string {
expectedAppContextResponse := AppContextResponse{ expectedAppContextResponse := controller.AppContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Auth: ACRAuth{ Providers: runtime.ConfiguredProviders,
Providers: runtime.ConfiguredProviders, Title: cfg.UI.Title,
}, AppURL: runtime.AppURL,
OAuth: ACROAuth{ CookieDomain: runtime.CookieDomain,
AutoRedirect: cfg.OAuth.AutoRedirect, ForgotPasswordMessage: cfg.UI.ForgotPasswordMessage,
}, BackgroundImage: cfg.UI.BackgroundImage,
UI: ACRUI{ OAuthAutoRedirect: cfg.OAuth.AutoRedirect,
Title: cfg.UI.Title, WarningsEnabled: cfg.UI.WarningsEnabled,
ForgotPasswordMessage: cfg.UI.ForgotPasswordMessage,
BackgroundImage: cfg.UI.BackgroundImage,
WarningsEnabled: cfg.UI.WarningsEnabled,
},
App: ACRApp{
AppURL: runtime.AppURL,
CookieDomain: runtime.CookieDomain,
SubdomainsEnabled: cfg.Auth.SubdomainsEnabled,
},
} }
bytes, err := json.Marshal(expectedAppContextResponse) bytes, err := json.Marshal(expectedAppContextResponse)
require.NoError(t, err) require.NoError(t, err)
@@ -63,7 +55,7 @@ func TestContextController(t *testing.T) {
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
path: "/api/context/user", path: "/api/context/user",
expected: func() string { expected: func() string {
expectedUserContextResponse := UserContextResponse{ expectedUserContextResponse := controller.UserContextResponse{
Status: 401, Status: 401,
Message: "Unauthorized", Message: "Unauthorized",
} }
@@ -91,16 +83,14 @@ func TestContextController(t *testing.T) {
}, },
path: "/api/context/user", path: "/api/context/user",
expected: func() string { expected: func() string {
expectedUserContextResponse := UserContextResponse{ expectedUserContextResponse := controller.UserContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Auth: UCRAuth{ IsLoggedIn: true,
Authenticated: true, Username: "johndoe",
Username: "johndoe", Name: "John Doe",
Name: "John Doe", Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain),
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain), Provider: "local",
ProviderID: "local",
},
} }
bytes, err := json.Marshal(expectedUserContextResponse) bytes, err := json.Marshal(expectedUserContextResponse)
require.NoError(t, err) require.NoError(t, err)
@@ -120,12 +110,7 @@ func TestContextController(t *testing.T) {
group := router.Group("/api") group := router.Group("/api")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
NewContextController(ContextControllerInput{ controller.NewContextController(log, cfg, runtime, group)
Log: log,
Config: &cfg,
Runtime: &runtime,
RouterGroup: group,
})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
+1 -9
View File
@@ -1,12 +1,5 @@
package controller package controller
type FrontendLoginFor string
const (
FrontendLoginForOIDC FrontendLoginFor = "oidc"
FrontendLoginForApp FrontendLoginFor = "app"
)
type UnauthorizedQuery struct { type UnauthorizedQuery struct {
Username string `url:"username"` Username string `url:"username"`
Resource string `url:"resource"` Resource string `url:"resource"`
@@ -15,6 +8,5 @@ type UnauthorizedQuery struct {
} }
type RedirectQuery struct { type RedirectQuery struct {
RedirectURI string `url:"redirect_uri"` RedirectURI string `url:"redirect_uri"`
LoginFor FrontendLoginFor `url:"login_for"`
} }
+4 -13
View File
@@ -1,24 +1,15 @@
package controller package controller
import ( import "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
"go.uber.org/dig"
)
type HealthController struct { type HealthController struct {
} }
type HealthControllerInput struct { func NewHealthController(router *gin.RouterGroup) *HealthController {
dig.In
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
}
func NewHealthController(i HealthControllerInput) *HealthController {
controller := &HealthController{} controller := &HealthController{}
i.RouterGroup.GET("/healthz", controller.healthHandler) router.GET("/healthz", controller.healthHandler)
i.RouterGroup.HEAD("/healthz", controller.healthHandler) router.HEAD("/healthz", controller.healthHandler)
return controller return controller
} }
@@ -1,4 +1,4 @@
package controller package controller_test
import ( import (
"encoding/json" "encoding/json"
@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
) )
func TestHealthController(t *testing.T) { func TestHealthController(t *testing.T) {
@@ -54,9 +55,7 @@ func TestHealthController(t *testing.T) {
group := router.Group("/api") group := router.Group("/api")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
NewHealthController(HealthControllerInput{ controller.NewHealthController(group)
RouterGroup: group,
})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
+43 -100
View File
@@ -3,7 +3,6 @@ package controller
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -12,7 +11,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
@@ -24,30 +22,26 @@ type OAuthRequest struct {
type OAuthController struct { type OAuthController struct {
log *logger.Logger log *logger.Logger
config *model.Config config model.Config
runtime *model.RuntimeConfig runtime model.RuntimeConfig
auth *service.AuthService auth *service.AuthService
} }
type OAuthControllerInput struct { func NewOAuthController(
dig.In log *logger.Logger,
config model.Config,
Log *logger.Logger runtimeConfig model.RuntimeConfig,
Config *model.Config router *gin.RouterGroup,
RuntimeConfig *model.RuntimeConfig auth *service.AuthService,
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` ) *OAuthController {
AuthService *service.AuthService
}
func NewOAuthController(i OAuthControllerInput) *OAuthController {
controller := &OAuthController{ controller := &OAuthController{
log: i.Log, log: log,
config: i.Config, config: config,
runtime: i.RuntimeConfig, runtime: runtimeConfig,
auth: i.AuthService, auth: auth,
} }
oauthGroup := i.RouterGroup.Group("/oauth") oauthGroup := router.Group("/oauth")
oauthGroup.GET("/url/:provider", controller.oauthURLHandler) oauthGroup.GET("/url/:provider", controller.oauthURLHandler)
oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler) oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler)
@@ -67,7 +61,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
return return
} }
var reqParams service.OAuthCallbackParams var reqParams service.OAuthURLParams
err = c.BindQuery(&reqParams) err = c.BindQuery(&reqParams)
@@ -81,13 +75,15 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
} }
if !controller.isOidcRequest(reqParams) { if !controller.isOidcRequest(reqParams) {
if !controller.isRedirectSafe(reqParams.RedirectURI) { isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.runtime.CookieDomain)
if !isRedirectSafe {
controller.log.App.Warn().Str("redirectUri", reqParams.RedirectURI).Msg("Unsafe redirect URI, ignoring") controller.log.App.Warn().Str("redirectUri", reqParams.RedirectURI).Msg("Unsafe redirect URI, ignoring")
reqParams.RedirectURI = "" reqParams.RedirectURI = ""
} }
} }
sessionId, err := controller.auth.NewOAuthSession(req.Provider, reqParams) sessionId, _, err := controller.auth.NewOAuthSession(req.Provider, reqParams)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session") controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session")
@@ -187,23 +183,9 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return return
} }
svc, err := controller.auth.GetOAuthService(sessionIdCookie) if !controller.auth.IsEmailWhitelisted(user.Email) {
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if svc.ID() != req.Provider {
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID())
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if !controller.auth.IsEmailWhitelisted(svc.ID(), user.Email) {
controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access") controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access")
controller.log.AuditLoginFailure(user.Email, svc.ID(), c.ClientIP(), "email not whitelisted") controller.log.AuditLoginFailure(user.Email, req.Provider, c.ClientIP(), "email not whitelisted")
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Username: user.Email, Username: user.Email,
@@ -244,6 +226,20 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
username = strings.Replace(user.Email, "@", "_", 1) username = strings.Replace(user.Email, "@", "_", 1)
} }
svc, err := controller.auth.GetOAuthService(sessionIdCookie)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if svc.ID() != req.Provider {
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID())
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
sessionCookie := repository.Session{ sessionCookie := repository.Session{
Username: username, Username: username,
Name: name, Name: name,
@@ -276,14 +272,13 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return return
} }
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/oidc/authorize?%s", controller.runtime.AppURL, queries.Encode())) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/authorize?%s", controller.runtime.AppURL, queries.Encode()))
return return
} }
if oauthPendingSession.CallbackParams.RedirectURI != "" { if oauthPendingSession.CallbackParams.RedirectURI != "" {
queries, err := query.Values(RedirectQuery{ queries, err := query.Values(RedirectQuery{
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI, RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
LoginFor: FrontendLoginForApp,
}) })
if err != nil { if err != nil {
@@ -299,68 +294,16 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, controller.runtime.AppURL) c.Redirect(http.StatusTemporaryRedirect, controller.runtime.AppURL)
} }
func (controller *OAuthController) isOidcRequest(params service.OAuthCallbackParams) bool { func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams) bool {
return params.LoginFor == string(FrontendLoginForOIDC) return params.Scope != "" &&
params.ResponseType != "" &&
params.ClientID != "" &&
params.RedirectURI != ""
} }
func (controller *OAuthController) getCookieDomain() string { func (controller *OAuthController) getCookieDomain() string {
if !controller.config.Auth.SubdomainsEnabled { if controller.config.Auth.SubdomainsEnabled {
return "" return "." + controller.runtime.CookieDomain
} }
return controller.runtime.CookieDomain return controller.runtime.CookieDomain
} }
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
u, err := url.Parse(redirectURI)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse redirect URI")
return false
}
if u.Scheme == "" || u.Host == "" {
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
return false
}
au, err := url.Parse(controller.runtime.AppURL)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
return false
}
if u.Scheme != au.Scheme {
controller.log.App.Warn().Msg("Redirect URI scheme does not match app URL scheme")
return false
}
getEffectivePort := func(u *url.URL) string {
if u.Port() != "" {
return u.Port()
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
if getEffectivePort(u) != getEffectivePort(au) {
controller.log.App.Warn().Msg("Redirect URI port does not match app URL port")
return false
}
if strings.EqualFold(u.Hostname(), au.Hostname()) {
return true
}
if !controller.config.Auth.SubdomainsEnabled {
return false
}
if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
return true
}
return false
}
@@ -1,187 +0,0 @@
package controller
import (
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
func TestOAuthControllerIsRedirectSafe(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
cfg, runtime := test.CreateTestConfigs(t)
type testCase struct {
description string
appURL string
cookieDomain string
subdomainsEnabled bool
redirectURI string
expected bool
}
tests := []testCase{
{
description: "Exact host match returns true",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://tinyauth.example.com",
expected: true,
},
{
description: "Exact host match is case insensitive",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://TinyAuth.Example.com",
expected: true,
},
{
description: "Exact host match with subdomains disabled returns true",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: false,
redirectURI: "https://tinyauth.example.com",
expected: true,
},
{
description: "Subdomain of cookie domain returns true when subdomains enabled",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://sub.example.com",
expected: true,
},
{
description: "Subdomain of cookie domain is case insensitive",
appURL: "https://tinyauth.example.com",
cookieDomain: "Example.COM",
subdomainsEnabled: true,
redirectURI: "https://SUB.example.com",
expected: true,
},
{
description: "Subdomain not matching cookie domain returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://sub.evil.com",
expected: false,
},
{
description: "Subdomain returns false when subdomains disabled",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: false,
redirectURI: "https://sub.example.com",
expected: false,
},
{
description: "Cookie domain itself is not a subdomain match",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://example.com",
expected: false,
},
{
description: "Different scheme returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "http://tinyauth.example.com",
expected: false,
},
{
description: "Different port returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://tinyauth.example.com:8080",
expected: false,
},
{
description: "Empty redirect URI returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "",
expected: false,
},
{
description: "Redirect URI without host returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https:/malicious",
expected: false,
},
{
description: "Redirect URI without scheme returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "tinyauth.example.com",
expected: false,
},
{
description: "Relative redirect URI returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "/some/path",
expected: false,
},
{
description: "Userinfo trick with malicious host returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://malicious.example.com@evil.com",
expected: false,
},
{
description: "Unparseable redirect URI returns false",
appURL: "https://tinyauth.example.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://exa\x7fmple.com",
expected: false,
},
{
description: "Unparseable app URL returns false",
appURL: "https://tinyauth.\x7fexample.com",
cookieDomain: "example.com",
subdomainsEnabled: true,
redirectURI: "https://tinyauth.example.com",
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.description, func(t *testing.T) {
router := gin.Default()
group := router.Group("/api")
gin.SetMode(gin.TestMode)
// Overwrite the app URL, cookie domain and subdomain setting for each test case
runtime.AppURL = tc.appURL
runtime.CookieDomain = tc.cookieDomain
cfg.Auth.SubdomainsEnabled = tc.subdomainsEnabled
ctrl := NewOAuthController(OAuthControllerInput{
Log: log,
Config: &cfg,
RuntimeConfig: &runtime,
RouterGroup: group,
})
assert.Equal(t, tc.expected, ctrl.isRedirectSafe(tc.redirectURI))
})
}
}
+169 -388
View File
@@ -1,39 +1,25 @@
package controller package controller
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices" "slices"
"strconv"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
"go.uber.org/dig"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
type authorizeErrorParams struct {
err error
reason string
reasonPublic string
callback string
callbackError string
state string
json bool
}
type OIDCController struct { type OIDCController struct {
log *logger.Logger log *logger.Logger
oidc *service.OIDCService oidc *service.OIDCService
runtime *model.RuntimeConfig runtime model.RuntimeConfig
} }
type AuthorizeCallback struct { type AuthorizeCallback struct {
@@ -70,40 +56,20 @@ type ClientCredentials struct {
ClientSecret string ClientSecret string
} }
type AuthorizeScreenParams struct { func NewOIDCController(
LoginFor FrontendLoginFor `url:"login_for"` log *logger.Logger,
OIDCTicket string `url:"oidc_ticket"` oidcService *service.OIDCService,
OIDCScope string `url:"oidc_scope"` runtimeConfig model.RuntimeConfig,
OIDCName string `url:"oidc_name"` router *gin.RouterGroup) *OIDCController {
OIDCPrompt service.OIDCPrompt `url:"oidc_prompt,omitempty"`
}
type AuthorizeCompleteRequest struct {
Ticket string `json:"ticket" binding:"required"`
}
type OIDCControllerInput struct {
dig.In
Log *logger.Logger
OIDCService *service.OIDCService
RuntimeConfig *model.RuntimeConfig
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
MainRouter *gin.RouterGroup `name:"mainRouterGroup"`
}
func NewOIDCController(i OIDCControllerInput) *OIDCController {
controller := &OIDCController{ controller := &OIDCController{
log: i.Log, log: log,
oidc: i.OIDCService, oidc: oidcService,
runtime: i.RuntimeConfig, runtime: runtimeConfig,
} }
i.MainRouter.POST("/authorize", controller.authorize) oidcGroup := router.Group("/oidc")
i.MainRouter.GET("/authorize", controller.authorize) oidcGroup.GET("/clients/:id", controller.GetClientInfo)
oidcGroup.POST("/authorize", controller.Authorize)
oidcGroup := i.RouterGroup.Group("/oidc")
oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
oidcGroup.POST("/token", controller.Token) oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo) oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo) oidcGroup.POST("/userinfo", controller.Userinfo)
@@ -111,27 +77,24 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController {
return controller return controller
} }
// This endpoint does **not** return a code, it handles param validation, ticket creation func (controller *OIDCController) GetClientInfo(c *gin.Context) {
// and then redirects to the frontend to handle the consent screen. It performs no destructive
// actions (like logging out an existing session)
func (controller *OIDCController) authorize(c *gin.Context) {
if controller.oidc == nil { if controller.oidc == nil {
controller.authorizeError(c, authorizeErrorParams{ controller.log.App.Warn().Msg("Received OIDC client info request but OIDC server is not configured")
err: errors.New("err_oidc_not_configured"), c.JSON(500, gin.H{
reason: "OIDC not configured", "status": 500,
reasonPublic: "This instance is not configured for OIDC", "message": "OIDC not configured",
}) })
return return
} }
req, err := controller.resolveAuthorizeRequest(c) var req ClientRequest
err := c.BindUri(&req)
if err != nil { if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to resolve authorize request") controller.log.App.Error().Err(err).Msg("Failed to bind URI")
controller.authorizeError(c, authorizeErrorParams{ c.JSON(400, gin.H{
err: err, "status": 400,
reason: "Failed to resolve authorize request", "message": "Bad Request",
reasonPublic: "The authorization request is invalid",
}) })
return return
} }
@@ -139,231 +102,108 @@ func (controller *OIDCController) authorize(c *gin.Context) {
client, ok := controller.oidc.GetClient(req.ClientID) client, ok := controller.oidc.GetClient(req.ClientID)
if !ok { if !ok {
controller.authorizeError(c, authorizeErrorParams{ controller.log.App.Warn().Str("clientId", req.ClientID).Msg("Client not found")
err: fmt.Errorf("client not found: %s", req.ClientID), c.JSON(404, gin.H{
reason: "Client not found", "status": 404,
reasonPublic: "The client ID is invalid", "message": "Client not found",
})
return
}
err = controller.oidc.ValidateAuthorizeParams(*req)
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params")
if err.Error() != "invalid_request_uri" {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed validate authorize params",
reasonPublic: "Invalid request parameters",
callback: req.RedirectURI,
callbackError: err.Error(),
state: req.State,
})
return
}
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Redirect URI not trusted",
reasonPublic: "The provided redirect URI is not trusted",
})
return
}
prompts := controller.oidc.GetPrompt(req.Prompt)
if slices.Contains(prompts, service.OIDCPromptNone) && len(prompts) > 1 {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("invalid prompt"),
reason: "Invalid prompt",
reasonPublic: "The prompt parameters are invalid",
callback: req.RedirectURI,
callbackError: "invalid_request",
state: req.State,
})
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
if !errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Warn().Err(err).Msg("Failed to get user context")
}
}
if (err != nil || !userContext.Authenticated) && slices.Contains(prompts, service.OIDCPromptNone) {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("user not logged in"),
reason: "User not logged in",
reasonPublic: "The user is not logged in",
callback: req.RedirectURI,
callbackError: "login_required",
state: req.State,
})
return
}
ticket := controller.oidc.CreateAuthorizeRequestTicket(*req)
values := AuthorizeScreenParams{
LoginFor: FrontendLoginForOIDC,
OIDCTicket: ticket,
OIDCScope: req.Scope,
OIDCName: client.Name,
}
if slices.Contains(prompts, service.OIDCPromptLogin) {
values.OIDCPrompt = service.OIDCPromptLogin
} else if slices.Contains(prompts, service.OIDCPromptNone) {
values.OIDCPrompt = service.OIDCPromptNone
}
if req.MaxAge != "" && userContext != nil {
maxAge, err := strconv.Atoi(req.MaxAge)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Invalid max_age",
reasonPublic: "The max_age parameter is invalid",
callback: req.RedirectURI,
callbackError: "invalid_request",
state: req.State,
})
return
}
if userContext.Authenticated {
authTime := time.Unix(userContext.AuthTime, 0)
if authTime.Add(time.Duration(maxAge) * time.Second).Before(time.Now()) {
values.OIDCPrompt = service.OIDCPromptLogin
}
}
}
queries, err := query.Values(values)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to compile authorize queries",
reasonPublic: "An internal error occured while processing your request",
callback: req.RedirectURI,
callbackError: "server_error",
state: req.State,
})
return
}
redirectUrl := fmt.Sprintf("%s/oidc/authorize?%s", controller.oidc.GetIssuer(), queries.Encode())
c.Redirect(http.StatusFound, redirectUrl)
}
// The actual **internal** endpoint that actually creates the code and session.
// It is called by the frontend after the user has logged in and given consent.
func (controller *OIDCController) authorizeComplete(c *gin.Context) {
if controller.oidc == nil {
// For this endpoint we return JSON errors since it's called
// by the frontend and not an external client, so there's
// no redirect_uri to send the user to in case of error
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err_oidc_not_configured"),
reason: "OIDC not configured",
reasonPublic: "This instance is not configured for OIDC",
json: true,
})
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
if !errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Warn().Err(err).Msg("Failed to get user context")
}
}
if err != nil || !userContext.Authenticated {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err user not logged in"),
reason: "User not logged in",
reasonPublic: "The user is not logged in",
json: true,
})
return
}
var req AuthorizeCompleteRequest
err = c.BindJSON(&req)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to bind JSON",
reasonPublic: "The client provided an invalid authorization request",
json: true,
})
return
}
authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.Ticket)
if !ok {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("authorize request not found for ticket"),
reason: "Invalid or expired ticket",
reasonPublic: "The authorization request has expired or is invalid",
json: true,
})
return
}
// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
// Create the sub to find and delete old sessions
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)
// Before storing the code, delete old session
err = controller.oidc.DeleteOldSession(c, sub)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to delete old sessions",
reasonPublic: "Failed to delete old sessions",
callback: authorizeReq.RedirectURI,
callbackError: "server_error",
state: authorizeReq.State,
json: true,
})
return
}
// Create the authorization code
code := controller.oidc.CreateCode(*authorizeReq, *userContext)
queries, err := query.Values(AuthorizeCallback{
Code: code,
State: authorizeReq.State,
})
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to build query",
reasonPublic: "Failed to build query",
callback: authorizeReq.RedirectURI,
callbackError: "server_error",
state: authorizeReq.State,
json: true,
}) })
return return
} }
c.JSON(200, gin.H{
"status": 200,
"client": client.ClientID,
"name": client.Name,
})
}
func (controller *OIDCController) Authorize(c *gin.Context) {
if controller.oidc == nil {
controller.authorizeError(c, errors.New("err_oidc_not_configured"), "OIDC not configured", "This instance is not configured for OIDC", "", "", "")
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
return
}
if !userContext.Authenticated {
controller.authorizeError(c, errors.New("err user not logged in"), "User not logged in", "The user is not logged in", "", "", "")
return
}
var req service.AuthorizeRequest
err = c.BindJSON(&req)
if err != nil {
controller.authorizeError(c, err, "Failed to bind JSON", "The client provided an invalid authorization request", "", "", "")
return
}
client, ok := controller.oidc.GetClient(req.ClientID)
if !ok {
controller.authorizeError(c, fmt.Errorf("client not found: %s", req.ClientID), "Client not found", "The client ID is invalid", "", "", "")
return
}
err = controller.oidc.ValidateAuthorizeParams(req)
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params")
if err.Error() != "invalid_request_uri" {
controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State)
return
}
controller.authorizeError(c, err, "Redirect URI not trusted", "The provided redirect URI is not trusted", "", "", "")
return
}
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.GetUsername(), client.ID))
code := utils.GenerateString(32)
// Before storing the code, delete old session
err = controller.oidc.DeleteOldSession(c, sub)
if err != nil {
controller.authorizeError(c, err, "Failed to delete old sessions", "Failed to delete old sessions", req.RedirectURI, "server_error", req.State)
return
}
err = controller.oidc.StoreCode(c, sub, code, req)
if err != nil {
controller.authorizeError(c, err, "Failed to store code", "Failed to store code", req.RedirectURI, "server_error", req.State)
return
}
// We also need a snapshot of the user that authorized this (skip if no openid scope)
if slices.Contains(strings.Fields(req.Scope), "openid") {
err = controller.oidc.StoreUserinfo(c, sub, *userContext, req)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to store user info")
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
return
}
}
queries, err := query.Values(AuthorizeCallback{
Code: code,
State: req.State,
})
if err != nil {
controller.authorizeError(c, err, "Failed to build query", "Failed to build query", req.RedirectURI, "server_error", req.State)
return
}
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()), "redirect_uri": fmt.Sprintf("%s?%s", req.RedirectURI, queries.Encode()),
}) })
} }
@@ -445,34 +285,39 @@ func (controller *OIDCController) Token(c *gin.Context) {
switch req.GrantType { switch req.GrantType {
case "authorization_code": case "authorization_code":
entry, ok := controller.oidc.GetCodeEntry(controller.oidc.Hash(req.Code), client.ClientID) entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)
if err != nil {
if !ok { if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil {
// ensure no code reuse controller.log.App.Error().Err(err).Msg("Failed to revoke tokens for replayed code")
usedCodeSub, ok := controller.oidc.IsCodeUsed(controller.oidc.Hash(req.Code)) }
if errors.Is(err, service.ErrCodeNotFound) {
if ok { controller.log.App.Warn().Msg("Code not found")
controller.log.App.Warn().Msg("Code reuse detected")
err := controller.oidc.DeleteSessionBySub(c, usedCodeSub)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to delete session for reused code")
}
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"error": "invalid_grant", "error": "invalid_grant",
}) })
return return
} }
if errors.Is(err, service.ErrCodeExpired) {
controller.log.App.Warn().Msg("Code not found") controller.log.App.Warn().Msg("Code expired")
c.JSON(400, gin.H{
"error": "invalid_grant",
})
return
}
if errors.Is(err, service.ErrInvalidClient) {
controller.log.App.Warn().Msg("Code does not belong to client")
c.JSON(400, gin.H{
"error": "invalid_client",
})
return
}
controller.log.App.Error().Err(err).Msg("Failed to get code entry")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"error": "invalid_grant", "error": "server_error",
}) })
return return
} }
// mark code as used to prevent reuse
controller.oidc.MarkCodeAsUsed(controller.oidc.Hash(req.Code), entry.Userinfo.Sub)
if entry.RedirectURI != req.RedirectURI { if entry.RedirectURI != req.RedirectURI {
controller.log.App.Warn().Msg("Redirect URI does not match") controller.log.App.Warn().Msg("Redirect URI does not match")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
@@ -481,7 +326,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
return return
} }
ok = controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier) ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
if !ok { if !ok {
controller.log.App.Warn().Msg("PKCE validation failed") controller.log.App.Warn().Msg("PKCE validation failed")
@@ -491,7 +336,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
return return
} }
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, *entry, entry.AuthTime) tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to generate access token") controller.log.App.Error().Err(err).Msg("Failed to generate access token")
@@ -501,7 +346,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
return return
} }
tokenResponse = *tokenRes tokenResponse = tokenRes
case "refresh_token": case "refresh_token":
tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken, creds.ClientID) tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken, creds.ClientID)
@@ -529,7 +374,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
return return
} }
tokenResponse = *tokenRes tokenResponse = tokenRes
} }
c.Header("cache-control", "no-store") c.Header("cache-control", "no-store")
@@ -593,7 +438,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
return return
} }
entry, err := controller.oidc.GetSessionByToken(c, controller.oidc.Hash(token)) entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
if err != nil { if err != nil {
if errors.Is(err, service.ErrTokenNotFound) { if errors.Is(err, service.ErrTokenNotFound) {
@@ -612,17 +457,15 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
} }
// If we don't have the openid scope, return an error // If we don't have the openid scope, return an error
if !slices.Contains(strings.Split(entry.Scope, " "), "openid") { if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
controller.log.App.Warn().Msg("OIDC userinfo accessed with missing openid scope") controller.log.App.Warn().Msg("OIDC userinfo accessed with token missing openid scope")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"error": "invalid_scope", "error": "invalid_scope",
}) })
return return
} }
var userinfo service.UserinfoResponse user, err := controller.oidc.GetUserinfo(c, entry.Sub)
err = json.Unmarshal([]byte(entry.UserinfoJson), &userinfo)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get user info") controller.log.App.Error().Err(err).Msg("Failed to get user info")
@@ -632,55 +475,46 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
return return
} }
c.JSON(200, controller.oidc.CompileUserinfo(userinfo, entry.Scope)) c.JSON(200, controller.oidc.CompileUserinfo(user, entry.Scope))
} }
func (controller *OIDCController) authorizeError(c *gin.Context, params authorizeErrorParams) { func (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {
controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error") controller.log.App.Warn().Err(err).Str("reason", reason).Msg("Authorization error")
if params.callback != "" { if callback != "" {
errorQueries := CallbackError{ errorQueries := CallbackError{
Error: params.callbackError, Error: callbackError,
} }
if params.reasonPublic != "" { if reasonUser != "" {
errorQueries.ErrorDescription = params.reasonPublic errorQueries.ErrorDescription = reasonUser
} }
if params.state != "" { if state != "" {
errorQueries.State = params.state errorQueries.State = state
} }
queries, err := query.Values(errorQueries) queries, err := query.Values(errorQueries)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to build callback error query")
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)
return return
} }
redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode()) c.JSON(200, gin.H{
"status": 200,
if params.json { "redirect_uri": fmt.Sprintf("%s?%s", callback, queries.Encode()),
c.JSON(200, gin.H{ })
"status": 200,
"redirect_uri": redirectUrl,
})
return
}
c.Redirect(http.StatusFound, redirectUrl)
return return
} }
errorQueries := ErrorScreen{ errorQueries := ErrorScreen{
Error: params.reasonPublic, Error: reasonUser,
} }
queries, err := query.Values(errorQueries) queries, err := query.Values(errorQueries)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to build error query")
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)
return return
} }
@@ -693,61 +527,8 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
redirectUrl = fmt.Sprintf("%s/error?%s", controller.runtime.AppURL, queries.Encode()) redirectUrl = fmt.Sprintf("%s/error?%s", controller.runtime.AppURL, queries.Encode())
} }
if params.json { c.JSON(200, gin.H{
c.JSON(200, gin.H{ "status": 200,
"status": 200, "redirect_uri": redirectUrl,
"redirect_uri": redirectUrl, })
})
return
}
c.Redirect(http.StatusFound, redirectUrl)
}
func (controller *OIDCController) resolveAuthorizeRequest(c *gin.Context) (*service.AuthorizeRequest, error) {
// step 1: if we have a request object, decode it and ignore other params. If not, bind the params as usual
// we check both query and form parameters for the request object since this endpoint can be called with both GET and POST
requestObject, err := controller.resolveRequestObject(c)
if err != nil {
return nil, err
}
if requestObject != nil {
return requestObject, nil
}
// step 2: by default we assume normal GET query parameters
// step 3: if it's a POST request, we try form parameters
return controller.resolveNormalParams(c)
}
func (controller *OIDCController) resolveRequestObject(c *gin.Context) (*service.AuthorizeRequest, error) {
raw := c.Query("request")
if raw == "" && c.Request.Method == http.MethodPost {
raw = c.PostForm("request")
}
if raw == "" {
return nil, nil
}
return controller.oidc.DecodeAuthorizeJWT(raw)
}
func (controller *OIDCController) resolveNormalParams(c *gin.Context) (*service.AuthorizeRequest, error) {
var req service.AuthorizeRequest
var bind binding.Binding = binding.Query
if c.Request.Method == http.MethodPost {
bind = binding.Form
}
if err := c.ShouldBindWith(&req, bind); err != nil {
return nil, err
}
return &req, nil
} }
File diff suppressed because it is too large Load Diff
+38 -49
View File
@@ -3,7 +3,6 @@ package controller
import ( import (
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@@ -13,7 +12,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
@@ -53,34 +51,27 @@ type ProxyContext struct {
} }
type ProxyController struct { type ProxyController struct {
log *logger.Logger log *logger.Logger
runtime *model.RuntimeConfig runtime model.RuntimeConfig
acls *service.AccessControlsService acls *service.AccessControlsService
auth *service.AuthService auth *service.AuthService
policyEngine *service.PolicyEngine
} }
type ProxyControllerInput struct { func NewProxyController(
dig.In log *logger.Logger,
runtime model.RuntimeConfig,
Log *logger.Logger router *gin.RouterGroup,
RuntimeConfig *model.RuntimeConfig acls *service.AccessControlsService,
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` auth *service.AuthService,
ACLsService *service.AccessControlsService ) *ProxyController {
AuthService *service.AuthService
PolicyEngine *service.PolicyEngine
}
func NewProxyController(i ProxyControllerInput) *ProxyController {
controller := &ProxyController{ controller := &ProxyController{
log: i.Log, log: log,
runtime: i.RuntimeConfig, runtime: runtime,
acls: i.ACLsService, acls: acls,
auth: i.AuthService, auth: auth,
policyEngine: i.PolicyEngine,
} }
proxyGroup := i.RouterGroup.Group("/auth") proxyGroup := router.Group("/auth")
proxyGroup.Any("/:proxy", controller.proxyHandler) proxyGroup.Any("/:proxy", controller.proxyHandler)
return controller return controller
@@ -110,13 +101,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP() clientIP := c.ClientIP()
aclsCtx := &service.ACLContext{ if controller.auth.IsBypassedIP(clientIP, acls) {
ACLs: acls,
IP: net.ParseIP(clientIP),
Path: proxyCtx.Path,
}
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
controller.setHeaders(c, acls) controller.setHeaders(c, acls)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -125,7 +110,15 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
if controller.policyEngine.Evaluate(service.RuleAuthEnabled, aclsCtx) { authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource")
controller.handleError(c, proxyCtx)
return
}
if !authEnabled {
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication") controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
controller.setHeaders(c, acls) controller.setHeaders(c, acls)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
@@ -135,7 +128,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
if !controller.policyEngine.Evaluate(service.RuleIPAllowed, aclsCtx) { if !controller.auth.CheckIP(clientIP, acls) {
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
IP: clientIP, IP: clientIP,
@@ -158,26 +151,23 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
c.Redirect(http.StatusFound, redirectURL) c.Redirect(http.StatusTemporaryRedirect, redirectURL)
return return
} }
userContext, err := new(model.UserContext).NewFromGin(c) userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil { if err != nil {
// No user context found is not an issue controller.log.App.Debug().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
if !errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Error().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
}
userContext = &model.UserContext{ userContext = &model.UserContext{
Authenticated: false, Authenticated: false,
} }
} }
aclsCtx.UserContext = userContext
if userContext.Authenticated { if userContext.Authenticated {
if !controller.policyEngine.Evaluate(service.RuleUserAllowed, aclsCtx) { userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
if !userAllowed {
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource") controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource")
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
@@ -207,7 +197,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
c.Redirect(http.StatusFound, redirectURL) c.Redirect(http.StatusTemporaryRedirect, redirectURL)
return return
} }
@@ -215,9 +205,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
var groupOK bool var groupOK bool
if userContext.IsOAuth() { if userContext.IsOAuth() {
groupOK = controller.policyEngine.Evaluate(service.RuleOAuthGroup, aclsCtx) groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls)
} else { } else {
groupOK = controller.policyEngine.Evaluate(service.RuleLDAPGroup, aclsCtx) groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls)
} }
if !groupOK { if !groupOK {
@@ -251,7 +241,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
c.Redirect(http.StatusFound, redirectURL) c.Redirect(http.StatusTemporaryRedirect, redirectURL)
return return
} }
} }
@@ -280,7 +270,6 @@ 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.Path), RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
LoginFor: FrontendLoginForApp,
}) })
if err != nil { if err != nil {
@@ -300,7 +289,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
c.Redirect(http.StatusFound, redirectURL) c.Redirect(http.StatusTemporaryRedirect, redirectURL)
} }
func (controller *ProxyController) setHeaders(c *gin.Context, acls *model.App) { func (controller *ProxyController) setHeaders(c *gin.Context, acls *model.App) {
@@ -336,7 +325,7 @@ func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyCon
return return
} }
c.Redirect(http.StatusFound, redirectURL) c.Redirect(http.StatusTemporaryRedirect, redirectURL)
} }
func (controller *ProxyController) getHeader(c *gin.Context, header string) (string, bool) { func (controller *ProxyController) getHeader(c *gin.Context, header string) (string, bool) {
+61 -403
View File
@@ -1,18 +1,14 @@
package controller package controller_test
import ( import (
"context" "context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "sync"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
@@ -26,6 +22,33 @@ func TestProxyController(t *testing.T) {
cfg, runtime := test.CreateTestConfigs(t) cfg, runtime := test.CreateTestConfigs(t)
acls := map[string]model.App{
"app_path_allow": {
Config: model.AppConfig{
Domain: "path-allow.example.com",
},
Path: model.AppPath{
Allow: "/allowed",
},
},
"app_user_allow": {
Config: model.AppConfig{
Domain: "user-allow.example.com",
},
Users: model.AppUsers{
Allow: "testuser",
},
},
"ip_bypass": {
Config: model.AppConfig{
Domain: "ip-bypass.example.com",
},
IP: model.AppIP{
Bypass: []string{"10.10.10.10"},
},
},
}
const browserUserAgent = ` const browserUserAgent = `
Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36` Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
@@ -66,17 +89,6 @@ func TestProxyController(t *testing.T) {
} }
tests := []testCase{ tests := []testCase{
{
description: "Should get bad request on invalid proxy",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/api/auth/invalid", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Bad request")
},
},
{ {
description: "Default forward auth should be detected and used for traefik", description: "Default forward auth should be detected and used for traefik",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
@@ -88,11 +100,9 @@ func TestProxyController(t *testing.T) {
req.Header.Set("user-agent", browserUserAgent) req.Header.Set("user-agent", browserUserAgent)
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code) assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location") location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -103,11 +113,9 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-original-url", "https://test.example.com/") req.Header.Set("x-original-url", "https://test.example.com/")
req.Header.Set("user-agent", browserUserAgent) req.Header.Set("user-agent", browserUserAgent)
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code) assert.Equal(t, 401, recorder.Code)
location := recorder.Header().Get("x-tinyauth-location") location := recorder.Header().Get("x-tinyauth-location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -119,11 +127,9 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("user-agent", browserUserAgent) req.Header.Set("user-agent", browserUserAgent)
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code) assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location") location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/hello")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2Fhello", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -137,11 +143,9 @@ func TestProxyController(t *testing.T) {
req.Header.Set("user-agent", browserUserAgent) req.Header.Set("user-agent", browserUserAgent)
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code) assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location") location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -154,11 +158,9 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-uri", "/") req.Header.Set("x-forwarded-uri", "/")
req.Header.Set("user-agent", browserUserAgent) req.Header.Set("user-agent", browserUserAgent)
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code) assert.Equal(t, 401, recorder.Code)
location := recorder.Header().Get("x-tinyauth-location") location := recorder.Header().Get("x-tinyauth-location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2F", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -172,11 +174,9 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-uri", "/hello") req.Header.Set("x-forwarded-uri", "/hello")
req.Header.Set("user-agent", browserUserAgent) req.Header.Set("user-agent", browserUserAgent)
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code) assert.Equal(t, 307, recorder.Code)
location := recorder.Header().Get("Location") location := recorder.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape("https://test.example.com/")) assert.Equal(t, "https://tinyauth.example.com/login?redirect_uri=https%3A%2F%2Ftest.example.com%2Fhello", location)
assert.Contains(t, location, "login_for=app")
assert.Contains(t, location, "https://tinyauth.example.com/login")
}, },
}, },
{ {
@@ -189,7 +189,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-uri", "/") req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code) assert.Equal(t, 401, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"status":401`) assert.Contains(t, recorder.Body.String(), `"status":401`)
assert.Contains(t, recorder.Body.String(), `"message":"Unauthorized"`) assert.Contains(t, recorder.Body.String(), `"message":"Unauthorized"`)
}, },
@@ -204,7 +204,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-uri", "/") req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code) assert.Equal(t, 401, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"status":401`) assert.Contains(t, recorder.Body.String(), `"status":401`)
assert.Contains(t, recorder.Body.String(), `"message":"Unauthorized"`) assert.Contains(t, recorder.Body.String(), `"message":"Unauthorized"`)
}, },
@@ -219,7 +219,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-uri", "/hello") req.Header.Set("x-forwarded-uri", "/hello")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusUnauthorized, recorder.Code) assert.Equal(t, 401, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"status":401`) assert.Contains(t, recorder.Body.String(), `"status":401`)
assert.Contains(t, recorder.Body.String(), `"message":"Unauthorized"`) assert.Contains(t, recorder.Body.String(), `"message":"Unauthorized"`)
}, },
@@ -236,7 +236,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-uri", "/") req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "testuser", recorder.Header().Get("remote-user")) assert.Equal(t, "testuser", recorder.Header().Get("remote-user"))
assert.Equal(t, "Testuser", recorder.Header().Get("remote-name")) assert.Equal(t, "Testuser", recorder.Header().Get("remote-name"))
assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email")) assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email"))
@@ -252,7 +252,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-original-url", "https://test.example.com/") req.Header.Set("x-original-url", "https://test.example.com/")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "testuser", recorder.Header().Get("remote-user")) assert.Equal(t, "testuser", recorder.Header().Get("remote-user"))
assert.Equal(t, "Testuser", recorder.Header().Get("remote-name")) assert.Equal(t, "Testuser", recorder.Header().Get("remote-name"))
assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email")) assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email"))
@@ -269,7 +269,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "testuser", recorder.Header().Get("remote-user")) assert.Equal(t, "testuser", recorder.Header().Get("remote-user"))
assert.Equal(t, "Testuser", recorder.Header().Get("remote-name")) assert.Equal(t, "Testuser", recorder.Header().Get("remote-name"))
assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email")) assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email"))
@@ -284,7 +284,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/allowed") req.Header.Set("x-forwarded-uri", "/allowed")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
}, },
}, },
{ {
@@ -294,7 +294,7 @@ func TestProxyController(t *testing.T) {
req := httptest.NewRequest("GET", "/api/auth/nginx", nil) req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
req.Header.Set("x-original-url", "https://path-allow.example.com/allowed") req.Header.Set("x-original-url", "https://path-allow.example.com/allowed")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
}, },
}, },
{ {
@@ -305,7 +305,7 @@ func TestProxyController(t *testing.T) {
req.Host = "path-allow.example.com" req.Host = "path-allow.example.com"
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
}, },
}, },
{ {
@@ -318,7 +318,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-uri", "/") req.Header.Set("x-forwarded-uri", "/")
req.Header.Set("x-forwarded-for", "10.10.10.10") req.Header.Set("x-forwarded-for", "10.10.10.10")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
}, },
}, },
{ {
@@ -329,7 +329,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-original-url", "https://ip-bypass.example.com/") req.Header.Set("x-original-url", "https://ip-bypass.example.com/")
req.Header.Set("x-forwarded-for", "10.10.10.10") req.Header.Set("x-forwarded-for", "10.10.10.10")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
}, },
}, },
{ {
@@ -341,7 +341,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-for", "10.10.10.10") req.Header.Set("x-forwarded-for", "10.10.10.10")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
}, },
}, },
{ {
@@ -355,7 +355,7 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/") req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, 200, recorder.Code)
}, },
}, },
{ {
@@ -369,357 +369,22 @@ func TestProxyController(t *testing.T) {
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/") req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusForbidden, recorder.Code) assert.Equal(t, 403, recorder.Code)
assert.Equal(t, "", recorder.Header().Get("remote-user")) assert.Equal(t, "", recorder.Header().Get("remote-user"))
assert.Equal(t, "", recorder.Header().Get("remote-name")) assert.Equal(t, "", recorder.Header().Get("remote-name"))
assert.Equal(t, "", recorder.Header().Get("remote-email")) assert.Equal(t, "", recorder.Header().Get("remote-email"))
}, },
}, },
{
description: "Test IP block rule, with non browser user agent",
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", "ip-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
req.Header.Set("x-forwarded-for", "10.10.10.10")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusForbidden, recorder.Code)
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), runtime.AppURL)
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), "ip=10.10.10.10")
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), "ip-block")
},
},
{
description: "Test IP block rule, with browser user agent",
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", "ip-block.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
req.Header.Set("x-forwarded-for", "10.10.10.10")
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("10.10.10.10"))
assert.Contains(t, location, url.QueryEscape("ip-block"))
assert.Contains(t, location, runtime.AppURL)
},
},
{
description: "OAuth allowed group",
middlewares: []gin.HandlerFunc{
func(ctx *gin.Context) {
ctx.Set("context", &model.UserContext{
Authenticated: true,
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{
Username: "testuser",
Name: "Testuser",
Email: "testuser@example.com",
},
Groups: []string{"group1"},
},
})
ctx.Next()
},
},
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", "oauth-group.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "testuser", recorder.Header().Get("remote-user"))
assert.Equal(t, "Testuser", recorder.Header().Get("remote-name"))
assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email"))
assert.Equal(t, "group1", recorder.Header().Get("remote-groups"))
},
},
{
description: "OAuth not in required groups and non browser",
middlewares: []gin.HandlerFunc{
func(ctx *gin.Context) {
ctx.Set("context", &model.UserContext{
Authenticated: true,
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{
Username: "testuser",
Name: "Testuser",
Email: "testuser@example.com",
},
Groups: []string{"group3"},
},
})
ctx.Next()
},
},
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", "oauth-group.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusForbidden, recorder.Code)
assert.Equal(t, "", recorder.Header().Get("remote-user"))
assert.Equal(t, "", recorder.Header().Get("remote-name"))
assert.Equal(t, "", recorder.Header().Get("remote-email"))
assert.Equal(t, "", recorder.Header().Get("remote-groups"))
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), runtime.AppURL)
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), "groupErr=true")
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), "oauth-group")
},
},
{
description: "OAuth not in required groups and browser",
middlewares: []gin.HandlerFunc{
func(ctx *gin.Context) {
ctx.Set("context", &model.UserContext{
Authenticated: true,
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{
Username: "testuser",
Name: "Testuser",
Email: "testuser@example.com",
},
Groups: []string{"group3"},
},
})
ctx.Next()
},
},
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", "oauth-group.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
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, "groupErr=true")
assert.Contains(t, location, "oauth-group")
assert.Contains(t, location, runtime.AppURL)
},
},
{
description: "LDAP allowed group",
middlewares: []gin.HandlerFunc{
func(ctx *gin.Context) {
ctx.Set("context", &model.UserContext{
Authenticated: true,
Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{
BaseContext: model.BaseContext{
Username: "testuser",
Name: "Testuser",
Email: "testuser@example.com",
},
Groups: []string{"group1"},
},
})
ctx.Next()
},
},
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", "ldap-group.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "testuser", recorder.Header().Get("remote-user"))
assert.Equal(t, "Testuser", recorder.Header().Get("remote-name"))
assert.Equal(t, "testuser@example.com", recorder.Header().Get("remote-email"))
assert.Equal(t, "group1", recorder.Header().Get("remote-groups"))
},
},
{
description: "LDAP not in required groups and non browser",
middlewares: []gin.HandlerFunc{
func(ctx *gin.Context) {
ctx.Set("context", &model.UserContext{
Authenticated: true,
Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{
BaseContext: model.BaseContext{
Username: "testuser",
Name: "Testuser",
Email: "testuser@example.com",
},
Groups: []string{"group3"},
},
})
ctx.Next()
},
},
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", "ldap-group.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusForbidden, recorder.Code)
assert.Equal(t, "", recorder.Header().Get("remote-user"))
assert.Equal(t, "", recorder.Header().Get("remote-name"))
assert.Equal(t, "", recorder.Header().Get("remote-email"))
assert.Equal(t, "", recorder.Header().Get("remote-groups"))
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), runtime.AppURL)
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), "groupErr=true")
assert.Contains(t, recorder.Header().Get("x-tinyauth-location"), "ldap-group")
},
},
{
description: "LDAP not in required groups and browser",
middlewares: []gin.HandlerFunc{
func(ctx *gin.Context) {
ctx.Set("context", &model.UserContext{
Authenticated: true,
Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{
BaseContext: model.BaseContext{
Username: "testuser",
Name: "Testuser",
Email: "testuser@example.com",
},
Groups: []string{"group3"},
},
})
ctx.Next()
},
},
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", "ldap-group.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
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, "groupErr=true")
assert.Contains(t, location, "ldap-group")
assert.Contains(t, location, runtime.AppURL)
},
},
{
description: "Should add basic auth if it's in ACLs",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
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", "basic-auth.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
req.Header.Set("authorization", "foo") // should be overridden by basic auth
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
authorizationHeader := recorder.Header().Get("Authorization")
assert.NotEmpty(t, authorizationHeader)
assert.Equal(t, fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte("test:password"))), authorizationHeader)
},
},
{
description: "Authorization header should be preserved when not basic auth acls",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
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", "/")
req.Header.Set("authorization", "Bearer mytoken")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
authorizationHeader := recorder.Header().Get("Authorization")
assert.NotEmpty(t, authorizationHeader)
assert.Equal(t, "Bearer mytoken", authorizationHeader)
},
},
{
description: "Should add response headers if present",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
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", "response-headers.example.com")
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "bar", recorder.Header().Get("x-foo"))
},
},
} }
store := memory.New() store := memory.New()
wg := &sync.WaitGroup{}
ctx := context.TODO() ctx := context.TODO()
dg := ding.New(ctx)
broker := service.NewOAuthBrokerService(service.OAuthBrokerServiceInput{ broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
Log: log, authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker)
Runtime: &runtime, aclsService := service.NewAccessControlsService(log, nil, acls)
Ctx: ctx,
})
aclsService := service.NewAccessControlsService(service.AccessControlServiceInput{
Log: log,
Config: &cfg,
LabelProvider: nil,
})
policyEngine, err := service.NewPolicyEngine(service.PolicyEngineInput{
Log: log,
Config: &cfg,
})
require.NoError(t, err)
policyEngine.RegisterRule(service.RuleUserAllowed, &service.UserAllowedRule{
Log: log,
})
policyEngine.RegisterRule(service.RuleOAuthGroup, &service.OAuthGroupRule{
Log: log,
})
policyEngine.RegisterRule(service.RuleLDAPGroup, &service.LDAPGroupRule{
Log: log,
})
policyEngine.RegisterRule(service.RuleAuthEnabled, &service.AuthEnabledRule{
Log: log,
})
policyEngine.RegisterRule(service.RuleIPAllowed, &service.IPAllowedRule{
Log: log,
Config: cfg,
})
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
Log: log,
})
authService := service.NewAuthService(service.AuthServiceInput{
Log: log,
Config: &cfg,
Runtime: &runtime,
Ctx: ctx,
Ding: dg,
LDAP: nil,
Queries: store,
OAuthBroker: broker,
Tailscale: nil,
PolicyEngine: policyEngine,
})
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
@@ -734,14 +399,7 @@ func TestProxyController(t *testing.T) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
NewProxyController(ProxyControllerInput{ controller.NewProxyController(log, runtime, group, aclsService, authService)
Log: log,
RuntimeConfig: &runtime,
RouterGroup: group,
ACLsService: aclsService,
AuthService: authService,
PolicyEngine: policyEngine,
})
test.run(t, router, recorder) test.run(t, router, recorder)
}) })
+8 -13
View File
@@ -5,30 +5,25 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"go.uber.org/dig"
) )
type ResourcesController struct { type ResourcesController struct {
config *model.Config config model.Config
fileServer http.Handler fileServer http.Handler
} }
type ResourcesControllerInput struct { func NewResourcesController(
dig.In config model.Config,
router *gin.RouterGroup,
RouterGroup *gin.RouterGroup `name:"mainRouterGroup"` ) *ResourcesController {
Config *model.Config fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Resources.Path)))
}
func NewResourcesController(i ResourcesControllerInput) *ResourcesController {
fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(i.Config.Resources.Path)))
controller := &ResourcesController{ controller := &ResourcesController{
config: i.Config, config: config,
fileServer: fileServer, fileServer: fileServer,
} }
i.RouterGroup.GET("/resources/*resource", controller.resourcesHandler) router.GET("/resources/*resource", controller.resourcesHandler)
return controller return controller
} }
@@ -1,4 +1,4 @@
package controller package controller_test
import ( import (
"net/http/httptest" "net/http/httptest"
@@ -9,7 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "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/controller"
"github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/test"
) )
@@ -19,12 +19,8 @@ func TestResourcesController(t *testing.T) {
err := os.MkdirAll(cfg.Resources.Path, 0777) err := os.MkdirAll(cfg.Resources.Path, 0777)
require.NoError(t, err) require.NoError(t, err)
// create a "backup" of the original configuration to restore after each test
originalCfg := cfg.Resources
type testCase struct { type testCase struct {
description string description string
customCfg *model.ResourcesConfig
run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder)
} }
@@ -57,32 +53,6 @@ func TestResourcesController(t *testing.T) {
assert.Equal(t, 404, recorder.Code) assert.Equal(t, 404, recorder.Code)
}, },
}, },
{
description: "Ensure resources controller returns 404 when resources path is empty",
customCfg: &model.ResourcesConfig{
Path: "",
Enabled: true,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/resources/testfile.txt", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 404, recorder.Code)
},
},
{
description: "Ensure resources controller returns 403 when resources are disabled",
customCfg: &model.ResourcesConfig{
Path: cfg.Resources.Path,
Enabled: false,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/resources/testfile.txt", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 403, recorder.Code)
},
},
} }
testFilePath := cfg.Resources.Path + "/testfile.txt" testFilePath := cfg.Resources.Path + "/testfile.txt"
@@ -99,18 +69,7 @@ func TestResourcesController(t *testing.T) {
group := router.Group("/") group := router.Group("/")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
// if custom configuration is provided, override the default config controller.NewResourcesController(cfg, group)
if test.customCfg != nil {
cfg.Resources = *test.customCfg
} else {
// Reset to default configuration for each test
cfg.Resources = originalCfg
}
NewResourcesController(ResourcesControllerInput{
RouterGroup: group,
Config: &cfg,
})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
test.run(t, router, recorder) test.run(t, router, recorder)
+11 -83
View File
@@ -11,7 +11,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@@ -28,31 +27,26 @@ type TotpRequest struct {
type UserController struct { type UserController struct {
log *logger.Logger log *logger.Logger
runtime *model.RuntimeConfig runtime model.RuntimeConfig
auth *service.AuthService auth *service.AuthService
} }
type UserControllerInput struct { func NewUserController(
dig.In log *logger.Logger,
runtimeConfig model.RuntimeConfig,
Log *logger.Logger router *gin.RouterGroup,
RuntimeConfig *model.RuntimeConfig auth *service.AuthService,
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` ) *UserController {
AuthService *service.AuthService
}
func NewUserController(i UserControllerInput) *UserController {
controller := &UserController{ controller := &UserController{
log: i.Log, log: log,
runtime: i.RuntimeConfig, runtime: runtimeConfig,
auth: i.AuthService, auth: auth,
} }
userGroup := i.RouterGroup.Group("/user") userGroup := router.Group("/user")
userGroup.POST("/login", controller.loginHandler) userGroup.POST("/login", controller.loginHandler)
userGroup.POST("/logout", controller.logoutHandler) userGroup.POST("/logout", controller.logoutHandler)
userGroup.POST("/totp", controller.totpHandler) userGroup.POST("/totp", controller.totpHandler)
userGroup.POST("/tailscale", controller.tailscaleHandler)
return controller return controller
} }
@@ -295,14 +289,6 @@ func (controller *UserController) totpHandler(c *gin.Context) {
context, err := new(model.UserContext).NewFromGin(c) context, err := new(model.UserContext).NewFromGin(c)
if err != nil { if err != nil {
if errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Warn().Msg("TOTP verification attempt without user context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
controller.log.App.Error().Err(err).Msg("Failed to create user context from request for TOTP verification") controller.log.App.Error().Err(err).Msg("Failed to create user context from request for TOTP verification")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
@@ -408,61 +394,3 @@ func (controller *UserController) totpHandler(c *gin.Context) {
"message": "Login successful", "message": "Login successful",
}) })
} }
func (controller *UserController) tailscaleHandler(c *gin.Context) {
context, err := new(model.UserContext).NewFromGin(c)
if err != nil {
if errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Warn().Msg("Tailscale login attempt without user context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
if context.Tailscale == nil {
controller.log.App.Warn().Msg("Tailscale login attempt without Tailscale context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
sessionCookie := repository.Session{
Username: context.Tailscale.Username,
Name: context.Tailscale.Name,
Email: context.Tailscale.Email,
Provider: "tailscale",
}
cookie, err := controller.auth.CreateSession(c, sessionCookie)
if err != nil {
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful Tailscale login")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
http.SetCookie(c.Writer, cookie)
controller.log.App.Info().Str("username", context.GetUsername()).Msg("Tailscale login successful, login complete")
controller.log.AuditLoginSuccess(context.GetUsername(), "tailscale", c.ClientIP())
c.JSON(200, gin.H{
"status": 200,
"message": "Login successful",
})
}
+18 -161
View File
@@ -1,4 +1,4 @@
package controller package controller_test
import ( import (
"context" "context"
@@ -6,14 +6,15 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
@@ -41,7 +42,6 @@ func TestUserController(t *testing.T) {
TOTPPending: true, TOTPPending: true,
}, },
}) })
c.Next()
} }
totpAttrCtx := func(c *gin.Context) { totpAttrCtx := func(c *gin.Context) {
@@ -57,7 +57,6 @@ func TestUserController(t *testing.T) {
TOTPPending: true, TOTPPending: true,
}, },
}) })
c.Next()
} }
simpleCtx := func(c *gin.Context) { simpleCtx := func(c *gin.Context) {
@@ -72,7 +71,6 @@ func TestUserController(t *testing.T) {
}, },
}, },
}) })
c.Next()
} }
store := memory.New() store := memory.New()
@@ -84,45 +82,11 @@ func TestUserController(t *testing.T) {
} }
tests := []testCase{ tests := []testCase{
{
description: "Login should fail gracefully on invalid json",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(`{"username": "testuser", "password":`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 400, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Bad Request")
},
},
{
description: "Should fail on missing user",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := LoginRequest{
Username: "nonexistentuser",
Password: "password",
}
loginReqBody, err := json.Marshal(loginReq)
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
assert.Len(t, recorder.Result().Cookies(), 0)
assert.Contains(t, recorder.Body.String(), "Unauthorized")
},
},
{ {
description: "Should be able to login with valid credentials", description: "Should be able to login with valid credentials",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := LoginRequest{ loginReq := controller.LoginRequest{
Username: "testuser", Username: "testuser",
Password: "password", Password: "password",
} }
@@ -150,7 +114,7 @@ func TestUserController(t *testing.T) {
description: "Should reject login with invalid credentials", description: "Should reject login with invalid credentials",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := LoginRequest{ loginReq := controller.LoginRequest{
Username: "testuser", Username: "testuser",
Password: "wrongpassword", Password: "wrongpassword",
} }
@@ -171,7 +135,7 @@ func TestUserController(t *testing.T) {
description: "Should rate limit on 3 invalid attempts", description: "Should rate limit on 3 invalid attempts",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := LoginRequest{ loginReq := controller.LoginRequest{
Username: "testuser", Username: "testuser",
Password: "wrongpassword", Password: "wrongpassword",
} }
@@ -206,7 +170,7 @@ func TestUserController(t *testing.T) {
description: "Should not allow full login with totp", description: "Should not allow full login with totp",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := LoginRequest{ loginReq := controller.LoginRequest{
Username: "totpuser", Username: "totpuser",
Password: "password", Password: "password",
} }
@@ -243,7 +207,7 @@ func TestUserController(t *testing.T) {
}, },
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
// First login to get a session cookie // First login to get a session cookie
loginReq := LoginRequest{ loginReq := controller.LoginRequest{
Username: "testuser", Username: "testuser",
Password: "password", Password: "password",
} }
@@ -279,87 +243,6 @@ func TestUserController(t *testing.T) {
assert.Equal(t, -1, cookie.MaxAge) // MaxAge -1 means delete cookie assert.Equal(t, -1, cookie.MaxAge) // MaxAge -1 means delete cookie
}, },
}, },
{
description: "Logout should be treated as valid without a session cookie",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("POST", "/api/user/logout", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
},
},
{
description: "TOTP should gracefully reject invalid json",
middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(`{"code":`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 400, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Bad Request")
},
},
{
description: "TOTP should fail on non-totp context",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
totpReq := TotpRequest{
Code: "123456",
}
totpReqBody, err := json.Marshal(totpReq)
require.NoError(t, err)
recorder = httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Unauthorized")
},
},
{
description: "TOTP should fail when user in context doesn't exist",
middlewares: []gin.HandlerFunc{
func(ctx *gin.Context) {
ctx.Set("context", &model.UserContext{
Authenticated: false,
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{
Username: "idontexist",
Name: "Totpuser",
Email: "totpuser@example.com",
},
TOTPPending: true,
},
})
ctx.Next()
},
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
totpReq := TotpRequest{
Code: "123456",
}
totpReqBody, err := json.Marshal(totpReq)
require.NoError(t, err)
recorder = httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
assert.Equal(t, 401, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Unauthorized")
},
},
{ {
description: "Should be able to login with totp", description: "Should be able to login with totp",
middlewares: []gin.HandlerFunc{ middlewares: []gin.HandlerFunc{
@@ -381,7 +264,7 @@ func TestUserController(t *testing.T) {
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now()) code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
require.NoError(t, err) require.NoError(t, err)
totpReq := TotpRequest{ totpReq := controller.TotpRequest{
Code: code, Code: code,
} }
@@ -419,7 +302,7 @@ func TestUserController(t *testing.T) {
}, },
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
for range 3 { for range 3 {
totpReq := TotpRequest{ totpReq := controller.TotpRequest{
Code: "000000", // invalid code Code: "000000", // invalid code
} }
@@ -451,7 +334,7 @@ func TestUserController(t *testing.T) {
description: "Login uses name and email from user attributes", description: "Login uses name and email from user attributes",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := LoginRequest{Username: "attruser", Password: "password"} loginReq := controller.LoginRequest{Username: "attruser", Password: "password"}
body, err := json.Marshal(loginReq) body, err := json.Marshal(loginReq)
require.NoError(t, err) require.NoError(t, err)
@@ -469,7 +352,7 @@ func TestUserController(t *testing.T) {
description: "Login with TOTP uses name and email from user attributes in pending session", description: "Login with TOTP uses name and email from user attributes in pending session",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
loginReq := LoginRequest{Username: "attrtotpuser", Password: "password"} loginReq := controller.LoginRequest{Username: "attrtotpuser", Password: "password"}
body, err := json.Marshal(loginReq) body, err := json.Marshal(loginReq)
require.NoError(t, err) require.NoError(t, err)
@@ -505,7 +388,7 @@ func TestUserController(t *testing.T) {
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now()) code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
require.NoError(t, err) require.NoError(t, err)
totpReq := TotpRequest{Code: code} totpReq := controller.TotpRequest{Code: code}
body, err := json.Marshal(totpReq) body, err := json.Marshal(totpReq)
require.NoError(t, err) require.NoError(t, err)
@@ -529,35 +412,14 @@ func TestUserController(t *testing.T) {
} }
ctx := context.TODO() ctx := context.TODO()
dg := ding.New(ctx) wg := &sync.WaitGroup{}
policyEngine, err := service.NewPolicyEngine(service.PolicyEngineInput{ broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
Log: log, authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker)
Config: &cfg,
})
require.NoError(t, err)
broker := service.NewOAuthBrokerService(service.OAuthBrokerServiceInput{
Log: log,
Runtime: &runtime,
Ctx: ctx,
})
authService := service.NewAuthService(service.AuthServiceInput{
Log: log,
Config: &cfg,
Runtime: &runtime,
Ctx: ctx,
Ding: dg,
LDAP: nil,
Queries: store,
OAuthBroker: broker,
Tailscale: nil,
PolicyEngine: policyEngine,
})
beforeEach := func() { beforeEach := func() {
// Clear failed login attempts before each test // Clear failed login attempts before each test
authService.ClearLoginAttempts() authService.ClearRateLimitsTestingOnly()
} }
for _, test := range tests { for _, test := range tests {
@@ -572,12 +434,7 @@ func TestUserController(t *testing.T) {
group := router.Group("/api") group := router.Group("/api")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
NewUserController(UserControllerInput{ controller.NewUserController(log, runtime, group, authService)
Log: log,
RuntimeConfig: &runtime,
RouterGroup: group,
AuthService: authService,
})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
+4 -87
View File
@@ -3,27 +3,11 @@ package controller
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"slices"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"go.uber.org/dig"
) )
const OpenIDConnectRel = "http://openid.net/specs/connect/1.0/issuer"
type WebfingerResponseLink struct {
Rel string `json:"rel,omitempty"`
Href string `json:"href"`
}
type WebfingerResponse struct {
Subject string `json:"subject"`
Links []WebfingerResponseLink `json:"links"`
}
type OpenIDConnectConfiguration struct { type OpenIDConnectConfiguration struct {
Issuer string `json:"issuer"` Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"` AuthorizationEndpoint string `json:"authorization_endpoint"`
@@ -46,21 +30,13 @@ type WellKnownController struct {
oidc *service.OIDCService oidc *service.OIDCService
} }
type WellKnownControllerInput struct { func NewWellKnownController(oidc *service.OIDCService, router *gin.RouterGroup) *WellKnownController {
dig.In
OIDCService *service.OIDCService
RouterGroup *gin.RouterGroup `name:"mainRouterGroup"`
}
func NewWellKnownController(i WellKnownControllerInput) *WellKnownController {
controller := &WellKnownController{ controller := &WellKnownController{
oidc: i.OIDCService, oidc: oidc,
} }
i.RouterGroup.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration) router.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
i.RouterGroup.GET("/.well-known/jwks.json", controller.JWKS) router.GET("/.well-known/jwks.json", controller.JWKS)
i.RouterGroup.GET("/.well-known/webfinger", controller.WebFinger)
return controller return controller
} }
@@ -121,62 +97,3 @@ func (controller *WellKnownController) JWKS(c *gin.Context) {
c.Status(http.StatusOK) c.Status(http.StatusOK)
} }
func (controller *WellKnownController) WebFinger(c *gin.Context) {
c.Header("Content-Type", "application/jrd+json")
c.Header("Access-Control-Allow-Origin", "*")
resource := c.Query("resource")
if !controller.validateWebFingerResource(resource) {
c.JSON(400, gin.H{
"status": 400,
"message": "invalid resource",
})
return
}
res := WebfingerResponse{
Subject: resource,
Links: []WebfingerResponseLink{},
}
rel := c.Request.URL.Query()["rel"]
if controller.oidc != nil && (len(rel) == 0 || slices.Contains(rel, OpenIDConnectRel)) {
res.Links = append(res.Links, WebfingerResponseLink{Rel: OpenIDConnectRel, Href: controller.oidc.GetIssuer()})
}
c.JSON(200, res)
}
func (controller *WellKnownController) validateWebFingerResource(resource string) bool {
prefix, suffix, found := strings.Cut(resource, ":")
if !found {
return false
}
switch prefix {
case "acct":
if strings.Count(suffix, "@") != 1 {
return false
}
username, domain, found := strings.Cut(suffix, "@")
if !found || username == "" || domain == "" {
return false
}
case "https", "http":
u, err := url.Parse(resource)
if err != nil {
return false
}
if u.Host == "" {
return false
}
default:
return false
}
return true
}
+13 -215
View File
@@ -1,17 +1,17 @@
package controller package controller_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http/httptest" "net/http/httptest"
"net/url" "sync"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/test"
@@ -26,25 +26,23 @@ func TestWellKnownController(t *testing.T) {
type testCase struct { type testCase struct {
description string description string
oidcEnabled bool
run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder)
} }
tests := []testCase{ tests := []testCase{
{ {
description: "Ensure well-known endpoint returns correct OIDC configuration", description: "Ensure well-known endpoint returns correct OIDC configuration",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/.well-known/openid-configuration", nil) req := httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code) assert.Equal(t, 200, recorder.Code)
res := OpenIDConnectConfiguration{} res := controller.OpenIDConnectConfiguration{}
err := json.Unmarshal(recorder.Body.Bytes(), &res) err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
expected := OpenIDConnectConfiguration{ expected := controller.OpenIDConnectConfiguration{
Issuer: runtime.AppURL, Issuer: runtime.AppURL,
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", runtime.AppURL), AuthorizationEndpoint: fmt.Sprintf("%s/authorize", runtime.AppURL),
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", runtime.AppURL), TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", runtime.AppURL),
@@ -58,8 +56,8 @@ func TestWellKnownController(t *testing.T) {
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"}, ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"},
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc", ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
RequestObjectSigningAlgValuesSupported: []string{"none"},
RequestParameterSupported: true, RequestParameterSupported: true,
RequestObjectSigningAlgValuesSupported: []string{"none"},
} }
assert.Equal(t, expected, res) assert.Equal(t, expected, res)
@@ -67,7 +65,6 @@ func TestWellKnownController(t *testing.T) {
}, },
{ {
description: "Ensure well-known endpoint returns correct JWKS", description: "Ensure well-known endpoint returns correct JWKS",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/.well-known/jwks.json", nil) req := httptest.NewRequest("GET", "/.well-known/jwks.json", nil)
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
@@ -76,218 +73,27 @@ func TestWellKnownController(t *testing.T) {
decodedBody := make(map[string]any) decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody) err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err) assert.NoError(t, err)
keys, ok := decodedBody["keys"].([]any) keys, ok := decodedBody["keys"].([]any)
require.True(t, ok) assert.True(t, ok)
assert.Len(t, keys, 1) assert.Len(t, keys, 1)
keyData, ok := keys[0].(map[string]any) keyData, ok := keys[0].(map[string]any)
require.True(t, ok) assert.True(t, ok)
assert.Equal(t, "RSA", keyData["kty"]) assert.Equal(t, "RSA", keyData["kty"])
assert.Equal(t, "sig", keyData["use"]) assert.Equal(t, "sig", keyData["use"])
assert.Equal(t, "RS256", keyData["alg"]) assert.Equal(t, "RS256", keyData["alg"])
}, },
}, },
{
description: "Ensure openid configuration returns 500 on nil oidc service",
oidcEnabled: false,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 500, recorder.Code)
decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err)
assert.Equal(t, "OIDC service not configured", decodedBody["message"])
},
},
{
description: "Ensure jwks endpoint returns 500 on nil oidc service",
oidcEnabled: false,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/.well-known/jwks.json", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 500, recorder.Code)
decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err)
assert.Equal(t, "OIDC service not configured", decodedBody["message"])
},
},
{
description: "Ensure webfinger returns 400 on invalid resource",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
req := httptest.NewRequest("GET", "/.well-known/webfinger?resource=invalid-resource", nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 400, recorder.Code)
assert.Equal(t, "application/jrd+json", recorder.Header().Get("content-type"))
assert.Equal(t, "*", recorder.Header().Get("access-control-allow-origin"))
decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err)
assert.Equal(t, "invalid resource", decodedBody["message"])
},
},
{
description: "Ensure webfinger resource validator allows acct",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
resource := "acct:testuser@example.com"
req := httptest.NewRequest("GET", fmt.Sprintf("/.well-known/webfinger?resource=%s", url.QueryEscape(resource)), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "application/jrd+json", recorder.Header().Get("content-type"))
assert.Equal(t, "*", recorder.Header().Get("access-control-allow-origin"))
},
},
{
description: "Ensure webfinger resource validator allows https",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
resource := "https://example.com/testuser"
req := httptest.NewRequest("GET", fmt.Sprintf("/.well-known/webfinger?resource=%s", url.QueryEscape(resource)), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "application/jrd+json", recorder.Header().Get("content-type"))
assert.Equal(t, "*", recorder.Header().Get("access-control-allow-origin"))
},
},
{
description: "Ensure webfinger resource validator allows http",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
resource := "http://example.com/testuser"
req := httptest.NewRequest("GET", fmt.Sprintf("/.well-known/webfinger?resource=%s", url.QueryEscape(resource)), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "application/jrd+json", recorder.Header().Get("content-type"))
assert.Equal(t, "*", recorder.Header().Get("access-control-allow-origin"))
},
},
{
description: "Webfinger should return no links when oidc is nil",
oidcEnabled: false,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
resource := "acct:testuser@example.com"
req := httptest.NewRequest("GET", fmt.Sprintf("/.well-known/webfinger?resource=%s", url.QueryEscape(resource)), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "application/jrd+json", recorder.Header().Get("content-type"))
assert.Equal(t, "*", recorder.Header().Get("access-control-allow-origin"))
decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err)
links, ok := decodedBody["links"].([]any)
require.True(t, ok)
assert.Len(t, links, 0)
},
},
{
description: "Webfinger should return links when oidc is configured and no rel is provided",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
resource := "acct:testuser@example.com"
req := httptest.NewRequest("GET", fmt.Sprintf("/.well-known/webfinger?resource=%s", url.QueryEscape(resource)), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "application/jrd+json", recorder.Header().Get("content-type"))
assert.Equal(t, "*", recorder.Header().Get("access-control-allow-origin"))
decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err)
links, ok := decodedBody["links"].([]any)
require.True(t, ok)
assert.Len(t, links, 1)
linkData, ok := links[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, "http://openid.net/specs/connect/1.0/issuer", linkData["rel"])
assert.Equal(t, runtime.AppURL, linkData["href"])
},
},
{
description: "Webfinger should return links when oidc is configured and rel is provided",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
resource := fmt.Sprintf("acct:%s@%s", "testuser", runtime.AppURL)
rel := "http://openid.net/specs/connect/1.0/issuer"
req := httptest.NewRequest("GET", fmt.Sprintf("/.well-known/webfinger?resource=%s&rel=%s", url.QueryEscape(resource), url.QueryEscape(rel)), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "application/jrd+json", recorder.Header().Get("content-type"))
assert.Equal(t, "*", recorder.Header().Get("access-control-allow-origin"))
decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err)
links, ok := decodedBody["links"].([]any)
require.True(t, ok)
assert.Len(t, links, 1)
linkData, ok := links[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, rel, linkData["rel"])
assert.Equal(t, runtime.AppURL, linkData["href"])
},
},
{
description: "Webfinger should return no links when oidc is configured and rel is provided but does not match",
oidcEnabled: true,
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
resource := "acct:testuser@example.com"
rel := "http://example.com/does-not-exist"
req := httptest.NewRequest("GET", fmt.Sprintf("/.well-known/webfinger?resource=%s&rel=%s", url.QueryEscape(resource), url.QueryEscape(rel)), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code)
assert.Equal(t, "application/jrd+json", recorder.Header().Get("content-type"))
assert.Equal(t, "*", recorder.Header().Get("access-control-allow-origin"))
decodedBody := make(map[string]any)
err := json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err)
links, ok := decodedBody["links"].([]any)
require.True(t, ok)
assert.Len(t, links, 0)
},
},
} }
ctx := context.TODO() ctx := context.TODO()
dg := ding.New(ctx) wg := &sync.WaitGroup{}
store := memory.New() store := memory.New()
oidcService, err := service.NewOIDCService(service.OIDCServiceInput{ oidcService, err := service.NewOIDCService(log, cfg, runtime, store, ctx, wg)
Log: log,
Config: &cfg,
Runtime: &runtime,
Queries: store,
Ding: dg,
})
require.NoError(t, err) require.NoError(t, err)
for _, test := range tests { for _, test := range tests {
@@ -297,15 +103,7 @@ func TestWellKnownController(t *testing.T) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
wellKnownControllerInput := WellKnownControllerInput{ controller.NewWellKnownController(oidcService, &router.RouterGroup)
RouterGroup: &router.RouterGroup,
}
if test.oidcEnabled {
wellKnownControllerInput.OIDCService = oidcService
}
NewWellKnownController(wellKnownControllerInput)
test.run(t, router, recorder) test.run(t, router, recorder)
}) })
+17 -92
View File
@@ -11,7 +11,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -37,30 +36,23 @@ var (
) )
type ContextMiddleware struct { type ContextMiddleware struct {
log *logger.Logger log *logger.Logger
runtime *model.RuntimeConfig runtime model.RuntimeConfig
auth *service.AuthService auth *service.AuthService
broker *service.OAuthBrokerService broker *service.OAuthBrokerService
tailscale *service.TailscaleService
} }
type ContextMiddlewareInput struct { func NewContextMiddleware(
dig.In log *logger.Logger,
runtime model.RuntimeConfig,
Log *logger.Logger auth *service.AuthService,
RuntimeConfig *model.RuntimeConfig broker *service.OAuthBrokerService,
AuthService *service.AuthService ) *ContextMiddleware {
BrokerService *service.OAuthBrokerService
TailscaleService *service.TailscaleService
}
func NewContextMiddleware(i ContextMiddlewareInput) *ContextMiddleware {
return &ContextMiddleware{ return &ContextMiddleware{
log: i.Log, log: log,
runtime: i.RuntimeConfig, runtime: runtime,
auth: i.AuthService, auth: auth,
broker: i.BrokerService, broker: broker,
tailscale: i.TailscaleService,
} }
} }
@@ -74,7 +66,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
uuid, err := c.Cookie(m.runtime.SessionCookieName) uuid, err := c.Cookie(m.runtime.SessionCookieName)
if err == nil { if err == nil {
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.ClientIP()) userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid)
if err == nil { if err == nil {
if cookie != nil { if cookie != nil {
@@ -110,28 +102,11 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
return return
} }
// Lastly check if we have a tailscale session to add
if m.tailscale != nil {
tailscaleContext, err := m.tailscaleWhois(c.ClientIP())
if err != nil {
m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", c.ClientIP(), err)
}
if tailscaleContext != nil {
c.Set("context", &model.UserContext{
Authenticated: false,
Provider: model.ProviderTailscale,
Tailscale: tailscaleContext,
})
}
}
c.Next() c.Next()
} }
} }
func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip string) (*model.UserContext, *http.Cookie, error) { func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model.UserContext, *http.Cookie, error) {
session, err := m.auth.GetSession(ctx, uuid) session, err := m.auth.GetSession(ctx, uuid)
if err != nil { if err != nil {
@@ -166,22 +141,6 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
if userContext.Local.Attributes.Email == "" { if userContext.Local.Attributes.Email == "" {
userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain) userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain)
} }
case model.ProviderTailscale:
tailscaleContext, err := m.tailscaleWhois(ip)
if err != nil {
return nil, nil, fmt.Errorf("error performing tailscale whois: %w", err)
}
if tailscaleContext == nil {
return nil, nil, fmt.Errorf("tailscale whois returned no result for IP: %s", ip)
}
if tailscaleContext.Email != userContext.Tailscale.Email {
return nil, nil, fmt.Errorf("device owner mismatch, login again")
}
userContext.Tailscale = tailscaleContext
case model.ProviderLDAP: case model.ProviderLDAP:
search, err := m.auth.SearchUser(userContext.LDAP.Username) search, err := m.auth.SearchUser(userContext.LDAP.Username)
@@ -214,7 +173,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID) return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID)
} }
if !m.auth.IsEmailWhitelisted(userContext.OAuth.ID, userContext.OAuth.Email) { if !m.auth.IsEmailWhitelisted(userContext.OAuth.Email) {
m.auth.DeleteSession(ctx, uuid) m.auth.DeleteSession(ctx, uuid)
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email) return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
} }
@@ -260,10 +219,6 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
case model.UserLocal: case model.UserLocal:
user := m.auth.GetLocalUser(username) user := m.auth.GetLocalUser(username)
if user == nil {
return nil, nil, fmt.Errorf("user not found locally: %s", username)
}
if user.TOTPSecret != "" { if user.TOTPSecret != "" {
return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", username) return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", username)
} }
@@ -311,33 +266,3 @@ func (m *ContextMiddleware) isIgnorePath(path string) bool {
} }
return false return false
} }
func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext, error) {
if m.tailscale == nil {
return nil, nil
}
whois, err := m.tailscale.Whois(ip)
if err != nil {
m.log.App.Error().Err(err).Msgf("Error performing Tailscale whois for IP %s: %v", ip, err)
return nil, err
}
if whois == nil {
return nil, nil
}
username := strings.Replace(whois.LoginName, "@", "_", 1)
uctx := model.TailscaleContext{
BaseContext: model.BaseContext{
Username: username,
Email: whois.LoginName,
Name: whois.DisplayName,
},
NodeName: whois.NodeName,
}
return &uctx, nil
}
+8 -34
View File
@@ -1,17 +1,18 @@
package middleware package middleware_test
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/middleware"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
@@ -249,44 +250,17 @@ func TestContextMiddleware(t *testing.T) {
} }
ctx := context.TODO() ctx := context.TODO()
dg := ding.New(ctx) wg := &sync.WaitGroup{}
store := memory.New() store := memory.New()
policyEngine, err := service.NewPolicyEngine(service.PolicyEngineInput{ broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
Log: log, authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker)
Config: &cfg,
})
require.NoError(t, err)
broker := service.NewOAuthBrokerService(service.OAuthBrokerServiceInput{ contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker)
Log: log,
Runtime: &runtime,
Ctx: ctx,
})
authService := service.NewAuthService(service.AuthServiceInput{
Log: log,
Config: &cfg,
Runtime: &runtime,
Ctx: ctx,
Ding: dg,
LDAP: nil,
Queries: store,
OAuthBroker: broker,
Tailscale: nil,
PolicyEngine: policyEngine,
})
contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{
Log: log,
RuntimeConfig: &runtime,
AuthService: authService,
BrokerService: broker,
TailscaleService: nil,
})
for _, test := range tests { for _, test := range tests {
authService.ClearLoginAttempts() authService.ClearRateLimitsTestingOnly()
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
+2 -8
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
"github.com/tinyauthapp/tinyauth/internal/assets" "github.com/tinyauthapp/tinyauth/internal/assets"
"go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -19,12 +18,7 @@ type UIMiddleware struct {
uiFileServer http.Handler uiFileServer http.Handler
} }
// for future use if we need to inject dependencies into the middleware func NewUIMiddleware() (*UIMiddleware, error) {
type UIMiddlewareInput struct {
dig.In
}
func NewUIMiddleware(_ UIMiddlewareInput) (*UIMiddleware, error) {
m := &UIMiddleware{} m := &UIMiddleware{}
ui, err := fs.Sub(assets.FrontendAssets, "dist") ui, err := fs.Sub(assets.FrontendAssets, "dist")
@@ -44,7 +38,7 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc {
path := strings.TrimPrefix(c.Request.URL.Path, "/") path := strings.TrimPrefix(c.Request.URL.Path, "/")
switch strings.SplitN(path, "/", 2)[0] { switch strings.SplitN(path, "/", 2)[0] {
case "api", "resources", ".well-known", "authorize": case "api", "resources", ".well-known":
c.Next() c.Next()
return return
case "robots.txt": case "robots.txt":
+2 -9
View File
@@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"go.uber.org/dig"
) )
// See context middleware for explanation of why we have to do this // See context middleware for explanation of why we have to do this
@@ -22,15 +21,9 @@ type ZerologMiddleware struct {
log *logger.Logger log *logger.Logger
} }
type ZerologMiddlewareInput struct { func NewZerologMiddleware(log *logger.Logger) *ZerologMiddleware {
dig.In
Log *logger.Logger
}
func NewZerologMiddleware(i ZerologMiddlewareInput) *ZerologMiddleware {
return &ZerologMiddleware{ return &ZerologMiddleware{
log: i.Log, log: log,
} }
} }
+133 -187
View File
@@ -1,30 +1,8 @@
package model package model
import (
"os"
"time"
)
type RuntimeEnv int
const (
RuntimeEnvUnknown RuntimeEnv = iota
RuntimeEnvDocker
)
func DetectRuntimeEnv() RuntimeEnv {
env := os.Getenv("RUNTIME_ENV")
switch env {
case "docker":
return RuntimeEnvDocker
default:
return RuntimeEnvUnknown
}
}
// Default configuration // Default configuration
func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config { func NewDefaultConfiguration() *Config {
cfg := &Config{ return &Config{
Database: DatabaseConfig{ Database: DatabaseConfig{
Driver: "sqlite", Driver: "sqlite",
Path: "./tinyauth.db", Path: "./tinyauth.db",
@@ -37,8 +15,9 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
Path: "./resources", Path: "./resources",
}, },
Server: ServerConfig{ Server: ServerConfig{
Port: 3000, Port: 3000,
Address: "0.0.0.0", Address: "0.0.0.0",
ConcurrentListenersEnabled: false,
}, },
Auth: AuthConfig{ Auth: AuthConfig{
SubdomainsEnabled: true, SubdomainsEnabled: true,
@@ -46,10 +25,6 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
SessionMaxLifetime: 0, // disabled SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, // 5 minutes LoginTimeout: 300, // 5 minutes
LoginMaxRetries: 3, LoginMaxRetries: 3,
ACLs: ACLsConfig{
Policy: "allow",
},
LockdownEnabled: true,
}, },
UI: UIConfig{ UI: UIConfig{
Title: "Tinyauth", Title: "Tinyauth",
@@ -84,253 +59,224 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
PrivateKeyPath: "./tinyauth_oidc_key", PrivateKeyPath: "./tinyauth_oidc_key",
PublicKeyPath: "./tinyauth_oidc_key.pub", PublicKeyPath: "./tinyauth_oidc_key.pub",
}, },
Tailscale: TailscaleConfig{ Experimental: ExperimentalConfig{
CacheDuration: int(time.Duration(5 * time.Minute).Seconds()), ConfigFile: "",
}, },
LabelProvider: "auto", LabelProvider: "auto",
} }
// apply path overrides for docker runtime
if runtimeEnv == RuntimeEnvDocker {
cfg.Database.Path = "/data/tinyauth.db"
cfg.Resources.Path = "/data/resources"
cfg.OIDC.PrivateKeyPath = "/data/oidc/key.pem"
cfg.OIDC.PublicKeyPath = "/data/oidc/key.pub"
}
return cfg
} }
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"`
Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"` Database DatabaseConfig `description:"Database configuration." yaml:"database"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"` Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"` Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"`
Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"` Server ServerConfig `description:"Server configuration." yaml:"server"`
Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"` Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"` Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"` OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"` OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"` UI UIConfig `description:"UI customization." yaml:"ui"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"` LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"` Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"` LabelProvider string `description:"Label provider to use for ACLs (auto, docker, or kubernetes). auto detects the environment." yaml:"labelProvider"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"` Log LogConfig `description:"Logging configuration." yaml:"log"`
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
ConfigFile string `description:"Path to config file." yaml:"-"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
Driver string `description:"The database driver to use. Valid values: sqlite, postgres, memory." yaml:"driver,omitempty"` Driver string `description:"The database driver to use. Valid values: sqlite, memory." yaml:"driver"`
Path string `description:"The path to the SQLite database file, or connection URL when driver is postgres." yaml:"path,omitempty"` Path string `description:"The path to the SQLite database, including file name. Only used when driver is sqlite." yaml:"path"`
} }
type AnalyticsConfig struct { type AnalyticsConfig struct {
Enabled bool `description:"Enable periodic version information collection." yaml:"enabled,omitempty"` Enabled bool `description:"Enable periodic version information collection." yaml:"enabled"`
} }
type ResourcesConfig struct { type ResourcesConfig struct {
Enabled bool `description:"Enable the resources server." yaml:"enabled,omitempty"` Enabled bool `description:"Enable the resources server." yaml:"enabled"`
Path string `description:"The directory where resources are stored." yaml:"path,omitempty"` Path string `description:"The directory where resources are stored." yaml:"path"`
} }
type ServerConfig struct { type ServerConfig struct {
Port int `description:"The port on which the server listens." yaml:"port,omitempty"` Port int `description:"The port on which the server listens." yaml:"port"`
Address string `description:"The address on which the server listens." yaml:"address,omitempty"` Address string `description:"The address on which the server listens." yaml:"address"`
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath,omitempty"` SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
ConcurrentListenersEnabled bool `description:"Enable listening on both TCP and Unix socket at the same time." yaml:"concurrentListenersEnabled"`
} }
type AuthConfig struct { type AuthConfig struct {
IP IPConfig `description:"IP whitelisting config options." yaml:"ip,omitempty"` IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users,omitempty"` Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled,omitempty"` SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled"`
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes,omitempty"` UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"`
UsersFile string `description:"Path to the users file." yaml:"usersFile,omitempty"` UsersFile string `description:"Path to the users file." yaml:"usersFile"`
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie,omitempty"` SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry,omitempty"` SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"` SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
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"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"`
} }
type UserAttributes struct { type UserAttributes struct {
Name string `description:"Full name of the user." yaml:"name,omitempty"` Name string `description:"Full name of the user." yaml:"name"`
GivenName string `description:"Given (first) name of the user." yaml:"givenName,omitempty"` GivenName string `description:"Given (first) name of the user." yaml:"givenName"`
FamilyName string `description:"Family (last) name of the user." yaml:"familyName,omitempty"` FamilyName string `description:"Family (last) name of the user." yaml:"familyName"`
MiddleName string `description:"Middle name of the user." yaml:"middleName,omitempty"` MiddleName string `description:"Middle name of the user." yaml:"middleName"`
Nickname string `description:"Nickname of the user." yaml:"nickname,omitempty"` Nickname string `description:"Nickname of the user." yaml:"nickname"`
Profile string `description:"URL of the user's profile page." yaml:"profile,omitempty"` Profile string `description:"URL of the user's profile page." yaml:"profile"`
Picture string `description:"URL of the user's profile picture." yaml:"picture,omitempty"` Picture string `description:"URL of the user's profile picture." yaml:"picture"`
Website string `description:"URL of the user's website." yaml:"website,omitempty"` Website string `description:"URL of the user's website." yaml:"website"`
Email string `description:"Email address of the user." yaml:"email,omitempty"` Email string `description:"Email address of the user." yaml:"email"`
Gender string `description:"Gender of the user." yaml:"gender,omitempty"` Gender string `description:"Gender of the user." yaml:"gender"`
Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate,omitempty"` Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate"`
Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo,omitempty"` Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo"`
Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale,omitempty"` Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"`
PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber,omitempty"` PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"`
Address AddressClaim `description:"Address of the user." yaml:"address,omitempty"` Address AddressClaim `description:"Address of the user." yaml:"address"`
} }
type AddressClaim struct { type AddressClaim struct {
Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted,omitempty" json:"formatted,omitempty"` Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"`
StreetAddress string `description:"Street address." yaml:"streetAddress,omitempty" json:"street_address,omitempty"` StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"`
Locality string `description:"City or locality." yaml:"locality,omitempty" json:"locality,omitempty"` Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"`
Region string `description:"State, province, or region." yaml:"region,omitempty" json:"region,omitempty"` Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"`
PostalCode string `description:"Zip or postal code." yaml:"postalCode,omitempty" json:"postal_code,omitempty"` PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"`
Country string `description:"Country." yaml:"country,omitempty" json:"country,omitempty"` Country string `description:"Country." yaml:"country" json:"country,omitempty"`
} }
type IPConfig struct { type IPConfig struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow,omitempty"` Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block,omitempty"` Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass,omitempty"`
} }
type OAuthConfig struct { type OAuthConfig struct {
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist,omitempty"` Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile,omitempty"` WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"`
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect,omitempty"` AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers,omitempty"` Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
} }
type OIDCConfig struct { type OIDCConfig struct {
PrivateKeyPath string `description:"Path to the private key file, including file name." yaml:"privateKeyPath,omitempty"` PrivateKeyPath string `description:"Path to the private key file, including file name." yaml:"privateKeyPath"`
PublicKeyPath string `description:"Path to the public key file, including file name." yaml:"publicKeyPath,omitempty"` PublicKeyPath string `description:"Path to the public key file, including file name." yaml:"publicKeyPath"`
Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients,omitempty"` Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients"`
} }
type UIConfig struct { type UIConfig struct {
Title string `description:"The title of the UI." yaml:"title,omitempty"` Title string `description:"The title of the UI." yaml:"title"`
ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage,omitempty"` ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage"`
BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage,omitempty"` BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage"`
WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled,omitempty"` WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled"`
} }
type LDAPConfig struct { type LDAPConfig struct {
Address string `description:"LDAP server address." yaml:"address,omitempty"` Address string `description:"LDAP server address." yaml:"address"`
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn,omitempty"` BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword,omitempty"` BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile,omitempty"` BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn,omitempty"` Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure,omitempty"` SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter,omitempty"` AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"`
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert,omitempty"` AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"`
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey,omitempty"` GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"`
GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL,omitempty"`
} }
type LogConfig struct { type LogConfig struct {
Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level,omitempty"` Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level"`
Json bool `description:"Enable JSON formatted logs." yaml:"json,omitempty"` Json bool `description:"Enable JSON formatted logs." yaml:"json"`
Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams,omitempty"` Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams"`
} }
type LogStreams struct { type LogStreams struct {
HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http,omitempty"` HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http"`
App LogStreamConfig `description:"Application logging." yaml:"app,omitempty"` App LogStreamConfig `description:"Application logging." yaml:"app"`
Audit LogStreamConfig `description:"Audit logging." yaml:"audit,omitempty"` Audit LogStreamConfig `description:"Audit logging." yaml:"audit"`
} }
type LogStreamConfig struct { type LogStreamConfig struct {
Enabled bool `description:"Enable this log stream." yaml:"enabled,omitempty"` Enabled bool `description:"Enable this log stream." yaml:"enabled"`
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"`
} }
type ExperimentalConfig struct{} type ExperimentalConfig struct {
ConfigFile string `description:"Path to config file." yaml:"-"`
type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"`
APIToken string `description:"Tailscale API token." yaml:"apiToken,omitempty"`
APITokenFile string `description:"Path to the file containing the Tailscale API token." yaml:"apiTokenFile,omitempty"`
Tailnet string `description:"Tailnet name." yaml:"tailnet,omitempty"`
CacheDuration int `description:"Cache duration for Tailscale device and user lists in seconds." yaml:"cacheDuration,omitempty"`
} }
// OAuth/OIDC config
type OAuthServiceConfig struct { type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID." yaml:"clientId,omitempty"` ClientID string `description:"OAuth client ID." yaml:"clientId"`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret,omitempty"` ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"`
ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile,omitempty"` ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile"`
Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist,omitempty"` Scopes []string `description:"OAuth scopes." yaml:"scopes"`
WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"` RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl"`
Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"` AuthURL string `description:"OAuth authorization URL." yaml:"authUrl"`
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"` TokenURL string `description:"OAuth token URL." yaml:"tokenUrl"`
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"` UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl"`
TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"` Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure"`
UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"` Name string `description:"Provider name in UI." yaml:"name"`
Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"`
Name string `description:"Provider name in UI." yaml:"name,omitempty"`
} }
type OIDCClientConfig struct { type OIDCClientConfig struct {
ID string `description:"OIDC client ID." yaml:"-"` ID string `description:"OIDC client ID." yaml:"-"`
ClientID string `description:"OIDC client ID." yaml:"clientId,omitempty"` ClientID string `description:"OIDC client ID." yaml:"clientId"`
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret,omitempty"` ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"`
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile,omitempty"` ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"`
TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris,omitempty"` TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris"`
Name string `description:"Client name in UI." yaml:"name,omitempty"` Name string `description:"Client name in UI." yaml:"name"`
}
type ACLsConfig struct {
Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy,omitempty"`
} }
// ACLs // ACLs
type Apps struct { type Apps struct {
Apps map[string]App `description:"App ACLs configuration." yaml:"apps,omitempty"` Apps map[string]App `description:"App ACLs configuration." yaml:"apps"`
} }
type App struct { type App struct {
Config AppConfig `description:"App configuration." yaml:"config,omitempty"` Config AppConfig `description:"App configuration." yaml:"config"`
Users AppUsers `description:"User access configuration." yaml:"users,omitempty"` Users AppUsers `description:"User access configuration." yaml:"users"`
OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth,omitempty"` OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth"`
IP AppIP `description:"IP access configuration." yaml:"ip,omitempty"` IP AppIP `description:"IP access configuration." yaml:"ip"`
Response AppResponse `description:"Response customization." yaml:"response,omitempty"` Response AppResponse `description:"Response customization." yaml:"response"`
Path AppPath `description:"Path access configuration." yaml:"path,omitempty"` Path AppPath `description:"Path access configuration." yaml:"path"`
LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap,omitempty"` LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap"`
} }
type AppConfig struct { type AppConfig struct {
Domain string `description:"The domain of the app." yaml:"domain,omitempty"` Domain string `description:"The domain of the app." yaml:"domain"`
} }
type AppUsers struct { type AppUsers struct {
Allow string `description:"Comma-separated list of allowed users." yaml:"allow,omitempty"` Allow string `description:"Comma-separated list of allowed users." yaml:"allow"`
Block string `description:"Comma-separated list of blocked users." yaml:"block,omitempty"` Block string `description:"Comma-separated list of blocked users." yaml:"block"`
} }
type AppOAuth struct { type AppOAuth struct {
Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist,omitempty"` Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist"`
Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups,omitempty"` Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"`
} }
type AppLDAP struct { type AppLDAP struct {
Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups,omitempty"` Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups"`
} }
type AppIP struct { type AppIP struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow,omitempty"` Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block,omitempty"` Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass,omitempty"` Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass"`
} }
type AppResponse struct { type AppResponse struct {
Headers []string `description:"Custom headers to add to the response." yaml:"headers,omitempty"` Headers []string `description:"Custom headers to add to the response." yaml:"headers"`
BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth,omitempty"` BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth"`
} }
type AppBasicAuth struct { type AppBasicAuth struct {
Username string `description:"Basic auth username." yaml:"username,omitempty"` Username string `description:"Basic auth username." yaml:"username"`
Password string `description:"Basic auth password." yaml:"password,omitempty"` Password string `description:"Basic auth password." yaml:"password"`
PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile,omitempty"` PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile"`
} }
type AppPath struct { type AppPath struct {
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow,omitempty"` Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"`
Block string `description:"Comma-separated list of blocked paths." yaml:"block,omitempty"` Block string `description:"Comma-separated list of blocked paths." yaml:"block"`
} }
-4
View File
@@ -17,11 +17,7 @@ var OverrideProviders = map[string]string{
"github": "GitHub", "github": "GitHub",
} }
var ReservedProviderNames = []string{"local", "ldap", "tailscale"}
const SessionCookieName = "tinyauth-session" const SessionCookieName = "tinyauth-session"
const CSRFCookieName = "tinyauth-csrf" const CSRFCookieName = "tinyauth-csrf"
const RedirectCookieName = "tinyauth-redirect" const RedirectCookieName = "tinyauth-redirect"
const OAuthSessionCookieName = "tinyauth-oauth" const OAuthSessionCookieName = "tinyauth-oauth"
const GracefulShutdownTimeout = 5 // seconds
+58 -59
View File
@@ -19,17 +19,14 @@ const (
ProviderBasicAuth ProviderBasicAuth
ProviderOAuth ProviderOAuth
ProviderLDAP ProviderLDAP
ProviderTailscale
) )
type UserContext struct { type UserContext struct {
Authenticated bool Authenticated bool
Provider ProviderType Provider ProviderType
AuthTime int64
Local *LocalContext Local *LocalContext
OAuth *OAuthContext OAuth *OAuthContext
LDAP *LDAPContext LDAP *LDAPContext
Tailscale *TailscaleContext
} }
type BaseContext struct { type BaseContext struct {
@@ -57,11 +54,6 @@ type LDAPContext struct {
Groups []string Groups []string
} }
type TailscaleContext struct {
BaseContext
NodeName string
}
func (c *UserContext) IsAuthenticated() bool { func (c *UserContext) IsAuthenticated() bool {
return c.Authenticated return c.Authenticated
} }
@@ -82,10 +74,6 @@ func (c *UserContext) IsBasicAuth() bool {
return c.Provider == ProviderBasicAuth && c.Local != nil return c.Provider == ProviderBasicAuth && c.Local != nil
} }
func (c *UserContext) IsTailscale() bool {
return c.Provider == ProviderTailscale && c.Tailscale != nil
}
func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) { func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
userContextValue, exists := ginctx.Get("context") userContextValue, exists := ginctx.Get("context")
@@ -99,7 +87,7 @@ func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
return nil, errors.New("invalid user context type") return nil, errors.New("invalid user context type")
} }
if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil && userContext.Tailscale == nil { if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil {
return nil, errors.New("incomplete user context") return nil, errors.New("incomplete user context")
} }
@@ -111,7 +99,6 @@ func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) { func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) {
*c = UserContext{ *c = UserContext{
Authenticated: !session.TotpPending, Authenticated: !session.TotpPending,
AuthTime: session.CreatedAt,
} }
switch session.Provider { switch session.Provider {
@@ -134,15 +121,6 @@ func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext,
Email: session.Email, Email: session.Email,
}, },
} }
case "tailscale":
c.Provider = ProviderTailscale
c.Tailscale = &TailscaleContext{
BaseContext: BaseContext{
Username: session.Username,
Name: session.Name,
Email: session.Email,
},
}
// By default we assume an unknown name which is oauth // By default we assume an unknown name which is oauth
default: default:
c.Provider = ProviderOAuth c.Provider = ProviderOAuth
@@ -167,55 +145,85 @@ func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext,
return c, nil return c, nil
} }
func (c *UserContext) getBaseContext() *BaseContext { func (c *UserContext) GetUsername() string {
switch c.Provider { switch c.Provider {
case ProviderLocal, ProviderBasicAuth: case ProviderLocal:
if c.Local == nil { if c.Local == nil {
return nil return ""
} }
return &c.Local.BaseContext return c.Local.Username
case ProviderLDAP: case ProviderLDAP:
if c.LDAP == nil { if c.LDAP == nil {
return nil return ""
} }
return &c.LDAP.BaseContext return c.LDAP.Username
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Username
case ProviderOAuth: case ProviderOAuth:
if c.OAuth == nil { if c.OAuth == nil {
return nil return ""
} }
return &c.OAuth.BaseContext return c.OAuth.Username
case ProviderTailscale:
if c.Tailscale == nil {
return nil
}
return &c.Tailscale.BaseContext
default: default:
return nil
}
}
func (c *UserContext) GetUsername() string {
base := c.getBaseContext()
if base == nil {
return "" return ""
} }
return base.Username
} }
func (c *UserContext) GetEmail() string { func (c *UserContext) GetEmail() string {
base := c.getBaseContext() switch c.Provider {
if base == nil { case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Email
case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Email
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Email
case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Email
default:
return "" return ""
} }
return base.Email
} }
func (c *UserContext) GetName() string { func (c *UserContext) GetName() string {
base := c.getBaseContext() switch c.Provider {
if base == nil { case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Name
case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Name
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Name
case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Name
default:
return "" return ""
} }
return base.Name
} }
func (c *UserContext) GetProviderID() string { func (c *UserContext) GetProviderID() string {
@@ -226,8 +234,6 @@ func (c *UserContext) GetProviderID() string {
return "ldap" return "ldap"
case ProviderOAuth: case ProviderOAuth:
return c.OAuth.ID return c.OAuth.ID
case ProviderTailscale:
return "tailscale"
default: default:
return "unknown" return "unknown"
} }
@@ -246,10 +252,3 @@ func (c *UserContext) OAuthName() string {
} }
return "" return ""
} }
func (c *UserContext) TailscaleNodeName() string {
if c.Tailscale != nil {
return c.Tailscale.NodeName
}
return ""
}
+82 -81
View File
@@ -1,4 +1,4 @@
package model package model_test
import ( import (
"net/http/httptest" "net/http/httptest"
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "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/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
) )
@@ -21,44 +22,44 @@ func TestContext(t *testing.T) {
tests := []struct { tests := []struct {
description string description string
context *UserContext context *model.UserContext
run func(*testing.T, *UserContext) any run func(*testing.T, *model.UserContext) any
expected any expected any
}{ }{
{ {
description: "IsAuthenticated reflects Authenticated field", description: "IsAuthenticated reflects Authenticated field",
context: &UserContext{Authenticated: true}, context: &model.UserContext{Authenticated: true},
run: func(t *testing.T, c *UserContext) any { return c.IsAuthenticated() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsAuthenticated() },
expected: true, expected: true,
}, },
{ {
description: "IsLocal returns true for ProviderLocal", description: "IsLocal returns true for ProviderLocal",
context: &UserContext{Provider: ProviderLocal, Local: &LocalContext{}}, context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}},
run: func(t *testing.T, c *UserContext) any { return c.IsLocal() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsLocal() },
expected: true, expected: true,
}, },
{ {
description: "IsOAuth returns true for ProviderOAuth", description: "IsOAuth returns true for ProviderOAuth",
context: &UserContext{Provider: ProviderOAuth, OAuth: &OAuthContext{}}, context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}},
run: func(t *testing.T, c *UserContext) any { return c.IsOAuth() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsOAuth() },
expected: true, expected: true,
}, },
{ {
description: "IsLDAP returns true for ProviderLDAP", description: "IsLDAP returns true for ProviderLDAP",
context: &UserContext{Provider: ProviderLDAP, LDAP: &LDAPContext{}}, context: &model.UserContext{Provider: model.ProviderLDAP, LDAP: &model.LDAPContext{}},
run: func(t *testing.T, c *UserContext) any { return c.IsLDAP() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsLDAP() },
expected: true, expected: true,
}, },
{ {
description: "IsBasicAuth returns true for ProviderBasicAuth", description: "IsBasicAuth returns true for ProviderBasicAuth",
context: &UserContext{Provider: ProviderBasicAuth, Local: &LocalContext{}}, context: &model.UserContext{Provider: model.ProviderBasicAuth, Local: &model.LocalContext{}},
run: func(t *testing.T, c *UserContext) any { return c.IsBasicAuth() }, run: func(t *testing.T, c *model.UserContext) any { return c.IsBasicAuth() },
expected: true, expected: true,
}, },
{ {
description: "NewFromSession local session is authenticated and ProviderLocal", description: "NewFromSession local session is authenticated and ProviderLocal",
context: &UserContext{}, context: &model.UserContext{},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
got, err := c.NewFromSession(&repository.Session{ got, err := c.NewFromSession(&repository.Session{
Username: "alice", Email: "alice@example.com", Name: "Alice", Username: "alice", Email: "alice@example.com", Name: "Alice",
Provider: "local", Provider: "local",
@@ -66,12 +67,12 @@ func TestContext(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
return [2]any{got.Provider, got.Authenticated} return [2]any{got.Provider, got.Authenticated}
}, },
expected: [2]any{ProviderLocal, true}, expected: [2]any{model.ProviderLocal, true},
}, },
{ {
description: "NewFromSession local session with TotpPending is not authenticated", description: "NewFromSession local session with TotpPending is not authenticated",
context: &UserContext{}, context: &model.UserContext{},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
got, err := c.NewFromSession(&repository.Session{ got, err := c.NewFromSession(&repository.Session{
Username: "bob", Provider: "local", TotpPending: true, Username: "bob", Provider: "local", TotpPending: true,
}) })
@@ -82,20 +83,20 @@ func TestContext(t *testing.T) {
}, },
{ {
description: "NewFromSession ldap session is ProviderLDAP", description: "NewFromSession ldap session is ProviderLDAP",
context: &UserContext{}, context: &model.UserContext{},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
got, err := c.NewFromSession(&repository.Session{ got, err := c.NewFromSession(&repository.Session{
Username: "carol", Provider: "ldap", Username: "carol", Provider: "ldap",
}) })
require.NoError(t, err) require.NoError(t, err)
return got.Provider return got.Provider
}, },
expected: ProviderLDAP, expected: model.ProviderLDAP,
}, },
{ {
description: "NewFromSession unknown provider defaults to OAuth and populates oauth fields", description: "NewFromSession unknown provider defaults to OAuth and populates oauth fields",
context: &UserContext{}, context: &model.UserContext{},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
got, err := c.NewFromSession(&repository.Session{ got, err := c.NewFromSession(&repository.Session{
Username: "dave", Provider: "github", Username: "dave", Provider: "github",
OAuthGroups: "devs,admins", OAuthSub: "sub-123", OAuthName: "GitHub", OAuthGroups: "devs,admins", OAuthSub: "sub-123", OAuthName: "GitHub",
@@ -103,126 +104,126 @@ func TestContext(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
return [5]any{got.Provider, got.OAuth.ID, got.OAuth.Sub, got.OAuth.DisplayName, got.OAuth.Groups} return [5]any{got.Provider, got.OAuth.ID, got.OAuth.Sub, got.OAuth.DisplayName, got.OAuth.Groups}
}, },
expected: [5]any{ProviderOAuth, "github", "sub-123", "GitHub", []string{"devs", "admins"}}, expected: [5]any{model.ProviderOAuth, "github", "sub-123", "GitHub", []string{"devs", "admins"}},
}, },
{ {
description: "Local getters return BaseContext fields", description: "Local getters return BaseContext fields",
context: &UserContext{ context: &model.UserContext{
Provider: ProviderLocal, Provider: model.ProviderLocal,
Local: &LocalContext{BaseContext: BaseContext{Username: "alice", Email: "alice@example.com", Name: "Alice"}}, Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice", Email: "alice@example.com", Name: "Alice"}},
}, },
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"alice", "alice@example.com", "Alice"}, expected: [3]string{"alice", "alice@example.com", "Alice"},
}, },
{ {
description: "BasicAuth getters fall back to local fields", description: "BasicAuth getters fall back to local fields",
context: &UserContext{ context: &model.UserContext{
Provider: ProviderBasicAuth, Provider: model.ProviderBasicAuth,
Local: &LocalContext{BaseContext: BaseContext{Username: "bob", Email: "bob@example.com", Name: "Bob"}}, Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "bob", Email: "bob@example.com", Name: "Bob"}},
}, },
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"bob", "bob@example.com", "Bob"}, expected: [3]string{"bob", "bob@example.com", "Bob"},
}, },
{ {
description: "LDAP getters return LDAP fields", description: "LDAP getters return LDAP fields",
context: &UserContext{ context: &model.UserContext{
Provider: ProviderLDAP, Provider: model.ProviderLDAP,
LDAP: &LDAPContext{BaseContext: BaseContext{Username: "carol", Email: "carol@example.com", Name: "Carol"}}, LDAP: &model.LDAPContext{BaseContext: model.BaseContext{Username: "carol", Email: "carol@example.com", Name: "Carol"}},
}, },
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"carol", "carol@example.com", "Carol"}, expected: [3]string{"carol", "carol@example.com", "Carol"},
}, },
{ {
description: "OAuth getters return OAuth fields", description: "OAuth getters return OAuth fields",
context: &UserContext{ context: &model.UserContext{
Provider: ProviderOAuth, Provider: model.ProviderOAuth,
OAuth: &OAuthContext{BaseContext: BaseContext{Username: "dave", Email: "dave@example.com", Name: "Dave"}}, OAuth: &model.OAuthContext{BaseContext: model.BaseContext{Username: "dave", Email: "dave@example.com", Name: "Dave"}},
}, },
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"dave", "dave@example.com", "Dave"}, expected: [3]string{"dave", "dave@example.com", "Dave"},
}, },
{ {
description: "ProviderName returns 'local' for ProviderLocal", description: "ProviderName returns 'local' for ProviderLocal",
context: &UserContext{Provider: ProviderLocal}, context: &model.UserContext{Provider: model.ProviderLocal},
run: func(t *testing.T, c *UserContext) any { return c.GetProviderID() }, run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
expected: "local", expected: "local",
}, },
{ {
description: "ProviderName returns 'local' for ProviderBasicAuth", description: "ProviderName returns 'local' for ProviderBasicAuth",
context: &UserContext{Provider: ProviderBasicAuth}, context: &model.UserContext{Provider: model.ProviderBasicAuth},
run: func(t *testing.T, c *UserContext) any { return c.GetProviderID() }, run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
expected: "local", expected: "local",
}, },
{ {
description: "ProviderName returns 'ldap' for ProviderLDAP", description: "ProviderName returns 'ldap' for ProviderLDAP",
context: &UserContext{Provider: ProviderLDAP}, context: &model.UserContext{Provider: model.ProviderLDAP},
run: func(t *testing.T, c *UserContext) any { return c.GetProviderID() }, run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
expected: "ldap", expected: "ldap",
}, },
{ {
description: "ProviderName returns OAuth provider ID for ProviderOAuth", description: "ProviderName returns OAuth provider ID for ProviderOAuth",
context: &UserContext{ context: &model.UserContext{
Provider: ProviderOAuth, Provider: model.ProviderOAuth,
OAuth: &OAuthContext{ID: "github"}, OAuth: &model.OAuthContext{ID: "github"},
}, },
run: func(t *testing.T, c *UserContext) any { return c.GetProviderID() }, run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
expected: "github", expected: "github",
}, },
{ {
description: "TOTPPending returns true when local context is pending", description: "TOTPPending returns true when local context is pending",
context: &UserContext{ context: &model.UserContext{
Provider: ProviderLocal, Provider: model.ProviderLocal,
Local: &LocalContext{TOTPPending: true}, Local: &model.LocalContext{TOTPPending: true},
}, },
run: func(t *testing.T, c *UserContext) any { return c.TOTPPending() }, run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
expected: true, expected: true,
}, },
{ {
description: "TOTPPending returns false when local context is not pending", description: "TOTPPending returns false when local context is not pending",
context: &UserContext{ context: &model.UserContext{
Provider: ProviderLocal, Provider: model.ProviderLocal,
Local: &LocalContext{TOTPPending: false}, Local: &model.LocalContext{TOTPPending: false},
}, },
run: func(t *testing.T, c *UserContext) any { return c.TOTPPending() }, run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
expected: false, expected: false,
}, },
{ {
description: "TOTPPending returns false for non-local providers", description: "TOTPPending returns false for non-local providers",
context: &UserContext{Provider: ProviderOAuth, OAuth: &OAuthContext{}}, context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}},
run: func(t *testing.T, c *UserContext) any { return c.TOTPPending() }, run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
expected: false, expected: false,
}, },
{ {
description: "OAuthName returns DisplayName for ProviderOAuth", description: "OAuthName returns DisplayName for ProviderOAuth",
context: &UserContext{ context: &model.UserContext{
Provider: ProviderOAuth, Provider: model.ProviderOAuth,
OAuth: &OAuthContext{DisplayName: "Google"}, OAuth: &model.OAuthContext{DisplayName: "Google"},
}, },
run: func(t *testing.T, c *UserContext) any { return c.OAuthName() }, run: func(t *testing.T, c *model.UserContext) any { return c.OAuthName() },
expected: "Google", expected: "Google",
}, },
{ {
description: "OAuthName returns empty string for non-oauth providers", description: "OAuthName returns empty string for non-oauth providers",
context: &UserContext{Provider: ProviderLocal, Local: &LocalContext{}}, context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}},
run: func(t *testing.T, c *UserContext) any { return c.OAuthName() }, run: func(t *testing.T, c *model.UserContext) any { return c.OAuthName() },
expected: "", expected: "",
}, },
{ {
description: "NewFromGin populates context from gin value", description: "NewFromGin populates context from gin value",
context: &UserContext{}, context: &model.UserContext{},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
stored := &UserContext{ stored := &model.UserContext{
Authenticated: true, Authenticated: true,
Provider: ProviderLocal, Provider: model.ProviderLocal,
Local: &LocalContext{BaseContext: BaseContext{Username: "alice"}}, Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice"}},
} }
got, err := c.NewFromGin(newGinCtx(stored, true)) got, err := c.NewFromGin(newGinCtx(stored, true))
require.NoError(t, err) require.NoError(t, err)
@@ -232,17 +233,17 @@ func TestContext(t *testing.T) {
}, },
{ {
description: "NewFromGin returns error when context value is missing", description: "NewFromGin returns error when context value is missing",
context: &UserContext{}, context: &model.UserContext{},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
_, err := c.NewFromGin(newGinCtx(nil, false)) _, err := c.NewFromGin(newGinCtx(nil, false))
return err.Error() return err.Error()
}, },
expected: ErrUserContextNotFound.Error(), expected: model.ErrUserContextNotFound.Error(),
}, },
{ {
description: "NewFromGin returns error when context value has wrong type", description: "NewFromGin returns error when context value has wrong type",
context: &UserContext{}, context: &model.UserContext{},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
_, err := c.NewFromGin(newGinCtx("not a user context", true)) _, err := c.NewFromGin(newGinCtx("not a user context", true))
return err.Error() return err.Error()
}, },
@@ -250,17 +251,17 @@ func TestContext(t *testing.T) {
}, },
{ {
description: "NewFromGin returns an error when context doesn't include user information", description: "NewFromGin returns an error when context doesn't include user information",
context: &UserContext{}, context: &model.UserContext{},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
_, err := c.NewFromGin(newGinCtx(&UserContext{Provider: ProviderLocal}, true)) _, err := c.NewFromGin(newGinCtx(&model.UserContext{Provider: model.ProviderLocal}, true))
return err.Error() return err.Error()
}, },
expected: "incomplete user context", expected: "incomplete user context",
}, },
{ {
description: "Getters should not panic if provider context is empty", description: "Getters should not panic if provider context is empty",
context: &UserContext{Provider: ProviderLocal}, context: &model.UserContext{Provider: model.ProviderLocal},
run: func(t *testing.T, c *UserContext) any { run: func(t *testing.T, c *model.UserContext) any {
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()} return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
}, },
expected: [3]string{"", "", ""}, expected: [3]string{"", "", ""},
+1
View File
@@ -12,6 +12,7 @@ type RuntimeConfig struct {
OAuthProviders map[string]OAuthServiceConfig OAuthProviders map[string]OAuthServiceConfig
OAuthWhitelist []string OAuthWhitelist []string
ConfiguredProviders []Provider ConfiguredProviders []Provider
OIDCClients []OIDCClientConfig
} }
type Provider struct { type Provider struct {
+288 -104
View File
@@ -101,182 +101,366 @@ func TestMemoryStore(t *testing.T) {
}, },
}, },
{ {
description: "Create and get OIDC session", description: "Create and get OIDC code",
run: func(t *testing.T, s repository.Store) { run: func(t *testing.T, s repository.Store) {
sess, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ code, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{
Sub: "sub-1", Sub: "sub-1",
AccessTokenHash: "at-1", CodeHash: "hash-1",
RefreshTokenHash: "rt-1", Scope: "openid",
Scope: "openid",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "sub-1", sess.Sub) assert.Equal(t, "sub-1", code.Sub)
got, err := s.GetOIDCSessionBySub(ctx, "sub-1") // destructive read removes the record
got, err := s.GetOidcCode(ctx, "hash-1")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, sess, got) assert.Equal(t, code, got)
},
}, _, err = s.GetOidcCode(ctx, "hash-1")
{
description: "Get OIDC session by sub not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOIDCSessionBySub(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound) assert.ErrorIs(t, err, repository.ErrNotFound)
}, },
}, },
{ {
description: "Get OIDC session by access token hash", description: "Get OIDC code not found",
run: func(t *testing.T, s repository.Store) { run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ _, err := s.GetOidcCode(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
got, err := s.GetOidcCodeBySub(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
// destructive — gone after read
_, err = s.GetOidcCodeBySub(ctx, "sub-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code by sub not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcCodeBySub(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code unsafe",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
got, err := s.GetOidcCodeUnsafe(ctx, "hash-1")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
// non-destructive — still present
_, err = s.GetOidcCodeUnsafe(ctx, "hash-1")
assert.NoError(t, err)
},
},
{
description: "Get OIDC code unsafe not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcCodeUnsafe(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code by sub unsafe",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
got, err := s.GetOidcCodeBySubUnsafe(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, "hash-1", got.CodeHash)
// non-destructive — still present
_, err = s.GetOidcCodeBySubUnsafe(ctx, "sub-1")
assert.NoError(t, err)
},
},
{
description: "Get OIDC code by sub unsafe not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcCodeBySubUnsafe(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Create OIDC code unique sub constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
_, err = s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-2"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_codes.sub")
},
},
{
description: "Delete OIDC code",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcCode(ctx, "hash-1"))
_, err = s.GetOidcCodeUnsafe(ctx, "hash-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC code by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcCodeBySub(ctx, "sub-1"))
_, err = s.GetOidcCodeUnsafe(ctx, "hash-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete expired OIDC codes",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1", ExpiresAt: 10})
require.NoError(t, err)
_, err = s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-2", CodeHash: "hash-2", ExpiresAt: 100})
require.NoError(t, err)
deleted, err := s.DeleteExpiredOidcCodes(ctx, 50)
require.NoError(t, err)
require.Len(t, deleted, 1)
assert.Equal(t, "hash-1", deleted[0].CodeHash)
_, err = s.GetOidcCodeUnsafe(ctx, "hash-2")
assert.NoError(t, err)
},
},
{
description: "Create and get OIDC token",
run: func(t *testing.T, s repository.Store) {
tok, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1",
AccessTokenHash: "at-hash-1",
CodeHash: "code-hash-1",
})
require.NoError(t, err)
assert.Equal(t, "sub-1", tok.Sub)
got, err := s.GetOidcToken(ctx, "at-hash-1")
require.NoError(t, err)
assert.Equal(t, tok, got)
},
},
{
description: "Get OIDC token not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcToken(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Create OIDC token unique sub constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{Sub: "sub-1", AccessTokenHash: "at-1"})
require.NoError(t, err)
_, err = s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{Sub: "sub-1", AccessTokenHash: "at-2"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_tokens.sub")
},
},
{
description: "Get OIDC token by refresh token",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1", Sub: "sub-1",
AccessTokenHash: "at-1", AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1", RefreshTokenHash: "rt-1",
}) })
require.NoError(t, err) require.NoError(t, err)
got, err := s.GetOIDCSessionByAccessTokenHash(ctx, "at-1") got, err := s.GetOidcTokenByRefreshToken(ctx, "rt-1")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub) assert.Equal(t, "sub-1", got.Sub)
}, },
}, },
{ {
description: "Get OIDC session by access token hash not found", description: "Get OIDC token by refresh token not found",
run: func(t *testing.T, s repository.Store) { run: func(t *testing.T, s repository.Store) {
_, err := s.GetOIDCSessionByAccessTokenHash(ctx, "missing") _, err := s.GetOidcTokenByRefreshToken(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound) assert.ErrorIs(t, err, repository.ErrNotFound)
}, },
}, },
{ {
description: "Get OIDC session by refresh token hash", description: "Get OIDC token by sub",
run: func(t *testing.T, s repository.Store) { run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ _, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
})
require.NoError(t, err)
got, err := s.GetOidcTokenBySub(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, "at-1", got.AccessTokenHash)
},
},
{
description: "Get OIDC token by sub not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcTokenBySub(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Update OIDC token by refresh token",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1", Sub: "sub-1",
AccessTokenHash: "at-1", AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1", RefreshTokenHash: "rt-1",
}) })
require.NoError(t, err) require.NoError(t, err)
got, err := s.GetOIDCSessionByRefreshTokenHash(ctx, "rt-1") updated, err := s.UpdateOidcTokenByRefreshToken(ctx, repository.UpdateOidcTokenByRefreshTokenParams{
require.NoError(t, err) RefreshTokenHash_2: "rt-1",
assert.Equal(t, "sub-1", got.Sub)
},
},
{
description: "Get OIDC session by refresh token hash not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOIDCSessionByRefreshTokenHash(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Create OIDC session unique sub constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"})
require.NoError(t, err)
_, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-2", RefreshTokenHash: "rt-2"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.sub")
},
},
{
description: "Create OIDC session unique access token hash constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"})
require.NoError(t, err)
_, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-2", AccessTokenHash: "at-1", RefreshTokenHash: "rt-2"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.access_token_hash")
},
},
{
description: "Create OIDC session unique refresh token hash constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"})
require.NoError(t, err)
_, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-2", AccessTokenHash: "at-2", RefreshTokenHash: "rt-1"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.refresh_token_hash")
},
},
{
description: "Update OIDC session",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1",
})
require.NoError(t, err)
updated, err := s.UpdateOIDCSession(ctx, repository.UpdateOIDCSessionParams{
Sub: "sub-1",
AccessTokenHash: "at-2", AccessTokenHash: "at-2",
RefreshTokenHash: "rt-2", RefreshTokenHash: "rt-2",
Scope: "openid profile",
TokenExpiresAt: 200, TokenExpiresAt: 200,
RefreshTokenExpiresAt: 400, RefreshTokenExpiresAt: 400,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "at-2", updated.AccessTokenHash) assert.Equal(t, "at-2", updated.AccessTokenHash)
assert.Equal(t, "rt-2", updated.RefreshTokenHash) assert.Equal(t, "rt-2", updated.RefreshTokenHash)
assert.Equal(t, "openid profile", updated.Scope)
// updated token hashes are now queryable, old ones are gone // old key gone, new key present
got, err := s.GetOIDCSessionByAccessTokenHash(ctx, "at-2") _, err = s.GetOidcToken(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
got, err := s.GetOidcToken(ctx, "at-2")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub) assert.Equal(t, "sub-1", got.Sub)
},
_, err = s.GetOIDCSessionByAccessTokenHash(ctx, "at-1") },
{
description: "Update OIDC token by refresh token not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpdateOidcTokenByRefreshToken(ctx, repository.UpdateOidcTokenByRefreshTokenParams{
RefreshTokenHash_2: "missing",
})
assert.ErrorIs(t, err, repository.ErrNotFound) assert.ErrorIs(t, err, repository.ErrNotFound)
}, },
}, },
{ {
description: "Update OIDC session not found", description: "Delete OIDC token",
run: func(t *testing.T, s repository.Store) { run: func(t *testing.T, s repository.Store) {
_, err := s.UpdateOIDCSession(ctx, repository.UpdateOIDCSessionParams{Sub: "missing"}) _, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{Sub: "sub-1", AccessTokenHash: "at-1"})
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC session by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"})
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, s.DeleteOIDCSessionBySub(ctx, "sub-1")) require.NoError(t, s.DeleteOidcToken(ctx, "at-1"))
_, err = s.GetOIDCSessionBySub(ctx, "sub-1") _, err = s.GetOidcToken(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound) assert.ErrorIs(t, err, repository.ErrNotFound)
}, },
}, },
{ {
description: "Delete expired OIDC sessions", description: "Delete OIDC token by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{Sub: "sub-1", AccessTokenHash: "at-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcTokenBySub(ctx, "sub-1"))
_, err = s.GetOidcToken(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC token by code hash",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
CodeHash: "code-1",
})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcTokenByCodeHash(ctx, "code-1"))
_, err = s.GetOidcToken(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete expired OIDC tokens",
run: func(t *testing.T, s repository.Store) { run: func(t *testing.T, s repository.Store) {
// both expiries past // both expiries past
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ _, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1", Sub: "sub-1", AccessTokenHash: "at-1",
TokenExpiresAt: 10, RefreshTokenExpiresAt: 10, TokenExpiresAt: 10, RefreshTokenExpiresAt: 10,
}) })
require.NoError(t, err) require.NoError(t, err)
// valid // valid
_, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{ _, err = s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-2", AccessTokenHash: "at-2", RefreshTokenHash: "rt-2", Sub: "sub-3", AccessTokenHash: "at-3",
TokenExpiresAt: 100, RefreshTokenExpiresAt: 100, TokenExpiresAt: 100, RefreshTokenExpiresAt: 100,
}) })
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, s.DeleteExpiredOIDCSessions(ctx, repository.DeleteExpiredOIDCSessionsParams{ deleted, err := s.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{
TokenExpiresAt: 50, TokenExpiresAt: 50,
RefreshTokenExpiresAt: 50, RefreshTokenExpiresAt: 50,
})) })
require.NoError(t, err)
assert.Len(t, deleted, 1)
_, err = s.GetOIDCSessionBySub(ctx, "sub-1") _, err = s.GetOidcToken(ctx, "at-3")
assert.ErrorIs(t, err, repository.ErrNotFound)
_, err = s.GetOIDCSessionBySub(ctx, "sub-2")
assert.NoError(t, err) assert.NoError(t, err)
}, },
}, },
{
description: "Create and get OIDC user info",
run: func(t *testing.T, s repository.Store) {
u, err := s.CreateOidcUserInfo(ctx, repository.CreateOidcUserInfoParams{
Sub: "sub-1",
Name: "Alice",
Email: "alice@example.com",
})
require.NoError(t, err)
assert.Equal(t, "sub-1", u.Sub)
got, err := s.GetOidcUserInfo(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, u, got)
},
},
{
description: "Get OIDC user info not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcUserInfo(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC user info",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcUserInfo(ctx, repository.CreateOidcUserInfoParams{Sub: "sub-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcUserInfo(ctx, "sub-1"))
_, err = s.GetOidcUserInfo(ctx, "sub-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
} }
for _, test := range tests { for _, test := range tests {
+205 -60
View File
@@ -7,90 +7,235 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
) )
func (s *Store) CreateOIDCSession(_ context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) { func (s *Store) CreateOidcCode(_ context.Context, arg repository.CreateOidcCodeParams) (repository.OidcCode, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
// Enforce UNIQUE constraints (sub is the primary key, access/refresh token hashes are unique). // Enforce sub UNIQUE constraint
for _, sess := range s.oidcSessions { for _, c := range s.oidcCodes {
switch { if c.Sub == arg.Sub {
case sess.Sub == arg.Sub: return repository.OidcCode{}, fmt.Errorf("UNIQUE constraint failed: oidc_codes.sub")
return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.sub")
case sess.AccessTokenHash == arg.AccessTokenHash:
return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.access_token_hash")
case sess.RefreshTokenHash == arg.RefreshTokenHash:
return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.refresh_token_hash")
} }
} }
sess := repository.OidcSession(arg) code := repository.OidcCode(arg)
s.oidcSessions[arg.Sub] = sess s.oidcCodes[arg.CodeHash] = code
return sess, nil return code, nil
} }
func (s *Store) GetOIDCSessionBySub(_ context.Context, sub string) (repository.OidcSession, error) { // GetOidcCode is a destructive read: it deletes and returns the code (mirrors SQLite's DELETE...RETURNING).
s.mu.RLock() func (s *Store) GetOidcCode(_ context.Context, codeHash string) (repository.OidcCode, error) {
defer s.mu.RUnlock() s.mu.Lock()
sess, ok := s.oidcSessions[sub] defer s.mu.Unlock()
c, ok := s.oidcCodes[codeHash]
if !ok { if !ok {
return repository.OidcSession{}, repository.ErrNotFound return repository.OidcCode{}, repository.ErrNotFound
} }
return sess, nil delete(s.oidcCodes, codeHash)
return c, nil
} }
func (s *Store) GetOIDCSessionByAccessTokenHash(_ context.Context, accessTokenHash string) (repository.OidcSession, error) { // GetOidcCodeBySub is a destructive read: it deletes and returns the code (mirrors SQLite's DELETE...RETURNING).
s.mu.RLock() func (s *Store) GetOidcCodeBySub(_ context.Context, sub string) (repository.OidcCode, error) {
defer s.mu.RUnlock()
for _, sess := range s.oidcSessions {
if sess.AccessTokenHash == accessTokenHash {
return sess, nil
}
}
return repository.OidcSession{}, repository.ErrNotFound
}
func (s *Store) GetOIDCSessionByRefreshTokenHash(_ context.Context, refreshTokenHash string) (repository.OidcSession, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sess := range s.oidcSessions {
if sess.RefreshTokenHash == refreshTokenHash {
return sess, nil
}
}
return repository.OidcSession{}, repository.ErrNotFound
}
func (s *Store) UpdateOIDCSession(_ context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
sess, ok := s.oidcSessions[arg.Sub] for k, c := range s.oidcCodes {
if c.Sub == sub {
delete(s.oidcCodes, k)
return c, nil
}
}
return repository.OidcCode{}, repository.ErrNotFound
}
// GetOidcCodeUnsafe is a non-destructive read (mirrors SQLite's SELECT).
func (s *Store) GetOidcCodeUnsafe(_ context.Context, codeHash string) (repository.OidcCode, error) {
s.mu.RLock()
defer s.mu.RUnlock()
c, ok := s.oidcCodes[codeHash]
if !ok { if !ok {
return repository.OidcSession{}, repository.ErrNotFound return repository.OidcCode{}, repository.ErrNotFound
} }
sess.AccessTokenHash = arg.AccessTokenHash return c, nil
sess.RefreshTokenHash = arg.RefreshTokenHash
sess.Scope = arg.Scope
sess.ClientID = arg.ClientID
sess.TokenExpiresAt = arg.TokenExpiresAt
sess.RefreshTokenExpiresAt = arg.RefreshTokenExpiresAt
sess.Nonce = arg.Nonce
sess.UserinfoJson = arg.UserinfoJson
s.oidcSessions[arg.Sub] = sess
return sess, nil
} }
func (s *Store) DeleteOIDCSessionBySub(_ context.Context, sub string) error { // GetOidcCodeBySubUnsafe is a non-destructive read (mirrors SQLite's SELECT).
func (s *Store) GetOidcCodeBySubUnsafe(_ context.Context, sub string) (repository.OidcCode, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, c := range s.oidcCodes {
if c.Sub == sub {
return c, nil
}
}
return repository.OidcCode{}, repository.ErrNotFound
}
func (s *Store) DeleteOidcCode(_ context.Context, codeHash string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
delete(s.oidcSessions, sub) delete(s.oidcCodes, codeHash)
return nil return nil
} }
func (s *Store) DeleteExpiredOIDCSessions(_ context.Context, arg repository.DeleteExpiredOIDCSessionsParams) error { func (s *Store) DeleteOidcCodeBySub(_ context.Context, sub string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
for k, sess := range s.oidcSessions { for k, c := range s.oidcCodes {
if sess.TokenExpiresAt < arg.TokenExpiresAt && sess.RefreshTokenExpiresAt < arg.RefreshTokenExpiresAt { if c.Sub == sub {
delete(s.oidcSessions, k) delete(s.oidcCodes, k)
} }
} }
return nil return nil
} }
func (s *Store) DeleteExpiredOidcCodes(_ context.Context, expiresAt int64) ([]repository.OidcCode, error) {
s.mu.Lock()
defer s.mu.Unlock()
var deleted []repository.OidcCode
for k, c := range s.oidcCodes {
if c.ExpiresAt < expiresAt {
deleted = append(deleted, c)
delete(s.oidcCodes, k)
}
}
return deleted, nil
}
func (s *Store) CreateOidcToken(_ context.Context, arg repository.CreateOidcTokenParams) (repository.OidcToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Enforce sub UNIQUE constraint
for _, t := range s.oidcTokens {
if t.Sub == arg.Sub {
return repository.OidcToken{}, fmt.Errorf("UNIQUE constraint failed: oidc_tokens.sub")
}
}
tok := repository.OidcToken{
Sub: arg.Sub,
AccessTokenHash: arg.AccessTokenHash,
RefreshTokenHash: arg.RefreshTokenHash,
CodeHash: arg.CodeHash,
Scope: arg.Scope,
ClientID: arg.ClientID,
TokenExpiresAt: arg.TokenExpiresAt,
RefreshTokenExpiresAt: arg.RefreshTokenExpiresAt,
Nonce: arg.Nonce,
}
s.oidcTokens[arg.AccessTokenHash] = tok
return tok, nil
}
func (s *Store) GetOidcToken(_ context.Context, accessTokenHash string) (repository.OidcToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.oidcTokens[accessTokenHash]
if !ok {
return repository.OidcToken{}, repository.ErrNotFound
}
return t, nil
}
func (s *Store) GetOidcTokenByRefreshToken(_ context.Context, refreshTokenHash string) (repository.OidcToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, t := range s.oidcTokens {
if t.RefreshTokenHash == refreshTokenHash {
return t, nil
}
}
return repository.OidcToken{}, repository.ErrNotFound
}
func (s *Store) GetOidcTokenBySub(_ context.Context, sub string) (repository.OidcToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, t := range s.oidcTokens {
if t.Sub == sub {
return t, nil
}
}
return repository.OidcToken{}, repository.ErrNotFound
}
func (s *Store) UpdateOidcTokenByRefreshToken(_ context.Context, arg repository.UpdateOidcTokenByRefreshTokenParams) (repository.OidcToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
for k, t := range s.oidcTokens {
if t.RefreshTokenHash == arg.RefreshTokenHash_2 {
delete(s.oidcTokens, k)
t.AccessTokenHash = arg.AccessTokenHash
t.RefreshTokenHash = arg.RefreshTokenHash
t.TokenExpiresAt = arg.TokenExpiresAt
t.RefreshTokenExpiresAt = arg.RefreshTokenExpiresAt
s.oidcTokens[arg.AccessTokenHash] = t
return t, nil
}
}
return repository.OidcToken{}, repository.ErrNotFound
}
func (s *Store) DeleteOidcToken(_ context.Context, accessTokenHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.oidcTokens, accessTokenHash)
return nil
}
func (s *Store) DeleteOidcTokenBySub(_ context.Context, sub string) error {
s.mu.Lock()
defer s.mu.Unlock()
for k, t := range s.oidcTokens {
if t.Sub == sub {
delete(s.oidcTokens, k)
}
}
return nil
}
func (s *Store) DeleteOidcTokenByCodeHash(_ context.Context, codeHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
for k, t := range s.oidcTokens {
if t.CodeHash == codeHash {
delete(s.oidcTokens, k)
}
}
return nil
}
func (s *Store) DeleteExpiredOidcTokens(_ context.Context, arg repository.DeleteExpiredOidcTokensParams) ([]repository.OidcToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
var deleted []repository.OidcToken
for k, t := range s.oidcTokens {
if t.TokenExpiresAt < arg.TokenExpiresAt && t.RefreshTokenExpiresAt < arg.RefreshTokenExpiresAt {
deleted = append(deleted, t)
delete(s.oidcTokens, k)
}
}
return deleted, nil
}
func (s *Store) CreateOidcUserInfo(_ context.Context, arg repository.CreateOidcUserInfoParams) (repository.OidcUserinfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
u := repository.OidcUserinfo(arg)
s.oidcUsers[arg.Sub] = u
return u, nil
}
func (s *Store) GetOidcUserInfo(_ context.Context, sub string) (repository.OidcUserinfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.oidcUsers[sub]
if !ok {
return repository.OidcUserinfo{}, repository.ErrNotFound
}
return u, nil
}
func (s *Store) DeleteOidcUserInfo(_ context.Context, sub string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.oidcUsers, sub)
return nil
}
+9 -5
View File
@@ -9,15 +9,19 @@ import (
// Store is a thread-safe in-memory implementation of repository.Store. // Store is a thread-safe in-memory implementation of repository.Store.
type Store struct { type Store struct {
mu sync.RWMutex mu sync.RWMutex
sessions map[string]repository.Session sessions map[string]repository.Session
oidcSessions map[string]repository.OidcSession oidcCodes map[string]repository.OidcCode
oidcTokens map[string]repository.OidcToken
oidcUsers map[string]repository.OidcUserinfo
} }
// New returns a new empty in-memory Store. // New returns a new empty in-memory Store.
func New() repository.Store { func New() repository.Store {
return &Store{ return &Store{
sessions: make(map[string]repository.Session), sessions: make(map[string]repository.Session),
oidcSessions: make(map[string]repository.OidcSession), oidcCodes: make(map[string]repository.OidcCode),
oidcTokens: make(map[string]repository.OidcToken),
oidcUsers: make(map[string]repository.OidcUserinfo),
} }
} }
+73 -11
View File
@@ -17,16 +17,49 @@ type Session struct {
OAuthSub string OAuthSub string
} }
type OidcSession struct { type OidcCode struct {
Sub string
CodeHash string
Scope string
RedirectURI string
ClientID string
ExpiresAt int64
Nonce string
CodeChallenge string
}
type OidcToken struct {
Sub string Sub string
AccessTokenHash string AccessTokenHash string
RefreshTokenHash string RefreshTokenHash string
CodeHash string
Scope string Scope string
ClientID string ClientID string
TokenExpiresAt int64 TokenExpiresAt int64
RefreshTokenExpiresAt int64 RefreshTokenExpiresAt int64
Nonce string Nonce string
UserinfoJson string }
type OidcUserinfo struct {
Sub string
Name string
PreferredUsername string
Email string
Groups string
UpdatedAt int64
GivenName string
FamilyName string
MiddleName string
Nickname string
Profile string
Picture string
Website string
Gender string
Birthdate string
Zoneinfo string
Locale string
PhoneNumber string
Address string
} }
type CreateSessionParams struct { type CreateSessionParams struct {
@@ -56,7 +89,18 @@ type UpdateSessionParams struct {
UUID string UUID string
} }
type CreateOIDCSessionParams struct { type CreateOidcCodeParams struct {
Sub string
CodeHash string
Scope string
RedirectURI string
ClientID string
ExpiresAt int64
Nonce string
CodeChallenge string
}
type CreateOidcTokenParams struct {
Sub string Sub string
AccessTokenHash string AccessTokenHash string
RefreshTokenHash string RefreshTokenHash string
@@ -64,23 +108,41 @@ type CreateOIDCSessionParams struct {
ClientID string ClientID string
TokenExpiresAt int64 TokenExpiresAt int64
RefreshTokenExpiresAt int64 RefreshTokenExpiresAt int64
CodeHash string
Nonce string Nonce string
UserinfoJson string
} }
type UpdateOIDCSessionParams struct { type UpdateOidcTokenByRefreshTokenParams struct {
AccessTokenHash string AccessTokenHash string
RefreshTokenHash string RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64 TokenExpiresAt int64
RefreshTokenExpiresAt int64 RefreshTokenExpiresAt int64
Nonce string RefreshTokenHash_2 string
UserinfoJson string
Sub string
} }
type DeleteExpiredOIDCSessionsParams struct { type DeleteExpiredOidcTokensParams struct {
TokenExpiresAt int64 TokenExpiresAt int64
RefreshTokenExpiresAt int64 RefreshTokenExpiresAt int64
} }
type CreateOidcUserInfoParams struct {
Sub string
Name string
PreferredUsername string
Email string
Groups string
UpdatedAt int64
GivenName string
FamilyName string
MiddleName string
Nickname string
Profile string
Picture string
Website string
Gender string
Birthdate string
Zoneinfo string
Locale string
PhoneNumber string
Address string
}
-31
View File
@@ -1,31 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package postgres
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

Some files were not shown because too many files have changed in this diff Show More