mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-05-12 15:28:20 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f927a570 | |||
| 0a6737891c | |||
| 63cc081853 | |||
| f6e994d834 | |||
| 906d810baf |
@@ -91,8 +91,6 @@ TINYAUTH_APPS_name_LDAP_GROUPS=
|
||||
|
||||
# Comma-separated list of allowed OAuth domains.
|
||||
TINYAUTH_OAUTH_WHITELIST=
|
||||
# Path to the OAuth whitelist file.
|
||||
TINYAUTH_OAUTH_WHITELISTFILE=
|
||||
# The OAuth provider to use for automatic redirection.
|
||||
TINYAUTH_OAUTH_AUTOREDIRECT=
|
||||
# OAuth client ID.
|
||||
|
||||
@@ -5,21 +5,18 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.26.0"
|
||||
|
||||
@@ -53,6 +50,6 @@ jobs:
|
||||
run: go test -coverprofile=coverage.txt -v ./...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -4,16 +4,12 @@ on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Delete old release
|
||||
run: gh release delete --cleanup-tag --yes nightly || echo release not found
|
||||
@@ -23,7 +19,7 @@ jobs:
|
||||
REPO: ${{ github.event.repository.name }}
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: nightly
|
||||
@@ -37,7 +33,7 @@ jobs:
|
||||
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
@@ -55,15 +51,15 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.26.0"
|
||||
|
||||
@@ -84,12 +80,12 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
cp -r frontend/dist internal/assets/dist
|
||||
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
|
||||
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: tinyauth-amd64
|
||||
path: tinyauth-amd64
|
||||
@@ -101,15 +97,15 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.26.0"
|
||||
|
||||
@@ -130,12 +126,12 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
cp -r frontend/dist internal/assets/dist
|
||||
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
|
||||
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: tinyauth-arm64
|
||||
path: tinyauth-arm64
|
||||
@@ -147,28 +143,28 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
@@ -190,7 +186,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-linux-amd64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -205,28 +201,28 @@ jobs:
|
||||
- image-build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
@@ -249,7 +245,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-distroless-linux-amd64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -263,28 +259,28 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
platforms: linux/arm64
|
||||
@@ -306,7 +302,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-linux-arm64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -321,28 +317,28 @@ jobs:
|
||||
- image-build-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
platforms: linux/arm64
|
||||
@@ -365,7 +361,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-distroless-linux-arm64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -379,25 +375,25 @@ jobs:
|
||||
- image-build-arm
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
flavor: |
|
||||
@@ -418,25 +414,25 @@ jobs:
|
||||
- image-build-arm-distroless
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-distroless-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
flavor: |
|
||||
@@ -456,14 +452,14 @@ jobs:
|
||||
- binary-build
|
||||
- binary-build-arm
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: tinyauth-*
|
||||
path: binaries
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: binaries/*
|
||||
tag_name: nightly
|
||||
|
||||
@@ -5,10 +5,6 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
generate-metadata:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -18,7 +14,7 @@ jobs:
|
||||
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Generate metadata
|
||||
id: metadata
|
||||
@@ -33,13 +29,13 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.26.0"
|
||||
|
||||
@@ -60,12 +56,12 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
cp -r frontend/dist internal/assets/dist
|
||||
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
|
||||
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: tinyauth-amd64
|
||||
path: tinyauth-amd64
|
||||
@@ -76,13 +72,13 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.26.0"
|
||||
|
||||
@@ -103,12 +99,12 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
cp -r frontend/dist internal/assets/dist
|
||||
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
|
||||
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: tinyauth-arm64
|
||||
path: tinyauth-arm64
|
||||
@@ -119,26 +115,26 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
@@ -160,7 +156,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-linux-amd64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -174,26 +170,26 @@ jobs:
|
||||
- image-build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
@@ -216,7 +212,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-distroless-linux-amd64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -229,26 +225,26 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
platforms: linux/arm64
|
||||
@@ -270,7 +266,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-linux-arm64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -284,26 +280,26 @@ jobs:
|
||||
- image-build-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
||||
uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
platforms: linux/arm64
|
||||
@@ -326,7 +322,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-distroless-linux-arm64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -340,25 +336,25 @@ jobs:
|
||||
- image-build-arm
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
flavor: |
|
||||
@@ -381,25 +377,25 @@ jobs:
|
||||
- image-build-arm-distroless
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-distroless-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
flavor: |
|
||||
@@ -423,13 +419,13 @@ jobs:
|
||||
- binary-build
|
||||
- binary-build-arm
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: tinyauth-*
|
||||
path: binaries
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: binaries/*
|
||||
|
||||
@@ -38,6 +38,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to code-scanning
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -2,19 +2,15 @@ name: Generate Sponsors List
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
generate-sponsors:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Generate Sponsors
|
||||
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
|
||||
uses: JamesIves/github-sponsors-readme-action@v1
|
||||
with:
|
||||
token: ${{ secrets.SPONSORS_GENERATOR_PAT }}
|
||||
active-only: false
|
||||
@@ -22,7 +18,7 @@ jobs:
|
||||
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="64px" alt="User avatar: {{{ login }}}" /></a> '
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: |
|
||||
|
||||
@@ -3,15 +3,11 @@ on:
|
||||
schedule:
|
||||
- cron: 0 10 * * *
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 30
|
||||
stale-pr-message: This PR has been inactive for 30 days and will be marked as stale.
|
||||
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Connect to server",
|
||||
"type": "go",
|
||||
"request": "attach",
|
||||
"mode": "remote",
|
||||
"remotePath": "/tinyauth",
|
||||
"port": 4000,
|
||||
"host": "127.0.0.1",
|
||||
"debugAdapter": "legacy"
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -38,9 +38,9 @@ COPY ./internal ./internal
|
||||
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
||||
|
||||
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.CommitHash=${COMMIT_HASH} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.Version=${VERSION} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
||||
|
||||
# Runner
|
||||
FROM alpine:3.23 AS runner
|
||||
|
||||
@@ -40,9 +40,9 @@ COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
||||
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.CommitHash=${COMMIT_HASH} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.Version=${VERSION} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
|
||||
|
||||
# Runner
|
||||
FROM gcr.io/distroless/static-debian12:latest AS runner
|
||||
|
||||
@@ -17,7 +17,7 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
|
||||
|
||||
# Deps
|
||||
deps:
|
||||
bun install --frozen-lockfile --cwd frontend
|
||||
bun install --cwd frontend
|
||||
go mod download
|
||||
|
||||
# Clean data
|
||||
@@ -37,9 +37,9 @@ webui: clean-webui
|
||||
# Build the binary
|
||||
binary: webui
|
||||
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.CommitHash=${COMMIT_HASH} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.Version=${TAG_NAME} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \
|
||||
-X github.com/tinyauthapp/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" \
|
||||
-o ${BIN_NAME} ./cmd/tinyauth
|
||||
|
||||
# Build for amd64
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<a href="https://scorecard.dev/viewer/?uri=github.com/tinyauthapp/tinyauth" target="_blank" title="OpenSSF Scorecard">
|
||||
<img src="https://api.scorecard.dev/projects/github.com/tinyauthapp/tinyauth/badge">
|
||||
</a>
|
||||
<a href="https://www.bestpractices.dev/projects/12681" target="_blank" title="OSSF Best Practices"><img src="https://www.bestpractices.dev/projects/12681/baseline"></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
@@ -65,7 +64,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
|
||||
|
||||
A big thank you to the following people for providing me with more coffee:
|
||||
|
||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/ax-mad"><img src="https://github.com/ax-mad.png" width="64px" alt="User avatar: ax-mad" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <a href="https://github.com/apearson"><img src="https://github.com/apearson.png" width="64px" alt="User avatar: apearson" /></a> <!-- sponsors -->
|
||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/ax-mad"><img src="https://github.com/ax-mad.png" width="64px" alt="User avatar: ax-mad" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <!-- sponsors -->
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -40,8 +40,7 @@ func createUserCmd() *cli.Command {
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
@@ -74,7 +73,7 @@ func createUserCmd() *cli.Command {
|
||||
return errors.New("username and password cannot be empty")
|
||||
}
|
||||
|
||||
log.App.Info().Str("username", tCfg.Username).Msg("Creating user")
|
||||
tlog.App.Info().Str("username", tCfg.Username).Msg("Creating user")
|
||||
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -87,7 +86,7 @@ func createUserCmd() *cli.Command {
|
||||
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
|
||||
}
|
||||
|
||||
log.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
|
||||
tlog.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
@@ -40,8 +40,7 @@ func generateTotpCmd() *cli.Command {
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
@@ -74,7 +73,7 @@ func generateTotpCmd() *cli.Command {
|
||||
docker = true
|
||||
}
|
||||
|
||||
if user.TOTPSecret != "" {
|
||||
if user.TotpSecret != "" {
|
||||
return fmt.Errorf("user already has a TOTP secret")
|
||||
}
|
||||
|
||||
@@ -89,9 +88,9 @@ func generateTotpCmd() *cli.Command {
|
||||
|
||||
secret := key.Secret()
|
||||
|
||||
log.App.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
||||
tlog.App.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
||||
|
||||
log.App.Info().Msg("Generated QR code")
|
||||
tlog.App.Info().Msg("Generated QR code")
|
||||
|
||||
config := qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
@@ -103,14 +102,14 @@ func generateTotpCmd() *cli.Command {
|
||||
|
||||
qrterminal.GenerateWithConfig(key.URL(), config)
|
||||
|
||||
user.TOTPSecret = secret
|
||||
user.TotpSecret = secret
|
||||
|
||||
// If using docker escape re-escape it
|
||||
if docker {
|
||||
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
|
||||
}
|
||||
|
||||
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.")
|
||||
tlog.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.")
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
type healthzResponse struct {
|
||||
@@ -26,8 +26,7 @@ func healthcheckCmd() *cli.Command {
|
||||
Resources: nil,
|
||||
AllowArg: true,
|
||||
Run: func(args []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
srvAddr := os.Getenv("TINYAUTH_SERVER_ADDRESS")
|
||||
if srvAddr == "" {
|
||||
@@ -49,7 +48,7 @@ func healthcheckCmd() *cli.Command {
|
||||
return errors.New("Could not determine app URL")
|
||||
}
|
||||
|
||||
log.App.Info().Str("app_url", appUrl).Msg("Performing health check")
|
||||
tlog.App.Info().Str("app_url", appUrl).Msg("Performing health check")
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -87,7 +86,7 @@ func healthcheckCmd() *cli.Command {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
log.App.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
|
||||
tlog.App.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -5,15 +5,16 @@ import (
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/loaders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
tConfig := model.NewDefaultConfiguration()
|
||||
tConfig := config.NewDefaultConfiguration()
|
||||
|
||||
loaders := []cli.ResourceLoader{
|
||||
&loaders.FileLoader{},
|
||||
@@ -107,7 +108,12 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func runCmd(cfg model.Config) error {
|
||||
func runCmd(cfg config.Config) error {
|
||||
logger := tlog.NewLogger(cfg.Log)
|
||||
logger.Init()
|
||||
|
||||
tlog.App.Info().Str("version", config.Version).Msg("Starting tinyauth")
|
||||
|
||||
app := bootstrap.NewBootstrapApp(cfg)
|
||||
|
||||
err := app.Setup()
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/pquerna/otp/totp"
|
||||
@@ -44,8 +44,7 @@ func verifyUserCmd() *cli.Command {
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
@@ -96,21 +95,21 @@ func verifyUserCmd() *cli.Command {
|
||||
return fmt.Errorf("password is incorrect: %w", err)
|
||||
}
|
||||
|
||||
if user.TOTPSecret == "" {
|
||||
if user.TotpSecret == "" {
|
||||
if tCfg.Totp != "" {
|
||||
log.App.Warn().Msg("User does not have TOTP secret")
|
||||
tlog.App.Warn().Msg("User does not have TOTP secret")
|
||||
}
|
||||
log.App.Info().Msg("User verified")
|
||||
tlog.App.Info().Msg("User verified")
|
||||
return nil
|
||||
}
|
||||
|
||||
ok := totp.Validate(tCfg.Totp, user.TOTPSecret)
|
||||
ok := totp.Validate(tCfg.Totp, user.TotpSecret)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("TOTP code incorrect")
|
||||
}
|
||||
|
||||
log.App.Info().Msg("User verified")
|
||||
tlog.App.Info().Msg("User verified")
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -3,8 +3,9 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
func versionCmd() *cli.Command {
|
||||
@@ -14,9 +15,9 @@ func versionCmd() *cli.Command {
|
||||
Configuration: nil,
|
||||
Resources: nil,
|
||||
Run: func(_ []string) error {
|
||||
fmt.Printf("Version: %s\n", model.Version)
|
||||
fmt.Printf("Commit Hash: %s\n", model.CommitHash)
|
||||
fmt.Printf("Build Timestamp: %s\n", model.BuildTimestamp)
|
||||
fmt.Printf("Version: %s\n", config.Version)
|
||||
fmt.Printf("Commit Hash: %s\n", config.CommitHash)
|
||||
fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /frontend
|
||||
COPY ./frontend/package.json ./
|
||||
COPY ./frontend/bun.lock ./
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun install
|
||||
|
||||
COPY ./frontend/public ./public
|
||||
COPY ./frontend/src ./src
|
||||
@@ -19,4 +19,4 @@ COPY ./frontend/vite.config.ts ./
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
ENTRYPOINT ["bun", "run", "dev"]
|
||||
ENTRYPOINT ["bun", "run", "dev"]
|
||||
+101
-147
@@ -11,45 +11,45 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"axios": "^1.16.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"axios": "^1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.1.0",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router": "^7.14.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"zod": "^4.4.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.100.10",
|
||||
"@types/node": "^25.7.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.99.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "3.8.3",
|
||||
"globals": "^17.5.0",
|
||||
"prettier": "3.8.2",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.12",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.8",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||
|
||||
@@ -88,9 +88,9 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
@@ -338,41 +338,41 @@
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
|
||||
|
||||
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.10", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-Ddou3agTWv5rvHSBby4yHlugUFHVh0nyo2fyoZ81qSxaTBIwNCoPgpiJhjo5QkThrH1wGC7k548BcMTszZCkBw=="],
|
||||
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.99.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-jVp1AEL7S7BeuQvH5SN1F5UdrNW/AbryKDeWUUMeAKNzh9C+Ik/bRSa/HeuJLlmaN+WOUkdDFbtCK0go7BxnUQ=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="],
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="],
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.99.0", "", { "dependencies": { "@tanstack/query-core": "5.99.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="],
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
@@ -400,25 +400,25 @@
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="],
|
||||
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
@@ -510,7 +510,7 @@
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
@@ -524,9 +524,9 @@
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
|
||||
"eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
|
||||
|
||||
@@ -564,7 +564,7 @@
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
@@ -586,7 +586,7 @@
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
|
||||
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"i18next": ["i18next@26.1.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ=="],
|
||||
"i18next": ["i18next@26.0.4", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA=="],
|
||||
|
||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||
|
||||
@@ -694,7 +694,7 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
|
||||
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
@@ -792,13 +792,13 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||
"prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
@@ -808,13 +808,13 @@
|
||||
|
||||
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.75.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw=="],
|
||||
"react-hook-form": ["react-hook-form@7.72.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.10", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg=="],
|
||||
"react-i18next": ["react-i18next@17.0.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
@@ -822,7 +822,7 @@
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-router": ["react-router@7.15.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ=="],
|
||||
"react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
@@ -866,13 +866,13 @@
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
@@ -886,11 +886,11 @@
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="],
|
||||
"typescript-eslint": ["typescript-eslint@8.58.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.1", "@typescript-eslint/parser": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
@@ -918,7 +918,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
|
||||
|
||||
"vite": ["vite@8.0.12", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg=="],
|
||||
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
@@ -940,7 +940,7 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
@@ -1002,13 +1002,13 @@
|
||||
|
||||
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
@@ -1016,36 +1016,16 @@
|
||||
|
||||
"@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
|
||||
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
"eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
|
||||
"hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||
|
||||
"i18next-browser-languagedetector/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"i18next-resources-to-backend/@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="],
|
||||
|
||||
"micromark/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
|
||||
@@ -1058,17 +1038,11 @@
|
||||
|
||||
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"react-i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="],
|
||||
|
||||
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"vite/rolldown": ["rolldown@1.0.0", "", { "dependencies": { "@oxc-project/types": "=0.129.0", "@rolldown/pluginutils": "1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0", "@rolldown/binding-darwin-arm64": "1.0.0", "@rolldown/binding-darwin-x64": "1.0.0", "@rolldown/binding-freebsd-x64": "1.0.0", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", "@rolldown/binding-linux-arm64-gnu": "1.0.0", "@rolldown/binding-linux-arm64-musl": "1.0.0", "@rolldown/binding-linux-ppc64-gnu": "1.0.0", "@rolldown/binding-linux-s390x-gnu": "1.0.0", "@rolldown/binding-linux-x64-gnu": "1.0.0", "@rolldown/binding-linux-x64-musl": "1.0.0", "@rolldown/binding-openharmony-arm64": "1.0.0", "@rolldown/binding-wasm32-wasi": "1.0.0", "@rolldown/binding-win32-arm64-msvc": "1.0.0", "@rolldown/binding-win32-x64-msvc": "1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA=="],
|
||||
"vite/rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="],
|
||||
|
||||
@@ -1086,65 +1060,45 @@
|
||||
|
||||
"@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
"vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="],
|
||||
"vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="],
|
||||
"vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="],
|
||||
"vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
|
||||
|
||||
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.129.0", "", {}, "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew=="],
|
||||
"vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ=="],
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ=="],
|
||||
"vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A=="],
|
||||
"vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0", "", { "os": "none", "cpu": "arm64" }, "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0", "", { "os": "win32", "cpu": "x64" }, "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0", "", {}, "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ=="],
|
||||
"vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
|
||||
+22
-22
@@ -17,44 +17,44 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"axios": "^1.16.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"axios": "^1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.1.0",
|
||||
"i18next": "^26.0.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router": "^7.14.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"zod": "^4.4.3"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.100.10",
|
||||
"@types/node": "^25.7.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.99.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "3.8.3",
|
||||
"globals": "^17.5.0",
|
||||
"prettier": "3.8.2",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.12"
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,5 @@
|
||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||
"groupsScopeName": "Groups",
|
||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
||||
"backToLoginButton": "Back to login",
|
||||
"phoneScopeName": "Phone",
|
||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
||||
"addressScopeName": "Address",
|
||||
"addressScopeDescription": "Allows the app to access your address."
|
||||
"backToLoginButton": "Back to login"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { toast } from "sonner";
|
||||
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react";
|
||||
import { Mail, Shield, User, Users } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -61,18 +61,6 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
|
||||
description: t("groupsScopeDescription"),
|
||||
icon: <Users {...scopeMapIconProps} />,
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
name: t("phoneScopeName"),
|
||||
description: t("phoneScopeDescription"),
|
||||
icon: <Phone {...scopeMapIconProps} />,
|
||||
},
|
||||
{
|
||||
id: "address",
|
||||
name: t("addressScopeName"),
|
||||
description: t("addressScopeDescription"),
|
||||
icon: <MapPin {...scopeMapIconProps} />,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
)
|
||||
|
||||
type EnvEntry struct {
|
||||
@@ -20,7 +20,7 @@ type EnvEntry struct {
|
||||
}
|
||||
|
||||
func generateExampleEnv() {
|
||||
cfg := model.NewDefaultConfiguration()
|
||||
cfg := config.NewDefaultConfiguration()
|
||||
entries := make([]EnvEntry, 0)
|
||||
|
||||
root := reflect.TypeOf(cfg).Elem()
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
)
|
||||
|
||||
type MarkdownEntry struct {
|
||||
@@ -21,7 +21,7 @@ type MarkdownEntry struct {
|
||||
}
|
||||
|
||||
func generateMarkdown() {
|
||||
cfg := model.NewDefaultConfiguration()
|
||||
cfg := config.NewDefaultConfiguration()
|
||||
entries := make([]MarkdownEntry, 0)
|
||||
|
||||
root := reflect.TypeOf(cfg).Elem()
|
||||
|
||||
@@ -19,10 +19,10 @@ require (
|
||||
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
|
||||
github.com/weppos/publicsuffix-go v0.50.3
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
k8s.io/apimachinery v0.36.0
|
||||
k8s.io/client-go v0.36.0
|
||||
modernc.org/sqlite v1.50.0
|
||||
gotest.tools/v3 v3.5.2
|
||||
modernc.org/sqlite v1.49.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -63,7 +63,6 @@ require (
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
@@ -74,6 +73,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
@@ -90,9 +90,8 @@ require (
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
@@ -107,7 +106,6 @@ require (
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
@@ -118,28 +116,16 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
@@ -97,14 +97,10 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -122,12 +118,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
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/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
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/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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -142,8 +132,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -174,8 +162,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -190,8 +176,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
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/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -219,15 +203,12 @@ github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFL
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -261,12 +242,9 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
|
||||
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/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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.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/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -283,8 +261,6 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE=
|
||||
github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
@@ -311,10 +287,6 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
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/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
@@ -336,8 +308,8 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
@@ -347,32 +319,16 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
|
||||
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
|
||||
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
|
||||
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
|
||||
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
|
||||
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
|
||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||
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/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
@@ -395,19 +351,11 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "given_name";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "family_name";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "middle_name";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "nickname";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "profile";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "picture";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "website";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "gender";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "birthdate";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "zoneinfo";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "locale";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "phone_number";
|
||||
ALTER TABLE "oidc_userinfo" DROP COLUMN "address";
|
||||
@@ -1,13 +0,0 @@
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "given_name" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "family_name" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "middle_name" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "nickname" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "profile" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "picture" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "website" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "gender" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "birthdate" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "zoneinfo" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "locale" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "phone_number" TEXT NOT NULL DEFAULT "";
|
||||
ALTER TABLE "oidc_userinfo" ADD COLUMN "address" TEXT NOT NULL DEFAULT "{}";
|
||||
+127
-284
@@ -3,195 +3,156 @@ package bootstrap
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"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/tlog"
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
accessControlService *service.AccessControlsService
|
||||
authService *service.AuthService
|
||||
dockerService *service.DockerService
|
||||
kubernetesService *service.KubernetesService
|
||||
ldapService *service.LdapService
|
||||
oauthBrokerService *service.OAuthBrokerService
|
||||
oidcService *service.OIDCService
|
||||
}
|
||||
|
||||
type BootstrapApp struct {
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
config config.Config
|
||||
context struct {
|
||||
appUrl string
|
||||
uuid string
|
||||
cookieDomain string
|
||||
sessionCookieName string
|
||||
csrfCookieName string
|
||||
redirectCookieName string
|
||||
oauthSessionCookieName string
|
||||
users []config.User
|
||||
oauthProviders map[string]config.OAuthServiceConfig
|
||||
configuredProviders []controller.Provider
|
||||
oidcClients []config.OIDCClientConfig
|
||||
}
|
||||
services Services
|
||||
log *logger.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
queries *repository.Queries
|
||||
router *gin.Engine
|
||||
db *sql.DB
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewBootstrapApp(config model.Config) *BootstrapApp {
|
||||
func NewBootstrapApp(config config.Config) *BootstrapApp {
|
||||
return &BootstrapApp{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) Setup() error {
|
||||
// create context
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
app.ctx = ctx
|
||||
app.cancel = cancel
|
||||
|
||||
// setup logger
|
||||
log := logger.NewLogger().WithConfig(app.config.Log)
|
||||
log.Init()
|
||||
app.log = log
|
||||
|
||||
// get app url
|
||||
if app.config.AppURL == "" {
|
||||
return errors.New("app url cannot be empty, perhaps config loading failed")
|
||||
return fmt.Errorf("app URL cannot be empty, perhaps config loading failed")
|
||||
}
|
||||
|
||||
appUrl, err := url.Parse(app.config.AppURL)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse app url: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.runtime.AppURL = appUrl.Scheme + "://" + appUrl.Host
|
||||
app.context.appUrl = appUrl.Scheme + "://" + appUrl.Host
|
||||
|
||||
// validate session config
|
||||
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
|
||||
return errors.New("session max lifetime cannot be less than session expiry")
|
||||
return fmt.Errorf("session max lifetime cannot be less than session expiry")
|
||||
}
|
||||
|
||||
// parse users
|
||||
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile, app.config.Auth.UserAttributes)
|
||||
// Parse users
|
||||
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load users: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.runtime.LocalUsers = *users
|
||||
app.context.users = users
|
||||
|
||||
// load oauth whitelist
|
||||
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
|
||||
// Setup OAuth providers
|
||||
app.context.oauthProviders = app.config.OAuth.Providers
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load oauth whitelist: %w", err)
|
||||
}
|
||||
|
||||
app.runtime.OAuthWhitelist = oauthWhitelist
|
||||
|
||||
// setup oauth providers
|
||||
app.runtime.OAuthProviders = app.config.OAuth.Providers
|
||||
|
||||
for id, provider := range app.runtime.OAuthProviders {
|
||||
for name, provider := range app.context.oauthProviders {
|
||||
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
|
||||
provider.ClientSecret = secret
|
||||
provider.ClientSecretFile = ""
|
||||
|
||||
if provider.RedirectURL == "" {
|
||||
provider.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + id
|
||||
provider.RedirectURL = app.context.appUrl + "/api/oauth/callback/" + name
|
||||
}
|
||||
|
||||
app.runtime.OAuthProviders[id] = provider
|
||||
app.context.oauthProviders[name] = provider
|
||||
}
|
||||
|
||||
// set presets for built-in providers
|
||||
for id, provider := range app.runtime.OAuthProviders {
|
||||
for id, provider := range app.context.oauthProviders {
|
||||
if provider.Name == "" {
|
||||
if name, ok := model.OverrideProviders[id]; ok {
|
||||
if name, ok := config.OverrideProviders[id]; ok {
|
||||
provider.Name = name
|
||||
} else {
|
||||
provider.Name = utils.Capitalize(id)
|
||||
}
|
||||
}
|
||||
app.runtime.OAuthProviders[id] = provider
|
||||
app.context.oauthProviders[id] = provider
|
||||
}
|
||||
|
||||
// setup oidc clients
|
||||
// Setup OIDC clients
|
||||
for id, client := range app.config.OIDC.Clients {
|
||||
client.ID = id
|
||||
app.runtime.OIDCClients = append(app.runtime.OIDCClients, client)
|
||||
app.context.oidcClients = append(app.context.oidcClients, client)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Get cookie domain
|
||||
cookieDomain, err := utils.GetCookieDomain(app.context.appUrl)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get cookie domain: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
app.runtime.CookieDomain = cookieDomain
|
||||
app.context.cookieDomain = cookieDomain
|
||||
|
||||
// cookie names
|
||||
app.runtime.UUID = utils.GenerateUUID(appUrl.Hostname())
|
||||
// Cookie names
|
||||
app.context.uuid = utils.GenerateUUID(appUrl.Hostname())
|
||||
cookieId := strings.Split(app.context.uuid, "-")[0]
|
||||
app.context.sessionCookieName = fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId)
|
||||
app.context.csrfCookieName = fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
|
||||
app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
|
||||
app.context.oauthSessionCookieName = fmt.Sprintf("%s-%s", config.OAuthSessionCookieName, cookieId)
|
||||
|
||||
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
|
||||
// Dumps
|
||||
tlog.App.Trace().Interface("config", app.config).Msg("Config dump")
|
||||
tlog.App.Trace().Interface("users", app.context.users).Msg("Users dump")
|
||||
tlog.App.Trace().Interface("oauthProviders", app.context.oauthProviders).Msg("OAuth providers dump")
|
||||
tlog.App.Trace().Str("cookieDomain", app.context.cookieDomain).Msg("Cookie domain")
|
||||
tlog.App.Trace().Str("sessionCookieName", app.context.sessionCookieName).Msg("Session cookie name")
|
||||
tlog.App.Trace().Str("csrfCookieName", app.context.csrfCookieName).Msg("CSRF cookie name")
|
||||
tlog.App.Trace().Str("redirectCookieName", app.context.redirectCookieName).Msg("Redirect cookie name")
|
||||
|
||||
app.runtime.SessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
|
||||
app.runtime.CSRFCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
|
||||
app.runtime.RedirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
|
||||
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
|
||||
|
||||
// database
|
||||
err = app.SetupDatabase()
|
||||
// Database
|
||||
db, err := app.SetupDatabase(app.config.Database.Path)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup database: %w", err)
|
||||
}
|
||||
|
||||
// after this point, we start initializing dependencies so it's a good time to setup a defer
|
||||
// to ensure that resources are cleaned up properly in case of an error during initialization
|
||||
defer func() {
|
||||
app.cancel()
|
||||
app.wg.Wait()
|
||||
app.db.Close()
|
||||
}()
|
||||
// Queries
|
||||
queries := repository.New(db)
|
||||
|
||||
// queries
|
||||
queries := repository.New(app.db)
|
||||
app.queries = queries
|
||||
|
||||
// services
|
||||
err = app.setupServices()
|
||||
// Services
|
||||
services, err := app.initServices(queries)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
|
||||
// configured providers
|
||||
configuredProviders := make([]model.Provider, 0)
|
||||
app.services = services
|
||||
|
||||
for id, provider := range app.runtime.OAuthProviders {
|
||||
configuredProviders = append(configuredProviders, model.Provider{
|
||||
// Configured providers
|
||||
configuredProviders := make([]controller.Provider, 0)
|
||||
|
||||
for id, provider := range app.context.oauthProviders {
|
||||
configuredProviders = append(configuredProviders, controller.Provider{
|
||||
Name: provider.Name,
|
||||
ID: id,
|
||||
OAuth: true,
|
||||
@@ -202,171 +163,70 @@ func (app *BootstrapApp) Setup() error {
|
||||
return configuredProviders[i].Name < configuredProviders[j].Name
|
||||
})
|
||||
|
||||
if app.services.authService.LocalAuthConfigured() {
|
||||
configuredProviders = append(configuredProviders, model.Provider{
|
||||
if services.authService.LocalAuthConfigured() {
|
||||
configuredProviders = append(configuredProviders, controller.Provider{
|
||||
Name: "Local",
|
||||
ID: "local",
|
||||
OAuth: false,
|
||||
})
|
||||
}
|
||||
|
||||
if app.services.authService.LDAPAuthConfigured() {
|
||||
configuredProviders = append(configuredProviders, model.Provider{
|
||||
if services.authService.LdapAuthConfigured() {
|
||||
configuredProviders = append(configuredProviders, controller.Provider{
|
||||
Name: "LDAP",
|
||||
ID: "ldap",
|
||||
OAuth: false,
|
||||
})
|
||||
}
|
||||
|
||||
tlog.App.Debug().Interface("providers", configuredProviders).Msg("Authentication providers")
|
||||
|
||||
if len(configuredProviders) == 0 {
|
||||
return errors.New("no authentication providers configured")
|
||||
return fmt.Errorf("no authentication providers configured")
|
||||
}
|
||||
|
||||
for _, provider := range configuredProviders {
|
||||
app.log.App.Debug().Str("provider", provider.Name).Msg("Configured authentication provider")
|
||||
}
|
||||
app.context.configuredProviders = configuredProviders
|
||||
|
||||
app.runtime.ConfiguredProviders = configuredProviders
|
||||
|
||||
// setup router
|
||||
err = app.setupRouter()
|
||||
// Setup router
|
||||
router, err := app.setupRouter()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup routes: %w", err)
|
||||
}
|
||||
|
||||
// start db cleanup routine
|
||||
app.log.App.Debug().Msg("Starting database cleanup routine")
|
||||
app.wg.Go(app.dbCleanupRoutine)
|
||||
// Start db cleanup routine
|
||||
tlog.App.Debug().Msg("Starting database cleanup routine")
|
||||
go app.dbCleanupRoutine(queries)
|
||||
|
||||
// if analytics are not disabled, start heartbeat
|
||||
// If analytics are not disabled, start heartbeat
|
||||
if app.config.Analytics.Enabled {
|
||||
app.log.App.Debug().Msg("Starting heartbeat routine")
|
||||
app.wg.Go(app.heartbeatRoutine)
|
||||
tlog.App.Debug().Msg("Starting heartbeat routine")
|
||||
go app.heartbeatRoutine()
|
||||
}
|
||||
|
||||
// create err channel to listen for server errors
|
||||
errChanLen := 0
|
||||
|
||||
runUnix := app.config.Server.SocketPath != ""
|
||||
runHTTP := app.config.Server.SocketPath == "" || app.config.Server.ConcurrentListenersEnabled
|
||||
|
||||
if runUnix {
|
||||
errChanLen++
|
||||
}
|
||||
|
||||
if runHTTP {
|
||||
errChanLen++
|
||||
}
|
||||
|
||||
errChan := make(chan error, errChanLen)
|
||||
|
||||
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
|
||||
for {
|
||||
select {
|
||||
case <-app.ctx.Done():
|
||||
app.log.App.Info().Msg("Oh, it's time for me to go, bye!")
|
||||
return nil
|
||||
case err := <-errChan:
|
||||
// If we have an socket path, bind to it
|
||||
if app.config.Server.SocketPath != "" {
|
||||
if _, err := os.Stat(app.config.Server.SocketPath); err == nil {
|
||||
tlog.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("server error: %w", err)
|
||||
return fmt.Errorf("failed to remove existing socket file: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) serveHTTP() error {
|
||||
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
||||
tlog.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
|
||||
if err := router.RunUnix(app.config.Server.SocketPath); err != nil {
|
||||
tlog.App.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
|
||||
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)
|
||||
// Start server
|
||||
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
|
||||
tlog.App.Info().Msgf("Starting server on %s", address)
|
||||
if err := router.Run(address); err != nil {
|
||||
tlog.App.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -376,20 +236,20 @@ func (app *BootstrapApp) heartbeatRoutine() {
|
||||
ticker := time.NewTicker(time.Duration(12) * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
type Heartbeat struct {
|
||||
type heartbeat struct {
|
||||
UUID string `json:"uuid"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
var body Heartbeat
|
||||
var body heartbeat
|
||||
|
||||
body.UUID = app.runtime.UUID
|
||||
body.Version = model.Version
|
||||
body.UUID = app.context.uuid
|
||||
body.Version = config.Version
|
||||
|
||||
bodyJson, err := json.Marshal(body)
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Error().Err(err).Msg("Failed to marshal heartbeat body, heartbeat routine will not start")
|
||||
tlog.App.Error().Err(err).Msg("Failed to marshal heartbeat body")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -397,62 +257,45 @@ func (app *BootstrapApp) heartbeatRoutine() {
|
||||
Timeout: 30 * time.Second, // The server should never take more than 30 seconds to respond
|
||||
}
|
||||
|
||||
heartbeatURL := model.APIServer + "/v1/instances/heartbeat"
|
||||
heartbeatURL := config.ApiServer + "/v1/instances/heartbeat"
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
app.log.App.Debug().Msg("Sending heartbeat")
|
||||
for range ticker.C {
|
||||
tlog.App.Debug().Msg("Sending heartbeat")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson))
|
||||
req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson))
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Error().Err(err).Msg("Failed to create heartbeat request")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to create heartbeat request")
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
res, err := client.Do(req)
|
||||
res, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Error().Err(err).Msg("Failed to send heartbeat")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to send heartbeat")
|
||||
continue
|
||||
}
|
||||
|
||||
res.Body.Close()
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 && res.StatusCode != 201 {
|
||||
app.log.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
|
||||
}
|
||||
case <-app.ctx.Done():
|
||||
app.log.App.Debug().Msg("Stopping heartbeat routine")
|
||||
ticker.Stop()
|
||||
return
|
||||
if res.StatusCode != 200 && res.StatusCode != 201 {
|
||||
tlog.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) dbCleanupRoutine() {
|
||||
func (app *BootstrapApp) dbCleanupRoutine(queries *repository.Queries) {
|
||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
app.log.App.Debug().Msg("Running database cleanup")
|
||||
|
||||
err := app.queries.DeleteExpiredSessions(app.ctx, time.Now().Unix())
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Error().Err(err).Msg("Failed to delete expired sessions")
|
||||
}
|
||||
|
||||
app.log.App.Debug().Msg("Database cleanup completed")
|
||||
case <-app.ctx.Done():
|
||||
app.log.App.Debug().Msg("Stopping database cleanup routine")
|
||||
ticker.Stop()
|
||||
return
|
||||
for range ticker.C {
|
||||
tlog.App.Debug().Msg("Cleaning up old database sessions")
|
||||
err := queries.DeleteExpiredSessions(ctx, time.Now().Unix())
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to clean up old database sessions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,26 +14,19 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func (app *BootstrapApp) SetupDatabase() error {
|
||||
dir := filepath.Dir(app.config.Database.Path)
|
||||
func (app *BootstrapApp) SetupDatabase(databasePath string) (*sql.DB, error) {
|
||||
dir := filepath.Dir(databasePath)
|
||||
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
||||
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", app.config.Database.Path)
|
||||
db, err := sql.Open("sqlite", databasePath)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Close the database if there is an error during migration
|
||||
defer func() {
|
||||
if err != nil {
|
||||
db.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Limit to 1 connection to sequence writes, this may need to be revisited in the future
|
||||
// if the sqlite connection starts being a bottleneck
|
||||
db.SetMaxOpenConns(1)
|
||||
@@ -41,29 +34,24 @@ func (app *BootstrapApp) SetupDatabase() error {
|
||||
migrations, err := iofs.New(assets.Migrations, "migrations")
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrations: %w", err)
|
||||
return nil, fmt.Errorf("failed to create migrations: %w", err)
|
||||
}
|
||||
|
||||
target, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sqlite3 instance: %w", err)
|
||||
return nil, fmt.Errorf("failed to create sqlite3 instance: %w", err)
|
||||
}
|
||||
|
||||
migrator, err := migrate.NewWithInstance("iofs", migrations, "sqlite3", target)
|
||||
|
||||
if err != nil {
|
||||
return 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 {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
app.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) GetDB() *sql.DB {
|
||||
return app.db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -2,16 +2,21 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (app *BootstrapApp) setupRouter() error {
|
||||
// we don't want gin debug mode
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
var DEV_MODES = []string{"main", "test", "development"}
|
||||
|
||||
func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||
if !slices.Contains(DEV_MODES, config.Version) {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Recovery())
|
||||
@@ -20,36 +25,98 @@ func (app *BootstrapApp) setupRouter() error {
|
||||
err := engine.SetTrustedProxies(app.config.Auth.TrustedProxies)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set trusted proxies: %w", err)
|
||||
return nil, fmt.Errorf("failed to set trusted proxies: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
contextMiddleware := middleware.NewContextMiddleware(app.log, app.runtime, app.services.authService, app.services.oauthBrokerService)
|
||||
engine.Use(contextMiddleware.Middleware())
|
||||
contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
}, app.services.authService, app.services.oauthBrokerService)
|
||||
|
||||
uiMiddleware, err := middleware.NewUIMiddleware()
|
||||
err := contextMiddleware.Init()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize UI middleware: %w", err)
|
||||
return nil, fmt.Errorf("failed to initialize context middleware: %w", err)
|
||||
}
|
||||
|
||||
engine.Use(contextMiddleware.Middleware())
|
||||
|
||||
uiMiddleware := middleware.NewUIMiddleware()
|
||||
|
||||
err = uiMiddleware.Init()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize UI middleware: %w", err)
|
||||
}
|
||||
|
||||
engine.Use(uiMiddleware.Middleware())
|
||||
|
||||
zerologMiddleware := middleware.NewZerologMiddleware(app.log)
|
||||
zerologMiddleware := middleware.NewZerologMiddleware()
|
||||
|
||||
err = zerologMiddleware.Init()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize zerolog middleware: %w", err)
|
||||
}
|
||||
|
||||
engine.Use(zerologMiddleware.Middleware())
|
||||
|
||||
apiRouter := engine.Group("/api")
|
||||
|
||||
controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
|
||||
controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
|
||||
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter)
|
||||
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService)
|
||||
controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
|
||||
controller.NewResourcesController(app.config, &engine.RouterGroup)
|
||||
controller.NewHealthController(apiRouter)
|
||||
controller.NewWellKnownController(app.services.oidcService, &engine.RouterGroup)
|
||||
contextController := controller.NewContextController(controller.ContextControllerConfig{
|
||||
Providers: app.context.configuredProviders,
|
||||
Title: app.config.UI.Title,
|
||||
AppURL: app.config.AppURL,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
ForgotPasswordMessage: app.config.UI.ForgotPasswordMessage,
|
||||
BackgroundImage: app.config.UI.BackgroundImage,
|
||||
OAuthAutoRedirect: app.config.OAuth.AutoRedirect,
|
||||
WarningsEnabled: app.config.UI.WarningsEnabled,
|
||||
}, apiRouter)
|
||||
|
||||
app.router = engine
|
||||
return nil
|
||||
contextController.SetupRoutes()
|
||||
|
||||
oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
SecureCookie: app.config.Auth.SecureCookie,
|
||||
CSRFCookieName: app.context.csrfCookieName,
|
||||
RedirectCookieName: app.context.redirectCookieName,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
OAuthSessionCookieName: app.context.oauthSessionCookieName,
|
||||
}, apiRouter, app.services.authService)
|
||||
|
||||
oauthController.SetupRoutes()
|
||||
|
||||
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter)
|
||||
|
||||
oidcController.SetupRoutes()
|
||||
|
||||
proxyController := controller.NewProxyController(controller.ProxyControllerConfig{
|
||||
AppURL: app.config.AppURL,
|
||||
}, apiRouter, app.services.accessControlService, app.services.authService)
|
||||
|
||||
proxyController.SetupRoutes()
|
||||
|
||||
userController := controller.NewUserController(controller.UserControllerConfig{
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
}, apiRouter, app.services.authService)
|
||||
|
||||
userController.SetupRoutes()
|
||||
|
||||
resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{
|
||||
Path: app.config.Resources.Path,
|
||||
Enabled: app.config.Resources.Enabled,
|
||||
}, &engine.RouterGroup)
|
||||
|
||||
resourcesController.SetupRoutes()
|
||||
|
||||
healthController := controller.NewHealthController(apiRouter)
|
||||
|
||||
healthController.SetupRoutes()
|
||||
|
||||
wellknownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, app.services.oidcService, engine)
|
||||
|
||||
wellknownController.SetupRoutes()
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
@@ -1,66 +1,110 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
func (app *BootstrapApp) setupServices() error {
|
||||
ldapService, err := service.NewLdapService(app.log, app.config, app.ctx, &app.wg)
|
||||
|
||||
if err != nil {
|
||||
app.log.App.Warn().Err(err).Msg("Failed to initialize LDAP connection, will continue without it")
|
||||
}
|
||||
|
||||
app.services.ldapService = ldapService
|
||||
|
||||
useKubernetes := app.config.LabelProvider == "kubernetes" ||
|
||||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
|
||||
|
||||
var labelProvider service.LabelProvider
|
||||
|
||||
if useKubernetes {
|
||||
app.log.App.Debug().Msg("Using Kubernetes label provider")
|
||||
|
||||
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
|
||||
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps)
|
||||
app.services.accessControlService = accessControlsService
|
||||
|
||||
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
|
||||
app.services.oauthBrokerService = oauthBrokerService
|
||||
|
||||
authService := service.NewAuthService(app.log, app.config, app.runtime, app.ctx, &app.wg, app.services.ldapService, app.queries, app.services.oauthBrokerService)
|
||||
app.services.authService = authService
|
||||
|
||||
oidcService, err := service.NewOIDCService(app.log, app.config, app.runtime, app.queries, app.ctx, &app.wg)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize oidc service: %w", err)
|
||||
}
|
||||
|
||||
app.services.oidcService = oidcService
|
||||
|
||||
return nil
|
||||
type Services struct {
|
||||
accessControlService *service.AccessControlsService
|
||||
authService *service.AuthService
|
||||
dockerService *service.DockerService
|
||||
ldapService *service.LdapService
|
||||
oauthBrokerService *service.OAuthBrokerService
|
||||
oidcService *service.OIDCService
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, error) {
|
||||
services := Services{}
|
||||
|
||||
ldapService := service.NewLdapService(service.LdapServiceConfig{
|
||||
Address: app.config.Ldap.Address,
|
||||
BindDN: app.config.Ldap.BindDN,
|
||||
BindPassword: app.config.Ldap.BindPassword,
|
||||
BaseDN: app.config.Ldap.BaseDN,
|
||||
Insecure: app.config.Ldap.Insecure,
|
||||
SearchFilter: app.config.Ldap.SearchFilter,
|
||||
AuthCert: app.config.Ldap.AuthCert,
|
||||
AuthKey: app.config.Ldap.AuthKey,
|
||||
})
|
||||
|
||||
err := ldapService.Init()
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Warn().Err(err).Msg("Failed to setup LDAP service, starting without it")
|
||||
ldapService.Unconfigure()
|
||||
}
|
||||
|
||||
services.ldapService = ldapService
|
||||
|
||||
dockerService := service.NewDockerService()
|
||||
|
||||
err = dockerService.Init()
|
||||
|
||||
if err != nil {
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
services.dockerService = dockerService
|
||||
|
||||
accessControlsService := service.NewAccessControlsService(dockerService, app.config.Apps)
|
||||
|
||||
err = accessControlsService.Init()
|
||||
|
||||
if err != nil {
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
services.accessControlService = accessControlsService
|
||||
|
||||
oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders)
|
||||
|
||||
err = oauthBrokerService.Init()
|
||||
|
||||
if err != nil {
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
services.oauthBrokerService = oauthBrokerService
|
||||
|
||||
authService := service.NewAuthService(service.AuthServiceConfig{
|
||||
Users: app.context.users,
|
||||
OauthWhitelist: app.config.OAuth.Whitelist,
|
||||
SessionExpiry: app.config.Auth.SessionExpiry,
|
||||
SessionMaxLifetime: app.config.Auth.SessionMaxLifetime,
|
||||
SecureCookie: app.config.Auth.SecureCookie,
|
||||
CookieDomain: app.context.cookieDomain,
|
||||
LoginTimeout: app.config.Auth.LoginTimeout,
|
||||
LoginMaxRetries: app.config.Auth.LoginMaxRetries,
|
||||
SessionCookieName: app.context.sessionCookieName,
|
||||
IP: app.config.Auth.IP,
|
||||
LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL,
|
||||
}, dockerService, services.ldapService, queries, services.oauthBrokerService)
|
||||
|
||||
err = authService.Init()
|
||||
|
||||
if err != nil {
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
services.authService = authService
|
||||
|
||||
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
|
||||
Clients: app.config.OIDC.Clients,
|
||||
PrivateKeyPath: app.config.OIDC.PrivateKeyPath,
|
||||
PublicKeyPath: app.config.OIDC.PublicKeyPath,
|
||||
Issuer: app.config.AppURL,
|
||||
SessionExpiry: app.config.Auth.SessionExpiry,
|
||||
}, queries)
|
||||
|
||||
err = oidcService.Init()
|
||||
|
||||
if err != nil {
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
services.oidcService = oidcService
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package config
|
||||
|
||||
// Default configuration
|
||||
func NewDefaultConfiguration() *Config {
|
||||
@@ -14,12 +14,10 @@ func NewDefaultConfiguration() *Config {
|
||||
Path: "./resources",
|
||||
},
|
||||
Server: ServerConfig{
|
||||
Port: 3000,
|
||||
Address: "0.0.0.0",
|
||||
ConcurrentListenersEnabled: false,
|
||||
Port: 3000,
|
||||
Address: "0.0.0.0",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
SubdomainsEnabled: true,
|
||||
SessionExpiry: 86400, // 1 day
|
||||
SessionMaxLifetime: 0, // disabled
|
||||
LoginTimeout: 300, // 5 minutes
|
||||
@@ -31,7 +29,7 @@ func NewDefaultConfiguration() *Config {
|
||||
BackgroundImage: "/background.jpg",
|
||||
WarningsEnabled: true,
|
||||
},
|
||||
LDAP: LDAPConfig{
|
||||
Ldap: LdapConfig{
|
||||
Insecure: false,
|
||||
SearchFilter: "(uid=%s)",
|
||||
GroupCacheTTL: 900, // 15 minutes
|
||||
@@ -61,25 +59,38 @@ func NewDefaultConfiguration() *Config {
|
||||
Experimental: ExperimentalConfig{
|
||||
ConfigFile: "",
|
||||
},
|
||||
LabelProvider: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
// Version information, set at build time
|
||||
|
||||
var Version = "development"
|
||||
var CommitHash = "development"
|
||||
var BuildTimestamp = "0000-00-00T00:00:00Z"
|
||||
|
||||
// Cookie name templates
|
||||
|
||||
var SessionCookieName = "tinyauth-session"
|
||||
var CSRFCookieName = "tinyauth-csrf"
|
||||
var RedirectCookieName = "tinyauth-redirect"
|
||||
var OAuthSessionCookieName = "tinyauth-oauth"
|
||||
|
||||
// Main app config
|
||||
|
||||
type Config struct {
|
||||
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
|
||||
Database DatabaseConfig `description:"Database configuration." yaml:"database"`
|
||||
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"`
|
||||
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"`
|
||||
Server ServerConfig `description:"Server configuration." yaml:"server"`
|
||||
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
|
||||
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
|
||||
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
|
||||
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
|
||||
UI UIConfig `description:"UI customization." yaml:"ui"`
|
||||
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"`
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
|
||||
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, or kubernetes). auto detects the environment." yaml:"labelProvider"`
|
||||
Log LogConfig `description:"Logging configuration." yaml:"log"`
|
||||
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
|
||||
Database DatabaseConfig `description:"Database configuration." yaml:"database"`
|
||||
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"`
|
||||
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"`
|
||||
Server ServerConfig `description:"Server configuration." yaml:"server"`
|
||||
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
|
||||
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
|
||||
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
|
||||
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
|
||||
UI UIConfig `description:"UI customization." yaml:"ui"`
|
||||
Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"`
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
|
||||
Log LogConfig `description:"Logging configuration." yaml:"log"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -96,51 +107,21 @@ type ResourcesConfig struct {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `description:"The port on which the server listens." yaml:"port"`
|
||||
Address string `description:"The address on which the server listens." yaml:"address"`
|
||||
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"`
|
||||
Port int `description:"The port on which the server listens." yaml:"port"`
|
||||
Address string `description:"The address on which the server listens." yaml:"address"`
|
||||
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
|
||||
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
|
||||
SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled"`
|
||||
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"`
|
||||
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
|
||||
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
|
||||
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
|
||||
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
||||
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
||||
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
||||
}
|
||||
|
||||
type UserAttributes struct {
|
||||
Name string `description:"Full name of the user." yaml:"name"`
|
||||
GivenName string `description:"Given (first) name of the user." yaml:"givenName"`
|
||||
FamilyName string `description:"Family (last) name of the user." yaml:"familyName"`
|
||||
MiddleName string `description:"Middle name of the user." yaml:"middleName"`
|
||||
Nickname string `description:"Nickname of the user." yaml:"nickname"`
|
||||
Profile string `description:"URL of the user's profile page." yaml:"profile"`
|
||||
Picture string `description:"URL of the user's profile picture." yaml:"picture"`
|
||||
Website string `description:"URL of the user's website." yaml:"website"`
|
||||
Email string `description:"Email address of the user." yaml:"email"`
|
||||
Gender string `description:"Gender of the user." yaml:"gender"`
|
||||
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"`
|
||||
Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"`
|
||||
PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"`
|
||||
Address AddressClaim `description:"Address of the user." yaml:"address"`
|
||||
}
|
||||
|
||||
type AddressClaim struct {
|
||||
Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"`
|
||||
StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"`
|
||||
Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"`
|
||||
Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"`
|
||||
PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"`
|
||||
Country string `description:"Country." yaml:"country" json:"country,omitempty"`
|
||||
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
|
||||
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
|
||||
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
|
||||
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
|
||||
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
|
||||
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
|
||||
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
||||
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
||||
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
||||
}
|
||||
|
||||
type IPConfig struct {
|
||||
@@ -149,10 +130,9 @@ type IPConfig struct {
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
|
||||
WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"`
|
||||
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
|
||||
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
|
||||
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
|
||||
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
|
||||
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
@@ -168,7 +148,7 @@ type UIConfig struct {
|
||||
WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled"`
|
||||
}
|
||||
|
||||
type LDAPConfig struct {
|
||||
type LdapConfig struct {
|
||||
Address string `description:"LDAP server address." yaml:"address"`
|
||||
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
|
||||
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
|
||||
@@ -201,6 +181,20 @@ type ExperimentalConfig struct {
|
||||
ConfigFile string `description:"Path to config file." yaml:"-"`
|
||||
}
|
||||
|
||||
// Config loader options
|
||||
|
||||
const DefaultNamePrefix = "TINYAUTH_"
|
||||
|
||||
// OAuth/OIDC config
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Groups any `json:"groups"`
|
||||
}
|
||||
|
||||
type OAuthServiceConfig struct {
|
||||
ClientID string `description:"OAuth client ID." yaml:"clientId"`
|
||||
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"`
|
||||
@@ -223,6 +217,58 @@ type OIDCClientConfig struct {
|
||||
Name string `description:"Client name in UI." yaml:"name"`
|
||||
}
|
||||
|
||||
var OverrideProviders = map[string]string{
|
||||
"google": "Google",
|
||||
"github": "GitHub",
|
||||
}
|
||||
|
||||
// User/session related stuff
|
||||
|
||||
type User struct {
|
||||
Username string
|
||||
Password string
|
||||
TotpSecret string
|
||||
}
|
||||
|
||||
type LdapUser struct {
|
||||
DN string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
type UserSearch struct {
|
||||
Username string
|
||||
Type string // local, ldap or unknown
|
||||
}
|
||||
|
||||
type UserContext struct {
|
||||
Username string
|
||||
Name string
|
||||
Email string
|
||||
IsLoggedIn bool
|
||||
IsBasicAuth bool
|
||||
OAuth bool
|
||||
Provider string
|
||||
TotpPending bool
|
||||
OAuthGroups string
|
||||
TotpEnabled bool
|
||||
OAuthName string
|
||||
OAuthSub string
|
||||
LdapGroups string
|
||||
}
|
||||
|
||||
// API responses and queries
|
||||
|
||||
type UnauthorizedQuery struct {
|
||||
Username string `url:"username"`
|
||||
Resource string `url:"resource"`
|
||||
GroupErr bool `url:"groupErr"`
|
||||
IP string `url:"ip"`
|
||||
}
|
||||
|
||||
type RedirectQuery struct {
|
||||
RedirectURI string `url:"redirect_uri"`
|
||||
}
|
||||
|
||||
// ACLs
|
||||
|
||||
type Apps struct {
|
||||
@@ -278,3 +324,7 @@ type AppPath struct {
|
||||
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"`
|
||||
Block string `description:"Comma-separated list of blocked paths." yaml:"block"`
|
||||
}
|
||||
|
||||
// API server
|
||||
|
||||
var ApiServer = "https://api.tinyauth.app"
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -19,86 +19,94 @@ type UserContextResponse struct {
|
||||
Email string `json:"email"`
|
||||
Provider string `json:"provider"`
|
||||
OAuth bool `json:"oauth"`
|
||||
TOTPPending bool `json:"totpPending"`
|
||||
TotpPending bool `json:"totpPending"`
|
||||
OAuthName string `json:"oauthName"`
|
||||
}
|
||||
|
||||
type AppContextResponse struct {
|
||||
Status int `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Providers []model.Provider `json:"providers"`
|
||||
Title string `json:"title"`
|
||||
AppURL string `json:"appUrl"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
||||
BackgroundImage string `json:"backgroundImage"`
|
||||
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
||||
WarningsEnabled bool `json:"warningsEnabled"`
|
||||
Status int `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Providers []Provider `json:"providers"`
|
||||
Title string `json:"title"`
|
||||
AppURL string `json:"appUrl"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
||||
BackgroundImage string `json:"backgroundImage"`
|
||||
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
||||
WarningsEnabled bool `json:"warningsEnabled"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
OAuth bool `json:"oauth"`
|
||||
}
|
||||
|
||||
type ContextControllerConfig struct {
|
||||
Providers []Provider
|
||||
Title string
|
||||
AppURL string
|
||||
CookieDomain string
|
||||
ForgotPasswordMessage string
|
||||
BackgroundImage string
|
||||
OAuthAutoRedirect string
|
||||
WarningsEnabled bool
|
||||
}
|
||||
|
||||
type ContextController struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
config ContextControllerConfig
|
||||
router *gin.RouterGroup
|
||||
}
|
||||
|
||||
func NewContextController(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtimeConfig model.RuntimeConfig,
|
||||
router *gin.RouterGroup,
|
||||
) *ContextController {
|
||||
controller := &ContextController{
|
||||
log: log,
|
||||
config: config,
|
||||
runtime: runtimeConfig,
|
||||
func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController {
|
||||
if !config.WarningsEnabled {
|
||||
tlog.App.Warn().Msg("UI warnings are disabled. This may expose users to security risks. Proceed with caution.")
|
||||
}
|
||||
|
||||
if !config.UI.WarningsEnabled {
|
||||
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.")
|
||||
return &ContextController{
|
||||
config: config,
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
|
||||
contextGroup := router.Group("/context")
|
||||
func (controller *ContextController) SetupRoutes() {
|
||||
contextGroup := controller.router.Group("/context")
|
||||
contextGroup.GET("/user", controller.userContextHandler)
|
||||
contextGroup.GET("/app", controller.appContextHandler)
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func (controller *ContextController) userContextHandler(c *gin.Context) {
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
||||
c.JSON(200, UserContextResponse{
|
||||
Status: 401,
|
||||
Message: "Unauthorized",
|
||||
IsLoggedIn: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
context, err := utils.GetContext(c)
|
||||
|
||||
userContext := UserContextResponse{
|
||||
Status: 200,
|
||||
Message: "Success",
|
||||
IsLoggedIn: context.Authenticated,
|
||||
Username: context.GetUsername(),
|
||||
Name: context.GetName(),
|
||||
Email: context.GetEmail(),
|
||||
Provider: context.GetProviderID(),
|
||||
OAuth: context.IsOAuth(),
|
||||
TOTPPending: context.TOTPPending(),
|
||||
OAuthName: context.OAuthName(),
|
||||
IsLoggedIn: context.IsLoggedIn,
|
||||
Username: context.Username,
|
||||
Name: context.Name,
|
||||
Email: context.Email,
|
||||
Provider: context.Provider,
|
||||
OAuth: context.OAuth,
|
||||
TotpPending: context.TotpPending,
|
||||
OAuthName: context.OAuthName,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Debug().Err(err).Msg("No user context found in request")
|
||||
userContext.Status = 401
|
||||
userContext.Message = "Unauthorized"
|
||||
userContext.IsLoggedIn = false
|
||||
c.JSON(200, userContext)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, userContext)
|
||||
}
|
||||
|
||||
func (controller *ContextController) appContextHandler(c *gin.Context) {
|
||||
appUrl, err := url.Parse(controller.runtime.AppURL)
|
||||
|
||||
appUrl, err := url.Parse(controller.config.AppURL)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
|
||||
tlog.App.Error().Err(err).Msg("Failed to parse app URL")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -109,13 +117,13 @@ func (controller *ContextController) appContextHandler(c *gin.Context) {
|
||||
c.JSON(200, AppContextResponse{
|
||||
Status: 200,
|
||||
Message: "Success",
|
||||
Providers: controller.runtime.ConfiguredProviders,
|
||||
Title: controller.config.UI.Title,
|
||||
Providers: controller.config.Providers,
|
||||
Title: controller.config.Title,
|
||||
AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
|
||||
CookieDomain: controller.runtime.CookieDomain,
|
||||
ForgotPasswordMessage: controller.config.UI.ForgotPasswordMessage,
|
||||
BackgroundImage: controller.config.UI.BackgroundImage,
|
||||
OAuthAutoRedirect: controller.config.OAuth.AutoRedirect,
|
||||
WarningsEnabled: controller.config.UI.WarningsEnabled,
|
||||
CookieDomain: controller.config.CookieDomain,
|
||||
ForgotPasswordMessage: controller.config.ForgotPasswordMessage,
|
||||
BackgroundImage: controller.config.BackgroundImage,
|
||||
OAuthAutoRedirect: controller.config.OAuthAutoRedirect,
|
||||
WarningsEnabled: controller.config.WarningsEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,20 +7,31 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContextController(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
tlog.NewTestLogger().Init()
|
||||
controllerConfig := controller.ContextControllerConfig{
|
||||
Providers: []controller.Provider{
|
||||
{
|
||||
Name: "Local",
|
||||
ID: "local",
|
||||
OAuth: false,
|
||||
},
|
||||
},
|
||||
Title: "Tinyauth",
|
||||
AppURL: "https://tinyauth.example.com",
|
||||
CookieDomain: "example.com",
|
||||
ForgotPasswordMessage: "foo",
|
||||
BackgroundImage: "/background.jpg",
|
||||
OAuthAutoRedirect: "none",
|
||||
WarningsEnabled: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
description string
|
||||
@@ -36,17 +47,17 @@ func TestContextController(t *testing.T) {
|
||||
expectedAppContextResponse := controller.AppContextResponse{
|
||||
Status: 200,
|
||||
Message: "Success",
|
||||
Providers: runtime.ConfiguredProviders,
|
||||
Title: cfg.UI.Title,
|
||||
AppURL: runtime.AppURL,
|
||||
CookieDomain: runtime.CookieDomain,
|
||||
ForgotPasswordMessage: cfg.UI.ForgotPasswordMessage,
|
||||
BackgroundImage: cfg.UI.BackgroundImage,
|
||||
OAuthAutoRedirect: cfg.OAuth.AutoRedirect,
|
||||
WarningsEnabled: cfg.UI.WarningsEnabled,
|
||||
Providers: controllerConfig.Providers,
|
||||
Title: controllerConfig.Title,
|
||||
AppURL: controllerConfig.AppURL,
|
||||
CookieDomain: controllerConfig.CookieDomain,
|
||||
ForgotPasswordMessage: controllerConfig.ForgotPasswordMessage,
|
||||
BackgroundImage: controllerConfig.BackgroundImage,
|
||||
OAuthAutoRedirect: controllerConfig.OAuthAutoRedirect,
|
||||
WarningsEnabled: controllerConfig.WarningsEnabled,
|
||||
}
|
||||
bytes, err := json.Marshal(expectedAppContextResponse)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
return string(bytes)
|
||||
}(),
|
||||
},
|
||||
@@ -60,7 +71,7 @@ func TestContextController(t *testing.T) {
|
||||
Message: "Unauthorized",
|
||||
}
|
||||
bytes, err := json.Marshal(expectedUserContextResponse)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
return string(bytes)
|
||||
}(),
|
||||
},
|
||||
@@ -68,16 +79,12 @@ func TestContextController(t *testing.T) {
|
||||
description: "Ensure user context returns when authorized",
|
||||
middlewares: []gin.HandlerFunc{
|
||||
func(c *gin.Context) {
|
||||
c.Set("context", &model.UserContext{
|
||||
Authenticated: true,
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: "johndoe",
|
||||
Name: "John Doe",
|
||||
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain),
|
||||
},
|
||||
},
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: "johndoe",
|
||||
Name: "John Doe",
|
||||
Email: utils.CompileUserEmail("johndoe", controllerConfig.CookieDomain),
|
||||
Provider: "local",
|
||||
IsLoggedIn: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -89,11 +96,11 @@ func TestContextController(t *testing.T) {
|
||||
IsLoggedIn: true,
|
||||
Username: "johndoe",
|
||||
Name: "John Doe",
|
||||
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain),
|
||||
Email: utils.CompileUserEmail("johndoe", controllerConfig.CookieDomain),
|
||||
Provider: "local",
|
||||
}
|
||||
bytes, err := json.Marshal(expectedUserContextResponse)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
return string(bytes)
|
||||
}(),
|
||||
},
|
||||
@@ -110,12 +117,13 @@ func TestContextController(t *testing.T) {
|
||||
group := router.Group("/api")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
controller.NewContextController(log, cfg, runtime, group)
|
||||
contextController := controller.NewContextController(controllerConfig, group)
|
||||
contextController.SetupRoutes()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
request, err := http.NewRequest("GET", test.path, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package controller
|
||||
|
||||
type UnauthorizedQuery struct {
|
||||
Username string `url:"username"`
|
||||
Resource string `url:"resource"`
|
||||
GroupErr bool `url:"groupErr"`
|
||||
IP string `url:"ip"`
|
||||
}
|
||||
|
||||
type RedirectQuery struct {
|
||||
RedirectURI string `url:"redirect_uri"`
|
||||
}
|
||||
@@ -3,15 +3,18 @@ package controller
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type HealthController struct {
|
||||
router *gin.RouterGroup
|
||||
}
|
||||
|
||||
func NewHealthController(router *gin.RouterGroup) *HealthController {
|
||||
controller := &HealthController{}
|
||||
return &HealthController{
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
|
||||
router.GET("/healthz", controller.healthHandler)
|
||||
router.HEAD("/healthz", controller.healthHandler)
|
||||
|
||||
return controller
|
||||
func (controller *HealthController) SetupRoutes() {
|
||||
controller.router.GET("/healthz", controller.healthHandler)
|
||||
controller.router.HEAD("/healthz", controller.healthHandler)
|
||||
}
|
||||
|
||||
func (controller *HealthController) healthHandler(c *gin.Context) {
|
||||
|
||||
@@ -7,12 +7,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHealthController(t *testing.T) {
|
||||
tlog.NewTestLogger().Init()
|
||||
tests := []struct {
|
||||
description string
|
||||
path string
|
||||
@@ -29,7 +30,7 @@ func TestHealthController(t *testing.T) {
|
||||
"message": "Healthy",
|
||||
}
|
||||
bytes, err := json.Marshal(expectedHealthResponse)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
return string(bytes)
|
||||
}(),
|
||||
},
|
||||
@@ -43,7 +44,7 @@ func TestHealthController(t *testing.T) {
|
||||
"message": "Healthy",
|
||||
}
|
||||
bytes, err := json.Marshal(expectedHealthResponse)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
return string(bytes)
|
||||
}(),
|
||||
},
|
||||
@@ -55,12 +56,13 @@ func TestHealthController(t *testing.T) {
|
||||
group := router.Group("/api")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
controller.NewHealthController(group)
|
||||
healthController := controller.NewHealthController(group)
|
||||
healthController.SetupRoutes()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
request, err := http.NewRequest(test.method, test.path, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"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/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
@@ -20,32 +20,33 @@ type OAuthRequest struct {
|
||||
Provider string `uri:"provider" binding:"required"`
|
||||
}
|
||||
|
||||
type OAuthController struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
auth *service.AuthService
|
||||
type OAuthControllerConfig struct {
|
||||
CSRFCookieName string
|
||||
OAuthSessionCookieName string
|
||||
RedirectCookieName string
|
||||
SecureCookie bool
|
||||
AppURL string
|
||||
CookieDomain string
|
||||
}
|
||||
|
||||
func NewOAuthController(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtimeConfig model.RuntimeConfig,
|
||||
router *gin.RouterGroup,
|
||||
auth *service.AuthService,
|
||||
) *OAuthController {
|
||||
controller := &OAuthController{
|
||||
log: log,
|
||||
config: config,
|
||||
runtime: runtimeConfig,
|
||||
auth: auth,
|
||||
}
|
||||
type OAuthController struct {
|
||||
config OAuthControllerConfig
|
||||
router *gin.RouterGroup
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
oauthGroup := router.Group("/oauth")
|
||||
func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *OAuthController {
|
||||
return &OAuthController{
|
||||
config: config,
|
||||
router: router,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *OAuthController) SetupRoutes() {
|
||||
oauthGroup := controller.router.Group("/oauth")
|
||||
oauthGroup.GET("/url/:provider", controller.oauthURLHandler)
|
||||
oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler)
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
@@ -53,7 +54,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
|
||||
err := c.BindUri(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -66,7 +67,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
err = c.BindQuery(&reqParams)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind query parameters")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind query parameters")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -75,10 +76,10 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !controller.isOidcRequest(reqParams) {
|
||||
isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.runtime.CookieDomain)
|
||||
isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.config.CookieDomain)
|
||||
|
||||
if !isRedirectSafe {
|
||||
controller.log.App.Warn().Str("redirectUri", reqParams.RedirectURI).Msg("Unsafe redirect URI, ignoring")
|
||||
tlog.App.Warn().Str("redirect_uri", reqParams.RedirectURI).Msg("Unsafe redirect URI detected, ignoring")
|
||||
reqParams.RedirectURI = ""
|
||||
}
|
||||
}
|
||||
@@ -86,7 +87,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
sessionId, _, err := controller.auth.NewOAuthSession(req.Provider, reqParams)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create OAuth session")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -97,7 +98,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
authUrl, err := controller.auth.GetOAuthURL(sessionId)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get OAuth URL for session")
|
||||
tlog.App.Error().Err(err).Msg("Failed to get OAuth URL")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -105,7 +106,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie(controller.runtime.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true)
|
||||
c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
@@ -119,7 +120,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
|
||||
err := c.BindUri(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -127,21 +128,21 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionIdCookie, err := c.Cookie(controller.runtime.OAuthSessionCookieName)
|
||||
sessionIdCookie, err := c.Cookie(controller.config.OAuthSessionCookieName)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get OAuth session cookie")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Warn().Err(err).Msg("OAuth session cookie missing")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie(controller.runtime.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true)
|
||||
c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
|
||||
|
||||
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get pending OAuth session")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Error().Err(err).Msg("Failed to get OAuth pending session")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,8 +150,8 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
|
||||
state := c.Query("state")
|
||||
if state != oauthPendingSession.State {
|
||||
controller.log.App.Warn().Msg("OAuth state mismatch")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Warn().Err(err).Msg("CSRF token mismatch")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -158,80 +159,68 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
_, err = controller.auth.GetOAuthToken(sessionIdCookie, code)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to exchange code for token")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Error().Err(err).Msg("Failed to exchange code for token")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := controller.auth.GetOAuthUserinfo(sessionIdCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get user info from OAuth provider")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
controller.log.App.Warn().Msg("OAuth provider did not return user info")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
if user.Email == "" {
|
||||
controller.log.App.Warn().Msg("OAuth provider did not return an email")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Error().Msg("OAuth provider did not return an email")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
if !controller.auth.IsEmailWhitelisted(user.Email) {
|
||||
controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access")
|
||||
controller.log.AuditLoginFailure(user.Email, req.Provider, c.ClientIP(), "email not whitelisted")
|
||||
tlog.App.Warn().Str("email", user.Email).Msg("Email not whitelisted")
|
||||
tlog.AuditLoginFailure(c, user.Email, req.Provider, "email not whitelisted")
|
||||
|
||||
queries, err := query.Values(UnauthorizedQuery{
|
||||
queries, err := query.Values(config.UnauthorizedQuery{
|
||||
Username: user.Email,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode()))
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode()))
|
||||
return
|
||||
}
|
||||
|
||||
var name string
|
||||
|
||||
if strings.TrimSpace(user.Name) != "" {
|
||||
controller.log.App.Debug().Msg("Using name from OAuth provider")
|
||||
tlog.App.Debug().Msg("Using name from OAuth provider")
|
||||
name = user.Name
|
||||
} else {
|
||||
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
|
||||
tlog.App.Debug().Msg("No name from OAuth provider, using pseudo name")
|
||||
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
|
||||
}
|
||||
|
||||
var username string
|
||||
|
||||
if strings.TrimSpace(user.PreferredUsername) != "" {
|
||||
controller.log.App.Debug().Msg("Using preferred username from OAuth provider")
|
||||
tlog.App.Debug().Msg("Using preferred username from OAuth provider")
|
||||
username = user.PreferredUsername
|
||||
} else {
|
||||
controller.log.App.Debug().Msg("No preferred username from OAuth provider, generating from email")
|
||||
tlog.App.Debug().Msg("No preferred username from OAuth provider, using pseudo username")
|
||||
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))
|
||||
tlog.App.Error().Err(err).Msg("Failed to get OAuth service for session")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.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))
|
||||
tlog.App.Error().Msgf("OAuth service ID mismatch: expected %s, got %s", svc.ID(), req.Provider)
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -245,48 +234,46 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
||||
OAuthSub: user.Sub,
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Msg("Creating session cookie for user")
|
||||
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||
|
||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
||||
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
|
||||
controller.log.AuditLoginSuccess(sessionCookie.Username, sessionCookie.Provider, c.ClientIP())
|
||||
tlog.AuditLoginSuccess(c, sessionCookie.Username, sessionCookie.Provider)
|
||||
|
||||
if controller.isOidcRequest(oauthPendingSession.CallbackParams) {
|
||||
controller.log.App.Debug().Msg("OIDC request detected, redirecting to authorization endpoint with callback params")
|
||||
tlog.App.Debug().Msg("OIDC request, redirecting to authorize page")
|
||||
queries, err := query.Values(oauthPendingSession.CallbackParams)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode OIDC callback query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode OIDC callback query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/authorize?%s", controller.runtime.AppURL, queries.Encode()))
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/authorize?%s", controller.config.AppURL, queries.Encode()))
|
||||
return
|
||||
}
|
||||
|
||||
if oauthPendingSession.CallbackParams.RedirectURI != "" {
|
||||
queries, err := query.Values(RedirectQuery{
|
||||
queries, err := query.Values(config.RedirectQuery{
|
||||
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode redirect query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query")
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", controller.runtime.AppURL, queries.Encode()))
|
||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", controller.config.AppURL, queries.Encode()))
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, controller.runtime.AppURL)
|
||||
c.Redirect(http.StatusTemporaryRedirect, controller.config.AppURL)
|
||||
}
|
||||
|
||||
func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams) bool {
|
||||
@@ -295,10 +282,3 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams)
|
||||
params.ClientID != "" &&
|
||||
params.RedirectURI != ""
|
||||
}
|
||||
|
||||
func (controller *OAuthController) getCookieDomain() string {
|
||||
if controller.config.Auth.SubdomainsEnabled {
|
||||
return "." + controller.runtime.CookieDomain
|
||||
}
|
||||
return controller.runtime.CookieDomain
|
||||
}
|
||||
|
||||
@@ -10,16 +10,17 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
type OIDCControllerConfig struct{}
|
||||
|
||||
type OIDCController struct {
|
||||
log *logger.Logger
|
||||
oidc *service.OIDCService
|
||||
runtime model.RuntimeConfig
|
||||
config OIDCControllerConfig
|
||||
router *gin.RouterGroup
|
||||
oidc *service.OIDCService
|
||||
}
|
||||
|
||||
type AuthorizeCallback struct {
|
||||
@@ -56,42 +57,29 @@ type ClientCredentials struct {
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
func NewOIDCController(
|
||||
log *logger.Logger,
|
||||
oidcService *service.OIDCService,
|
||||
runtimeConfig model.RuntimeConfig,
|
||||
router *gin.RouterGroup) *OIDCController {
|
||||
controller := &OIDCController{
|
||||
log: log,
|
||||
oidc: oidcService,
|
||||
runtime: runtimeConfig,
|
||||
func NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup) *OIDCController {
|
||||
return &OIDCController{
|
||||
config: config,
|
||||
oidc: oidcService,
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
|
||||
oidcGroup := router.Group("/oidc")
|
||||
func (controller *OIDCController) SetupRoutes() {
|
||||
oidcGroup := controller.router.Group("/oidc")
|
||||
oidcGroup.GET("/clients/:id", controller.GetClientInfo)
|
||||
oidcGroup.POST("/authorize", controller.Authorize)
|
||||
oidcGroup.POST("/token", controller.Token)
|
||||
oidcGroup.GET("/userinfo", controller.Userinfo)
|
||||
oidcGroup.POST("/userinfo", controller.Userinfo)
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||
if controller.oidc == nil {
|
||||
controller.log.App.Warn().Msg("Received OIDC client info request but OIDC server is not configured")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "OIDC not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req ClientRequest
|
||||
|
||||
err := c.BindUri(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -102,7 +90,7 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||
client, ok := controller.oidc.GetClient(req.ClientID)
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Str("clientId", req.ClientID).Msg("Client not found")
|
||||
tlog.App.Warn().Str("client_id", req.ClientID).Msg("Client not found")
|
||||
c.JSON(404, gin.H{
|
||||
"status": 404,
|
||||
"message": "Client not found",
|
||||
@@ -118,19 +106,19 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
if controller.oidc == nil {
|
||||
if !controller.oidc.IsConfigured() {
|
||||
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)
|
||||
userContext, err := utils.GetContext(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 {
|
||||
if !userContext.IsLoggedIn {
|
||||
controller.authorizeError(c, errors.New("err user not logged in"), "User not logged in", "The user is not logged in", "", "", "")
|
||||
return
|
||||
}
|
||||
@@ -153,7 +141,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
err = controller.oidc.ValidateAuthorizeParams(req)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params")
|
||||
tlog.App.Error().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
|
||||
@@ -163,7 +151,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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))
|
||||
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.Username, client.ID))
|
||||
code := utils.GenerateString(32)
|
||||
|
||||
// Before storing the code, delete old session
|
||||
@@ -182,10 +170,10 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
|
||||
// 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)
|
||||
err = controller.oidc.StoreUserinfo(c, sub, userContext, req)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to store user info")
|
||||
tlog.App.Error().Err(err).Msg("Failed to insert user info into database")
|
||||
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
|
||||
return
|
||||
}
|
||||
@@ -208,10 +196,10 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (controller *OIDCController) Token(c *gin.Context) {
|
||||
if controller.oidc == nil {
|
||||
controller.log.App.Warn().Msg("Received OIDC request but OIDC server is not configured")
|
||||
c.JSON(500, gin.H{
|
||||
"error": "server_error",
|
||||
if !controller.oidc.IsConfigured() {
|
||||
tlog.App.Warn().Msg("OIDC not configured")
|
||||
c.JSON(404, gin.H{
|
||||
"error": "not_found",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -220,7 +208,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
err := c.Bind(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to bind token request")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind token request")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -229,7 +217,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
err = controller.oidc.ValidateGrantType(req.GrantType)
|
||||
if err != nil {
|
||||
controller.log.App.Warn().Err(err).Msg("Invalid grant type")
|
||||
tlog.App.Warn().Str("grant_type", req.GrantType).Msg("Unsupported grant type")
|
||||
c.JSON(400, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
@@ -244,12 +232,12 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
// If it fails, we try basic auth
|
||||
if creds.ClientID == "" || creds.ClientSecret == "" {
|
||||
controller.log.App.Debug().Msg("Client credentials not found in form, trying basic auth")
|
||||
tlog.App.Debug().Msg("Tried form values and they are empty, trying basic auth")
|
||||
|
||||
clientId, clientSecret, ok := c.Request.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Msg("Client credentials not found in basic auth")
|
||||
tlog.App.Error().Msg("Missing authorization header")
|
||||
c.Header("www-authenticate", `Basic realm="Tinyauth OIDC Token Endpoint"`)
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
@@ -266,7 +254,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
client, ok := controller.oidc.GetClient(creds.ClientID)
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Client not found")
|
||||
tlog.App.Warn().Str("client_id", creds.ClientID).Msg("Client not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
@@ -274,7 +262,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
}
|
||||
|
||||
if client.ClientSecret != creds.ClientSecret {
|
||||
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Invalid client secret")
|
||||
tlog.App.Warn().Str("client_id", creds.ClientID).Msg("Invalid client secret")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
@@ -288,30 +276,30 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)
|
||||
if err != nil {
|
||||
if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to delete code")
|
||||
tlog.App.Error().Err(err).Msg("Failed to delete access token by code hash")
|
||||
}
|
||||
if errors.Is(err, service.ErrCodeNotFound) {
|
||||
controller.log.App.Warn().Msg("Code not found")
|
||||
tlog.App.Warn().Msg("Code not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrCodeExpired) {
|
||||
controller.log.App.Warn().Msg("Code expired")
|
||||
tlog.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")
|
||||
tlog.App.Warn().Msg("Invalid client ID")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get code entry")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -319,7 +307,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
}
|
||||
|
||||
if entry.RedirectURI != req.RedirectURI {
|
||||
controller.log.App.Warn().Msg("Redirect URI does not match")
|
||||
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI mismatch")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
@@ -329,7 +317,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Msg("PKCE validation failed")
|
||||
tlog.App.Warn().Msg("PKCE validation failed")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
@@ -339,7 +327,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to generate access token")
|
||||
tlog.App.Error().Err(err).Msg("Failed to generate access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -352,7 +340,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTokenExpired) {
|
||||
controller.log.App.Warn().Msg("Refresh token expired")
|
||||
tlog.App.Error().Err(err).Msg("Refresh token expired")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
@@ -360,14 +348,14 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
}
|
||||
|
||||
if errors.Is(err, service.ErrInvalidClient) {
|
||||
controller.log.App.Warn().Msg("Refresh token does not belong to client")
|
||||
tlog.App.Error().Err(err).Msg("Invalid client")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
controller.log.App.Error().Err(err).Msg("Failed to refresh access token")
|
||||
tlog.App.Error().Err(err).Msg("Failed to refresh access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -384,10 +372,10 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
if controller.oidc == nil {
|
||||
controller.log.App.Warn().Msg("Received OIDC userinfo request but OIDC server is not configured")
|
||||
c.JSON(500, gin.H{
|
||||
"error": "server_error",
|
||||
if !controller.oidc.IsConfigured() {
|
||||
tlog.App.Warn().Msg("OIDC not configured")
|
||||
c.JSON(404, gin.H{
|
||||
"error": "not_found",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -398,7 +386,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
if authorization != "" {
|
||||
tokenType, bearerToken, ok := strings.Cut(authorization, " ")
|
||||
if !ok {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with invalid authorization header")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with malformed authorization header")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -406,7 +394,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
if strings.ToLower(tokenType) != "bearer" {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with non-bearer token")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token type")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -416,7 +404,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
token = bearerToken
|
||||
} else if c.Request.Method == http.MethodPost {
|
||||
if c.ContentType() != "application/x-www-form-urlencoded" {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo POST accessed with invalid content type")
|
||||
tlog.App.Warn().Msg("OIDC userinfo POST accessed with invalid content type")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -424,14 +412,14 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
}
|
||||
token = c.PostForm("access_token")
|
||||
if token == "" {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo POST accessed without access_token")
|
||||
tlog.App.Warn().Msg("OIDC userinfo POST accessed without access_token in body")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed without authorization header or POST body")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed without authorization header")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
@@ -441,15 +429,15 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTokenNotFound) {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with invalid token")
|
||||
if err == service.ErrTokenNotFound {
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get access token")
|
||||
tlog.App.Err(err).Msg("Failed to get token entry")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -458,7 +446,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
|
||||
// If we don't have the openid scope, return an error
|
||||
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
|
||||
controller.log.App.Warn().Msg("OIDC userinfo accessed with token missing openid scope")
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed without openid scope")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_scope",
|
||||
})
|
||||
@@ -468,7 +456,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
user, err := controller.oidc.GetUserinfo(c, entry.Sub)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get user info")
|
||||
tlog.App.Err(err).Msg("Failed to get user entry")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
@@ -479,7 +467,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {
|
||||
controller.log.App.Warn().Err(err).Str("reason", reason).Msg("Authorization error")
|
||||
tlog.App.Error().Err(err).Msg(reason)
|
||||
|
||||
if callback != "" {
|
||||
errorQueries := CallbackError{
|
||||
@@ -519,16 +507,8 @@ func (controller *OIDCController) authorizeError(c *gin.Context, err error, reas
|
||||
return
|
||||
}
|
||||
|
||||
redirectUrl := ""
|
||||
|
||||
if controller.oidc != nil {
|
||||
redirectUrl = fmt.Sprintf("%s/error?%s", controller.oidc.GetIssuer(), queries.Encode())
|
||||
} else {
|
||||
redirectUrl = fmt.Sprintf("%s/error?%s", controller.runtime.AppURL, queries.Encode())
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": redirectUrl,
|
||||
"redirect_uri": fmt.Sprintf("%s/error?%s", controller.oidc.GetIssuer(), queries.Encode()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,46 +1,55 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOIDCController(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
tlog.NewTestLogger().Init()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
oidcServiceCfg := service.OIDCServiceConfig{
|
||||
Clients: map[string]config.OIDCClientConfig{
|
||||
"test": {
|
||||
ClientID: "some-client-id",
|
||||
ClientSecret: "some-client-secret",
|
||||
TrustedRedirectURIs: []string{"https://test.example.com/callback"},
|
||||
Name: "Test Client",
|
||||
},
|
||||
},
|
||||
PrivateKeyPath: path.Join(tempDir, "key.pem"),
|
||||
PublicKeyPath: path.Join(tempDir, "key.pub"),
|
||||
Issuer: "https://tinyauth.example.com",
|
||||
SessionExpiry: 500,
|
||||
}
|
||||
|
||||
controllerCfg := controller.OIDCControllerConfig{}
|
||||
|
||||
simpleCtx := func(c *gin.Context) {
|
||||
c.Set("context", &model.UserContext{
|
||||
Authenticated: true,
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: "test",
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
},
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: "test",
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
IsLoggedIn: true,
|
||||
Provider: "local",
|
||||
})
|
||||
c.Next()
|
||||
}
|
||||
@@ -90,7 +99,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, res["redirect_uri"], "https://tinyauth.example.com/error?error=User+is+not+logged+in+or+the+session+is+invalid")
|
||||
},
|
||||
@@ -110,7 +119,7 @@ func TestOIDCController(t *testing.T) {
|
||||
Nonce: "some-nonce",
|
||||
}
|
||||
reqBodyBytes, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -118,7 +127,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, res["redirect_uri"], "https://test.example.com/callback?error=unsupported_response_type&error_description=Invalid+request+parameters&state=some-state")
|
||||
},
|
||||
@@ -138,7 +147,7 @@ func TestOIDCController(t *testing.T) {
|
||||
Nonce: "some-nonce",
|
||||
}
|
||||
reqBodyBytes, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -147,11 +156,11 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
redirectURI := res["redirect_uri"].(string)
|
||||
url, err := url.Parse(redirectURI)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queryParams := url.Query()
|
||||
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||
@@ -170,7 +179,7 @@ func TestOIDCController(t *testing.T) {
|
||||
RedirectURI: "https://test.example.com/callback",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -178,7 +187,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, res["error"], "unsupported_grant_type")
|
||||
},
|
||||
@@ -193,7 +202,7 @@ func TestOIDCController(t *testing.T) {
|
||||
RedirectURI: "https://test.example.com/callback",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -231,7 +240,7 @@ func TestOIDCController(t *testing.T) {
|
||||
RedirectURI: "https://test.example.com/callback",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -254,11 +263,11 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var authorizeRes map[string]any
|
||||
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
redirectURI := authorizeRes["redirect_uri"].(string)
|
||||
url, err := url.Parse(redirectURI)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queryParams := url.Query()
|
||||
code := queryParams.Get("code")
|
||||
@@ -270,7 +279,7 @@ func TestOIDCController(t *testing.T) {
|
||||
RedirectURI: "https://test.example.com/callback",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -293,7 +302,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var tokenRes map[string]any
|
||||
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, ok := tokenRes["refresh_token"]
|
||||
assert.True(t, ok, "Expected refresh token in response")
|
||||
@@ -307,7 +316,7 @@ func TestOIDCController(t *testing.T) {
|
||||
ClientSecret: "some-client-secret",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -319,7 +328,7 @@ func TestOIDCController(t *testing.T) {
|
||||
assert.Equal(t, 200, recorder.Code)
|
||||
var refreshRes map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &refreshRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, ok = refreshRes["access_token"]
|
||||
assert.True(t, ok, "Expected access token in refresh response")
|
||||
@@ -340,11 +349,11 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var authorizeRes map[string]any
|
||||
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
redirectURI := authorizeRes["redirect_uri"].(string)
|
||||
url, err := url.Parse(redirectURI)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queryParams := url.Query()
|
||||
code := queryParams.Get("code")
|
||||
@@ -356,7 +365,7 @@ func TestOIDCController(t *testing.T) {
|
||||
RedirectURI: "https://test.example.com/callback",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -376,7 +385,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var secondRes map[string]any
|
||||
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "invalid_grant", secondRes["error"])
|
||||
},
|
||||
@@ -404,7 +413,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var tokenRes map[string]any
|
||||
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
accessToken := tokenRes["access_token"].(string)
|
||||
assert.NotEmpty(t, accessToken)
|
||||
@@ -416,7 +425,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var userInfoRes map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, ok := userInfoRes["sub"]
|
||||
assert.True(t, ok, "Expected sub claim in userinfo response")
|
||||
@@ -436,7 +445,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", res["error"])
|
||||
},
|
||||
},
|
||||
@@ -451,7 +460,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", res["error"])
|
||||
},
|
||||
},
|
||||
@@ -466,7 +475,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", res["error"])
|
||||
},
|
||||
},
|
||||
@@ -481,7 +490,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_grant", res["error"])
|
||||
},
|
||||
},
|
||||
@@ -496,7 +505,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", res["error"])
|
||||
},
|
||||
},
|
||||
@@ -511,7 +520,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", res["error"])
|
||||
},
|
||||
},
|
||||
@@ -528,7 +537,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var tokenRes map[string]any
|
||||
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
accessToken := tokenRes["access_token"].(string)
|
||||
assert.NotEmpty(t, accessToken)
|
||||
@@ -542,7 +551,7 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var userInfoRes map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, ok := userInfoRes["sub"]
|
||||
assert.True(t, ok, "Expected sub claim in userinfo response")
|
||||
@@ -566,7 +575,7 @@ func TestOIDCController(t *testing.T) {
|
||||
CodeChallengeMethod: "",
|
||||
}
|
||||
reqBodyBytes, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -575,11 +584,11 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
redirectURI := res["redirect_uri"].(string)
|
||||
url, err := url.Parse(redirectURI)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queryParams := url.Query()
|
||||
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||
@@ -596,7 +605,7 @@ func TestOIDCController(t *testing.T) {
|
||||
CodeVerifier: "some-challenge",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -627,7 +636,7 @@ func TestOIDCController(t *testing.T) {
|
||||
CodeChallengeMethod: "S256",
|
||||
}
|
||||
reqBodyBytes, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -636,11 +645,11 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
redirectURI := res["redirect_uri"].(string)
|
||||
url, err := url.Parse(redirectURI)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queryParams := url.Query()
|
||||
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||
@@ -657,7 +666,7 @@ func TestOIDCController(t *testing.T) {
|
||||
CodeVerifier: "some-challenge",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -688,7 +697,7 @@ func TestOIDCController(t *testing.T) {
|
||||
CodeChallengeMethod: "S256",
|
||||
}
|
||||
reqBodyBytes, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -697,11 +706,11 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
redirectURI := res["redirect_uri"].(string)
|
||||
url, err := url.Parse(redirectURI)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queryParams := url.Query()
|
||||
assert.Equal(t, queryParams.Get("state"), "some-state")
|
||||
@@ -718,7 +727,7 @@ func TestOIDCController(t *testing.T) {
|
||||
CodeVerifier: "some-challenge-1",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(tokenReqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -749,7 +758,7 @@ func TestOIDCController(t *testing.T) {
|
||||
CodeChallengeMethod: "foo",
|
||||
}
|
||||
reqBodyBytes, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -758,11 +767,11 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
redirectURI := res["redirect_uri"].(string)
|
||||
url, err := url.Parse(redirectURI)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queryParams := url.Query()
|
||||
error := queryParams.Get("error")
|
||||
@@ -781,11 +790,11 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
var res map[string]any
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
redirectURI := res["redirect_uri"].(string)
|
||||
url, err := url.Parse(redirectURI)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
queryParams := url.Query()
|
||||
code := queryParams.Get("code")
|
||||
@@ -797,7 +806,7 @@ func TestOIDCController(t *testing.T) {
|
||||
RedirectURI: "https://test.example.com/callback",
|
||||
}
|
||||
reqBodyEncoded, err := query.Values(reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -808,7 +817,7 @@ func TestOIDCController(t *testing.T) {
|
||||
assert.Equal(t, 200, recorder.Code)
|
||||
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
accessToken := res["access_token"].(string)
|
||||
assert.NotEmpty(t, accessToken)
|
||||
@@ -833,22 +842,20 @@ func TestOIDCController(t *testing.T) {
|
||||
assert.Equal(t, 401, recorder.Code)
|
||||
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &res)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "invalid_grant", res["error"])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app := bootstrap.NewBootstrapApp(cfg)
|
||||
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||
|
||||
err := app.SetupDatabase()
|
||||
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
|
||||
require.NoError(t, err)
|
||||
|
||||
queries := repository.New(app.GetDB())
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
|
||||
oidcService, err := service.NewOIDCService(log, cfg, runtime, queries, context.TODO(), wg)
|
||||
queries := repository.New(db)
|
||||
oidcService := service.NewOIDCService(oidcServiceCfg, queries)
|
||||
err = oidcService.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -862,7 +869,8 @@ func TestOIDCController(t *testing.T) {
|
||||
group := router.Group("/api")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
controller.NewOIDCController(log, oidcService, runtime, group)
|
||||
oidcController := controller.NewOIDCController(controllerCfg, oidcService, group)
|
||||
oidcController.SetupRoutes()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@@ -871,6 +879,7 @@ func TestOIDCController(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
app.GetDB().Close()
|
||||
err = db.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"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/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
@@ -50,31 +50,29 @@ type ProxyContext struct {
|
||||
ProxyType ProxyType
|
||||
}
|
||||
|
||||
type ProxyController struct {
|
||||
log *logger.Logger
|
||||
runtime model.RuntimeConfig
|
||||
acls *service.AccessControlsService
|
||||
auth *service.AuthService
|
||||
type ProxyControllerConfig struct {
|
||||
AppURL string
|
||||
}
|
||||
|
||||
func NewProxyController(
|
||||
log *logger.Logger,
|
||||
runtime model.RuntimeConfig,
|
||||
router *gin.RouterGroup,
|
||||
acls *service.AccessControlsService,
|
||||
auth *service.AuthService,
|
||||
) *ProxyController {
|
||||
controller := &ProxyController{
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
acls: acls,
|
||||
auth: auth,
|
||||
type ProxyController struct {
|
||||
config ProxyControllerConfig
|
||||
router *gin.RouterGroup
|
||||
acls *service.AccessControlsService
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController {
|
||||
return &ProxyController{
|
||||
config: config,
|
||||
router: router,
|
||||
acls: acls,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
proxyGroup := router.Group("/auth")
|
||||
func (controller *ProxyController) SetupRoutes() {
|
||||
proxyGroup := controller.router.Group("/auth")
|
||||
proxyGroup.Any("/:proxy", controller.proxyHandler)
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
@@ -82,7 +80,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
proxyCtx, err := controller.getProxyContext(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get proxy context from request")
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get proxy context")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad request",
|
||||
@@ -90,18 +88,22 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tlog.App.Trace().Interface("ctx", proxyCtx).Msg("Got proxy context")
|
||||
|
||||
// Get acls
|
||||
acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get ACLs for resource")
|
||||
tlog.App.Error().Err(err).Msg("Failed to get access controls for resource")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
tlog.App.Trace().Interface("acls", acls).Msg("ACLs for resource")
|
||||
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
if controller.auth.IsBypassedIP(clientIP, acls) {
|
||||
if controller.auth.IsBypassedIP(acls.IP, clientIP) {
|
||||
controller.setHeaders(c, acls)
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
@@ -110,16 +112,16 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls)
|
||||
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls.Path)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource")
|
||||
tlog.App.Error().Err(err).Msg("Failed to check if auth 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")
|
||||
tlog.App.Debug().Msg("Authentication disabled for resource, allowing access")
|
||||
controller.setHeaders(c, acls)
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
@@ -128,19 +130,19 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !controller.auth.CheckIP(clientIP, acls) {
|
||||
queries, err := query.Values(UnauthorizedQuery{
|
||||
if !controller.auth.CheckIP(acls.IP, clientIP) {
|
||||
queries, err := query.Values(config.UnauthorizedQuery{
|
||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||
IP: clientIP,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -155,38 +157,44 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userContext, err := new(model.UserContext).NewFromGin(c)
|
||||
var userContext config.UserContext
|
||||
|
||||
context, err := utils.GetContext(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Debug().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
|
||||
userContext = &model.UserContext{
|
||||
Authenticated: false,
|
||||
tlog.App.Debug().Msg("No user context found in request, treating as not logged in")
|
||||
userContext = config.UserContext{
|
||||
IsLoggedIn: false,
|
||||
}
|
||||
} else {
|
||||
userContext = context
|
||||
}
|
||||
|
||||
if userContext.Authenticated {
|
||||
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
|
||||
tlog.App.Trace().Interface("context", userContext).Msg("User context from request")
|
||||
|
||||
if userContext.IsLoggedIn {
|
||||
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")
|
||||
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource")
|
||||
|
||||
queries, err := query.Values(UnauthorizedQuery{
|
||||
queries, err := query.Values(config.UnauthorizedQuery{
|
||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
if userContext.IsOAuth() {
|
||||
queries.Set("username", userContext.GetEmail())
|
||||
if userContext.OAuth {
|
||||
queries.Set("username", userContext.Email)
|
||||
} else {
|
||||
queries.Set("username", userContext.GetUsername())
|
||||
queries.Set("username", userContext.Username)
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -201,36 +209,36 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if userContext.IsOAuth() || userContext.IsLDAP() {
|
||||
if userContext.OAuth || userContext.Provider == "ldap" {
|
||||
var groupOK bool
|
||||
|
||||
if userContext.IsOAuth() {
|
||||
groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls)
|
||||
if userContext.OAuth {
|
||||
groupOK = controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups)
|
||||
} else {
|
||||
groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls)
|
||||
groupOK = controller.auth.IsInLdapGroup(c, userContext, acls.LDAP.Groups)
|
||||
}
|
||||
|
||||
if !groupOK {
|
||||
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not in the required group to access resource")
|
||||
tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements")
|
||||
|
||||
queries, err := query.Values(UnauthorizedQuery{
|
||||
queries, err := query.Values(config.UnauthorizedQuery{
|
||||
Resource: strings.Split(proxyCtx.Host, ".")[0],
|
||||
GroupErr: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode unauthorized query")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
if userContext.IsOAuth() {
|
||||
queries.Set("username", userContext.GetEmail())
|
||||
if userContext.OAuth {
|
||||
queries.Set("username", userContext.Email)
|
||||
} else {
|
||||
queries.Set("username", userContext.GetUsername())
|
||||
queries.Set("username", userContext.Username)
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.runtime.AppURL, queries.Encode())
|
||||
redirectURL := fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -246,18 +254,17 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
c.Header("Remote-User", utils.SanitizeHeader(userContext.GetUsername()))
|
||||
c.Header("Remote-Name", utils.SanitizeHeader(userContext.GetName()))
|
||||
c.Header("Remote-Email", utils.SanitizeHeader(userContext.GetEmail()))
|
||||
c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
|
||||
c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name))
|
||||
c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email))
|
||||
|
||||
if userContext.IsLDAP() {
|
||||
c.Header("Remote-Groups", utils.SanitizeHeader(strings.Join(userContext.LDAP.Groups, ",")))
|
||||
if userContext.Provider == "ldap" {
|
||||
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.LdapGroups))
|
||||
} else if userContext.Provider != "local" {
|
||||
c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups))
|
||||
}
|
||||
|
||||
if userContext.IsOAuth() {
|
||||
c.Header("Remote-Groups", utils.SanitizeHeader(strings.Join(userContext.OAuth.Groups, ",")))
|
||||
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuth.Sub))
|
||||
}
|
||||
c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub))
|
||||
|
||||
controller.setHeaders(c, acls)
|
||||
|
||||
@@ -268,17 +275,17 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
queries, err := query.Values(RedirectQuery{
|
||||
queries, err := query.Values(config.RedirectQuery{
|
||||
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to encode redirect query")
|
||||
tlog.App.Error().Err(err).Msg("Failed to encode redirect URI query")
|
||||
controller.handleError(c, proxyCtx)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/login?%s", controller.runtime.AppURL, queries.Encode())
|
||||
redirectURL := fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -292,29 +299,26 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
func (controller *ProxyController) setHeaders(c *gin.Context, acls *model.App) {
|
||||
func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) {
|
||||
c.Header("Authorization", c.Request.Header.Get("Authorization"))
|
||||
|
||||
if acls == nil {
|
||||
return
|
||||
}
|
||||
|
||||
headers := utils.ParseHeaders(acls.Response.Headers)
|
||||
|
||||
for key, value := range headers {
|
||||
tlog.App.Debug().Str("header", key).Msg("Setting header")
|
||||
c.Header(key, value)
|
||||
}
|
||||
|
||||
basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile)
|
||||
|
||||
if acls.Response.BasicAuth.Username != "" && basicPassword != "" {
|
||||
controller.log.App.Debug().Msg("Setting basic auth header for response")
|
||||
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.EncodeBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))
|
||||
tlog.App.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header")
|
||||
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) {
|
||||
redirectURL := fmt.Sprintf("%s/error", controller.runtime.AppURL)
|
||||
redirectURL := fmt.Sprintf("%s/error", controller.config.AppURL)
|
||||
|
||||
if !controller.useBrowserResponse(proxyCtx) {
|
||||
c.Header("x-tinyauth-location", redirectURL)
|
||||
@@ -515,7 +519,7 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
||||
return ProxyContext{}, err
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Msgf("Determined proxy type: %v", proxy)
|
||||
tlog.App.Debug().Msgf("Proxy: %v", req.Proxy)
|
||||
|
||||
authModules := controller.determineAuthModules(proxy)
|
||||
|
||||
@@ -526,13 +530,13 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
||||
var ctx ProxyContext
|
||||
|
||||
for _, module := range authModules {
|
||||
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module)
|
||||
tlog.App.Debug().Msgf("Trying auth module: %v", module)
|
||||
ctx, err = controller.getContextFromAuthModule(c, module)
|
||||
if err == nil {
|
||||
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
|
||||
tlog.App.Debug().Msgf("Auth module %v succeeded", module)
|
||||
break
|
||||
}
|
||||
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
|
||||
tlog.App.Debug().Err(err).Msgf("Auth module %v failed", module)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -544,9 +548,9 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
|
||||
isBrowser := BrowserUserAgentRegex.MatchString(userAgent)
|
||||
|
||||
if isBrowser {
|
||||
controller.log.App.Debug().Msg("Request identified as coming from a browser client")
|
||||
tlog.App.Debug().Msg("Request identified as coming from a browser")
|
||||
} else {
|
||||
controller.log.App.Debug().Msg("Request identified as coming from a non-browser client")
|
||||
tlog.App.Debug().Msg("Request identified as coming from a non-browser client")
|
||||
}
|
||||
|
||||
ctx.IsBrowser = isBrowser
|
||||
|
||||
@@ -1,51 +1,70 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProxyController(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
tlog.NewTestLogger().Init()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
authServiceCfg := service.AuthServiceConfig{
|
||||
Users: []config.User{
|
||||
{
|
||||
Username: "testuser",
|
||||
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||
},
|
||||
{
|
||||
Username: "totpuser",
|
||||
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
|
||||
},
|
||||
},
|
||||
SessionExpiry: 10, // 10 seconds, useful for testing
|
||||
CookieDomain: "example.com",
|
||||
LoginTimeout: 10, // 10 seconds, useful for testing
|
||||
LoginMaxRetries: 3,
|
||||
SessionCookieName: "tinyauth-session",
|
||||
}
|
||||
|
||||
acls := map[string]model.App{
|
||||
controllerCfg := controller.ProxyControllerConfig{
|
||||
AppURL: "https://tinyauth.example.com",
|
||||
}
|
||||
|
||||
acls := map[string]config.App{
|
||||
"app_path_allow": {
|
||||
Config: model.AppConfig{
|
||||
Config: config.AppConfig{
|
||||
Domain: "path-allow.example.com",
|
||||
},
|
||||
Path: model.AppPath{
|
||||
Path: config.AppPath{
|
||||
Allow: "/allowed",
|
||||
},
|
||||
},
|
||||
"app_user_allow": {
|
||||
Config: model.AppConfig{
|
||||
Config: config.AppConfig{
|
||||
Domain: "user-allow.example.com",
|
||||
},
|
||||
Users: model.AppUsers{
|
||||
Users: config.AppUsers{
|
||||
Allow: "testuser",
|
||||
},
|
||||
},
|
||||
"ip_bypass": {
|
||||
Config: model.AppConfig{
|
||||
Config: config.AppConfig{
|
||||
Domain: "ip-bypass.example.com",
|
||||
},
|
||||
IP: model.AppIP{
|
||||
IP: config.AppIP{
|
||||
Bypass: []string{"10.10.10.10"},
|
||||
},
|
||||
},
|
||||
@@ -55,31 +74,24 @@ func TestProxyController(t *testing.T) {
|
||||
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`
|
||||
|
||||
simpleCtx := func(c *gin.Context) {
|
||||
c.Set("context", &model.UserContext{
|
||||
Authenticated: true,
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: "testuser",
|
||||
Name: "Testuser",
|
||||
Email: "testuser@example.com",
|
||||
},
|
||||
},
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: "testuser",
|
||||
Name: "Testuser",
|
||||
Email: "testuser@example.com",
|
||||
IsLoggedIn: true,
|
||||
Provider: "local",
|
||||
})
|
||||
c.Next()
|
||||
}
|
||||
|
||||
simpleCtxTotp := func(c *gin.Context) {
|
||||
c.Set("context", &model.UserContext{
|
||||
Authenticated: true,
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: "totpuser",
|
||||
Name: "Totpuser",
|
||||
Email: "totpuser@example.com",
|
||||
},
|
||||
},
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: "totpuser",
|
||||
Name: "Totpuser",
|
||||
Email: "totpuser@example.com",
|
||||
IsLoggedIn: true,
|
||||
Provider: "local",
|
||||
TotpEnabled: true,
|
||||
})
|
||||
c.Next()
|
||||
}
|
||||
@@ -379,19 +391,32 @@ func TestProxyController(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
app := bootstrap.NewBootstrapApp(cfg)
|
||||
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
||||
|
||||
err := app.SetupDatabase()
|
||||
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||
|
||||
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
|
||||
require.NoError(t, err)
|
||||
|
||||
queries := repository.New(app.GetDB())
|
||||
queries := repository.New(db)
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
ctx := context.TODO()
|
||||
docker := service.NewDockerService()
|
||||
err = docker.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
||||
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
|
||||
aclsService := service.NewAccessControlsService(log, nil, acls)
|
||||
ldap := service.NewLdapService(service.LdapServiceConfig{})
|
||||
err = ldap.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
broker := service.NewOAuthBrokerService(oauthBrokerCfgs)
|
||||
err = broker.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
|
||||
err = authService.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
aclsService := service.NewAccessControlsService(docker, acls)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
@@ -406,13 +431,15 @@ func TestProxyController(t *testing.T) {
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
controller.NewProxyController(log, runtime, group, aclsService, authService)
|
||||
proxyController := controller.NewProxyController(controllerCfg, group, aclsService, authService)
|
||||
proxyController.SetupRoutes()
|
||||
|
||||
test.run(t, router, recorder)
|
||||
})
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
app.GetDB().Close()
|
||||
err = db.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,39 +4,42 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
type ResourcesControllerConfig struct {
|
||||
Path string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type ResourcesController struct {
|
||||
config model.Config
|
||||
config ResourcesControllerConfig
|
||||
router *gin.RouterGroup
|
||||
fileServer http.Handler
|
||||
}
|
||||
|
||||
func NewResourcesController(
|
||||
config model.Config,
|
||||
router *gin.RouterGroup,
|
||||
) *ResourcesController {
|
||||
fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Resources.Path)))
|
||||
func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {
|
||||
fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Path)))
|
||||
|
||||
controller := &ResourcesController{
|
||||
return &ResourcesController{
|
||||
config: config,
|
||||
router: router,
|
||||
fileServer: fileServer,
|
||||
}
|
||||
}
|
||||
|
||||
router.GET("/resources/*resource", controller.resourcesHandler)
|
||||
|
||||
return controller
|
||||
func (controller *ResourcesController) SetupRoutes() {
|
||||
controller.router.GET("/resources/*resource", controller.resourcesHandler)
|
||||
}
|
||||
|
||||
func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
|
||||
if controller.config.Resources.Path == "" {
|
||||
if controller.config.Path == "" {
|
||||
c.JSON(404, gin.H{
|
||||
"status": 404,
|
||||
"message": "Resources not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !controller.config.Resources.Enabled {
|
||||
if !controller.config.Enabled {
|
||||
c.JSON(403, gin.H{
|
||||
"status": 403,
|
||||
"message": "Resources are disabled",
|
||||
|
||||
@@ -3,20 +3,26 @@ package controller_test
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
)
|
||||
|
||||
func TestResourcesController(t *testing.T) {
|
||||
cfg, _ := test.CreateTestConfigs(t)
|
||||
tlog.NewTestLogger().Init()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
err := os.MkdirAll(cfg.Resources.Path, 0777)
|
||||
resourcesControllerCfg := controller.ResourcesControllerConfig{
|
||||
Path: path.Join(tempDir, "resources"),
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
err := os.Mkdir(resourcesControllerCfg.Path, 0777)
|
||||
require.NoError(t, err)
|
||||
|
||||
type testCase struct {
|
||||
@@ -55,11 +61,11 @@ func TestResourcesController(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testFilePath := cfg.Resources.Path + "/testfile.txt"
|
||||
testFilePath := resourcesControllerCfg.Path + "/testfile.txt"
|
||||
err = os.WriteFile(testFilePath, []byte("This is a test file."), 0777)
|
||||
require.NoError(t, err)
|
||||
|
||||
testFilePathParent := filepath.Dir(cfg.Resources.Path) + "/somefile.txt"
|
||||
testFilePathParent := tempDir + "/somefile.txt"
|
||||
err = os.WriteFile(testFilePathParent, []byte("This file should not be accessible."), 0777)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -69,7 +75,8 @@ func TestResourcesController(t *testing.T) {
|
||||
group := router.Group("/")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
controller.NewResourcesController(cfg, group)
|
||||
resourcesController := controller.NewResourcesController(resourcesControllerCfg, group)
|
||||
resourcesController.SetupRoutes()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
test.run(t, router, recorder)
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"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/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
@@ -25,30 +22,29 @@ type TotpRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
log *logger.Logger
|
||||
runtime model.RuntimeConfig
|
||||
auth *service.AuthService
|
||||
type UserControllerConfig struct {
|
||||
CookieDomain string
|
||||
}
|
||||
|
||||
func NewUserController(
|
||||
log *logger.Logger,
|
||||
runtimeConfig model.RuntimeConfig,
|
||||
router *gin.RouterGroup,
|
||||
auth *service.AuthService,
|
||||
) *UserController {
|
||||
controller := &UserController{
|
||||
log: log,
|
||||
runtime: runtimeConfig,
|
||||
auth: auth,
|
||||
}
|
||||
type UserController struct {
|
||||
config UserControllerConfig
|
||||
router *gin.RouterGroup
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
userGroup := router.Group("/user")
|
||||
func NewUserController(config UserControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *UserController {
|
||||
return &UserController{
|
||||
config: config,
|
||||
router: router,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *UserController) SetupRoutes() {
|
||||
userGroup := controller.router.Group("/user")
|
||||
userGroup.POST("/login", controller.loginHandler)
|
||||
userGroup.POST("/logout", controller.logoutHandler)
|
||||
userGroup.POST("/totp", controller.totpHandler)
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
@@ -56,7 +52,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind JSON")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind JSON")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -64,13 +60,13 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt")
|
||||
tlog.App.Debug().Str("username", req.Username).Msg("Login attempt")
|
||||
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
|
||||
|
||||
if isLocked {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
||||
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "account locked")
|
||||
tlog.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
||||
tlog.AuditLoginFailure(c, req.Username, "username", "account locked")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.JSON(429, gin.H{
|
||||
@@ -80,35 +76,12 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
search, err := controller.auth.SearchUser(req.Username)
|
||||
userSearch := controller.auth.SearchUser(req.Username)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrUserNotFound) {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt")
|
||||
if userSearch.Type == "unknown" {
|
||||
tlog.App.Warn().Str("username", req.Username).Msg("User not found")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
if search.Type == model.UserLocal {
|
||||
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "invalid password")
|
||||
} else {
|
||||
controller.log.AuditLoginFailure(req.Username, "ldap", c.ClientIP(), "invalid password")
|
||||
}
|
||||
tlog.AuditLoginFailure(c, req.Username, "username", "user not found")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -116,43 +89,38 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var localUser *model.LocalUser
|
||||
if !controller.auth.VerifyUser(userSearch, req.Password) {
|
||||
tlog.App.Warn().Str("username", req.Username).Msg("Invalid password")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
tlog.AuditLoginFailure(c, req.Username, "username", "invalid password")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if search.Type == model.UserLocal {
|
||||
localUser = controller.auth.GetLocalUser(req.Username)
|
||||
tlog.App.Info().Str("username", req.Username).Msg("Login successful")
|
||||
tlog.AuditLoginSuccess(c, req.Username, "username")
|
||||
|
||||
if localUser == nil {
|
||||
controller.log.App.Error().Str("username", req.Username).Msg("Local user not found after successful password verification")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.auth.RecordLoginAttempt(req.Username, true)
|
||||
|
||||
if localUser.TOTPSecret != "" {
|
||||
controller.log.App.Debug().Str("username", req.Username).Msg("TOTP required for user, creating pending TOTP session")
|
||||
if userSearch.Type == "local" {
|
||||
user := controller.auth.GetLocalUser(userSearch.Username)
|
||||
|
||||
name := localUser.Attributes.Name
|
||||
if name == "" {
|
||||
name = utils.Capitalize(localUser.Username)
|
||||
}
|
||||
if user.TotpSecret != "" {
|
||||
tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
|
||||
|
||||
email := localUser.Attributes.Email
|
||||
if email == "" {
|
||||
email = utils.CompileUserEmail(localUser.Username, controller.runtime.CookieDomain)
|
||||
}
|
||||
|
||||
cookie, err := controller.auth.CreateSession(c, repository.Session{
|
||||
Username: localUser.Username,
|
||||
Name: name,
|
||||
Email: email,
|
||||
err := controller.auth.CreateSessionCookie(c, &repository.Session{
|
||||
Username: user.Username,
|
||||
Name: utils.Capitalize(user.Username),
|
||||
Email: utils.CompileUserEmail(user.Username, controller.config.CookieDomain),
|
||||
Provider: "local",
|
||||
TotpPending: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create pending TOTP session")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -160,8 +128,6 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"message": "TOTP required",
|
||||
@@ -174,30 +140,20 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
sessionCookie := repository.Session{
|
||||
Username: req.Username,
|
||||
Name: utils.Capitalize(req.Username),
|
||||
Email: utils.CompileUserEmail(req.Username, controller.runtime.CookieDomain),
|
||||
Email: utils.CompileUserEmail(req.Username, controller.config.CookieDomain),
|
||||
Provider: "local",
|
||||
}
|
||||
|
||||
if search.Type == model.UserLocal {
|
||||
if localUser.Attributes.Name != "" {
|
||||
sessionCookie.Name = localUser.Attributes.Name
|
||||
}
|
||||
if localUser.Attributes.Email != "" {
|
||||
sessionCookie.Email = localUser.Attributes.Email
|
||||
}
|
||||
}
|
||||
|
||||
if search.Type == model.UserLDAP {
|
||||
if userSearch.Type == "ldap" {
|
||||
sessionCookie.Provider = "ldap"
|
||||
if search.Email != "" {
|
||||
sessionCookie.Email = search.Email
|
||||
}
|
||||
}
|
||||
|
||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
||||
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||
|
||||
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Failed to create session cookie after successful login")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -205,18 +161,6 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
|
||||
controller.log.App.Info().Str("username", req.Username).Msg("Login successful")
|
||||
|
||||
if search.Type == model.UserLocal {
|
||||
controller.log.AuditLoginSuccess(req.Username, "local", c.ClientIP())
|
||||
} else {
|
||||
controller.log.AuditLoginSuccess(req.Username, "ldap", c.ClientIP())
|
||||
}
|
||||
|
||||
controller.auth.RecordLoginAttempt(req.Username, true)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"message": "Login successful",
|
||||
@@ -224,49 +168,15 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (controller *UserController) logoutHandler(c *gin.Context) {
|
||||
controller.log.App.Debug().Msg("Logout attempt")
|
||||
tlog.App.Debug().Msg("Logout request received")
|
||||
|
||||
uuid, err := c.Cookie(controller.runtime.SessionCookieName)
|
||||
controller.auth.DeleteSessionCookie(c)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
controller.log.App.Warn().Msg("Logout attempt without session cookie, treating as successful logout")
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"message": "Logout successful",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.log.App.Error().Err(err).Msg("Error retrieving session cookie on logout")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
})
|
||||
return
|
||||
context, err := utils.GetContext(c)
|
||||
if err == nil && context.IsLoggedIn {
|
||||
tlog.AuditLogout(c, context.Username, context.Provider)
|
||||
}
|
||||
|
||||
cookie, err := controller.auth.DeleteSession(c, uuid)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Error deleting session on logout")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err == nil {
|
||||
controller.log.AuditLogout(context.GetUsername(), context.GetProviderID(), c.ClientIP())
|
||||
} else {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to get user context during logout, logging audit with unknown user")
|
||||
controller.log.AuditLogout("unknown", "unknown", c.ClientIP())
|
||||
}
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"message": "Logout successful",
|
||||
@@ -278,7 +188,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to bind JSON for TOTP verification")
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind JSON")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
@@ -286,10 +196,10 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
context, err := utils.GetContext(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request for TOTP verification")
|
||||
tlog.App.Error().Err(err).Msg("Failed to get user context")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -297,8 +207,8 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !context.TOTPPending() {
|
||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("TOTP verification attempt without pending TOTP session")
|
||||
if !context.TotpPending {
|
||||
tlog.App.Warn().Msg("TOTP attempt without a pending TOTP session")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -306,13 +216,12 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Str("username", context.GetUsername()).Msg("TOTP verification attempt")
|
||||
tlog.App.Debug().Str("username", context.Username).Msg("TOTP verification attempt")
|
||||
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername())
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(context.Username)
|
||||
|
||||
if isLocked {
|
||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Account is locked due to too many failed TOTP attempts")
|
||||
controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "account locked")
|
||||
tlog.App.Warn().Str("username", context.Username).Msg("Account is locked due to too many failed TOTP attempts")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.JSON(429, gin.H{
|
||||
@@ -322,23 +231,14 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user := controller.auth.GetLocalUser(context.GetUsername())
|
||||
user := controller.auth.GetLocalUser(context.Username)
|
||||
|
||||
if user == nil {
|
||||
controller.log.App.Error().Str("username", context.GetUsername()).Msg("Local user not found during TOTP verification")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ok := totp.Validate(req.Code, user.TOTPSecret)
|
||||
ok := totp.Validate(req.Code, user.TotpSecret)
|
||||
|
||||
if !ok {
|
||||
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code during verification attempt")
|
||||
controller.auth.RecordLoginAttempt(context.GetUsername(), false)
|
||||
controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "invalid TOTP code")
|
||||
tlog.App.Warn().Str("username", context.Username).Msg("Invalid TOTP code")
|
||||
controller.auth.RecordLoginAttempt(context.Username, false)
|
||||
tlog.AuditLoginFailure(c, context.Username, "totp", "invalid totp code")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -346,37 +246,24 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := c.Cookie(controller.runtime.SessionCookieName)
|
||||
tlog.App.Info().Str("username", context.Username).Msg("TOTP verification successful")
|
||||
tlog.AuditLoginSuccess(c, context.Username, "totp")
|
||||
|
||||
if err == nil {
|
||||
_, err = controller.auth.DeleteSession(c, uuid)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification")
|
||||
}
|
||||
} else {
|
||||
controller.log.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, cannot delete it")
|
||||
}
|
||||
|
||||
controller.auth.RecordLoginAttempt(context.GetUsername(), true)
|
||||
controller.auth.RecordLoginAttempt(context.Username, true)
|
||||
|
||||
sessionCookie := repository.Session{
|
||||
Username: user.Username,
|
||||
Name: utils.Capitalize(user.Username),
|
||||
Email: utils.CompileUserEmail(user.Username, controller.runtime.CookieDomain),
|
||||
Email: utils.CompileUserEmail(user.Username, controller.config.CookieDomain),
|
||||
Provider: "local",
|
||||
}
|
||||
|
||||
if user.Attributes.Name != "" {
|
||||
sessionCookie.Name = user.Attributes.Name
|
||||
}
|
||||
if user.Attributes.Email != "" {
|
||||
sessionCookie.Email = user.Attributes.Email
|
||||
}
|
||||
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
|
||||
|
||||
cookie, err := controller.auth.CreateSession(c, sessionCookie)
|
||||
err = controller.auth.CreateSessionCookie(c, &sessionCookie)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful TOTP verification")
|
||||
tlog.App.Error().Err(err).Msg("Failed to create session cookie")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "Internal Server Error",
|
||||
@@ -384,11 +271,6 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
|
||||
controller.log.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful, login complete")
|
||||
controller.log.AuditLoginSuccess(context.GetUsername(), "local", c.ClientIP())
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"message": "Login successful",
|
||||
|
||||
@@ -1,85 +1,53 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserController(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
tlog.NewTestLogger().Init()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
|
||||
totpCtx := func(c *gin.Context) {
|
||||
c.Set("context", &model.UserContext{
|
||||
Authenticated: false,
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: "totpuser",
|
||||
Name: "Totpuser",
|
||||
Email: "totpuser@example.com",
|
||||
},
|
||||
TOTPPending: true,
|
||||
authServiceCfg := service.AuthServiceConfig{
|
||||
Users: []config.User{
|
||||
{
|
||||
Username: "testuser",
|
||||
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||
},
|
||||
})
|
||||
{
|
||||
Username: "totpuser",
|
||||
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
|
||||
TotpSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
|
||||
},
|
||||
},
|
||||
SessionExpiry: 10, // 10 seconds, useful for testing
|
||||
CookieDomain: "example.com",
|
||||
LoginTimeout: 10, // 10 seconds, useful for testing
|
||||
LoginMaxRetries: 3,
|
||||
SessionCookieName: "tinyauth-session",
|
||||
}
|
||||
|
||||
totpAttrCtx := func(c *gin.Context) {
|
||||
c.Set("context", &model.UserContext{
|
||||
Authenticated: false,
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: "attrtotpuser",
|
||||
Name: "Bob Jones",
|
||||
Email: "bob@example.com",
|
||||
},
|
||||
TOTPPending: true,
|
||||
},
|
||||
})
|
||||
userControllerCfg := controller.UserControllerConfig{
|
||||
CookieDomain: "example.com",
|
||||
}
|
||||
|
||||
simpleCtx := func(c *gin.Context) {
|
||||
c.Set("context", &model.UserContext{
|
||||
Authenticated: true,
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: "testuser",
|
||||
Name: "Test User",
|
||||
Email: "testuser@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
app := bootstrap.NewBootstrapApp(cfg)
|
||||
|
||||
err := app.SetupDatabase()
|
||||
require.NoError(t, err)
|
||||
|
||||
queries := repository.New(app.GetDB())
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
middlewares []gin.HandlerFunc
|
||||
@@ -96,7 +64,7 @@ func TestUserController(t *testing.T) {
|
||||
Password: "password",
|
||||
}
|
||||
loginReqBody, err := json.Marshal(loginReq)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -104,15 +72,13 @@ func TestUserController(t *testing.T) {
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 200, recorder.Code)
|
||||
require.Len(t, recorder.Result().Cookies(), 1)
|
||||
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||
|
||||
cookie := recorder.Result().Cookies()[0]
|
||||
assert.Equal(t, "tinyauth-session", cookie.Name)
|
||||
assert.True(t, cookie.HttpOnly)
|
||||
assert.Equal(t, "example.com", cookie.Domain)
|
||||
// 3 seconds should be more than enough for even slow test environments
|
||||
assert.GreaterOrEqual(t, cookie.MaxAge, 7)
|
||||
assert.LessOrEqual(t, cookie.MaxAge, 10)
|
||||
assert.Equal(t, 10, cookie.MaxAge)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -124,7 +90,7 @@ func TestUserController(t *testing.T) {
|
||||
Password: "wrongpassword",
|
||||
}
|
||||
loginReqBody, err := json.Marshal(loginReq)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -145,7 +111,7 @@ func TestUserController(t *testing.T) {
|
||||
Password: "wrongpassword",
|
||||
}
|
||||
loginReqBody, err := json.Marshal(loginReq)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for range 3 {
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -180,7 +146,7 @@ func TestUserController(t *testing.T) {
|
||||
Password: "password",
|
||||
}
|
||||
loginReqBody, err := json.Marshal(loginReq)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -191,25 +157,22 @@ func TestUserController(t *testing.T) {
|
||||
|
||||
decodedBody := make(map[string]any)
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, decodedBody["totpPending"], true)
|
||||
|
||||
// should set the session cookie
|
||||
require.Len(t, recorder.Result().Cookies(), 1)
|
||||
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||
cookie := recorder.Result().Cookies()[0]
|
||||
assert.Equal(t, "tinyauth-session", cookie.Name)
|
||||
assert.True(t, cookie.HttpOnly)
|
||||
assert.Equal(t, "example.com", cookie.Domain)
|
||||
assert.GreaterOrEqual(t, cookie.MaxAge, 3597)
|
||||
assert.LessOrEqual(t, cookie.MaxAge, 3600)
|
||||
assert.Equal(t, 3600, cookie.MaxAge) // 1 hour, default for totp pending sessions
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should be able to logout",
|
||||
middlewares: []gin.HandlerFunc{
|
||||
simpleCtx,
|
||||
},
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
// First login to get a session cookie
|
||||
loginReq := controller.LoginRequest{
|
||||
@@ -217,7 +180,7 @@ func TestUserController(t *testing.T) {
|
||||
Password: "password",
|
||||
}
|
||||
loginReqBody, err := json.Marshal(loginReq)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -225,10 +188,9 @@ func TestUserController(t *testing.T) {
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 200, recorder.Code)
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||
|
||||
cookie := cookies[0]
|
||||
cookie := recorder.Result().Cookies()[0]
|
||||
assert.Equal(t, "tinyauth-session", cookie.Name)
|
||||
|
||||
// Now logout using the session cookie
|
||||
@@ -239,72 +201,48 @@ func TestUserController(t *testing.T) {
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 200, recorder.Code)
|
||||
cookies = recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||
|
||||
cookie = cookies[0]
|
||||
assert.Equal(t, "tinyauth-session", cookie.Name)
|
||||
assert.Equal(t, "", cookie.Value)
|
||||
assert.Equal(t, -1, cookie.MaxAge) // MaxAge -1 means delete cookie
|
||||
logoutCookie := recorder.Result().Cookies()[0]
|
||||
assert.Equal(t, "tinyauth-session", logoutCookie.Name)
|
||||
assert.Equal(t, "", logoutCookie.Value)
|
||||
assert.Equal(t, -1, logoutCookie.MaxAge) // MaxAge -1 means delete cookie
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should be able to login with totp",
|
||||
middlewares: []gin.HandlerFunc{
|
||||
totpCtx,
|
||||
},
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
|
||||
UUID: "test-totp-login-uuid",
|
||||
Username: "test",
|
||||
Email: "test@example.com",
|
||||
Name: "Test",
|
||||
Provider: "local",
|
||||
TotpPending: true,
|
||||
Expiry: time.Now().Add(1 * time.Hour).Unix(),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
totpReq := controller.TotpRequest{
|
||||
Code: code,
|
||||
}
|
||||
|
||||
totpReqBody, err := json.Marshal(totpReq)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "tinyauth-session",
|
||||
Value: "test-totp-login-uuid",
|
||||
HttpOnly: true,
|
||||
MaxAge: 3600,
|
||||
Expires: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, 200, recorder.Code)
|
||||
require.Len(t, recorder.Result().Cookies(), 1)
|
||||
assert.Len(t, recorder.Result().Cookies(), 1)
|
||||
|
||||
// should set a new session cookie with totp pending removed
|
||||
totpCookie := recorder.Result().Cookies()[0]
|
||||
assert.Equal(t, "tinyauth-session", totpCookie.Name)
|
||||
assert.True(t, totpCookie.HttpOnly)
|
||||
assert.Equal(t, "example.com", totpCookie.Domain)
|
||||
assert.GreaterOrEqual(t, totpCookie.MaxAge, 7)
|
||||
assert.LessOrEqual(t, totpCookie.MaxAge, 10)
|
||||
assert.Equal(t, 10, totpCookie.MaxAge) // should use the regular session expiry time
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Totp should rate limit on multiple invalid attempts",
|
||||
middlewares: []gin.HandlerFunc{
|
||||
totpCtx,
|
||||
},
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
for range 3 {
|
||||
totpReq := controller.TotpRequest{
|
||||
@@ -312,7 +250,7 @@ func TestUserController(t *testing.T) {
|
||||
}
|
||||
|
||||
totpReqBody, err := json.Marshal(totpReq)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
|
||||
@@ -335,98 +273,43 @@ func TestUserController(t *testing.T) {
|
||||
assert.Contains(t, recorder.Body.String(), "Too many failed TOTP attempts.")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Login uses name and email from user attributes",
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
loginReq := controller.LoginRequest{Username: "attruser", Password: "password"}
|
||||
body, err := json.Marshal(loginReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, 200, recorder.Code)
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
assert.Equal(t, "tinyauth-session", cookies[0].Name)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Login with TOTP uses name and email from user attributes in pending session",
|
||||
middlewares: []gin.HandlerFunc{},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
loginReq := controller.LoginRequest{Username: "attrtotpuser", Password: "password"}
|
||||
body, err := json.Marshal(loginReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, 200, recorder.Code)
|
||||
var res map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &res))
|
||||
assert.Equal(t, true, res["totpPending"])
|
||||
require.Len(t, recorder.Result().Cookies(), 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "TOTP completion uses name and email from user attributes",
|
||||
middlewares: []gin.HandlerFunc{
|
||||
totpAttrCtx,
|
||||
},
|
||||
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
|
||||
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
|
||||
UUID: "test-totp-login-attributes-uuid",
|
||||
Username: "test",
|
||||
Email: "test@example.com",
|
||||
Name: "Test",
|
||||
Provider: "local",
|
||||
TotpPending: true,
|
||||
Expiry: time.Now().Add(1 * time.Hour).Unix(),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
totpReq := controller.TotpRequest{Code: code}
|
||||
body, err := json.Marshal(totpReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "tinyauth-session",
|
||||
Value: "test-totp-login-attributes-uuid",
|
||||
HttpOnly: true,
|
||||
MaxAge: 3600,
|
||||
Expires: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, 200, recorder.Code)
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
assert.Equal(t, "tinyauth-session", cookies[0].Name)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
wg := &sync.WaitGroup{}
|
||||
oauthBrokerCfgs := make(map[string]config.OAuthServiceConfig)
|
||||
|
||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
||||
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
|
||||
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||
|
||||
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
|
||||
require.NoError(t, err)
|
||||
|
||||
queries := repository.New(db)
|
||||
|
||||
docker := service.NewDockerService()
|
||||
err = docker.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
ldap := service.NewLdapService(service.LdapServiceConfig{})
|
||||
err = ldap.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
broker := service.NewOAuthBrokerService(oauthBrokerCfgs)
|
||||
err = broker.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
|
||||
err = authService.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
beforeEach := func() {
|
||||
// Clear failed login attempts before each test
|
||||
authService.ClearRateLimitsTestingOnly()
|
||||
}
|
||||
|
||||
setTotpMiddlewareOverrides := []string{
|
||||
"Should be able to login with totp",
|
||||
"Totp should rate limit on multiple invalid attempts",
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
beforeEach()
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
@@ -436,10 +319,28 @@ func TestUserController(t *testing.T) {
|
||||
router.Use(middleware)
|
||||
}
|
||||
|
||||
// Gin is stupid and doesn't allow setting a middleware after the groups
|
||||
// so we need to do some stupid overrides here
|
||||
if slices.Contains(setTotpMiddlewareOverrides, test.description) {
|
||||
// Assuming the cookie is set, it should be picked up by the
|
||||
// context middleware
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: "totpuser",
|
||||
Name: "Totpuser",
|
||||
Email: "totpuser@example.com",
|
||||
Provider: "local",
|
||||
TotpPending: true,
|
||||
TotpEnabled: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
group := router.Group("/api")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
controller.NewUserController(log, runtime, group, authService)
|
||||
userController := controller.NewUserController(userControllerCfg, group, authService)
|
||||
userController.SetupRoutes()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@@ -448,6 +349,7 @@ func TestUserController(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
app.GetDB().Close()
|
||||
err = db.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,30 +26,28 @@ type OpenIDConnectConfiguration struct {
|
||||
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||
}
|
||||
|
||||
type WellKnownControllerConfig struct{}
|
||||
|
||||
type WellKnownController struct {
|
||||
oidc *service.OIDCService
|
||||
config WellKnownControllerConfig
|
||||
engine *gin.Engine
|
||||
oidc *service.OIDCService
|
||||
}
|
||||
|
||||
func NewWellKnownController(oidc *service.OIDCService, router *gin.RouterGroup) *WellKnownController {
|
||||
controller := &WellKnownController{
|
||||
oidc: oidc,
|
||||
func NewWellKnownController(config WellKnownControllerConfig, oidc *service.OIDCService, engine *gin.Engine) *WellKnownController {
|
||||
return &WellKnownController{
|
||||
config: config,
|
||||
oidc: oidc,
|
||||
engine: engine,
|
||||
}
|
||||
}
|
||||
|
||||
router.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
|
||||
router.GET("/.well-known/jwks.json", controller.JWKS)
|
||||
|
||||
return controller
|
||||
func (controller *WellKnownController) SetupRoutes() {
|
||||
controller.engine.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
|
||||
controller.engine.GET("/.well-known/jwks.json", controller.JWKS)
|
||||
}
|
||||
|
||||
func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) {
|
||||
if controller.oidc == nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "OIDC service not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
issuer := controller.oidc.GetIssuer()
|
||||
c.JSON(200, OpenIDConnectConfiguration{
|
||||
Issuer: issuer,
|
||||
@@ -63,7 +61,7 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context
|
||||
SubjectTypesSupported: []string{"pairwise"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
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"},
|
||||
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
||||
RequestParameterSupported: true,
|
||||
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
||||
@@ -71,19 +69,11 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context
|
||||
}
|
||||
|
||||
func (controller *WellKnownController) JWKS(c *gin.Context) {
|
||||
if controller.oidc == nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"message": "OIDC service not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
jwks, err := controller.oidc.GetJWK()
|
||||
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
"status": "500",
|
||||
"message": "failed to get JWK",
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWellKnownController(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
tlog.NewTestLogger().Init()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
oidcServiceCfg := service.OIDCServiceConfig{
|
||||
Clients: map[string]config.OIDCClientConfig{
|
||||
"test": {
|
||||
ClientID: "some-client-id",
|
||||
ClientSecret: "some-client-secret",
|
||||
TrustedRedirectURIs: []string{"https://test.example.com/callback"},
|
||||
Name: "Test Client",
|
||||
},
|
||||
},
|
||||
PrivateKeyPath: path.Join(tempDir, "key.pem"),
|
||||
PublicKeyPath: path.Join(tempDir, "key.pub"),
|
||||
Issuer: "https://tinyauth.example.com",
|
||||
SessionExpiry: 500,
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
@@ -44,18 +56,18 @@ func TestWellKnownController(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
expected := controller.OpenIDConnectConfiguration{
|
||||
Issuer: runtime.AppURL,
|
||||
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", runtime.AppURL),
|
||||
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", runtime.AppURL),
|
||||
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", runtime.AppURL),
|
||||
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", runtime.AppURL),
|
||||
Issuer: oidcServiceCfg.Issuer,
|
||||
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", oidcServiceCfg.Issuer),
|
||||
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", oidcServiceCfg.Issuer),
|
||||
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", oidcServiceCfg.Issuer),
|
||||
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", oidcServiceCfg.Issuer),
|
||||
ScopesSupported: service.SupportedScopes,
|
||||
ResponseTypesSupported: service.SupportedResponseTypes,
|
||||
GrantTypesSupported: service.SupportedGrantTypes,
|
||||
SubjectTypesSupported: []string{"pairwise"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
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"},
|
||||
ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc",
|
||||
RequestParameterSupported: true,
|
||||
RequestObjectSigningAlgValuesSupported: []string{"none"},
|
||||
@@ -89,17 +101,15 @@ func TestWellKnownController(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
wg := &sync.WaitGroup{}
|
||||
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||
|
||||
app := bootstrap.NewBootstrapApp(cfg)
|
||||
|
||||
err := app.SetupDatabase()
|
||||
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
|
||||
require.NoError(t, err)
|
||||
|
||||
queries := repository.New(app.GetDB())
|
||||
queries := repository.New(db)
|
||||
|
||||
oidcService, err := service.NewOIDCService(log, cfg, runtime, queries, ctx, wg)
|
||||
oidcService := service.NewOIDCService(oidcServiceCfg, queries)
|
||||
err = oidcService.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -109,13 +119,15 @@ func TestWellKnownController(t *testing.T) {
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
controller.NewWellKnownController(oidcService, &router.RouterGroup)
|
||||
wellKnownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, oidcService, router)
|
||||
wellKnownController.SetupRoutes()
|
||||
|
||||
test.run(t, router, recorder)
|
||||
})
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
app.GetDB().Close()
|
||||
err = db.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"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/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -35,27 +32,28 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
type ContextMiddleware struct {
|
||||
log *logger.Logger
|
||||
runtime model.RuntimeConfig
|
||||
auth *service.AuthService
|
||||
broker *service.OAuthBrokerService
|
||||
type ContextMiddlewareConfig struct {
|
||||
CookieDomain string
|
||||
}
|
||||
|
||||
func NewContextMiddleware(
|
||||
log *logger.Logger,
|
||||
runtime model.RuntimeConfig,
|
||||
auth *service.AuthService,
|
||||
broker *service.OAuthBrokerService,
|
||||
) *ContextMiddleware {
|
||||
type ContextMiddleware struct {
|
||||
config ContextMiddlewareConfig
|
||||
auth *service.AuthService
|
||||
broker *service.OAuthBrokerService
|
||||
}
|
||||
|
||||
func NewContextMiddleware(config ContextMiddlewareConfig, auth *service.AuthService, broker *service.OAuthBrokerService) *ContextMiddleware {
|
||||
return &ContextMiddleware{
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
auth: auth,
|
||||
broker: broker,
|
||||
config: config,
|
||||
auth: auth,
|
||||
broker: broker,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ContextMiddleware) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if m.isIgnorePath(c.Request.Method + " " + c.Request.URL.Path) {
|
||||
@@ -63,41 +61,177 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := c.Cookie(m.runtime.SessionCookieName)
|
||||
cookie, err := m.auth.GetSessionCookie(c)
|
||||
|
||||
if err == nil {
|
||||
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid)
|
||||
|
||||
if err == nil {
|
||||
if cookie != nil {
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
}
|
||||
|
||||
m.log.App.Debug().Msgf("Authenticated user %s via session cookie", userContext.GetUsername())
|
||||
c.Set("context", userContext)
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
m.log.App.Debug().Msgf("Error authenticating session cookie: %v", err)
|
||||
}
|
||||
if err != nil {
|
||||
tlog.App.Debug().Err(err).Msg("No valid session cookie found")
|
||||
goto basic
|
||||
}
|
||||
|
||||
username, password, ok := c.Request.BasicAuth()
|
||||
if cookie.TotpPending {
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: cookie.Username,
|
||||
Name: cookie.Name,
|
||||
Email: cookie.Email,
|
||||
Provider: "local",
|
||||
TotpPending: true,
|
||||
TotpEnabled: true,
|
||||
})
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if ok {
|
||||
userContext, headers, err := m.basicAuth(username, password)
|
||||
switch cookie.Provider {
|
||||
case "local", "ldap":
|
||||
userSearch := m.auth.SearchUser(cookie.Username)
|
||||
|
||||
if err != nil {
|
||||
m.log.App.Error().Msgf("Error authenticating basic auth: %v", err)
|
||||
if userSearch.Type == "unknown" {
|
||||
tlog.App.Debug().Msg("User from session cookie not found")
|
||||
m.auth.DeleteSessionCookie(c)
|
||||
goto basic
|
||||
}
|
||||
|
||||
if userSearch.Type != cookie.Provider {
|
||||
tlog.App.Warn().Msg("User type from session cookie does not match user search type")
|
||||
m.auth.DeleteSessionCookie(c)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
c.Header(k, v)
|
||||
var ldapGroups []string
|
||||
|
||||
if cookie.Provider == "ldap" {
|
||||
ldapUser, err := m.auth.GetLdapUser(userSearch.Username)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Error retrieving LDAP user details")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
ldapGroups = ldapUser.Groups
|
||||
}
|
||||
|
||||
c.Set("context", userContext)
|
||||
m.auth.RefreshSessionCookie(c)
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: cookie.Username,
|
||||
Name: cookie.Name,
|
||||
Email: cookie.Email,
|
||||
Provider: cookie.Provider,
|
||||
IsLoggedIn: true,
|
||||
LdapGroups: strings.Join(ldapGroups, ","),
|
||||
})
|
||||
c.Next()
|
||||
return
|
||||
default:
|
||||
_, exists := m.broker.GetService(cookie.Provider)
|
||||
|
||||
if !exists {
|
||||
tlog.App.Debug().Msg("OAuth provider from session cookie not found")
|
||||
m.auth.DeleteSessionCookie(c)
|
||||
goto basic
|
||||
}
|
||||
|
||||
if !m.auth.IsEmailWhitelisted(cookie.Email) {
|
||||
tlog.App.Debug().Msg("Email from session cookie not whitelisted")
|
||||
m.auth.DeleteSessionCookie(c)
|
||||
goto basic
|
||||
}
|
||||
|
||||
m.auth.RefreshSessionCookie(c)
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: cookie.Username,
|
||||
Name: cookie.Name,
|
||||
Email: cookie.Email,
|
||||
Provider: cookie.Provider,
|
||||
OAuthGroups: cookie.OAuthGroups,
|
||||
OAuthName: cookie.OAuthName,
|
||||
OAuthSub: cookie.OAuthSub,
|
||||
IsLoggedIn: true,
|
||||
OAuth: true,
|
||||
})
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
basic:
|
||||
basic := m.auth.GetBasicAuth(c)
|
||||
|
||||
if basic == nil {
|
||||
tlog.App.Debug().Msg("No basic auth provided")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
locked, remaining := m.auth.IsAccountLocked(basic.Username)
|
||||
|
||||
if locked {
|
||||
tlog.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", basic.Username, remaining)
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
userSearch := m.auth.SearchUser(basic.Username)
|
||||
|
||||
if userSearch.Type == "unknown" || userSearch.Type == "error" {
|
||||
m.auth.RecordLoginAttempt(basic.Username, false)
|
||||
tlog.App.Debug().Msg("User from basic auth not found")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !m.auth.VerifyUser(userSearch, basic.Password) {
|
||||
m.auth.RecordLoginAttempt(basic.Username, false)
|
||||
tlog.App.Debug().Msg("Invalid password for basic auth user")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
m.auth.RecordLoginAttempt(basic.Username, true)
|
||||
|
||||
switch userSearch.Type {
|
||||
case "local":
|
||||
tlog.App.Debug().Msg("Basic auth user is local")
|
||||
|
||||
user := m.auth.GetLocalUser(basic.Username)
|
||||
|
||||
if user.TotpSecret != "" {
|
||||
tlog.App.Debug().Msg("User with TOTP not allowed to login via basic auth")
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: user.Username,
|
||||
Name: utils.Capitalize(user.Username),
|
||||
Email: utils.CompileUserEmail(user.Username, m.config.CookieDomain),
|
||||
Provider: "local",
|
||||
IsLoggedIn: true,
|
||||
IsBasicAuth: true,
|
||||
})
|
||||
c.Next()
|
||||
return
|
||||
case "ldap":
|
||||
tlog.App.Debug().Msg("Basic auth user is LDAP")
|
||||
|
||||
ldapUser, err := m.auth.GetLdapUser(basic.Username)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Debug().Err(err).Msg("Error retrieving LDAP user details")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("context", &config.UserContext{
|
||||
Username: basic.Username,
|
||||
Name: utils.Capitalize(basic.Username),
|
||||
Email: utils.CompileUserEmail(basic.Username, m.config.CookieDomain),
|
||||
Provider: "ldap",
|
||||
IsLoggedIn: true,
|
||||
LdapGroups: strings.Join(ldapUser.Groups, ","),
|
||||
IsBasicAuth: true,
|
||||
})
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -106,158 +240,6 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model.UserContext, *http.Cookie, error) {
|
||||
session, err := m.auth.GetSession(ctx, uuid)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error retrieving session: %w", err)
|
||||
}
|
||||
|
||||
userContext, err := new(model.UserContext).NewFromSession(session)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error creating user context from session: %w", err)
|
||||
}
|
||||
|
||||
if userContext.Provider == model.ProviderLocal &&
|
||||
userContext.Local.TOTPPending {
|
||||
return userContext, nil, nil
|
||||
}
|
||||
|
||||
switch userContext.Provider {
|
||||
case model.ProviderLocal:
|
||||
user := m.auth.GetLocalUser(userContext.Local.Username)
|
||||
|
||||
if user == nil {
|
||||
return nil, nil, fmt.Errorf("local user not found")
|
||||
}
|
||||
|
||||
userContext.Local.Attributes = user.Attributes
|
||||
|
||||
if userContext.Local.Attributes.Name == "" {
|
||||
userContext.Local.Attributes.Name = utils.Capitalize(user.Username)
|
||||
}
|
||||
|
||||
if userContext.Local.Attributes.Email == "" {
|
||||
userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain)
|
||||
}
|
||||
case model.ProviderLDAP:
|
||||
search, err := m.auth.SearchUser(userContext.LDAP.Username)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error searching for ldap user: %w", err)
|
||||
}
|
||||
|
||||
if search.Type != model.UserLDAP {
|
||||
return nil, nil, fmt.Errorf("user from session cookie is not ldap")
|
||||
}
|
||||
|
||||
user, err := m.auth.GetLDAPUser(search.Username)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
|
||||
}
|
||||
|
||||
userContext.LDAP.Groups = user.Groups
|
||||
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
|
||||
|
||||
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain)
|
||||
if search.Email != "" {
|
||||
userContext.LDAP.Email = search.Email
|
||||
}
|
||||
|
||||
case model.ProviderOAuth:
|
||||
_, exists := m.broker.GetService(userContext.OAuth.ID)
|
||||
|
||||
if !exists {
|
||||
return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID)
|
||||
}
|
||||
|
||||
if !m.auth.IsEmailWhitelisted(userContext.OAuth.Email) {
|
||||
m.auth.DeleteSession(ctx, uuid)
|
||||
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
|
||||
}
|
||||
}
|
||||
|
||||
cookie, err := m.auth.RefreshSession(ctx, uuid)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error refreshing session: %w", err)
|
||||
}
|
||||
|
||||
return userContext, cookie, nil
|
||||
}
|
||||
|
||||
func (m *ContextMiddleware) basicAuth(username string, password string) (*model.UserContext, map[string]string, error) {
|
||||
headers := make(map[string]string)
|
||||
userContext := new(model.UserContext)
|
||||
locked, remaining := m.auth.IsAccountLocked(username)
|
||||
|
||||
if locked {
|
||||
m.log.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", username, remaining)
|
||||
headers["x-tinyauth-lock-locked"] = "true"
|
||||
headers["x-tinyauth-lock-reset"] = time.Now().Add(time.Duration(remaining) * time.Second).Format(time.RFC3339)
|
||||
return nil, headers, nil
|
||||
}
|
||||
|
||||
search, err := m.auth.SearchUser(username)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error searching for user: %w", err)
|
||||
}
|
||||
|
||||
err = m.auth.CheckUserPassword(*search, password)
|
||||
|
||||
if err != nil {
|
||||
m.auth.RecordLoginAttempt(username, false)
|
||||
return nil, nil, fmt.Errorf("invalid password for basic auth user: %w", err)
|
||||
}
|
||||
|
||||
m.auth.RecordLoginAttempt(username, true)
|
||||
|
||||
switch search.Type {
|
||||
case model.UserLocal:
|
||||
user := m.auth.GetLocalUser(username)
|
||||
|
||||
if user.TOTPSecret != "" {
|
||||
return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", username)
|
||||
}
|
||||
|
||||
userContext.Local = &model.LocalContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: user.Username,
|
||||
Name: utils.Capitalize(user.Username),
|
||||
Email: utils.CompileUserEmail(user.Username, m.runtime.CookieDomain),
|
||||
},
|
||||
Attributes: user.Attributes,
|
||||
}
|
||||
userContext.Provider = model.ProviderLocal
|
||||
case model.UserLDAP:
|
||||
user, err := m.auth.GetLDAPUser(username)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
|
||||
}
|
||||
|
||||
userContext.LDAP = &model.LDAPContext{
|
||||
BaseContext: model.BaseContext{
|
||||
Username: username,
|
||||
Name: utils.Capitalize(username),
|
||||
},
|
||||
Groups: user.Groups,
|
||||
}
|
||||
userContext.Provider = model.ProviderLDAP
|
||||
|
||||
userContext.LDAP.Email = utils.CompileUserEmail(username, m.runtime.CookieDomain)
|
||||
if search.Email != "" {
|
||||
userContext.LDAP.Email = search.Email
|
||||
}
|
||||
}
|
||||
|
||||
userContext.Authenticated = true
|
||||
return userContext, nil, nil
|
||||
}
|
||||
|
||||
func (m *ContextMiddleware) isIgnorePath(path string) bool {
|
||||
for _, prefix := range contextSkipPathsPrefix {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
||||
"github.com/tinyauthapp/tinyauth/internal/middleware"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestContextMiddleware(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
|
||||
basicAuthHeader := func(username, password string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
||||
}
|
||||
|
||||
seedSession := func(t *testing.T, queries *repository.Queries, params repository.CreateSessionParams) {
|
||||
t.Helper()
|
||||
_, err := queries.CreateSession(context.Background(), params)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type runArgs struct {
|
||||
do func(req *http.Request) (*model.UserContext, *httptest.ResponseRecorder)
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
run func(t *testing.T, args runArgs)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Skip path bypasses auth processing",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
req := httptest.NewRequest("GET", "/api/healthz", nil)
|
||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
assert.Nil(t, userCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "No credentials yields no context",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
assert.Nil(t, userCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Valid session cookie sets authenticated local context",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
uuid := "session-valid-local"
|
||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
||||
UUID: uuid,
|
||||
Username: "testuser",
|
||||
Provider: "local",
|
||||
Expiry: time.Now().Add(10 * time.Second).Unix(),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
require.NotNil(t, userCtx)
|
||||
assert.Equal(t, model.ProviderLocal, userCtx.Provider)
|
||||
assert.Equal(t, "testuser", userCtx.GetUsername())
|
||||
assert.True(t, userCtx.Authenticated)
|
||||
require.NotNil(t, userCtx.Local)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Session cookie with totp pending sets unauthenticated context with totp enabled",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
uuid := "session-totp-pending"
|
||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
||||
UUID: uuid,
|
||||
Username: "totpuser",
|
||||
Provider: "local",
|
||||
TotpPending: true,
|
||||
Expiry: time.Now().Add(60 * time.Second).Unix(),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
require.NotNil(t, userCtx)
|
||||
assert.Equal(t, "totpuser", userCtx.GetUsername())
|
||||
assert.False(t, userCtx.Authenticated)
|
||||
require.NotNil(t, userCtx.Local)
|
||||
assert.True(t, userCtx.Local.TOTPPending)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Unknown session cookie yields no context",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: "does-not-exist"})
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
assert.Nil(t, userCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Session for missing local user yields no context",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
uuid := "session-deleted-user"
|
||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
||||
UUID: uuid,
|
||||
Username: "ghostuser",
|
||||
Provider: "local",
|
||||
Expiry: time.Now().Add(10 * time.Second).Unix(),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
assert.Nil(t, userCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Expired session cookie yields no context",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
uuid := "session-expired"
|
||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
||||
UUID: uuid,
|
||||
Username: "testuser",
|
||||
Provider: "local",
|
||||
Expiry: time.Now().Add(-1 * time.Second).Unix(),
|
||||
CreatedAt: time.Now().Add(-10 * time.Second).Unix(),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
assert.Nil(t, userCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Valid basic auth sets authenticated local context",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
require.NotNil(t, userCtx)
|
||||
assert.Equal(t, model.ProviderLocal, userCtx.Provider)
|
||||
assert.Equal(t, "testuser", userCtx.GetUsername())
|
||||
assert.True(t, userCtx.Authenticated)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Invalid basic auth password yields no context",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "wrongpassword"))
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
assert.Nil(t, userCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Basic auth is rejected for users with totp",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.Header.Set("Authorization", basicAuthHeader("totpuser", "password"))
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
assert.Nil(t, userCtx)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Locked account on basic auth sets lock headers",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
for range 3 {
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "wrongpassword"))
|
||||
args.do(req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
|
||||
userCtx, recorder := args.do(req)
|
||||
|
||||
assert.Nil(t, userCtx)
|
||||
assert.Equal(t, "true", recorder.Header().Get("x-tinyauth-lock-locked"))
|
||||
assert.NotEmpty(t, recorder.Header().Get("x-tinyauth-lock-reset"))
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Cookie auth takes precedence over basic auth",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
uuid := "session-precedence"
|
||||
seedSession(t, args.queries, repository.CreateSessionParams{
|
||||
UUID: uuid,
|
||||
Username: "testuser",
|
||||
Provider: "local",
|
||||
Expiry: time.Now().Add(10 * time.Second).Unix(),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid})
|
||||
req.Header.Set("Authorization", basicAuthHeader("totpuser", "password"))
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
require.NotNil(t, userCtx)
|
||||
assert.Equal(t, "testuser", userCtx.GetUsername())
|
||||
assert.True(t, userCtx.Authenticated)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Ensure fallback to basic auth when cookie is missing",
|
||||
run: func(t *testing.T, args runArgs) {
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.Header.Set("Authorization", basicAuthHeader("testuser", "password"))
|
||||
userCtx, _ := args.do(req)
|
||||
|
||||
require.NotNil(t, userCtx)
|
||||
assert.Equal(t, "testuser", userCtx.GetUsername())
|
||||
assert.True(t, userCtx.Authenticated)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
wg := &sync.WaitGroup{}
|
||||
|
||||
app := bootstrap.NewBootstrapApp(cfg)
|
||||
|
||||
err := app.SetupDatabase()
|
||||
require.NoError(t, err)
|
||||
|
||||
queries := repository.New(app.GetDB())
|
||||
|
||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
||||
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
|
||||
|
||||
contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker)
|
||||
|
||||
for _, test := range tests {
|
||||
authService.ClearRateLimitsTestingOnly()
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
do := func(req *http.Request) (*model.UserContext, *httptest.ResponseRecorder) {
|
||||
var captured *model.UserContext
|
||||
router := gin.New()
|
||||
router.Use(contextMiddleware.Middleware())
|
||||
handler := func(c *gin.Context) {
|
||||
if val, exists := c.Get("context"); exists {
|
||||
captured, _ = val.(*model.UserContext)
|
||||
}
|
||||
}
|
||||
router.GET("/api/test", handler)
|
||||
router.GET("/api/healthz", handler)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
return captured, recorder
|
||||
}
|
||||
|
||||
test.run(t, runArgs{do: do, queries: queries})
|
||||
})
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
app.GetDB().Close()
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/assets"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -18,25 +19,29 @@ type UIMiddleware struct {
|
||||
uiFileServer http.Handler
|
||||
}
|
||||
|
||||
func NewUIMiddleware() (*UIMiddleware, error) {
|
||||
m := &UIMiddleware{}
|
||||
func NewUIMiddleware() *UIMiddleware {
|
||||
return &UIMiddleware{}
|
||||
}
|
||||
|
||||
func (m *UIMiddleware) Init() error {
|
||||
ui, err := fs.Sub(assets.FrontendAssets, "dist")
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load ui assets: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
m.uiFs = ui
|
||||
m.uiFileServer = http.FileServerFS(ui)
|
||||
|
||||
return m, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *UIMiddleware) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
|
||||
tlog.App.Debug().Str("path", path).Msg("path")
|
||||
|
||||
switch strings.SplitN(path, "/", 2)[0] {
|
||||
case "api", "resources", ".well-known":
|
||||
c.Next()
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
// See context middleware for explanation of why we have to do this
|
||||
@@ -17,14 +17,14 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
type ZerologMiddleware struct {
|
||||
log *logger.Logger
|
||||
type ZerologMiddleware struct{}
|
||||
|
||||
func NewZerologMiddleware() *ZerologMiddleware {
|
||||
return &ZerologMiddleware{}
|
||||
}
|
||||
|
||||
func NewZerologMiddleware(log *logger.Logger) *ZerologMiddleware {
|
||||
return &ZerologMiddleware{
|
||||
log: log,
|
||||
}
|
||||
func (m *ZerologMiddleware) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ZerologMiddleware) logPath(path string) bool {
|
||||
@@ -50,7 +50,7 @@ func (m *ZerologMiddleware) Middleware() gin.HandlerFunc {
|
||||
|
||||
latency := time.Since(tStart).String()
|
||||
|
||||
subLogger := m.log.HTTP.With().Str("method", method).
|
||||
subLogger := tlog.HTTP.With().Str("method", method).
|
||||
Str("path", path).
|
||||
Str("address", address).
|
||||
Str("client_ip", clientIP).
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package model
|
||||
|
||||
const DefaultNamePrefix = "TINYAUTH_"
|
||||
|
||||
const APIServer = "https://api.tinyauth.app"
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Groups any `json:"groups"`
|
||||
}
|
||||
|
||||
var OverrideProviders = map[string]string{
|
||||
"google": "Google",
|
||||
"github": "GitHub",
|
||||
}
|
||||
|
||||
const SessionCookieName = "tinyauth-session"
|
||||
const CSRFCookieName = "tinyauth-csrf"
|
||||
const RedirectCookieName = "tinyauth-redirect"
|
||||
const OAuthSessionCookieName = "tinyauth-oauth"
|
||||
@@ -1,254 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserContextNotFound = errors.New("user context not found")
|
||||
)
|
||||
|
||||
type ProviderType int
|
||||
|
||||
const (
|
||||
ProviderLocal ProviderType = iota
|
||||
ProviderBasicAuth
|
||||
ProviderOAuth
|
||||
ProviderLDAP
|
||||
)
|
||||
|
||||
type UserContext struct {
|
||||
Authenticated bool
|
||||
Provider ProviderType
|
||||
Local *LocalContext
|
||||
OAuth *OAuthContext
|
||||
LDAP *LDAPContext
|
||||
}
|
||||
|
||||
type BaseContext struct {
|
||||
Username string
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
type LocalContext struct {
|
||||
BaseContext
|
||||
TOTPPending bool
|
||||
Attributes UserAttributes
|
||||
}
|
||||
|
||||
type OAuthContext struct {
|
||||
BaseContext
|
||||
Groups []string
|
||||
Sub string
|
||||
DisplayName string
|
||||
ID string
|
||||
}
|
||||
|
||||
type LDAPContext struct {
|
||||
BaseContext
|
||||
Groups []string
|
||||
}
|
||||
|
||||
func (c *UserContext) IsAuthenticated() bool {
|
||||
return c.Authenticated
|
||||
}
|
||||
|
||||
func (c *UserContext) IsLocal() bool {
|
||||
return c.Provider == ProviderLocal && c.Local != nil
|
||||
}
|
||||
|
||||
func (c *UserContext) IsOAuth() bool {
|
||||
return c.Provider == ProviderOAuth && c.OAuth != nil
|
||||
}
|
||||
|
||||
func (c *UserContext) IsLDAP() bool {
|
||||
return c.Provider == ProviderLDAP && c.LDAP != nil
|
||||
}
|
||||
|
||||
func (c *UserContext) IsBasicAuth() bool {
|
||||
return c.Provider == ProviderBasicAuth && c.Local != nil
|
||||
}
|
||||
|
||||
func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
|
||||
userContextValue, exists := ginctx.Get("context")
|
||||
|
||||
if !exists {
|
||||
return nil, ErrUserContextNotFound
|
||||
}
|
||||
|
||||
userContext, ok := userContextValue.(*UserContext)
|
||||
|
||||
if !ok || userContext == nil {
|
||||
return nil, errors.New("invalid user context type")
|
||||
}
|
||||
|
||||
if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil {
|
||||
return nil, errors.New("incomplete user context")
|
||||
}
|
||||
|
||||
*c = *userContext
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Compatability layer until we get an excuse to drop in database migrations
|
||||
func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) {
|
||||
*c = UserContext{
|
||||
Authenticated: !session.TotpPending,
|
||||
}
|
||||
|
||||
switch session.Provider {
|
||||
case "local":
|
||||
c.Provider = ProviderLocal
|
||||
c.Local = &LocalContext{
|
||||
BaseContext: BaseContext{
|
||||
Username: session.Username,
|
||||
Name: session.Name,
|
||||
Email: session.Email,
|
||||
},
|
||||
TOTPPending: session.TotpPending,
|
||||
}
|
||||
case "ldap":
|
||||
c.Provider = ProviderLDAP
|
||||
c.LDAP = &LDAPContext{
|
||||
BaseContext: BaseContext{
|
||||
Username: session.Username,
|
||||
Name: session.Name,
|
||||
Email: session.Email,
|
||||
},
|
||||
}
|
||||
// By default we assume an unknown name which is oauth
|
||||
default:
|
||||
c.Provider = ProviderOAuth
|
||||
c.OAuth = &OAuthContext{
|
||||
BaseContext: BaseContext{
|
||||
Username: session.Username,
|
||||
Name: session.Name,
|
||||
Email: session.Email,
|
||||
},
|
||||
Groups: func() []string {
|
||||
if session.OAuthGroups == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(session.OAuthGroups, ",")
|
||||
}(),
|
||||
Sub: session.OAuthSub,
|
||||
DisplayName: session.OAuthName,
|
||||
ID: session.Provider,
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *UserContext) GetUsername() string {
|
||||
switch c.Provider {
|
||||
case ProviderLocal:
|
||||
if c.Local == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Local.Username
|
||||
case ProviderLDAP:
|
||||
if c.LDAP == nil {
|
||||
return ""
|
||||
}
|
||||
return c.LDAP.Username
|
||||
case ProviderBasicAuth:
|
||||
if c.Local == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Local.Username
|
||||
case ProviderOAuth:
|
||||
if c.OAuth == nil {
|
||||
return ""
|
||||
}
|
||||
return c.OAuth.Username
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UserContext) GetEmail() string {
|
||||
switch c.Provider {
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UserContext) GetName() string {
|
||||
switch c.Provider {
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UserContext) GetProviderID() string {
|
||||
switch c.Provider {
|
||||
case ProviderBasicAuth, ProviderLocal:
|
||||
return "local"
|
||||
case ProviderLDAP:
|
||||
return "ldap"
|
||||
case ProviderOAuth:
|
||||
return c.OAuth.ID
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UserContext) TOTPPending() bool {
|
||||
if c.Provider == ProviderLocal && c.Local != nil {
|
||||
return c.Local.TOTPPending
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *UserContext) OAuthName() string {
|
||||
if c.Provider == ProviderOAuth && c.OAuth != nil {
|
||||
return c.OAuth.DisplayName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
)
|
||||
|
||||
func TestContext(t *testing.T) {
|
||||
newGinCtx := func(value any, set bool) *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
if set {
|
||||
c.Set("context", value)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
description string
|
||||
context *model.UserContext
|
||||
run func(*testing.T, *model.UserContext) any
|
||||
expected any
|
||||
}{
|
||||
{
|
||||
description: "IsAuthenticated reflects Authenticated field",
|
||||
context: &model.UserContext{Authenticated: true},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsAuthenticated() },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "IsLocal returns true for ProviderLocal",
|
||||
context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsLocal() },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "IsOAuth returns true for ProviderOAuth",
|
||||
context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsOAuth() },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "IsLDAP returns true for ProviderLDAP",
|
||||
context: &model.UserContext{Provider: model.ProviderLDAP, LDAP: &model.LDAPContext{}},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsLDAP() },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "IsBasicAuth returns true for ProviderBasicAuth",
|
||||
context: &model.UserContext{Provider: model.ProviderBasicAuth, Local: &model.LocalContext{}},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.IsBasicAuth() },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "NewFromSession local session is authenticated and ProviderLocal",
|
||||
context: &model.UserContext{},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
got, err := c.NewFromSession(&repository.Session{
|
||||
Username: "alice", Email: "alice@example.com", Name: "Alice",
|
||||
Provider: "local",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return [2]any{got.Provider, got.Authenticated}
|
||||
},
|
||||
expected: [2]any{model.ProviderLocal, true},
|
||||
},
|
||||
{
|
||||
description: "NewFromSession local session with TotpPending is not authenticated",
|
||||
context: &model.UserContext{},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
got, err := c.NewFromSession(&repository.Session{
|
||||
Username: "bob", Provider: "local", TotpPending: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return got.Authenticated
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "NewFromSession ldap session is ProviderLDAP",
|
||||
context: &model.UserContext{},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
got, err := c.NewFromSession(&repository.Session{
|
||||
Username: "carol", Provider: "ldap",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return got.Provider
|
||||
},
|
||||
expected: model.ProviderLDAP,
|
||||
},
|
||||
{
|
||||
description: "NewFromSession unknown provider defaults to OAuth and populates oauth fields",
|
||||
context: &model.UserContext{},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
got, err := c.NewFromSession(&repository.Session{
|
||||
Username: "dave", Provider: "github",
|
||||
OAuthGroups: "devs,admins", OAuthSub: "sub-123", OAuthName: "GitHub",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return [5]any{got.Provider, got.OAuth.ID, got.OAuth.Sub, got.OAuth.DisplayName, got.OAuth.Groups}
|
||||
},
|
||||
expected: [5]any{model.ProviderOAuth, "github", "sub-123", "GitHub", []string{"devs", "admins"}},
|
||||
},
|
||||
{
|
||||
description: "Local getters return BaseContext fields",
|
||||
context: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice", Email: "alice@example.com", Name: "Alice"}},
|
||||
},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
||||
},
|
||||
expected: [3]string{"alice", "alice@example.com", "Alice"},
|
||||
},
|
||||
{
|
||||
description: "BasicAuth getters fall back to local fields",
|
||||
context: &model.UserContext{
|
||||
Provider: model.ProviderBasicAuth,
|
||||
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "bob", Email: "bob@example.com", Name: "Bob"}},
|
||||
},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
||||
},
|
||||
expected: [3]string{"bob", "bob@example.com", "Bob"},
|
||||
},
|
||||
{
|
||||
description: "LDAP getters return LDAP fields",
|
||||
context: &model.UserContext{
|
||||
Provider: model.ProviderLDAP,
|
||||
LDAP: &model.LDAPContext{BaseContext: model.BaseContext{Username: "carol", Email: "carol@example.com", Name: "Carol"}},
|
||||
},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
||||
},
|
||||
expected: [3]string{"carol", "carol@example.com", "Carol"},
|
||||
},
|
||||
{
|
||||
description: "OAuth getters return OAuth fields",
|
||||
context: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{BaseContext: model.BaseContext{Username: "dave", Email: "dave@example.com", Name: "Dave"}},
|
||||
},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
||||
},
|
||||
expected: [3]string{"dave", "dave@example.com", "Dave"},
|
||||
},
|
||||
{
|
||||
description: "ProviderName returns 'local' for ProviderLocal",
|
||||
context: &model.UserContext{Provider: model.ProviderLocal},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
|
||||
expected: "local",
|
||||
},
|
||||
{
|
||||
description: "ProviderName returns 'local' for ProviderBasicAuth",
|
||||
context: &model.UserContext{Provider: model.ProviderBasicAuth},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
|
||||
expected: "local",
|
||||
},
|
||||
{
|
||||
description: "ProviderName returns 'ldap' for ProviderLDAP",
|
||||
context: &model.UserContext{Provider: model.ProviderLDAP},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
|
||||
expected: "ldap",
|
||||
},
|
||||
{
|
||||
description: "ProviderName returns OAuth provider ID for ProviderOAuth",
|
||||
context: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{ID: "github"},
|
||||
},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.GetProviderID() },
|
||||
expected: "github",
|
||||
},
|
||||
{
|
||||
description: "TOTPPending returns true when local context is pending",
|
||||
context: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{TOTPPending: true},
|
||||
},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "TOTPPending returns false when local context is not pending",
|
||||
context: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{TOTPPending: false},
|
||||
},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "TOTPPending returns false for non-local providers",
|
||||
context: &model.UserContext{Provider: model.ProviderOAuth, OAuth: &model.OAuthContext{}},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.TOTPPending() },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "OAuthName returns DisplayName for ProviderOAuth",
|
||||
context: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{DisplayName: "Google"},
|
||||
},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.OAuthName() },
|
||||
expected: "Google",
|
||||
},
|
||||
{
|
||||
description: "OAuthName returns empty string for non-oauth providers",
|
||||
context: &model.UserContext{Provider: model.ProviderLocal, Local: &model.LocalContext{}},
|
||||
run: func(t *testing.T, c *model.UserContext) any { return c.OAuthName() },
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
description: "NewFromGin populates context from gin value",
|
||||
context: &model.UserContext{},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
stored := &model.UserContext{
|
||||
Authenticated: true,
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{BaseContext: model.BaseContext{Username: "alice"}},
|
||||
}
|
||||
got, err := c.NewFromGin(newGinCtx(stored, true))
|
||||
require.NoError(t, err)
|
||||
return [2]any{got.Authenticated, got.GetUsername()}
|
||||
},
|
||||
expected: [2]any{true, "alice"},
|
||||
},
|
||||
{
|
||||
description: "NewFromGin returns error when context value is missing",
|
||||
context: &model.UserContext{},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
_, err := c.NewFromGin(newGinCtx(nil, false))
|
||||
return err.Error()
|
||||
},
|
||||
expected: model.ErrUserContextNotFound.Error(),
|
||||
},
|
||||
{
|
||||
description: "NewFromGin returns error when context value has wrong type",
|
||||
context: &model.UserContext{},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
_, err := c.NewFromGin(newGinCtx("not a user context", true))
|
||||
return err.Error()
|
||||
},
|
||||
expected: "invalid user context type",
|
||||
},
|
||||
{
|
||||
description: "NewFromGin returns an error when context doesn't include user information",
|
||||
context: &model.UserContext{},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
_, err := c.NewFromGin(newGinCtx(&model.UserContext{Provider: model.ProviderLocal}, true))
|
||||
return err.Error()
|
||||
},
|
||||
expected: "incomplete user context",
|
||||
},
|
||||
{
|
||||
description: "Getters should not panic if provider context is empty",
|
||||
context: &model.UserContext{Provider: model.ProviderLocal},
|
||||
run: func(t *testing.T, c *model.UserContext) any {
|
||||
return [3]string{c.GetUsername(), c.GetEmail(), c.GetName()}
|
||||
},
|
||||
expected: [3]string{"", "", ""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, test.run(t, test.context))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package model
|
||||
|
||||
type RuntimeConfig struct {
|
||||
AppURL string
|
||||
UUID string
|
||||
CookieDomain string
|
||||
SessionCookieName string
|
||||
CSRFCookieName string
|
||||
RedirectCookieName string
|
||||
OAuthSessionCookieName string
|
||||
LocalUsers []LocalUser
|
||||
OAuthProviders map[string]OAuthServiceConfig
|
||||
OAuthWhitelist []string
|
||||
ConfiguredProviders []Provider
|
||||
OIDCClients []OIDCClientConfig
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
OAuth bool `json:"oauth"`
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package model
|
||||
|
||||
type UserSearchType int
|
||||
|
||||
const (
|
||||
UserLocal UserSearchType = iota
|
||||
UserLDAP
|
||||
)
|
||||
|
||||
type LDAPUser struct {
|
||||
DN string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
type LocalUser struct {
|
||||
Username string
|
||||
Password string
|
||||
TOTPSecret string
|
||||
Attributes UserAttributes
|
||||
}
|
||||
|
||||
type UserSearch struct {
|
||||
Username string
|
||||
Email string // used for LDAP, we can't throw it to LDAPUser because it would need another cache or an LDAP lookup every time
|
||||
Type UserSearchType
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package model
|
||||
|
||||
var Version = "development"
|
||||
var CommitHash = "development"
|
||||
var BuildTimestamp = "0000-00-00T00:00:00Z"
|
||||
@@ -34,19 +34,6 @@ type OidcUserinfo struct {
|
||||
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 Session struct {
|
||||
|
||||
@@ -124,24 +124,11 @@ INSERT INTO "oidc_userinfo" (
|
||||
"preferred_username",
|
||||
"email",
|
||||
"groups",
|
||||
"updated_at",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"middle_name",
|
||||
"nickname",
|
||||
"profile",
|
||||
"picture",
|
||||
"website",
|
||||
"gender",
|
||||
"birthdate",
|
||||
"zoneinfo",
|
||||
"locale",
|
||||
"phone_number",
|
||||
"address"
|
||||
"updated_at"
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address
|
||||
RETURNING sub, name, preferred_username, email, "groups", updated_at
|
||||
`
|
||||
|
||||
type CreateOidcUserInfoParams struct {
|
||||
@@ -151,19 +138,6 @@ type CreateOidcUserInfoParams struct {
|
||||
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
|
||||
}
|
||||
|
||||
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
|
||||
@@ -174,19 +148,6 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo
|
||||
arg.Email,
|
||||
arg.Groups,
|
||||
arg.UpdatedAt,
|
||||
arg.GivenName,
|
||||
arg.FamilyName,
|
||||
arg.MiddleName,
|
||||
arg.Nickname,
|
||||
arg.Profile,
|
||||
arg.Picture,
|
||||
arg.Website,
|
||||
arg.Gender,
|
||||
arg.Birthdate,
|
||||
arg.Zoneinfo,
|
||||
arg.Locale,
|
||||
arg.PhoneNumber,
|
||||
arg.Address,
|
||||
)
|
||||
var i OidcUserinfo
|
||||
err := row.Scan(
|
||||
@@ -196,19 +157,6 @@ func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfo
|
||||
&i.Email,
|
||||
&i.Groups,
|
||||
&i.UpdatedAt,
|
||||
&i.GivenName,
|
||||
&i.FamilyName,
|
||||
&i.MiddleName,
|
||||
&i.Nickname,
|
||||
&i.Profile,
|
||||
&i.Picture,
|
||||
&i.Website,
|
||||
&i.Gender,
|
||||
&i.Birthdate,
|
||||
&i.Zoneinfo,
|
||||
&i.Locale,
|
||||
&i.PhoneNumber,
|
||||
&i.Address,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -508,7 +456,7 @@ func (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken,
|
||||
}
|
||||
|
||||
const getOidcUserInfo = `-- name: GetOidcUserInfo :one
|
||||
SELECT sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address FROM "oidc_userinfo"
|
||||
SELECT sub, name, preferred_username, email, "groups", updated_at FROM "oidc_userinfo"
|
||||
WHERE "sub" = ?
|
||||
`
|
||||
|
||||
@@ -522,19 +470,6 @@ func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo
|
||||
&i.Email,
|
||||
&i.Groups,
|
||||
&i.UpdatedAt,
|
||||
&i.GivenName,
|
||||
&i.FamilyName,
|
||||
&i.MiddleName,
|
||||
&i.Nickname,
|
||||
&i.Profile,
|
||||
&i.Picture,
|
||||
&i.Website,
|
||||
&i.Gender,
|
||||
&i.Birthdate,
|
||||
&i.Zoneinfo,
|
||||
&i.Locale,
|
||||
&i.PhoneNumber,
|
||||
&i.Address,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -1,65 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
type LabelProvider interface {
|
||||
GetLabels(appDomain string) (*model.App, error)
|
||||
}
|
||||
|
||||
type AccessControlsService struct {
|
||||
log *logger.Logger
|
||||
labelProvider *LabelProvider
|
||||
static map[string]model.App
|
||||
docker *DockerService
|
||||
static map[string]config.App
|
||||
}
|
||||
|
||||
func NewAccessControlsService(
|
||||
log *logger.Logger,
|
||||
labelProvider *LabelProvider,
|
||||
static map[string]model.App) *AccessControlsService {
|
||||
func NewAccessControlsService(docker *DockerService, static map[string]config.App) *AccessControlsService {
|
||||
return &AccessControlsService{
|
||||
log: log,
|
||||
labelProvider: labelProvider,
|
||||
static: static,
|
||||
docker: docker,
|
||||
static: static,
|
||||
}
|
||||
}
|
||||
|
||||
func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
||||
var appAcls *model.App
|
||||
func (acls *AccessControlsService) Init() error {
|
||||
return nil // No initialization needed
|
||||
}
|
||||
|
||||
func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {
|
||||
for app, config := range acls.static {
|
||||
if config.Config.Domain == domain {
|
||||
acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||
appAcls = &config
|
||||
break // If we find a match by domain, we can stop searching
|
||||
tlog.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||
return config, nil
|
||||
}
|
||||
|
||||
if strings.SplitN(domain, ".", 2)[0] == app {
|
||||
acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||
appAcls = &config
|
||||
break // If we find a match by app name, we can stop searching
|
||||
tlog.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||
return config, nil
|
||||
}
|
||||
}
|
||||
return appAcls
|
||||
return config.App{}, errors.New("no results")
|
||||
}
|
||||
|
||||
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
||||
func (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {
|
||||
// First check in the static config
|
||||
app := acls.lookupStaticACLs(domain)
|
||||
app, err := acls.lookupStaticACLs(domain)
|
||||
|
||||
if app != nil {
|
||||
acls.log.App.Debug().Msg("Using static ACLs for app")
|
||||
if err == nil {
|
||||
tlog.App.Debug().Msg("Using ACls from static configuration")
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// If we have a label provider configured, try to get ACLs from it
|
||||
if acls.labelProvider != nil {
|
||||
return (*acls.labelProvider).GetLabels(domain)
|
||||
}
|
||||
|
||||
// no labels
|
||||
return nil, nil
|
||||
// Fallback to Docker labels
|
||||
tlog.App.Debug().Msg("Falling back to Docker labels for ACLs")
|
||||
return acls.docker.GetLabels(domain)
|
||||
}
|
||||
|
||||
+230
-251
@@ -5,22 +5,20 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
|
||||
"slices"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -30,10 +28,6 @@ const MaxOAuthPendingSessions = 256
|
||||
const OAuthCleanupCount = 16
|
||||
const MaxLoginAttemptRecords = 256
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
)
|
||||
|
||||
// slightly modified version of the AuthorizeRequest from the OIDC service to basically accept all
|
||||
// parameters and pass them to the authorize page if needed
|
||||
type OAuthURLParams struct {
|
||||
@@ -72,42 +66,41 @@ type Lockdown struct {
|
||||
ActiveUntil time.Time
|
||||
}
|
||||
|
||||
type AuthServiceConfig struct {
|
||||
Users []config.User
|
||||
OauthWhitelist []string
|
||||
SessionExpiry int
|
||||
SessionMaxLifetime int
|
||||
SecureCookie bool
|
||||
CookieDomain string
|
||||
LoginTimeout int
|
||||
LoginMaxRetries int
|
||||
SessionCookieName string
|
||||
IP config.IPConfig
|
||||
LDAPGroupsCacheTTL int
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
context context.Context
|
||||
|
||||
ldap *LdapService
|
||||
queries *repository.Queries
|
||||
oauthBroker *OAuthBrokerService
|
||||
|
||||
config AuthServiceConfig
|
||||
docker *DockerService
|
||||
loginAttempts map[string]*LoginAttempt
|
||||
ldapGroupsCache map[string]*LdapGroupsCache
|
||||
oauthPendingSessions map[string]*OAuthPendingSession
|
||||
oauthMutex sync.RWMutex
|
||||
loginMutex sync.RWMutex
|
||||
ldapGroupsMutex sync.RWMutex
|
||||
ldap *LdapService
|
||||
queries *repository.Queries
|
||||
oauthBroker *OAuthBrokerService
|
||||
lockdown *Lockdown
|
||||
lockdownCtx context.Context
|
||||
lockdownCancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtime model.RuntimeConfig,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
ldap *LdapService,
|
||||
queries *repository.Queries,
|
||||
oauthBroker *OAuthBrokerService,
|
||||
) *AuthService {
|
||||
service := &AuthService{
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
context: ctx,
|
||||
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService {
|
||||
return &AuthService{
|
||||
config: config,
|
||||
docker: docker,
|
||||
loginAttempts: make(map[string]*LoginAttempt),
|
||||
ldapGroupsCache: make(map[string]*LdapGroupsCache),
|
||||
oauthPendingSessions: make(map[string]*OAuthPendingSession),
|
||||
@@ -115,80 +108,86 @@ func NewAuthService(
|
||||
queries: queries,
|
||||
oauthBroker: oauthBroker,
|
||||
}
|
||||
|
||||
wg.Go(service.CleanupOAuthSessionsRoutine)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
|
||||
if auth.GetLocalUser(username) != nil {
|
||||
return &model.UserSearch{
|
||||
func (auth *AuthService) Init() error {
|
||||
go auth.CleanupOAuthSessionsRoutine()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) SearchUser(username string) config.UserSearch {
|
||||
if auth.GetLocalUser(username).Username != "" {
|
||||
return config.UserSearch{
|
||||
Username: username,
|
||||
Type: model.UserLocal,
|
||||
}, nil
|
||||
Type: "local",
|
||||
}
|
||||
}
|
||||
|
||||
if auth.ldap != nil {
|
||||
userDN, email, err := auth.ldap.GetUserInfo(username)
|
||||
if auth.ldap.IsConfigured() {
|
||||
userDN, err := auth.ldap.GetUserDN(username)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ldap user: %w", err)
|
||||
tlog.App.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP")
|
||||
return config.UserSearch{
|
||||
Type: "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
return &model.UserSearch{
|
||||
return config.UserSearch{
|
||||
Username: userDN,
|
||||
Email: email,
|
||||
Type: model.UserLDAP,
|
||||
}, nil
|
||||
Type: "ldap",
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrUserNotFound
|
||||
return config.UserSearch{
|
||||
Type: "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
func (auth *AuthService) CheckUserPassword(search model.UserSearch, password string) error {
|
||||
func (auth *AuthService) VerifyUser(search config.UserSearch, password string) bool {
|
||||
switch search.Type {
|
||||
case model.UserLocal:
|
||||
case "local":
|
||||
user := auth.GetLocalUser(search.Username)
|
||||
if user == nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
||||
case model.UserLDAP:
|
||||
if auth.ldap != nil {
|
||||
return auth.CheckPassword(user, password)
|
||||
case "ldap":
|
||||
if auth.ldap.IsConfigured() {
|
||||
err := auth.ldap.Bind(search.Username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind to ldap user: %w", err)
|
||||
tlog.App.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
|
||||
return false
|
||||
}
|
||||
|
||||
err = auth.ldap.BindService(true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind to ldap service account: %w", err)
|
||||
tlog.App.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
|
||||
return false
|
||||
}
|
||||
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return errors.New("unknown user search type")
|
||||
tlog.App.Debug().Str("type", search.Type).Msg("Unknown user type for authentication")
|
||||
return false
|
||||
}
|
||||
return errors.New("user authentication failed")
|
||||
|
||||
tlog.App.Warn().Str("username", search.Username).Msg("User authentication failed")
|
||||
return false
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetLocalUser(username string) *model.LocalUser {
|
||||
if auth.runtime.LocalUsers == nil {
|
||||
return nil
|
||||
}
|
||||
for _, user := range auth.runtime.LocalUsers {
|
||||
func (auth *AuthService) GetLocalUser(username string) config.User {
|
||||
for _, user := range auth.config.Users {
|
||||
if user.Username == username {
|
||||
return &user
|
||||
return user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
tlog.App.Warn().Str("username", username).Msg("Local user not found")
|
||||
return config.User{}
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
if auth.ldap == nil {
|
||||
return nil, errors.New("ldap service not configured")
|
||||
func (auth *AuthService) GetLdapUser(userDN string) (config.LdapUser, error) {
|
||||
if !auth.ldap.IsConfigured() {
|
||||
return config.LdapUser{}, errors.New("LDAP service not initialized")
|
||||
}
|
||||
|
||||
auth.ldapGroupsMutex.RLock()
|
||||
@@ -196,7 +195,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
auth.ldapGroupsMutex.RUnlock()
|
||||
|
||||
if exists && time.Now().Before(entry.Expires) {
|
||||
return &model.LDAPUser{
|
||||
return config.LdapUser{
|
||||
DN: userDN,
|
||||
Groups: entry.Groups,
|
||||
}, nil
|
||||
@@ -205,22 +204,26 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
groups, err := auth.ldap.GetUserGroups(userDN)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ldap groups: %w", err)
|
||||
return config.LdapUser{}, err
|
||||
}
|
||||
|
||||
auth.ldapGroupsMutex.Lock()
|
||||
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
|
||||
Groups: groups,
|
||||
Expires: time.Now().Add(time.Duration(auth.config.LDAP.GroupCacheTTL) * time.Second),
|
||||
Expires: time.Now().Add(time.Duration(auth.config.LDAPGroupsCacheTTL) * time.Second),
|
||||
}
|
||||
auth.ldapGroupsMutex.Unlock()
|
||||
|
||||
return &model.LDAPUser{
|
||||
return config.LdapUser{
|
||||
DN: userDN,
|
||||
Groups: groups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) CheckPassword(user config.User, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
auth.loginMutex.RLock()
|
||||
defer auth.loginMutex.RUnlock()
|
||||
@@ -230,7 +233,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
return true, remaining
|
||||
}
|
||||
|
||||
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
|
||||
if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
@@ -248,7 +251,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
}
|
||||
|
||||
func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
||||
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
|
||||
if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -279,21 +282,21 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
||||
|
||||
attempt.FailedAttempts++
|
||||
|
||||
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
||||
if attempt.FailedAttempts >= auth.config.LoginMaxRetries {
|
||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second)
|
||||
tlog.App.Warn().Str("identifier", identifier).Int("timeout", auth.config.LoginTimeout).Msg("Account locked due to too many failed login attempts")
|
||||
}
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
||||
return utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email)
|
||||
return utils.CheckFilter(strings.Join(auth.config.OauthWhitelist, ","), email)
|
||||
}
|
||||
|
||||
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
|
||||
func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *repository.Session) error {
|
||||
uuid, err := uuid.NewRandom()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session uuid: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var expiry int
|
||||
@@ -301,11 +304,9 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
if data.TotpPending {
|
||||
expiry = 3600
|
||||
} else {
|
||||
expiry = auth.config.Auth.SessionExpiry
|
||||
expiry = auth.config.SessionExpiry
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
|
||||
|
||||
session := repository.CreateSessionParams{
|
||||
UUID: uuid.String(),
|
||||
Username: data.Username,
|
||||
@@ -314,55 +315,53 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
Provider: data.Provider,
|
||||
TotpPending: data.TotpPending,
|
||||
OAuthGroups: data.OAuthGroups,
|
||||
Expiry: expiresAt.Unix(),
|
||||
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
OAuthName: data.OAuthName,
|
||||
OAuthSub: data.OAuthSub,
|
||||
}
|
||||
|
||||
_, err = auth.queries.CreateSession(ctx, session)
|
||||
_, err = auth.queries.CreateSession(c, session)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session entry: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
c.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http.Cookie, error) {
|
||||
session, err := auth.queries.GetSession(ctx, uuid)
|
||||
func (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {
|
||||
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve session: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
session, err := auth.queries.GetSession(c, cookie)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
var refreshThreshold int64
|
||||
|
||||
if auth.config.Auth.SessionExpiry <= int(time.Hour.Seconds()) {
|
||||
refreshThreshold = int64(auth.config.Auth.SessionExpiry / 2)
|
||||
if auth.config.SessionExpiry <= int(time.Hour.Seconds()) {
|
||||
refreshThreshold = int64(auth.config.SessionExpiry / 2)
|
||||
} else {
|
||||
refreshThreshold = int64(time.Hour.Seconds())
|
||||
}
|
||||
|
||||
if session.Expiry-currentTime > refreshThreshold {
|
||||
return nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
newExpiry := session.Expiry + refreshThreshold
|
||||
|
||||
_, err = auth.queries.UpdateSession(ctx, repository.UpdateSessionParams{
|
||||
_, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{
|
||||
Username: session.Username,
|
||||
Email: session.Email,
|
||||
Name: session.Name,
|
||||
@@ -376,160 +375,150 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update session expiry: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
|
||||
MaxAge: int(newExpiry - currentTime),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
c.SetCookie(auth.config.SessionCookieName, cookie, int(newExpiry-currentTime), "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
||||
tlog.App.Trace().Str("username", session.Username).Msg("Session cookie refreshed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.Cookie, error) {
|
||||
err := auth.queries.DeleteSession(ctx, uuid)
|
||||
func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
|
||||
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
||||
|
||||
if err != nil {
|
||||
auth.log.App.Error().Err(err).Str("uuid", uuid).Msg("Failed to delete session from database")
|
||||
return err
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
err = auth.queries.DeleteSession(c, cookie)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.SetCookie(auth.config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*repository.Session, error) {
|
||||
session, err := auth.queries.GetSession(ctx, uuid)
|
||||
func (auth *AuthService) GetSessionCookie(c *gin.Context) (repository.Session, error) {
|
||||
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
||||
|
||||
if err != nil {
|
||||
return repository.Session{}, err
|
||||
}
|
||||
|
||||
session, err := auth.queries.GetSession(c, cookie)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("session not found")
|
||||
return repository.Session{}, fmt.Errorf("session not found")
|
||||
}
|
||||
return nil, err
|
||||
return repository.Session{}, err
|
||||
}
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
if auth.config.Auth.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
|
||||
if currentTime-session.CreatedAt > int64(auth.config.Auth.SessionMaxLifetime) {
|
||||
err = auth.queries.DeleteSession(ctx, uuid)
|
||||
if auth.config.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
|
||||
if currentTime-session.CreatedAt > int64(auth.config.SessionMaxLifetime) {
|
||||
err = auth.queries.DeleteSession(c, cookie)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete expired session: %w", err)
|
||||
tlog.App.Error().Err(err).Msg("Failed to delete session exceeding max lifetime")
|
||||
}
|
||||
return nil, fmt.Errorf("session max lifetime exceeded")
|
||||
return repository.Session{}, fmt.Errorf("session expired due to max lifetime exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
if currentTime > session.Expiry {
|
||||
err = auth.queries.DeleteSession(ctx, uuid)
|
||||
err = auth.queries.DeleteSession(c, cookie)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete expired session: %w", err)
|
||||
tlog.App.Error().Err(err).Msg("Failed to delete expired session")
|
||||
}
|
||||
return nil, fmt.Errorf("session expired")
|
||||
return repository.Session{}, fmt.Errorf("session expired")
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
return repository.Session{
|
||||
UUID: session.UUID,
|
||||
Username: session.Username,
|
||||
Email: session.Email,
|
||||
Name: session.Name,
|
||||
Provider: session.Provider,
|
||||
TotpPending: session.TotpPending,
|
||||
OAuthGroups: session.OAuthGroups,
|
||||
OAuthName: session.OAuthName,
|
||||
OAuthSub: session.OAuthSub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) LocalAuthConfigured() bool {
|
||||
return len(auth.runtime.LocalUsers) > 0
|
||||
return len(auth.config.Users) > 0
|
||||
}
|
||||
|
||||
func (auth *AuthService) LDAPAuthConfigured() bool {
|
||||
return auth.ldap != nil
|
||||
func (auth *AuthService) LdapAuthConfigured() bool {
|
||||
return auth.ldap.IsConfigured()
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if context.Provider == model.ProviderOAuth {
|
||||
auth.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
|
||||
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
|
||||
func (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {
|
||||
if context.OAuth {
|
||||
tlog.App.Debug().Msg("Checking OAuth whitelist")
|
||||
return utils.CheckFilter(acls.OAuth.Whitelist, context.Email)
|
||||
}
|
||||
|
||||
if acls.Users.Block != "" {
|
||||
auth.log.App.Debug().Msg("Checking users block list")
|
||||
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
|
||||
tlog.App.Debug().Msg("Checking blocked users")
|
||||
if utils.CheckFilter(acls.Users.Block, context.Username) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("Checking users allow list")
|
||||
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
|
||||
tlog.App.Debug().Msg("Checking users")
|
||||
return utils.CheckFilter(acls.Users.Allow, context.Username)
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {
|
||||
if requiredGroups == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if !context.IsOAuth() {
|
||||
auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
|
||||
auth.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
|
||||
return true
|
||||
}
|
||||
|
||||
for _, userGroup := range context.OAuth.Groups {
|
||||
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
|
||||
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
|
||||
for id := range config.OverrideProviders {
|
||||
if context.Provider == id {
|
||||
tlog.App.Info().Str("provider", id).Msg("OAuth groups not supported for this provider")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("No groups matched")
|
||||
return false
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if !context.IsLDAP() {
|
||||
auth.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, userGroup := range context.LDAP.Groups {
|
||||
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
|
||||
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
|
||||
for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") {
|
||||
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
|
||||
tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Msg("No groups matched")
|
||||
tlog.App.Debug().Msg("No groups matched")
|
||||
return false
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error) {
|
||||
if acls == nil {
|
||||
return true, nil
|
||||
func (auth *AuthService) IsInLdapGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {
|
||||
if requiredGroups == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
for userGroup := range strings.SplitSeq(context.LdapGroups, ",") {
|
||||
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
|
||||
tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
tlog.App.Debug().Msg("No groups matched")
|
||||
return false
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) {
|
||||
// Check for block list
|
||||
if acls.Path.Block != "" {
|
||||
regex, err := regexp.Compile(acls.Path.Block)
|
||||
if path.Block != "" {
|
||||
regex, err := regexp.Compile(path.Block)
|
||||
|
||||
if err != nil {
|
||||
return true, err
|
||||
@@ -541,8 +530,8 @@ func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error
|
||||
}
|
||||
|
||||
// Check for allow list
|
||||
if acls.Path.Allow != "" {
|
||||
regex, err := regexp.Compile(acls.Path.Allow)
|
||||
if path.Allow != "" {
|
||||
regex, err := regexp.Compile(path.Allow)
|
||||
|
||||
if err != nil {
|
||||
return true, err
|
||||
@@ -556,23 +545,31 @@ func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return true
|
||||
func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User {
|
||||
username, password, ok := c.Request.BasicAuth()
|
||||
if !ok {
|
||||
tlog.App.Debug().Msg("No basic auth provided")
|
||||
return nil
|
||||
}
|
||||
return &config.User{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool {
|
||||
// Merge the global and app IP filter
|
||||
blockedIps := append(auth.config.Auth.IP.Block, acls.IP.Block...)
|
||||
allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...)
|
||||
blockedIps := append(auth.config.IP.Block, acls.Block...)
|
||||
allowedIPs := append(auth.config.IP.Allow, acls.Allow...)
|
||||
|
||||
for _, blocked := range blockedIps {
|
||||
res, err := utils.FilterIP(blocked, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
||||
tlog.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
|
||||
tlog.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -580,42 +577,38 @@ func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
|
||||
for _, allowed := range allowedIPs {
|
||||
res, err := utils.FilterIP(allowed, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
||||
tlog.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
|
||||
tlog.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedIPs) > 0 {
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
||||
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
||||
return false
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default")
|
||||
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default")
|
||||
return true
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsBypassedIP(ip string, acls *model.App) bool {
|
||||
if acls == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, bypassed := range acls.IP.Bypass {
|
||||
func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool {
|
||||
for _, bypassed := range acls.Bypass {
|
||||
res, err := utils.FilterIP(bypassed, ip)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||
tlog.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||
continue
|
||||
}
|
||||
if res {
|
||||
auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
|
||||
tlog.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
|
||||
tlog.App.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -682,21 +675,21 @@ func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.T
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, error) {
|
||||
func (auth *AuthService) GetOAuthUserinfo(sessionId string) (config.Claims, error) {
|
||||
session, err := auth.GetOAuthPendingSession(sessionId)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return config.Claims{}, err
|
||||
}
|
||||
|
||||
if session.Token == nil {
|
||||
return nil, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
||||
return config.Claims{}, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
||||
}
|
||||
|
||||
userinfo, err := (*session.Service).GetUserinfo(session.Token)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get userinfo: %w", err)
|
||||
return config.Claims{}, fmt.Errorf("failed to get userinfo: %w", err)
|
||||
}
|
||||
|
||||
return userinfo, nil
|
||||
@@ -719,32 +712,21 @@ func (auth *AuthService) EndOAuthSession(sessionId string) {
|
||||
}
|
||||
|
||||
func (auth *AuthService) CleanupOAuthSessionsRoutine() {
|
||||
auth.log.App.Debug().Msg("Starting OAuth session cleanup routine")
|
||||
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
auth.log.App.Debug().Msg("Running OAuth session cleanup")
|
||||
for range ticker.C {
|
||||
auth.oauthMutex.Lock()
|
||||
|
||||
auth.oauthMutex.Lock()
|
||||
now := time.Now()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for sessionId, session := range auth.oauthPendingSessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
}
|
||||
for sessionId, session := range auth.oauthPendingSessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
}
|
||||
|
||||
auth.oauthMutex.Unlock()
|
||||
auth.log.App.Debug().Msg("OAuth session cleanup completed")
|
||||
case <-auth.context.Done():
|
||||
auth.log.App.Debug().Msg("Stopping OAuth session cleanup routine")
|
||||
return
|
||||
}
|
||||
|
||||
auth.oauthMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,11 +795,11 @@ func (auth *AuthService) lockdownMode() {
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
|
||||
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
|
||||
tlog.App.Warn().Msg("Multiple login attempts detected, possibly DDOS attack. Activating temporary lockdown.")
|
||||
|
||||
auth.lockdown = &Lockdown{
|
||||
Active: true,
|
||||
ActiveUntil: time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second),
|
||||
ActiveUntil: time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second),
|
||||
}
|
||||
|
||||
// At this point all login attemps will also expire so,
|
||||
@@ -834,14 +816,11 @@ func (auth *AuthService) lockdownMode() {
|
||||
// Timer expired, end lockdown
|
||||
case <-ctx.Done():
|
||||
// Context cancelled, end lockdown
|
||||
case <-auth.context.Done():
|
||||
// Service is shutting down, end lockdown
|
||||
}
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
|
||||
auth.log.App.Info().Msg("Exiting lockdown mode")
|
||||
|
||||
tlog.App.Info().Msg("Lockdown period ended, resuming normal operation")
|
||||
auth.lockdown = nil
|
||||
auth.loginMutex.Unlock()
|
||||
}
|
||||
|
||||
@@ -3,112 +3,104 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
container "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
type DockerService struct {
|
||||
log *logger.Logger
|
||||
client *client.Client
|
||||
context context.Context
|
||||
|
||||
client *client.Client
|
||||
context context.Context
|
||||
isConnected bool
|
||||
}
|
||||
|
||||
func NewDockerService(
|
||||
log *logger.Logger,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) (*DockerService, error) {
|
||||
func NewDockerService() *DockerService {
|
||||
return &DockerService{}
|
||||
}
|
||||
|
||||
func (docker *DockerService) Init() error {
|
||||
client, err := client.NewClientWithOpts(client.FromEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
client.NegotiateAPIVersion(ctx)
|
||||
|
||||
_, err = client.Ping(ctx)
|
||||
docker.client = client
|
||||
docker.context = ctx
|
||||
|
||||
_, err = docker.client.Ping(docker.context)
|
||||
|
||||
if err != nil {
|
||||
log.App.Debug().Err(err).Msg("Docker not connected")
|
||||
return nil, nil
|
||||
tlog.App.Debug().Err(err).Msg("Docker not connected")
|
||||
docker.isConnected = false
|
||||
docker.client = nil
|
||||
docker.context = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
service := &DockerService{
|
||||
log: log,
|
||||
client: client,
|
||||
context: ctx,
|
||||
}
|
||||
docker.isConnected = true
|
||||
tlog.App.Debug().Msg("Docker connected")
|
||||
|
||||
service.isConnected = true
|
||||
service.log.App.Debug().Msg("Docker connected successfully")
|
||||
|
||||
wg.Go(service.watchAndClose)
|
||||
|
||||
return service, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) getContainers() ([]container.Summary, error) {
|
||||
return docker.client.ContainerList(docker.context, container.ListOptions{})
|
||||
containers, err := docker.client.ContainerList(docker.context, container.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) inspectContainer(containerId string) (container.InspectResponse, error) {
|
||||
return docker.client.ContainerInspect(docker.context, containerId)
|
||||
inspect, err := docker.client.ContainerInspect(docker.context, containerId)
|
||||
if err != nil {
|
||||
return container.InspectResponse{}, err
|
||||
}
|
||||
return inspect, nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
|
||||
func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
|
||||
if !docker.isConnected {
|
||||
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
|
||||
return nil, nil
|
||||
tlog.App.Debug().Msg("Docker not connected, returning empty labels")
|
||||
return config.App{}, nil
|
||||
}
|
||||
|
||||
containers, err := docker.getContainers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return config.App{}, err
|
||||
}
|
||||
|
||||
for _, ctr := range containers {
|
||||
inspect, err := docker.inspectContainer(ctr.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return config.App{}, err
|
||||
}
|
||||
|
||||
labels, err := decoders.DecodeLabels[model.Apps](inspect.Config.Labels, "apps")
|
||||
labels, err := decoders.DecodeLabels[config.Apps](inspect.Config.Labels, "apps")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return config.App{}, err
|
||||
}
|
||||
|
||||
for appName, appLabels := range labels.Apps {
|
||||
if appLabels.Config.Domain == appDomain {
|
||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
|
||||
return &appLabels, nil
|
||||
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
|
||||
return appLabels, nil
|
||||
}
|
||||
|
||||
if strings.SplitN(appDomain, ".", 2)[0] == appName {
|
||||
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
|
||||
return &appLabels, nil
|
||||
tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
|
||||
return appLabels, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) watchAndClose() {
|
||||
<-docker.context.Done()
|
||||
docker.log.App.Debug().Msg("Closing Docker client")
|
||||
if docker.client != nil {
|
||||
err := docker.client.Close()
|
||||
if err != nil {
|
||||
docker.log.App.Error().Err(err).Msg("Error closing Docker client")
|
||||
}
|
||||
}
|
||||
tlog.App.Debug().Msg("No matching container found, returning empty labels")
|
||||
return config.App{}, nil
|
||||
}
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
type ingressKey struct {
|
||||
namespace string
|
||||
name string
|
||||
}
|
||||
|
||||
type ingressAppKey struct {
|
||||
ingressKey
|
||||
appName string
|
||||
}
|
||||
|
||||
type ingressApp struct {
|
||||
domain string
|
||||
appName string
|
||||
app model.App
|
||||
}
|
||||
|
||||
type KubernetesService struct {
|
||||
log *logger.Logger
|
||||
ctx context.Context
|
||||
|
||||
client dynamic.Interface
|
||||
started bool
|
||||
mu sync.RWMutex
|
||||
ingressApps map[ingressKey][]ingressApp
|
||||
domainIndex map[string]ingressAppKey
|
||||
appNameIndex map[string]ingressAppKey
|
||||
}
|
||||
|
||||
func NewKubernetesService(
|
||||
log *logger.Logger,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) (*KubernetesService, error) {
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get in-cluster kubernetes config: %w", err)
|
||||
}
|
||||
|
||||
client, err := dynamic.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
|
||||
}
|
||||
|
||||
gvr := schema.GroupVersionResource{
|
||||
Group: "networking.k8s.io",
|
||||
Version: "v1",
|
||||
Resource: "ingresses",
|
||||
}
|
||||
|
||||
accessCtx, accessCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer accessCancel()
|
||||
|
||||
_, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
|
||||
if err != nil {
|
||||
log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access Ingress API, Kubernetes label provider will be disabled")
|
||||
return nil, fmt.Errorf("failed to access ingress api: %w", err)
|
||||
}
|
||||
|
||||
log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
|
||||
|
||||
service := &KubernetesService{
|
||||
log: log,
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
ingressApps: make(map[ingressKey][]ingressApp),
|
||||
domainIndex: make(map[string]ingressAppKey),
|
||||
appNameIndex: make(map[string]ingressAppKey),
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
service.watchGVR(gvr)
|
||||
})
|
||||
|
||||
service.started = true
|
||||
log.App.Debug().Msg("Kubernetes label provider started successfully")
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
key := ingressKey{namespace, name}
|
||||
// Remove existing entries for this ingress
|
||||
if existing, ok := k.ingressApps[key]; ok {
|
||||
for _, app := range existing {
|
||||
delete(k.domainIndex, app.domain)
|
||||
delete(k.appNameIndex, app.appName)
|
||||
}
|
||||
}
|
||||
// Add new entries
|
||||
k.ingressApps[key] = apps
|
||||
for _, app := range apps {
|
||||
appKey := ingressAppKey{key, app.appName}
|
||||
k.domainIndex[app.domain] = appKey
|
||||
k.appNameIndex[app.appName] = appKey
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) removeIngress(namespace, name string) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
key := ingressKey{namespace, name}
|
||||
if apps, ok := k.ingressApps[key]; ok {
|
||||
for _, app := range apps {
|
||||
delete(k.domainIndex, app.domain)
|
||||
delete(k.appNameIndex, app.appName)
|
||||
}
|
||||
delete(k.ingressApps, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) getByDomain(domain string) *model.App {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
|
||||
if appKey, ok := k.domainIndex[domain]; ok {
|
||||
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
|
||||
for i := range apps {
|
||||
app := &apps[i]
|
||||
if app.domain == domain && app.appName == appKey.appName {
|
||||
return &app.app
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) getByAppName(appName string) *model.App {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
|
||||
if appKey, ok := k.appNameIndex[appName]; ok {
|
||||
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
|
||||
for i := range apps {
|
||||
app := &apps[i]
|
||||
if app.appName == appName {
|
||||
return &app.app
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
||||
namespace := item.GetNamespace()
|
||||
name := item.GetName()
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
k.removeIngress(namespace, name)
|
||||
return
|
||||
}
|
||||
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
|
||||
k.removeIngress(namespace, name)
|
||||
return
|
||||
}
|
||||
var apps []ingressApp
|
||||
for appName, appLabels := range labels.Apps {
|
||||
if appLabels.Config.Domain == "" {
|
||||
continue
|
||||
}
|
||||
apps = append(apps, ingressApp{
|
||||
domain: appLabels.Config.Domain,
|
||||
appName: appName,
|
||||
app: appLabels,
|
||||
})
|
||||
}
|
||||
if len(apps) == 0 {
|
||||
k.removeIngress(namespace, name)
|
||||
} else {
|
||||
k.addIngressApps(namespace, name, apps)
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
|
||||
ctx, cancel := context.WithTimeout(k.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list resources for resync")
|
||||
return err
|
||||
}
|
||||
for i := range list.Items {
|
||||
k.updateFromItem(&list.Items[i])
|
||||
}
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWatcher drains events from an active watcher until it closes or the context is done.
|
||||
// Returns true if the caller should restart the watcher, false if it should exit.
|
||||
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker) bool {
|
||||
for {
|
||||
select {
|
||||
case <-k.ctx.Done():
|
||||
w.Stop()
|
||||
return false
|
||||
case event, ok := <-w.ResultChan():
|
||||
if !ok {
|
||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting watcher")
|
||||
w.Stop()
|
||||
time.Sleep(5 * time.Second)
|
||||
return true
|
||||
}
|
||||
item, ok := event.Object.(*unstructured.Unstructured)
|
||||
if !ok {
|
||||
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Received unexpected event object, skipping")
|
||||
continue
|
||||
}
|
||||
switch event.Type {
|
||||
case watch.Added, watch.Modified:
|
||||
k.updateFromItem(item)
|
||||
case watch.Deleted:
|
||||
k.removeIngress(item.GetNamespace(), item.GetName())
|
||||
}
|
||||
case <-resyncTicker.C:
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
|
||||
resyncTicker := time.NewTicker(5 * time.Minute)
|
||||
defer resyncTicker.Stop()
|
||||
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry")
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-k.ctx.Done():
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher")
|
||||
return
|
||||
case <-resyncTicker.C:
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry")
|
||||
}
|
||||
default:
|
||||
ctx, cancel := context.WithCancel(k.ctx)
|
||||
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry")
|
||||
cancel()
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully")
|
||||
if !k.runWatcher(gvr, watcher, resyncTicker) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
|
||||
if !k.started {
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// First check cache
|
||||
app := k.getByDomain(appDomain)
|
||||
if app != nil {
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
|
||||
return app, nil
|
||||
}
|
||||
appName := strings.SplitN(appDomain, ".", 2)[0]
|
||||
app = k.getByAppName(appName)
|
||||
if app != nil {
|
||||
k.log.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
|
||||
return app, nil
|
||||
}
|
||||
|
||||
k.log.App.Debug().Str("domain", appDomain).Msg("No labels found for domain")
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestKubernetesService(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
run func(t *testing.T, svc *KubernetesService)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Cache by domain returns app and misses unknown domain",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
|
||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
||||
{domain: "foo.example.com", appName: "foo", app: app},
|
||||
})
|
||||
|
||||
got := svc.getByDomain("foo.example.com")
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "foo.example.com", got.Config.Domain)
|
||||
|
||||
got = svc.getByDomain("notfound.example.com")
|
||||
assert.Nil(t, got)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Cache by app name returns app and misses unknown name",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
app := model.App{Config: model.AppConfig{Domain: "bar.example.com"}}
|
||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
||||
{domain: "bar.example.com", appName: "bar", app: app},
|
||||
})
|
||||
|
||||
got := svc.getByAppName("bar")
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "bar.example.com", got.Config.Domain)
|
||||
|
||||
got = svc.getByAppName("notfound")
|
||||
assert.Nil(t, got)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "RemoveIngress clears domain and app name entries",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
app := model.App{Config: model.AppConfig{Domain: "baz.example.com"}}
|
||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
||||
{domain: "baz.example.com", appName: "baz", app: app},
|
||||
})
|
||||
|
||||
svc.removeIngress("default", "my-ingress")
|
||||
|
||||
got := svc.getByDomain("baz.example.com")
|
||||
assert.Nil(t, got)
|
||||
got = svc.getByAppName("baz")
|
||||
assert.Nil(t, got)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "AddIngressApps replaces stale entries for the same ingress",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
|
||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
||||
{domain: "old.example.com", appName: "old", app: old},
|
||||
})
|
||||
|
||||
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
|
||||
svc.addIngressApps("default", "my-ingress", []ingressApp{
|
||||
{domain: "new.example.com", appName: "new", app: updated},
|
||||
})
|
||||
|
||||
got := svc.getByDomain("old.example.com")
|
||||
assert.Nil(t, got)
|
||||
|
||||
got = svc.getByDomain("new.example.com")
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "new.example.com", got.Config.Domain)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "GetLabels returns app from cache when started",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
svc.started = true
|
||||
|
||||
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
|
||||
svc.addIngressApps("default", "ing", []ingressApp{
|
||||
{domain: "hit.example.com", appName: "hit", app: app},
|
||||
})
|
||||
|
||||
got, err := svc.GetLabels("hit.example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hit.example.com", got.Config.Domain)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "GetLabels returns empty app on cache miss when started",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
svc.started = true
|
||||
|
||||
got, err := svc.GetLabels("notfound.example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "GetLabels resolves app by app name",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
svc.started = true
|
||||
|
||||
app := model.App{Config: model.AppConfig{Domain: "myapp.internal.example.com"}}
|
||||
svc.addIngressApps("default", "ing", []ingressApp{
|
||||
{domain: "myapp.internal.example.com", appName: "myapp", app: app},
|
||||
})
|
||||
|
||||
got, err := svc.GetLabels("myapp.internal.example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "myapp.internal.example.com", got.Config.Domain)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "GetLabels returns empty app when service not yet started",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
got, err := svc.GetLabels("anything.example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "UpdateFromItem parses annotations and populates cache",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
item := unstructured.Unstructured{}
|
||||
item.SetNamespace("default")
|
||||
item.SetName("test-ingress")
|
||||
item.SetAnnotations(map[string]string{
|
||||
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
|
||||
"tinyauth.apps.myapp.users.allow": "alice",
|
||||
})
|
||||
|
||||
svc.updateFromItem(&item)
|
||||
|
||||
got := svc.getByDomain("myapp.example.com")
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "myapp.example.com", got.Config.Domain)
|
||||
assert.Equal(t, "alice", got.Users.Allow)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "UpdateFromItem with no annotations removes existing cache entries",
|
||||
run: func(t *testing.T, svc *KubernetesService) {
|
||||
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
|
||||
svc.addIngressApps("default", "test-ingress", []ingressApp{
|
||||
{domain: "todelete.example.com", appName: "todelete", app: app},
|
||||
})
|
||||
|
||||
item := unstructured.Unstructured{}
|
||||
item.SetNamespace("default")
|
||||
item.SetName("test-ingress")
|
||||
|
||||
svc.updateFromItem(&item)
|
||||
|
||||
got := svc.getByDomain("todelete.example.com")
|
||||
assert.Nil(t, got)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
svc := &KubernetesService{
|
||||
ingressApps: make(map[ingressKey][]ingressApp),
|
||||
domainIndex: make(map[string]ingressAppKey),
|
||||
appNameIndex: make(map[string]ingressAppKey),
|
||||
log: log,
|
||||
}
|
||||
test.run(t, svc)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,47 +9,69 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v5"
|
||||
ldapgo "github.com/go-ldap/ldap/v3"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
type LdapService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
context context.Context
|
||||
|
||||
conn *ldapgo.Conn
|
||||
mutex sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
type LdapServiceConfig struct {
|
||||
Address string
|
||||
BindDN string
|
||||
BindPassword string
|
||||
BaseDN string
|
||||
Insecure bool
|
||||
SearchFilter string
|
||||
AuthCert string
|
||||
AuthKey string
|
||||
}
|
||||
|
||||
func NewLdapService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
) (*LdapService, error) {
|
||||
if config.LDAP.Address == "" {
|
||||
return nil, nil
|
||||
type LdapService struct {
|
||||
config LdapServiceConfig
|
||||
conn *ldapgo.Conn
|
||||
mutex sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
isConfigured bool
|
||||
}
|
||||
|
||||
func NewLdapService(config LdapServiceConfig) *LdapService {
|
||||
return &LdapService{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (ldap *LdapService) IsConfigured() bool {
|
||||
return ldap.isConfigured
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Unconfigure() error {
|
||||
if !ldap.isConfigured {
|
||||
return nil
|
||||
}
|
||||
|
||||
ldap := &LdapService{
|
||||
log: log,
|
||||
config: config,
|
||||
context: ctx,
|
||||
if ldap.conn != nil {
|
||||
if err := ldap.conn.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close LDAP connection: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ldap.isConfigured = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Init() error {
|
||||
if ldap.config.Address == "" {
|
||||
ldap.isConfigured = false
|
||||
return nil
|
||||
}
|
||||
|
||||
ldap.isConfigured = true
|
||||
|
||||
// Check whether authentication with client certificate is possible
|
||||
if config.LDAP.AuthCert != "" && config.LDAP.AuthKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(config.LDAP.AuthCert, config.LDAP.AuthKey)
|
||||
|
||||
if ldap.config.AuthCert != "" && ldap.config.AuthKey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(ldap.config.AuthCert, ldap.config.AuthKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err)
|
||||
return fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err)
|
||||
}
|
||||
|
||||
log.App.Info().Msg("LDAP mTLS authentication configured successfully")
|
||||
|
||||
ldap.cert = &cert
|
||||
tlog.App.Info().Msg("Using LDAP with mTLS authentication")
|
||||
|
||||
// TODO: Add optional extra CA certificates, instead of `InsecureSkipVerify`
|
||||
/*
|
||||
@@ -62,39 +84,26 @@ func NewLdapService(
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
_, err := ldap.connect()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to ldap server: %w", err)
|
||||
return fmt.Errorf("failed to connect to LDAP server: %w", err)
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
ldap.log.App.Debug().Msg("Starting LDAP connection heartbeat routine")
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
err := ldap.heartbeat()
|
||||
if err != nil {
|
||||
ldap.log.App.Warn().Err(err).Msg("LDAP connection heartbeat failed, attempting to reconnect")
|
||||
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
|
||||
ldap.log.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
|
||||
continue
|
||||
}
|
||||
ldap.log.App.Info().Msg("Successfully reconnected to LDAP server")
|
||||
go func() {
|
||||
for range time.Tick(time.Duration(5) * time.Minute) {
|
||||
err := ldap.heartbeat()
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("LDAP connection heartbeat failed")
|
||||
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
|
||||
tlog.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
|
||||
continue
|
||||
}
|
||||
case <-ldap.context.Done():
|
||||
ldap.log.App.Debug().Msg("LDAP service context cancelled, stopping heartbeat")
|
||||
return
|
||||
tlog.App.Info().Msg("Successfully reconnected to LDAP server")
|
||||
}
|
||||
}
|
||||
})
|
||||
}()
|
||||
|
||||
return ldap, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
||||
@@ -111,13 +120,13 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
||||
// 2. conn.StartTLS(tlsConfig)
|
||||
// 3. conn.externalBind()
|
||||
if ldap.cert != nil {
|
||||
conn, err = ldapgo.DialURL(ldap.config.LDAP.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{*ldap.cert},
|
||||
}))
|
||||
} else {
|
||||
conn, err = ldapgo.DialURL(ldap.config.LDAP.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: ldap.config.LDAP.Insecure,
|
||||
conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: ldap.config.Insecure,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}))
|
||||
}
|
||||
@@ -134,15 +143,16 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
|
||||
return ldap.conn, nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, err error) {
|
||||
func (ldap *LdapService) GetUserDN(username string) (string, error) {
|
||||
// Escape the username to prevent LDAP injection
|
||||
escapedUsername := ldapgo.EscapeFilter(username)
|
||||
filter := fmt.Sprintf(ldap.config.LDAP.SearchFilter, escapedUsername)
|
||||
filter := fmt.Sprintf(ldap.config.SearchFilter, escapedUsername)
|
||||
|
||||
searchRequest := ldapgo.NewSearchRequest(
|
||||
ldap.config.LDAP.BaseDN,
|
||||
ldap.config.BaseDN,
|
||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||
filter,
|
||||
[]string{"dn", "mail"},
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -151,22 +161,22 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
|
||||
|
||||
searchResult, err := ldap.conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(searchResult.Entries) != 1 {
|
||||
return "", "", fmt.Errorf("multiple or no entries found for user %s", username)
|
||||
return "", fmt.Errorf("multiple or no entries found for user %s", username)
|
||||
}
|
||||
|
||||
entry := searchResult.Entries[0]
|
||||
return entry.DN, entry.GetAttributeValue("mail"), nil
|
||||
userDN := searchResult.Entries[0].DN
|
||||
return userDN, nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {
|
||||
escapedUserDN := ldapgo.EscapeFilter(userDN)
|
||||
|
||||
searchRequest := ldapgo.NewSearchRequest(
|
||||
ldap.config.LDAP.BaseDN,
|
||||
ldap.config.BaseDN,
|
||||
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
|
||||
fmt.Sprintf("(&(objectclass=groupOfUniqueNames)(uniquemember=%s))", escapedUserDN),
|
||||
[]string{"dn"},
|
||||
@@ -214,7 +224,7 @@ func (ldap *LdapService) BindService(rebind bool) error {
|
||||
if ldap.cert != nil {
|
||||
return ldap.conn.ExternalBind()
|
||||
}
|
||||
return ldap.conn.Bind(ldap.config.LDAP.BindDN, ldap.config.LDAP.BindPassword)
|
||||
return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)
|
||||
}
|
||||
|
||||
func (ldap *LdapService) Bind(userDN string, password string) error {
|
||||
@@ -228,7 +238,7 @@ func (ldap *LdapService) Bind(userDN string, password string) error {
|
||||
}
|
||||
|
||||
func (ldap *LdapService) heartbeat() error {
|
||||
ldap.log.App.Debug().Msg("Performing LDAP connection heartbeat")
|
||||
tlog.App.Debug().Msg("Performing LDAP connection heartbeat")
|
||||
|
||||
searchRequest := ldapgo.NewSearchRequest(
|
||||
"",
|
||||
@@ -250,7 +260,7 @@ func (ldap *LdapService) heartbeat() error {
|
||||
}
|
||||
|
||||
func (ldap *LdapService) reconnect() error {
|
||||
ldap.log.App.Info().Msg("Attempting to reconnect to LDAP server")
|
||||
tlog.App.Info().Msg("Reconnecting to LDAP server")
|
||||
|
||||
exp := backoff.NewExponentialBackOff()
|
||||
exp.InitialInterval = 500 * time.Millisecond
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
|
||||
"slices"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -17,43 +14,37 @@ type OAuthServiceImpl interface {
|
||||
NewRandom() string
|
||||
GetAuthURL(state string, verifier string) string
|
||||
GetToken(code string, verifier string) (*oauth2.Token, error)
|
||||
GetUserinfo(token *oauth2.Token) (*model.Claims, error)
|
||||
GetUserinfo(token *oauth2.Token) (config.Claims, error)
|
||||
}
|
||||
|
||||
type OAuthBrokerService struct {
|
||||
log *logger.Logger
|
||||
|
||||
services map[string]OAuthServiceImpl
|
||||
configs map[string]model.OAuthServiceConfig
|
||||
configs map[string]config.OAuthServiceConfig
|
||||
}
|
||||
|
||||
var presets = map[string]func(config model.OAuthServiceConfig, ctx context.Context) *OAuthService{
|
||||
var presets = map[string]func(config config.OAuthServiceConfig) *OAuthService{
|
||||
"github": newGitHubOAuthService,
|
||||
"google": newGoogleOAuthService,
|
||||
}
|
||||
|
||||
func NewOAuthBrokerService(
|
||||
log *logger.Logger,
|
||||
configs map[string]model.OAuthServiceConfig,
|
||||
ctx context.Context,
|
||||
) *OAuthBrokerService {
|
||||
service := &OAuthBrokerService{
|
||||
log: log,
|
||||
func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService {
|
||||
return &OAuthBrokerService{
|
||||
services: make(map[string]OAuthServiceImpl),
|
||||
configs: configs,
|
||||
}
|
||||
}
|
||||
|
||||
for name, cfg := range configs {
|
||||
func (broker *OAuthBrokerService) Init() error {
|
||||
for name, cfg := range broker.configs {
|
||||
if presetFunc, exists := presets[name]; exists {
|
||||
service.services[name] = presetFunc(cfg, ctx)
|
||||
service.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
|
||||
broker.services[name] = presetFunc(cfg)
|
||||
tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
|
||||
} else {
|
||||
service.services[name] = NewOAuthService(cfg, name, ctx)
|
||||
service.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from custom config")
|
||||
broker.services[name] = NewOAuthService(cfg, name)
|
||||
tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from config")
|
||||
}
|
||||
}
|
||||
|
||||
return service
|
||||
return nil
|
||||
}
|
||||
|
||||
func (broker *OAuthBrokerService) GetConfiguredServices() []string {
|
||||
|
||||
@@ -8,13 +8,12 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
)
|
||||
|
||||
type GithubEmailResponse []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
type GithubUserInfoResponse struct {
|
||||
@@ -23,33 +22,33 @@ type GithubUserInfoResponse struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
func defaultExtractor(client *http.Client, url string) (*model.Claims, error) {
|
||||
return simpleReq[model.Claims](client, url, nil)
|
||||
func defaultExtractor(client *http.Client, url string) (config.Claims, error) {
|
||||
return simpleReq[config.Claims](client, url, nil)
|
||||
}
|
||||
|
||||
func githubExtractor(client *http.Client, _ string) (*model.Claims, error) {
|
||||
var user model.Claims
|
||||
func githubExtractor(client *http.Client, url string) (config.Claims, error) {
|
||||
var user config.Claims
|
||||
|
||||
userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{
|
||||
"accept": "application/vnd.github+json",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return config.Claims{}, err
|
||||
}
|
||||
|
||||
userEmails, err := simpleReq[GithubEmailResponse](client, "https://api.github.com/user/emails", map[string]string{
|
||||
"accept": "application/vnd.github+json",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return config.Claims{}, err
|
||||
}
|
||||
|
||||
if len(*userEmails) == 0 {
|
||||
return nil, errors.New("no emails found")
|
||||
if len(userEmails) == 0 {
|
||||
return user, errors.New("no emails found")
|
||||
}
|
||||
|
||||
for _, email := range *userEmails {
|
||||
if email.Primary && email.Verified {
|
||||
for _, email := range userEmails {
|
||||
if email.Primary {
|
||||
user.Email = email.Email
|
||||
break
|
||||
}
|
||||
@@ -57,31 +56,22 @@ func githubExtractor(client *http.Client, _ string) (*model.Claims, error) {
|
||||
|
||||
// Use first available email if no primary email was found
|
||||
if user.Email == "" {
|
||||
for _, email := range *userEmails {
|
||||
if email.Verified {
|
||||
user.Email = email.Email
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user.Email == "" {
|
||||
return nil, errors.New("no verified email found")
|
||||
user.Email = userEmails[0].Email
|
||||
}
|
||||
|
||||
user.PreferredUsername = userInfo.Login
|
||||
user.Name = userInfo.Name
|
||||
user.Sub = strconv.Itoa(userInfo.ID)
|
||||
|
||||
return &user, nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func simpleReq[T any](client *http.Client, url string, headers map[string]string) (*T, error) {
|
||||
func simpleReq[T any](client *http.Client, url string, headers map[string]string) (T, error) {
|
||||
var decodedRes T
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return decodedRes, err
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
@@ -90,23 +80,23 @@ func simpleReq[T any](client *http.Client, url string, headers map[string]string
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return decodedRes, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("request failed with status: %s", res.Status)
|
||||
return decodedRes, fmt.Errorf("request failed with status: %s", res.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return decodedRes, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &decodedRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return decodedRes, err
|
||||
}
|
||||
|
||||
return &decodedRes, nil
|
||||
return decodedRes, nil
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"golang.org/x/oauth2/endpoints"
|
||||
)
|
||||
|
||||
func newGoogleOAuthService(config model.OAuthServiceConfig, ctx context.Context) *OAuthService {
|
||||
func newGoogleOAuthService(config config.OAuthServiceConfig) *OAuthService {
|
||||
scopes := []string{"openid", "email", "profile"}
|
||||
config.Scopes = scopes
|
||||
config.AuthURL = endpoints.Google.AuthURL
|
||||
config.TokenURL = endpoints.Google.TokenURL
|
||||
config.UserinfoURL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
return NewOAuthService(config, "google", ctx)
|
||||
return NewOAuthService(config, "google")
|
||||
}
|
||||
|
||||
func newGitHubOAuthService(config model.OAuthServiceConfig, ctx context.Context) *OAuthService {
|
||||
func newGitHubOAuthService(config config.OAuthServiceConfig) *OAuthService {
|
||||
scopes := []string{"read:user", "user:email"}
|
||||
config.Scopes = scopes
|
||||
config.AuthURL = endpoints.GitHub.AuthURL
|
||||
config.TokenURL = endpoints.GitHub.TokenURL
|
||||
return NewOAuthService(config, "github", ctx).WithUserinfoExtractor(githubExtractor)
|
||||
return NewOAuthService(config, "github").WithUserinfoExtractor(githubExtractor)
|
||||
}
|
||||
|
||||
@@ -6,21 +6,21 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type UserinfoExtractor func(client *http.Client, url string) (*model.Claims, error)
|
||||
type UserinfoExtractor func(client *http.Client, url string) (config.Claims, error)
|
||||
|
||||
type OAuthService struct {
|
||||
serviceCfg model.OAuthServiceConfig
|
||||
serviceCfg config.OAuthServiceConfig
|
||||
config *oauth2.Config
|
||||
ctx context.Context
|
||||
userinfoExtractor UserinfoExtractor
|
||||
id string
|
||||
}
|
||||
|
||||
func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Context) *OAuthService {
|
||||
func NewOAuthService(config config.OAuthServiceConfig, id string) *OAuthService {
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
@@ -29,7 +29,8 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
|
||||
},
|
||||
},
|
||||
}
|
||||
vctx := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
return &OAuthService{
|
||||
serviceCfg: config,
|
||||
@@ -43,7 +44,7 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
|
||||
TokenURL: config.TokenURL,
|
||||
},
|
||||
},
|
||||
ctx: vctx,
|
||||
ctx: ctx,
|
||||
userinfoExtractor: defaultExtractor,
|
||||
id: id,
|
||||
}
|
||||
@@ -77,7 +78,7 @@ func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, er
|
||||
return s.config.Exchange(s.ctx, code, oauth2.VerifierOption(verifier))
|
||||
}
|
||||
|
||||
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) {
|
||||
func (s *OAuthService) GetUserinfo(token *oauth2.Token) (config.Claims, error) {
|
||||
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
||||
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
|
||||
}
|
||||
|
||||
+153
-230
@@ -16,21 +16,19 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var (
|
||||
SupportedScopes = []string{"openid", "profile", "email", "phone", "address", "groups"}
|
||||
SupportedScopes = []string{"openid", "profile", "email", "groups"}
|
||||
SupportedResponseTypes = []string{"code"}
|
||||
SupportedGrantTypes = []string{"authorization_code", "refresh_token"}
|
||||
)
|
||||
@@ -50,17 +48,6 @@ type ClaimSet struct {
|
||||
Iat int64 `json:"iat"`
|
||||
Exp int64 `json:"exp"`
|
||||
Name string `json:"name,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Birthdate string `json:"birthdate,omitempty"`
|
||||
Zoneinfo string `json:"zoneinfo,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
@@ -69,27 +56,13 @@ type ClaimSet struct {
|
||||
}
|
||||
|
||||
type UserinfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Birthdate string `json:"birthdate,omitempty"`
|
||||
Zoneinfo string `json:"zoneinfo,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
PhoneNumber string `json:"phone_number,omitempty"`
|
||||
PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"`
|
||||
Address *model.AddressClaim `json:"address,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
@@ -112,180 +85,179 @@ type AuthorizeRequest struct {
|
||||
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||
}
|
||||
|
||||
type OIDCService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
queries *repository.Queries
|
||||
context context.Context
|
||||
|
||||
clients map[string]model.OIDCClientConfig
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey crypto.PublicKey
|
||||
issuer string
|
||||
type OIDCServiceConfig struct {
|
||||
Clients map[string]config.OIDCClientConfig
|
||||
PrivateKeyPath string
|
||||
PublicKeyPath string
|
||||
Issuer string
|
||||
SessionExpiry int
|
||||
}
|
||||
|
||||
func NewOIDCService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
runtime model.RuntimeConfig,
|
||||
queries *repository.Queries,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup) (*OIDCService, error) {
|
||||
type OIDCService struct {
|
||||
config OIDCServiceConfig
|
||||
queries *repository.Queries
|
||||
clients map[string]config.OIDCClientConfig
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey crypto.PublicKey
|
||||
issuer string
|
||||
isConfigured bool
|
||||
}
|
||||
|
||||
func NewOIDCService(config OIDCServiceConfig, queries *repository.Queries) *OIDCService {
|
||||
return &OIDCService{
|
||||
config: config,
|
||||
queries: queries,
|
||||
}
|
||||
}
|
||||
|
||||
func (service *OIDCService) IsConfigured() bool {
|
||||
return service.isConfigured
|
||||
}
|
||||
|
||||
func (service *OIDCService) Init() error {
|
||||
// If not configured, skip init
|
||||
if len(runtime.OIDCClients) == 0 {
|
||||
return nil, nil
|
||||
if len(service.config.Clients) == 0 {
|
||||
service.isConfigured = false
|
||||
return nil
|
||||
}
|
||||
|
||||
service.isConfigured = true
|
||||
|
||||
// Ensure issuer is https
|
||||
uissuer, err := url.Parse(runtime.AppURL)
|
||||
uissuer, err := url.Parse(service.config.Issuer)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse app url: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if uissuer.Scheme != "https" {
|
||||
return nil, errors.New("issuer must be https")
|
||||
return errors.New("issuer must be https")
|
||||
}
|
||||
|
||||
issuer := fmt.Sprintf("%s://%s", uissuer.Scheme, uissuer.Host)
|
||||
service.issuer = fmt.Sprintf("%s://%s", uissuer.Scheme, uissuer.Host)
|
||||
|
||||
// Create/load private and public keys
|
||||
if strings.TrimSpace(config.OIDC.PrivateKeyPath) == "" ||
|
||||
strings.TrimSpace(config.OIDC.PublicKeyPath) == "" {
|
||||
return nil, errors.New("private key path and public key path are required")
|
||||
if strings.TrimSpace(service.config.PrivateKeyPath) == "" ||
|
||||
strings.TrimSpace(service.config.PublicKeyPath) == "" {
|
||||
return errors.New("private key path and public key path are required")
|
||||
}
|
||||
|
||||
var privateKey *rsa.PrivateKey
|
||||
|
||||
fprivateKey, err := os.ReadFile(config.OIDC.PrivateKeyPath)
|
||||
fprivateKey, err := os.ReadFile(service.config.PrivateKeyPath)
|
||||
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate private key: %w", err)
|
||||
return err
|
||||
}
|
||||
der := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
if der == nil {
|
||||
return nil, errors.New("failed to marshal private key")
|
||||
return errors.New("failed to marshal private key")
|
||||
}
|
||||
encoded := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: der,
|
||||
})
|
||||
log.App.Trace().Str("type", "RSA PRIVATE KEY").Msg("Generated private RSA key")
|
||||
err = os.WriteFile(config.OIDC.PrivateKeyPath, encoded, 0600)
|
||||
tlog.App.Trace().Str("type", "RSA PRIVATE KEY").Msg("Generated private RSA key")
|
||||
err = os.WriteFile(service.config.PrivateKeyPath, encoded, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write private key to file: %w", err)
|
||||
return err
|
||||
}
|
||||
service.privateKey = privateKey
|
||||
} else {
|
||||
block, _ := pem.Decode(fprivateKey)
|
||||
if block == nil {
|
||||
return nil, errors.New("failed to decode private key")
|
||||
return errors.New("failed to decode private key")
|
||||
}
|
||||
log.App.Trace().Str("type", block.Type).Msg("Loaded private key")
|
||||
tlog.App.Trace().Str("type", block.Type).Msg("Loaded private key")
|
||||
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
return err
|
||||
}
|
||||
service.privateKey = privateKey
|
||||
}
|
||||
|
||||
var publicKey crypto.PublicKey
|
||||
|
||||
fpublicKey, err := os.ReadFile(config.OIDC.PublicKeyPath)
|
||||
fpublicKey, err := os.ReadFile(service.config.PublicKeyPath)
|
||||
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("failed to read public key: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
publicKey = privateKey.Public()
|
||||
publicKey := service.privateKey.Public()
|
||||
der := x509.MarshalPKCS1PublicKey(publicKey.(*rsa.PublicKey))
|
||||
if der == nil {
|
||||
return nil, errors.New("failed to marshal public key")
|
||||
return errors.New("failed to marshal public key")
|
||||
}
|
||||
encoded := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PUBLIC KEY",
|
||||
Bytes: der,
|
||||
})
|
||||
log.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key")
|
||||
err = os.WriteFile(config.OIDC.PublicKeyPath, encoded, 0644)
|
||||
tlog.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key")
|
||||
err = os.WriteFile(service.config.PublicKeyPath, encoded, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
service.publicKey = publicKey
|
||||
} else {
|
||||
block, _ := pem.Decode(fpublicKey)
|
||||
if block == nil {
|
||||
return nil, errors.New("failed to decode public key")
|
||||
return errors.New("failed to decode public key")
|
||||
}
|
||||
log.App.Trace().Str("type", block.Type).Msg("Loaded public key")
|
||||
tlog.App.Trace().Str("type", block.Type).Msg("Loaded public key")
|
||||
switch block.Type {
|
||||
case "RSA PUBLIC KEY":
|
||||
publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
publicKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||
return err
|
||||
}
|
||||
service.publicKey = publicKey
|
||||
case "PUBLIC KEY":
|
||||
publicKey, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||
return err
|
||||
}
|
||||
service.publicKey = publicKey.(crypto.PublicKey)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported public key type: %s", block.Type)
|
||||
return fmt.Errorf("unsupported public key type: %s", block.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// We will reorganize the client into a map with the client ID as the key
|
||||
clients := make(map[string]model.OIDCClientConfig)
|
||||
service.clients = make(map[string]config.OIDCClientConfig)
|
||||
|
||||
for id, client := range config.OIDC.Clients {
|
||||
for id, client := range service.config.Clients {
|
||||
client.ID = id
|
||||
if client.Name == "" {
|
||||
client.Name = utils.Capitalize(client.ID)
|
||||
}
|
||||
clients[client.ClientID] = client
|
||||
service.clients[client.ClientID] = client
|
||||
}
|
||||
|
||||
// Load the client secrets from files if they exist
|
||||
for id, client := range clients {
|
||||
for id, client := range service.clients {
|
||||
secret := utils.GetSecret(client.ClientSecret, client.ClientSecretFile)
|
||||
if secret != "" {
|
||||
client.ClientSecret = secret
|
||||
}
|
||||
client.ClientSecretFile = ""
|
||||
clients[id] = client
|
||||
log.App.Debug().Str("clientId", client.ClientID).Msg("Loaded OIDC client configuration")
|
||||
service.clients[id] = client
|
||||
tlog.App.Info().Str("id", client.ID).Msg("Registered OIDC client")
|
||||
}
|
||||
|
||||
// Initialize the service
|
||||
service := &OIDCService{
|
||||
log: log,
|
||||
config: config,
|
||||
runtime: runtime,
|
||||
queries: queries,
|
||||
context: ctx,
|
||||
|
||||
clients: clients,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
issuer: issuer,
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
wg.Go(service.cleanupRoutine)
|
||||
|
||||
return service, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetIssuer() string {
|
||||
return service.issuer
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetClient(id string) (model.OIDCClientConfig, bool) {
|
||||
func (service *OIDCService) GetClient(id string) (config.OIDCClientConfig, bool) {
|
||||
client, ok := service.clients[id]
|
||||
return client, ok
|
||||
}
|
||||
@@ -309,7 +281,7 @@ func (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error
|
||||
return errors.New("invalid_scope")
|
||||
}
|
||||
if !slices.Contains(SupportedScopes, scope) {
|
||||
service.log.App.Warn().Str("scope", scope).Msg("Requested unsupported scope")
|
||||
tlog.App.Warn().Str("scope", scope).Msg("Unsupported OIDC scope, will be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +331,7 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
||||
entry.CodeChallenge = req.CodeChallenge
|
||||
} else {
|
||||
entry.CodeChallenge = service.hashAndEncodePKCE(req.CodeChallenge)
|
||||
service.log.App.Warn().Msg("Using plain PKCE code challenge method is not recommended, consider switching to S256 for better security")
|
||||
tlog.App.Warn().Msg("Received plain PKCE code challenge, it's recommended to use S256 for better security")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,42 +341,22 @@ func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, r
|
||||
return err
|
||||
}
|
||||
|
||||
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext model.UserContext, req AuthorizeRequest) error {
|
||||
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {
|
||||
userInfoParams := repository.CreateOidcUserInfoParams{
|
||||
Sub: sub,
|
||||
Name: userContext.GetName(),
|
||||
Email: userContext.GetEmail(),
|
||||
PreferredUsername: userContext.GetUsername(),
|
||||
Name: userContext.Name,
|
||||
Email: userContext.Email,
|
||||
PreferredUsername: userContext.Username,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if userContext.IsLocal() {
|
||||
addressJSON, err := json.Marshal(userContext.Local.Attributes.Address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userInfoParams.GivenName = userContext.Local.Attributes.GivenName
|
||||
userInfoParams.FamilyName = userContext.Local.Attributes.FamilyName
|
||||
userInfoParams.MiddleName = userContext.Local.Attributes.MiddleName
|
||||
userInfoParams.Nickname = userContext.Local.Attributes.Nickname
|
||||
userInfoParams.Profile = userContext.Local.Attributes.Profile
|
||||
userInfoParams.Picture = userContext.Local.Attributes.Picture
|
||||
userInfoParams.Website = userContext.Local.Attributes.Website
|
||||
userInfoParams.Gender = userContext.Local.Attributes.Gender
|
||||
userInfoParams.Birthdate = userContext.Local.Attributes.Birthdate
|
||||
userInfoParams.Zoneinfo = userContext.Local.Attributes.Zoneinfo
|
||||
userInfoParams.Locale = userContext.Local.Attributes.Locale
|
||||
userInfoParams.PhoneNumber = userContext.Local.Attributes.PhoneNumber
|
||||
userInfoParams.Address = string(addressJSON)
|
||||
}
|
||||
|
||||
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
|
||||
if userContext.IsLDAP() {
|
||||
userInfoParams.Groups = strings.Join(userContext.LDAP.Groups, ",")
|
||||
if userContext.Provider == "ldap" {
|
||||
userInfoParams.Groups = userContext.LdapGroups
|
||||
}
|
||||
|
||||
if userContext.IsOAuth() {
|
||||
userInfoParams.Groups = strings.Join(userContext.OAuth.Groups, ",")
|
||||
if userContext.OAuth && len(userContext.OAuthGroups) > 0 {
|
||||
userInfoParams.Groups = userContext.OAuthGroups
|
||||
}
|
||||
|
||||
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
|
||||
@@ -449,9 +401,9 @@ func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, client
|
||||
return oidcCode, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {
|
||||
func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {
|
||||
createdAt := time.Now().Unix()
|
||||
expiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
||||
expiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||
|
||||
hasher := sha256.New()
|
||||
|
||||
@@ -515,7 +467,7 @@ func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OIDCClientConfig, codeEntry repository.OidcCode) (TokenResponse, error) {
|
||||
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OIDCClientConfig, codeEntry repository.OidcCode) (TokenResponse, error) {
|
||||
user, err := service.GetUserinfo(c, codeEntry.Sub)
|
||||
|
||||
if err != nil {
|
||||
@@ -531,16 +483,16 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OID
|
||||
accessToken := utils.GenerateString(32)
|
||||
refreshToken := utils.GenerateString(32)
|
||||
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||
|
||||
// Refresh token lives double the time of an access token but can't be used to access userinfo
|
||||
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry*2) * time.Second).Unix()
|
||||
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
|
||||
|
||||
tokenResponse := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(service.config.Auth.SessionExpiry),
|
||||
ExpiresIn: int64(service.config.SessionExpiry),
|
||||
IDToken: idToken,
|
||||
Scope: strings.ReplaceAll(codeEntry.Scope, ",", " "),
|
||||
}
|
||||
@@ -552,7 +504,7 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client model.OID
|
||||
ClientID: client.ClientID,
|
||||
Scope: codeEntry.Scope,
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
RefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||
RefreshTokenExpiresAt: refrshTokenExpiresAt,
|
||||
Nonce: codeEntry.Nonce,
|
||||
CodeHash: codeEntry.CodeHash,
|
||||
})
|
||||
@@ -568,7 +520,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
||||
entry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if err == sql.ErrNoRows {
|
||||
return TokenResponse{}, ErrTokenNotFound
|
||||
}
|
||||
return TokenResponse{}, err
|
||||
@@ -589,7 +541,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
||||
return TokenResponse{}, err
|
||||
}
|
||||
|
||||
idToken, err := service.generateIDToken(model.OIDCClientConfig{
|
||||
idToken, err := service.generateIDToken(config.OIDCClientConfig{
|
||||
ClientID: entry.ClientID,
|
||||
}, user, entry.Scope, entry.Nonce)
|
||||
|
||||
@@ -600,14 +552,14 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
||||
accessToken := utils.GenerateString(32)
|
||||
newRefreshToken := utils.GenerateString(32)
|
||||
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix()
|
||||
refreshTokenExpiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry*2) * time.Second).Unix()
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
|
||||
|
||||
tokenResponse := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(service.config.Auth.SessionExpiry),
|
||||
ExpiresIn: int64(service.config.SessionExpiry),
|
||||
IDToken: idToken,
|
||||
Scope: strings.ReplaceAll(entry.Scope, ",", " "),
|
||||
}
|
||||
@@ -616,7 +568,7 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
||||
AccessTokenHash: service.Hash(accessToken),
|
||||
RefreshTokenHash: service.Hash(newRefreshToken),
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
RefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||
RefreshTokenExpiresAt: refrshTokenExpiresAt,
|
||||
RefreshTokenHash_2: service.Hash(refreshToken), // that's the selector, it's not stored in the db
|
||||
})
|
||||
|
||||
@@ -647,7 +599,7 @@ func (service *OIDCService) GetAccessToken(c *gin.Context, tokenHash string) (re
|
||||
entry, err := service.queries.GetOidcToken(c, tokenHash)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if err == sql.ErrNoRows {
|
||||
return repository.OidcToken{}, ErrTokenNotFound
|
||||
}
|
||||
return repository.OidcToken{}, err
|
||||
@@ -685,22 +637,12 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
||||
if slices.Contains(scopes, "profile") {
|
||||
userInfo.Name = user.Name
|
||||
userInfo.PreferredUsername = user.PreferredUsername
|
||||
userInfo.GivenName = user.GivenName
|
||||
userInfo.FamilyName = user.FamilyName
|
||||
userInfo.MiddleName = user.MiddleName
|
||||
userInfo.Nickname = user.Nickname
|
||||
userInfo.Profile = user.Profile
|
||||
userInfo.Picture = user.Picture
|
||||
userInfo.Website = user.Website
|
||||
userInfo.Gender = user.Gender
|
||||
userInfo.Birthdate = user.Birthdate
|
||||
userInfo.Zoneinfo = user.Zoneinfo
|
||||
userInfo.Locale = user.Locale
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "email") {
|
||||
userInfo.Email = user.Email
|
||||
userInfo.EmailVerified = user.Email != ""
|
||||
// We can set this as a configuration option in the future but for now it's a good idea to assume it's true
|
||||
userInfo.EmailVerified = true
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "groups") {
|
||||
@@ -711,19 +653,6 @@ func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "phone") {
|
||||
userInfo.PhoneNumber = user.PhoneNumber
|
||||
verified := user.PhoneNumber != ""
|
||||
userInfo.PhoneNumberVerified = &verified
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "address") {
|
||||
var addr model.AddressClaim
|
||||
if err := json.Unmarshal([]byte(user.Address), &addr); err == nil {
|
||||
userInfo.Address = &addr
|
||||
}
|
||||
}
|
||||
|
||||
return userInfo
|
||||
}
|
||||
|
||||
@@ -750,62 +679,56 @@ func (service *OIDCService) DeleteOldSession(ctx context.Context, sub string) er
|
||||
}
|
||||
|
||||
// Cleanup routine - Resource heavy due to the linked tables
|
||||
func (service *OIDCService) cleanupRoutine() {
|
||||
service.log.App.Debug().Msg("Starting OIDC cleanup routine")
|
||||
func (service *OIDCService) Cleanup() {
|
||||
// We need a context for the routine
|
||||
ctx := context.Background()
|
||||
|
||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.log.App.Debug().Msg("Performing OIDC cleanup routine")
|
||||
for range ticker.C {
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
// For the OIDC tokens, if they are expired we delete the userinfo and codes
|
||||
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{
|
||||
TokenExpiresAt: currentTime,
|
||||
RefreshTokenExpiresAt: currentTime,
|
||||
})
|
||||
|
||||
// For the OIDC tokens, if they are expired we delete the userinfo and codes
|
||||
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(service.context, repository.DeleteExpiredOidcTokensParams{
|
||||
TokenExpiresAt: currentTime,
|
||||
RefreshTokenExpiresAt: currentTime,
|
||||
})
|
||||
if err != nil {
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete expired tokens")
|
||||
}
|
||||
|
||||
for _, expiredToken := range expiredTokens {
|
||||
err := service.DeleteOldSession(ctx, expiredToken.Sub)
|
||||
if err != nil {
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete old session")
|
||||
}
|
||||
}
|
||||
|
||||
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
|
||||
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(ctx, currentTime)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
||||
}
|
||||
|
||||
for _, expiredCode := range expiredCodes {
|
||||
token, err := service.queries.GetOidcTokenBySub(ctx, expiredCode.Sub)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired tokens")
|
||||
}
|
||||
|
||||
for _, expiredToken := range expiredTokens {
|
||||
err := service.DeleteOldSession(service.context, expiredToken.Sub)
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
|
||||
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(service.context, currentTime)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
||||
}
|
||||
|
||||
for _, expiredCode := range expiredCodes {
|
||||
token, err := service.queries.GetOidcTokenBySub(service.context, expiredCode.Sub)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to get token by sub for expired code")
|
||||
if err == sql.ErrNoRows {
|
||||
continue
|
||||
}
|
||||
|
||||
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
|
||||
err := service.DeleteOldSession(service.context, expiredCode.Sub)
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired code")
|
||||
}
|
||||
}
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get OIDC token by sub")
|
||||
}
|
||||
|
||||
service.log.App.Debug().Msg("Finished OIDC cleanup routine")
|
||||
case <-service.context.Done():
|
||||
service.log.App.Debug().Msg("Stopping OIDC cleanup routine")
|
||||
return
|
||||
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
|
||||
err := service.DeleteOldSession(ctx, expiredCode.Sub)
|
||||
if err != nil {
|
||||
tlog.App.Warn().Err(err).Msg("Failed to delete session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func newTestUser() repository.OidcUserinfo {
|
||||
addr := model.AddressClaim{
|
||||
Formatted: "123 Main St",
|
||||
StreetAddress: "123 Main St",
|
||||
Locality: "Springfield",
|
||||
Region: "IL",
|
||||
PostalCode: "62701",
|
||||
Country: "US",
|
||||
}
|
||||
addrJSON, _ := json.Marshal(addr)
|
||||
|
||||
return repository.OidcUserinfo{
|
||||
Sub: "test-sub",
|
||||
Name: "Test User",
|
||||
PreferredUsername: "testuser",
|
||||
Email: "test@example.com",
|
||||
Groups: "admins,users",
|
||||
UpdatedAt: 1234567890,
|
||||
GivenName: "Test",
|
||||
FamilyName: "User",
|
||||
MiddleName: "M",
|
||||
Nickname: "testy",
|
||||
Profile: "https://example.com/testuser",
|
||||
Picture: "https://example.com/testuser.jpg",
|
||||
Website: "https://testuser.example.com",
|
||||
Gender: "male",
|
||||
Birthdate: "1990-01-01",
|
||||
Zoneinfo: "America/Chicago",
|
||||
Locale: "en-US",
|
||||
PhoneNumber: "+15555550100",
|
||||
Address: string(addrJSON),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileUserinfo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
cfg := model.Config{
|
||||
OIDC: model.OIDCConfig{
|
||||
PrivateKeyPath: dir + "/key.pem",
|
||||
PublicKeyPath: dir + "/key.pub",
|
||||
},
|
||||
Auth: model.AuthConfig{
|
||||
SessionExpiry: 3600,
|
||||
},
|
||||
}
|
||||
|
||||
runtime := model.RuntimeConfig{
|
||||
AppURL: "https://tinyauth.example.com",
|
||||
}
|
||||
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
ctx := context.TODO()
|
||||
wg := &sync.WaitGroup{}
|
||||
|
||||
svc, err := service.NewOIDCService(log, cfg, runtime, nil, ctx, wg)
|
||||
require.NoError(t, err)
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
mutate func(u *repository.OidcUserinfo)
|
||||
scope string
|
||||
run func(t *testing.T, info service.UserinfoResponse)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "openid scope only returns sub and updated_at",
|
||||
scope: "openid",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "test-sub", info.Sub)
|
||||
assert.Equal(t, int64(1234567890), info.UpdatedAt)
|
||||
assert.Empty(t, info.Name)
|
||||
assert.Empty(t, info.Email)
|
||||
assert.Nil(t, info.Groups)
|
||||
assert.Nil(t, info.PhoneNumberVerified)
|
||||
assert.Nil(t, info.Address)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "profile scope returns all profile fields",
|
||||
scope: "openid,profile",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "Test User", info.Name)
|
||||
assert.Equal(t, "testuser", info.PreferredUsername)
|
||||
assert.Equal(t, "Test", info.GivenName)
|
||||
assert.Equal(t, "User", info.FamilyName)
|
||||
assert.Equal(t, "M", info.MiddleName)
|
||||
assert.Equal(t, "testy", info.Nickname)
|
||||
assert.Equal(t, "https://example.com/testuser", info.Profile)
|
||||
assert.Equal(t, "https://example.com/testuser.jpg", info.Picture)
|
||||
assert.Equal(t, "https://testuser.example.com", info.Website)
|
||||
assert.Equal(t, "male", info.Gender)
|
||||
assert.Equal(t, "1990-01-01", info.Birthdate)
|
||||
assert.Equal(t, "America/Chicago", info.Zoneinfo)
|
||||
assert.Equal(t, "en-US", info.Locale)
|
||||
assert.Empty(t, info.Email)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "email scope sets email and email_verified true when email present",
|
||||
scope: "openid,email",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "test@example.com", info.Email)
|
||||
assert.True(t, info.EmailVerified)
|
||||
assert.Empty(t, info.Name)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "email scope sets email_verified false when email absent",
|
||||
scope: "openid,email",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Email = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Empty(t, info.Email)
|
||||
assert.False(t, info.EmailVerified)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "phone scope sets phone_number_verified true when phone present",
|
||||
scope: "openid,phone",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
||||
require.NotNil(t, info.PhoneNumberVerified)
|
||||
assert.True(t, *info.PhoneNumberVerified)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "phone scope sets phone_number_verified false when phone absent",
|
||||
scope: "openid,phone",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.PhoneNumber = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
require.NotNil(t, info.PhoneNumberVerified)
|
||||
assert.False(t, *info.PhoneNumberVerified)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "address scope returns parsed address",
|
||||
scope: "openid,address",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
require.NotNil(t, info.Address)
|
||||
assert.Equal(t, "123 Main St", info.Address.Formatted)
|
||||
assert.Equal(t, "123 Main St", info.Address.StreetAddress)
|
||||
assert.Equal(t, "Springfield", info.Address.Locality)
|
||||
assert.Equal(t, "IL", info.Address.Region)
|
||||
assert.Equal(t, "62701", info.Address.PostalCode)
|
||||
assert.Equal(t, "US", info.Address.Country)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "address scope with invalid JSON omits address",
|
||||
scope: "openid,address",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Address = "not-valid-json" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Nil(t, info.Address)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "groups scope returns split groups",
|
||||
scope: "openid,groups",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "groups scope returns empty slice when no groups",
|
||||
scope: "openid,groups",
|
||||
mutate: func(u *repository.OidcUserinfo) { u.Groups = "" },
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, []string{}, info.Groups)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "all scopes return all fields",
|
||||
scope: "openid,profile,email,phone,address,groups",
|
||||
run: func(t *testing.T, info service.UserinfoResponse) {
|
||||
assert.Equal(t, "Test User", info.Name)
|
||||
assert.Equal(t, "test@example.com", info.Email)
|
||||
assert.Equal(t, "+15555550100", info.PhoneNumber)
|
||||
require.NotNil(t, info.PhoneNumberVerified)
|
||||
assert.True(t, *info.PhoneNumberVerified)
|
||||
require.NotNil(t, info.Address)
|
||||
assert.Equal(t, "Springfield", info.Address.Locality)
|
||||
assert.Equal(t, []string{"admins", "users"}, info.Groups)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
user := newTestUser()
|
||||
if test.mutate != nil {
|
||||
test.mutate(&user)
|
||||
}
|
||||
info := svc.CompileUserinfo(user, test.scope)
|
||||
test.run(t, info)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var TestingTOTPSecret = "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK"
|
||||
|
||||
func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
config := model.Config{
|
||||
UI: model.UIConfig{
|
||||
Title: "Tinyauth Test",
|
||||
ForgotPasswordMessage: "foo",
|
||||
BackgroundImage: "/background.jpg",
|
||||
WarningsEnabled: true,
|
||||
},
|
||||
OAuth: model.OAuthConfig{
|
||||
AutoRedirect: "none",
|
||||
},
|
||||
OIDC: model.OIDCConfig{
|
||||
Clients: map[string]model.OIDCClientConfig{
|
||||
"test": {
|
||||
ClientID: "some-client-id",
|
||||
ClientSecret: "some-client-secret",
|
||||
TrustedRedirectURIs: []string{"https://test.example.com/callback"},
|
||||
Name: "Test Client",
|
||||
},
|
||||
},
|
||||
PrivateKeyPath: filepath.Join(tempDir, "key.pem"),
|
||||
PublicKeyPath: filepath.Join(tempDir, "key.pub"),
|
||||
},
|
||||
Auth: model.AuthConfig{
|
||||
SessionExpiry: 10,
|
||||
LoginTimeout: 10,
|
||||
LoginMaxRetries: 3,
|
||||
},
|
||||
Database: model.DatabaseConfig{
|
||||
Path: filepath.Join(tempDir, "test.db"),
|
||||
},
|
||||
Resources: model.ResourcesConfig{
|
||||
Enabled: true,
|
||||
Path: filepath.Join(tempDir, "resources"),
|
||||
},
|
||||
}
|
||||
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||
require.NoError(t, err)
|
||||
|
||||
runtime := model.RuntimeConfig{
|
||||
ConfiguredProviders: []model.Provider{
|
||||
{
|
||||
Name: "Local",
|
||||
ID: "local",
|
||||
OAuth: false,
|
||||
},
|
||||
},
|
||||
LocalUsers: []model.LocalUser{
|
||||
{
|
||||
Username: "testuser",
|
||||
Password: string(passwd),
|
||||
},
|
||||
{
|
||||
Username: "totpuser",
|
||||
Password: string(passwd),
|
||||
TOTPSecret: TestingTOTPSecret,
|
||||
},
|
||||
{
|
||||
Username: "attruser",
|
||||
Password: string(passwd),
|
||||
Attributes: model.UserAttributes{
|
||||
Name: "Alice Smith",
|
||||
Email: "alice@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Username: "attrtotpuser",
|
||||
Password: string(passwd),
|
||||
TOTPSecret: TestingTOTPSecret,
|
||||
Attributes: model.UserAttributes{
|
||||
Name: "Bob Jones",
|
||||
Email: "bob@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
CookieDomain: "example.com",
|
||||
AppURL: "https://tinyauth.example.com",
|
||||
SessionCookieName: "tinyauth-session",
|
||||
OIDCClients: func() []model.OIDCClientConfig {
|
||||
var clients []model.OIDCClientConfig
|
||||
for id, client := range config.OIDC.Clients {
|
||||
client.ID = id
|
||||
clients = append(clients, client)
|
||||
}
|
||||
return clients
|
||||
}(),
|
||||
}
|
||||
|
||||
return config, runtime
|
||||
}
|
||||
+22
-22
@@ -7,6 +7,10 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||
)
|
||||
|
||||
@@ -20,12 +24,13 @@ func GetCookieDomain(u string) (string, error) {
|
||||
host := parsed.Hostname()
|
||||
|
||||
if netIP := net.ParseIP(host); netIP != nil {
|
||||
return "", errors.New("ip addresses not allowed")
|
||||
return "", errors.New("IP addresses not allowed")
|
||||
}
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
|
||||
if len(parts) == 2 {
|
||||
tlog.App.Warn().Msgf("Running on the root domain, cookies will be set for .%v", host)
|
||||
return host, nil
|
||||
}
|
||||
|
||||
@@ -44,27 +49,6 @@ func GetCookieDomain(u string) (string, error) {
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
func GetStandaloneCookieDomain(u string) (string, error) {
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
|
||||
if netIP := net.ParseIP(host); netIP != nil {
|
||||
return "", errors.New("ip addresses not allowed")
|
||||
}
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
|
||||
if len(parts) < 2 {
|
||||
return "", errors.New("invalid app url")
|
||||
}
|
||||
|
||||
return host, nil
|
||||
}
|
||||
|
||||
func ParseFileToLine(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
users := make([]string, 0)
|
||||
@@ -89,6 +73,22 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) {
|
||||
return res
|
||||
}
|
||||
|
||||
func GetContext(c *gin.Context) (config.UserContext, error) {
|
||||
userContextValue, exists := c.Get("context")
|
||||
|
||||
if !exists {
|
||||
return config.UserContext{}, errors.New("no user context in request")
|
||||
}
|
||||
|
||||
userContext, ok := userContextValue.(*config.UserContext)
|
||||
|
||||
if !ok {
|
||||
return config.UserContext{}, errors.New("invalid user context in request")
|
||||
}
|
||||
|
||||
return *userContext, nil
|
||||
}
|
||||
|
||||
func IsRedirectSafe(redirectURL string, domain string) bool {
|
||||
if redirectURL == "" {
|
||||
return false
|
||||
|
||||
@@ -3,8 +3,11 @@ package utils_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestGetRootDomain(t *testing.T) {
|
||||
@@ -12,14 +15,14 @@ func TestGetRootDomain(t *testing.T) {
|
||||
domain := "http://sub.tinyauth.app"
|
||||
expected := "tinyauth.app"
|
||||
result, err := utils.GetCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// Domain with multiple subdomains
|
||||
domain = "http://b.c.tinyauth.app"
|
||||
expected = "c.tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// Invalid domain (only TLD)
|
||||
@@ -30,7 +33,7 @@ func TestGetRootDomain(t *testing.T) {
|
||||
// IP address
|
||||
domain = "http://10.10.10.10"
|
||||
_, err = utils.GetCookieDomain(domain)
|
||||
assert.ErrorContains(t, err, "ip addresses not allowed")
|
||||
assert.ErrorContains(t, err, "IP addresses not allowed")
|
||||
|
||||
// Invalid URL
|
||||
domain = "http://[::1]:namedport"
|
||||
@@ -41,14 +44,14 @@ func TestGetRootDomain(t *testing.T) {
|
||||
domain = "https://sub.tinyauth.app/path"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// URL with port
|
||||
domain = "http://sub.tinyauth.app:8080"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// Domain managed by ICANN
|
||||
@@ -95,35 +98,57 @@ func TestFilter(t *testing.T) {
|
||||
testFunc := func(n int) bool { return n%2 == 0 }
|
||||
expected := []int{2, 4}
|
||||
result := utils.Filter(slice, testFunc)
|
||||
assert.Equal(t, expected, result)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
|
||||
// Case with no matches
|
||||
slice = []int{1, 3, 5}
|
||||
testFunc = func(n int) bool { return n%2 == 0 }
|
||||
expected = []int{}
|
||||
result = utils.Filter(slice, testFunc)
|
||||
assert.Equal(t, expected, result)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
|
||||
// Case with all matches
|
||||
slice = []int{2, 4, 6}
|
||||
testFunc = func(n int) bool { return n%2 == 0 }
|
||||
expected = []int{2, 4, 6}
|
||||
result = utils.Filter(slice, testFunc)
|
||||
assert.Equal(t, expected, result)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
|
||||
// Case with empty slice
|
||||
slice = []int{}
|
||||
testFunc = func(n int) bool { return n%2 == 0 }
|
||||
expected = []int{}
|
||||
result = utils.Filter(slice, testFunc)
|
||||
assert.Equal(t, expected, result)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
|
||||
// Case with different type (string)
|
||||
sliceStr := []string{"apple", "banana", "cherry"}
|
||||
testFuncStr := func(s string) bool { return len(s) > 5 }
|
||||
expectedStr := []string{"banana", "cherry"}
|
||||
resultStr := utils.Filter(sliceStr, testFuncStr)
|
||||
assert.Equal(t, expectedStr, resultStr)
|
||||
assert.DeepEqual(t, expectedStr, resultStr)
|
||||
}
|
||||
|
||||
func TestGetContext(t *testing.T) {
|
||||
// Setup
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
|
||||
// Normal case
|
||||
c.Set("context", &config.UserContext{Username: "testuser"})
|
||||
result, err := utils.GetContext(c)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, "testuser", result.Username)
|
||||
|
||||
// Case with no context
|
||||
c.Set("context", nil)
|
||||
_, err = utils.GetContext(c)
|
||||
assert.Error(t, err, "invalid user context in request")
|
||||
|
||||
// Case with invalid context type
|
||||
c.Set("context", "invalid type")
|
||||
_, err = utils.GetContext(c)
|
||||
assert.Error(t, err, "invalid user context in request")
|
||||
}
|
||||
|
||||
func TestIsRedirectSafe(t *testing.T) {
|
||||
@@ -133,95 +158,50 @@ func TestIsRedirectSafe(t *testing.T) {
|
||||
// Case with no subdomain
|
||||
redirectURL := "http://example.com/welcome"
|
||||
result := utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.True(t, result)
|
||||
assert.Equal(t, true, result)
|
||||
|
||||
// Case with different domain
|
||||
redirectURL = "http://malicious.com/phishing"
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.False(t, result)
|
||||
assert.Equal(t, false, result)
|
||||
|
||||
// Case with subdomain
|
||||
redirectURL = "http://sub.example.com/page"
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.True(t, result)
|
||||
assert.Equal(t, true, result)
|
||||
|
||||
// Case with sub-subdomain
|
||||
redirectURL = "http://a.b.example.com/home"
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.True(t, result)
|
||||
assert.Equal(t, true, result)
|
||||
|
||||
// Case with empty redirect URL
|
||||
redirectURL = ""
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.False(t, result)
|
||||
assert.Equal(t, false, result)
|
||||
|
||||
// Case with invalid URL
|
||||
redirectURL = "http://[::1]:namedport"
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.False(t, result)
|
||||
assert.Equal(t, false, result)
|
||||
|
||||
// Case with URL having port
|
||||
redirectURL = "http://sub.example.com:8080/page"
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.True(t, result)
|
||||
assert.Equal(t, true, result)
|
||||
|
||||
// Case with URL having different subdomain
|
||||
redirectURL = "http://another.example.com/page"
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.True(t, result)
|
||||
assert.Equal(t, true, result)
|
||||
|
||||
// Case with URL having different TLD
|
||||
redirectURL = "http://example.org/page"
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.False(t, result)
|
||||
assert.Equal(t, false, result)
|
||||
|
||||
// Case with malicious domain
|
||||
redirectURL = "https://malicious-example.com/yoyo"
|
||||
result = utils.IsRedirectSafe(redirectURL, domain)
|
||||
assert.False(t, result)
|
||||
}
|
||||
|
||||
func TestGetStandaloneCookieDomain(t *testing.T) {
|
||||
// Normal case
|
||||
domain := "http://tinyauth.app"
|
||||
expected := "tinyauth.app"
|
||||
result, err := utils.GetStandaloneCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// URL with subdomain (full hostname is returned, no subdomain stripping)
|
||||
domain = "http://sub.tinyauth.app"
|
||||
expected = "sub.tinyauth.app"
|
||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// URL with port (port should be stripped)
|
||||
domain = "http://tinyauth.app:8080"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// URL with path
|
||||
domain = "https://tinyauth.app/some/path"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// IP address
|
||||
domain = "http://10.10.10.10"
|
||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.ErrorContains(t, err, "ip addresses not allowed")
|
||||
|
||||
// Invalid domain (only TLD)
|
||||
domain = "com"
|
||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.ErrorContains(t, err, "invalid app url")
|
||||
|
||||
// Invalid URL
|
||||
domain = "http://[::1]:namedport"
|
||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host")
|
||||
assert.Equal(t, false, result)
|
||||
}
|
||||
|
||||
@@ -3,41 +3,42 @@ package decoders_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestDecodeLabels(t *testing.T) {
|
||||
// Variables
|
||||
expected := model.Apps{
|
||||
Apps: map[string]model.App{
|
||||
expected := config.Apps{
|
||||
Apps: map[string]config.App{
|
||||
"foo": {
|
||||
Config: model.AppConfig{
|
||||
Config: config.AppConfig{
|
||||
Domain: "example.com",
|
||||
},
|
||||
Users: model.AppUsers{
|
||||
Users: config.AppUsers{
|
||||
Allow: "user1,user2",
|
||||
Block: "user3",
|
||||
},
|
||||
OAuth: model.AppOAuth{
|
||||
OAuth: config.AppOAuth{
|
||||
Whitelist: "somebody@example.com",
|
||||
Groups: "group3",
|
||||
},
|
||||
IP: model.AppIP{
|
||||
IP: config.AppIP{
|
||||
Allow: []string{"10.71.0.1/24", "10.71.0.2"},
|
||||
Block: []string{"10.10.10.10", "10.0.0.0/24"},
|
||||
Bypass: []string{"192.168.1.1"},
|
||||
},
|
||||
Response: model.AppResponse{
|
||||
Response: config.AppResponse{
|
||||
Headers: []string{"X-Foo=Bar", "X-Baz=Qux"},
|
||||
BasicAuth: model.AppBasicAuth{
|
||||
BasicAuth: config.AppBasicAuth{
|
||||
Username: "admin",
|
||||
Password: "password",
|
||||
PasswordFile: "/path/to/passwordfile",
|
||||
},
|
||||
},
|
||||
Path: model.AppPath{
|
||||
Path: config.AppPath{
|
||||
Allow: "/public",
|
||||
Block: "/private",
|
||||
},
|
||||
@@ -62,7 +63,7 @@ func TestDecodeLabels(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test
|
||||
result, err := decoders.DecodeLabels[model.Apps](test, "apps")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
result, err := decoders.DecodeLabels[config.Apps](test, "apps")
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, expected, result)
|
||||
}
|
||||
|
||||
@@ -4,25 +4,24 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestReadFile(t *testing.T) {
|
||||
// Setup
|
||||
file, err := os.Create("/tmp/tinyauth_test_file")
|
||||
require.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
_, err = file.WriteString("file content\n")
|
||||
require.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
err = file.Close()
|
||||
require.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
defer os.Remove("/tmp/tinyauth_test_file")
|
||||
|
||||
// Normal case
|
||||
content, err := ReadFile("/tmp/tinyauth_test_file")
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, "file content\n", content)
|
||||
|
||||
// Non-existing file
|
||||
|
||||
@@ -3,8 +3,9 @@ package utils_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestParseHeaders(t *testing.T) {
|
||||
@@ -17,7 +18,7 @@ func TestParseHeaders(t *testing.T) {
|
||||
"X-Custom-Header": "Value",
|
||||
"Another-Header": "AnotherValue",
|
||||
}
|
||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
||||
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||
|
||||
// Case insensitivity and trimming
|
||||
headers = []string{
|
||||
@@ -28,7 +29,7 @@ func TestParseHeaders(t *testing.T) {
|
||||
"X-Custom-Header": "Value",
|
||||
"Another-Header": "AnotherValue",
|
||||
}
|
||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
||||
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||
|
||||
// Invalid headers (missing '=', empty key/value)
|
||||
headers = []string{
|
||||
@@ -38,7 +39,7 @@ func TestParseHeaders(t *testing.T) {
|
||||
" = ",
|
||||
}
|
||||
expected = map[string]string{}
|
||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
||||
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||
|
||||
// Headers with unsafe characters
|
||||
headers = []string{
|
||||
@@ -51,7 +52,7 @@ func TestParseHeaders(t *testing.T) {
|
||||
"Another-Header": "AnotherValue",
|
||||
"Good-Header": "GoodValue",
|
||||
}
|
||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
||||
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||
|
||||
// Header with spaces in key (should be ignored)
|
||||
headers = []string{
|
||||
@@ -61,7 +62,7 @@ func TestParseHeaders(t *testing.T) {
|
||||
expected = map[string]string{
|
||||
"Valid-Header": "ValidValue",
|
||||
}
|
||||
assert.Equal(t, expected, utils.ParseHeaders(headers))
|
||||
assert.DeepEqual(t, expected, utils.ParseHeaders(headers))
|
||||
}
|
||||
|
||||
func TestSanitizeHeader(t *testing.T) {
|
||||
|
||||
@@ -4,20 +4,21 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/paerser/env"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
type EnvLoader struct{}
|
||||
|
||||
func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
|
||||
vars := env.FindPrefixedEnvVars(os.Environ(), model.DefaultNamePrefix, cmd.Configuration)
|
||||
vars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration)
|
||||
if len(vars) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := env.Decode(vars, model.DefaultNamePrefix, cmd.Configuration); err != nil {
|
||||
if err := env.Decode(vars, config.DefaultNamePrefix, cmd.Configuration); err != nil {
|
||||
return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
HTTP zerolog.Logger
|
||||
App zerolog.Logger
|
||||
config model.LogConfig
|
||||
base zerolog.Logger
|
||||
audit zerolog.Logger
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
func NewLogger() *Logger {
|
||||
return &Logger{
|
||||
writer: os.Stderr,
|
||||
config: model.LogConfig{
|
||||
Level: "error",
|
||||
Json: true,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
App: model.LogStreamConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
// No reason to enable audit by default since it will be suppressed by the log level
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) WithConfig(cfg model.LogConfig) *Logger {
|
||||
l.config = cfg
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Logger) WithSimpleConfig() *Logger {
|
||||
l.config = model.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Logger) WithTestConfig() *Logger {
|
||||
l.config = model.LogConfig{
|
||||
Level: "trace",
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Logger) WithWriter(writer io.Writer) *Logger {
|
||||
l.writer = writer
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Logger) Init() {
|
||||
base := log.With().
|
||||
Timestamp().
|
||||
Logger().
|
||||
Level(l.parseLogLevel(l.config.Level)).Output(l.writer)
|
||||
|
||||
if !l.config.Json {
|
||||
base = base.Output(zerolog.ConsoleWriter{
|
||||
Out: l.writer,
|
||||
TimeFormat: time.RFC3339,
|
||||
})
|
||||
}
|
||||
|
||||
if base.GetLevel() == zerolog.TraceLevel || base.GetLevel() == zerolog.DebugLevel {
|
||||
base = base.With().Caller().Logger()
|
||||
}
|
||||
|
||||
l.base = base
|
||||
l.audit = l.createLogger("audit", l.config.Streams.Audit)
|
||||
l.HTTP = l.createLogger("http", l.config.Streams.HTTP)
|
||||
l.App = l.createLogger("app", l.config.Streams.App)
|
||||
}
|
||||
|
||||
func (l *Logger) parseLogLevel(level string) zerolog.Level {
|
||||
if level == "" {
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
parsed, err := zerolog.ParseLevel(strings.ToLower(level))
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("level", level).Msg("Invalid log level, defaulting to error")
|
||||
parsed = zerolog.ErrorLevel
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (l *Logger) createLogger(component string, cfg model.LogStreamConfig) zerolog.Logger {
|
||||
if !cfg.Enabled {
|
||||
return zerolog.Nop()
|
||||
}
|
||||
sub := l.base.With().Str("stream", component).Logger()
|
||||
if cfg.Level != "" {
|
||||
sub = sub.Level(l.parseLogLevel(cfg.Level))
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func (l *Logger) AuditLoginSuccess(username, provider, ip string) {
|
||||
l.audit.Info().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "login").
|
||||
Str("result", "success").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", ip).
|
||||
Send()
|
||||
}
|
||||
|
||||
func (l *Logger) AuditLoginFailure(username, provider, ip, reason string) {
|
||||
l.audit.Warn().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "login").
|
||||
Str("result", "failure").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", ip).
|
||||
Str("reason", reason).
|
||||
Send()
|
||||
}
|
||||
|
||||
func (l *Logger) AuditLogout(username, provider, ip string) {
|
||||
l.audit.Info().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "logout").
|
||||
Str("result", "success").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", ip).
|
||||
Send()
|
||||
}
|
||||
|
||||
// Used for testing
|
||||
func (l *Logger) GetConfig() model.LogConfig {
|
||||
return l.config
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestLogger(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
run func(t *testing.T)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Should create a simple logger with the expected config",
|
||||
run: func(t *testing.T) {
|
||||
l := logger.NewLogger().WithSimpleConfig()
|
||||
l.Init()
|
||||
|
||||
cfg := l.GetConfig()
|
||||
|
||||
assert.Equal(t, cfg, model.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should create a test logger with the expected config",
|
||||
run: func(t *testing.T) {
|
||||
l := logger.NewLogger().WithTestConfig()
|
||||
l.Init()
|
||||
|
||||
cfg := l.GetConfig()
|
||||
|
||||
assert.Equal(t, cfg, model.LogConfig{
|
||||
Level: "trace",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: true},
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should create a logger with a custom config",
|
||||
run: func(t *testing.T) {
|
||||
customCfg := model.LogConfig{
|
||||
Level: "debug",
|
||||
Json: true,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: false},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
l := logger.NewLogger().WithConfig(customCfg)
|
||||
l.Init()
|
||||
|
||||
cfg := l.GetConfig()
|
||||
|
||||
assert.Equal(t, cfg, customCfg)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Default logger should use error type and log json",
|
||||
run: func(t *testing.T) {
|
||||
buf := bytes.Buffer{}
|
||||
|
||||
l := logger.NewLogger().WithWriter(&buf)
|
||||
l.Init()
|
||||
|
||||
cfg := l.GetConfig()
|
||||
|
||||
assert.Equal(t, cfg, model.LogConfig{
|
||||
Level: "error",
|
||||
Json: true,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
})
|
||||
|
||||
l.App.Error().Msg("test")
|
||||
|
||||
var entry map[string]any
|
||||
err := json.Unmarshal(buf.Bytes(), &entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "test", entry["message"])
|
||||
assert.Equal(t, "app", entry["stream"])
|
||||
assert.Equal(t, "error", entry["level"])
|
||||
assert.NotEmpty(t, entry["time"])
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should default to error level if an invalid level is provided",
|
||||
run: func(t *testing.T) {
|
||||
buf := bytes.Buffer{}
|
||||
|
||||
customCfg := model.LogConfig{
|
||||
Level: "invalid",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: true},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
l := logger.NewLogger().WithConfig(customCfg).WithWriter(&buf)
|
||||
l.Init()
|
||||
|
||||
assert.Equal(t, zerolog.ErrorLevel, l.App.GetLevel())
|
||||
assert.Equal(t, zerolog.ErrorLevel, l.HTTP.GetLevel())
|
||||
|
||||
// should not get logged
|
||||
l.AuditLoginFailure("test", "test", "test", "test")
|
||||
|
||||
assert.Empty(t, buf.String())
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should use nop logger for disabled streams",
|
||||
run: func(t *testing.T) {
|
||||
buf := bytes.Buffer{}
|
||||
|
||||
customCfg := model.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: model.LogStreams{
|
||||
HTTP: model.LogStreamConfig{Enabled: false},
|
||||
App: model.LogStreamConfig{Enabled: true},
|
||||
Audit: model.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
l := logger.NewLogger().WithConfig(customCfg).WithWriter(&buf)
|
||||
l.Init()
|
||||
|
||||
assert.Equal(t, zerolog.Disabled, l.HTTP.GetLevel())
|
||||
|
||||
l.App.Info().Msg("test")
|
||||
|
||||
l.AuditLoginFailure("test_nop", "test_nop", "test_nop", "test_nop")
|
||||
|
||||
assert.NotEmpty(t, buf.String())
|
||||
assert.NotContains(t, buf.String(), "test_nop")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, test.run)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func ParseSecretFile(contents string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func EncodeBasicAuth(username string, password string) string {
|
||||
func GetBasicAuth(username string, password string) string {
|
||||
auth := username + ":" + password
|
||||
return base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestGetSecret(t *testing.T) {
|
||||
// Setup
|
||||
file, err := os.Create("/tmp/tinyauth_test_secret")
|
||||
require.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
_, err = file.WriteString(" secret \n")
|
||||
require.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
err = file.Close()
|
||||
require.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
defer os.Remove("/tmp/tinyauth_test_secret")
|
||||
|
||||
// Get from config
|
||||
@@ -55,50 +55,50 @@ func TestParseSecretFile(t *testing.T) {
|
||||
assert.Equal(t, "", utils.ParseSecretFile(content))
|
||||
}
|
||||
|
||||
func TestEncodeBasicAuth(t *testing.T) {
|
||||
func TestGetBasicAuth(t *testing.T) {
|
||||
// Normal case
|
||||
username := "user"
|
||||
password := "pass"
|
||||
expected := "dXNlcjpwYXNz" // base64 of "user:pass"
|
||||
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
|
||||
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
|
||||
|
||||
// Empty username
|
||||
username = ""
|
||||
password = "pass"
|
||||
expected = "OnBhc3M=" // base64 of ":pass"
|
||||
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
|
||||
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
|
||||
|
||||
// Empty password
|
||||
username = "user"
|
||||
password = ""
|
||||
expected = "dXNlcjo=" // base64 of "user:"
|
||||
assert.Equal(t, expected, utils.EncodeBasicAuth(username, password))
|
||||
assert.Equal(t, expected, utils.GetBasicAuth(username, password))
|
||||
}
|
||||
|
||||
func TestFilterIP(t *testing.T) {
|
||||
// Exact match IPv4
|
||||
ok, err := utils.FilterIP("10.10.0.1", "10.10.0.1")
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, true, ok)
|
||||
|
||||
// Non-match IPv4
|
||||
ok, err = utils.FilterIP("10.10.0.1", "10.10.0.2")
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, false, ok)
|
||||
|
||||
// CIDR match IPv4
|
||||
ok, err = utils.FilterIP("10.10.0.0/24", "10.10.0.2")
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, true, ok)
|
||||
|
||||
// CIDR match IPv4 with '-' instead of '/'
|
||||
ok, err = utils.FilterIP("10.10.10.0-24", "10.10.10.5")
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, true, ok)
|
||||
|
||||
// CIDR non-match IPv4
|
||||
ok, err = utils.FilterIP("10.10.0.0/24", "10.5.0.1")
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, false, ok)
|
||||
|
||||
// Invalid CIDR
|
||||
@@ -145,5 +145,5 @@ func TestGenerateUUID(t *testing.T) {
|
||||
|
||||
// Different output for different input
|
||||
id3 := utils.GenerateUUID("differentstring")
|
||||
assert.NotEqual(t, id2, id3)
|
||||
assert.Assert(t, id1 != id3)
|
||||
}
|
||||
|
||||
@@ -28,41 +28,3 @@ func CoalesceToString(value any) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ParseNonEmptyLines(contents string) []string {
|
||||
lines := make([]string, 0)
|
||||
|
||||
for line := range strings.SplitSeq(contents, "\n") {
|
||||
lineTrimmed := strings.TrimSpace(line)
|
||||
if lineTrimmed == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, lineTrimmed)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func GetStringList(valuesCfg []string, valuesPath string) ([]string, error) {
|
||||
values := make([]string, 0, len(valuesCfg))
|
||||
|
||||
for _, value := range valuesCfg {
|
||||
valueTrimmed := strings.TrimSpace(value)
|
||||
if valueTrimmed == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, valueTrimmed)
|
||||
}
|
||||
|
||||
if valuesPath == "" {
|
||||
return values, nil
|
||||
}
|
||||
|
||||
contents, err := ReadFile(valuesPath)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
values = append(values, ParseNonEmptyLines(contents)...)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestCapitalize(t *testing.T) {
|
||||
@@ -57,33 +57,3 @@ func TestCompileUserEmail(t *testing.T) {
|
||||
// Test with invalid email
|
||||
assert.Equal(t, "user@example.com", utils.CompileUserEmail("user", "example.com"))
|
||||
}
|
||||
|
||||
func TestParseNonEmptyLines(t *testing.T) {
|
||||
lines := utils.ParseNonEmptyLines(" first@example.com \n\n second@example.com \n \n")
|
||||
|
||||
assert.Equal(t, []string{"first@example.com", "second@example.com"}, lines)
|
||||
}
|
||||
|
||||
func TestGetStringList(t *testing.T) {
|
||||
file, err := os.Create("/tmp/tinyauth_list_test_file")
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = file.WriteString(" third@example.com \n\n fourth@example.com \n")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = file.Close()
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove("/tmp/tinyauth_list_test_file")
|
||||
|
||||
values, err := utils.GetStringList([]string{" first@example.com ", "", "second@example.com"}, "/tmp/tinyauth_list_test_file")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"first@example.com", "second@example.com", "third@example.com", "fourth@example.com"}, values)
|
||||
|
||||
values, err = utils.GetStringList(nil, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{}, values)
|
||||
|
||||
values, err = utils.GetStringList(nil, "/tmp/non_existing_list_file")
|
||||
assert.ErrorContains(t, err, "no such file or directory")
|
||||
assert.Equal(t, []string{}, values)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package tlog
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// functions here use CallerSkipFrame to ensure correct caller info is logged
|
||||
|
||||
func AuditLoginSuccess(c *gin.Context, username, provider string) {
|
||||
Audit.Info().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "login").
|
||||
Str("result", "success").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", c.ClientIP()).
|
||||
Send()
|
||||
}
|
||||
|
||||
func AuditLoginFailure(c *gin.Context, username, provider string, reason string) {
|
||||
Audit.Warn().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "login").
|
||||
Str("result", "failure").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", c.ClientIP()).
|
||||
Str("reason", reason).
|
||||
Send()
|
||||
}
|
||||
|
||||
func AuditLogout(c *gin.Context, username, provider string) {
|
||||
Audit.Info().
|
||||
CallerSkipFrame(1).
|
||||
Str("event", "logout").
|
||||
Str("result", "success").
|
||||
Str("username", username).
|
||||
Str("provider", provider).
|
||||
Str("ip", c.ClientIP()).
|
||||
Send()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package tlog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
Audit zerolog.Logger
|
||||
HTTP zerolog.Logger
|
||||
App zerolog.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
Audit zerolog.Logger
|
||||
HTTP zerolog.Logger
|
||||
App zerolog.Logger
|
||||
)
|
||||
|
||||
func NewLogger(cfg config.LogConfig) *Logger {
|
||||
baseLogger := log.With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger().
|
||||
Level(parseLogLevel(cfg.Level))
|
||||
|
||||
if !cfg.Json {
|
||||
baseLogger = baseLogger.Output(zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: time.RFC3339,
|
||||
})
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
Audit: createLogger("audit", cfg.Streams.Audit, baseLogger),
|
||||
HTTP: createLogger("http", cfg.Streams.HTTP, baseLogger),
|
||||
App: createLogger("app", cfg.Streams.App, baseLogger),
|
||||
}
|
||||
}
|
||||
|
||||
func NewSimpleLogger() *Logger {
|
||||
return NewLogger(config.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: config.LogStreams{
|
||||
HTTP: config.LogStreamConfig{Enabled: true},
|
||||
App: config.LogStreamConfig{Enabled: true},
|
||||
Audit: config.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func NewTestLogger() *Logger {
|
||||
return NewLogger(config.LogConfig{
|
||||
Level: "trace",
|
||||
Streams: config.LogStreams{
|
||||
HTTP: config.LogStreamConfig{Enabled: true},
|
||||
App: config.LogStreamConfig{Enabled: true},
|
||||
Audit: config.LogStreamConfig{Enabled: true},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (l *Logger) Init() {
|
||||
Audit = l.Audit
|
||||
HTTP = l.HTTP
|
||||
App = l.App
|
||||
}
|
||||
|
||||
func createLogger(component string, streamCfg config.LogStreamConfig, baseLogger zerolog.Logger) zerolog.Logger {
|
||||
if !streamCfg.Enabled {
|
||||
return zerolog.Nop()
|
||||
}
|
||||
subLogger := baseLogger.With().Str("log_stream", component).Logger()
|
||||
// override level if specified, otherwise use base level
|
||||
if streamCfg.Level != "" {
|
||||
subLogger = subLogger.Level(parseLogLevel(streamCfg.Level))
|
||||
}
|
||||
return subLogger
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) zerolog.Level {
|
||||
if level == "" {
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
parsedLevel, err := zerolog.ParseLevel(strings.ToLower(level))
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
|
||||
parsedLevel = zerolog.InfoLevel
|
||||
}
|
||||
return parsedLevel
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package tlog_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestNewLogger(t *testing.T) {
|
||||
cfg := config.LogConfig{
|
||||
Level: "debug",
|
||||
Json: true,
|
||||
Streams: config.LogStreams{
|
||||
HTTP: config.LogStreamConfig{Enabled: true, Level: "info"},
|
||||
App: config.LogStreamConfig{Enabled: true, Level: ""},
|
||||
Audit: config.LogStreamConfig{Enabled: false, Level: ""},
|
||||
},
|
||||
}
|
||||
|
||||
logger := tlog.NewLogger(cfg)
|
||||
|
||||
assert.Assert(t, logger != nil)
|
||||
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.InfoLevel)
|
||||
assert.Assert(t, logger.App.GetLevel() == zerolog.DebugLevel)
|
||||
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
|
||||
}
|
||||
|
||||
func TestNewSimpleLogger(t *testing.T) {
|
||||
logger := tlog.NewSimpleLogger()
|
||||
assert.Assert(t, logger != nil)
|
||||
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.InfoLevel)
|
||||
assert.Assert(t, logger.App.GetLevel() == zerolog.InfoLevel)
|
||||
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
|
||||
}
|
||||
|
||||
func TestLoggerInit(t *testing.T) {
|
||||
logger := tlog.NewSimpleLogger()
|
||||
logger.Init()
|
||||
|
||||
assert.Assert(t, tlog.App.GetLevel() != zerolog.Disabled)
|
||||
}
|
||||
|
||||
func TestLoggerWithDisabledStreams(t *testing.T) {
|
||||
cfg := config.LogConfig{
|
||||
Level: "info",
|
||||
Json: false,
|
||||
Streams: config.LogStreams{
|
||||
HTTP: config.LogStreamConfig{Enabled: false},
|
||||
App: config.LogStreamConfig{Enabled: false},
|
||||
Audit: config.LogStreamConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
logger := tlog.NewLogger(cfg)
|
||||
|
||||
assert.Assert(t, logger.HTTP.GetLevel() == zerolog.Disabled)
|
||||
assert.Assert(t, logger.App.GetLevel() == zerolog.Disabled)
|
||||
assert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)
|
||||
}
|
||||
|
||||
func TestLogStreamField(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
cfg := config.LogConfig{
|
||||
Level: "info",
|
||||
Json: true,
|
||||
Streams: config.LogStreams{
|
||||
HTTP: config.LogStreamConfig{Enabled: true},
|
||||
App: config.LogStreamConfig{Enabled: true},
|
||||
Audit: config.LogStreamConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
logger := tlog.NewLogger(cfg)
|
||||
|
||||
// Override output for HTTP logger to capture output
|
||||
logger.HTTP = logger.HTTP.Output(&buf)
|
||||
|
||||
logger.HTTP.Info().Msg("test message")
|
||||
|
||||
var logEntry map[string]interface{}
|
||||
err := json.Unmarshal(buf.Bytes(), &logEntry)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, "http", logEntry["log_stream"])
|
||||
assert.Equal(t, "test message", logEntry["message"])
|
||||
}
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/config"
|
||||
)
|
||||
|
||||
func ParseUsers(usersStr []string, userAttributes map[string]model.UserAttributes) (*[]model.LocalUser, error) {
|
||||
var users []model.LocalUser
|
||||
func ParseUsers(usersStr []string) ([]config.User, error) {
|
||||
var users []config.User
|
||||
|
||||
if len(usersStr) == 0 {
|
||||
return nil, nil
|
||||
return []config.User{}, nil
|
||||
}
|
||||
|
||||
for _, user := range usersStr {
|
||||
@@ -22,27 +22,47 @@ func ParseUsers(usersStr []string, userAttributes map[string]model.UserAttribute
|
||||
}
|
||||
parsed, err := ParseUser(strings.TrimSpace(user))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []config.User{}, err
|
||||
}
|
||||
if attrs, ok := userAttributes[parsed.Username]; ok {
|
||||
parsed.Attributes = attrs
|
||||
}
|
||||
users = append(users, *parsed)
|
||||
users = append(users, parsed)
|
||||
}
|
||||
|
||||
return &users, nil
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]model.UserAttributes) (*[]model.LocalUser, error) {
|
||||
usersStr, err := GetStringList(usersCfg, usersPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func GetUsers(usersCfg []string, usersPath string) ([]config.User, error) {
|
||||
var usersStr []string
|
||||
|
||||
if len(usersCfg) == 0 && usersPath == "" {
|
||||
return []config.User{}, nil
|
||||
}
|
||||
|
||||
return ParseUsers(usersStr, userAttributes)
|
||||
if len(usersCfg) > 0 {
|
||||
usersStr = append(usersStr, usersCfg...)
|
||||
}
|
||||
|
||||
if usersPath != "" {
|
||||
contents, err := ReadFile(usersPath)
|
||||
|
||||
if err != nil {
|
||||
return []config.User{}, err
|
||||
}
|
||||
|
||||
lines := strings.SplitSeq(contents, "\n")
|
||||
|
||||
for line := range lines {
|
||||
lineTrimmed := strings.TrimSpace(line)
|
||||
if lineTrimmed == "" {
|
||||
continue
|
||||
}
|
||||
usersStr = append(usersStr, lineTrimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return ParseUsers(usersStr)
|
||||
}
|
||||
|
||||
func ParseUser(userStr string) (*model.LocalUser, error) {
|
||||
func ParseUser(userStr string) (config.User, error) {
|
||||
if strings.Contains(userStr, "$$") {
|
||||
userStr = strings.ReplaceAll(userStr, "$$", "$")
|
||||
}
|
||||
@@ -50,27 +70,27 @@ func ParseUser(userStr string) (*model.LocalUser, error) {
|
||||
parts := strings.SplitN(userStr, ":", 4)
|
||||
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return nil, errors.New("invalid user format")
|
||||
return config.User{}, errors.New("invalid user format")
|
||||
}
|
||||
|
||||
for i, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("invalid user format")
|
||||
return config.User{}, errors.New("invalid user format")
|
||||
}
|
||||
parts[i] = trimmed
|
||||
}
|
||||
|
||||
user := model.LocalUser{
|
||||
user := config.User{
|
||||
Username: parts[0],
|
||||
Password: parts[1],
|
||||
}
|
||||
|
||||
if len(parts) == 3 {
|
||||
user.TOTPSecret = parts[2]
|
||||
user.TotpSecret = parts[2]
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func CompileUserEmail(username string, domain string) string {
|
||||
|
||||
@@ -4,136 +4,141 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
func TestGetUsers(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"
|
||||
|
||||
// Setup
|
||||
file, err := os.Create(tmpDir + "/tinyauth_users_test.txt")
|
||||
require.NoError(t, err)
|
||||
file, err := os.Create("/tmp/tinyauth_users_test.txt")
|
||||
assert.NilError(t, err)
|
||||
|
||||
_, err = file.WriteString(" user1:" + hash + " \n user2:" + hash + " ") // Spacing is on purpose
|
||||
require.NoError(t, err)
|
||||
_, err = file.WriteString(" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G \n user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G ") // Spacing is on purpose
|
||||
assert.NilError(t, err)
|
||||
|
||||
err = file.Close()
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpDir + "/tinyauth_users_test.txt")
|
||||
assert.NilError(t, err)
|
||||
defer os.Remove("/tmp/tinyauth_users_test.txt")
|
||||
|
||||
noAttrs := map[string]model.UserAttributes{}
|
||||
// Test file
|
||||
users, err := utils.GetUsers([]string{}, "/tmp/tinyauth_users_test.txt")
|
||||
|
||||
// Test file only
|
||||
users, err := utils.GetUsers([]string{}, tmpDir+"/tinyauth_users_test.txt", noAttrs)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, users)
|
||||
assert.Len(t, *users, 2)
|
||||
assert.Equal(t, 2, len(users))
|
||||
|
||||
assert.Equal(t, "user1", (*users)[0].Username)
|
||||
assert.Equal(t, hash, (*users)[0].Password)
|
||||
assert.Equal(t, "user2", (*users)[1].Username)
|
||||
assert.Equal(t, hash, (*users)[1].Password)
|
||||
assert.Equal(t, "user1", users[0].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
||||
assert.Equal(t, "user2", users[1].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
||||
|
||||
// Test inline config only
|
||||
users, err = utils.GetUsers([]string{"user3:" + hash, "user4:" + hash}, "", noAttrs)
|
||||
// Test config
|
||||
users, err = utils.GetUsers([]string{"user3:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "user4:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"}, "")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Len(t, *users, 2)
|
||||
assert.Equal(t, "user3", (*users)[0].Username)
|
||||
assert.Equal(t, "user4", (*users)[1].Username)
|
||||
assert.Equal(t, 2, len(users))
|
||||
|
||||
assert.Equal(t, "user3", users[0].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
||||
assert.Equal(t, "user4", users[1].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
||||
|
||||
// Test both
|
||||
users, err = utils.GetUsers([]string{"user5:" + hash}, tmpDir+"/tinyauth_users_test.txt", noAttrs)
|
||||
users, err = utils.GetUsers([]string{"user5:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"}, "/tmp/tinyauth_users_test.txt")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Len(t, *users, 3)
|
||||
assert.Equal(t, 3, len(users))
|
||||
|
||||
usernames := map[string]bool{}
|
||||
for _, u := range *users {
|
||||
usernames[u.Username] = true
|
||||
}
|
||||
assert.True(t, usernames["user1"])
|
||||
assert.True(t, usernames["user2"])
|
||||
assert.True(t, usernames["user5"])
|
||||
|
||||
// Test attributes applied from userAttributes map
|
||||
attrs := map[string]model.UserAttributes{
|
||||
"user1": {Name: "User One", Email: "user1@example.com"},
|
||||
}
|
||||
users, err = utils.GetUsers([]string{}, tmpDir+"/tinyauth_users_test.txt", attrs)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, *users, 2)
|
||||
|
||||
for _, u := range *users {
|
||||
if u.Username == "user1" {
|
||||
assert.Equal(t, "User One", u.Attributes.Name)
|
||||
assert.Equal(t, "user1@example.com", u.Attributes.Email)
|
||||
}
|
||||
if u.Username == "user2" {
|
||||
assert.Equal(t, "", u.Attributes.Name)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "user5", users[0].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
||||
assert.Equal(t, "user1", users[1].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
||||
assert.Equal(t, "user2", users[2].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[2].Password)
|
||||
|
||||
// Test empty
|
||||
users, err = utils.GetUsers([]string{}, "", noAttrs)
|
||||
users, err = utils.GetUsers([]string{}, "")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, users)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, 0, len(users))
|
||||
|
||||
// Test non-existent file
|
||||
users, err = utils.GetUsers([]string{}, tmpDir+"/non_existent_file.txt", noAttrs)
|
||||
users, err = utils.GetUsers([]string{}, "/tmp/non_existent_file.txt")
|
||||
|
||||
assert.ErrorContains(t, err, "no such file or directory")
|
||||
assert.Nil(t, users)
|
||||
|
||||
assert.Equal(t, 0, len(users))
|
||||
}
|
||||
|
||||
func TestParseUsers(t *testing.T) {
|
||||
// Valid users
|
||||
users, err := utils.ParseUsers([]string{"user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF"}) // user2 has TOTP
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(users))
|
||||
|
||||
assert.Equal(t, "user1", users[0].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
||||
assert.Equal(t, "", users[0].TotpSecret)
|
||||
assert.Equal(t, "user2", users[1].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
||||
assert.Equal(t, "ABCDEF", users[1].TotpSecret)
|
||||
|
||||
// Valid weirdly spaced users
|
||||
users, err = utils.ParseUsers([]string{" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G ", " user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF "}) // Spacing is on purpose
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(users))
|
||||
|
||||
assert.Equal(t, "user1", users[0].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password)
|
||||
assert.Equal(t, "", users[0].TotpSecret)
|
||||
assert.Equal(t, "user2", users[1].Username)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password)
|
||||
assert.Equal(t, "ABCDEF", users[1].TotpSecret)
|
||||
}
|
||||
|
||||
func TestParseUser(t *testing.T) {
|
||||
hash := "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G"
|
||||
|
||||
// Valid user without TOTP
|
||||
user, err := utils.ParseUser("user1:" + hash)
|
||||
user, err := utils.ParseUser("user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, "user1", user.Username)
|
||||
assert.Equal(t, hash, user.Password)
|
||||
assert.Equal(t, "", user.TOTPSecret)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password)
|
||||
assert.Equal(t, "", user.TotpSecret)
|
||||
|
||||
// Valid user with TOTP
|
||||
user, err = utils.ParseUser("user2:" + hash + ":ABCDEF")
|
||||
user, err = utils.ParseUser("user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, "user2", user.Username)
|
||||
assert.Equal(t, hash, user.Password)
|
||||
assert.Equal(t, "ABCDEF", user.TOTPSecret)
|
||||
assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password)
|
||||
assert.Equal(t, "ABCDEF", user.TotpSecret)
|
||||
|
||||
// Valid user with $$ in password
|
||||
user, err = utils.ParseUser("user3:pa$$word123")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, "user3", user.Username)
|
||||
assert.Equal(t, "pa$word123", user.Password)
|
||||
assert.Equal(t, "", user.TOTPSecret)
|
||||
assert.Equal(t, "", user.TotpSecret)
|
||||
|
||||
// User with spaces
|
||||
user, err = utils.ParseUser(" user4 : password123 : TOTPSECRET ")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NilError(t, err)
|
||||
|
||||
assert.Equal(t, "user4", user.Username)
|
||||
assert.Equal(t, "password123", user.Password)
|
||||
assert.Equal(t, "TOTPSECRET", user.TOTPSecret)
|
||||
assert.Equal(t, "TOTPSECRET", user.TotpSecret)
|
||||
|
||||
// Invalid users
|
||||
_, err = utils.ParseUser("user1") // Missing password
|
||||
|
||||
+2
-15
@@ -95,22 +95,9 @@ INSERT INTO "oidc_userinfo" (
|
||||
"preferred_username",
|
||||
"email",
|
||||
"groups",
|
||||
"updated_at",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"middle_name",
|
||||
"nickname",
|
||||
"profile",
|
||||
"picture",
|
||||
"website",
|
||||
"gender",
|
||||
"birthdate",
|
||||
"zoneinfo",
|
||||
"locale",
|
||||
"phone_number",
|
||||
"address"
|
||||
"updated_at"
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
|
||||
+6
-19
@@ -22,23 +22,10 @@ CREATE TABLE IF NOT EXISTS "oidc_tokens" (
|
||||
);
|
||||
|
||||
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
|
||||
"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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user